From a3ff8161761675a5d8d24a74b6208f2ed4759234 Mon Sep 17 00:00:00 2001 From: Iraklis Kourtis Date: Mon, 12 Oct 2026 11:44:27 -0400 Subject: [PATCH] fix(kernel): name the zero-thickness rim when a shell can't open beside a round (#316) Shelling a body through a face that a convex round meets tangentially, with the round at least as large as the thickness, puts the cavity wall through the rim where the round meets the open face. Both shell strategies then fail and the first one's generic error ("a corner whose faces don't meet the same way after the move") was reported. The shell now says why and what to change. Successful shells are unchanged. Found by fuzz seed 33, which reaches this case now that a disc's two edges can be rounded to half its thickness. Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 Co-authored-by: Claude Opus 5.5 --- crates/kernel/src/offset.rs | 2 +- crates/kernel/src/polyhedron.rs | 43 ++++++++++++++++++++++++++++++--- crates/kernel/src/tests.rs | 17 +++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index c0f3afd9..dac14dd2 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -42,7 +42,7 @@ fn tube_centre(o: Vec3, a: Vec3, big: f64, h: f64, p: Vec3) -> Option { impl Surf { /// Outward unit normal of the surface at `p` (on or near it). - fn normal(&self, p: Vec3) -> Option { + pub(crate) fn normal(&self, p: Vec3) -> Option { match *self { Surf::Plane { n, .. } => Some(n), Surf::Cylinder { o, a, convex, .. } => { diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 13a5b7e6..31bba414 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -373,16 +373,51 @@ fn shell_planar(b: &Body, open: &[Vec3], thickness: f64) -> Result { return Err(KernelError::Invalid("select at least one face to remove".into())); } let margin = thickness + size * 0.05; + shell_opened(b, &healed, open, &opened, thickness, margin).map_err(|e| match rim_without_thickness(&healed, &opened, thickness) { + Some(r) => unsupported_shell(&format!( + "the rim would have no thickness: an open face meets a round of radius {r:.4} mm tangentially, and the shell is {thickness:.4} mm thick (make the round smaller than the thickness)" + )), + None => e, + }) +} + +/// The radius of a convex round (cylinder or torus) that meets an open face tangentially and +/// is at least `thickness`: the cavity's wall then passes through the rim where the round meets +/// the open face, leaving nothing between them. +fn rim_without_thickness(b: &Body, opened: &[usize], thickness: f64) -> Option { + use mt::{BoundedCurve, ParametricCurve}; + let faces: Vec<&mt::Face> = b.solid.face_iter().collect(); + let tol = (b.size() * 1e-7).max(1e-9) * 100.0; + opened.iter().filter_map(|fi| faces.get(*fi)).find_map(|open| { + let plane = crate::offset::surf_of(open, tol)?; + faces.iter().filter(|g| !std::ptr::eq(**g, *open)).find_map(|g| { + let round = crate::offset::surf_of(g, tol)?; + let r = match round { + crate::offset::Surf::Cylinder { r, convex: true, .. } | crate::offset::Surf::Torus { r, convex: true, .. } => r, + _ => return None, + }; + let shared = g.edge_iter().find(|e| open.edge_iter().any(|x| x.id() == e.id()))?; + let c = shared.curve(); + let (t0, t1) = c.range_tuple(); + let mid = from_p3(c.subs((t0 + t1) / 2.0)); + let tangent = plane.normal(mid)?.dot(round.normal(mid)?) > 1.0 - 1e-6; + (tangent && r >= thickness - tol).then_some(r) + }) + }) +} + +fn shell_opened(b: &Body, healed: &Body, open: &[Vec3], opened: &[usize], thickness: f64, margin: f64) -> Result { + let size = healed.size(); // Open faces pushed out past the body: the simple way, unless a face tangent to an open // one (a fillet along its edge) can't follow; then every face moves in (tangencies keep) // and the opening is the open face's copy swept out through the body. - let cavity = match offset_planar(&healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness }) { + let cavity = match offset_planar(healed, |fi, _| if opened.contains(&fi) { margin } else { -thickness }) { Ok(c) => c, - Err(e) => return shell_by_opening(&healed, &opened, thickness, margin).map_err(|_| e), + Err(e) => return shell_by_opening(healed, opened, thickness, margin).map_err(|_| e), }; - let r = crate::ops::boolean(&healed, &cavity, crate::BoolOp::Cut); + let r = crate::ops::boolean(healed, &cavity, crate::BoolOp::Cut); if r.is_err() - && let Ok(x) = shell_by_opening(&healed, &opened, thickness, margin) + && let Ok(x) = shell_by_opening(healed, opened, thickness, margin) { return Ok(x); } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 4a509c8e..a49eff1c 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1930,3 +1930,20 @@ fn shell_of_a_pocket_whose_floor_falls_to_a_drain() { let m = measure(&s).unwrap(); assert!(rel(m.volume, want) < 1e-4, "{} vs {want}", m.volume); } + +#[test] +fn shell_open_beside_a_round_as_large_as_the_wall_is_refused() { + // Opening the bottom of a box whose bottom outline is rounded: the cavity's side walls stand + // the thickness in from the outside, where a round at least that large meets the bottom, so + // the rim would have no thickness. Smaller rounds leave a rim. + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap(); + let loop_ = [Vec3::new(20.0, 0.0, 0.0), Vec3::new(40.0, 15.0, 0.0), Vec3::new(20.0, 30.0, 0.0), Vec3::new(0.0, 15.0, 0.0)]; + let bottom = [Vec3::new(20.0, 15.0, 0.0)]; + for r in [2.0, 2.4] { + let f = fillet(&b, &loop_, r).unwrap(); + let e = shell(&f, &bottom, 2.0).unwrap_err(); + assert!(e.to_string().starts_with("the operation failed: not supported yet: the rim"), "{r}: {e}"); + } + let f = fillet(&b, &loop_, 1.5).unwrap(); + assert!(shell(&f, &bottom, 2.0).unwrap().validity().is_empty()); +}