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}