Perf/mesh collision (#1307)

Thanks @Ian-cmd-ops.
This commit is contained in:
H. Conrad authored and GitHub committed 2026-10-16 01:12:39 -04:00
1 parent 5e9285ca75
commit d29ca29578
6 files changed
+257 -73

No files matched your search

+3
View File
@@ -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"] }
@@ -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}");
}
}
+7
View File
@@ -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).
+2
View File
@@ -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",
+218 -73
View File
@@ -97,53 +97,6 @@ where
res
}
fn colliding_segment_pairs(sort_endpoints: Vec<EndPoint>) -> impl Iterator<Item = (usize, usize)> {
let mut current = [Vec::<usize>::new(), Vec::<usize>::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<Point3> = tri0.iter().collect();
let bdb1: BoundingBox<Point3> = 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<Point3> {
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<Point3>)> {
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<Point3>, bdb1: &BoundingBox<Point3>) -> 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<Point3>)],
tris1: &[([Point3; 3], BoundingBox<Point3>)],
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::<usize>::new(), Vec::<usize>::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::<Vec<_>>();
let tris1 = poly1.faces().triangle_iter().collect::<Vec<_>>();
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::<Vec<_>>();
let tris1 = poly1.faces().triangle_iter().collect::<Vec<_>>();
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<EndPoint>) -> Vec<(usize, usize)> {
let mut current = [Vec::<usize>::new(), Vec::<usize>::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<Point3> = tri0.iter().collect();
let b1: BoundingBox<Point3> = 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<u64>) -> Result<usize, String> {
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:?}");
}
}
+3
View File
@@ -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;