Skip to main content

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}