Files
OneCAD/crates/kernel/src/fuzz.rs
T
bherbruckandClaude Opus 5.5 ccde7a4837 Robustness runs and the kernel fixes they found
- solvecraft-cli fuzz / fuzz-one: seeded random modelling sessions (boxes, extruded profiles with arcs, revolves; joins and cuts of boxes, cylinders and spheres, fillets, chamfers, shells, holes, patterns) on a grid that makes coincident faces and tangencies common, every step checked (command ok, every body a sound solid via the new Body::validity, volume moving the right way), plus the boolean volume identity on random primitives; one process per seed with a time limit; failures minimised to scripts
- shell/offset: corners where a curved face meets another at an angle are refined onto the moved surfaces (Newton); edges that are neither lines nor arcs are rebuilt through their moved points; one shell per piece; faces that would cross say so (not supported yet) instead of leaving a cavity inside out; flat revolved faces count as planes; the unhealed body is tried when the healed one fails
- cylinders start their seams at an odd angle (grid-aligned seams broke booleans along them)
- partial revolves of profiles touching the axis build no zero-area face on the axis
- booleans on bodies in several pieces retry a piece at a time
- regression tests for each, and a fixed set of seeds in cargo test

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
2026-10-10 00:04:37 +00:00

161 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Robustness checks on random inputs (`solvecraft-cli fuzz`, docs/robustness.md): a small
//! seeded random generator, and the boolean volume identity on random pairs of primitives
//! placed on a coarse grid, so coincident faces and tangencies come up often.
use serde::Serialize;
use solvecraft_geom::Vec3;
use crate::{Body, BoolOp, boolean, box_solid, cylinder, measure, sphere, torus};
/// xorshift64*: small, fast, and the same everywhere (wasm included).
#[derive(Clone, Debug)]
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Rng {
// Spread nearby seeds apart (splitmix64), never zero.
let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
Rng((z ^ (z >> 31)) | 1)
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// 0…n−1 (0 when n is 0).
pub fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
}
/// In [0, 1).
pub fn unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
pub fn chance(&mut self, p: f64) -> bool {
self.unit() < p
}
pub fn pick<T: Copy>(&mut self, xs: &[T]) -> Option<T> {
xs.get(self.below(xs.len())).copied()
}
/// A multiple of `step` in [lo, hi].
pub fn grid(&mut self, lo: f64, hi: f64, step: f64) -> f64 {
let n = ((hi - lo) / step).floor().max(0.0) as usize;
lo + step * self.below(n + 1) as f64
}
}
/// A primitive used by the boolean checks, as plain data (so a failure can be written down).
#[derive(Clone, Debug, Serialize)]
pub enum Shape {
Box { a: [f64; 3], b: [f64; 3] },
Cylinder { base: [f64; 3], axis: [f64; 3], radius: f64, height: f64 },
Sphere { center: [f64; 3], radius: f64 },
Torus { center: [f64; 3], major: f64, minor: f64 },
}
impl Shape {
pub fn body(&self) -> crate::Result<Body> {
let v = |p: &[f64; 3]| Vec3::new(p[0], p[1], p[2]);
match self {
Shape::Box { a, b } => box_solid(v(a), v(b)),
Shape::Cylinder { base, axis, radius, height } => cylinder(v(base), v(axis), *radius, *height),
Shape::Sphere { center, radius } => sphere(v(center), *radius),
Shape::Torus { center, major, minor } => torus(v(center), *major, *minor),
}
}
fn random(r: &mut Rng) -> Shape {
let g = 2.5;
let mut p = || [r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g), r.grid(0.0, 30.0, g)];
let at = p();
match r.below(10) {
0..=4 => {
let size = [r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g), r.grid(5.0, 30.0, g)];
Shape::Box { a: at, b: [at[0] + size[0], at[1] + size[1], at[2] + size[2]] }
}
5..=7 => {
let axis = r.pick(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap_or([0.0, 0.0, 1.0]);
Shape::Cylinder { base: at, axis, radius: r.grid(2.5, 10.0, g), height: r.grid(5.0, 30.0, g) }
}
8 => Shape::Sphere { center: at, radius: r.grid(2.5, 12.5, g) },
_ => {
let minor = r.grid(2.5, 5.0, g);
Shape::Torus { center: at, major: minor + r.grid(2.5, 10.0, g), minor }
}
}
}
}
/// One boolean check: the shapes, what went wrong (none when the identity holds).
#[derive(Clone, Debug, Serialize)]
pub struct BoolCase {
pub seed: u64,
pub a: Shape,
pub b: Shape,
/// "ok", or the kind of failure: "error", "invalid", "identity".
pub outcome: String,
pub detail: String,
}
fn vol(b: &Option<Body>) -> crate::Result<f64> {
match b {
Some(b) => Ok(measure(b)?.volume),
None => Ok(0.0),
}
}
/// The two primitives [`boolean_case`] uses for a seed.
pub fn boolean_shapes(seed: u64) -> (Shape, Shape) {
let mut r = Rng::new(seed);
(Shape::random(&mut r), Shape::random(&mut r))
}
/// Union, intersection and cut of two random primitives: each result is a sound solid, and
/// V(A∪B) + V(A∩B) = V(A) + V(B) and V(A−B) = V(A) − V(A∩B), within 0.2 %.
pub fn boolean_case(seed: u64) -> BoolCase {
let (a, b) = boolean_shapes(seed);
let mut case = BoolCase { seed, a: a.clone(), b: b.clone(), outcome: "ok".into(), detail: String::new() };
let fail = |c: &mut BoolCase, kind: &str, d: String| {
c.outcome = kind.into();
c.detail = d;
};
let (Ok(ba), Ok(bb)) = (a.body(), b.body()) else {
fail(&mut case, "error", "a primitive could not be built".into());
return case;
};
let mut res = Vec::new();
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
match boolean(&ba, &bb, op) {
Ok(x) => {
if let Some(body) = &x {
let bad = body.validity();
if !bad.is_empty() {
fail(&mut case, "invalid", format!("{op:?}: {}", bad.join("; ")));
return case;
}
}
res.push(x);
}
Err(e) => {
fail(&mut case, "error", format!("{op:?}: {e}"));
return case;
}
}
}
let v = |b: &Option<Body>| vol(b).unwrap_or(f64::NAN);
let (va, vb) = (v(&Some(ba)), v(&Some(bb)));
let [u, i, c] = &res[..] else { return case };
let (vu, vi, vc) = (v(u), v(i), v(c));
let tol = 2e-3 * (va + vb);
let e1 = (vu + vi - va - vb).abs();
let e2 = (vc - (va - vi)).abs();
if !(e1 <= tol && e2 <= tol) {
fail(&mut case, "identity", format!("A {va:.3}, B {vb:.3}, A∪B {vu:.3}, A∩B {vi:.3}, A−B {vc:.3}: off by {e1:.3} and {e2:.3}"));
}
case
}