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()); +}