From 4a86ad5a7cb504e38e63bfdb7c33371081719294 Mon Sep 17 00:00:00 2001 From: Jonas Wrede Date: Tue, 13 Oct 2026 10:26:07 +0200 Subject: [PATCH] fix: extend a cut or join tool only past an end that lies on a target face (#545, #544) (#609) An extrude cut or join only extends its tool past an end that lies on a target face, and by less than half the way to the next face level, so a tool can no longer cut through a thin floor beyond an end that touches no face. Un-ignores the #545 and #544 regression tests. Fixes #545. Co-authored-by: jonaswre --- crates/doc/src/eval.rs | 35 ++++++++++++++++--- crates/engine/src/boolean_regression_tests.rs | 2 -- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 5c7d8d42..a065ba03 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1494,7 +1494,10 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target /// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss /// starting on the face it joins). Extending the tool past such an end changes nothing when the -/// space beyond is empty (cut) or already material (join), so do exactly that. +/// space beyond is empty (cut) or already material (join), so do exactly that. Only an end +/// that lies on a target face parallel to it moves, and by less than half the way to the next +/// such face level: the samples can miss material beyond the end (a thin floor between a recess +/// above and a ring pocket below), which a longer tool would cut through. fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) { if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() { return (lo, hi); @@ -1509,18 +1512,40 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: let samples = region_samples(r); let n = plane.normal(); let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); - let ok_beyond = |h: f64, dir: f64| { + // Heights of the targets' faces parallel to the profile plane. + let tol = (size * 1e-7).max(1e-9); + let levels: Vec = meshes + .iter() + .flat_map(|m| { + m.triangles.iter().filter_map(|t| { + let [a, b, c] = m.tri(t)?; + let normal = (b - a).cross(c - a).normalized()?; + (normal.dot(n).abs() > 1.0 - 1e-9).then(|| (a - plane.origin).dot(n)) + }) + }) + .collect(); + // How far the end at `h` may move in `dir`: zero unless a face lies on it, then short of + // half the gap to the next face level beyond it. + let room = |h: f64, dir: f64| { + if !levels.iter().any(|z| (z - h).abs() < tol) { + return 0.0; + } + let gap = levels.iter().map(|z| (z - h) * dir).filter(|g| *g > tol).fold(f64::INFINITY, f64::min); + delta.min(gap * 0.5) + }; + let ok_beyond = |h: f64, dir: f64, step: f64| { samples.iter().all(|s| { let base = plane.to_world(*s); [0.5, 1.0].iter().all(|k| { - let p = base + n * (h + dir * delta * k); + let p = base + n * (h + dir * step * k); let inside = inside_any(p); if op == Operation::Cut { !inside } else { inside } }) }) }; - let lo2 = if ok_beyond(lo, -1.0) { lo - delta } else { lo }; - let hi2 = if ok_beyond(hi, 1.0) { hi + delta } else { hi }; + let (down, up) = (room(lo, -1.0), room(hi, 1.0)); + let lo2 = if down > tol && ok_beyond(lo, -1.0, down) { lo - down } else { lo }; + let hi2 = if up > tol && ok_beyond(hi, 1.0, up) { hi + up } else { hi }; (lo2, hi2) } diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index f2f15a28..3526ed89 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -93,7 +93,6 @@ fn struts_ending_on_a_bore_wall_join_without_a_panic() { /// yet the cut was refused (#544). It removes π · 1² · 10 mm³ (within 5 %: the measure tessellates /// the small bore) and adds the bore's wall and floor to the bar's six faces. #[test] -#[ignore = "fails until #544 is fixed"] fn blind_hole_into_a_long_d_profile_bar_stops_short_of_the_round_front() { let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/blind_hole_in_long_d_profile.json")).unwrap(); let steps = script.get("commands").and_then(Value::as_array).unwrap(); @@ -111,7 +110,6 @@ fn blind_hole_into_a_long_d_profile_bar_stops_short_of_the_round_front() { /// A ring pocket cut from below a recess leaves a 0.5 mm floor joining the outer wall to the inner /// block: one connected part, yet the cut left two bodies and lost volume (#545). #[test] -#[ignore = "fails until #545 is fixed"] fn ring_cut_below_a_recess_keeps_the_part_in_one_body() { let s = replay(include_str!("../../../examples/regressions/ring_cut_below_recess_thin_floor.json")); let bodies: Vec<(String, f64)> = s.model.state().bodies.iter().map(|b| (b.name.clone(), volume(&s, &b.name))).collect();