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 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}