Pipe (solid or hollow, along sketch curves), Bounding Solid, Scale (uniform or per axis), Offset Face (planar faces), physical materials with mass in Measure; sweeps keep profile holes
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
37f4e4e49e
commit
840eddb875
9 files changed
+395
-24
No files matched your search
@@ -48,6 +48,22 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
|
||||
CommandSpec::new("PrimitivePipe", "Pipe", pipe)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
.params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("StockModelCommand", "Bounding Solid", bounding_solid)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("box")
|
||||
.params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"),
|
||||
CommandSpec::new("ModifyScale", "Scale", scale)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("scale")
|
||||
.params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"),
|
||||
CommandSpec::new("FusionOffsetFacesCommand", "Offset Face", offset_face)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("offset_face")
|
||||
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
|
||||
CommandSpec::new("FusionThreadCommand", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
@@ -783,3 +799,61 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
|
||||
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
|
||||
}
|
||||
|
||||
fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PrimitivePipe";
|
||||
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
|
||||
let path = string_list(p, "path");
|
||||
if path.is_empty() {
|
||||
return Err(bad(cmd, "`path` must list curve ids"));
|
||||
}
|
||||
let diameter = req_expr(cmd, p, "diameter")?;
|
||||
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
|
||||
let wall = expr(p, "wall");
|
||||
if let Some(w) = &wall {
|
||||
check_expr(s, w, Kind::Length, cmd, "wall")?;
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Pipe { path_sketch, path, diameter, wall, operation: operation(p, cmd)?, targets: string_list(p, "targets") })
|
||||
}
|
||||
|
||||
fn bounding_solid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "StockModelCommand";
|
||||
let margin = expr(p, "margin").unwrap_or_else(|| "0".into());
|
||||
check_expr(s, &margin, Kind::Length, cmd, "margin")?;
|
||||
add_feature(s, p, FeatureKind::BoundingSolid { bodies: string_list(p, "bodies"), margin })
|
||||
}
|
||||
|
||||
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ModifyScale";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
return Err(bad(cmd, "`bodies` must list bodies"));
|
||||
}
|
||||
let factors = match p.get("factors").and_then(Value::as_array) {
|
||||
Some(a) if a.len() == 3 => {
|
||||
let e: Vec<String> = a.iter().map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).collect();
|
||||
for x in &e {
|
||||
check_expr(s, x, Kind::Unitless, cmd, "factors")?;
|
||||
}
|
||||
Some([e[0].clone(), e[1].clone(), e[2].clone()])
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "`factors` must have three values")),
|
||||
None => None,
|
||||
};
|
||||
let factor = match (&factors, expr(p, "factor")) {
|
||||
(_, Some(f)) => f,
|
||||
(Some(_), None) => "1".into(),
|
||||
(None, None) => return Err(bad(cmd, "give `factor` or `factors`")),
|
||||
};
|
||||
check_expr(s, &factor, Kind::Unitless, cmd, "factor")?;
|
||||
let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
add_feature(s, p, FeatureKind::Scale { bodies, origin, factor, factors })
|
||||
}
|
||||
|
||||
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionOffsetFacesCommand";
|
||||
let faces = face_points(p, cmd)?;
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
add_feature(s, p, FeatureKind::OffsetFace { faces, distance, body: str_(p, "body").map(str::to_string) })
|
||||
}
|
||||
@@ -19,6 +19,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
|
||||
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
|
||||
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
|
||||
CommandSpec::new("PhysicalMaterialCommand", "Physical Material", physical_material)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("material")
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
@@ -43,7 +48,17 @@ pub fn measure_json(b: &solvecraft_doc::ModelBody) -> Value {
|
||||
fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.model.state();
|
||||
let bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
|
||||
let mut bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
|
||||
// Mass from each body's material.
|
||||
for b in &mut bodies {
|
||||
let name = b["name"].as_str().unwrap_or_default().to_string();
|
||||
if let Some(v) = b["volume_mm3"].as_f64() {
|
||||
let rho = s.doc.density(&name);
|
||||
b["material"] = json!(s.doc.materials.get(&name).cloned().unwrap_or_else(|| "Default".into()));
|
||||
b["density_g_cm3"] = json!(rho);
|
||||
b["mass_g"] = json!(v * rho / 1000.0);
|
||||
}
|
||||
}
|
||||
if !want.is_empty() && bodies.len() != want.len() {
|
||||
return Err(bad("MeasureCommand", "unknown body name"));
|
||||
}
|
||||
@@ -52,7 +67,10 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
|
||||
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
|
||||
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
|
||||
Ok(json!({"body_count": bodies.len(), "bodies": bodies, "total": {"volume_mm3": tv, "area_mm2": ta, "faces": tf, "edges": te, "vertices": tvx}}))
|
||||
let tm: f64 = bodies.iter().filter_map(|b| b["mass_g"].as_f64()).sum();
|
||||
Ok(
|
||||
json!({"body_count": bodies.len(), "bodies": bodies, "total": {"volume_mm3": tv, "area_mm2": ta, "mass_g": tm, "faces": tf, "edges": te, "vertices": tvx}}),
|
||||
)
|
||||
}
|
||||
|
||||
fn sketch_json(s: &Session, id: u64) -> Option<Value> {
|
||||
@@ -212,3 +230,30 @@ fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
Ok(json!({ "threads": st.threads }))
|
||||
}
|
||||
|
||||
fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PhysicalMaterialCommand";
|
||||
let bodies = string_list(p, "bodies");
|
||||
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
|
||||
let canonical = solvecraft_doc::MATERIALS
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
|
||||
.map(|(n, _)| n.to_string())
|
||||
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
|
||||
let st = s.model.state();
|
||||
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, "`bodies` must list existing bodies"));
|
||||
}
|
||||
for b in &bodies {
|
||||
if canonical == "Default" {
|
||||
s.doc_mut().materials.remove(b);
|
||||
} else {
|
||||
s.doc_mut().materials.insert(b.clone(), canonical.clone());
|
||||
}
|
||||
}
|
||||
Ok(json!({"bodies": bodies, "material": canonical}))
|
||||
}
|
||||
|
||||
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"materials": solvecraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
|
||||
}
|
||||
@@ -697,3 +697,36 @@ fn holes_at_sketch_points_threads_and_components() {
|
||||
assert_eq!(back.components.len(), 2);
|
||||
assert_eq!(back.body_components.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_scale_offset_bounding_and_materials() {
|
||||
let mut s = Session::default();
|
||||
// Pipe along an L path (line, arc, line) on XZ.
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
|
||||
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
|
||||
run(&mut s, "ArcCenterTwoPoint", json!({"center": [30, 10], "start": "l1.end", "end": [40, 10], "id": "a1"}));
|
||||
run(&mut s, "DrawPolyline", json!({"points": ["a1.end", [40, 40]], "ids": ["l2"]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "PrimitivePipe", json!({"path_sketch": "Path", "path": ["l1", "a1", "l2"], "diameter": 4, "wall": 1, "body_name": "Tube"}));
|
||||
let len = 30.0 + PI * 10.0 / 2.0 + 30.0;
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, PI * (4.0 - 1.0) * len) < 2e-3, "{v} vs {}", PI * 3.0 * len);
|
||||
// Bounding solid around it; then scale a box and push one of its faces.
|
||||
run(&mut s, "StockModelCommand", json!({"bodies": ["Tube"], "margin": 1, "body_name": "Stock"}));
|
||||
let m = run(&mut s, "MeasureCommand", json!({"bodies": ["Stock"]}));
|
||||
let bb = &m["bodies"][0]["bbox"];
|
||||
assert!((bb["min"][1].as_f64().unwrap() + 3.0).abs() < 1e-6 && (bb["max"][1].as_f64().unwrap() - 41.0).abs() < 1e-6, "{bb}");
|
||||
run(&mut s, "PrimitiveBox", json!({"corner": [100, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Cube"}));
|
||||
run(&mut s, "ModifyScale", json!({"bodies": ["Cube"], "factor": 2, "origin": [100, 0, 0]}));
|
||||
let vc = run(&mut s, "MeasureCommand", json!({"bodies": ["Cube"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(vc, 8000.0) < 1e-9, "{vc}");
|
||||
run(&mut s, "FusionOffsetFacesCommand", json!({"faces": [[110, 10, 20]], "distance": 5}));
|
||||
let vc = run(&mut s, "MeasureCommand", json!({"bodies": ["Cube"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(vc, 20.0 * 20.0 * 25.0) < 1e-9, "{vc}");
|
||||
// Materials and mass.
|
||||
run(&mut s, "PhysicalMaterialCommand", json!({"bodies": ["Cube"], "material": "aluminum"}));
|
||||
let m = run(&mut s, "MeasureCommand", json!({"bodies": ["Cube"]}));
|
||||
assert!((m["bodies"][0]["mass_g"].as_f64().unwrap() - 10_000.0 * 2.7 / 1000.0).abs() < 1e-6, "{m}");
|
||||
assert_eq!(m["bodies"][0]["material"], "Aluminum");
|
||||
assert!(s.execute("PhysicalMaterialCommand", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
|
||||
}
|
||||
Reference in new issue
Block a user