1 parent
5e9285ca75
commit
d29ca29578
6 files changed
+257
-73
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -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).
|
||||
|
||||
|
||||
Vendored
+2
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user