From 9a2a3cef8ba769e37e71031da1e3dce5d076479e Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:33:27 +0200 Subject: [PATCH] A sweep along a path with sharp corners follows the corners: sweeping a rim profile along three lines (an open rectangle) gave a body of volume -0.0006 mm3 with its centre of mass at x = -3.5e9, and no error kernel::sweep swept segment by segment: a line translates the profile along it, an arc turns it. At a sharp corner the next line runs within the plane the profile has reached, so the profile slid sideways in its own plane and the walls folded into an inside-out, zero-volume shell. Paths whose direction changes at a joint (more than 1e-3 rad) are now swept as a polyline by sweep_path, which mitres the corners; arcs become points within 0.01 mm. Smooth paths (lines and tangent arcs) are swept as before. Pipe uses the same path. Tests: sweep_round_a_sharp_corner_is_mitred sweeps a 2 x 2 square along an L of two 10 mm lines and checks the volume, 80 mm3 (fails on main). Co-Authored-By: Claude Opus 5.5 --- crates/kernel/src/build.rs | 57 +++++++++++++++++++++++++++++++++++++- crates/kernel/src/tests.rs | 16 +++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 955611f6..f6fc3ba6 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -795,13 +795,68 @@ pub enum PathSeg { }, } +impl PathSeg { + /// Unit directions of the path where the segment starts and ends. + fn tangents(&self) -> Option<(Vec3, Vec3)> { + match *self { + PathSeg::Line { a, b } => { + let t = (b - a).normalized()?; + Some((t, t)) + } + PathSeg::Arc { a, center, axis, angle } => { + let k = axis.normalized()?; + let end = rotate_about(a, center, k, angle); + Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?)) + } + } + } + + /// Points along the segment (an arc within `tol` of its chords), without its start. + fn points_after_start(&self, tol: f64) -> Vec { + match *self { + PathSeg::Line { b, .. } => vec![b], + PathSeg::Arc { a, center, axis, angle } => { + let Some(k) = axis.normalized() else { return vec![a] }; + let r = (a - center).len(); + let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() }; + let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512); + (1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect() + } + } + } +} + +/// `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 { + let v = p - center; + let (s, c) = angle.sin_cos(); + center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c)) +} + /// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile -/// perpendicular to the path (lines translate it, arcs rotate it). +/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners +/// is swept as a polyline, mitred at the corners. pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result { region_ok(region)?; if path.is_empty() || path.len() > 1000 { return Err(KernelError::Invalid("the path needs 1…1000 segments".into())); } + // Segment by segment the profile only follows a path whose direction runs on across each + // joint: at a corner the next line would slide the profile within its own plane. + let ends: Vec<(Vec3, Vec3)> = + path.iter().map(PathSeg::tangents).collect::>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?; + let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()); + if corner { + 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())), + }; + let mut pts = vec![first]; + for seg in path { + pts.extend(seg.points_after_start(1e-2)); + } + return crate::sweep_path::sweep_path(plane, region, &pts); + } // The outer loop and each hole, swept along the path; the ends are capped with holes. let mut starts = vec![wire(plane, ®ion.outer.ccw())?]; for h in ®ion.holes { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 2dbd1c58..6a5946bb 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -258,6 +258,22 @@ fn loft_and_sweep() { assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume); } +#[test] +fn sweep_round_a_sharp_corner_is_mitred() { + // A 2 × 2 square centred on an L of two 10 mm lines: lines alone would slide the profile + // along the second leg within its own plane (an empty, inside-out body). Mitred, the + // volume is the area times the path length. + let sq = + Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, -1.0), Vec2::new(1.0, -1.0), Vec2::new(1.0, 1.0), Vec2::new(-1.0, 1.0)]), holes: vec![] }; + let path = [ + PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 10.0) }, + PathSeg::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(10.0, 0.0, 10.0) }, + ]; + let b = sweep(&Plane::XY, &sq, &path).unwrap(); + let m = measure(&b).unwrap(); + assert!(rel(m.volume, 80.0) < 1e-9, "{}", m.volume); +} + #[test] fn moving_a_body_with_an_intersection_curve_edge_ends() { // A cylinder cut by a tilted box keeps an elliptical intersection curve as an edge. Moving