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 relation is non-exhaustive; an unknown variant is not a
169 // verdict and must not silently read as "clear".
170 _ => {
171 report.degenerate_skips += 1;
172 }
173 }
174 }
175 }
176
177 // Containment requires overlapping bounds. Checking that first turns the
178 // common disjoint case from O(probes x triangles) into two box tests:
179 // measured 1265 ms -> under 2 ms for two 2k-triangle spheres.
180 let bounds_a = mesh_bounds(a);
181 let bounds_b = mesh_bounds(b);
182 if report.kind != Interference::Penetrating
183 && !a.indices.is_empty()
184 && !b.indices.is_empty()
185 && bounds_a.intersects(&bounds_b)
186 {
187 // Prepare each winding mesh ONCE. `WindingMesh::prepare` runs a full
188 // audit_mesh, so preparing per probe made containment quadratic in
189 // triangle count with an enormous constant.
190 let a_in_b = WindingMesh::prepare(b, tolerance)
191 .ok()
192 .is_some_and(|w| interior_probes(a).any(|p| inside(&w, p)));
193 let b_in_a = WindingMesh::prepare(a, tolerance)
194 .ok()
195 .is_some_and(|w| interior_probes(b).any(|p| inside(&w, p)));
196 if a_in_b || b_in_a {
197 report.kind = Interference::Penetrating;
198 report.containment = true;
199 }
200 }
201
202 Ok(report)
203}
204
205/// Padded axis-aligned box per triangle.
206fn triangle_boxes(mesh: &TriMesh, pad: Scalar) -> Vec<Aabb> {
207 (0..mesh.indices.len() / 3)
208 .map(|i| {
209 let [a, b, c] = triangle(mesh, i);
210 let lo = Point3::new(
211 a.x.min(b.x).min(c.x) - pad,
212 a.y.min(b.y).min(c.y) - pad,
213 a.z.min(b.z).min(c.z) - pad,
214 );
215 let hi = Point3::new(
216 a.x.max(b.x).max(c.x) + pad,
217 a.y.max(b.y).max(c.y) + pad,
218 a.z.max(b.z).max(c.z) + pad,
219 );
220 Aabb { min: lo, max: hi }
221 })
222 .collect()
223}
224
225/// Corner positions of triangle `i`.
226fn triangle(mesh: &TriMesh, i: usize) -> [Point3; 3] {
227 let base = i * 3;
228 [
229 mesh.positions[mesh.indices[base] as usize],
230 mesh.positions[mesh.indices[base + 1] as usize],
231 mesh.positions[mesh.indices[base + 2] as usize],
232 ]
233}
234
235/// Whether two coplanar triangles actually share area.
236///
237/// `triangle_triangle_relation` returns `Coplanar` from a vertex-side test and
238/// never reaches its edge tests, so it cannot answer this. Projecting onto the
239/// dominant plane axis and testing in 2D can: exact `orient2d` decides both
240/// edge crossings and vertex containment.
241fn coplanar_pair_overlaps(a: [Point3; 3], b: [Point3; 3]) -> bool {
242 let normal = (a[1] - a[0]).cross(a[2] - a[0]);
243 let drop = dominant_axis(normal);
244 let pa = a.map(|p| flatten(p, drop));
245 let pb = b.map(|p| flatten(p, drop));
246
247 // Either triangle containing any corner of the other is overlap.
248 if pb.iter().any(|p| point_in_triangle(*p, pa)) || pa.iter().any(|p| point_in_triangle(*p, pb))
249 {
250 return true;
251 }
252 // Otherwise they overlap only if their boundaries cross.
253 let edges = |t: [Point2; 3]| [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]];
254 edges(pa)
255 .iter()
256 .any(|ea| edges(pb).iter().any(|eb| segments_cross(*ea, *eb)))
257}
258
259/// Index of the largest-magnitude normal component.
260fn dominant_axis(n: axiolid_core::Vec3) -> usize {
261 let (x, y, z) = (n.x.abs(), n.y.abs(), n.z.abs());
262 if x >= y && x >= z {
263 0
264 } else if y >= z {
265 1
266 } else {
267 2
268 }
269}
270
271/// Drop the dominant axis, keeping the projection non-degenerate.
272fn flatten(p: Point3, drop: usize) -> Point2 {
273 match drop {
274 0 => Point2::new(p.y, p.z),
275 1 => Point2::new(p.x, p.z),
276 _ => Point2::new(p.x, p.y),
277 }
278}
279
280/// Containment by exact orientation, boundary included.
281fn point_in_triangle(p: Point2, t: [Point2; 3]) -> bool {
282 let s = |i: usize, j: usize| sign_of(orient2d(t[i], t[j], p));
283 let (a, b, c) = (s(0, 1), s(1, 2), s(2, 0));
284 let non_negative = a >= 0 && b >= 0 && c >= 0;
285 let non_positive = a <= 0 && b <= 0 && c <= 0;
286 non_negative || non_positive
287}
288
289/// Whether two closed segments share a point.
290fn segments_cross(u: [Point2; 2], v: [Point2; 2]) -> bool {
291 let d1 = sign_of(orient2d(u[0], u[1], v[0]));
292 let d2 = sign_of(orient2d(u[0], u[1], v[1]));
293 let d3 = sign_of(orient2d(v[0], v[1], u[0]));
294 let d4 = sign_of(orient2d(v[0], v[1], u[1]));
295 if d1 * d2 < 0 && d3 * d4 < 0 {
296 return true;
297 }
298 // Collinear touching cases.
299 (d1 == 0 && between(u[0], v[0], u[1]))
300 || (d2 == 0 && between(u[0], v[1], u[1]))
301 || (d3 == 0 && between(v[0], u[0], v[1]))
302 || (d4 == 0 && between(v[0], u[1], v[1]))
303}
304
305/// Whether collinear `q` lies within the `p`-`r` box.
306fn between(p: Point2, q: Point2, r: Point2) -> bool {
307 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)
308}
309
310/// Certified sign as a small integer.
311fn sign_of(c: axiolid_contracts::Certified) -> i32 {
312 match c.sign() {
313 Some(axiolid_contracts::Sign::Positive) => 1,
314 Some(axiolid_contracts::Sign::Negative) => -1,
315 _ => 0,
316 }
317}
318
319/// Whether `point` lies strictly inside the closed mesh `solid`.
320///
321/// Uses the generalized winding number rather than ray parity. Parity is
322/// unreliable exactly where building geometry lives: axis-aligned boxes put
323/// rays along faces and through shared edges, and every tie-break there is a
324/// guess. Winding accumulates oriented solid angle, so it degrades smoothly
325/// instead of flipping.
326///
327/// A point ON the boundary has winding near 1/2 and is deliberately NOT
328/// inside: two slabs sharing a face are in contact, not overlapping. That
329/// single decision is what keeps every abutting wall out of a clash report.
330pub fn point_inside(point: Point3, solid: &TriMesh, tolerance: Tolerance) -> Option<bool> {
331 let winding = WindingMesh::prepare(solid, tolerance).ok()?;
332 let w = winding.winding_number(point).ok()?.value;
333 // Interior is ~1, exterior ~0, boundary ~0.5. Require a clear interior so
334 // a boundary point never counts as containment.
335 Some(w > 0.75)
336}
337
338/// Points used to test whether one solid reaches inside another.
339///
340/// Vertices alone are not enough: two boxes crossing face-to-face have every
341/// corner outside or on the other's boundary, yet plainly share volume. Each
342/// triangle centroid of the overlapping faces does lie strictly inside, so
343/// both are sampled.
344///
345/// This is a sampling test, so it can only ever produce false negatives, never
346/// false positives: a reported penetration is always real. A pathological
347/// sliver overlap smaller than a triangle is missed, which is why the exact
348/// surface-crossing test above runs first and independently.
349fn interior_probes(mesh: &TriMesh) -> impl Iterator<Item = Point3> + '_ {
350 // Centre of the mesh's own bounding box: a convex solid always contains
351 // it, and for a non-convex one the triangle-nudge probes below still fire.
352 let mut lo = Point3::splat(Scalar::INFINITY);
353 let mut hi = Point3::splat(Scalar::NEG_INFINITY);
354 for p in &mesh.positions {
355 lo = lo.min(*p);
356 hi = hi.max(*p);
357 }
358 let centre = (lo + hi) * 0.5;
359
360 // Each triangle centroid pulled a whisker toward the mesh centre. On the
361 // boundary the winding number is undefined -- measured as ~1.0 rather
362 // than 0.5 -- so a probe left exactly on a shared face reads as inside
363 // and turns face contact into a false penetration. The nudge is relative
364 // so it scales with the model.
365 let span = (hi - lo).length().max(1.0);
366 // The nudge must be small enough not to step over a real overlap. A 1e-9
367 // model-relative step is the same order as the smallest overlap worth
368 // reporting, so it would hide exactly the cases this function exists to
369 // catch. 1e-12 is far below any meaningful interference yet still clears
370 // the exact-arithmetic boundary where winding is undefined.
371 let nudge = span * 1e-12;
372
373 core::iter::once(centre).chain(mesh.indices.chunks_exact(3).map(move |t| {
374 let a = mesh.positions[t[0] as usize];
375 let b = mesh.positions[t[1] as usize];
376 let c = mesh.positions[t[2] as usize];
377 let m = (a + b + c) / 3.0;
378 let toward = centre - m;
379 let len = toward.length();
380 if len > 0.0 {
381 m + toward * (nudge / len)
382 } else {
383 m
384 }
385 }))
386}
387
388/// Interior test against an already-prepared winding mesh.
389///
390/// Shares the 0.75 threshold with `point_inside`; see there for why a
391/// boundary point must not count as inside.
392fn inside(winding: &WindingMesh<'_, TriMesh>, point: Point3) -> bool {
393 winding
394 .winding_number(point)
395 .map(|w| w.value > 0.75)
396 .unwrap_or(false)
397}
398
399/// Axis-aligned bounds of a mesh.
400fn mesh_bounds(mesh: &TriMesh) -> Aabb {
401 let mut bounds = Aabb::empty();
402 for p in &mesh.positions {
403 bounds.extend(*p);
404 }
405 bounds
406}