From 5165e428cd1908461a259a0dbb9a3d472a003a25 Mon Sep 17 00:00:00 2001 From: phanoob Date: Fri, 16 Oct 2026 04:19:28 +0200 Subject: [PATCH] Boolean: refuse a nudged result that meshes inconsistently (#1360) (#1455) The last-resort nudge's result is also measured finely; a face left a hair wide that meshes as its whole plane no longer passes as a plausible cut. --- crates/engine/src/cmd/features_more_tests.rs | 33 ++++++++++++++++++++ crates/kernel/src/ops.rs | 12 +++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 413c0478..582ae1fa 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -1058,6 +1058,39 @@ fn a_ring_cut_round_a_hole_takes_in_the_hole_it_is_drawn_round() { assert!(if ok { rel(blind, taken) < 1e-3 } else { blind.abs() < 1e-6 }, "{ok} {blind} vs {taken}"); } +/// #1360: an annulus cut 3 mm into a chamfered disc, its inner circle on a hole's wall. The +/// boolean can't intersect the two walls lying on each other; its last resort (the tool moved by +/// a micron) left a face a hair wide that meshes as its whole plane at a fine step, and the part +/// came back 21000 mm³ larger than before, a square plane across it. A cut may fail there, but it +/// never gives back more than it was given. +#[test] +fn a_cut_flush_with_a_hole_never_grows_the_part() { + let mut s = Session::default(); + for (c, p) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.circle.center", json!({"center":[0,0],"radius":40})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance":10})), + ("solid.hole", json!({"position":[20,0,10],"diameter":6})), + ("solid.chamfer", json!({"edges":[[40,0,10]],"distance":1})), + ("sketch.create", json!({"plane":{"face":[0,0,10]}})), + ("sketch.circle.center", json!({"center":[20,0],"radius":3})), + ("sketch.circle.center", json!({"center":[20,0],"radius":6})), + ("sketch.finish", json!({})), + ] { + run(&mut s, c, p); + } + let before = volume(&mut s); + // Profile 2: the annulus between the hole's wall and the larger circle. + let r = s.execute("solid.extrude", &json!({"direction":"negative","distance":"3","operation":"cut","profiles":[2]})); + let after = volume(&mut s); + let ring = std::f64::consts::PI * (36.0 - 9.0) * 3.0; + match r { + Ok(_) => assert!(rel(before - after, ring) < 1e-2, "{before} -> {after}, want {ring} taken"), + Err(_) => assert!(rel(after, before) < 1e-9, "{before} -> {after} after a failed cut"), + } +} + /// A line of text cut into a face: the letters' curves (the font's quadratics) are read /// exactly, and the cut is decided in 2D in one boolean, at any depth and where the text runs /// off the face. The B-rep boolean failed on many letters at once ("no result" at 3.5 mm, and diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 491890e5..6bb9a983 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -956,15 +956,23 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { match r { Ok(Some(body)) => { let (ok, v) = check(&body); - if ok { + // Measured finely too: a face the boolean left a hair wide, its loops a hair + // apart, can mesh as its whole plane at a fine step though it meshed as a sliver + // at the check's (#1360: an annulus cut flush with a hole's wall came out 21000 + // mm³ larger than the part it was cut from, a square plane across it). + let fine_ok = ok && crate::measure(&body).is_ok_and(|m| plausible(m.volume, op, va, vb, slack)); + if ok && !fine_ok { + last = format!("{last}; the nudged tool gave a solid whose faces mesh inconsistently"); + } else if ok { merge(diagnostics); let mut event = BooleanFallback::new(BooleanStrategy::ToolNudge); event.translation_mm = translation.len(); event.rotation_rad = 2e-6; record(event); return Ok(Some(body)); + } else { + last = format!("{last}; the nudged tool gave an implausible volume {v:.4}"); } - last = format!("{last}; the nudged tool gave an implausible volume {v:.4}"); } Ok(None) => { merge(diagnostics);