Surface Sweep: sheets carried from open curves and profiles along a path (#843)

Surface Sweep carries open curves and closed profile boundaries along a sketch path into surface bodies, with smooth walls along curved point paths. Adds the SurfaceSweep feature (format 18 note), the surface.sweep 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 20:48:30 -04:00
1 parent 57d738eac4
commit aba57f1ca2
16 files changed
+480 -27

No files matched your search

+3 -3
View File
@@ -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<Vec3> {
pub(crate) fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
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))
+2 -1
View File
@@ -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};
+175 -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 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<Seg2>], 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<Seg2>], path: &[crate::PathSeg]) -> Result<Body> {
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::<Option<_>>().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<dyn Fn(Vec3) -> 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<Seg2>], path: &[Vec3]) -> Result<Body> {
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<mt::Face>| {
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<mt::Wire> {
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<mt::Edge> {
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<Vec3> = (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<Vec3> = (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) }];
+131 -4
View File
@@ -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<Body> {
/// 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<Vec3>, Vec<Station>)> {
let pts: Vec<Vec3> = 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<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
@@ -202,6 +202,133 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body>
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<Seg2>], path: &[Vec3]) -> Result<Vec<mt::Face>> {
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<Vec<mt::Wire>> = st
.iter()
.map(|s| {
let pl = station_plane(plane, s0, s);
chains.iter().map(|c| crate::build::chain_wire(&pl, c)).collect::<Result<Vec<_>>>()
})
.collect::<Result<_>>()?;
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<Seg2>], path: &[Vec3]) -> Result<Option<Vec<mt::Face>>> {
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::<usize>() > 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<Vec3> = (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<Vec3> = 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<Vec<mt::Point3>> = (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<mt::Face> = guard("path sweep", || {
let m = chain.len();
// Vertices along the chain (an open chain has one more than it has segments).
let corners: Vec<Vec2> = chain.iter().map(Seg2::start).chain(chain.last().map(Seg2::end).filter(|_| !closed)).collect();
let start: Vec<mt::Vertex> = corners.iter().map(|q| builder::vertex(p3(at(0, *q)))).collect();
let end: Vec<mt::Vertex> = 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<Body> {
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<mt::Wire>> = Vec::with_capacity(st.len());