ifc_lite_clash/
narrow.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Narrow-phase classification for one candidate element pair. Faithful port
6//! of `packages/clash/src/engine-ts/narrow.ts`: control flow, comparisons,
7//! and result construction match bit-for-bit so the two engines agree.
8
9use crate::aabb::{aabb_contains, bounds_of_points, overlap_bounds, signed_gap, Aabb};
10use crate::depth::{
11    box_penetration, clearly_inside, contained_solid_is_buried, crossing_vertex_penetration,
12    depth_clash_result,
13};
14use crate::triangle::{tri_tri_distance, tri_tri_intersect};
15use crate::tri_mesh::TriMesh;
16use crate::vec3::{centroid, mid, Vec3};
17
18/// Clash classification. Discriminants match the public ABI (`Hard = 0`, etc.).
19#[derive(Clone, Copy, PartialEq, Debug)]
20pub enum ClashStatus {
21    Hard = 0,
22    Clearance = 1,
23    Touch = 2,
24}
25
26/// How `NarrowResult::distance` was obtained. Discriminants match the public
27/// ABI and the TS `ClashDistanceKind` (`Mesh = 0`, `Estimate = 1`).
28///
29/// `Mesh` is a value measured on the triangle meshes; `Estimate` is read off the
30/// two element AABBs (the smallest overlapping box dimension) because no
31/// crossing-triangle vertex of either mesh lies strictly inside the other solid.
32/// The two are not interchangeable, so every result carries its provenance.
33#[derive(Clone, Copy, PartialEq, Debug)]
34pub enum DistanceKind {
35    Mesh = 0,
36    Estimate = 1,
37}
38
39/// The narrow-phase outcome for one element pair.
40pub struct NarrowResult {
41    pub status: ClashStatus,
42    pub distance: f64,
43    pub distance_kind: DistanceKind,
44    pub point: Vec3,
45    pub bounds: Aabb,
46    /// For a `Hard` result, the f32 noise floor of `distance` along the
47    /// direction it was measured (`depth_floor` / `estimate_floor`): the depth
48    /// at or below which this pair would have been `Touch`. `None` otherwise.
49    /// Carried out so the reported touching band is decided by the same rule
50    /// as the verdict (#5639).
51    pub depth_floor: Option<f64>,
52}
53
54/// Run the narrow phase for a candidate element pair.
55///
56/// `mode`: `0` = hard, `1` = clearance. `tolerance` and `clearance` carry the
57/// rule parameters; `report_touch` toggles face-contact reporting. Returns
58/// `None` when the pair is not a clash.
59#[allow(clippy::too_many_arguments)]
60pub fn test_pair(
61    aabb_a: &Aabb,
62    tri_a: &TriMesh,
63    aabb_b: &Aabb,
64    tri_b: &TriMesh,
65    mode: u8,
66    tolerance: f64,
67    clearance: f64,
68    report_touch: bool,
69) -> Option<NarrowResult> {
70    let is_clearance = mode == 1;
71    let margin = tolerance.max(if is_clearance { clearance } else { 0.0 });
72
73    // Iterate the smaller mesh, querying the larger one's BVH.
74    let a_smaller = tri_a.count <= tri_b.count;
75    let (small, large) = if a_smaller {
76        (tri_a, tri_b)
77    } else {
78        (tri_b, tri_a)
79    };
80
81    // A CONTAINED pair (one element's AABB wholly inside the other's) is the
82    // one class whose AABB estimate is fabricated — the signed gap reads the
83    // small element's own extent, not anything about the overlap (#1866) —
84    // so for such pairs collect the crossing triangles: their vertices are
85    // the mesh-level evidence `depth_clash_result`'s f32 noise-floor gate
86    // needs (see `crossing_vertex_penetration` — evidence for the floor test
87    // only, never a reported depth). Allocated only for contained pairs;
88    // every other pair has a genuine AABB overlap bound to floor-test.
89    let contained = aabb_contains(aabb_b, aabb_a) || aabb_contains(aabb_a, aabb_b);
90    let mut cross_small = if contained {
91        Some(vec![false; small.count])
92    } else {
93        None
94    };
95    let mut cross_large = if contained {
96        Some(vec![false; large.count])
97    } else {
98        None
99    };
100
101    let mut intersects = false;
102    let mut contact_sum: [f64; 3] = [0.0, 0.0, 0.0];
103    let mut contact_n: u32 = 0;
104    // Tight contact AABB: min/max of the crossing-triangle contact points, so
105    // a hard verdict reports the local contact region, not the whole-element
106    // AABB overlap (#1362 / #1402).
107    let mut c_min: Vec3 = [f64::INFINITY; 3];
108    let mut c_max: Vec3 = [f64::NEG_INFINITY; 3];
109    // Near-contact AABB for coplanar/flush overlaps (no triangle crossing):
110    // the local touching region, e.g. a wall corner, not the whole-element
111    // AABB intersection (#1362 / #1402).
112    let mut nc_min: Vec3 = [f64::INFINITY; 3];
113    let mut nc_max: Vec3 = [f64::NEG_INFINITY; 3];
114    let mut nc_n: u32 = 0;
115    let mut min_dist = f64::INFINITY;
116    let mut closest_a: Vec3 = aabb_a.min;
117    let mut closest_b: Vec3 = aabb_b.min;
118
119    // Must keep the TS reference's iteration order; not an `enumerate()` shape.
120    #[allow(clippy::needless_range_loop)]
121    for ts in 0..small.count {
122        let sb = small.tri_bounds(ts);
123        let hits = large.query_tris(&sb.inflate(margin));
124        if hits.is_empty() {
125            continue;
126        }
127        let [s0, s1, s2] = small.tri(ts);
128        for tl in hits {
129            let [l0, l1, l2] = large.tri(tl as usize);
130            if tri_tri_intersect(s0, s1, s2, l0, l1, l2) {
131                intersects = true;
132                if let (Some(cs), Some(cl)) = (cross_small.as_mut(), cross_large.as_mut()) {
133                    cs[ts] = true;
134                    cl[tl as usize] = true;
135                }
136                let c = mid(centroid(s0, s1, s2), centroid(l0, l1, l2));
137                contact_sum[0] += c[0];
138                contact_sum[1] += c[1];
139                contact_sum[2] += c[2];
140                contact_n += 1;
141                for i in 0..3 {
142                    if c[i] < c_min[i] {
143                        c_min[i] = c[i];
144                    }
145                    if c[i] > c_max[i] {
146                        c_max[i] = c[i];
147                    }
148                }
149            } else {
150                // Not a crossing: measure the gap (drives clearance/touch) and,
151                // when touching, accumulate into the contact region — even after
152                // a crossing is found, since coincident faces of flush members
153                // register as touches, not crossings, yet carry most of the area.
154                let (dist, p_a, p_b) = tri_tri_distance(s0, s1, s2, l0, l1, l2);
155                if dist < min_dist {
156                    min_dist = dist;
157                    closest_a = p_a;
158                    closest_b = p_b;
159                }
160                if dist <= tolerance {
161                    let cp = mid(p_a, p_b);
162                    nc_n += 1;
163                    for i in 0..3 {
164                        if cp[i] < nc_min[i] {
165                            nc_min[i] = cp[i];
166                        }
167                        if cp[i] > nc_max[i] {
168                            nc_max[i] = cp[i];
169                        }
170                    }
171                }
172            }
173        }
174    }
175
176    let overlap = overlap_bounds(aabb_a, aabb_b);
177
178    // Tight contact region: union of the triangle crossings (c_min/c_max) and
179    // coplanar/flush touches (nc_min/nc_max), clamped to the element overlap —
180    // crossings alone miss coincident faces, near-contacts alone miss angled
181    // crossings. Falls back to the overlap when neither was captured (#1362/#1402).
182    let mut t_min: Vec3 = [f64::INFINITY; 3];
183    let mut t_max: Vec3 = [f64::NEG_INFINITY; 3];
184    let mut t_n: u32 = 0;
185    if contact_n > 0 {
186        for i in 0..3 {
187            if c_min[i] < t_min[i] {
188                t_min[i] = c_min[i];
189            }
190            if c_max[i] > t_max[i] {
191                t_max[i] = c_max[i];
192            }
193        }
194        t_n += 1;
195    }
196    if nc_n > 0 {
197        for i in 0..3 {
198            if nc_min[i] < t_min[i] {
199                t_min[i] = nc_min[i];
200            }
201            if nc_max[i] > t_max[i] {
202                t_max[i] = nc_max[i];
203            }
204        }
205        t_n += 1;
206    }
207    let contact_bounds = if t_n > 0 {
208        // Clamp per-axis to the element overlap (overlap_bounds would degenerate
209        // a disjoint axis to a midpoint that can land OUTSIDE the overlap).
210        let mut min: Vec3 = [0.0; 3];
211        let mut max: Vec3 = [0.0; 3];
212        for i in 0..3 {
213            min[i] = t_min[i].max(overlap.min[i]).min(overlap.max[i]);
214            max[i] = t_max[i].max(overlap.min[i]).min(overlap.max[i]);
215        }
216        Aabb::new(min, max)
217    } else {
218        overlap
219    };
220
221    if intersects {
222        let point: Vec3 = if contact_n > 0 {
223            let n = contact_n as f64;
224            [contact_sum[0] / n, contact_sum[1] / n, contact_sum[2] / n]
225        } else {
226            overlap.center()
227        };
228        // Exact box-box depth when both elements are boxes (see
229        // `box_penetration`); otherwise the AABB overlap — an estimate,
230        // not a measured depth, since it can be a dimension of one element.
231        // `depth_clash_result` applies the f32 floor to EVERY candidate
232        // depth before any estimate-vs-mesh selection, so a pair below the
233        // floor reports `Touch` regardless of which quantity would have been
234        // reported. For a contained pair the crossing-vertex penetration is
235        // that third candidate: the estimate is fabricated there (the small
236        // element's own extent), so without it a flush contained pair —
237        // whose only measurable penetration is f32 noise — would be
238        // promoted to `Hard` at a number that measures nothing (the eight
239        // Infra-Bridge pairs, see `depth_clash_result`). Since #5406 a pair
240        // flush to within the tri-tri predicate's own f32 noise band no
241        // longer crosses at all; this still decides a crossing above that
242        // band but at or below its precision floor.
243        // `None` means "no crossing vertex inside at all" (e.g. a thin member
244        // piercing straight through) — no evidence either way, not evidence
245        // of a sub-floor contact. The deeper side wins, the small side on a
246        // tie (the TS kernel's order).
247        let mesh_evidence = match (cross_small.as_ref(), cross_large.as_ref()) {
248            (Some(cs), Some(cl)) => match (
249                crossing_vertex_penetration(small, large, cs),
250                crossing_vertex_penetration(large, small, cl),
251            ) {
252                (Some(s), Some(l)) if l.depth > s.depth => Some(l),
253                (s, l) => s.or(l),
254            },
255            _ => None,
256        };
257        return depth_clash_result(
258            box_penetration(small, large),
259            (-signed_gap(aabb_a, aabb_b)).max(0.0),
260            mesh_evidence,
261            aabb_a,
262            aabb_b,
263            report_touch,
264            point,
265            contact_bounds,
266        );
267    }
268
269    // Fully-enclosed solid: one element's AABB is wholly inside the other's,
270    // so it may be buried. No surface crossing means the inner solid is
271    // entirely inside OR outside, so ray-casting one probe point of the
272    // contained mesh decides it (correctly "outside" for a concave notch,
273    // unlike an AABB test) — one chosen off the other's surface, see
274    // `contained_solid_is_buried` (#5473). B-contains-A tested first so the
275    // inner pick is deterministic on equal AABBs. Exact box-box depth when
276    // both are boxes; else the AABB gap is an estimate.
277    let enclosed = if aabb_contains(aabb_b, aabb_a) {
278        contained_solid_is_buried(tri_a, tri_b, aabb_a, aabb_b)
279    } else if aabb_contains(aabb_a, aabb_b) {
280        contained_solid_is_buried(tri_b, tri_a, aabb_a, aabb_b)
281    } else {
282        false
283    };
284    if enclosed {
285        // May legitimately return `None` (below the f32 floor and
286        // `!report_touch`) — a suppressed touch, not "no clash" — so return
287        // it as-is rather than falling through further.
288        return depth_clash_result(
289            box_penetration(small, large),
290            -signed_gap(aabb_a, aabb_b),
291            None,
292            aabb_a,
293            aabb_b,
294            report_touch,
295            overlap.center(),
296            overlap,
297        );
298    }
299
300    if min_dist == f64::INFINITY {
301        // Broad-phase candidate with no triangle-level proximity — not a clash.
302        return None;
303    }
304
305    // Surfaces coincide/touch with no genuine crossing, but the AABBs
306    // penetrate beyond tolerance (coplanar surfaces, e.g. axis-aligned
307    // boxes). AABB penetration ALONE is not enough: skewed/abutting members
308    // sharing only a face have overlapping AABBs yet no shared volume (old
309    // false-hard-clash bug, #1362). Confirm a real shared volume by probing
310    // for an interior point inside BOTH solids — the vertex-centroid midpoint
311    // (skewed overlaps) or the AABB-overlap centre (unequal-length aligned
312    // overlaps); a bare face touch has neither. Accept if EITHER qualifies.
313    if min_dist <= tolerance {
314        let gap = signed_gap(aabb_a, aabb_b);
315        if gap < -tolerance {
316            let probe_centroid = mid(tri_a.vertex_centroid(), tri_b.vertex_centroid());
317            let probe_overlap = overlap.center();
318            // Each probe counts only when it is CLEARLY inside both solids:
319            // for a flush pair the AABB-overlap centre sits ON the shared
320            // face, where ray parity is a coin flip, and a lucky flip used to
321            // report the pair Hard at the AABB estimate (an element
322            // dimension, not a depth) — differently wherever the model sat
323            // (#5751). Same rule as `contained_solid_is_buried`.
324            let inside_both = |p: Vec3| {
325                clearly_inside(tri_a, p, aabb_a, aabb_b) && clearly_inside(tri_b, p, aabb_a, aabb_b)
326            };
327            if inside_both(probe_centroid) || inside_both(probe_overlap) {
328                // Tight contact region (clamped to the element overlap, not the
329                // whole-element AABB intersection, #1362/#1402). Exact box-box
330                // depth when both are boxes. May legitimately return `None`
331                // (below the f32 floor and `!report_touch`) — a suppressed
332                // touch, not "no shared volume" — so return it as-is rather
333                // than falling through to the face-touch handling meant for
334                // the "probes failed" case below.
335                return depth_clash_result(
336                    box_penetration(small, large),
337                    -gap,
338                    None,
339                    aabb_a,
340                    aabb_b,
341                    report_touch,
342                    mid(closest_a, closest_b),
343                    contact_bounds,
344                );
345            }
346            // Only a face touch (no shared volume): fall through to the touch
347            // handling below, which suppresses it unless report_touch is set.
348        }
349    }
350
351    // Clearance rule: ANY gap within the required clearance is a violation,
352    // including sub-tolerance, nearly-touching gaps (the most severe). These
353    // must not be swallowed by the touch band below.
354    if is_clearance && min_dist <= clearance {
355        return Some(NarrowResult {
356            status: ClashStatus::Clearance,
357            distance: min_dist,
358            // `min_dist` is an exact triangle-to-triangle distance.
359            distance_kind: DistanceKind::Mesh,
360            point: mid(closest_a, closest_b),
361            bounds: bounds_of_points(closest_a, closest_b),
362            depth_floor: None,
363        });
364    }
365
366    // Otherwise only bare contact within tolerance remains; suppressed unless the
367    // rule opts in. `<=` so an exact touch at tolerance 0 is still caught.
368    if min_dist <= tolerance {
369        if !report_touch {
370            return None;
371        }
372        return Some(NarrowResult {
373            status: ClashStatus::Touch,
374            distance: min_dist,
375            distance_kind: DistanceKind::Mesh,
376            point: mid(closest_a, closest_b),
377            bounds: bounds_of_points(closest_a, closest_b),
378            depth_floor: None,
379        });
380    }
381
382    None
383}