Surface Extrude: sheets from open curves and profile walls (#815)

surface.extrude sweeps open curve chains and profile boundaries along the sketch normal into a surface body (walls only, no caps). Saved under format 17.

Refs #757

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Irakli KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-13 14:54:38 -04:00
1 parent 8dd583930e
commit e540832626
17 files changed
+493 -79

No files matched your search

+59 -23
View File
@@ -31,11 +31,41 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
if !lp.is_closed(1e-6) {
return Err(KernelError::Invalid("profile loop is not closed".into()));
}
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
chain_sources(plane, &lp.segs, true)
}
/// An open wire in 3D through a chain of sketch segments, each starting where the one before
/// ends (a closed chain makes a closed wire).
pub(crate) fn chain_wire(plane: &Plane, segs: &[Seg2]) -> Result<mt::Wire> {
let n = segs.len();
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid(format!("curve chain with {n} segments")));
}
if !segs.iter().all(seg_ok) {
return Err(KernelError::Invalid("non-finite curve".into()));
}
for (s, t) in segs.iter().zip(segs.iter().skip(1)) {
if s.end().dist(t.start()) > 1e-6 {
return Err(KernelError::Invalid("the curves are not connected end to end".into()));
}
}
let closed = segs.first().zip(segs.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= 1e-6);
chain_sources(plane, segs, closed).map(|(wire, _)| wire)
}
/// The edges of a chain of segments (closed: the last one ends on the first's start vertex),
/// with each edge's segment and that segment's position in the chain (a big arc makes two
/// edges).
fn chain_sources(plane: &Plane, segs: &[Seg2], closed: bool) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
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);
let mut sources = 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 e: mt::Edge = match *s {
Seg2::Line { .. } => builder::line(a, b),
Seg2::Arc { sweep, .. } => {
@@ -128,32 +158,38 @@ fn region_ok(r: &Region2) -> Result<()> {
if r.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into()));
}
for s in std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs) {
let ok = match *s {
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
Seg2::Arc { center, radius, start, sweep } => {
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
}
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
};
if !ok {
return Err(KernelError::Invalid("non-finite profile".into()));
}
if !std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).all(seg_ok) {
return Err(KernelError::Invalid("non-finite profile".into()));
}
Ok(())
}
/// A segment with finite, well-formed numbers.
fn seg_ok(s: &Seg2) -> bool {
match *s {
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
Seg2::Arc { center, radius, start, sweep } => {
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
}
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
}
}
/// A finite, orthonormal sketch frame.
pub(crate) fn frame_ok(plane: &Plane) -> bool {
plane.origin.is_finite()
&& plane.x.is_finite()
&& plane.y.is_finite()
&& (plane.x.len2() - 1.).abs() <= 1e-8
&& (plane.y.len2() - 1.).abs() <= 1e-8
&& plane.x.dot(plane.y).abs() <= 1e-8
}
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
if !plane.origin.is_finite()
|| !plane.x.is_finite()
|| !plane.y.is_finite()
|| (plane.x.len2() - 1.).abs() > 1e-8
|| (plane.y.len2() - 1.).abs() > 1e-8
|| plane.x.dot(plane.y).abs() > 1e-8
{
if !frame_ok(plane) {
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
}
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
+1 -1
View File
@@ -109,7 +109,7 @@ pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
pub use surface_kind::{FaceSurface, face_surface};
pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicken, trim};
pub use surfaces::{copy_faces, extend, extrude_sheet, patch_edges, patch_region, stitch, thicken, trim};
pub use sweep_path::sweep_path;
pub use topo::{CylinderFace, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
+82 -2
View File
@@ -2,12 +2,13 @@
//! filling a closed loop of edges — planar, or B-spline patches for a loop that is not — or a
//! 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) and copying faces out of a body.
//! Thicken (a surface offset along its normals into a solid), copying faces out of a body, and
//! sheets swept from sketch curves (surface extrude).
use std::collections::HashMap;
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
use solidcraft_geom::{Plane, Region2, Vec3};
use solidcraft_geom::{Plane, Region2, Seg2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
@@ -63,6 +64,42 @@ pub fn patch_region(plane: &Plane, region: &Region2) -> Result<Body> {
Ok(rebuilt_body(surface_body(vec![f]), &[], &rec))
}
/// Most curve chains one surface extrude takes.
const MAX_CHAINS: usize = 10_000;
/// A surface extruded from chains of sketch segments on `plane`: each chain (open, or closed
/// such as a profile's boundary) moved straight from `start` to `end` along the plane normal
/// (offsets in mm). The walls it sweeps form one surface body with no caps. Its history has no
/// operands: every face is new.
pub fn extrude_sheet(plane: &Plane, chains: &[Vec<Seg2>], start: f64, end: f64) -> Result<Body> {
if !crate::build::frame_ok(plane) {
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
}
if !(start.is_finite() && end.is_finite() && start.abs() < 1e7 && end.abs() < 1e7) {
return Err(KernelError::Invalid("extrude distance is not finite".into()));
}
let (lo, hi) = if end >= start { (start, end) } else { (end, start) };
if hi - lo < 1e-6 {
return Err(KernelError::Invalid("extrude distance is zero".into()));
}
if chains.is_empty() || chains.len() > MAX_CHAINS {
return Err(KernelError::Invalid("pick the curves or profiles to extrude".into()));
}
let base = plane.offset(lo);
let along = crate::body::v3(plane.normal() * (hi - lo));
let mut rec = Rebuild::default();
let mut faces = Vec::new();
for chain in chains {
let wire = crate::build::chain_wire(&base, chain)?;
let walls: mt::Shell = guard("surface extrude", || Ok(mt::builder::tsweep(&wire, along)))?;
for f in walls.face_iter() {
rec.made(f, Vec::new());
faces.push(f.clone());
}
}
Ok(rebuilt_body(surface_body(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;
@@ -630,6 +667,49 @@ mod tests {
(a - b).abs() / b.abs().max(1e-9)
}
/// Walls swept from an open line, an open arc and a closed square: areas are length × height,
/// no caps, and stitching two patches onto the square's walls closes the box.
#[test]
fn extrude_sheet_of_open_and_closed_chains() {
let line = vec![Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }];
let s = extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, 5.0).unwrap();
assert!(s.is_surface() && s.face_count() == 1);
assert!(rel(measure(&s).unwrap().area, 50.0) < 1e-9);
// A quarter arc of radius 4, swept from -1 to 2: area r θ h.
let arc = vec![Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 4.0, start: 0.0, sweep: std::f64::consts::FRAC_PI_2 }];
let a = measure(&extrude_sheet(&Plane::XY, &[arc], 2.0, -1.0).unwrap()).unwrap().area;
let want = 4.0 * std::f64::consts::FRAC_PI_2 * 3.0;
assert!(rel(a, want) < 1e-3, "{a} vs {want}"); // (measured on meshes)
// A line then a line back up: one chain, two faces.
let bent = vec![line[0], Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 6.0) }];
assert_eq!(extrude_sheet(&Plane::XY, &[bent], 0.0, 1.0).unwrap().face_count(), 2);
// A closed square: four walls, open at both ends; with a patch on each end, a solid box.
let sq = Region2 {
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
holes: vec![],
};
let walls = extrude_sheet(&Plane::XY, std::slice::from_ref(&sq.outer.segs), 0.0, 4.0).unwrap();
assert!(walls.is_surface() && walls.face_count() == 4);
assert!(rel(measure(&walls).unwrap().area, 160.0) < 1e-9);
let (bottom, top) = (patch_region(&Plane::XY, &sq).unwrap(), patch_region(&Plane::XY.offset(4.0), &sq).unwrap());
let closed = stitch(&[&walls, &bottom, &top], 1e-6).unwrap();
assert!(!closed.is_surface());
assert!(rel(measure(&closed).unwrap().volume, 400.0) < 1e-9);
}
#[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) }];
assert!(extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, 0.0).is_err());
assert!(extrude_sheet(&Plane::XY, std::slice::from_ref(&line), 0.0, f64::NAN).is_err());
assert!(extrude_sheet(&Plane::XY, &[], 0.0, 1.0).is_err());
assert!(extrude_sheet(&Plane::XY, &[vec![]], 0.0, 1.0).is_err());
let gap = vec![line[0], Seg2::Line { a: Vec2::new(11.0, 0.0), b: Vec2::new(12.0, 0.0) }];
assert!(extrude_sheet(&Plane::XY, &[gap], 0.0, 1.0).is_err());
let nan = vec![Seg2::Line { a: Vec2::new(0.0, f64::NAN), b: Vec2::new(1.0, 0.0) }];
assert!(extrude_sheet(&Plane::XY, &[nan], 0.0, 1.0).is_err());
}
#[test]
fn patch_and_thicken_a_square() {
let r = Region2 {