From d29ca29578d54a012a62d824fb83bada57f77b77 Mon Sep 17 00:00:00 2001 From: "H. Conrad" <78785932+Ian-cmd-ops@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:12:39 -0700 Subject: [PATCH] Perf/mesh collision (#1307) Thanks @Ian-cmd-ops. --- crates/kernel/Cargo.toml | 3 + .../tests/mesh_collision_equivalence.rs | 24 ++ vendor/README.md | 7 + vendor/truck-meshalgo/Cargo.toml | 2 + .../truck-meshalgo/src/analyzers/collision.rs | 291 +++++++++++++----- vendor/truck-meshalgo/src/analyzers/mod.rs | 3 + 6 files changed, 257 insertions(+), 73 deletions(-) create mode 100644 crates/kernel/tests/mesh_collision_equivalence.rs diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml index 1ab5837c..d174e08f 100644 --- a/crates/kernel/Cargo.toml +++ b/crates/kernel/Cargo.toml @@ -25,4 +25,7 @@ spade = { workspace = true } rayon = { workspace = true } [dev-dependencies] +# The vendored crate tests never build in CI, so its equivalence checks run from kernel tests. +# Resolver 3 keeps this dev-only feature out of shipped builds. +truck-meshalgo = { workspace = true, features = ["test-hooks"] } proptest = { version = "1", default-features = false, features = ["std"] } diff --git a/crates/kernel/tests/mesh_collision_equivalence.rs b/crates/kernel/tests/mesh_collision_equivalence.rs new file mode 100644 index 00000000..19d9051b --- /dev/null +++ b/crates/kernel/tests/mesh_collision_equivalence.rs @@ -0,0 +1,24 @@ +//! Equivalence checks for SolidCraft's changes to the vendored truck crates. +//! +//! The vendored crates are outside the workspace, so their own tests never run in CI. Each +//! change that replaces an algorithm keeps the old one behind the crate's `test-hooks` feature +//! (on only for these tests) and these tests check the new one gives the same answers. + +use truck_meshalgo::analyzers::collision_test_hooks as collision; + +/// Mesh collision candidate search (sweep with per-triangle boxes): same collisions, same order. +#[test] +fn mesh_collision_same_as_before() { + match collision::same_collisions_in_the_same_order(0..200) { + Ok(n) => assert!(n > 0, "the random meshes produced no collisions to compare"), + Err(e) => panic!("{e}"), + } +} + +/// NaN coordinates in a mesh: the collision search goes on without a panic. +#[test] +fn mesh_collision_nan_triangles_do_not_panic() { + if let Err(e) = collision::nan_triangles_do_not_panic() { + panic!("{e}"); + } +} diff --git a/vendor/README.md b/vendor/README.md index 3b1e4ea5..98202edf 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -135,6 +135,13 @@ Changes: stops refining a face's grid at 16 384 points. Each edge is still divided once for both its faces. `triangulation` and `robust_triangulation` divide by the chord tolerance alone, as before. +- `analyzers/collision.rs`: the collision search builds each triangle's box once, filters the + sweep's open list by box overlap as it goes (it cloned the list for every triangle and built + both boxes for every pair), and runs the exact triangle tests on the rayon pool, in order + (sequential on wasm). Same collisions in the same order. The old search is kept as + `analyzers::collision_test_hooks`, behind a `test-hooks` feature that only + `solidcraft-kernel`'s tests turn on, and `crates/kernel/tests/mesh_collision_equivalence.rs` + compares the two. - `lib.rs`: compiler warnings no longer fail the build. Example, test and bench targets are dropped (their sources are not vendored). diff --git a/vendor/truck-meshalgo/Cargo.toml b/vendor/truck-meshalgo/Cargo.toml index 73c8199c..0570e8f0 100644 --- a/vendor/truck-meshalgo/Cargo.toml +++ b/vendor/truck-meshalgo/Cargo.toml @@ -97,6 +97,8 @@ default = [ "tessellation", ] filters = [] +# Exposes equivalence checks for the SolidCraft kernel tests. Never on in shipped builds. +test-hooks = [] tessellation = [ "spade", "filters", diff --git a/vendor/truck-meshalgo/src/analyzers/collision.rs b/vendor/truck-meshalgo/src/analyzers/collision.rs index e435ce9d..cf71ee8e 100644 --- a/vendor/truck-meshalgo/src/analyzers/collision.rs +++ b/vendor/truck-meshalgo/src/analyzers/collision.rs @@ -97,53 +97,6 @@ where res } -fn colliding_segment_pairs(sort_endpoints: Vec) -> impl Iterator { - let mut current = [Vec::::new(), Vec::::new()]; - sort_endpoints - .into_iter() - .filter_map( - move |EndPoint { - r#type, - segnum, - index, - .. - }| match r#type { - EndPointType::Front => { - current[segnum].push(index); - Some(current[1 - segnum].clone().into_iter().map(move |i| { - if segnum == 0 { - (index, i) - } else { - (i, index) - } - })) - } - EndPointType::Back => { - let i = current[segnum] - .iter() - .enumerate() - .find(|(_, idx)| **idx == index) - .unwrap() - .0; - current[segnum].swap_remove(i); - None - } - }, - ) - .flatten() -} - -fn disjoint_bdbs(tri0: [Point3; 3], tri1: [Point3; 3]) -> bool { - let bdb0: BoundingBox = tri0.iter().collect(); - let bdb1: BoundingBox = tri1.iter().collect(); - bdb0.max()[0] < bdb1.min()[0] - || bdb1.max()[0] < bdb0.min()[0] - || bdb0.max()[1] < bdb1.min()[1] - || bdb1.max()[1] < bdb0.min()[1] - || bdb0.max()[2] < bdb1.min()[2] - || bdb1.max()[2] < bdb0.min()[2] -} - fn collide_seg_triangle(seg: [Point3; 2], tri: [Point3; 3]) -> Option { let ab = tri[1] - tri[0]; let bc = tri[2] - tri[1]; @@ -208,25 +161,99 @@ fn make_pos_tri(poly: &PolygonMesh, face: [StandardVertex; 3]) -> [Point3; 3] { array![i => poly.positions()[face[i].pos]; 3] } +/// Triangles of a mesh with their bounding boxes, each computed once. +fn boxed_triangles(poly: &PolygonMesh) -> Vec<([Point3; 3], BoundingBox)> { + poly.faces() + .triangle_iter() + .map(|face| { + let tri = make_pos_tri(poly, face); + (tri, tri.iter().collect()) + }) + .collect() +} + +/// Whether two triangles' bounding boxes miss each other (touching boxes do not miss). +#[inline(always)] +fn disjoint_boxes(bdb0: &BoundingBox, bdb1: &BoundingBox) -> bool { + bdb0.max()[0] < bdb1.min()[0] + || bdb1.max()[0] < bdb0.min()[0] + || bdb0.max()[1] < bdb1.min()[1] + || bdb1.max()[1] < bdb0.min()[1] + || bdb0.max()[2] < bdb1.min()[2] + || bdb1.max()[2] < bdb0.min()[2] +} + +/// Pairs of triangles (one from each mesh) whose bounding boxes overlap, in the order the +/// sweep along `unit` meets them. +/// +/// The sweep keeps the triangles whose interval along `unit` is open. When a triangle opens, +/// it pairs with every open triangle of the other mesh whose box it overlaps. The pairs and +/// their order are the same as the earlier version, which cloned the open list for every +/// triangle and rejected disjoint boxes afterwards (rebuilding both boxes for each pair). +fn candidate_pairs( + tris0: &[([Point3; 3], BoundingBox)], + tris1: &[([Point3; 3], BoundingBox)], + unit: Vector3, +) -> Vec<(usize, usize)> { + let endpoints = sorted_endpoints( + tris0.iter().map(|(t, _)| *t), + tris1.iter().map(|(t, _)| *t), + unit, + ); + let boxes = [tris0, tris1]; + let mut open = [Vec::::new(), Vec::::new()]; + let mut pairs = Vec::new(); + for EndPoint { + r#type, + segnum, + index, + .. + } in endpoints + { + match r#type { + EndPointType::Front => { + let mine = &boxes[segnum][index].1; + for &other in &open[1 - segnum] { + if !disjoint_boxes(mine, &boxes[1 - segnum][other].1) { + pairs.push(if segnum == 0 { + (index, other) + } else { + (other, index) + }); + } + } + open[segnum].push(index); + } + EndPointType::Back => { + // Every interval opens before it closes unless a NaN broke the sort. Then the + // interval stays open: more box tests, never a panic (the web build aborts on any). + if let Some(i) = open[segnum].iter().position(|idx| *idx == index) { + open[segnum].swap_remove(i); + } + } + } + } + pairs +} + fn collision(poly0: &PolygonMesh, poly1: &PolygonMesh) -> Vec<(Point3, Point3)> { let unit = match poly0.positions().is_empty() { true => return Vec::new(), false => hash::take_one_unit(poly0.positions()[0]), }; - let tris0 = poly0.faces().triangle_iter().collect::>(); - let tris1 = poly1.faces().triangle_iter().collect::>(); - let iter0 = tris0.iter().map(|face| make_pos_tri(poly0, *face)); - let iter1 = tris1.iter().map(|face| make_pos_tri(poly1, *face)); - colliding_segment_pairs(sorted_endpoints(iter0, iter1, unit)) - .filter_map(|(idx0, idx1)| { - let tri0 = make_pos_tri(poly0, tris0[idx0]); - let tri1 = make_pos_tri(poly1, tris1[idx1]); - match disjoint_bdbs(tri0, tri1) { - true => None, - false => collide_triangles(tri0, tri1), - } - }) - .collect() + let (tris0, tris1) = (boxed_triangles(poly0), boxed_triangles(poly1)); + let pairs = candidate_pairs(&tris0, &tris1, unit); + let test = |&(i, j): &(usize, usize)| collide_triangles(tris0[i].0, tris1[j].0); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + // Indexed parallel collect: results in the same order as the sequential loop. + pairs.par_iter().filter_map(test).collect() + } + #[cfg(target_arch = "wasm32")] + { + pairs.iter().filter_map(test).collect() + } } fn are_colliding(poly0: &PolygonMesh, poly1: &PolygonMesh) -> Option<(Point3, Point3)> { @@ -234,18 +261,19 @@ fn are_colliding(poly0: &PolygonMesh, poly1: &PolygonMesh) -> Option<(Point3, Po true => return None, false => hash::take_one_unit(poly0.positions()[0]), }; - let tris0 = poly0.faces().triangle_iter().collect::>(); - let tris1 = poly1.faces().triangle_iter().collect::>(); - let iter0 = tris0.iter().map(|face| make_pos_tri(poly0, *face)); - let iter1 = tris1.iter().map(|face| make_pos_tri(poly1, *face)); - colliding_segment_pairs(sorted_endpoints(iter0, iter1, unit)).find_map(|(idx0, idx1)| { - let tri0 = make_pos_tri(poly0, tris0[idx0]); - let tri1 = make_pos_tri(poly1, tris1[idx1]); - match disjoint_bdbs(tri0, tri1) { - true => None, - false => collide_triangles(tri0, tri1), - } - }) + let (tris0, tris1) = (boxed_triangles(poly0), boxed_triangles(poly1)); + let pairs = candidate_pairs(&tris0, &tris1, unit); + let test = |&(i, j): &(usize, usize)| collide_triangles(tris0[i].0, tris1[j].0); + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + // The first collision in sweep order, as the sequential search returns. + pairs.par_iter().find_map_first(test) + } + #[cfg(target_arch = "wasm32")] + { + pairs.iter().find_map(test) + } } #[test] @@ -273,3 +301,120 @@ fn collide_triangles_test() { ]; assert!(collide_triangles(tri0, tri1).is_none()); } + +/// The candidate search before boxes were computed once and the open list stopped being +/// cloned: kept to check the new search finds the same collisions in the same order. +/// +/// Not part of the API. Built only for tests, or with the `test-hooks` feature, so the +/// SolidCraft kernel tests can run these checks in CI (this crate's own tests do not run there). +#[cfg(any(test, feature = "test-hooks"))] +#[doc(hidden)] +pub mod test_hooks { + use super::*; + + fn old_pairs(sort_endpoints: Vec) -> Vec<(usize, usize)> { + let mut current = [Vec::::new(), Vec::::new()]; + let mut out = Vec::new(); + for EndPoint { r#type, segnum, index, .. } in sort_endpoints { + match r#type { + EndPointType::Front => { + current[segnum].push(index); + for i in current[1 - segnum].clone() { + out.push(if segnum == 0 { (index, i) } else { (i, index) }); + } + } + EndPointType::Back => { + if let Some(i) = current[segnum].iter().position(|idx| *idx == index) { + current[segnum].swap_remove(i); + } + } + } + } + out + } + + fn old_disjoint(tri0: [Point3; 3], tri1: [Point3; 3]) -> bool { + let b0: BoundingBox = tri0.iter().collect(); + let b1: BoundingBox = tri1.iter().collect(); + disjoint_boxes(&b0, &b1) + } + + /// Small seeded generator (no extra dependency). + struct Lcg(u64); + impl Lcg { + fn unit(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + /// On a coarse grid half the time, so shared vertices, touching boxes and + /// degenerate triangles come up. + fn coord(&mut self) -> f64 { + if self.unit() < 0.5 { (self.unit() * 8.0).floor() * 0.5 } else { self.unit() * 4.0 } + } + fn tri(&mut self) -> [Point3; 3] { + array![_ => Point3::new(self.coord(), self.coord(), self.coord()); 3] + } + } + + /// A NaN coordinate breaks the endpoint sort: the search must go on without a panic. + pub fn nan_triangles_do_not_panic() -> Result<(), String> { + let good = [Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0), Point3::new(0.0, 1.0, 0.0)]; + let nan = [Point3::new(f64::NAN, 0.0, 0.0), Point3::new(1.0, f64::NAN, 0.0), Point3::new(0.0, 1.0, 0.5)]; + let crossing = [Point3::new(0.2, 0.2, -1.0), Point3::new(0.2, 0.2, 1.0), Point3::new(0.6, 0.2, 0.0)]; + for (a, b) in [(vec![good, nan], vec![crossing, nan]), (vec![nan, nan], vec![nan]), (vec![nan], vec![good])] { + let ba: Vec<_> = a.iter().map(|t| (*t, t.iter().collect())).collect(); + let bb: Vec<_> = b.iter().map(|t| (*t, t.iter().collect())).collect(); + let r = std::panic::catch_unwind(|| candidate_pairs(&ba, &bb, Vector3::new(1.0, 0.0, 0.0))); + if r.is_err() { + return Err(format!("panicked on {a:?} / {b:?}")); + } + } + Ok(()) + } + + /// The new candidate search finds the same collisions, in the same order, as the old one + /// on random triangle sets. Returns how many collisions were compared. + pub fn same_collisions_in_the_same_order(seeds: std::ops::Range) -> Result { + let mut compared = 0; + for seed in seeds { + let mut r = Lcg(seed * 7919 + 1); + let n0 = 1 + (r.unit() * 60.0) as usize; + let n1 = 1 + (r.unit() * 60.0) as usize; + let t0: Vec<[Point3; 3]> = (0..n0).map(|_| r.tri()).collect(); + let t1: Vec<[Point3; 3]> = (0..n1).map(|_| r.tri()).collect(); + let unit = hash::take_one_unit(t0[0][0]); + + let old: Vec<(Point3, Point3)> = old_pairs(sorted_endpoints(t0.iter().copied(), t1.iter().copied(), unit)) + .into_iter() + .filter(|&(i, j)| !old_disjoint(t0[i], t1[j])) + .filter_map(|(i, j)| collide_triangles(t0[i], t1[j])) + .collect(); + + let b0: Vec<_> = t0.iter().map(|t| (*t, t.iter().collect())).collect(); + let b1: Vec<_> = t1.iter().map(|t| (*t, t.iter().collect())).collect(); + let new: Vec<(Point3, Point3)> = candidate_pairs(&b0, &b1, unit) + .into_iter() + .filter_map(|(i, j)| collide_triangles(t0[i], t1[j])) + .collect(); + + if old.len() != new.len() { + return Err(format!("seed {seed}: {} collisions before, {} now", old.len(), new.len())); + } + for (k, (a, b)) in old.iter().zip(&new).enumerate() { + if !(a.0 == b.0 && a.1 == b.1) { + return Err(format!("seed {seed}: collision {k} differs: {a:?} vs {b:?}")); + } + } + compared += old.len(); + } + Ok(compared) + } + + #[cfg(test)] + #[test] + fn hooks_pass() { + assert_eq!(nan_triangles_do_not_panic(), Ok(())); + let n = same_collisions_in_the_same_order(0..200); + assert!(matches!(n, Ok(n) if n > 0), "{n:?}"); + } +} diff --git a/vendor/truck-meshalgo/src/analyzers/mod.rs b/vendor/truck-meshalgo/src/analyzers/mod.rs index 993bc706..292c8f1d 100644 --- a/vendor/truck-meshalgo/src/analyzers/mod.rs +++ b/vendor/truck-meshalgo/src/analyzers/mod.rs @@ -8,6 +8,9 @@ mod topology; mod volume; pub use collision::Collision; +#[cfg(feature = "test-hooks")] +#[doc(hidden)] +pub use collision::test_hooks as collision_test_hooks; pub use in_out_judge::IncludingPointInDomain; pub use point_cloud::WithPointCloud; pub use splitting::ExperimentalSplitters;