diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 6e299245..7e35708d 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -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:?}"); + } +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 9078e4ab..1548897f 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1562,6 +1562,15 @@ pub(crate) fn cap_and_close(sides: Vec, 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) -> Result { + 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 { @@ -1881,6 +1890,25 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result 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 }) } +/// 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 { let p = 3; diff --git a/crates/kernel/src/issue_2005_tests.rs b/crates/kernel/src/issue_2005_tests.rs new file mode 100644 index 00000000..78c328e4 --- /dev/null +++ b/crates/kernel/src/issue_2005_tests.rs @@ -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(), ®ion, &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}"); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 0f63b8de..a2b84cd6 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index f084660c..3c8ebdc8 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -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). diff --git a/crates/kernel/src/sweep_path.rs b/crates/kernel/src/sweep_path.rs index 1a7e8707..322fc766 100644 --- a/crates/kernel/src/sweep_path.rs +++ b/crates/kernel/src/sweep_path.rs @@ -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> { 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 { + 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> { 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 = 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 { (*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::>>()?); } + let closed = closed_path(&pts); + if closed { + let offsets = loops.iter().map(|l| closing_offset(plane, l, &st)).collect::>>()?; + let first = wires.first().ok_or_else(|| KernelError::Invalid("sweep path".into()))?; + let end: Vec = 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 = 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 { + 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::>>()?; + 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 { let lp = &profile.lp; diff --git a/examples/regressions/closed_sweep_torus_cut.json b/examples/regressions/closed_sweep_torus_cut.json new file mode 100644 index 00000000..6442ea89 --- /dev/null +++ b/examples/regressions/closed_sweep_torus_cut.json @@ -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"}}} + ] +}