diff --git a/crates/kernel/src/modify_history_tests.rs b/crates/kernel/src/modify_history_tests.rs index 04880f70b6..1c6165ff2d 100644 --- a/crates/kernel/src/modify_history_tests.rs +++ b/crates/kernel/src/modify_history_tests.rs @@ -537,6 +537,21 @@ mod smlib { assert!(e.to_string().contains("no room"), "{e}"); } + /// A hole's wall is stored as two halves; a pick on the line between them opens the whole + /// wall (kernel-cases shell/4010). The cavity is the plate inset by 2.5 less the hole's + /// cylinder, open to the hole. + #[test] + fn shell_open_at_a_hole_wall_opens_the_whole_wall() { + let hole = Loop2::circle(Vec2::new(20.0, 15.0), 5.0); + let plate = extrude(&Plane::XY, &[Region2 { outer: square(0.0, 0.0, 40.0, 30.0), holes: vec![hole] }], 0.0, 12.5).unwrap().pop().unwrap(); + let r = offset_backend::shell(&plate, &[Vec3::new(25.0, 15.0, 6.25)], 2.5).unwrap(); + let pi = std::f64::consts::PI; + let want = (15000.0 - pi * 25.0 * 12.5) - (35.0 * 25.0 * 7.5 - pi * 25.0 * 7.5); + // The measure is a mesh volume (within 1e-5 of the true one on this round hole). + assert!((volume(&r) - want).abs() < want * 1e-5, "{} vs {want}", volume(&r)); + verified(&r, &plate, reach(2.5 * 1.5)); + } + #[test] fn offset_moves_a_face() { let b = ell(); diff --git a/crates/kernel/src/offset_backend.rs b/crates/kernel/src/offset_backend.rs index 091e0137dd..10ecbe7a88 100644 --- a/crates/kernel/src/offset_backend.rs +++ b/crates/kernel/src/offset_backend.rs @@ -73,14 +73,19 @@ fn run(name: &str, b: &Body, op: impl FnOnce(&Brep) -> std::result::Result Result { if !(thickness.is_finite() && thickness > 1e-6) { return Err(KernelError::Invalid("shell thickness must be positive".into())); } + let picked = faces_under(b, open)?; + let opened = crate::offset::with_same_surface(b, &picked); let tol = pick_tolerance(b); - let pts = points(open); + let pts = points(&face_points(b, &opened)?); let (r, h) = run("shell", b, |s| s.shell(&pts, tol, -thickness))?; let h = h.and_then(|h| with_offsets(&r, b, h, thickness)); Ok(r.with_checked_history(h, &record::counts(&[b]))) @@ -118,6 +123,30 @@ pub(crate) fn thicken(b: &Body, thickness: f64, symmetric: bool) -> Result Ok(r.with_checked_history(h, &record::counts(&[b]))) } +/// The face of `b` under each point: the face of the nearest triangle, within the picking reach. +fn faces_under(b: &Body, at: &[Vec3]) -> Result> { + let reach = pick_tolerance(b); + let mesh = b.tessellate((b.size() * 1e-3).max(1e-3))?; + let mut out: Vec = Vec::new(); + for p in at { + let near = mesh + .triangles + .iter() + .zip(&mesh.tri_face) + .filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (crate::boolean_guard::point_triangle_dist(*p, x, y, z), *f as usize))) + .min_by(|x, y| x.0.total_cmp(&y.0)); + match near { + Some((d, f)) if d < reach => { + if !out.contains(&f) { + out.push(f); + } + } + _ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))), + } + } + Ok(out) +} + /// One point inside each face `faces` of `b`: the centroid of its largest triangle. fn face_points(b: &Body, faces: &[usize]) -> Result> { let mesh = b.tessellate((b.size() * 1e-3).max(1e-3))?; diff --git a/crates/kernel/src/polyhedron.rs b/crates/kernel/src/polyhedron.rs index 5c2607ff00..f87648396a 100644 --- a/crates/kernel/src/polyhedron.rs +++ b/crates/kernel/src/polyhedron.rs @@ -879,11 +879,12 @@ pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result { let run = |body: &Body| -> Result { let healed = crate::boolean_record::healed(body)?.with_split_lines(body.splits.clone()); let chosen = faces_at(&healed, at)?; - #[cfg(feature = "smlib")] - if crate::backend()? == crate::Backend::Smlib { - return crate::offset_backend::offset_faces(&healed, &chosen, distance).map(|r| record::after(r, &healed, &record::counts(&[body]))); - } - match offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }) { + let moved = match crate::backend()? { + #[cfg(feature = "smlib")] + crate::Backend::Smlib => crate::offset_backend::offset_faces(&healed, &chosen, distance), + _ => offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 }), + }; + match moved { Ok(r) => Ok(record::after(r, &healed, &record::counts(&[body]))), // A piece of a split face (its neighbour stays): the piece swept along its normal // joins on (outward) or cuts in, with walls along the split lines.