diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index b6251c0c..d18a9e4d 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -261,7 +261,7 @@ impl FeatureKind { | FeatureKind::Thicken { .. } | FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {} - FeatureKind::Patch { edges, .. } => edges.iter_mut().for_each(pt), + FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt), FeatureKind::SurfaceTrim { plane, tool, keep, .. } => { map_plane_ref(m, plane); if let Some(t) = tool { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index ab832fc9..8363c4dc 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -634,6 +634,12 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, }, + /// A planar surface body extended outward by `distance` past the edges through `edges`. + SurfaceExtend { + body: String, + edges: Vec, + distance: String, + }, /// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of /// `keep`. SurfaceTrim { @@ -887,6 +893,7 @@ impl FeatureKind { FeatureKind::Stitch { .. } => "StitchFeature", FeatureKind::Thicken { .. } => "ThickenFeature", FeatureKind::SurfaceTrim { .. } => "TrimFeature", + FeatureKind::SurfaceExtend { .. } => "ExtendFeature", FeatureKind::Import { .. } => "BaseFeature", FeatureKind::MeshImport { .. } => "MeshFeature", } @@ -945,6 +952,7 @@ impl FeatureKind { FeatureKind::Stitch { .. } => "Stitch", FeatureKind::Thicken { .. } => "Thicken", FeatureKind::SurfaceTrim { .. } => "Trim", + FeatureKind::SurfaceExtend { .. } => "Extend", FeatureKind::Import { .. } => "Import", FeatureKind::MeshImport { .. } => "Mesh", } @@ -1086,6 +1094,7 @@ impl FeatureKind { FeatureKind::Stitch { tolerance, .. } => v.push(tolerance), FeatureKind::Thicken { thickness, .. } => v.push(thickness), FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v), + FeatureKind::SurfaceExtend { distance, .. } => v.push(distance), FeatureKind::Patch { .. } => {} FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {} } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 0d594a91..eaa1a0db 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1916,9 +1916,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. } => { more::plastic_eval(doc, vals, f, st) } - FeatureKind::Patch { .. } | FeatureKind::Stitch { .. } | FeatureKind::Thicken { .. } | FeatureKind::SurfaceTrim { .. } => { - surface::eval(doc, vals, f, st) - } + FeatureKind::Patch { .. } + | FeatureKind::Stitch { .. } + | FeatureKind::Thicken { .. } + | FeatureKind::SurfaceTrim { .. } + | FeatureKind::SurfaceExtend { .. } => surface::eval(doc, vals, f, st), FeatureKind::Emboss { .. } | FeatureKind::Rib { .. } | FeatureKind::ReplaceFace { .. } diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs index 8ba3cb69..aef3d1ba 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -74,6 +74,16 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } Ok(()) } + FeatureKind::SurfaceExtend { body, edges, distance } => { + let d = val(vals, distance, Kind::Length)?; + let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; + let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; + let out = kernel::extend(&mb.body, edges, d)?; + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::new(mb.name, out, mb.feature); + } + Ok(()) + } _ => Err(DocError::Invalid("not a surface feature".into())), } } diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index b4d50576..ef1513f7 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -226,6 +226,7 @@ impl FeatureKind { FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)), FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)), FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v), + FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)), FeatureKind::Patch { .. } => {} FeatureKind::Combine { .. } | FeatureKind::SheetBase { .. } diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 92588edb..fdb8a649 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -22,6 +22,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SURFACE", "CREATE") .icon("thicken") .params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"), + CommandSpec::new("FusionExtendCommand", "Extend", extend) + .at("SURFACE", "MODIFY") + .icon("extend") + .params("body: planar surface name; edges: [[x,y,z]] (points on straight edges to push out); distance: expr"), CommandSpec::new("FusionSurfaceTrimCommand", "Trim", trim) .at("SURFACE", "MODIFY") .icon("trim") @@ -78,6 +82,21 @@ fn thicken(s: &mut Session, p: &Value) -> Result { add_feature(s, p, kind) } +fn extend(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionExtendCommand"; + let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string(); + if s.model.state().body(&body).is_none() { + return Err(bad(cmd, format!("no body `{body}`"))); + } + let edges: Vec = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default(); + if edges.is_empty() || edges.len() > 10_000 { + return Err(bad(cmd, "`edges` must list points on the edges to extend")); + } + let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?; + check_expr(s, &distance, Kind::Length, cmd, "distance")?; + add_feature(s, p, FeatureKind::SurfaceExtend { body, edges, distance }) +} + fn trim(s: &mut Session, p: &Value) -> Result { let cmd = "FusionSurfaceTrimCommand"; let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string(); @@ -121,9 +140,10 @@ mod tests { let st = s.world_state(); assert!(st.body("Sheet").unwrap().body.is_surface()); run(&mut s, "FusionSurfaceTrimCommand", json!({"body": "Sheet", "plane": {"origin": [3, 0, 0], "normal": [1, 0, 0]}, "keep": [8, 5, 0]})); + run(&mut s, "FusionExtendCommand", json!({"body": "Sheet", "edges": [[10, 5, 0]], "distance": 5})); run(&mut s, "FusionSurfaceThickenCommand", json!({"bodies": ["Sheet"], "thickness": "2 mm", "body_name": "Slab"})); let v = volume(&s, "Slab"); - assert!((v - 140.0).abs() < 1e-9, "{v}"); + assert!((v - 240.0).abs() < 1e-9, "{v}"); // Stitch the patch of a box's open top back on. run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"})); run( diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f1a3c893..746e883d 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -57,7 +57,7 @@ pub use splitface::{SplitTool, split_body, split_faces}; pub use step::step_export; pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate}; pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; -pub use surfaces::{copy_faces, patch_edges, patch_region, stitch, thicken, trim}; +pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicken, trim}; pub use sweep_path::sweep_path; pub use topo::{CylinderFace, cylinder_face_at}; pub use topo::{TopoCounts, merged_topology, seam_flags}; diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 8e2f8b8f..41f96443 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -480,6 +480,71 @@ pub fn trim(body: &Body, tool: &crate::SplitTool, keep: Vec3) -> Result { }) } +/// A planar surface extended: the chosen straight edges (picked by points on them) move +/// outward in the face's plane by `distance`, their neighbours lengthened to meet them. +pub fn extend(body: &Body, at: &[Vec3], distance: f64) -> Result { + body.require_brep("extend")?; + if !(distance.is_finite() && distance > 0.0) { + return Err(KernelError::Invalid("the extension distance must be positive".into())); + } + let faces: Vec = body.solid.face_iter().cloned().collect(); + let [face] = faces.as_slice() else { return Err(KernelError::Invalid("not supported yet: extending a surface of more than one face".into())) }; + let Some(crate::splitface::Analytic::Plane { n }) = crate::splitface::analytic_field(&face.oriented_surface()) else { + return Err(KernelError::Invalid("not supported yet: extending a surface that is not planar".into())); + }; + let chosen: Vec = at + .iter() + .map(|p| edge_near(body, *p).map(|e| e.id()).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))) + .collect::>()?; + guard("extend", || { + let bounds = face.boundaries(); + let [outer] = bounds.as_slice() else { return Err(KernelError::Invalid("not supported yet: extending a surface with holes".into())) }; + let edges: Vec = outer.edge_iter().cloned().collect(); + let m = edges.len(); + // Each edge as a line (point, direction) moved outward when chosen. + let mut lines = Vec::with_capacity(m); + for e in &edges { + let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point())); + if !matches!(e.curve(), mt::Curve::Line(_)) { + return Err(KernelError::Invalid("not supported yet: extending a surface with curved edges".into())); + } + let d = (b - a).normalized().ok_or_else(|| KernelError::Invalid("a zero-length edge".into()))?; + // Outward: right of the edge seen along the normal (the face is on its left). + let out = d.cross(n); + let shift = if chosen.contains(&e.id()) || chosen.contains(&e.inverse().id()) { distance } else { 0.0 }; + lines.push((a + out * shift, d)); + } + // Corners: where consecutive lines meet. + let meet = |(p, d): (Vec3, Vec3), (q, e): (Vec3, Vec3)| -> Option { + let w = q - p; + let c = d.cross(e); + let cc = c.dot(c); + if cc < 1e-24 { + return None; + } + Some(p + d * (w.cross(e).dot(c) / cc)) + }; + let mut corners = Vec::with_capacity(m); + for i in 0..m { + let (Some(prev), Some(cur)) = (lines.get((i + m - 1) % m), lines.get(i)) else { continue }; + let p = meet(*prev, *cur).unwrap_or(cur.0); + corners.push(mt::Vertex::new(p3(p))); + } + let wire: mt::Wire = (0..m) + .filter_map(|i| { + let (a, b) = (corners.get(i)?, corners.get((i + 1) % m)?); + Some(mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point())))) + }) + .collect::>() + .into(); + let mut f = mt::builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("extend: {e}")))?; + if f.oriented_surface().normal(0.0, 0.0).dot(crate::body::v3(n)) < 0.0 { + f = f.inverse(); + } + Ok(surface_body(vec![f])) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -554,6 +619,20 @@ mod tests { assert!(rel(measure(&closed).unwrap().volume, 6000.0) < 1e-9); } + #[test] + fn extend_a_planar_surface() { + let r = Region2 { + outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]), + holes: vec![], + }; + let s = patch_region(&Plane::XY, &r).unwrap(); + let e = extend(&s, &[Vec3::new(10.0, 5.0, 0.0)], 5.0).unwrap(); + assert!(rel(measure(&e).unwrap().area, 150.0) < 1e-9); + let e = extend(&s, &[Vec3::new(10.0, 5.0, 0.0), Vec3::new(5.0, 10.0, 0.0)], 5.0).unwrap(); + assert!(rel(measure(&e).unwrap().area, 225.0) < 1e-9); + assert!(extend(&s, &[Vec3::new(10.0, 5.0, 0.0)], -1.0).is_err()); + } + #[test] fn trim_keeps_one_side() { let r = Region2 { diff --git a/docs/parity.md b/docs/parity.md index a14bd484..ded20f82 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,9 +2,9 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 279 / 541 commands live (52%).** SOLID + SKETCH: 168 / 291 (58%). Registered commands: 280. +**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 279 / 541 commands live (52%).** SOLID + SKETCH: 168 / 291 (58%). Registered commands: 281. -Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 500 / 1175 (43%). +Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 501 / 1175 (43%). | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -54,7 +54,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| | SURFACE | CREATE | 9 | 25 | 36% | -| SURFACE | MODIFY | 15 | 26 | 58% | +| SURFACE | MODIFY | 16 | 26 | 62% | | SURFACE | ASSEMBLE | 11 | 20 | 55% | | SURFACE | CONFIGURE | 2 | 3 | 67% | | SURFACE | CONSTRUCT | 2 | 20 | 10% |