diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 8e0e9912..39d717ca 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -289,6 +289,41 @@ pub enum FeatureKind { body: String, plane: PlaneRef, }, + /// Scale bodies about a point (uniform, or per axis). + Scale { + bodies: Vec, + #[serde(default)] + origin: Vec3, + factor: String, + /// Per-axis factors instead of `factor`. + #[serde(default, skip_serializing_if = "Option::is_none")] + factors: Option<[String; 3]>, + }, + /// Move planar faces (at the given points) along their normals; the body keeps its shape. + OffsetFace { + faces: Vec, + distance: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + /// A box around bodies, grown by a margin. + BoundingSolid { + bodies: Vec, + #[serde(default = "zero_expr")] + margin: String, + }, + /// A tube along a chain of sketch curves (solid, or hollow with a wall thickness). + Pipe { + path_sketch: u64, + path: Vec, + diameter: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + wall: Option, + #[serde(default)] + operation: Operation, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + targets: Vec, + }, Move { bodies: Vec, translate: [String; 3], @@ -445,6 +480,10 @@ impl FeatureKind { FeatureKind::Draft { .. } => "DraftFeature", FeatureKind::Split { .. } => "SplitBodyFeature", FeatureKind::Move { .. } => "MoveFeature", + FeatureKind::Scale { .. } => "ScaleFeature", + FeatureKind::OffsetFace { .. } => "OffsetFacesFeature", + FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature", + FeatureKind::Pipe { .. } => "PipeFeature", FeatureKind::Import { .. } => "BaseFeature", FeatureKind::MeshImport { .. } => "MeshFeature", } @@ -474,6 +513,10 @@ impl FeatureKind { FeatureKind::Draft { .. } => "Draft", FeatureKind::Split { .. } => "Split", FeatureKind::Move { .. } => "Move", + FeatureKind::Scale { .. } => "Scale", + FeatureKind::OffsetFace { .. } => "OffsetFace", + FeatureKind::BoundingSolid { .. } => "BoundingSolid", + FeatureKind::Pipe { .. } => "Pipe", FeatureKind::Import { .. } => "Import", FeatureKind::MeshImport { .. } => "Mesh", } @@ -531,6 +574,16 @@ impl FeatureKind { } } FeatureKind::Thread { length, .. } => v.extend(length.iter().map(String::as_str)), + FeatureKind::Scale { factor, factors, .. } => { + v.push(factor); + v.extend(factors.iter().flatten().map(String::as_str)); + } + FeatureKind::OffsetFace { distance, .. } => v.push(distance), + FeatureKind::BoundingSolid { margin, .. } => v.push(margin), + FeatureKind::Pipe { diameter, wall, .. } => { + v.push(diameter); + v.extend(wall.iter().map(String::as_str)); + } FeatureKind::Move { translate, angle, .. } => { v.extend(translate.iter().map(String::as_str)); if let Some(a) = angle { @@ -559,6 +612,10 @@ pub struct Feature { pub kind: FeatureKind, } +fn zero_expr() -> String { + "0".into() +} + fn is_root(c: &u64) -> bool { *c == 0 } @@ -581,6 +638,9 @@ pub struct Document { /// Components below the root, as a tree (by parent). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub components: Vec, + /// Physical material per body (by body name); others use the default. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub materials: std::collections::BTreeMap, /// Bodies moved into another component than their feature's (by body name). #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub body_components: std::collections::BTreeMap, @@ -611,9 +671,15 @@ impl Document { next_id: 1, components: Vec::new(), body_components: Default::default(), + materials: Default::default(), } } + /// Density of a body's material (g/cm³). + pub fn density(&self, body: &str) -> f64 { + self.materials.get(body).and_then(|m| material_density(m)).unwrap_or(DEFAULT_DENSITY) + } + /// The component a body belongs to: where it was moved, else its feature's. pub fn body_component(&self, body: &str, feature: u64) -> u64 { self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0)) @@ -910,3 +976,28 @@ impl Document { Ok(()) } } + +/// Density of the default material (g/cm³), as Fusion's default. +pub const DEFAULT_DENSITY: f64 = 1.29; + +/// Physical materials: name and density (g/cm³). +pub const MATERIALS: [(&str, f64); 14] = [ + ("Default", DEFAULT_DENSITY), + ("Steel", 7.85), + ("Stainless Steel", 8.0), + ("Aluminum", 2.70), + ("Brass", 8.50), + ("Copper", 8.96), + ("Titanium", 4.43), + ("Cast Iron", 7.20), + ("ABS Plastic", 1.06), + ("PLA", 1.24), + ("Nylon", 1.14), + ("Polycarbonate", 1.20), + ("Oak", 0.75), + ("Glass", 2.50), +]; + +pub fn material_density(name: &str) -> Option { + MATERIALS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name.trim())).map(|(_, d)| *d) +} diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 040a4d03..d96acf91 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -940,6 +940,34 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - let segs = path_segments(ps, path, start)?; regions.iter().map(|r| kernel::sweep(&ss.plane, r, &segs).map_err(DocError::from)).collect() } + FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => { + let ps = + st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; + let r = val(vals, diameter, Kind::Length)? / 2.0; + if !(r > 1e-6) { + return Err(DocError::Invalid("pipe diameter must be positive".into())); + } + let segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?; + let first = segs.first().ok_or_else(|| DocError::Invalid("empty path".into()))?; + let (start, tangent) = match *first { + kernel::PathSeg::Line { a, b } => (a, b - a), + kernel::PathSeg::Arc { a, center, axis, .. } => (a, axis.cross(a - center)), + }; + let t = tangent.normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?; + let plane = Plane::from_normal(start, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?; + let holes = match wall { + Some(w) => { + let wt = val(vals, w, Kind::Length)?; + if !(wt > 0.0 && wt < r) { + return Err(DocError::Invalid("the wall must be thinner than the radius".into())); + } + vec![solvecraft_geom::Loop2::circle(Vec2::ZERO, r - wt).reversed()] + } + None => Vec::new(), + }; + let region = Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes }; + Ok(vec![kernel::sweep(&plane, ®ion, &segs)?]) + } FeatureKind::Box { corner, length, width, height, .. } => { let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); Ok(vec![kernel::box_solid(*corner, *corner + s)?]) @@ -985,6 +1013,13 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result (Operation, Vec) { | FeatureKind::Sphere { operation, .. } | FeatureKind::Torus { operation, .. } => (*operation, Vec::new()), FeatureKind::Hole { .. } => (Operation::Cut, Vec::new()), + FeatureKind::Pipe { operation, targets, .. } => (*operation, targets.clone()), FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, targets.clone()), _ => (Operation::NewBody, Vec::new()), } @@ -1156,6 +1192,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: Ok(()) } FeatureKind::Extrude { operation, targets, .. } + | FeatureKind::Pipe { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } | FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => { @@ -1291,6 +1328,59 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } Ok(()) } + FeatureKind::Scale { bodies, origin, factor, factors } => { + let k = match factors { + Some(fs) => [val(vals, &fs[0], Kind::Unitless)?, val(vals, &fs[1], Kind::Unitless)?, val(vals, &fs[2], Kind::Unitless)?], + None => { + let x = val(vals, factor, Kind::Unitless)?; + [x, x, x] + } + }; + if k.iter().any(|x| !(x.is_finite() && *x > 1e-9 && *x < 1e6)) { + return Err(DocError::Invalid("scale factors must be positive".into())); + } + let o = *origin; + // Column-major: scale about the origin. + let m = [ + [k[0], 0.0, 0.0, 0.0], + [0.0, k[1], 0.0, 0.0], + [0.0, 0.0, k[2], 0.0], + [o.x * (1.0 - k[0]), o.y * (1.0 - k[1]), o.z * (1.0 - k[2]), 1.0], + ]; + for n in bodies { + let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; + if let Some(mb) = st.bodies.get_mut(i) { + let nb = kernel::transform_matrix(&mb.body, m)?; + *mb = ModelBody::new(mb.name.clone(), nb, mb.feature); + } + } + Ok(()) + } + FeatureKind::OffsetFace { faces, distance, body } => { + let d = val(vals, distance, Kind::Length)?; + let i = body_at(st, body, faces)?; + let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; + let nb = kernel::offset_faces(&mb.body, faces, d)?; + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::new(mb.name, nb, mb.feature); + } + Ok(()) + } + FeatureKind::BoundingSolid { bodies, margin } => { + let m = val(vals, margin, Kind::Length)?; + let mut bb = solvecraft_geom::Aabb3::EMPTY; + for b in st.bodies.iter().filter(|b| bodies.is_empty() || bodies.contains(&b.name)) { + bb = bb.union(&b.mesh().bounds()); + } + if bb.is_empty() { + return Err(DocError::Invalid("no bodies to bound".into())); + } + let pad = Vec3::new(m, m, m); + let b = kernel::box_solid(bb.min - pad, bb.max + pad)?; + let name = unique_body_name(st, f.body_names.first().map(String::as_str).unwrap_or("Bounding")); + st.bodies.push(ModelBody::new(name, b, f.id)); + Ok(()) + } FeatureKind::Move { bodies, translate, rotate_axis, angle } => { let t = Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 330b208f..3c20d353 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { 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 { + 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 { + 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 { + 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 = 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 { + 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) }) +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index b299d7ac..3a163411 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -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 { let want = string_list(p, "bodies"); let st = s.model.state(); - let bodies: Vec = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect(); + let mut bodies: Vec = 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 { 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 { @@ -212,3 +230,30 @@ fn model_threads(s: &mut Session, _p: &Value) -> Result { let st = s.model.state(); Ok(json!({ "threads": st.threads })) } + +fn physical_material(s: &mut Session, p: &Value) -> Result { + 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 { + Ok(json!({"materials": solvecraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::>()})) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3e9f529a..073ac40c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index b0d1a0a3..cb1d5cbf 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -554,28 +554,36 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result if path.is_empty() || path.len() > 1000 { return Err(KernelError::Invalid("the path needs 1…1000 segments".into())); } - let w0 = wire(plane, ®ion.outer.ccw())?; + // The outer loop and each hole, swept along the path; the ends are capped with holes. + let mut starts = vec![wire(plane, ®ion.outer.ccw())?]; + for h in ®ion.holes { + starts.push(wire(plane, &h.ccw().reversed())?); + } guard("sweep", || { let mut shell = mt::Shell::new(); - let mut cur = w0.clone(); - for seg in path { - let mut part: mt::Shell = match *seg { - PathSeg::Line { a, b } => builder::tsweep(&cur, v3(b - a)), - PathSeg::Arc { center, axis, angle, .. } => { - let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?; - builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle)) - } - }; - let next = part - .extract_boundaries() - .into_iter() - .find(|w| w.edge_iter().all(|e| !cur.edge_iter().any(|c| c.id() == e.id()))) - .ok_or_else(|| KernelError::Failed("sweep: lost the profile".into()))?; - shell.append(&mut part); - cur = next.inverse(); + let mut ends = Vec::new(); + for w0 in &starts { + let mut cur = w0.clone(); + for seg in path { + let mut part: mt::Shell = match *seg { + PathSeg::Line { a, b } => builder::tsweep(&cur, v3(b - a)), + PathSeg::Arc { center, axis, angle, .. } => { + let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?; + builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle)) + } + }; + let next = part + .extract_boundaries() + .into_iter() + .find(|w| w.edge_iter().all(|e| !cur.edge_iter().any(|c| c.id() == e.id()))) + .ok_or_else(|| KernelError::Failed("sweep: lost the profile".into()))?; + shell.append(&mut part); + cur = next.inverse(); + } + ends.push(cur); } let faces: Vec = shell.face_iter().cloned().collect(); - cap_and_close(faces, std::slice::from_ref(&w0), std::slice::from_ref(&cur)) + cap_and_close(faces, &starts, &ends) }) } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 8f1c5926..284aa78b 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -34,7 +34,7 @@ pub use measure::{BodyMeasure, measure}; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix}; pub use polybool::planar_boolean; -pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell}; +pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use step::step_export; pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared}; pub use topo::{CylinderFace, cylinder_face_at}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index cd5acdbd..1dfb5533 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -69,15 +69,15 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve Some(bad as f64 / n.max(1) as f64) } -/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes -/// everything or an intersection of disjoint bodies. Results are checked against volume bounds -/// and retried with shifted copies and other tolerances when they fail or look wrong. thread_local! { /// Set while a boolean runs on bodies whose coincident faces were pushed apart (no /// second round of pushing). static APART: std::cell::Cell = const { std::cell::Cell::new(false) }; } +/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes +/// everything or an intersection of disjoint bodies. Results are checked against volume bounds +/// and retried with shifted copies and other tolerances when they fail or look wrong. pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { a.require_brep("a boolean")?; b.require_brep("a boolean")?; diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index db63b6bd..27693fcc 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -460,3 +460,33 @@ fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> { } Some(out) } + +/// Move the planar faces at the given points along their outward normals by `distance` +/// (negative: into the body). The body's other faces follow (its topology is kept). +pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { + b.require_brep("offset faces")?; + if !distance.is_finite() || distance.abs() > 1e6 { + return Err(KernelError::Invalid("offset distance".into())); + } + let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?; + let size = healed.size(); + let mesh = healed.tessellate((size * 1e-3).max(1e-3))?; + let mut chosen: Vec = Vec::new(); + for p in at { + let near = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize))) + .min_by(|a, c| a.0.total_cmp(&c.0)); + match near { + Some((d, f)) if d < size * 1e-3 + 1e-6 => { + if !chosen.contains(&f) { + chosen.push(f); + } + } + _ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))), + } + } + offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) +}