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:
bherbruckandClaude Opus 5.5 committed 2026-10-07 23:00:57 +00:00
1 parent 37f4e4e49e
commit 840eddb875
9 files changed
+395 -24

No files matched your search

+74
View File
@@ -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) })
}
+47 -2
View File
@@ -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<_>>()}))
}