From f17ff913d6b7cb4a529799b81b34a0e4d144f692 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:16:01 +0200 Subject: [PATCH] A panic while healing a boolean result no longer ends the boolean: a shallow, wide loft cut from a top face (556.68 x 99 down 4 mm to 80 x 80 in a 900 x 480 x 160 slab) failed with "called Option::unwrap() on a None value" heal_keep evaluated the result's intersection curves outside any guard. truck panics where such a curve no longer evaluates, and the panic escaped boolean_whole, so the retries and fallbacks that would have succeeded never ran. heal_keep now runs under guard and returns the solid unhealed when healing panics (as its doc already promised for failures), and the boolean builds its result body under guard too, so a result that cannot be built counts as a failed attempt. Tests: shallow_loft_cut_from_the_top_face replays examples/regressions/shallow_loft_cut_from_top_face.json and checks the exact prismatoid volume (fails on main with the panic). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/boolean_regression_tests.rs | 34 +++++++++++++++++++ crates/engine/src/lib.rs | 2 ++ crates/kernel/src/heal.rs | 10 ++++-- crates/kernel/src/ops.rs | 2 +- .../shallow_loft_cut_from_top_face.json | 16 +++++++++ 5 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/boolean_regression_tests.rs create mode 100644 examples/regressions/shallow_loft_cut_from_top_face.json diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs new file mode 100644 index 00000000..4db35a63 --- /dev/null +++ b/crates/engine/src/boolean_regression_tests.rs @@ -0,0 +1,34 @@ +//! Booleans that failed, panicked or hung, replayed from the scripts in +//! `examples/regressions/` (each script's description says what went wrong). + +use serde_json::Value; + +use crate::Session; + +fn replay(script: &str) -> Session { + let mut s = Session::default(); + let script: Value = serde_json::from_str(script).unwrap(); + s.run_script(&script).unwrap(); + s +} + +fn volume(s: &Session, body: &str) -> f64 { + s.model.state().body(body).map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0) +} + +/// Slab less a 557.68 × 100 × 120 pocket and the loft below its floor (556.68 × 99 down 4 mm to +/// 80 × 80; prismatoid: h/6 · (A_top + A_bottom + 4 A_mid)). +fn slab_pocket_loft() -> (f64, f64) { + let slab = 900.0 * 480.0 * 160.0; + let loft = 4.0 / 6.0 * (556.68 * 99.0 + 80.0 * 80.0 + 4.0 * (556.68 + 80.0) / 2.0 * (99.0 + 80.0) / 2.0); + (slab - 557.68 * 100.0 * 120.0, loft) +} + +#[test] +fn shallow_loft_cut_from_the_top_face() { + let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json")); + let (_, loft) = slab_pocket_loft(); + let want = 900.0 * 480.0 * 160.0 - loft; + let got = volume(&s, "Body1"); + assert!((got - want).abs() < 1e-9 * want, "{got} vs {want}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 22a5106f..beb357bb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -503,6 +503,8 @@ impl Session { } } +#[cfg(test)] +mod boolean_regression_tests; #[cfg(test)] mod construct_tests; #[cfg(test)] diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index fcd17878..b86c5858 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -404,6 +404,12 @@ pub fn heal(solid: Solid, size: f64) -> Solid { /// [`heal`], keeping faces apart along lines they were split along on purpose. pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec]) -> Solid { + // Edges a boolean made are intersection curves, which panic where they no longer + // evaluate; the solid then stays as it is. + crate::guard("heal", || Ok(heal_shells(&solid, size, keep))).ok().flatten().unwrap_or(solid) +} + +fn heal_shells(solid: &Solid, size: f64, keep: &[Vec]) -> Option { let tol = (size * 1e-7).max(1e-9); let mut shells = Vec::new(); let mut changed = false; @@ -428,9 +434,9 @@ pub(crate) fn heal_keep(solid: Solid, size: f64, keep: &[Vec]) -> Solid { shells.push(cur); } if !changed { - return solid; + return None; } - Solid::try_new(shells).unwrap_or(solid) + Solid::try_new(shells).ok() } #[cfg(test)] diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index b812cbb7..1b9f9afd 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -296,7 +296,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { empty_votes += 1; last = "empty result".into(); } - Ok(s) => match Body::new(crate::heal::heal_keep(s, size, &keep)) { + Ok(s) => match guard("boolean result", || Body::new(crate::heal::heal_keep(s, size, &keep))) { Ok(body) => { let (ok, v) = check(&body); if ok { diff --git a/examples/regressions/shallow_loft_cut_from_top_face.json b/examples/regressions/shallow_loft_cut_from_top_face.json new file mode 100644 index 00000000..a898231b --- /dev/null +++ b/examples/regressions/shallow_loft_cut_from_top_face.json @@ -0,0 +1,16 @@ +{ + "description": "A shallow, wide loft cut starting in a body's top face: slab 900 x 480 x 160, loft cut from a 556.68 x 99 rectangle on the top face down 4 mm to an 80 x 80 square (sides about 0.9 degrees from horizontal). Healing an intermediate boolean result panicked outside the boolean's guard and stopped every later attempt. Result: the slab less the loft, 69 003 015.31 mm³.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [900, 480]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 160, "name": "LoftTop"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, 329.34], "p1": [728.34, 428.34]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 156, "name": "LoftBottom"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [410, 338.84], "p1": [490, 418.84]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.loft", "params": {"sections": [{"sketch": "LoftTop"}, {"sketch": "LoftBottom"}], "operation": "cut", "name": "LoftCut"}} + ] +}