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.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-13 21:07:16 -04:00
1 parent 7c85970bfc
commit 897b7d93b5
15 files changed
+365 -55

No files matched your search

+67 -32
View File
@@ -1000,6 +1000,29 @@ pub(crate) fn cap_and_close(sides: Vec<Side>, 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<Body> {
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<mt::Wire>, Vec<Vec<Vec<usize>>>, 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<Body> {
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<Side> = 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<Vec<usize>>]) -> Result<Vec<Side>> {
let mut sides: Vec<Side> = 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<mt::BSplineCurve<mt::Point3>> {
/// 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<Vec<usize>>], from_end: bool) -> Result<Body> {
/// 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<Vec<usize>>], closed: bool) -> Result<Vec<Side>> {
let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}"));
let edges: Vec<Vec<mt::Edge>> = 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<Vec<usize>>], 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<mt::Edge> = Vec::with_capacity(n);
for k in 0..n {
let pts: Vec<Vec3> = 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<mt::Edge> = 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<Vec3> = 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<Vec<usize>>], 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<mt::BSplineCurve<mt::Poi
/// A closed wire whose edges are cubic B-splines parameterised (nearly) by arc length, so that
/// ruled surfaces between sections pair points proportionally along each edge.
pub(crate) fn uniform_wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
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<mt::Wire> {
const SAMPLES: usize = 24;
let n = lp.segs.len();
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
let n = segs.len();
let mut verts: Vec<mt::Vertex> = 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<Vec3> = (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)));
+1 -1
View File
@@ -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;
+111 -1
View File
@@ -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<Seg2>)]) -> Result<Body> {
if sections.len() < 2 || sections.len() > 50 {
return Err(KernelError::Invalid("a loft needs 2…50 sections".into()));
}
let closed: Vec<bool> = 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::<Result<_>>()?;
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<mt::Face> = 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<mt::Edge> {
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) }];