From 897b7d93b5d9717eb5330b28977c0518acc2824e Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Tue, 13 Oct 2026 21:07:16 -0400 Subject: [PATCH] Surface Loft: sheets through open or closed sections (#847) Surface Loft lofts open or closed curve chains, one section per sketch in pick order, into surface bodies. Adds the SurfaceLoft feature (format 18 note), the surface.loft command, a dialog with Edit Feature support, and kernel and engine tests. Part of #757. --- crates/doc/src/assembly.rs | 1 + crates/doc/src/clipboard.rs | 1 + crates/doc/src/document.rs | 23 +++++- crates/doc/src/eval.rs | 3 +- crates/doc/src/eval_surface.rs | 28 ++++++- crates/doc/src/format.rs | 2 + crates/doc/src/params.rs | 1 + crates/engine/src/cmd/surface.rs | 71 +++++++++++++--- crates/kernel/src/build.rs | 99 +++++++++++++++-------- crates/kernel/src/lib.rs | 2 +- crates/kernel/src/surfaces.rs | 112 +++++++++++++++++++++++++- crates/ui-egui/src/dialogs_surface.rs | 41 +++++++++- crates/ui-egui/src/preselect_tests.rs | 29 +++++++ crates/ui-egui/src/timeline.rs | 1 + docs/features.md | 6 +- 15 files changed, 365 insertions(+), 55 deletions(-) diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index b81ac653..7c01ea8a 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -325,6 +325,7 @@ impl FeatureKind { | FeatureKind::SurfaceExtrude { .. } | FeatureKind::SurfaceRevolve { .. } | FeatureKind::SurfaceSweep { .. } + | FeatureKind::SurfaceLoft { .. } | FeatureKind::Combine { .. } | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 6e81d4a3..037c582f 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -62,6 +62,7 @@ impl FeatureKind { FeatureKind::Pipe { path_sketch, .. } => sk(path_sketch), FeatureKind::SheetFold { sketch: Some(s), .. } => sk(s), FeatureKind::Loft { sections, .. } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)), + FeatureKind::SurfaceLoft { sections } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)), FeatureKind::Hole { points: Some(p), .. } => sk(&mut p.sketch), FeatureKind::Pattern { features, pattern, .. } => { feats(features); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2e1eff11..5f26388c 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -982,6 +982,11 @@ pub enum FeatureKind { path_sketch: u64, path: Vec, }, + /// A surface body lofted through sections in order, each one open or closed chain of + /// sketch curves: walls only, no caps. + SurfaceLoft { + sections: Vec, + }, /// A planar surface body extended outward by `distance` past the edges through `edges`. SurfaceExtend { body: String, @@ -1136,6 +1141,17 @@ pub struct LoftSection { pub point: Option, } +/// A section of a surface loft: one chain from a sketch (one profile's boundary, or curves +/// meeting end to end). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SheetSection { + pub sketch: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profiles: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub curves: Vec, +} + /// Coil cross-sections. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -1292,6 +1308,7 @@ impl FeatureKind { 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::SurfaceLoft { sections } => sections.iter().map(|s| s.sketch).collect(), K::SheetFold { sketch, .. } | K::SplitFace { sketch, .. } | K::Patch { sketch, .. } => sketch.iter().copied().collect(), K::Hole { points, .. } => points.iter().map(|p| p.sketch).collect(), K::Sketch { .. } @@ -1420,6 +1437,7 @@ impl FeatureKind { FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrudeFeature", FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolveFeature", FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature", + FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature", FeatureKind::Stitch { .. } => "StitchFeature", FeatureKind::Thicken { .. } => "ThickenFeature", FeatureKind::SurfaceTrim { .. } => "TrimFeature", @@ -1491,6 +1509,7 @@ impl FeatureKind { FeatureKind::SurfaceExtrude { .. } => "SurfaceExtrude", FeatureKind::SurfaceRevolve { .. } => "SurfaceRevolve", FeatureKind::SurfaceSweep { .. } => "SurfaceSweep", + FeatureKind::SurfaceLoft { .. } => "SurfaceLoft", FeatureKind::Stitch { .. } => "Stitch", FeatureKind::Thicken { .. } => "Thicken", FeatureKind::SurfaceTrim { .. } => "Trim", @@ -1520,6 +1539,7 @@ impl FeatureKind { vec![(*sk, profiles)] } FeatureKind::Loft { sections, .. } => sections.iter_mut().filter(|s| s.point.is_none()).map(|s| (s.sketch, &mut s.profiles)).collect(), + FeatureKind::SurfaceLoft { sections } => sections.iter_mut().filter_map(|s| Some((s.sketch, s.profiles.as_mut()?))).collect(), _ => Vec::new(), } } @@ -1541,6 +1561,7 @@ impl FeatureKind { FeatureKind::Loft { sections, .. } => { sections.iter().filter(|section| section.point.is_none()).map(|section| (section.sketch, §ion.profiles)).collect() } + FeatureKind::SurfaceLoft { sections } => sections.iter().filter_map(|s| Some((s.sketch, s.profiles.as_ref()?))).collect(), _ => Vec::new(), } } @@ -1648,7 +1669,7 @@ impl FeatureKind { }, FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::Shell { thickness, .. } => v.push(thickness), - FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::SurfaceSweep { .. } => {} + FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::SurfaceSweep { .. } | FeatureKind::SurfaceLoft { .. } => {} 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 e9e3e9b0..5af5dc54 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3637,7 +3637,8 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } | FeatureKind::SurfaceRevolve { .. } - | FeatureKind::SurfaceSweep { .. } => surface::eval(doc, vals, f, st), + | FeatureKind::SurfaceSweep { .. } + | FeatureKind::SurfaceLoft { .. } => 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 fe2fe42b..5282c821 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, -//! Revolve and Sweep, Unstitch and Reverse Normal (kernel `surfaces`). +//! Revolve, Sweep and Loft, Unstitch and Reverse Normal (kernel `surfaces`). use super::*; use solidcraft_geom::Seg2; @@ -181,6 +181,32 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st.bodies.push(ModelBody::new(name, body, f.id)); Ok(()) } + FeatureKind::SurfaceLoft { sections } => { + let mut secs = Vec::with_capacity(sections.len()); + for sec in sections { + let ss = sketch_in_feature_frame( + doc, + st.sketch(sec.sketch) + .ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?, + f.component, + )?; + let profiles = match (&sec.profiles, sec.curves.is_empty()) { + (None, true) => Some(&ProfileSel::All), + (p, _) => p.as_ref(), + }; + let mut chains = sheet_chains(&ss, profiles, &sec.curves)?.into_iter(); + let (Some(chain), None) = (chains.next(), chains.next()) else { + return Err(DocError::Invalid( + "each loft section must be one chain: one profile without holes, or curves meeting end to end".into(), + )); + }; + secs.push((ss.plane, chain)); + } + let body = kernel::loft_sheet(&secs)?; + 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 ab41a1ba..778a62f4 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -120,6 +120,8 @@ //! `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. +//! A `SurfaceLoft` feature (Surface Loft: `sections`, each a `sketch` with optional `profiles` +//! and `curves`) 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 bfad6994..fcc1fdd5 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -290,6 +290,7 @@ impl FeatureKind { | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::SurfaceSweep { .. } + | FeatureKind::SurfaceLoft { .. } | FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } | FeatureKind::BodyTransfer { .. } => {} diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs index 33d402ec..e78f526a 100644 --- a/crates/engine/src/cmd/surface.rs +++ b/crates/engine/src/cmd/surface.rs @@ -42,10 +42,10 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SURFACE", "CREATE") .icon("surface_sweep") .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") + CommandSpec::new("surface.loft", "Surface Loft", surface_loft) .at("SURFACE", "CREATE") .icon("surface_loft") - .params("planned: the parameters of solid.loft, with open sections as well as closed ones (no caps); caps?: bool"), + .params("sections: [{sketch, curves?: [curve ids], profiles?}] (2…50 in order; each one chain: a profile without holes, or curves meeting end to end; all open or all closed); name?, body_name? (a surface body: ruled between two sections, smooth through three or more; no caps)"), planned!("surface.offset", "Offset Surface", "758") .at("SURFACE", "CREATE") .icon("surface_offset") @@ -167,6 +167,25 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result { add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path }) } +fn surface_loft(s: &mut Session, p: &Value) -> Result { + let cmd = "surface.loft"; + let given = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must list the sections in order"))?; + if !(2..=50).contains(&given.len()) { + return Err(bad(cmd, "a loft needs 2…50 sections")); + } + let mut sections = Vec::with_capacity(given.len()); + for sec in given { + let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections.sketch")?; + let curves = string_list(sec, "curves"); + if curves.len() > 1000 { + return Err(bad(cmd, "at most 1000 curves a section")); + } + let profiles = if sec.get("profiles").is_some() { Some(profiles(sec, cmd)?) } else { None }; + sections.push(solidcraft_doc::SheetSection { sketch, profiles, curves }); + } + add_feature(s, p, FeatureKind::SurfaceLoft { sections }) +} + fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result> { let bodies = string_list(p, "bodies"); if bodies.is_empty() { @@ -489,21 +508,49 @@ mod tests { assert!(s.execute("surface.sweep", &json!({"sketch": "S", "curves": ["l"], "path_sketch": "P", "path": ["nope"]})).is_err()); } + /// Surface Loft between sketches 5 apart: two lines give a 10 × 5 strip, two rectangles + /// their four walls; undo, redo and reopening; a mix of open and closed sections, one + /// section, and a section of two separate curves are refused. + #[test] + fn surface_loft_open_and_closed_sections() { + let mut s = Session::default(); + run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Mid"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["a"]})); + run(&mut s, "sketch.line", json!({"points": [[0, 3], [10, 3]], "ids": ["apart"]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20, 0], "p1": [30, 6]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "Mid", "name": "B"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]], "ids": ["b"]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [20, 0], "p1": [30, 6]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "surface.loft", json!({"sections": [{"sketch": "A", "curves": ["a"]}, {"sketch": "B", "curves": ["b"]}], "body_name": "Strip"})); + assert!(s.world_state().body("Strip").unwrap().body.is_surface()); + assert!((area(&s, "Strip") - 50.0).abs() < 1e-6, "{}", area(&s, "Strip")); + run(&mut s, "surface.loft", json!({"sections": [{"sketch": "A", "profiles": [0]}, {"sketch": "B", "profiles": [0]}], "body_name": "Walls"})); + let st = s.world_state(); + let walls = &st.body("Walls").unwrap().body; + assert!(walls.is_surface() && walls.face_count() == 4); + assert!((area(&s, "Walls") - 160.0).abs() < 1e-6, "{}", area(&s, "Walls")); + 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, "Walls") - area(&s, "Walls")).abs() < 1e-9); + assert!(s.execute("surface.loft", &json!({"sections": [{"sketch": "A", "curves": ["a"]}, {"sketch": "B", "profiles": [0]}]})).is_err()); + assert!(s.execute("surface.loft", &json!({"sections": [{"sketch": "A", "curves": ["a"]}]})).is_err()); + assert!( + s.execute("surface.loft", &json!({"sections": [{"sketch": "A", "curves": ["a", "apart"]}, {"sketch": "B", "curves": ["b"]}]})).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.loft", - "surface.offset", - "surface.ruled", - "surface.boundary", - "surface.blend", - "surface.untrim", - "surface.fillet", - "inspect.continuity", - ]; + let planned = + ["surface.offset", "surface.ruled", "surface.boundary", "surface.blend", "surface.untrim", "surface.fillet", "inspect.continuity"]; let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "B"})); let before = s.doc.features.len(); diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 54d115cd..f4681874 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1000,6 +1000,29 @@ pub(crate) fn cap_and_close(sides: Vec, w0: &[mt::Wire], w1: &[mt::Wire], /// Ruled loft through two or more planar sections (outer loops only). The sections and the /// same sections in reverse order give the same body. pub fn loft(sections: &[(Plane, Loop2)]) -> Result { + let (wires, edge_sources, reversed) = loft_wires(sections)?; + // The wires run from the first section given, or (turned round) the last. + // Three sections or more: one smooth surface through them all for each segment. + if wires.len() >= 3 + && let Ok(b) = guard("loft", || { + let faces = smooth_loft_faces(&wires, &edge_sources, true)?; + let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("smooth loft: sections".into())) }; + cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), reversed) + }) + { + return Ok(b); + } + guard("loft", || { + let sides = ruled_loft_faces(&wires, &edge_sources)?; + let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) }; + cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed) + }) +} + +/// The wires of a loft's closed sections, matched: oriented alike, resampled to the same +/// segment count and turned to start alike; with the profile segments under each wire edge, and +/// whether the sections were turned round into the canonical order. +pub(crate) fn loft_wires(sections: &[(Plane, Loop2)]) -> Result<(Vec, Vec>>, bool)> { if sections.len() < 2 || sections.len() > 50 { return Err(KernelError::Invalid("a loft needs 2…50 sections".into())); } @@ -1060,24 +1083,22 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result { wires.push(uniform_wire(p, &r.lp)?); edge_sources.push(r.from); } - // The wires run from the first section given, or (turned round) the last. - // Three sections or more: one smooth surface through them all for each segment. - if wires.len() >= 3 - && let Ok(b) = guard("loft", || smooth_loft(&wires, &edge_sources, reversed)) - { - return Ok(b); - } - guard("loft", || { - // Each ruled face lies between two sections: it comes from the section edges it spans. - let mut sides: Vec = Vec::new(); - for (w, src) in wires.windows(2).zip(edge_sources.windows(2)) { - let (Some(a), Some(b), Some(na), Some(nb)) = (w.first(), w.get(1), src.first(), src.get(1)) else { continue }; - let shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("loft: {e}")))?; - sides.extend(record::ruled(&shell, a, b, na, nb)); + Ok((wires, edge_sources, reversed)) +} + +/// Ruled faces between each two neighbouring section wires; each comes from the section edges +/// it spans. +pub(crate) fn ruled_loft_faces(wires: &[mt::Wire], edge_sources: &[Vec>]) -> Result> { + let mut sides: Vec = Vec::new(); + for (k, w) in wires.windows(2).enumerate() { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + let shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("loft: {e}")))?; + match (edge_sources.get(k), edge_sources.get(k + 1)) { + (Some(na), Some(nb)) => sides.extend(record::ruled(&shell, a, b, na, nb)), + _ => sides.extend(shell.face_iter().map(|f| (f.clone(), None))), } - let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) }; - cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed) - }) + } + Ok(sides) } /// Points across the sections (one per section, in order) as a smooth B-spline: cubic through @@ -1097,7 +1118,10 @@ fn across(pts: &[Vec3]) -> Option> { /// k-th edges of all sections share one knot vector and their control points pair up. Face k /// comes from the profile segments under the k-th edge of every section (`numbers`: by /// section, by edge); the caps as [`cap_and_close`] takes them (`from_end`). -fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec>], from_end: bool) -> Result { +/// One smooth face through every section for each wire edge (cubic across four sections or +/// more, quadratic across three), neighbours sharing their seams. `closed` wires join their +/// last edge to their first; open ones have a seam at each end. +pub(crate) fn smooth_loft_faces(wires: &[mt::Wire], numbers: &[Vec>], closed: bool) -> Result> { let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}")); let edges: Vec> = wires.iter().map(|w| w.edge_iter().cloned().collect()).collect(); let n = edges.first().map(Vec::len).ok_or_else(|| fail("no sections"))?; @@ -1111,13 +1135,17 @@ fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec>], from_end: bool) } }; let (Some(first), Some(last)) = (edges.first(), edges.last()) else { return Err(fail("sections")) }; - // Seams: through the k-th vertex of every section. - let mut seams: Vec = Vec::with_capacity(n); - for k in 0..n { - let pts: Vec = edges.iter().filter_map(|es| es.get(k).map(|e| from_p3(e.front().point()))).collect(); + // Seams: through the k-th vertex of every section (an open wire's last one at the end of + // its last edge). + let vertex = |e: &mt::Edge, end: bool| if end { e.back().clone() } else { e.front().clone() }; + let seam_count = if closed { n } else { n + 1 }; + let mut seams: Vec = Vec::with_capacity(seam_count); + for k in 0..seam_count { + let (at, end) = if k < n { (k, false) } else { (n - 1, true) }; + let pts: Vec = edges.iter().filter_map(|es| es.get(at).map(|e| from_p3(vertex(e, end).point()))).collect(); let c = across(&pts).ok_or_else(|| fail("seam"))?; - let (Some(a), Some(b)) = (first.get(k), last.get(k)) else { return Err(fail("seam")) }; - seams.push(mt::Edge::new(a.front(), b.front(), mt::Curve::BSplineCurve(c))); + let (Some(a), Some(b)) = (first.get(at), last.get(at)) else { return Err(fail("seam")) }; + seams.push(mt::Edge::new(&vertex(a, end), &vertex(b, end), mt::Curve::BSplineCurve(c))); } let mut faces = Vec::with_capacity(n); for k in 0..n { @@ -1139,17 +1167,16 @@ fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec>], from_end: bool) } let vk = vk.ok_or_else(|| fail("surface"))?; let surface = mt::BSplineSurface::try_new((uk, vk), ctrl).map_err(|e| fail(&e.to_string()))?; - let (Some(e0), Some(e1), Some(s0), Some(s1)) = (first.get(k), last.get(k), seams.get(k), seams.get((k + 1) % n)) else { + let (Some(e0), Some(e1), Some(s0), Some(s1)) = (first.get(k), last.get(k), seams.get(k), seams.get((k + 1) % seam_count)) else { return Err(fail("face")); }; // u along the section edge, v across the sections: counter-clockwise in (u, v). let w: mt::Wire = vec![e0.clone(), s1.clone(), e1.inverse(), s0.inverse()].into(); let face = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?; let under: Vec<&usize> = numbers.iter().filter_map(|s| s.get(k)).flatten().collect(); - faces.push((face, (under.len() >= numbers.len()).then(|| record::profile(under)))); + faces.push((face, (under.len() >= numbers.len().max(1)).then(|| record::profile(under)))); } - let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(fail("sections")) }; - cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), from_end) + Ok(faces) } /// A 3D path segment for sweeps. @@ -1342,12 +1369,20 @@ pub(crate) fn interpolate_cubic(pts: &[Vec3]) -> Option Result { + uniform_chain_wire(plane, &lp.segs, true) +} + +/// [`uniform_wire`] of a chain of segments, open or `closed`. +pub(crate) fn uniform_chain_wire(plane: &Plane, segs: &[Seg2], closed: bool) -> Result { const SAMPLES: usize = 24; - let n = lp.segs.len(); - let verts: Vec = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect(); + let n = segs.len(); + let mut verts: Vec = segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect(); + if !closed && let Some(last) = segs.last() { + verts.push(builder::vertex(p3(plane.to_world(last.end())))); + } let mut edges = Vec::with_capacity(n); - for (i, s) in lp.segs.iter().enumerate() { - let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue }; + for (i, s) in segs.iter().enumerate() { + let (Some(a), Some(b)) = (verts.get(i), verts.get(if closed { (i + 1) % n } else { i + 1 })) else { continue }; let pts: Vec = (0..SAMPLES).map(|k| plane.to_world(s.point_at(k as f64 / (SAMPLES - 1) as f64))).collect(); let curve = interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("loft curve".into()))?; edges.push(mt::Edge::new(a, b, mt::Curve::BSplineCurve(curve))); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 6115a624..c10460e1 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -117,7 +117,7 @@ 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, sweep_sheet, + copy_faces, extend, extrude_sheet, loft_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; diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 253f4bfd..d082fc81 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, revolve and sweep). +//! sheets swept from sketch curves (surface extrude, revolve, sweep and loft). use std::collections::HashMap; @@ -276,6 +276,75 @@ fn far_wire(swept: &mt::Shell, start: &mt::Wire, map: &dyn Fn(Vec3) -> Vec3) -> Ok(out.into()) } +/// A surface lofted through two or more sections, each a chain of sketch segments on its plane, +/// all open or all closed: ruled between two sections, one smooth face per segment through +/// three or more. Closed sections are matched as the solid loft matches them; open ones are +/// split to the same number of segments (longest first) and each turned to run the way the one +/// before it does. Walls only, no caps; the history has no operands: every face is new. +pub fn loft_sheet(sections: &[(Plane, Vec)]) -> Result { + if sections.len() < 2 || sections.len() > 50 { + return Err(KernelError::Invalid("a loft needs 2…50 sections".into())); + } + let closed: Vec = sections + .iter() + .map(|(_, c)| match (c.first(), c.last()) { + (Some(f), Some(l)) if c.len() <= 4096 => Ok(l.end().dist(f.start()) <= 1e-6), + _ => Err(KernelError::Invalid("a loft section has no curves, or too many".into())), + }) + .collect::>()?; + let all_closed = closed.iter().all(|c| *c); + if !all_closed && closed.iter().any(|c| *c) { + return Err(KernelError::Invalid("the loft sections must be all open or all closed".into())); + } + for (plane, chain) in sections { + // Checks the frame, the numbers and that each chain runs end to end. + if !crate::build::frame_ok(plane) { + return Err(KernelError::Invalid("a loft section's frame is not finite and orthonormal".into())); + } + crate::build::chain_wire(plane, chain)?; + } + let (wires, sources) = if all_closed { + let loops: Vec<(Plane, solidcraft_geom::Loop2)> = sections.iter().map(|(p, c)| (*p, solidcraft_geom::Loop2 { segs: c.clone() })).collect(); + let (wires, sources, _) = crate::build::loft_wires(&loops)?; + (wires, sources) + } else { + let n = sections.iter().map(|(_, c)| c.len()).max().unwrap_or(0); + let mut wires = Vec::with_capacity(sections.len()); + let mut prev: Option<(Vec3, Vec3)> = None; + for (plane, chain) in sections { + let mut segs = chain.clone(); + // Split the longest segment in half until the counts match. + while segs.len() < n { + let Some(i) = segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())).map(|(i, _)| i) else { break }; + let (a, b) = segs[i].split_half(); + segs.splice(i..=i, [a, b]); + } + let ends = |c: &[Seg2]| c.first().zip(c.last()).map(|(f, l)| (plane.to_world(f.start()), plane.to_world(l.end()))); + if let (Some((ps, pe)), Some((s, e))) = (prev, ends(&segs)) + && s.dist(ps) + e.dist(pe) > s.dist(pe) + e.dist(ps) + { + segs = segs.iter().rev().map(Seg2::reversed).collect(); + } + prev = ends(&segs); + wires.push(crate::build::uniform_chain_wire(plane, &segs, false)?); + } + (wires, Vec::new()) + }; + let faces = guard("surface loft", || { + let smooth = if wires.len() >= 3 { crate::build::smooth_loft_faces(&wires, &sources, all_closed).ok() } else { None }; + match smooth { + Some(f) => Ok(f), + None => crate::build::ruled_loft_faces(&wires, &sources), + } + })?; + let mut rec = Rebuild::default(); + let faces: Vec = faces.into_iter().map(|(f, _)| f).collect(); + for f in &faces { + rec.made(f, Vec::new()); + } + body_of(faces, &[], &rec) +} + /// 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; @@ -1053,6 +1122,47 @@ mod tests { assert!(sweep_sheet_points(&Plane::XY, std::slice::from_ref(&line), &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err()); } + /// Sheets lofted between open lines and closed loops: a ruled strip (even when the second + /// line runs the other way, or is in two pieces), a smooth strip through three, the walls + /// between two squares, and a cylinder through three circles. + #[test] + fn loft_sheet_areas() { + use std::f64::consts::PI; + let at = |z: f64| Plane::XY.offset(z); + let line = |a: f64, b: f64| Seg2::Line { a: Vec2::new(a, 0.0), b: Vec2::new(b, 0.0) }; + let area = |b: &Body| measure(b).unwrap().area; + let strip = loft_sheet(&[(at(0.0), vec![line(0.0, 10.0)]), (at(5.0), vec![line(0.0, 10.0)])]).unwrap(); + assert!(strip.is_surface() && rel(area(&strip), 50.0) < 1e-6, "{}", area(&strip)); + let turned = loft_sheet(&[(at(0.0), vec![line(0.0, 10.0)]), (at(5.0), vec![line(10.0, 0.0)])]).unwrap(); + assert!(rel(area(&turned), 50.0) < 1e-6, "not twisted: {}", area(&turned)); + let pieces = loft_sheet(&[(at(0.0), vec![line(0.0, 10.0)]), (at(5.0), vec![line(0.0, 5.0), line(5.0, 10.0)])]).unwrap(); + assert!(rel(area(&pieces), 50.0) < 1e-6 && pieces.face_count() == 2, "{}", area(&pieces)); + let three = loft_sheet(&[(at(0.0), vec![line(0.0, 10.0)]), (at(5.0), vec![line(0.0, 10.0)]), (at(10.0), vec![line(0.0, 10.0)])]).unwrap(); + assert!(rel(area(&three), 100.0) < 1e-3, "{}", area(&three)); // (measured on meshes) + let sq = 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)]); + let walls = loft_sheet(&[(at(0.0), sq.segs.clone()), (at(4.0), sq.segs.clone())]).unwrap(); + assert!(walls.is_surface() && walls.face_count() == 4 && rel(area(&walls), 160.0) < 1e-6, "{}", area(&walls)); + let circle = Loop2::circle(Vec2::ZERO, 2.0).segs; + let tube = loft_sheet(&[(at(0.0), circle.clone()), (at(5.0), circle.clone()), (at(10.0), circle)]).unwrap(); + assert!(tube.is_surface()); + let want = 2.0 * PI * 2.0 * 10.0; + assert!(rel(area(&tube), want) < 2e-3, "{} vs {want}", area(&tube)); // (measured on meshes) + } + + #[test] + fn loft_sheet_refuses_bad_input() { + let at = |z: f64| Plane::XY.offset(z); + let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }]; + let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)]).segs; + assert!(loft_sheet(&[(at(0.0), line.clone())]).is_err(), "one section"); + assert!(loft_sheet(&[(at(0.0), line.clone()), (at(1.0), sq)]).is_err(), "open and closed"); + assert!(loft_sheet(&[(at(0.0), line.clone()), (at(1.0), vec![])]).is_err(), "an empty section"); + let gap = vec![line[0], Seg2::Line { a: Vec2::new(11.0, 0.0), b: Vec2::new(12.0, 0.0) }]; + assert!(loft_sheet(&[(at(0.0), line.clone()), (at(1.0), gap)]).is_err(), "a section with a gap"); + let nan = vec![Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::new(1.0, 0.0) }]; + assert!(loft_sheet(&[(at(0.0), line), (at(1.0), nan)]).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/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index a40c2830..9d008fa6 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, Revolve and Sweep, -//! 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, Sweep and +//! Loft, 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; @@ -26,6 +26,7 @@ pub enum Surf { axis: Option, }, Sweep, + Loft, Patch, Unstitch, ReverseNormal, @@ -53,6 +54,7 @@ impl Surf { Surf::Extrude { .. } => "SURFACE EXTRUDE", Surf::Revolve { .. } => "SURFACE REVOLVE", Surf::Sweep => "SURFACE SWEEP", + Surf::Loft => "SURFACE LOFT", Surf::Patch => "PATCH", Surf::Unstitch => "UNSTITCH", Surf::ReverseNormal => "REVERSE NORMAL", @@ -84,6 +86,7 @@ fn inputs(k: &Surf) -> Vec { 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::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | 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)], @@ -106,6 +109,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "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.loft" => Surf::Loft, "surface.patch" => Surf::Patch, "surface.unstitch" => Surf::Unstitch, "surface.reverse_normal" => Surf::ReverseNormal, @@ -168,7 +172,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::Sweep => {} + Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {} } enter } @@ -281,6 +285,34 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map { + need(0, "the sections, one sketch each")?; + // One section per sketch, in the order the sketches were first picked. + let st = app.session.world_state(); + let sketch_of = |x: &Sel| match x { + Sel::Profile { sketch, .. } => Some(*sketch), + Sel::SketchCurve { id } => st.sketches.iter().rev().find(|ss| ss.sketch.curve_index(id).is_some()).map(|ss| ss.feature), + _ => None, + }; + let mut order: Vec = Vec::new(); + for x in items(inputs, 0) { + let sk = sketch_of(x).ok_or("a picked curve is no longer in a sketch")?; + if !order.contains(&sk) { + order.push(sk); + } + } + if order.len() < 2 { + return Err("pick sections in two or more sketches".into()); + } + let sections: Vec = order + .iter() + .map(|sk| { + let picks: Vec = items(inputs, 0).iter().filter(|x| sketch_of(x) == Some(*sk)).cloned().collect(); + sheet_picks(app, &picks) + }) + .collect::>()?; + ("surface.loft", json!({ "sections": sections })) + } Surf::Patch => { need(0, "a boundary (sketch profiles or a loop of edges)")?; let profiles: Vec<(u64, usize)> = @@ -405,6 +437,9 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K Surf::Sweep, vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()], ), + FeatureKind::SurfaceLoft { sections } => { + (Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).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 3e5fed42..3c085023 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -910,6 +910,35 @@ fn surface_sweep_dialog_takes_curves_and_a_path() { assert_eq!(back.inputs[1].items, vec![Sel::SketchCurve { id: "up".into() }]); } +#[test] +fn surface_loft_dialog_makes_a_section_per_sketch() { + // A line in each of two sketches 5 apart: two sections, a 10 × 5 strip; Edit Feature brings + // the curves back. + let mut app = app_with(json!([ + {"command": "construct.plane.offset", "params": {"base": "XY", "offset": 5, "name": "Mid"}}, + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [10, 0]], "ids": ["a"]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "sketch.create", "params": {"plane": "Mid"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [10, 0]], "ids": ["b"]}}, + {"command": "sketch.finish", "params": {}} + ])); + let mut d = start(&mut app, vec![], "surface.loft"); + d.inputs[0].items = vec![Sel::SketchCurve { id: "a".into() }]; + assert!(apply_commands(&app, &d).is_err(), "one section"); + d.inputs[0].items.push(Sel::SketchCurve { id: "b".into() }); + let (id, p) = apply_commands(&app, &d).unwrap().remove(0); + assert_eq!(id, "surface.loft"); + assert_eq!(p["sections"].as_array().map(Vec::len), Some(2), "{p}"); + assert_eq!((&p["sections"][0]["curves"], &p["sections"][1]["curves"]), (&json!(["a"]), &json!(["b"])), "{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: "a".into() }, Sel::SketchCurve { id: "b".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 4b795c08..fda5106c 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -16,6 +16,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::ReverseNormal { .. } => "reverse_normal", FeatureKind::SurfaceRevolve { .. } => "surface_revolve", FeatureKind::SurfaceSweep { .. } => "surface_sweep", + FeatureKind::SurfaceLoft { .. } => "surface_loft", FeatureKind::Revolve { .. } => "revolve", FeatureKind::Fillet { .. } => "fillet", FeatureKind::Chamfer { .. } => "chamfer", diff --git a/docs/features.md b/docs/features.md index 48de59ef..a781e064 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`). **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). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **341 commands.** Planned (registered, not built yet): 7. 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 (10, 7 planned) +## Surfaces (11, 6 planned) | Command | Id | Toolbar | |---|---|---| @@ -157,7 +157,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Surface Extrude | `surface.extrude` | SURFACE › CREATE | | Surface Revolve | `surface.revolve` | SURFACE › CREATE | | Surface Sweep | `surface.sweep` | SURFACE › CREATE | -| Surface Loft *(planned: [#757](https://github.com/bherbruck/solidcraft/issues/757))* | `surface.loft` | SURFACE › CREATE | +| Surface Loft | `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 | | Boundary Surface *(planned: [#763](https://github.com/bherbruck/solidcraft/issues/763))* | `surface.boundary` | SURFACE › CREATE |