From 1bf377c14b9a420da776062e2962094246e8a1f1 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 19:30:51 +0000 Subject: [PATCH] No panics in the boolean's final assembly (the web build aborts on any panic: the Plastic Enclosure sample panicked 8 times and crashed the browser's start page); face pushes check their result and fall back to the general offset (a boss standing on a plate joins again with off-axis cylinder seams); offset errors name the face they can't move; docs/robustness.md with the before and after rates; caught panics counted per design in the robustness runs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- apps/solvecraft-cli/src/fuzz.rs | 13 ++- crates/engine/src/tests/robustness.rs | 31 ++++++ crates/kernel/src/coplanar.rs | 9 +- crates/kernel/src/offset.rs | 10 +- crates/kernel/src/splitface.rs | 3 +- docs/robustness.md | 96 +++++++++++++++++++ .../transversal/faces_classification/mod.rs | 35 ++++--- .../src/transversal/integrate/mod.rs | 18 ++-- 8 files changed, 182 insertions(+), 33 deletions(-) create mode 100644 docs/robustness.md diff --git a/apps/solvecraft-cli/src/fuzz.rs b/apps/solvecraft-cli/src/fuzz.rs index 64c1f488..f8fc26b3 100644 --- a/apps/solvecraft-cli/src/fuzz.rs +++ b/apps/solvecraft-cli/src/fuzz.rs @@ -21,6 +21,11 @@ pub fn one(args: &[String]) -> Result<(), String> { let seed: u64 = args.first().and_then(|s| s.parse().ok()).ok_or("fuzz-one [--steps N] [--minimise]")?; let steps = flag(args, "--steps", 8usize); let minimise = args.iter().any(|a| a == "--minimise"); + // Panics caught inside the kernel: harmless natively, fatal in the web build. + static PANICS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + std::panic::set_hook(Box::new(|_| { + PANICS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })); eprintln!("design {seed}"); let run = fuzz::run_with(seed, steps, &mut |st| eprintln!("step {}", serde_json::to_string(st).unwrap_or_default())); let mut design = json!({ @@ -28,6 +33,7 @@ pub fn one(args: &[String]) -> Result<(), String> { "steps": run.steps.iter().map(|s| s.kind).collect::>(), "unsupported": run.unsupported, "rejected": run.rejected, + "panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst), }); if run.outcome.is_bug() { design["script"] = run.script(); @@ -40,7 +46,8 @@ pub fn one(args: &[String]) -> Result<(), String> { let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed); eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default()); let b = boolean_case(seed); - println!("{}", json!({"seed": seed, "design": design, "boolean": b})); + let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst); + println!("{}", json!({"seed": seed, "design": design, "boolean": b, "boolean_panics": bp})); Ok(()) } @@ -172,7 +179,10 @@ fn summarise(results: &[Value], took: Duration) -> String { let mut at_step: BTreeMap = BTreeMap::new(); let mut unsupported: BTreeMap = BTreeMap::new(); let mut steps = 0; + let (mut dp, mut bp) = (0, 0); for v in results { + dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0); + bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0); let k = if v.get("timeout").is_some() { "timeout".to_string() } else if v.get("crash").is_some() { @@ -204,6 +214,7 @@ fn summarise(results: &[Value], took: Duration) -> String { for (k, c) in &unsupported { s += &format!("- {k}: {c}\n"); } + s += &format!("\nCaught panics (fatal in the web build): designs {}, boolean checks {}\n", pct(dp), pct(bp)); s += "\nBoolean identity (union, intersection, cut of two primitives):\n"; for (k, c) in &boolean { s += &format!("- {k}: {}\n", pct(*c)); diff --git a/crates/engine/src/tests/robustness.rs b/crates/engine/src/tests/robustness.rs index cd4eb7a6..542c9bc6 100644 --- a/crates/engine/src/tests/robustness.rs +++ b/crates/engine/src/tests/robustness.rs @@ -79,6 +79,37 @@ fn shell_thicker_than_a_step_says_so() { assert!(e.to_string().contains("not supported yet"), "{e}"); } +/// Panics inside the kernel are caught natively, but abort the web build (wasm can't unwind). +/// Building the samples must not panic at all (the Plastic Enclosure's bosses used to, 8 times). +#[test] +fn samples_build_without_panics() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + let me = std::thread::current().id(); + let count = Arc::new(AtomicUsize::new(0)); + let c2 = count.clone(); + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if std::thread::current().id() == me { + c2.fetch_add(1, Ordering::SeqCst); + } + let _ = info; + })); + let mut failed = Vec::new(); + for sample in crate::sample::samples() { + let mut s = Session::default(); + for c in (sample.script)()["commands"].as_array().into_iter().flatten() { + let _ = s.execute(c["command"].as_str().unwrap_or(""), &c["params"]); + } + let n = count.swap(0, Ordering::SeqCst); + if n > 0 { + failed.push(format!("{}: {n} panic(s)", sample.name)); + } + } + std::panic::set_hook(prev); + assert!(failed.is_empty(), "{failed:?}"); +} + /// Seeds that run clean: no failure, no invalid body, no volume going the wrong way. #[test] fn fixed_seeds_run_clean() { diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index 1ecf2499..f986de45 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -42,8 +42,11 @@ fn planes(b: &Body) -> Vec<(usize, PlaneFace)> { /// Push the planar faces of `b` lying in the plane (n, d) — n their outward normal — along n /// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane. -fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result { - match push_faces_exact(b, n, d, delta) { +pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result { + // The exact rebuild, unless it came out unsound (a curved side it couldn't re-sweep). + match push_faces_exact(b, n, d, delta) + .and_then(|r| if r.validity().is_empty() { Ok(r) } else { Err(KernelError::Failed("pushing a face: an unsound result".into())) }) + { Ok(r) => Ok(r), // Faces of planes and cylinders: every face follows (any neighbours). Err(e) => { @@ -66,7 +69,7 @@ fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result { } } -fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result { +pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result { let size = b.size(); let tol = (size * 1e-7).max(1e-9); let fail = |m: &str| KernelError::Failed(format!("pushing a face: {m}")); diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index 227da9f5..e0b95b6c 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -68,7 +68,7 @@ impl Surf { fn grad(&self, p: Vec3) -> Option { match *self { Surf::Plane { n, .. } => Some(n), - Surf::Cylinder { convex, .. } | Surf::Sphere { convex, .. } | Surf::Cone { convex, .. } => { + Surf::Cylinder { convex, .. } | Surf::Sphere { convex, .. } | Surf::Cone { convex, .. } | Surf::Torus { convex, .. } => { self.normal(p).map(|n| if convex { n } else { -n }) } } @@ -454,7 +454,13 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul let mut new = Vec::with_capacity(faces.len()); let mut shifts = Vec::with_capacity(faces.len()); for (i, f) in faces.iter().enumerate() { - let sf = surf_of(f, tol * 100.0).ok_or_else(|| fail("only bodies with planes, cylinders, cones, spheres and tori"))?; + let sf = surf_of(f, tol * 100.0).ok_or_else(|| { + let at = samples(f).first().map(|(p, _)| format!(" (a face at [{:.2}, {:.2}, {:.2}])", p.x, p.y, p.z)).unwrap_or_default(); + if std::env::var("DBG_OFFSET").is_ok() { + eprintln!("unrecognised face: {:?}", f.surface()); + } + fail(&format!("only bodies with planes, cylinders, cones, spheres and tori{at}")) + })?; let n = match sf { Surf::Plane { n, .. } => n, Surf::Cylinder { a, .. } | Surf::Cone { a, .. } | Surf::Torus { a, .. } => a.any_perp(), diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index aab9675f..6eeab3dd 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -1230,7 +1230,8 @@ mod tests { let mut tops: Vec = s.faces(0.01).unwrap().iter().filter(|f| (f.centroid.z - 20.0).abs() < 1e-6).map(|f| f.area).collect(); tops.sort_by(f64::total_cmp); let pi = std::f64::consts::PI; - assert!(rel(tops[0], pi * 16.0) < 1e-3 && rel(tops[1], pi * 84.0) < 1e-3, "{tops:?}"); + // (Face areas come from meshes: within their sag, which depends on where seams lie.) + assert!(rel(tops[0], pi * 16.0) < 2e-3 && rel(tops[1], pi * 84.0) < 2e-3, "{tops:?}"); } /// A plane through a hole in a plate's top: the top splits in two, each piece holding diff --git a/docs/robustness.md b/docs/robustness.md new file mode 100644 index 00000000..e92d8914 --- /dev/null +++ b/docs/robustness.md @@ -0,0 +1,96 @@ +# Kernel robustness + +Real users hit edge cases the Fusion oracle (docs/oracle.md) never sees: faces that coincide +by accident, tangencies, seams lying where another body cuts, walls thinner than a shell. The +robustness runs build random but realistic designs and check every step. + +## How it runs + +``` +solvecraft-cli fuzz --from 0 --count 1000 --jobs 8 --timeout 90 --out runs/ --report report.md +solvecraft-cli fuzz-one --minimise # one design, cut down to the steps that matter +``` + +- **Designs** (`crates/engine/src/fuzz.rs`): a seeded generator starts from a box, an extruded + sketch profile closed by an arc (sometimes notched), or a revolved section (partial or full + turns, sometimes touching the axis). It then adds up to eight steps: joins and cuts of + boxes, cylinders and spheres, fillets and chamfers on random edges, shells, holes (simple, + blind, drilled), and rectangular and circular patterns of the last feature. Placements snap + to a 2.5 mm grid and often to the body's own faces, so coincident faces and tangencies come + up all the time, as they do in real parts. +- **Checks after every step:** the command succeeds; every body is a sound solid (each shell + closed and consistently oriented, every face meshes, positive finite volume: + `Body::validity`); the volume moves the right way (a cut never adds, a join never removes). +- **Boolean identity** (`crates/kernel/src/fuzz.rs`): for two random primitives on the same + grid (boxes, cylinders, spheres, tori), union, intersection and cut each give a sound solid + and V(A∪B) + V(A∩B) = V(A) + V(B), V(A−B) = V(A) − V(A∩B), within 0.2 %. +- Each seed runs in its own process with a time limit, so hangs and hard crashes are recorded + too. A step the kernel refuses as **not supported yet** (a fillet between two curved faces, + a shell thicker than a wall) is undone and counted separately, and the design goes on: those + are known limitations, not failures. Booleans that fail count as failures even when their + message says "not supported". +- `cargo test` runs a fixed set of seeds that must stay clean + (`crates/engine/src/tests/robustness.rs`), next to the minimised cases fixed so far. + +## Results + +1000 seeds (0–999), 8 processes on a heavily loaded machine (load average 60–80), 90 s per +seed. A "timeout" there is mostly a design whose booleans are very slow (seed 20 finishes in +2 min 8 s alone), not a hang. The "after" run had more of them: the machine was busier still +(test suites ran alongside), and failing booleans now try more fallbacks before giving up; +fewer designs fail outright (27.1 % against 36.3 %). Both figures are from before the last two +fixes below (sound face pushes, no panics in the boolean's final assembly). + +| Designs | before | after | +|---|---|---| +| clean | 49.8 % | 53.8 % | +| failed | 36.3 % | 27.1 % | +| invalid body | 0.7 % | 0.9 % | +| volume the wrong way | 0.2 % | 0.2 % | +| over 90 s | 13.0 % | 18.0 % | + +| Boolean identity | before | after | +|---|---|---| +| holds | 85.4 % | 80.8 % | +| a boolean failed | 1.6 % | 1.2 % | +| not run (the design ran out of time) | 13.0 % | 18.0 % | + +### Fixed + +- **Shell corners where a curved face meets another at an angle** (the biggest cause, 215 of + the 363 failed designs before). Moving a vertex took one linear step, exact only for planes; + a cylinder meeting a plane at an angle (a shallow arc side) landed off the moved surface. + Now refined by Newton steps onto the moved surfaces. +- **Shell edges that are neither lines nor arcs** (a hole's wall meeting a curved side): the + edge is rebuilt through its moved points. +- **Shells of bodies in several pieces** ("this shell is not connected"): one shell per piece. +- **Cylinder seams on the grid.** A cylinder's seam lines lay on its frame's axes, exactly + where faces placed on the same grid cut it, and booleans failed along the seam. Seams now + start at an odd angle: cylinders tangent to box faces from inside or out, and crossing + cylinders touching along a line, now work. +- **Partial revolves of profiles touching the axis** (a quarter or half turn of a rectangle + from the axis): the edge on the axis no longer sweeps a zero-area face, which every boolean + attempt failed on. +- **Booleans on bodies in several pieces** (a join that left two lumps): when the whole fails + or comes out empty, a piece at a time. +- **Pushing a face apart** (coincident faces) checks its result: the exact rebuild could leave a + cylinder unsound, and then the general offset is used. A boss standing on a plate joins again. +- **No panics in the boolean's final assembly.** Natively a panic inside the kernel is caught, + but the web build aborts on any panic; the Plastic Enclosure sample panicked 8 times building + its bosses and crashed the start page in the browser. The vendored shapeops now returns + `None` instead, and a test builds every sample with a panic counter. The runs count caught + panics per design ("Caught panics" in the report), since each one would crash the web build. + +### Known limitations (counted as "not supported", with a clear message) + +- A shell thicker than a wall or a step: the moved faces would cross and the cavity turn + inside out. Fusion fills such places solid; we need a topology-changing offset. +- Fillets and chamfers between two curved faces, or ending at faces that aren't planar. + +### Still failing + +- Partial unions of a cap and a face (a cylinder's end lying partly on a box face): pushing + one face into the other adds material where they don't overlap. +- Shells of partial annuli (a half ring) whose cavity truck's boolean rejects although the + same cavity built another way cuts cleanly. +- Very slow booleans on bodies with many patterned features. diff --git a/vendor/truck-shapeops/src/transversal/faces_classification/mod.rs b/vendor/truck-shapeops/src/transversal/faces_classification/mod.rs index aeb674aa..6d69b70d 100644 --- a/vendor/truck-shapeops/src/transversal/faces_classification/mod.rs +++ b/vendor/truck-shapeops/src/transversal/faces_classification/mod.rs @@ -26,10 +26,11 @@ impl FacesClassification { pub fn and_or_unknown(&self) -> [Shell; 3] { let [mut and, mut or, mut unknown] = <[Shell; 3]>::default(); for face in &self.shell { - match self.status.get(&face.id()).unwrap() { - ShapesOpStatus::And => and.push(face.clone()), - ShapesOpStatus::Or => or.push(face.clone()), - ShapesOpStatus::Unknown => unknown.push(face.clone()), + // SolveCraft: no panics (wasm can't catch them). + match self.status.get(&face.id()) { + Some(ShapesOpStatus::And) => and.push(face.clone()), + Some(ShapesOpStatus::Or) => or.push(face.clone()), + _ => unknown.push(face.clone()), } } [and, or, unknown] @@ -42,21 +43,19 @@ impl FacesClassification { let components = unknown.connected_components(); for comp in components { let boundary = comp.extract_boundaries(); - if and_boundary - .iter() - .flatten() - .any(|edge| edge.id() == boundary[0][0].id()) - { + let Some(first) = boundary.first().and_then(|w| w.edge_iter().next()).map(|e| e.id()) else { continue }; + let set = if and_boundary.iter().flatten().any(|edge| edge.id() == first) { + Some(ShapesOpStatus::And) + } else if or_boundary.iter().flatten().any(|edge| edge.id() == first) { + Some(ShapesOpStatus::Or) + } else { + None + }; + if let Some(st) = set { comp.iter().for_each(|face| { - *self.status.get_mut(&face.id()).unwrap() = ShapesOpStatus::And; - }) - } else if or_boundary - .iter() - .flatten() - .any(|edge| edge.id() == boundary[0][0].id()) - { - comp.iter().for_each(|face| { - *self.status.get_mut(&face.id()).unwrap() = ShapesOpStatus::Or; + if let Some(x) = self.status.get_mut(&face.id()) { + *x = st; + } }) } } diff --git a/vendor/truck-shapeops/src/transversal/integrate/mod.rs b/vendor/truck-shapeops/src/transversal/integrate/mod.rs index 405738af..83b2ce0c 100644 --- a/vendor/truck-shapeops/src/transversal/integrate/mod.rs +++ b/vendor/truck-shapeops/src/transversal/integrate/mod.rs @@ -68,7 +68,8 @@ fn process_one_pair_of_shells, S: ShapeOpsSurface>( cls1.integrate_by_component(); let [mut and0, mut or0, unknown0] = cls0.and_or_unknown(); unknown0.into_iter().try_for_each(|face| { - let pt = face.boundaries()[0].vertex_iter().next().unwrap().point(); + // SolveCraft: no panics (wasm can't catch them). + let pt = face.boundaries().first()?.vertex_iter().next()?.point(); let dir = hash::take_one_unit(pt); let count = poly_shell1.iter().try_fold(0, |count, face| { let poly = face.surface()?; @@ -83,7 +84,8 @@ fn process_one_pair_of_shells, S: ShapeOpsSurface>( })?; let [mut and1, mut or1, unknown1] = cls1.and_or_unknown(); unknown1.into_iter().try_for_each(|face| { - let pt = face.boundaries()[0].vertex_iter().next().unwrap().point(); + // SolveCraft: no panics (wasm can't catch them). + let pt = face.boundaries().first()?.vertex_iter().next()?.point(); let dir = hash::take_one_unit(pt); let count = poly_shell0.iter().try_fold(0, |count, face| { let poly = face.surface()?; @@ -109,8 +111,8 @@ pub fn and, S: ShapeOpsSurface>( ) -> Option> { let mut iter0 = solid0.boundaries().iter(); let mut iter1 = solid1.boundaries().iter(); - let shell0 = iter0.next().unwrap(); - let shell1 = iter1.next().unwrap(); + let shell0 = iter0.next()?; + let shell1 = iter1.next()?; let [mut and_shell, _] = process_one_pair_of_shells(shell0, shell1, tol)?; for shell in iter0 { let [res, _] = process_one_pair_of_shells(&and_shell, shell, tol)?; @@ -121,7 +123,7 @@ pub fn and, S: ShapeOpsSurface>( and_shell = res; } let boundaries = components_in_order(&and_shell); - Some(Solid::new(boundaries)) + Solid::try_new(boundaries).ok() } /// OR operation between two solids. @@ -132,8 +134,8 @@ pub fn or, S: ShapeOpsSurface>( ) -> Option> { let mut iter0 = solid0.boundaries().iter(); let mut iter1 = solid1.boundaries().iter(); - let shell0 = iter0.next().unwrap(); - let shell1 = iter1.next().unwrap(); + let shell0 = iter0.next()?; + let shell1 = iter1.next()?; let [_, mut or_shell] = process_one_pair_of_shells(shell0, shell1, tol)?; for shell in iter0 { let [_, res] = process_one_pair_of_shells(&or_shell, shell, tol)?; @@ -144,7 +146,7 @@ pub fn or, S: ShapeOpsSurface>( or_shell = res; } let boundaries = components_in_order(&or_shell); - Some(Solid::new(boundaries)) + Solid::try_new(boundaries).ok() } // SolveCraft: truck's connected components come out in hash order of face addresses, so the