ci: nightly property tests with more cases (#1498)
Thanks @Ian-cmd-ops.
This commit is contained in:
1 parent
f98c5e350d
commit
c5b7009ce3
9 files changed
+435
-9
No files matched your search
@@ -0,0 +1,220 @@
|
||||
//! 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 skip this file (.config/nextest.toml);
|
||||
//! pushes to main run it, and the nightly `property-tests` job uses 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.
|
||||
|
||||
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);
|
||||
ProptestConfig { cases, max_shrink_iters: 200, ..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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user