From 91de39d3fe8f9fd3a58dec9ca947ea4072a9cf4f Mon Sep 17 00:00:00 2001 From: Mqrius Date: Tue, 13 Oct 2026 04:38:48 +0200 Subject: [PATCH] fix(kernel): revolving adjacent profiles on the axis makes no zero-area faces (#490) Profiles revolved together merge into one region that keeps a vertex where each shared edge met the axis. With the axis edge in several pieces the loop skipped the builders for a profile with an edge on the axis, and the generic sweep turned each piece into a degenerate face that booleans failed on. Runs of collinear axis lines are merged into one first. Fixes #487 Co-authored-by: Claude Opus 5.5 --- crates/kernel/src/build.rs | 11 +++++++++-- crates/kernel/src/numbered.rs | 29 +++++++++++++++++++++++++++++ crates/kernel/src/tests.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 8d0ca544..2eaa52f1 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -341,7 +341,15 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: _ => nl, } }; - let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l))); + // Adjacent profiles revolved together leave the axis edge split where they met: one + // edge again, so the builders for a profile with an edge on the axis take it (swept + // pieces of the axis are zero-area faces that booleans fail on). + let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; + let axis_run = |s: &Seg2, t: &Seg2| { + let (a, b, c) = (s.start(), s.end(), t.end()); + on_axis(a) && on_axis(b) && on_axis(c) && (b - a).dot(c - b) > 0.0 + }; + let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l).merged_lines(axis_run))); let r = &given.region(); // A half disc on the axis revolved fully is a sphere: build it without poles. Every // patch is swept from the half disc's arcs. @@ -388,7 +396,6 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: // there, which truck only tessellates reliably with the axis through the world origin. Off // it, the body measured too large and booleans with it failed or removed the wrong side. // Build such a profile there and move it into place, as `revolve_touching_axis` does. - let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; let axis_edges = r.outer.segs.iter().filter(|s| matches!(**s, Seg2::Line { a, b } if on_axis(a) && on_axis(b))).count(); let shift = if axis_edges > 0 { o } else { Vec3::ZERO }; let (profile, f) = if shift.len() > 0.0 { diff --git a/crates/kernel/src/numbered.rs b/crates/kernel/src/numbered.rs index 2fbb48f7..092bb3d5 100644 --- a/crates/kernel/src/numbered.rs +++ b/crates/kernel/src/numbered.rs @@ -125,6 +125,35 @@ impl NumberedLoop { out } + /// Each run of lines that `joins` says continue one another as a single line, over what + /// they all ran over. (Adjacent profiles merged into one region keep a vertex where each + /// shared edge met the outline.) + pub(crate) fn merged_lines(&self, joins: impl Fn(&Seg2, &Seg2) -> bool) -> NumberedLoop { + let n = self.lp.segs.len(); + let join = |i: usize, j: usize| match (self.lp.segs.get(i), self.lp.segs.get(j)) { + (Some(s @ Seg2::Line { .. }), Some(t @ Seg2::Line { .. })) => joins(s, t), + _ => false, + }; + // Start where a run starts (a loop of nothing but one run stays as it is). + let Some(k) = (0..n).find(|&i| !join((i + n - 1) % n, i)) else { return self.clone() }; + let mut out = NumberedLoop { lp: Loop2 { segs: Vec::new() }, from: Vec::new() }; + for j in 0..n { + let i = (k + j) % n; + let (Some(s), from) = (self.lp.segs.get(i).copied(), self.numbers(i)) else { continue }; + if j > 0 + && join((i + n - 1) % n, i) + && let (Some(Seg2::Line { b, .. }), Some(prev)) = (out.lp.segs.last_mut(), out.from.last_mut()) + { + *b = s.end(); + *prev = union([prev.as_slice(), from]); + continue; + } + out.lp.segs.push(s); + out.from.push(from.to_vec()); + } + out + } + /// Split segment `k` in half, both halves over what it ran over. fn split_half(&mut self, k: usize) { if k >= self.lp.segs.len() || k >= self.from.len() { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 0a4a4c07..537e5450 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -125,6 +125,32 @@ fn revolve_washer_and_sphere() { assert!(rel(ms.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{}", ms.volume); } +#[test] +fn revolve_adjacent_profiles_on_the_axis() { + // Two rectangles on the axis, revolved together: their merged outline keeps a vertex + // where the shared edge met the axis. The swept axis pieces used to be zero-area faces, + // and cutting the body's flat end out of another failed. + let r = Region2 { + outer: Loop2::polygon(&[ + Vec2::new(0.0, 0.0), + Vec2::new(30.0, 0.0), + Vec2::new(60.0, 0.0), + Vec2::new(60.0, 20.0), + Vec2::new(30.0, 20.0), + Vec2::new(0.0, 20.0), + ]), + holes: vec![], + }; + let b = revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::X, 2.0 * PI).unwrap().pop().unwrap(); + assert!(rel(measure(&b).unwrap().volume, PI * 400.0 * 60.0) < 5e-4); + let faces = b.faces(1e-3).unwrap(); + assert!(faces.iter().all(|f| f.area > 1e-6), "{:?}", faces.iter().map(|f| f.area).collect::>()); + let disc = cylinder(Vec3::new(-1.0, 0.0, 0.0), Vec3::X, 40.0, 3.0).unwrap(); + let cut = boolean(&disc, &b, BoolOp::Cut).unwrap().unwrap(); + let want = PI * 1600.0 * 3.0 - PI * 400.0 * 2.0; + assert!(rel(measure(&cut).unwrap().volume, want) < 1e-3, "{} vs {want}", measure(&cut).unwrap().volume); +} + #[test] fn chamfer_and_step() { let b = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();