diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 9422f593..b81ac653 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -324,6 +324,7 @@ impl FeatureKind { | FeatureKind::Extrude { .. } | FeatureKind::SurfaceExtrude { .. } | FeatureKind::SurfaceRevolve { .. } + | FeatureKind::SurfaceSweep { .. } | FeatureKind::Combine { .. } | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index d7991d78..6e81d4a3 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -55,7 +55,7 @@ impl FeatureKind { | FeatureKind::SheetContour { sketch, .. } | FeatureKind::Rib { sketch, .. } => sk(sketch), FeatureKind::Patch { sketch: Some(s), .. } => sk(s), - FeatureKind::Sweep { sketch, path_sketch, .. } => { + FeatureKind::Sweep { sketch, path_sketch, .. } | FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => { sk(sketch); sk(path_sketch); } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index ef19528c..2e1eff11 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -971,6 +971,17 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "std::ops::Not::not")] symmetric: bool, }, + /// A surface body swept along a chain of sketch curves from open curve chains and the + /// boundaries of profiles: walls only, no caps. + SurfaceSweep { + sketch: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + profiles: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + curves: Vec, + path_sketch: u64, + path: Vec, + }, /// A planar surface body extended outward by `distance` past the edges through `edges`. SurfaceExtend { body: String, @@ -1278,7 +1289,7 @@ impl FeatureKind { | K::Rib { sketch, .. } | K::SheetBase { sketch, .. } | K::SheetContour { sketch, .. } => vec![*sketch], - K::Sweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch], + K::Sweep { sketch, path_sketch, .. } | K::SurfaceSweep { sketch, path_sketch, .. } => vec![*sketch, *path_sketch], K::Pipe { path_sketch, .. } | K::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } => vec![*path_sketch], K::Loft { sections, .. } => sections.iter().map(|s| s.sketch).collect(), K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(), @@ -1408,6 +1419,7 @@ impl FeatureKind { FeatureKind::Patch { .. } => "PatchFeature", FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature", FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature", + FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature", FeatureKind::Stitch { .. } => "StitchFeature", FeatureKind::Thicken { .. } => "ThickenFeature", FeatureKind::SurfaceTrim { .. } => "TrimFeature", @@ -1478,6 +1490,7 @@ impl FeatureKind { FeatureKind::Patch { .. } => "Patch", FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude", FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve", + FeatureKind::SurfaceSweep { .. } => "SurfaceSweep", FeatureKind::Stitch { .. } => "Stitch", FeatureKind::Thicken { .. } => "Thicken", FeatureKind::SurfaceTrim { .. } => "Trim", @@ -1502,7 +1515,8 @@ impl FeatureKind { | FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)], FeatureKind::Patch { sketch: Some(sk), profiles, .. } | FeatureKind::SurfaceExtrude { sketch: sk, profiles: Some(profiles), .. } - | FeatureKind::SurfaceRevolve { sketch: sk, profiles: Some(profiles), .. } => { + | FeatureKind::SurfaceRevolve { sketch: sk, profiles: Some(profiles), .. } + | FeatureKind::SurfaceSweep { sketch: sk, profiles: Some(profiles), .. } => { vec![(*sk, profiles)] } FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(), @@ -1520,7 +1534,8 @@ impl FeatureKind { | FeatureKind::SheetBase { sketch, profiles, .. } => vec![(*sketch, profiles)], FeatureKind::Patch { sketch: Some(sketch), profiles, .. } | FeatureKind::SurfaceExtrude { sketch, profiles: Some(profiles), .. } - | FeatureKind::SurfaceRevolve { sketch, profiles: Some(profiles), .. } => { + | FeatureKind::SurfaceRevolve { sketch, profiles: Some(profiles), .. } + | FeatureKind::SurfaceSweep { sketch, profiles: Some(profiles), .. } => { vec![(*sketch, profiles)] } FeatureKind::Loft { sections, .. } => { @@ -1633,7 +1648,7 @@ impl FeatureKind { }, FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::Shell { thickness, .. } => v.push(thickness), - FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } => {} + FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::SurfaceSweep { .. } => {} FeatureKind::Draft { angle, neutral, .. } => { v.push(angle); plane_exprs(neutral, &mut v); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 2edda63d..e9e3e9b0 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -880,6 +880,7 @@ fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelStat fn placement_digest(doc: &Document, f: &Feature) -> u64 { let path = match &f.kind { FeatureKind::Sweep { path_sketch, .. } + | FeatureKind::SurfaceSweep { path_sketch, .. } | FeatureKind::Pipe { path_sketch, .. } | FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch), _ => None, @@ -892,7 +893,7 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 { for frame in [doc.component_transform(f.component), doc.component_transform(owner)] { frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); } - if let FeatureKind::Sweep { sketch, .. } = &f.kind { + if let FeatureKind::Sweep { sketch, .. } | FeatureKind::SurfaceSweep { sketch, .. } = &f.kind { let owner = doc.feature(*sketch).map(|feature| feature.component).unwrap_or(0); doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash)); } @@ -3635,7 +3636,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::SurfaceExtrude { .. } | FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } - | FeatureKind::SurfaceRevolve { .. } => surface::eval(doc, vals, f, st), + | FeatureKind::SurfaceRevolve { .. } + | FeatureKind::SurfaceSweep { .. } => 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 c612c294..fe2fe42b 100644 --- a/crates/doc/src/eval_surface.rs +++ b/crates/doc/src/eval_surface.rs @@ -1,5 +1,5 @@ //! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude, -//! Unstitch and Reverse Normal (kernel `surfaces`). +//! Revolve and Sweep, Unstitch and Reverse Normal (kernel `surfaces`). use super::*; use solidcraft_geom::Seg2; @@ -155,6 +155,32 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } Ok(()) } + FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => { + let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; + let ps = + st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; + // As Sweep: a 3D path sketch is followed in the feature's frame, the curves with it. + let profile; + let ss = if !ps.sketch.wires.is_empty() { + profile = profile_in_frame(doc, ss, f.component)?; + &profile + } else { + ss + }; + let mapped = path_in_frame(doc, ps, f.component)?; + let ps = mapped.as_ref(); + let chains = sheet_chains(ss, profiles.as_ref(), curves)?; + // The path runs on from the end nearest the middle of the curves. + let pts: Vec = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect(); + let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64)); + let body = match path_points(ps, path, start)? { + Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?, + None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?, + }; + let name = new_name(st, f, 0); + st.bodies.push(ModelBody::new(name, body, f.id)); + Ok(()) + } _ => Err(DocError::Invalid("not a surface feature".into())), } } diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 67eed4b1..ab41a1ba 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -118,6 +118,8 @@ //! saves as before. 0.9.0 would heal a gap the design leaves open. //! A `SurfaceRevolve` feature (Surface Revolve: `sketch`, optional `profiles`, `curves`, //! `axis`, `angle`, optional `angle2` and `symmetric`) is also first saved in 18. +//! A `SurfaceSweep` feature (Surface Sweep: `sketch`, optional `profiles` and `curves`, +//! `path_sketch`, `path`) is also first saved in 18. use serde_json::Value; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index de802a9b..bfad6994 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -289,6 +289,7 @@ impl FeatureKind { | FeatureKind::BoundaryFill { .. } | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } + | FeatureKind::SurfaceSweep { .. } | FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {} diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 5fc3f42b..33d402ec 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -5,7 +5,7 @@ use serde_json::Value; use solidcraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind}; use super::CommandSpec; -use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles}; +use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id}; use crate::params::{bad, bool_, expr, str_, string_list, vec3}; use crate::{Result, Session}; @@ -38,10 +38,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SURFACE", "CREATE") .icon("surface_revolve") .params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{origin, dir}; angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"), - planned!("surface.sweep", "Surface Sweep", "757") + CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep) .at("SURFACE", "CREATE") .icon("surface_sweep") - .params("planned: the parameters of solid.sweep, with an open profile chain as well as a closed one (no caps); caps?: bool"), + .params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); name?, body_name? (a surface body, no caps)"), planned!("surface.loft", "Surface Loft", "757") .at("SURFACE", "CREATE") .icon("surface_loft") @@ -155,6 +155,18 @@ fn surface_revolve(s: &mut Session, p: &Value) -> Result { add_feature(s, p, FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric }) } +fn surface_sweep(s: &mut Session, p: &Value) -> Result { + let cmd = "surface.sweep"; + let sketch = feature_sketch(s, p, cmd)?; + let (profiles, curves) = sheet_inputs(p, cmd)?; + let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?; + let path = string_list(p, "path"); + if path.is_empty() || path.len() > 1000 { + return Err(bad(cmd, "`path` must list 1…1000 curve ids")); + } + add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path }) +} + fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result> { let bodies = string_list(p, "bodies"); if bodies.is_empty() { @@ -432,13 +444,57 @@ mod tests { assert!(s.execute("surface.revolve", &json!({"sketch": "S", "curves": ["side"], "axis": "y", "angle": "400 deg"})).is_err()); } + /// Surface Sweep: a line along y carried up a path in the XZ plane, straight then round a + /// quarter turn (every point of it 5 from the arc's axis: 10 × (5 + 5π/2)), or round a + /// corner (10 × both legs); a rectangle's walls up the straight leg; undo, redo, reopening. + #[test] + fn surface_sweep_curves_and_profiles() { + use std::f64::consts::FRAC_PI_2; + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 10]], "ids": ["l"]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "P"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, 5]], "ids": ["up"]})); + let arc = run( + &mut s, + "sketch.arc.three_point", + json!({"start": [0, 5], "end": [5, 10], "through": [5.0 - 5.0 / 2f64.sqrt(), 5.0 + 5.0 / 2f64.sqrt()]}), + )["created"]["curves"][0] + .as_str() + .unwrap() + .to_string(); + run(&mut s, "sketch.line", json!({"points": [[0, 5], [5, 5]], "ids": ["over"]})); + run(&mut s, "sketch.finish", json!({})); + let close = |a: f64, want: f64| assert!((a - want).abs() < want * 2e-3, "{a} vs {want}"); // (measured on meshes) + run(&mut s, "surface.sweep", json!({"sketch": "S", "curves": ["l"], "path_sketch": "P", "path": ["up", arc], "body_name": "Bend"})); + assert!(s.world_state().body("Bend").unwrap().body.is_surface()); + close(area(&s, "Bend"), 10.0 * (5.0 + 5.0 * FRAC_PI_2)); + run(&mut s, "surface.sweep", json!({"sketch": "S", "curves": ["l"], "path_sketch": "P", "path": ["up", "over"], "body_name": "Corner"})); + close(area(&s, "Corner"), 100.0); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-1, -1], "p1": [1, 2]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "surface.sweep", json!({"sketch": "R", "path_sketch": "P", "path": ["up"], "body_name": "Walls"})); + let st = s.world_state(); + assert!(st.body("Walls").unwrap().body.is_surface() && st.body("Walls").unwrap().body.face_count() == 4); + close(area(&s, "Walls"), 10.0 * 5.0); + run(&mut s, "edit.undo", json!({})); + assert!(s.world_state().body("Walls").is_none()); + run(&mut s, "edit.redo", json!({})); + let cold = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((area(&cold, "Bend") - area(&s, "Bend")).abs() < 1e-9); + // Refused: no path, and a path curve that is not there. + assert!(s.execute("surface.sweep", &json!({"sketch": "S", "curves": ["l"], "path_sketch": "P"})).is_err()); + assert!(s.execute("surface.sweep", &json!({"sketch": "S", "curves": ["l"], "path_sketch": "P", "path": ["nope"]})).is_err()); + } + /// The planned surface commands are registered but refuse to run: disabled with the /// issue that tracks them, an error (never a panic) from every caller, and the design left /// as it was. #[test] fn planned_commands_refuse_and_leave_the_design_alone() { let planned = [ - "surface.sweep", "surface.loft", "surface.offset", "surface.ruled", diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 9dda0ce3..54d115cd 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1170,7 +1170,7 @@ pub enum PathSeg { impl PathSeg { /// Unit directions of the path where the segment starts and ends. - fn tangents(&self) -> Option<(Vec3, Vec3)> { + pub(crate) fn tangents(&self) -> Option<(Vec3, Vec3)> { match *self { PathSeg::Line { a, b } => { let t = (b - a).normalized()?; @@ -1185,7 +1185,7 @@ impl PathSeg { } /// Points along the segment (an arc within `tol` of its chords), without its start. - fn points_after_start(&self, tol: f64) -> Vec { + pub(crate) fn points_after_start(&self, tol: f64) -> Vec { match *self { PathSeg::Line { b, .. } => vec![b], PathSeg::Arc { a, center, axis, angle } => { @@ -1200,7 +1200,7 @@ impl PathSeg { } /// `p` turned by `angle` about the unit axis `k` through `center` (right hand). -fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 { +pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 { let v = p - center; let (s, c) = angle.sin_cos(); center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c)) diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 0df3d1aa..6115a624 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -117,7 +117,8 @@ pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_s pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; pub use surface_kind::{FaceSurface, face_surface}; pub use surfaces::{ - copy_faces, extend, extrude_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, thicken, trim, unstitch, + copy_faces, extend, extrude_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet, + sweep_sheet_points, thicken, trim, unstitch, }; pub use sweep_path::sweep_path; pub use topo::{CylinderFace, cylinder_face_at}; diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 63065372..253f4bfd 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -3,7 +3,7 @@ //! planar sketch region), Stitch (surfaces sharing edges sewn //! into one body, a solid when they close), Trim (a surface cut by a tool, one side kept), //! Thicken (a surface offset along its normals into a solid), copying faces out of a body, and -//! sheets swept from sketch curves (surface extrude and revolve). +//! sheets swept from sketch curves (surface extrude, revolve and sweep). use std::collections::HashMap; @@ -162,6 +162,120 @@ pub fn revolve_sheet(plane: &Plane, chains: &[Vec], axis_origin: Vec2, axi body_of(faces, &[], &rec) } +/// A surface swept from chains of sketch segments on `plane` along a path that starts near +/// them. Along a path whose direction runs on across every joint, lines slide the chains and +/// arcs turn them, exactly; any other path (curves followed as polylines) goes through +/// [`sweep_sheet_points`]. The walls form one surface body with no caps (a solid where they +/// close); its history has no operands: every face is new. +pub fn sweep_sheet(plane: &Plane, chains: &[Vec], path: &[crate::PathSeg]) -> Result { + use crate::PathSeg; + if !crate::build::frame_ok(plane) { + return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into())); + } + if chains.is_empty() || chains.len() > MAX_CHAINS { + return Err(KernelError::Invalid("pick the curves or profiles to sweep".into())); + } + if path.is_empty() || path.len() > 1000 { + return Err(KernelError::Invalid("the path needs 1…1000 segments".into())); + } + let ends: Vec<(Vec3, Vec3)> = + path.iter().map(PathSeg::tangents).collect::>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?; + let first = match path.first() { + Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a, + None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())), + }; + if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) { + let mut pts = vec![first]; + for seg in path { + pts.extend(seg.points_after_start(1e-2)); + } + return sweep_sheet_points(plane, chains, &pts); + } + let mut rec = Rebuild::default(); + let mut faces = Vec::new(); + for chain in chains { + let mut cur = crate::build::chain_wire(plane, chain)?; + for seg in path { + let (part, map): (mt::Shell, Box Vec3>) = match *seg { + PathSeg::Line { a, b } => { + (guard("surface sweep", || Ok(mt::builder::tsweep(&cur, crate::body::v3(b - a))))?, Box::new(move |p| p + (b - a))) + } + PathSeg::Arc { center, axis, angle, .. } => { + let k = axis.normalized().ok_or_else(|| KernelError::Invalid("a path arc has no axis".into()))?; + let part = guard("surface sweep", || Ok(mt::builder::rsweep(&cur, p3(center), crate::body::v3(k), mt::Rad(angle))))?; + (part, Box::new(move |p| crate::build::rotate_about(p, center, k, angle))) + } + }; + let next = far_wire(&part, &cur, map.as_ref())?; + for f in part.face_iter() { + rec.made(f, Vec::new()); + faces.push(f.clone()); + } + cur = next; + } + } + body_of(faces, &[], &rec) +} + +/// A surface swept from chains of sketch segments on `plane` along a 3D polyline path: one smooth +/// B-spline wall per segment where the path has no corners, else ruled walls between the path's +/// points, mitred at the corners (see [`sweep_sheet`]). +pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec], path: &[Vec3]) -> Result { + if !crate::build::frame_ok(plane) { + return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into())); + } + if chains.is_empty() || chains.len() > MAX_CHAINS { + return Err(KernelError::Invalid("pick the curves or profiles to sweep".into())); + } + let sheet = |faces: Vec| { + let mut rec = Rebuild::default(); + for f in &faces { + rec.made(f, Vec::new()); + } + body_of(faces, &[], &rec) + }; + let ruled = sheet(crate::sweep_path::ruled_sheet(plane, chains, path)?)?; + // Smooth walls where the path has no corners, kept when their area agrees with the ruled + // walls' (a fit that went wrong falls back to those). + if let Ok(Some(faces)) = crate::sweep_path::smooth_sheet(plane, chains, path) + && let Ok(smooth) = sheet(faces) + && let (Ok(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled)) + && (a.area - b.area).abs() <= 0.02 * b.area.abs() + { + return Ok(smooth); + } + Ok(ruled) +} + +/// The far side of a sweep of the wire `start`: for each of its edges, in order and the same +/// way round, the edge of `swept` that `map` carries it onto (matched by its ends and middle). +fn far_wire(swept: &mt::Shell, start: &mt::Wire, map: &dyn Fn(Vec3) -> Vec3) -> Result { + let near = |p: Vec3, q: Vec3| p.dist(q) <= 1e-6 * (1.0 + p.len().max(q.len())); + let mid = |e: &mt::Edge| { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + from_p3(c.subs((t0 + t1) / 2.0)) + }; + let mut out = Vec::new(); + for e in start.edge_iter() { + let (a, b, m) = (map(from_p3(e.front().point())), map(from_p3(e.back().point())), map(mid(e))); + let found = swept.edge_iter().find_map(|f| { + let (fa, fb) = (from_p3(f.front().point()), from_p3(f.back().point())); + if !near(mid(&f), m) { + None + } else if near(fa, a) && near(fb, b) { + Some(f) + } else if near(fa, b) && near(fb, a) { + Some(f.inverse()) + } else { + None + } + }); + out.push(found.ok_or_else(|| KernelError::Failed("sweep: lost the curves along the path".into()))?); + } + Ok(out.into()) +} + /// The edge of a body nearest a point (by samples along each edge). pub(crate) fn edge_near(body: &Body, p: Vec3) -> Option { let mut best: Option<(f64, mt::Edge)> = None; @@ -879,6 +993,66 @@ mod tests { assert!(revolve_sheet(&Plane::XY, &[vec![line((1.0, 0.0), (1.0, 1.0))]], Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.0), 0.0, 1.0).is_err()); } + /// A line along x swept up z: straight (length × height), then on round a quarter turn + /// (a quarter cylinder of radius 5 about an axis along x: 10 × 5π/2, the arc taking up the + /// line's far edge), and along a path with a corner (mitred ruled walls, 10 × both legs); a + /// closed square's four walls; a 3D point path. + #[test] + fn sweep_sheet_areas() { + use crate::PathSeg; + use std::f64::consts::FRAC_PI_2; + let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }]; + let up = PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 5.0) }; + let area = |b: &Body| measure(b).unwrap().area; + let straight = sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[up]).unwrap(); + assert!(straight.is_surface() && rel(area(&straight), 50.0) < 1e-9); + let bend = PathSeg::Arc { a: Vec3::new(0.0, 0.0, 5.0), center: Vec3::new(0.0, 5.0, 5.0), axis: -Vec3::X, angle: FRAC_PI_2 }; + let smooth = sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[up, bend]).unwrap(); + assert!(smooth.is_surface()); + let want = 50.0 + 10.0 * 5.0 * FRAC_PI_2; + assert!(rel(area(&smooth), want) < 2e-3, "{} vs {want}", area(&smooth)); // (measured on meshes) + let over = PathSeg::Line { a: Vec3::new(0.0, 0.0, 5.0), b: Vec3::new(0.0, 5.0, 5.0) }; + let cornered = sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[up, over]).unwrap(); + assert!(cornered.is_surface() && rel(area(&cornered), 100.0) < 1e-9, "{}", area(&cornered)); + let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 4.0), Vec2::new(0.0, 4.0)]); + let walls = sweep_sheet(&Plane::XY, std::slice::from_ref(&sq.segs), &[up]).unwrap(); + assert!(walls.is_surface() && walls.face_count() == 4 && rel(area(&walls), 80.0) < 1e-9); + let pts: Vec = (0..=10).map(|i| Vec3::new(0.0, 0.0, i as f64)).collect(); + assert!(rel(area(&sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &pts).unwrap()), 100.0) < 1e-6); + // A smooth point path: a quarter circle of radius 30 in the YZ plane. One smooth wall (not + // forty ruled strips), a piece of cylinder: 10 × 15π. + let arc: Vec = (0..=40) + .map(|i| { + let t = i as f64 / 40.0 * FRAC_PI_2; + Vec3::new(0.0, 30.0 * (1.0 - t.cos()), 30.0 * t.sin()) + }) + .collect(); + let curved = sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &arc).unwrap(); + assert_eq!(curved.face_count(), 1, "smooth wall"); + let want = 10.0 * 30.0 * FRAC_PI_2; + assert!(rel(area(&curved), want) < 2e-3, "{} vs {want}", area(&curved)); // (measured on meshes) + // A bent chain along the same path: two smooth walls joined by one shared seam edge. + let bent = vec![line[0], Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 5.0) }]; + let two = sweep_sheet_points(&Plane::XY, &[bent], &arc).unwrap(); + assert_eq!(two.face_count(), 2); + let edges: std::collections::HashSet<_> = two.solid.edge_iter().map(|e| e.id()).collect(); + assert_eq!(edges.len(), 7, "the walls share their seam"); + } + + #[test] + fn sweep_sheet_refuses_bad_input() { + use crate::PathSeg; + let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }]; + let up = PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 5.0) }; + assert!(sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[]).is_err()); + assert!(sweep_sheet(&Plane::XY, &[], &[up]).is_err()); + assert!(sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[PathSeg::Line { a: Vec3::ZERO, b: Vec3::ZERO }]).is_err()); + let no_axis = PathSeg::Arc { a: Vec3::ZERO, center: Vec3::new(0.0, 5.0, 0.0), axis: Vec3::ZERO, angle: 1.0 }; + assert!(sweep_sheet(&Plane::XY, std::slice::from_ref(&line), &[no_axis]).is_err()); + assert!(sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &[Vec3::ZERO]).is_err()); + assert!(sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err()); + } + #[test] fn extrude_sheet_refuses_bad_input() { let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }]; diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index 34f2041e..8020c448 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -4,7 +4,7 @@ //! between stations, mitred at the corners. use mt::builder; -use solidcraft_geom::{Plane, Region2, Vec2, Vec3}; +use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling as mt; use crate::body::{Body, p3}; @@ -175,9 +175,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 { tangents.first().map(|t| t.dot(plane.normal()).abs()).unwrap_or(1.0).max(1e-3) } -/// Ruled walls between stations at the path's points (mitred at corners), every loop of the -/// (oriented) profile. -fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result { +/// Stations at the path's points (at most [`MAX_RULED_STATIONS`]), mitred at corners, with the +/// points they stand at. +fn ruled_stations(pts: &[Vec3]) -> Result<(Vec, Vec)> { let pts: Vec = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() }; let n = pts.len(); let seg_dir = |i: usize| -> Option { (*pts.get(i + 1)? - *pts.get(i)?).normalized() }; @@ -202,6 +202,133 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result mitre.push(m); } let st = stations(&pts, &tangents, &mitre)?; + Ok((pts, st)) +} + +/// Walls of chains of sketch segments on `plane` (open, or closed) carried along a 3D polyline +/// path that starts near them: ruled between stations at the path's points, mitred at corners, +/// with no caps. Neighbouring walls share their edges. +pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec], path: &[Vec3]) -> Result> { + let (pts, st) = ruled_stations(&clean(path)?)?; + let edges: usize = chains.iter().map(Vec::len).sum(); + if st.len().saturating_mul(edges) > 400_000 { + return Err(KernelError::Invalid("the sweep is too detailed (curves × path points)".into())); + } + let s0 = pts.first().copied().unwrap_or_default(); + let wires: Vec> = st + .iter() + .map(|s| { + let pl = station_plane(plane, s0, s); + chains.iter().map(|c| crate::build::chain_wire(&pl, c)).collect::>>() + }) + .collect::>()?; + guard("path sweep", || { + let mut faces = Vec::new(); + for w in wires.windows(2) { + let (Some(w0), Some(w1)) = (w.first(), w.get(1)) else { continue }; + for (a, b) in w0.iter().zip(w1) { + let sides: mt::Shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("path sweep: {e}")))?; + faces.extend(sides.face_iter().cloned()); + } + } + Ok(faces) + }) +} + +/// Walls of chains of sketch segments on `plane` carried along a smooth 3D polyline path (no +/// turn sharper than [`CORNER`]): one B-spline wall per segment through evenly spaced, +/// rotation-minimising stations, neighbouring walls sharing their seam edges; no caps. `None` +/// for a path with corners (see [`ruled_sheet`]). +pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec], path: &[Vec3]) -> Result>> { + let pts = clean(path)?; + if pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER) { + return Ok(None); + } + let fail = |w: &str| KernelError::Failed(format!("path sweep: {w}")); + if chains.iter().map(Vec::len).sum::() > 4096 { + return Err(fail("too many curves for a smooth sweep")); + } + let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS); + let sp = resample(&pts, n); + let tangents = central_tangents(&sp)?; + let st = stations(&sp, &tangents, &[])?; + let s0 = st.first().map(|s| s.at).unwrap_or_default(); + let at = |i: usize, q: Vec2| st.get(i).map(|s| s.at + s.apply(plane.to_world(q) - s0)).unwrap_or_default(); + let n = st.len() - 1; + let mut faces = Vec::new(); + for chain in chains { + let closed = chain.first().zip(chain.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= 1e-6); + // Each segment: its curve at the first and last station, the columns through its start + // and end, and the wall surface. + let mut data = Vec::with_capacity(chain.len()); + for s in chain { + let mut station_curves = Vec::with_capacity(n + 1); + for i in 0..=n { + let q: Vec = (0..SEG_SAMPLES).map(|j| at(i, s.point_at(j as f64 / (SEG_SAMPLES - 1) as f64))).collect(); + station_curves.push(interpolate_cubic(&q).ok_or_else(|| fail("profile curve"))?); + } + let kv = station_curves.first().map(|c| c.knot_vec().clone()).ok_or_else(|| fail("no stations"))?; + let mut columns = Vec::with_capacity(SEG_SAMPLES); + for j in 0..SEG_SAMPLES { + let q: Vec = station_curves + .iter() + .map(|c| { + let p = c.control_point(j); + Vec3::new(p.x, p.y, p.z) + }) + .collect(); + columns.push(interpolate_cubic(&q).ok_or_else(|| fail("path curve"))?); + } + let ku = columns.first().map(|c| c.knot_vec().clone()).ok_or_else(|| fail("no columns"))?; + let net: Vec> = (0..=n).map(|m| columns.iter().map(|c| *c.control_point(m)).collect()).collect(); + let surf = mt::BSplineSurface::try_new((ku, kv), net).map_err(|e| fail(&e.to_string()))?; + let (Some(c0), Some(c1), Some(first), Some(last)) = + (station_curves.first().cloned(), station_curves.last().cloned(), columns.first().cloned(), columns.last().cloned()) + else { + return Err(fail("stations")); + }; + data.push((c0, c1, first, last, surf)); + } + let walls: Vec = guard("path sweep", || { + let m = chain.len(); + // Vertices along the chain (an open chain has one more than it has segments). + let corners: Vec = chain.iter().map(Seg2::start).chain(chain.last().map(Seg2::end).filter(|_| !closed)).collect(); + let start: Vec = corners.iter().map(|q| builder::vertex(p3(at(0, *q)))).collect(); + let end: Vec = corners.iter().map(|q| builder::vertex(p3(at(n, *q)))).collect(); + let next = |j: usize| if closed { (j + 1) % m } else { j + 1 }; + let mut seams = Vec::with_capacity(corners.len()); + for (k, (a, b)) in start.iter().zip(&end).enumerate() { + // The column through each corner: the start column of the segment leaving it, or + // the end column of the last segment. + let col = match data.get(k) { + Some((_, _, first, _, _)) => first, + None => data.last().map(|d| &d.3).ok_or_else(|| fail("seam"))?, + }; + seams.push(mt::Edge::try_new(a, b, mt::Curve::BSplineCurve(col.clone())).map_err(|e| fail(&e.to_string()))?); + } + let mut walls = Vec::with_capacity(m); + for (j, (c0, c1, _, _, surf)) in data.iter().enumerate() { + let (Some(a0), Some(b0), Some(a1), Some(b1), Some(sa), Some(sb)) = + (start.get(j), start.get(next(j)), end.get(j), end.get(next(j)), seams.get(j), seams.get(next(j))) + else { + return Err(fail("edges")); + }; + let e0 = mt::Edge::try_new(a0, b0, mt::Curve::BSplineCurve(c0.clone())).map_err(|e| fail(&e.to_string()))?; + let e1 = mt::Edge::try_new(a1, b1, mt::Curve::BSplineCurve(c1.clone())).map_err(|e| fail(&e.to_string()))?; + let w: mt::Wire = vec![sa.clone(), e1, sb.inverse(), e0.inverse()].into(); + walls.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surf.clone())).map_err(|e| fail(&e.to_string()))?); + } + Ok(walls) + })?; + faces.extend(walls); + } + Ok(Some(faces)) +} + +/// Ruled walls between stations at the path's points (mitred at corners), every loop of the +/// (oriented) profile. +fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result { + let (pts, st) = ruled_stations(pts)?; let s0 = pts.first().copied().unwrap_or_default(); let loops: Vec<&NumberedLoop> = profile.loops().collect(); let mut wires: Vec> = Vec::with_capacity(st.len()); diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index 4abd9284..a40c2830 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -1,5 +1,5 @@ -//! Dialogs for the surface tools and face splitting (#19): Surface Extrude and Revolve, Patch, -//! Stitch, Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature. +//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve and Sweep, +//! Patch, Stitch, Thicken, Extend, Trim and Split Face. Each has a live preview and Edit Feature. use serde_json::{Map, Value, json}; use solidcraft_engine::Sel; @@ -25,6 +25,7 @@ pub enum Surf { symmetric: bool, axis: Option, }, + Sweep, Patch, Unstitch, ReverseNormal, @@ -51,6 +52,7 @@ impl Surf { match self { Surf::Extrude { .. } => "SURFACE EXTRUDE", Surf::Revolve { .. } => "SURFACE REVOLVE", + Surf::Sweep => "SURFACE SWEEP", Surf::Patch => "PATCH", Surf::Unstitch => "UNSTITCH", Surf::ReverseNormal => "REVERSE NORMAL", @@ -81,6 +83,7 @@ fn inputs(k: &Surf) -> Vec { match k { Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)], Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)], + Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)], Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)], Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)], Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)], @@ -102,6 +105,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let k = match id { "surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 }, "surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None }, + "surface.sweep" => Surf::Sweep, "surface.patch" => Surf::Patch, "surface.unstitch" => Surf::Unstitch, "surface.reverse_normal" => Surf::ReverseNormal, @@ -164,7 +168,7 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool { combo(ui, "sx_dir", &EXTRUDE_SIDES.map(|(label, _)| label), direction); ui.end_row(); } - Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal => {} + Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep => {} } enter } @@ -266,6 +270,17 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { + need(0, "profiles or curves to sweep")?; + need(1, "the path")?; + let mut p = sheet_picks(app, items(inputs, 0))?; + let path: Vec = + items(inputs, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect(); + p["path_sketch"] = + json!(crate::dialogs::curves_sketch(app, &path, p["sketch"].as_u64()).ok_or("the path curves must be in one other sketch")?); + p["path"] = json!(path); + ("surface.sweep", p) + } Surf::Patch => { need(0, "a boundary (sketch profiles or a loop of edges)")?; let profiles: Vec<(u64, usize)> = @@ -386,6 +401,10 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K } return Some((Kind::Surface(k), ins, extra)); } + FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => ( + Surf::Sweep, + vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()], + ), FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => { let sels = sheet_sels(app, *sketch, profiles.as_ref(), curves); let direction = match extent.direction { diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index 3804f63d..3e5fed42 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -882,6 +882,34 @@ fn surface_revolve_dialog_takes_curves_and_an_axis() { assert_eq!(back.inputs[1].items, vec![Sel::Axis { name: "y".into() }]); } +#[test] +fn surface_sweep_dialog_takes_curves_and_a_path() { + // A line along y carried 5 up a line in the XZ plane: a 10 × 5 sheet. Edit Feature brings + // back the curve and the path. + let mut app = app_with(json!([ + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [0, 10]], "ids": ["l"]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "sketch.create", "params": {"plane": "XZ"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [0, 5]], "ids": ["up"]}}, + {"command": "sketch.finish", "params": {}} + ])); + let mut d = start(&mut app, vec![], "surface.sweep"); + d.inputs[0].items = vec![Sel::SketchCurve { id: "l".into() }]; + assert!(apply_commands(&app, &d).is_err(), "no path yet"); + d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }]; + let (id, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!((id.as_str(), &p["path"], &p["curves"]), ("surface.sweep", &json!(["up"]), &json!(["l"])), "{p}"); + assert_ne!(p["path_sketch"], p["sketch"], "{p}"); + app.session.execute(&id, &p).unwrap(); + let area = app.session.model.state().bodies.last().unwrap().mesh().measure().area; + assert!((area - 50.0).abs() < 1e-6, "{area}"); + let fid = app.session.doc.features.last().unwrap().id; + let back = crate::dialogs::for_feature(&app, fid, None).unwrap(); + assert_eq!(back.inputs[0].items, vec![Sel::SketchCurve { id: "l".into() }]); + assert_eq!(back.inputs[1].items, vec![Sel::SketchCurve { id: "up".into() }]); +} + #[test] fn patch_dialog_fills_a_loop_of_edges_that_is_not_planar() { // A box with its top cut away by a sideways cylinder: its rim is a closed loop of four diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 8660271b..4b795c08 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -15,6 +15,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Unstitch { .. } => "unstitch", FeatureKind::ReverseNormal { .. } => "reverse_normal", FeatureKind::SurfaceRevolve { .. } => "surface_revolve", + FeatureKind::SurfaceSweep { .. } => "surface_sweep", FeatureKind::Revolve { .. } => "revolve", FeatureKind::Fillet { .. } => "fillet", FeatureKind::Chamfer { .. } => "chamfer", diff --git a/docs/features.md b/docs/features.md index 3ae2bd0e..48de59ef 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **339 commands.** Planned (registered, not built yet): 9. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **340 commands.** Planned (registered, not built yet): 8. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). ## Sketch (98) @@ -145,7 +145,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Align | `solid.align` | SOLID › MODIFY | | Remove | `solid.remove` | SOLID › MODIFY | -## Surfaces (9, 8 planned) +## Surfaces (10, 7 planned) | Command | Id | Toolbar | |---|---|---| @@ -156,7 +156,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Trim | `surface.trim` | SURFACE › MODIFY | | Surface Extrude | `surface.extrude` | SURFACE › CREATE | | Surface Revolve | `surface.revolve` | SURFACE › CREATE | -| Surface Sweep *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.sweep` | SURFACE › CREATE | +| Surface Sweep | `surface.sweep` | SURFACE › CREATE | | Surface Loft *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.loft` | SURFACE › CREATE | | Offset Surface *(planned: [#758](https://github.com/bherbruck/solidcraft/issues/758))* | `surface.offset` | SURFACE › CREATE | | Ruled Surface *(planned: [#762](https://github.com/bherbruck/solidcraft/issues/762))* | `surface.ruled` | SURFACE › CREATE |