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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-10 00:04:37 +00:00
1 parent ccde7a4837
commit 1bf377c14b
8 files changed
+182 -33

No files matched your search

+12 -1
View File
@@ -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 <seed> [--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::<Vec<_>>(),
"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<String, usize> = BTreeMap::new();
let mut unsupported: BTreeMap<String, usize> = 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));
+31
View File
@@ -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() {
+6 -3
View File
@@ -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<Body> {
match push_faces_exact(b, n, d, delta) {
pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
// 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<Body> {
}
}
fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
let size = b.size();
let tol = (size * 1e-7).max(1e-9);
let fail = |m: &str| KernelError::Failed(format!("pushing a face: {m}"));
+8 -2
View File
@@ -68,7 +68,7 @@ impl Surf {
fn grad(&self, p: Vec3) -> Option<Vec3> {
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(),
+2 -1
View File
@@ -1230,7 +1230,8 @@ mod tests {
let mut tops: Vec<f64> = 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
+96
View File
@@ -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 <seed> --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.
@@ -26,10 +26,11 @@ impl<P, C, S> FacesClassification<P, C, S> {
pub fn and_or_unknown(&self) -> [Shell<P, C, S>; 3] {
let [mut and, mut or, mut unknown] = <[Shell<P, C, S>; 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<P, C, S> FacesClassification<P, C, S> {
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;
}
})
}
}
+10 -8
View File
@@ -68,7 +68,8 @@ fn process_one_pair_of_shells<C: ShapeOpsCurve<S>, 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<C: ShapeOpsCurve<S>, 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<C: ShapeOpsCurve<S>, S: ShapeOpsSurface>(
) -> Option<Solid<Point3, C, S>> {
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<C: ShapeOpsCurve<S>, 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<C: ShapeOpsCurve<S>, S: ShapeOpsSurface>(
) -> Option<Solid<Point3, C, S>> {
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<C: ShapeOpsCurve<S>, 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