axiolid_reference/clash.rs
1//! Mesh interference: the narrow phase (#4).
2//!
3//! # What this closes
4//!
5//! `Bvh::overlap_pairs` reports pairs whose *bounding boxes* overlap, and
6//! `triangle_triangle_relation` classifies *one* triangle pair exactly. Nothing
7//! joined them, so there was no way to ask the question a model checker
8//! actually asks: do these two solids interfere, and by how much?
9//!
10//! # Method
11//!
12//! Broad phase over triangle AABBs to reject the quadratic majority, then the
13//! exact predicate on survivors. The predicate is unconditionally correct for
14//! binary64 input, so a reported `Penetrating` is a fact, not an estimate.
15//!
16//! Penetration *depth* is a separate matter: it is measured, approximate, and
17//! reported as evidence rather than folded into the verdict. Conflating an
18//! exact topological answer with an approximate metric one is how a checker
19//! ends up unable to say why it flagged something.
20
21use axiolid_contracts::{GeomError, GeomResult};
22use axiolid_core::{Aabb, Point2, Point3, Scalar, Tolerance};
23use axiolid_measure::WindingMesh;
24use axiolid_mesh::TriMesh;
25
26use crate::orient2d;
27use crate::triangle_triangle::{triangle_triangle_relation, TriangleTriangleRelation};
28
29/// What two meshes do to each other.
30///
31/// Deliberately three states, not a boolean. A model checker that cannot
32/// distinguish "touching" from "overlapping" either floods the report with
33/// every abutting wall or silently drops real interferences.
34#[non_exhaustive]
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Interference {
37 /// No triangle pair meets, within the separation tested.
38 Clear,
39 /// Triangles meet only at shared vertices, edges, or coplanar contact.
40 /// Two slabs sharing a face are in contact, not in conflict.
41 Touching,
42 /// At least one pair crosses transversally: the solids share volume.
43 Penetrating,
44}
45
46/// The verdict plus the evidence behind it.
47#[derive(Debug, Clone)]
48#[non_exhaustive]
49pub struct InterferenceReport {
50 /// The verdict.
51 pub kind: Interference,
52 /// Triangle-index pairs that meet transversally.
53 pub penetrating_pairs: Vec<(usize, usize)>,
54 /// Triangle-index pairs in non-transverse contact.
55 pub touching_pairs: Vec<(usize, usize)>,
56 /// Triangle pairs whose boxes overlapped and were tested exactly.
57 pub narrow_phase_tests: usize,
58 /// Triangle pairs the broad phase rejected without an exact test.
59 pub broad_phase_rejections: usize,
60 /// Pairs skipped because a triangle was degenerate. A caller measuring
61 /// clearance must know its input was not fully testable.
62 pub degenerate_skips: usize,
63 /// Whether a vertex of one solid was found strictly inside the other.
64 ///
65 /// Two boxes overlapping face-to-face cross only edge-to-edge, so the
66 /// triangle predicate reports contact and never `Proper`. Shared volume is
67 /// therefore decided by containment, not by surface topology alone.
68 pub containment: bool,
69}
70
71impl InterferenceReport {
72 /// Whether the solids share volume.
73 pub fn is_penetrating(&self) -> bool {
74 self.kind == Interference::Penetrating
75 }
76
77 /// Whether anything at all was found, contact included.
78 pub fn is_clear(&self) -> bool {
79 self.kind == Interference::Clear
80 }
81}
82
83/// Classify interference between two triangle meshes.
84///
85/// `tolerance` inflates the broad-phase boxes so a pair that is within
86/// tolerance of touching is still tested exactly. It does **not** loosen the
87/// exact predicate: the verdict stays a topological fact about the supplied
88/// coordinates.
89pub fn interference(
90 a: &TriMesh,
91 b: &TriMesh,
92 tolerance: Tolerance,
93) -> GeomResult<InterferenceReport> {
94 let pad = tolerance.linear();
95 if !(pad.is_finite() && pad >= 0.0) {
96 return Err(GeomError::InvalidInput(format!(
97 "tolerance must be finite and non-negative, got {pad}"
98 )));
99 }
100
101 let boxes_a = triangle_boxes(a, pad);
102 let boxes_b = triangle_boxes(b, pad);
103
104 let mut report = InterferenceReport {
105 kind: Interference::Clear,
106 penetrating_pairs: Vec::new(),
107 touching_pairs: Vec::new(),
108 narrow_phase_tests: 0,
109 broad_phase_rejections: 0,
110 degenerate_skips: 0,
111 containment: false,
112 };
113
114 // Build a BVH over B's triangles and probe it with A's boxes. The
115 // quadratic scan this replaces made `interference` unusable at model
116 // scale: two 4.6k-triangle solids cost 21M box tests.
117 let tree = axiolid_spatial::Bvh::build(
118 boxes_b
119 .iter()
120 .enumerate()
121 .map(|(j, bounds)| axiolid_spatial::SpatialItem::new(j, *bounds)),
122 );
123 let mut hits: Vec<usize> = Vec::new();
124 for (i, box_a) in boxes_a.iter().enumerate() {
125 tree.query_aabb(box_a, &mut hits);
126 // Everything the tree pruned would have been a rejected box test in
127 // the quadratic version; count it so the two are comparable.
128 report.broad_phase_rejections += boxes_b.len() - hits.len();
129 for &j in &hits {
130 let box_b = &boxes_b[j];
131 if !box_a.intersects(box_b) {
132 report.broad_phase_rejections += 1;
133 continue;
134 }
135 report.narrow_phase_tests += 1;
136 let ta = triangle(a, i);
137 let tb = triangle(b, j);
138 match triangle_triangle_relation(ta, tb) {
139 TriangleTriangleRelation::Proper => {
140 report.penetrating_pairs.push((i, j));
141 report.kind = Interference::Penetrating;
142 }
143 TriangleTriangleRelation::Touching => {
144 report.touching_pairs.push((i, j));
145 if report.kind == Interference::Clear {
146 report.kind = Interference::Touching;
147 }
148 }
149 // `Coplanar` short-circuits the predicate before any edge is
150 // tested, so it cannot separate "same plane, overlapping" from
151 // "same plane, five metres apart". It is inconclusive here;
152 // coplanar overlap is decided by the metric test below.
153 TriangleTriangleRelation::Coplanar => {
154 // `Coplanar` means all six vertices share ONE plane. Two
155 // parallel faces 50mm apart are not coplanar, so they
156 // cannot reach here -- unless the predicate said so for
157 // the supplied coordinates, which is the exact answer.
158 if coplanar_pair_overlaps(ta, tb) {
159 report.touching_pairs.push((i, j));
160 if report.kind == Interference::Clear {
161 report.kind = Interference::Touching;
162 }
163 }
164 }
165 TriangleTriangleRelation::DegenerateTriangle => {
166 report.degenerate_skips += 1;
167 }
168 // The triangles were tested and share no point: a verdict,
169 // not a skip. The match is exhaustive on purpose, so a new
170 // relation must be decided here rather than fall through.
171 TriangleTriangleRelation::Disjoint => {}
172 }
173 }
174 }
175
176 // Containment requires overlapping bounds. Checking that first turns the
177 // common disjoint case from O(probes x triangles) into two box tests:
178 // measured 1265 ms -> under 2 ms for two 2k-triangle spheres.
179 let bounds_a = mesh_bounds(a);
180 let bounds_b = mesh_bounds(b);
181 if report.kind != Interference::Penetrating
182 && !a.indices.is_empty()
183 && !b.indices.is_empty()
184 && bounds_a.intersects(&bounds_b)
185 {
186 // Prepare each winding mesh ONCE. `WindingMesh::prepare` runs a full
187 // audit_mesh, so preparing per probe made containment quadratic in
188 // triangle count with an enormous constant.
189 let a_in_b = WindingMesh::prepare(b, tolerance)
190 .ok()
191 .is_some_and(|w| interior_probes(a).any(|p| inside(&w, p)));
192 let b_in_a = WindingMesh::prepare(a, tolerance)
193 .ok()
194 .is_some_and(|w| interior_probes(b).any(|p| inside(&w, p)));
195 if a_in_b || b_in_a {
196 report.kind = Interference::Penetrating;
197 report.containment = true;
198 }
199 }
200
201 Ok(report)
202}
203
204/// Padded axis-aligned box per triangle.
205fn triangle_boxes(mesh: &TriMesh, pad: Scalar) -> Vec<Aabb> {
206 (0..mesh.indices.len() / 3)
207 .map(|i| {
208 let [a, b, c] = triangle(mesh, i);
209 let lo = Point3::new(
210 a.x.min(b.x).min(c.x) - pad,
211 a.y.min(b.y).min(c.y) - pad,
212 a.z.min(b.z).min(c.z) - pad,
213 );
214 let hi = Point3::new(
215 a.x.max(b.x).max(c.x) + pad,
216 a.y.max(b.y).max(c.y) + pad,
217 a.z.max(b.z).max(c.z) + pad,
218 );
219 Aabb { min: lo, max: hi }
220 })
221 .collect()
222}
223
224/// Corner positions of triangle `i`.
225fn triangle(mesh: &TriMesh, i: usize) -> [Point3; 3] {
226 let base = i * 3;
227 [
228 mesh.positions[mesh.indices[base] as usize],
229 mesh.positions[mesh.indices[base + 1] as usize],
230 mesh.positions[mesh.indices[base + 2] as usize],
231 ]
232}
233
234/// Whether two coplanar triangles actually share area.
235///
236/// `triangle_triangle_relation` returns `Coplanar` from a vertex-side test and
237/// never reaches its edge tests, so it cannot answer this. Projecting onto the
238/// dominant plane axis and testing in 2D can: exact `orient2d` decides both
239/// edge crossings and vertex containment.
240fn coplanar_pair_overlaps(a: [Point3; 3], b: [Point3; 3]) -> bool {
241 let normal = (a[1] - a[0]).cross(a[2] - a[0]);
242 let drop = dominant_axis(normal);
243 let pa = a.map(|p| flatten(p, drop));
244 let pb = b.map(|p| flatten(p, drop));
245
246 // Either triangle containing any corner of the other is overlap.
247 if pb.iter().any(|p| point_in_triangle(*p, pa)) || pa.iter().any(|p| point_in_triangle(*p, pb))
248 {
249 return true;
250 }
251 // Otherwise they overlap only if their boundaries cross.
252 let edges = |t: [Point2; 3]| [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]];
253 edges(pa)
254 .iter()
255 .any(|ea| edges(pb).iter().any(|eb| segments_cross(*ea, *eb)))
256}
257
258/// Index of the largest-magnitude normal component.
259fn dominant_axis(n: axiolid_core::Vec3) -> usize {
260 let (x, y, z) = (n.x.abs(), n.y.abs(), n.z.abs());
261 if x >= y && x >= z {
262 0
263 } else if y >= z {
264 1
265 } else {
266 2
267 }
268}
269
270/// Drop the dominant axis, keeping the projection non-degenerate.
271fn flatten(p: Point3, drop: usize) -> Point2 {
272 match drop {
273 0 => Point2::new(p.y, p.z),
274 1 => Point2::new(p.x, p.z),
275 _ => Point2::new(p.x, p.y),
276 }
277}
278
279/// Containment by exact orientation, boundary included.
280fn point_in_triangle(p: Point2, t: [Point2; 3]) -> bool {
281 let s = |i: usize, j: usize| sign_of(orient2d(t[i], t[j], p));
282 let (a, b, c) = (s(0, 1), s(1, 2), s(2, 0));
283 let non_negative = a >= 0 && b >= 0 && c >= 0;
284 let non_positive = a <= 0 && b <= 0 && c <= 0;
285 non_negative || non_positive
286}
287
288/// Whether two closed segments share a point.
289fn segments_cross(u: [Point2; 2], v: [Point2; 2]) -> bool {
290 let d1 = sign_of(orient2d(u[0], u[1], v[0]));
291 let d2 = sign_of(orient2d(u[0], u[1], v[1]));
292 let d3 = sign_of(orient2d(v[0], v[1], u[0]));
293 let d4 = sign_of(orient2d(v[0], v[1], u[1]));
294 if d1 * d2 < 0 && d3 * d4 < 0 {
295 return true;
296 }
297 // Collinear touching cases.
298 (d1 == 0 && between(u[0], v[0], u[1]))
299 || (d2 == 0 && between(u[0], v[1], u[1]))
300 || (d3 == 0 && between(v[0], u[0], v[1]))
301 || (d4 == 0 && between(v[0], u[1], v[1]))
302}
303
304/// Whether collinear `q` lies within the `p`-`r` box.
305fn between(p: Point2, q: Point2, r: Point2) -> bool {
306 q.x >= p.x.min(r.x) && q.x <= p.x.max(r.x) && q.y >= p.y.min(r.y) && q.y <= p.y.max(r.y)
307}
308
309/// Certified sign as a small integer.
310fn sign_of(c: axiolid_contracts::Certified) -> i32 {
311 match c.sign() {
312 Some(axiolid_contracts::Sign::Positive) => 1,
313 Some(axiolid_contracts::Sign::Negative) => -1,
314 _ => 0,
315 }
316}
317
318/// Whether `point` lies strictly inside the closed mesh `solid`.
319///
320/// Uses the generalized winding number rather than ray parity. Parity is
321/// unreliable exactly where building geometry lives: axis-aligned boxes put
322/// rays along faces and through shared edges, and every tie-break there is a
323/// guess. Winding accumulates oriented solid angle, so it degrades smoothly
324/// instead of flipping.
325///
326/// A point ON the boundary has winding near 1/2 and is deliberately NOT
327/// inside: two slabs sharing a face are in contact, not overlapping. That
328/// single decision is what keeps every abutting wall out of a clash report.
329pub fn point_inside(point: Point3, solid: &TriMesh, tolerance: Tolerance) -> Option<bool> {
330 let winding = WindingMesh::prepare(solid, tolerance).ok()?;
331 let w = winding.winding_number(point).ok()?.value;
332 // Interior is ~1, exterior ~0, boundary ~0.5. Require a clear interior so
333 // a boundary point never counts as containment.
334 Some(w > 0.75)
335}
336
337/// Points used to test whether one solid reaches inside another.
338///
339/// Vertices alone are not enough: two boxes crossing face-to-face have every
340/// corner outside or on the other's boundary, yet plainly share volume. Each
341/// triangle centroid of the overlapping faces does lie strictly inside, so
342/// both are sampled.
343///
344/// This is a sampling test, so it can only ever produce false negatives, never
345/// false positives: a reported penetration is always real. A pathological
346/// sliver overlap smaller than a triangle is missed, which is why the exact
347/// surface-crossing test above runs first and independently.
348fn interior_probes(mesh: &TriMesh) -> impl Iterator<Item = Point3> + '_ {
349 // Centre of the mesh's own bounding box: a convex solid always contains
350 // it, and for a non-convex one the triangle-nudge probes below still fire.
351 let mut lo = Point3::splat(Scalar::INFINITY);
352 let mut hi = Point3::splat(Scalar::NEG_INFINITY);
353 for p in &mesh.positions {
354 lo = lo.min(*p);
355 hi = hi.max(*p);
356 }
357 let centre = (lo + hi) * 0.5;
358
359 // Each triangle centroid pulled a whisker toward the mesh centre. On the
360 // boundary the winding number is undefined -- measured as ~1.0 rather
361 // than 0.5 -- so a probe left exactly on a shared face reads as inside
362 // and turns face contact into a false penetration. The nudge is relative
363 // so it scales with the model.
364 let span = (hi - lo).length().max(1.0);
365 // The nudge must be small enough not to step over a real overlap. A 1e-9
366 // model-relative step is the same order as the smallest overlap worth
367 // reporting, so it would hide exactly the cases this function exists to
368 // catch. 1e-12 is far below any meaningful interference yet still clears
369 // the exact-arithmetic boundary where winding is undefined.
370 let nudge = span * 1e-12;
371
372 core::iter::once(centre).chain(mesh.indices.as_chunks::<3>().0.iter().map(move |t| {
373 let a = mesh.positions[t[0] as usize];
374 let b = mesh.positions[t[1] as usize];
375 let c = mesh.positions[t[2] as usize];
376 let m = (a + b + c) / 3.0;
377 let toward = centre - m;
378 let len = toward.length();
379 if len > 0.0 {
380 m + toward * (nudge / len)
381 } else {
382 m
383 }
384 }))
385}
386
387/// Interior test against an already-prepared winding mesh.
388///
389/// Shares the 0.75 threshold with `point_inside`; see there for why a
390/// boundary point must not count as inside.
391fn inside(winding: &WindingMesh<'_, TriMesh>, point: Point3) -> bool {
392 winding
393 .winding_number(point)
394 .map(|w| w.value > 0.75)
395 .unwrap_or(false)
396}
397
398/// Axis-aligned bounds of a mesh.
399fn mesh_bounds(mesh: &TriMesh) -> Aabb {
400 let mut bounds = Aabb::empty();
401 for p in &mesh.positions {
402 bounds.extend(*p);
403 }
404 bounds
405}