Sweep: close circular tools before cutting a torus (#2005) (#2010)

Thanks @fkirill.
This commit is contained in:
fkirill authored and GitHub committed 2026-10-17 12:46:21 -04:00
1 parent 8be736c33e
commit 2c924fd385
7 files changed
+289 -7

No files matched your search

@@ -475,3 +475,19 @@ fn box_intersect_splits_the_end_off_a_raked_wedge_after_its_end_rounds() {
}
}
}
/// #2005: changing a circular sweep from a separate body to a half-turn cut must create the
/// internal channel, rather than failing the Boolean on coincident end caps.
#[test]
fn closed_sweep_redefines_to_a_half_turn_torus_cut() {
let s = replay(include_str!("../../../examples/regressions/closed_sweep_torus_cut.json"));
assert_eq!(s.model.state().bodies.len(), 1);
let body = &s.model.state().bodies[0].body;
assert!(body.validity().is_empty(), "{:?}", body.validity());
let mesh = body.tessellate(0.02).unwrap();
let expected = (std::f64::consts::PI * 64.0 - 64.0) * std::f64::consts::TAU * 30.0;
assert!((mesh.measure().volume - expected).abs() < expected * 0.015);
for (point, material) in [(solidcraft_geom::Vec3::new(30.0, 0.0, 0.0), false), (solidcraft_geom::Vec3::new(30.0, 0.0, 7.0), true)] {
assert_eq!(mesh.contains(point), material, "material at {point:?}");
}
}
+45
View File
@@ -1562,6 +1562,15 @@ pub(crate) fn cap_and_close(sides: Vec<Side>, w0: &[mt::Wire], w1: &[mt::Wire],
Err(KernelError::Failed(format!("closing the shell: {last}")))
}
/// A sweep whose walls already close, including separate shells around profile holes.
pub(crate) fn close_sweep(sides: Vec<Side>) -> Result<Body> {
let sources: std::collections::HashMap<_, _> = sides.iter().map(|(f, p)| (f.id(), p.clone())).collect();
let shell: mt::Shell = sides.into_iter().map(|(f, _)| f).collect();
let solid = Solid::try_new(shell.connected_components()).map_err(|e| KernelError::Failed(format!("closing the sweep: {e}")))?;
let history = solid.face_iter().map(|f| sources.get(&f.id()).cloned().flatten()).collect();
record::attach(Body::new(solid)?, &[], history, Default::default())
}
/// 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> {
@@ -1881,6 +1890,25 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
if corner {
return crate::sweep_path::sweep_path(plane, region, &path_polyline(path)?);
}
let points = path_polyline(path)?;
if crate::sweep_path::closed_path(&points) {
// Sketch circles have two half arcs. Sweep the whole turn in one operation so truck
// shares the closing edges itself. Other closed paths use stations with shared ends.
if let Some((center, axis, angle)) = full_circle(path) {
return guard("closed sweep", || {
let profile = region_wires(plane, &oriented(region)?)?;
let mut sides = Vec::new();
for p in &profile {
let shell: mt::Shell = builder::rsweep(&p.wire, p3(center), v3(axis), mt::Rad(angle));
let numbers = seeds(std::slice::from_ref(p));
let prov = record::columns(shell.face_iter(), &numbers, None, record::Column::ToEnd, &[]);
sides.extend(shell.face_iter().enumerate().map(|(i, f)| (f.clone(), prov.as_ref().and_then(|p| p.get(i)).cloned().flatten())));
}
close_sweep(sides)
});
}
return crate::sweep_path::sweep_path(plane, region, &points);
}
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let profile = region_wires(plane, &oriented(region)?)?;
let starts = wires_of(&profile);
@@ -1927,6 +1955,23 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
})
}
/// A connected, single turn about one centre and axis, possibly split into sketch arcs.
fn full_circle(path: &[PathSeg]) -> Option<(Vec3, Vec3, f64)> {
let PathSeg::Arc { a: first, center, axis, .. } = *path.first()? else { return None };
let axis = axis.normalized()?;
let mut end = first;
let mut total = 0.0;
for seg in path {
let PathSeg::Arc { a, center: c, axis: k, angle } = *seg else { return None };
if a.dist(end) > 1e-7 || c.dist(center) > 1e-7 || k.normalized()?.dist(axis) > 1e-9 || !angle.is_finite() {
return None;
}
total += angle;
end = rotate_about(a, c, axis, angle);
}
((total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((center, axis, total))
}
/// Cox–de Boor basis values N_{j,3}(u) for all j.
fn cubic_basis(knots: &[f64], n_ctrl: usize, u: f64) -> Vec<f64> {
let p = 3;
+93
View File
@@ -0,0 +1,93 @@
//! Issue #2005: a closed sweep must join its end back to its start, without coincident caps.
//! The capped tool used to stall or fail when cutting the torus it lay inside.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::{Body, BoolOp, PathSeg, SweepControl, boolean, measure, sweep_with};
fn square(half: f64) -> Region2 {
Region2 {
outer: Loop2::polygon(&[Vec2::new(-half, -half), Vec2::new(half, -half), Vec2::new(half, half), Vec2::new(-half, half)]),
holes: vec![],
}
}
fn profile_plane() -> Plane {
Plane { origin: Vec3::new(30.0, 0.0, 0.0), x: Vec3::X, y: Vec3::Z }
}
fn circle_path() -> [PathSeg; 2] {
// A sketch circle reaches the kernel as two half arcs.
[
PathSeg::Arc { a: Vec3::new(30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle: PI },
PathSeg::Arc { a: Vec3::new(-30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle: PI },
]
}
fn tube(body: &Body, volume: f64, genus: usize) {
let m = measure(body).unwrap();
assert!((m.volume - volume).abs() < volume * 0.01, "{m:?}, expected {volume}");
// A closed orientable tube has Euler characteristic 2 - 2g. Coincident caps gave 2.
assert_eq!(m.vertices + m.faces + 2 * genus, m.edges + 2, "{m:?}");
assert!(body.solid.boundaries().iter().all(|s| s.extract_boundaries().is_empty()));
let h = body.history().expect("sweep provenance");
assert!(h.faces.iter().all(|f| f.origins.iter().all(|o| matches!(o, crate::history::FaceOrigin::Profile { .. }))));
}
#[test]
fn closed_circle_sweeps_have_no_caps() {
for twist in [0.0, FRAC_PI_2, PI, TAU, -PI] {
let ctl = SweepControl { twist, ..SweepControl::default() };
let b = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &ctl).unwrap();
tube(&b, 64.0 * TAU * 30.0, 1);
}
}
#[test]
fn one_full_arc_closes_like_a_sketch_circle() {
for angle in [TAU, -TAU] {
let path = [PathSeg::Arc { a: Vec3::new(30.0, 0.0, 0.0), center: Vec3::ZERO, axis: Vec3::Z, angle }];
let b = sweep_with(&profile_plane(), &square(4.0), &path, &SweepControl::default()).unwrap();
tube(&b, 64.0 * TAU * 30.0, 1);
}
}
#[test]
fn closed_polyline_sweep_shares_its_starting_section() {
let path = crate::path_polyline(&circle_path()).unwrap();
let b = crate::sweep_path(&profile_plane(), &square(4.0), &path).unwrap();
tube(&b, 64.0 * TAU * 30.0, 1);
}
#[test]
fn closed_sweep_refuses_an_end_section_that_does_not_match() {
for ctl in [SweepControl { twist: PI / 4.0, ..SweepControl::default() }, SweepControl { scale: 2.0, ..SweepControl::default() }] {
let err = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &ctl).unwrap_err();
assert!(err.to_string().contains("closed path"), "{err}");
}
}
#[test]
fn closed_holed_sweep_joins_every_loop() {
let region = Region2 { holes: vec![square(2.0).outer], ..square(4.0) };
let ctl = SweepControl { twist: PI, ..SweepControl::default() };
let b = sweep_with(&profile_plane(), &region, &circle_path(), &ctl).unwrap();
// Two disjoint toroidal shells, one bounding the cavity.
let m = measure(&b).unwrap();
assert!((m.volume - 48.0 * TAU * 30.0).abs() < m.volume * 0.02, "{m:?}");
assert_eq!(m.vertices + m.faces, m.edges, "{m:?}");
}
#[test]
fn half_turn_sweep_cuts_a_torus() {
let base = crate::torus(Vec3::ZERO, 30.0, 8.0).unwrap();
let tool = sweep_with(&profile_plane(), &square(4.0), &circle_path(), &SweepControl { twist: PI, ..SweepControl::default() }).unwrap();
let result = boolean(&base, &tool, BoolOp::Cut).unwrap().expect("cut leaves the torus wall");
let expected = (PI * 64.0 - 64.0) * TAU * 30.0;
let m = measure(&result).unwrap();
assert!((m.volume - expected).abs() < expected * 0.015, "{m:?}, expected {expected}");
}
+2
View File
@@ -59,6 +59,8 @@ mod issue_1080_tests;
#[cfg(test)]
mod issue_1510_tests;
#[cfg(test)]
mod issue_2005_tests;
#[cfg(test)]
mod issue_324_tests;
#[cfg(test)]
mod issue_616_tests;
+18 -1
View File
@@ -225,6 +225,19 @@ fn trivial(
mod shortcut_tests {
use super::*;
#[test]
fn seam_roundoff_is_not_a_thin_meeting_but_real_thin_solids_are() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
// This circle's seams lie on the X axis: its cylinder's vertex box is flat in Y.
let region = Region2 { outer: Loop2::circle(Vec2::ZERO, 8.0), holes: vec![] };
let periodic = crate::extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap();
let block = crate::box_solid(Vec3::new(-40.0, -40.0, -10.0), Vec3::new(40.0, 40.0, 10.0)).unwrap();
assert!(meeting_size(&periodic, &block).is_infinite());
let thin = crate::box_solid(Vec3::ZERO, Vec3::new(80.0, 80.0, 1e-5)).unwrap();
assert!((meeting_size(&thin, &thin) - 1e-5).abs() < 1e-12);
}
#[test]
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
@@ -513,7 +526,11 @@ fn meeting_size(a: &Body, b: &Body) -> f64 {
};
let (ba, bb) = (bbox(a), bbox(b));
let s = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }.size();
if s.x > 0.0 && s.y > 0.0 && s.z > 0.0 { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
// A closed sweep's section seams need not span its geometry. Roundoff in a flat
// vertex box is not a thin meeting region: refining for it
// makes millions of triangles and skips the bounded containment shortcut (#2005).
let roundoff = a.size().max(b.size()) * 1e-12;
if s.x > roundoff && s.y > roundoff && s.z > roundoff { s.x.min(s.y).min(s.z) } else { f64::INFINITY }
}
/// Smallest side of the box where the bounds of two meshes overlap (infinite when they don't).
+98 -6
View File
@@ -10,7 +10,7 @@ use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, p3};
use crate::build::{cap_and_close, interpolate_cubic, uniform_wire};
use crate::build::{cap_and_close, close_sweep, interpolate_cubic, smooth_loft_faces, uniform_wire};
use crate::numbered::{NumberedLoop, NumberedRegion};
use crate::record::{self, Side};
use crate::{KernelError, Result, guard};
@@ -171,6 +171,43 @@ fn clean(path: &[Vec3]) -> Result<Vec<Vec3>> {
Ok(out)
}
pub(crate) fn closed_path(pts: &[Vec3]) -> bool {
pts.len() > 3 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.dist(*b) <= 1e-7)
}
/// The cyclic correspondence of a loop's last section to its first. Matching whole curves
/// (rather than vertices alone) prevents sewing an incompatible twist or scale shut.
fn closing_offset(plane: &Plane, profile: &NumberedLoop, st: &[Station]) -> Result<usize> {
let fail =
|| KernelError::Invalid("a sweep along a closed path must return to its starting profile; adjust the twist, end scale or rails".into());
let (Some(first), Some(last)) = (st.first(), st.last()) else { return Err(fail()) };
let n = profile.lp.segs.len();
if n == 0 || n > 4096 {
return Err(fail());
}
let at = |s: &Station, q: Vec2| s.at + s.apply(plane.to_world(q) - first.at);
let tol = (profile.lp.signed_area().abs().sqrt() * 1e-7).clamp(1e-8, 1e-5);
// Filter offsets by the first corner before comparing all the curve samples.
let start = profile.lp.segs.first().map(Seg2::start).ok_or_else(fail)?;
(0..n)
.filter(|k| profile.lp.segs.get(*k).is_some_and(|s| at(last, start).dist(at(first, s.start())) <= tol))
.find(|k| {
profile.lp.segs.iter().enumerate().all(|(j, a)| {
profile.lp.segs.get((j + k) % n).is_some_and(|b| {
(0..SEG_SAMPLES).all(|i| {
let t = i as f64 / (SEG_SAMPLES - 1) as f64;
at(last, a.point_at(t)).dist(at(first, b.point_at(t))) <= tol
})
})
})
})
.ok_or_else(fail)
}
fn rotated_wire(wire: &mt::Wire, offset: usize) -> mt::Wire {
wire.edge_iter().skip(offset).chain(wire.edge_iter().take(offset)).cloned().collect()
}
fn turn(a: Vec3, b: Vec3) -> f64 {
match (a.normalized(), b.normalized()) {
(Some(a), Some(b)) => a.dot(b).clamp(-1.0, 1.0).acos(),
@@ -506,7 +543,12 @@ pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &Swe
let v = swept_volume(plane, region.area().abs(), &st, &tangents);
(st, v)
};
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
if closed_path(&pts) {
let offset = closing_offset(plane, &profile.outer, &st)?;
if let Ok(b) = smooth_closed_walls(plane, &profile.outer, &st, offset, expected) {
return Ok(b);
}
} else if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
return Ok(b);
}
}
@@ -515,10 +557,11 @@ pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &Swe
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
let n = p.len();
let closed = closed_path(p);
(0..n)
.map(|i| {
let a = p.get(i.saturating_sub(1)).copied().unwrap_or_default();
let b = p.get((i + 1).min(n - 1)).copied().unwrap_or_default();
let a = p.get(if closed && (i == 0 || i + 1 == n) { n - 2 } else { i.saturating_sub(1) }).copied().unwrap_or_default();
let b = p.get(if closed && (i == 0 || i + 1 == n) { 1 } else { (i + 1).min(n - 1) }).copied().unwrap_or_default();
(b - a).normalized().ok_or_else(|| KernelError::Invalid("the sweep path has no direction".into()))
})
.collect()
@@ -535,12 +578,25 @@ fn ruled_stations(plane: &Plane, pts: &[Vec3], ctl: &SweepControl) -> Result<(Ve
let pts = densify(pts, ctl);
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts };
let n = pts.len();
let closed = closed_path(&pts);
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
let mut tangents = Vec::with_capacity(n);
let mut mitre = Vec::with_capacity(n);
for i in 0..n {
let d_in = if i > 0 { seg_dir(i - 1) } else { None };
let d_out = if i + 1 < n { seg_dir(i) } else { None };
let d_in = if i > 0 {
seg_dir(i - 1)
} else if closed {
seg_dir(n - 2)
} else {
None
};
let d_out = if i + 1 < n {
seg_dir(i)
} else if closed {
seg_dir(0)
} else {
None
};
let (t, m) = match (d_in, d_out) {
(Some(a), Some(b)) => {
let t = (a + b).normalized().ok_or_else(|| KernelError::Invalid("the sweep path turns back on itself".into()))?;
@@ -706,6 +762,15 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepContr
let pl = station_plane(plane, s0, s);
wires.push(loops.iter().map(|l| uniform_wire(&pl, &l.lp)).collect::<Result<Vec<_>>>()?);
}
let closed = closed_path(&pts);
if closed {
let offsets = loops.iter().map(|l| closing_offset(plane, l, &st)).collect::<Result<Vec<_>>>()?;
let first = wires.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?;
let end: Vec<mt::Wire> = first.iter().zip(offsets).map(|(w, k)| rotated_wire(w, k)).collect();
if let Some(last) = wires.last_mut() {
*last = end;
}
}
guard("path sweep", || {
// Every station's wires have the loops' segments, edge for edge.
let mut faces: Vec<Side> = Vec::new();
@@ -716,11 +781,38 @@ fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepContr
faces.extend(record::ruled(&sides, a, b, &l.from, &l.from));
}
}
if closed {
return close_sweep(faces);
}
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("path sweep".into())) };
cap_and_close(faces, first, last, false)
})
}
/// Two smooth runs share their middle section and the closing section. Splitting the run
/// avoids edges from a vertex back to itself when the profile returns without a permutation.
fn smooth_closed_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], offset: usize, expected: f64) -> Result<Body> {
let fail = || KernelError::Failed("closed path sweep: the walls do not close a solid".into());
if profile.lp.segs.len() > 64 || st.len() < 7 {
return Err(fail());
}
guard("closed path sweep", || {
let first = st.first().ok_or_else(fail)?;
let mut wires = st.iter().map(|s| uniform_wire(&station_plane(plane, first.at, s), &profile.lp)).collect::<Result<Vec<_>>>()?;
let end = rotated_wire(wires.first().ok_or_else(fail)?, offset);
if let Some(last) = wires.last_mut() {
*last = end;
}
let middle = (wires.len() - 1) / 2;
let numbers = vec![profile.from.clone(); wires.len()];
let mut sides = smooth_loft_faces(wires.get(..=middle).ok_or_else(fail)?, numbers.get(..=middle).ok_or_else(fail)?, true)?;
sides.extend(smooth_loft_faces(wires.get(middle..).ok_or_else(fail)?, numbers.get(middle..).ok_or_else(fail)?, true)?);
let b = close_sweep(sides)?;
let tol = (profile.lp.signed_area().abs().sqrt() * 0.05).max(1e-3);
if b.tessellate(tol).is_ok_and(|m| (m.measure().volume - expected).abs() <= 0.15 * expected.abs()) { Ok(b) } else { Err(fail()) }
})
}
/// One B-spline wall per profile segment through the stations, capped; checked by volume.
fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected: f64) -> Result<Body> {
let lp = &profile.lp;
@@ -0,0 +1,17 @@
{
"description": "Issue #2005: redefine a 90 degree new-body sweep around a circle to a 180 degree cut inside a torus. A closed tool must join back to its starting section without coincident caps.",
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Path"}},
{"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 30}},
{"command": "sketch.finish", "params": {}},
{"command": "sketch.create", "params": {"plane": "XZ", "name": "Tube"}},
{"command": "sketch.circle.center", "params": {"center": [30, 0], "radius": 8}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.revolve", "params": {"axis": "Z", "body_name": "Torus"}},
{"command": "sketch.create", "params": {"plane": "XZ", "name": "SweepProfile"}},
{"command": "sketch.line", "params": {"points": [[26, -4], [34, -4], [34, 4], [26, 4]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.sweep", "params": {"sketch": 4, "path_sketch": 1, "path": ["c1"], "operation": "new_body", "twist": "90 deg"}},
{"command": "timeline.redefine", "params": {"feature": 5, "command": "solid.sweep", "params": {"sketch": 4, "path_sketch": 1, "path": ["c1"], "profiles": [0], "operation": "cut", "twist": "180 deg"}}}
]
}