manifold-rust 0.13.1

Pure Rust port of the Manifold 3D geometry library
Documentation
// robust/cross_validation_tests.rs — Phase 7 validation battery: on MANIFOLD
// inputs the robust engine must near-exactly reproduce the exact engine
// (the user-specified acceptance criterion for this feature):
//   - |Δvolume| and |Δarea| ≤ 1e-9 (relative),
//   - identical genus and manifoldness,
//   - vertex and triangle counts equal (triangulation itself may differ),
//   - bidirectional vertex-position match within 1e-9·bbox.scale().
// Pairs are drawn from primitive shapes under seeded pseudo-random rigid
// transforms (generic position — tangential configurations are covered by
// dedicated tests elsewhere).

use crate::linalg::Vec3;
use crate::manifold::Manifold;
use crate::types::{BooleanEngine, Error, OpType};

fn v(x: f64, y: f64, z: f64) -> Vec3 {
    Vec3::new(x, y, z)
}

struct Lcg(u64);

impl Lcg {
    fn next(&mut self) -> u64 {
        self.0 = self
            .0
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        self.0
    }
    fn uniform(&mut self, lo: f64, hi: f64) -> f64 {
        let u = (self.next() >> 11) as f64 / (1u64 << 53) as f64;
        lo + u * (hi - lo)
    }
}

fn shapes() -> Vec<(&'static str, Manifold)> {
    vec![
        ("cube", Manifold::cube(v(2.0, 2.0, 2.0), true)),
        ("sphere8", Manifold::sphere(1.2, 8)),
        ("sphere12", Manifold::sphere(1.0, 12)),
        ("cylinder", Manifold::cylinder_centered(2.0, 0.9, 0.9, 10, true)),
        ("cone", Manifold::cylinder_centered(2.0, 1.1, 0.3, 8, true)),
        ("tetra", Manifold::tetrahedron().scale(v(1.5, 1.5, 1.5))),
    ]
}

/// Random rigid transform in generic position.
fn jiggle(m: &Manifold, rng: &mut Lcg) -> Manifold {
    m.rotate(
        rng.uniform(5.0, 85.0),
        rng.uniform(5.0, 85.0),
        rng.uniform(5.0, 85.0),
    )
    .translate(v(
        rng.uniform(-0.7, 0.7),
        rng.uniform(-0.7, 0.7),
        rng.uniform(-0.7, 0.7),
    ))
}

/// Bidirectional vertex match: every vertex of `a` has a vertex of `b`
/// within `tol` and vice versa. Returns the number of unmatched vertices.
fn unmatched_verts(a: &Manifold, b: &Manifold, tol: f64) -> usize {
    let av = &a.as_impl().vert_pos;
    let bv = &b.as_impl().vert_pos;
    let close = |p: Vec3, q: Vec3| {
        (p.x - q.x).abs() <= tol && (p.y - q.y).abs() <= tol && (p.z - q.z).abs() <= tol
    };
    let mut misses = 0;
    for &p in av {
        if !bv.iter().any(|&q| close(p, q)) {
            misses += 1;
        }
    }
    for &q in bv {
        if !av.iter().any(|&p| close(p, q)) {
            misses += 1;
        }
    }
    misses
}

fn cross_validate(a: &Manifold, b: &Manifold, what: &str) {
    for op in [OpType::Add, OpType::Subtract, OpType::Intersect] {
        let ctx = format!("{what} {op:?}");
        // The exact engine can assert on rare rotated configurations (the
        // pair_up "non-manifold edge" panic, a known C++-inherited failure
        // class — see PORTING_PLAN.md). When it cannot produce a reference,
        // sanity-check the robust result alone instead of comparing; the
        // robust engine existing is the fix for those inputs, not the bug.
        let exact = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            a.boolean_with_engine(b, op, BooleanEngine::Exact)
        })) {
            Ok(m) => m,
            Err(_) => {
                eprintln!("{ctx}: exact engine panicked; robust-only sanity check");
                let robust = a.boolean_with_engine(b, op, BooleanEngine::Robust);
                assert_eq!(robust.status(), Error::NoError, "{ctx}: robust status");
                assert!(robust.volume() >= 0.0, "{ctx}: robust volume sane");
                continue;
            }
        };
        let robust = a.boolean_with_engine(b, op, BooleanEngine::Robust);

        assert_eq!(exact.status(), Error::NoError, "{ctx}: exact status");
        assert_eq!(robust.status(), Error::NoError, "{ctx}: robust status");
        assert!(!robust.as_impl().is_soup, "{ctx}: robust output not manifold");

        let ve = exact.volume();
        let vr = robust.volume();
        assert!(
            (vr - ve).abs() <= 1e-9 * ve.abs().max(1.0),
            "{ctx}: volume {vr} vs {ve}"
        );
        let ae = exact.surface_area();
        let ar = robust.surface_area();
        assert!(
            (ar - ae).abs() <= 1e-9 * ae.abs().max(1.0),
            "{ctx}: area {ar} vs {ae}"
        );
        assert_eq!(robust.genus(), exact.genus(), "{ctx}: genus");

        assert_eq!(
            robust.num_vert(),
            exact.num_vert(),
            "{ctx}: vertex count {} vs {}",
            robust.num_vert(),
            exact.num_vert()
        );
        assert_eq!(
            robust.num_tri(),
            exact.num_tri(),
            "{ctx}: triangle count {} vs {}",
            robust.num_tri(),
            exact.num_tri()
        );

        let tol = 1e-9 * exact.bounding_box().scale().max(1.0);
        let misses = unmatched_verts(&robust, &exact, tol);
        assert_eq!(misses, 0, "{ctx}: {misses} unmatched vertices");
    }
}

#[test]
fn battery_cube_pairs() {
    let mut rng = Lcg(0xC0FFEE);
    let cube = Manifold::cube(v(2.0, 2.0, 2.0), true);
    for i in 0..4 {
        let b = jiggle(&cube, &mut rng);
        cross_validate(&cube, &b, &format!("cube/jiggled-cube #{i}"));
    }
}

#[test]
fn battery_mixed_pairs() {
    let mut rng = Lcg(0xBADC0DE);
    let shapes = shapes();
    // A deterministic selection of cross-shape pairs.
    let picks = [(0usize, 3usize), (1, 0), (2, 4), (3, 5), (4, 1), (5, 2)];
    for (i, (sa, sb)) in picks.iter().enumerate() {
        let (name_a, a) = &shapes[*sa];
        let (name_b, b) = &shapes[*sb];
        let b = jiggle(b, &mut rng);
        cross_validate(a, &b, &format!("{name_a}/{name_b} #{i}"));
    }
}

/// Extended battery — more pairs, bigger meshes. Slow with debug-build
/// exact rational arithmetic; run explicitly:
/// `cargo test --release battery_extended -- --ignored`
#[test]
#[ignore = "extended battery: run in release, see comment"]
fn battery_extended() {
    let mut rng = Lcg(0x5EED);
    let mut shapes = shapes();
    shapes.push(("sphere20", Manifold::sphere(1.1, 20)));
    shapes.push(("cyl16", Manifold::cylinder_centered(2.4, 1.0, 1.0, 16, true)));
    let n = shapes.len();
    for i in 0..30 {
        let sa = (rng.next() % n as u64) as usize;
        let sb = (rng.next() % n as u64) as usize;
        let (name_a, a) = &shapes[sa];
        let (name_b, b) = &shapes[sb];
        let a = jiggle(a, &mut rng);
        let b = jiggle(b, &mut rng);
        cross_validate(&a, &b, &format!("{name_a}/{name_b} ext#{i}"));
    }
}