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:
1 parent
ccde7a4837
commit
1bf377c14b
8 files changed
+182
-33
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user