UI-15: project_lifecycle (create/open/edit/save/restart/switch/undo with A/B isolation and fail-closed corrupt/future/legacy cases), script_limits (adversarial corpus), bvh_differential (randomized brute-force comparison with tie discipline), export_interop (queue discipline, STL/DXF/GLB semantics, grid termination), runtime_ui (full lifecycle plus desktop/mobile budgets and soak). cad.yml: CORE canonical-module gates, UI session-module gates (switch/rebuild/journal/sandbox/ai/cache/BVH/coords/capture/exports), integration-lane ownership notes; lanes otherwise unchanged.
264 lines
8.1 KiB
Rust
264 lines
8.1 KiB
Rust
//! UI-15 `bvh_differential` — randomized differential raycasts compare
|
|
//! BVH nearest hit with brute force across empty, degenerate,
|
|
//! overlapping, transformed, huge, and non-finite-rejected scenes.
|
|
//!
|
|
//! Structural validator checks every generated tree. Mutation and
|
|
//! project-switch cases reject stale BVHs (rebuilt per revision).
|
|
|
|
use cad_ui::bvh::{Bvh, BvhPickOptions, BvhRay};
|
|
use cad_ui::makepad_csg::{TriMesh, Vec3d as CsgVec3};
|
|
use cad_ui::math::DVec3;
|
|
use makepad_widgets::Mat4f;
|
|
|
|
// Simple deterministic PRNG (xorshift64*) — no dev-dependency needed.
|
|
struct Rng(u64);
|
|
|
|
impl Rng {
|
|
fn next(&mut self) -> u64 {
|
|
let mut x = self.0;
|
|
x ^= x >> 12;
|
|
x ^= x << 25;
|
|
x ^= x >> 27;
|
|
self.0 = x;
|
|
x.wrapping_mul(0x2545F4914F6CDD1D)
|
|
}
|
|
|
|
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
|
lo + (self.next() as f64 / u64::MAX as f64) * (hi - lo)
|
|
}
|
|
}
|
|
|
|
fn tri_mesh_from_boxes(n: usize, rng: &mut Rng) -> Vec<(u64, TriMesh, Mat4f)> {
|
|
let mut out = Vec::new();
|
|
for i in 0..n {
|
|
let (cx, cy, cz) = (
|
|
rng.range(-10.0, 10.0),
|
|
rng.range(-10.0, 10.0),
|
|
rng.range(-10.0, 10.0),
|
|
);
|
|
let s = rng.range(0.5, 2.0);
|
|
let v = vec![
|
|
CsgVec3 {
|
|
x: cx - s,
|
|
y: cy - s,
|
|
z: cz - s,
|
|
},
|
|
CsgVec3 {
|
|
x: cx + s,
|
|
y: cy - s,
|
|
z: cz - s,
|
|
},
|
|
CsgVec3 {
|
|
x: cx + s,
|
|
y: cy + s,
|
|
z: cz - s,
|
|
},
|
|
CsgVec3 {
|
|
x: cx - s,
|
|
y: cy + s,
|
|
z: cz - s,
|
|
},
|
|
CsgVec3 {
|
|
x: cx - s,
|
|
y: cy - s,
|
|
z: cz + s,
|
|
},
|
|
CsgVec3 {
|
|
x: cx + s,
|
|
y: cy - s,
|
|
z: cz + s,
|
|
},
|
|
CsgVec3 {
|
|
x: cx + s,
|
|
y: cy + s,
|
|
z: cz + s,
|
|
},
|
|
CsgVec3 {
|
|
x: cx - s,
|
|
y: cy + s,
|
|
z: cz + s,
|
|
},
|
|
];
|
|
let triangles = vec![
|
|
[0, 1, 2],
|
|
[0, 2, 3],
|
|
[4, 6, 5],
|
|
[4, 7, 6],
|
|
[0, 4, 5],
|
|
[0, 5, 1],
|
|
[2, 6, 7],
|
|
[2, 7, 3],
|
|
[0, 3, 7],
|
|
[0, 7, 4],
|
|
[1, 5, 6],
|
|
[1, 6, 2],
|
|
];
|
|
out.push((
|
|
i as u64 + 1,
|
|
TriMesh {
|
|
vertices: v,
|
|
triangles,
|
|
},
|
|
Mat4f::identity(),
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn to_dvec(p: CsgVec3) -> DVec3 {
|
|
DVec3 {
|
|
x: p.x,
|
|
y: p.y,
|
|
z: p.z,
|
|
}
|
|
}
|
|
|
|
fn brute_force(meshes: &[(u64, TriMesh, Mat4f)], ray: &BvhRay) -> Option<(u64, f64)> {
|
|
let mut best: Option<(u64, f64)> = None;
|
|
for (id, mesh, _model) in meshes {
|
|
for (ti, tri) in mesh.triangles.iter().enumerate() {
|
|
let (a, b, c) = (
|
|
to_dvec(mesh.vertices[tri[0] as usize]),
|
|
to_dvec(mesh.vertices[tri[1] as usize]),
|
|
to_dvec(mesh.vertices[tri[2] as usize]),
|
|
);
|
|
// Inline Moller-Trumbore (same contract as the BVH leaf test).
|
|
let e1 = DVec3 {
|
|
x: b.x - a.x,
|
|
y: b.y - a.y,
|
|
z: b.z - a.z,
|
|
};
|
|
let e2 = DVec3 {
|
|
x: c.x - a.x,
|
|
y: c.y - a.y,
|
|
z: c.z - a.z,
|
|
};
|
|
let h = DVec3 {
|
|
x: ray.dir.y * e2.z - ray.dir.z * e2.y,
|
|
y: ray.dir.z * e2.x - ray.dir.x * e2.z,
|
|
z: ray.dir.x * e2.y - ray.dir.y * e2.x,
|
|
};
|
|
let det = e1.x * h.x + e1.y * h.y + e1.z * h.z;
|
|
if det.abs() < 1e-12 {
|
|
continue;
|
|
}
|
|
let f = 1.0 / det;
|
|
let s = DVec3 {
|
|
x: ray.origin.x - a.x,
|
|
y: ray.origin.y - a.y,
|
|
z: ray.origin.z - a.z,
|
|
};
|
|
let u = f * (s.x * h.x + s.y * h.y + s.z * h.z);
|
|
if !(0.0..=1.0).contains(&u) {
|
|
continue;
|
|
}
|
|
let q = DVec3 {
|
|
x: s.y * e1.z - s.z * e1.y,
|
|
y: s.z * e1.x - s.x * e1.z,
|
|
z: s.x * e1.y - s.y * e1.x,
|
|
};
|
|
let v = f * (ray.dir.x * q.x + ray.dir.y * q.y + ray.dir.z * q.z);
|
|
if !(0.0..=1.0).contains(&v) || u + v > 1.0 {
|
|
continue;
|
|
}
|
|
let t = f * (e2.x * q.x + e2.y * q.y + e2.z * q.z);
|
|
if t > 1e-9 && best.map(|(_, bt)| t < bt).unwrap_or(true) {
|
|
best = Some((*id, t));
|
|
}
|
|
let _ = ti;
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
#[test]
|
|
fn randomized_differential_matches_brute_force() {
|
|
let mut rng = Rng(0x12345678);
|
|
for round in 0..25 {
|
|
let n = 1 + (rng.next() % 6) as usize;
|
|
let meshes = tri_mesh_from_boxes(n, &mut rng);
|
|
let refs: Vec<(u64, &TriMesh, &Mat4f)> =
|
|
meshes.iter().map(|(id, m, t)| (*id, m, t)).collect();
|
|
let bvh = Bvh::build(&refs);
|
|
bvh.validate()
|
|
.unwrap_or_else(|e| panic!("round {round}: {e}"));
|
|
for _ in 0..8 {
|
|
let ray = BvhRay::new(
|
|
DVec3 {
|
|
x: rng.range(-15.0, 15.0),
|
|
y: rng.range(-15.0, 15.0),
|
|
z: rng.range(-15.0, 15.0),
|
|
},
|
|
DVec3 {
|
|
x: rng.range(-1.0, 1.0),
|
|
y: rng.range(-1.0, 1.0),
|
|
z: rng.range(-1.0, 1.0),
|
|
},
|
|
);
|
|
// Skip degenerate (near-zero) directions.
|
|
let len =
|
|
(ray.dir.x * ray.dir.x + ray.dir.y * ray.dir.y + ray.dir.z * ray.dir.z).sqrt();
|
|
if len < 1e-6 {
|
|
continue;
|
|
}
|
|
let triangle_at = |node_id: u64, tri_idx: u32| {
|
|
let (_, m, _) = meshes.iter().find(|(id, _, _)| *id == node_id).unwrap();
|
|
let t = m.triangles[tri_idx as usize];
|
|
(
|
|
to_dvec(m.vertices[t[0] as usize]),
|
|
to_dvec(m.vertices[t[1] as usize]),
|
|
to_dvec(m.vertices[t[2] as usize]),
|
|
)
|
|
};
|
|
let got = bvh.raycast(&ray, &BvhPickOptions::default(), triangle_at);
|
|
let want = brute_force(&meshes, &ray);
|
|
match (got, want) {
|
|
(None, None) => {}
|
|
(Some(g), Some((id, t))) => {
|
|
// Distance first: a farther hit is always a real bug.
|
|
assert!(
|
|
(g.t - t).abs() < 1e-6,
|
|
"round {round}: BVH distance {} != brute force {t}",
|
|
g.t
|
|
);
|
|
// Identity follows except on exact ties (random boxes
|
|
// may overlap with coplanar faces): equal distance
|
|
// means both hits are nearest, so either is correct.
|
|
if (g.t - t).abs() >= 1e-9 {
|
|
assert_eq!(g.node_id, id, "round {round}: non-tied identity");
|
|
}
|
|
}
|
|
(g, w) => panic!("round {round}: BVH {g:?} vs brute {w:?}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_degenerate_and_huge_scenes() {
|
|
// Empty.
|
|
let bvh = Bvh::build(&[]);
|
|
bvh.validate().unwrap();
|
|
// Degenerate (all triangles collapsed to a point).
|
|
let v = vec![
|
|
CsgVec3 {
|
|
x: 1.0,
|
|
y: 1.0,
|
|
z: 1.0
|
|
};
|
|
3
|
|
];
|
|
let mesh = TriMesh {
|
|
vertices: v,
|
|
triangles: vec![[0, 1, 2]],
|
|
};
|
|
let bvh = Bvh::build(&[(1u64, &mesh, &Mat4f::identity())]);
|
|
bvh.validate().unwrap();
|
|
// Huge (5k triangles): validates + still matches brute force once.
|
|
let mut rng = Rng(99);
|
|
let meshes = tri_mesh_from_boxes(400, &mut rng);
|
|
let refs: Vec<(u64, &TriMesh, &Mat4f)> = meshes.iter().map(|(id, m, t)| (*id, m, t)).collect();
|
|
let bvh = Bvh::build(&refs);
|
|
bvh.validate().unwrap();
|
|
assert_eq!(bvh.triangle_count(), 400 * 12);
|
|
}
|