Pushes to main skip boolean_props (random inputs); the nightly property-tests job runs it. The three shrunk cases are ignored tests with their issues (#1647, #1648, #1649), and proptest's failure file is named outright. Co-Authored-By: Claude Opus 5.5 <[email protected]>
273 lines
13 KiB
Rust
273 lines
13 KiB
Rust
//! Property tests for the kernel boolean (`boolean`, `measure`, `transform`).
|
||
//!
|
||
//! Each test states one invariant that every correct B-rep boolean holds. proptest picks the
|
||
//! inputs, and when a case fails it shrinks the inputs to a small case that still fails. Paste
|
||
//! that case into a normal `#[test]` as a regression test.
|
||
//!
|
||
//! Case count: `PROPTEST_CASES` (default 8). Pull requests and pushes to main skip this file
|
||
//! (.config/nextest.toml, .github/workflows/ci.yml): its random inputs differ on every run, so a
|
||
//! known kernel bug would turn main red at random. The nightly `property-tests` job runs it with
|
||
//! more cases. A long local run:
|
||
//! `PROPTEST_CASES=2000 cargo test -p solidcraft-kernel --test boolean_props`.
|
||
//! proptest saves every failing seed in `tests/boolean_props.proptest-regressions`. Commit it, and
|
||
//! copy the shrunk case into a `#[test]` below (ignored, with its issue, until the kernel is fixed).
|
||
|
||
use proptest::prelude::*;
|
||
use solidcraft_geom::Vec3;
|
||
use solidcraft_kernel::{Body, BoolOp, boolean, box_solid, cylinder, measure, sphere, transform};
|
||
|
||
/// Relative volume tolerance. The same 0.2 % that `fuzz::boolean_case` uses.
|
||
const REL: f64 = 2e-3;
|
||
|
||
#[derive(Clone, Debug)]
|
||
enum Prim {
|
||
Box { at: [f64; 3], size: [f64; 3] },
|
||
Cyl { at: [f64; 3], axis: usize, r: f64, h: f64 },
|
||
Sph { at: [f64; 3], r: f64 },
|
||
}
|
||
|
||
impl Prim {
|
||
fn body(&self) -> Body {
|
||
let v = |p: [f64; 3]| Vec3::new(p[0], p[1], p[2]);
|
||
let axes = [Vec3::X, Vec3::Y, Vec3::Z];
|
||
match *self {
|
||
Prim::Box { at, size } => box_solid(v(at), v([at[0] + size[0], at[1] + size[1], at[2] + size[2]])),
|
||
Prim::Cyl { at, axis, r, h } => cylinder(v(at), axes[axis], r, h),
|
||
Prim::Sph { at, r } => sphere(v(at), r),
|
||
}
|
||
.expect("a primitive with positive size must build")
|
||
}
|
||
}
|
||
|
||
/// Coordinates on a 2.5 mm grid: coincident faces, shared edges and tangencies come up often.
|
||
/// These are the cases that break B-rep booleans.
|
||
fn grid() -> impl Strategy<Value = f64> + Clone {
|
||
(0u32..=12).prop_map(|i| i as f64 * 2.5)
|
||
}
|
||
/// Size on the grid, never zero.
|
||
fn grid_size() -> impl Strategy<Value = f64> + Clone {
|
||
(2u32..=12).prop_map(|i| i as f64 * 2.5)
|
||
}
|
||
/// Generic coordinates: almost never coincident. A failure here is a plain bug, not an edge case.
|
||
fn free() -> impl Strategy<Value = f64> + Clone {
|
||
0.0f64..30.0
|
||
}
|
||
fn free_size() -> impl Strategy<Value = f64> + Clone {
|
||
3.0f64..30.0
|
||
}
|
||
|
||
fn prim(c: impl Strategy<Value = f64> + Clone, s: impl Strategy<Value = f64> + Clone) -> impl Strategy<Value = Prim> {
|
||
let p = [c.clone(), c.clone(), c];
|
||
prop_oneof![
|
||
5 => (p.clone(), [s.clone(), s.clone(), s.clone()]).prop_map(|(at, size)| Prim::Box { at, size }),
|
||
3 => (p.clone(), 0usize..3, s.clone(), s.clone()).prop_map(|(at, axis, r, h)| Prim::Cyl { at, axis, r: r / 2.0, h }),
|
||
2 => (p, s).prop_map(|(at, r)| Prim::Sph { at, r: r / 2.0 }),
|
||
]
|
||
}
|
||
fn grid_prim() -> impl Strategy<Value = Prim> {
|
||
prim(grid(), grid_size())
|
||
}
|
||
fn free_prim() -> impl Strategy<Value = Prim> {
|
||
prim(free(), free_size())
|
||
}
|
||
|
||
/// Volume of a boolean result (an empty result has volume 0). Also fails the case when the
|
||
/// result is not a sound solid.
|
||
fn run(a: &Body, b: &Body, op: BoolOp) -> Result<f64, TestCaseError> {
|
||
let r = boolean(a, b, op).map_err(|e| TestCaseError::fail(format!("{op:?} returned an error: {e}")))?;
|
||
match r {
|
||
None => Ok(0.0),
|
||
Some(body) => {
|
||
let bad = body.validity();
|
||
prop_assert!(bad.is_empty(), "{op:?} result is not a sound solid: {}", bad.join("; "));
|
||
Ok(measure(&body).map_err(|e| TestCaseError::fail(format!("measure: {e}")))?.volume)
|
||
}
|
||
}
|
||
}
|
||
fn vol(b: &Body) -> f64 {
|
||
measure(b).expect("measure a primitive").volume
|
||
}
|
||
fn close(x: f64, y: f64, scale: f64) -> bool {
|
||
(x - y).abs() <= REL * scale.max(1e-9)
|
||
}
|
||
|
||
/// Inclusion-exclusion: V(A∪B) + V(A∩B) = V(A) + V(B), and V(A−B) = V(A) − V(A∩B).
|
||
fn identity(a: &Prim, b: &Prim) -> Result<(), TestCaseError> {
|
||
let (ba, bb) = (a.body(), b.body());
|
||
let (va, vb) = (vol(&ba), vol(&bb));
|
||
let (vu, vi, vc) = (run(&ba, &bb, BoolOp::Union)?, run(&ba, &bb, BoolOp::Intersect)?, run(&ba, &bb, BoolOp::Cut)?);
|
||
prop_assert!(close(vu + vi, va + vb, va + vb), "union+intersect {} != A+B {} (A {va:.3}, B {vb:.3}, U {vu:.3}, I {vi:.3})", vu + vi, va + vb);
|
||
prop_assert!(close(vc, va - vi, va + vb), "cut {vc:.3} != A-I {:.3}", va - vi);
|
||
Ok(())
|
||
}
|
||
|
||
fn cfg(default: u32) -> ProptestConfig {
|
||
// The env var overrides the default; proptest reads PROPTEST_CASES itself only when the
|
||
// config does not set `cases`, so read it here.
|
||
let cases = std::env::var("PROPTEST_CASES").ok().and_then(|s| s.parse().ok()).unwrap_or(default);
|
||
// Integration tests have no lib.rs or main.rs beside them, so proptest's default persistence
|
||
// cannot place its file; name it.
|
||
let file = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/boolean_props.proptest-regressions");
|
||
ProptestConfig {
|
||
cases,
|
||
max_shrink_iters: 200,
|
||
failure_persistence: Some(Box::new(prop::test_runner::FileFailurePersistence::Direct(file))),
|
||
..ProptestConfig::default()
|
||
}
|
||
}
|
||
|
||
proptest! {
|
||
#![proptest_config(cfg(8))]
|
||
|
||
/// Generic placement: these must all pass. A failure is a real bug.
|
||
#[test]
|
||
fn identity_generic(a in free_prim(), b in free_prim()) {
|
||
identity(&a, &b)?;
|
||
}
|
||
|
||
/// Grid placement: coincident and tangent faces. The hard cases.
|
||
#[test]
|
||
fn identity_on_grid(a in grid_prim(), b in grid_prim()) {
|
||
identity(&a, &b)?;
|
||
}
|
||
|
||
/// Union and intersection do not depend on operand order.
|
||
#[test]
|
||
fn commutative(a in grid_prim(), b in grid_prim()) {
|
||
let (ba, bb) = (a.body(), b.body());
|
||
let s = vol(&ba) + vol(&bb);
|
||
for op in [BoolOp::Union, BoolOp::Intersect] {
|
||
let (ab, ba_) = (run(&ba, &bb, op)?, run(&bb, &ba, op)?);
|
||
prop_assert!(close(ab, ba_, s), "{op:?}: A,B gives {ab:.4} but B,A gives {ba_:.4}");
|
||
}
|
||
}
|
||
|
||
/// Moving both operands together does not change the answer. A large offset exposes
|
||
/// absolute tolerances (1e-6, 1e-9 literals) that should scale with the model.
|
||
#[test]
|
||
fn translation_invariant(a in grid_prim(), b in grid_prim(), off in prop::sample::select(vec![0.0, 1.0e3, 1.0e5])) {
|
||
let (ba, bb) = (a.body(), b.body());
|
||
let t = Vec3::new(off, -off * 0.7, off * 0.3);
|
||
let mv = |x: &Body| transform(x, t, Vec3::ZERO, Vec3::Z, 0.0).expect("translate");
|
||
let (ta, tb) = (mv(&ba), mv(&bb));
|
||
let s = vol(&ba) + vol(&bb);
|
||
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
|
||
let (here, there) = (run(&ba, &bb, op)?, run(&ta, &tb, op)?);
|
||
prop_assert!(close(here, there, s), "{op:?} at origin {here:.4}, moved by {off} gives {there:.4}");
|
||
}
|
||
}
|
||
|
||
/// Scaling the whole problem scales volume by k³. Small parts (0.05x) and large parts
|
||
/// (40x) expose tolerances that do not scale.
|
||
#[test]
|
||
fn scale_invariant(a in grid_prim(), b in grid_prim(), k in prop::sample::select(vec![0.05, 1.0, 40.0])) {
|
||
let sc = |p: &Prim| match p.clone() {
|
||
Prim::Box { at, size } => Prim::Box { at: at.map(|x| x * k), size: size.map(|x| x * k) },
|
||
Prim::Cyl { at, axis, r, h } => Prim::Cyl { at: at.map(|x| x * k), axis, r: r * k, h: h * k },
|
||
Prim::Sph { at, r } => Prim::Sph { at: at.map(|x| x * k), r: r * k },
|
||
};
|
||
let (ba, bb, ka, kb) = (a.body(), b.body(), sc(&a).body(), sc(&b).body());
|
||
let s = vol(&ba) + vol(&bb);
|
||
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
|
||
let (one, big) = (run(&ba, &bb, op)?, run(&ka, &kb, op)? / (k * k * k));
|
||
prop_assert!(close(one, big, s), "{op:?} at scale 1 gives {one:.4}, at scale {k} gives {big:.4} (normalised)");
|
||
}
|
||
}
|
||
|
||
/// A∪A = A, A∩A = A, A−A = nothing.
|
||
#[test]
|
||
fn self_operations(a in grid_prim()) {
|
||
let ba = a.body();
|
||
let va = vol(&ba);
|
||
prop_assert!(close(run(&ba, &ba, BoolOp::Union)?, va, va), "A∪A != A");
|
||
prop_assert!(close(run(&ba, &ba, BoolOp::Intersect)?, va, va), "A∩A != A");
|
||
prop_assert!(run(&ba, &ba, BoolOp::Cut)? <= REL * va, "A−A is not empty");
|
||
}
|
||
|
||
/// The same inputs give the same output, twice in a row. The boolean keeps thread-local
|
||
/// state (the nudge guard); this catches state that leaks from one call to the next.
|
||
#[test]
|
||
fn deterministic(a in grid_prim(), b in grid_prim()) {
|
||
let (ba, bb) = (a.body(), b.body());
|
||
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Cut] {
|
||
let m = |r: Option<Body>| r.map(|x| { let m = measure(&x).unwrap(); (m.volume.to_bits(), m.faces, m.edges) });
|
||
let first = boolean(&ba, &bb, op).ok().map(m);
|
||
let second = boolean(&ba, &bb, op).ok().map(m);
|
||
prop_assert_eq!(first, second, "{:?} gave two different answers for the same input", op);
|
||
}
|
||
}
|
||
|
||
/// Two boxes whose overlap is known exactly (closed form, no tessellation guesswork).
|
||
#[test]
|
||
fn box_box_exact(a in [grid(), grid(), grid()], sa in [grid_size(), grid_size(), grid_size()],
|
||
b in [grid(), grid(), grid()], sb in [grid_size(), grid_size(), grid_size()]) {
|
||
let overlap: f64 = (0..3).map(|i| ((a[i] + sa[i]).min(b[i] + sb[i]) - a[i].max(b[i])).max(0.0)).product();
|
||
let (pa, pb) = (Prim::Box { at: a, size: sa }, Prim::Box { at: b, size: sb });
|
||
let (ba, bb) = (pa.body(), pb.body());
|
||
let (va, vb) = (sa.iter().product::<f64>(), sb.iter().product::<f64>());
|
||
let s = va + vb;
|
||
// Per axis: the gap between the boxes (<0 overlap, 0 touch, >0 apart).
|
||
let gap: Vec<f64> = (0..3).map(|i| a[i].max(b[i]) - (a[i] + sa[i]).min(b[i] + sb[i])).collect();
|
||
// Touching along an edge or at a corner only: a union there is not a manifold solid.
|
||
// The kernel refuses it on purpose ("zero-thickness edge contact"). Allowed, as an error.
|
||
let edge_only = gap.iter().all(|g| *g <= 0.0) && gap.iter().filter(|g| **g == 0.0).count() >= 2;
|
||
prop_assert!(close(run(&ba, &bb, BoolOp::Intersect)?, overlap, s), "intersect != closed-form overlap {overlap}");
|
||
prop_assert!(close(run(&ba, &bb, BoolOp::Cut)?, va - overlap, s), "cut != closed form {}", va - overlap);
|
||
match run(&ba, &bb, BoolOp::Union) {
|
||
Err(_) if edge_only => {}
|
||
r => prop_assert!(close(r?, s - overlap, s), "union != closed form {}", s - overlap),
|
||
}
|
||
}
|
||
|
||
/// Two boxes that share part of a face must fuse into one solid. This is the most common
|
||
/// join in real parts (a boss on a plate).
|
||
#[test]
|
||
fn face_touching_boxes_fuse(x in grid(), y in grid(), w in grid_size(), d in grid_size(), h in grid_size()) {
|
||
let plate = Prim::Box { at: [0.0, 0.0, 0.0], size: [30.0, 30.0, 5.0] }.body();
|
||
let boss = Prim::Box { at: [x.min(27.5), y.min(27.5), 5.0], size: [w, d, h] }.body();
|
||
let (vp, vb) = (vol(&plate), vol(&boss));
|
||
prop_assert!(close(run(&plate, &boss, BoolOp::Union)?, vp + vb, vp + vb), "plate + boss lost or gained volume");
|
||
}
|
||
}
|
||
|
||
// Cases the property tests found, as plain tests. Ignored until the kernel bug is fixed: run them
|
||
// with `cargo test -p solidcraft-kernel --test boolean_props -- --ignored`.
|
||
|
||
fn check(r: Result<(), TestCaseError>) {
|
||
if let Err(e) = r {
|
||
panic!("{e}");
|
||
}
|
||
}
|
||
|
||
/// A box and a cylinder in general position: the intersect is refused as an implausible volume.
|
||
#[test]
|
||
#[ignore = "kernel bug, #1647"]
|
||
fn generic_box_cylinder_intersect() {
|
||
let a = Prim::Box { at: [11.483767761089219, 4.801612107504224, 3.6313476467611787], size: [4.789088988834864, 3.0, 10.419335961063835] };
|
||
let b = Prim::Cyl { at: [29.2480632732879, 8.3683289461485, 6.947922225984966], axis: 2, r: 13.502884497756853, h: 19.719218725477496 };
|
||
check(identity(&a, &b));
|
||
}
|
||
|
||
/// A sphere and a box on the grid: the union has a face that does not mesh.
|
||
#[test]
|
||
#[ignore = "kernel bug, #1648"]
|
||
fn grid_sphere_box_union_is_sound() {
|
||
let a = Prim::Sph { at: [2.5, 15.0, 27.5], r: 12.5 };
|
||
let b = Prim::Box { at: [0.0, 0.0, 17.5], size: [5.0, 7.5, 5.0] };
|
||
check(identity(&a, &b));
|
||
}
|
||
|
||
/// Two crossing cylinders: the union moved 1000 mm away loses about a tenth of its volume.
|
||
#[test]
|
||
#[ignore = "kernel bug, #1649"]
|
||
fn crossing_cylinders_union_moved_far() {
|
||
let a = Prim::Cyl { at: [17.5, 30.0, 7.5], axis: 0, r: 5.0, h: 10.0 }.body();
|
||
let b = Prim::Cyl { at: [17.5, 30.0, 15.0], axis: 1, r: 12.5, h: 5.0 }.body();
|
||
let t = Vec3::new(1000.0, -700.0, 300.0);
|
||
let mv = |x: &Body| transform(x, t, Vec3::ZERO, Vec3::Z, 0.0).expect("translate");
|
||
let s = vol(&a) + vol(&b);
|
||
let here = run(&a, &b, BoolOp::Union).unwrap();
|
||
let there = run(&mv(&a), &mv(&b), BoolOp::Union).unwrap();
|
||
assert!(close(here, there, s), "union at origin {here:.4}, moved by 1000 gives {there:.4}");
|
||
}
|