ifc_lite_clash/session.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//! Public clash session: ingest element geometry, then run rules.
6//!
7//! The broad phase mirrors `packages/clash/src/engine-ts/broad.ts` (BVH over
8//! group_a element AABBs, query each group_b element inflated by `margin`); the
9//! narrow phase delegates to [`crate::narrow::test_pair`]. Records carry GLOBAL
10//! element indices.
11
12use std::cell::RefCell;
13use std::collections::HashSet;
14
15use crate::aabb::Aabb;
16use crate::bvh::Bvh;
17use crate::narrow::{test_pair, ClashStatus, DistanceKind};
18use crate::tri_mesh::TriMesh;
19
20/// One classified clash between two elements (GLOBAL element indices).
21pub struct ClashRecord {
22 pub a: u32,
23 pub b: u32,
24 pub status: ClashStatus,
25 pub distance: f64,
26 /// Whether `distance` was measured on the meshes or estimated from the AABBs.
27 pub distance_kind: DistanceKind,
28 pub point: [f64; 3],
29 /// `[minx, miny, minz, maxx, maxy, maxz]`.
30 pub bounds: [f64; 6],
31}
32
33/// The records produced by a single rule run.
34pub struct RuleResult {
35 pub records: Vec<ClashRecord>,
36}
37
38/// Per-element geometry plus a lazily built per-triangle BVH.
39struct Element {
40 aabb: Aabb,
41 positions: Vec<f64>,
42 indices: Vec<u32>,
43 /// Built on first narrow-phase use, then cached for the session lifetime.
44 mesh: RefCell<Option<TriMesh>>,
45}
46
47/// A clash session: stores per-element geometry, element AABBs, and caches the
48/// per-element triangle BVHs that the narrow phase needs.
49pub struct ClashSession {
50 elements: Vec<Element>,
51}
52
53impl Default for ClashSession {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl ClashSession {
60 pub fn new() -> Self {
61 Self {
62 elements: Vec::new(),
63 }
64 }
65
66 /// Ingest `N` elements from flat arenas.
67 ///
68 /// - `positions`: concatenated per-element vertex coords (`x, y, z, ...`).
69 /// - `pos_ranges`: 2 per element = `[float_offset, float_len]`.
70 /// - `indices`: concatenated per-element LOCAL (0-based within that
71 /// element's vertices) triangle indices.
72 /// - `idx_ranges`: 2 per element = `[idx_offset, idx_len]`.
73 /// - `aabbs`: 6 per element = `[minx, miny, minz, maxx, maxy, maxz]`.
74 ///
75 /// Vertex/AABB coords are `f32`-sourced; they are stored and computed in
76 /// `f64`.
77 pub fn ingest(
78 &mut self,
79 positions: &[f32],
80 pos_ranges: &[u32],
81 indices: &[u32],
82 idx_ranges: &[u32],
83 aabbs: &[f32],
84 ) {
85 // Reset first, so a reused session does not accumulate stale elements.
86 self.elements.clear();
87 let n = pos_ranges.len() / 2;
88 self.elements.reserve(n);
89 for e in 0..n {
90 // Default to an empty element so global indices stay aligned with the
91 // caller's arena even if this element's slices are malformed — no
92 // panic (which under `panic = abort` would poison the shared wasm
93 // module); it simply never produces a clash.
94 let mut element_positions: Vec<f64> = Vec::new();
95 let mut element_indices: Vec<u32> = Vec::new();
96 let mut aabb = Aabb::new([0.0; 3], [0.0; 3]);
97
98 let ranges_ok = e * 2 + 1 < idx_ranges.len() && e * 6 + 5 < aabbs.len();
99 if ranges_ok {
100 let pos_off = pos_ranges[e * 2] as usize;
101 let pos_len = pos_ranges[e * 2 + 1] as usize;
102 let idx_off = idx_ranges[e * 2] as usize;
103 let idx_len = idx_ranges[e * 2 + 1] as usize;
104 if pos_off
105 .checked_add(pos_len)
106 .is_some_and(|end| end <= positions.len())
107 && idx_off
108 .checked_add(idx_len)
109 .is_some_and(|end| end <= indices.len())
110 {
111 element_positions = positions[pos_off..pos_off + pos_len]
112 .iter()
113 .map(|&v| v as f64)
114 .collect();
115 element_indices = indices[idx_off..idx_off + idx_len].to_vec();
116 let ab = e * 6;
117 aabb = Aabb::new(
118 [aabbs[ab] as f64, aabbs[ab + 1] as f64, aabbs[ab + 2] as f64],
119 [aabbs[ab + 3] as f64, aabbs[ab + 4] as f64, aabbs[ab + 5] as f64],
120 );
121 }
122 }
123
124 self.elements.push(Element {
125 aabb,
126 positions: element_positions,
127 indices: element_indices,
128 mesh: RefCell::new(None),
129 });
130 }
131 }
132
133 /// Build (or reuse) the cached triangle mesh for global element `idx` and
134 /// run `f` against it. Avoids re-borrowing the `RefCell` across the call.
135 fn with_mesh<R>(&self, idx: u32, f: impl FnOnce(&TriMesh) -> R) -> R {
136 let element = &self.elements[idx as usize];
137 {
138 let mut slot = element.mesh.borrow_mut();
139 if slot.is_none() {
140 *slot = Some(TriMesh::new(
141 element.positions.clone(),
142 element.indices.clone(),
143 ));
144 }
145 }
146 let slot = element.mesh.borrow();
147 f(slot.as_ref().expect("mesh built above"))
148 }
149
150 /// Run one rule.
151 ///
152 /// `group_a` / `group_b` are GLOBAL element indices. `group_b` is
153 /// `None` for a self-clash within `group_a` (pairs with `i < j` by
154 /// position in `group_a`), and `Some` for a two-sided rule. `Some(&[])`
155 /// is a two-sided rule whose B side matched nothing, and yields no
156 /// pairs -- it is NOT a self-clash (#5354).
157 ///
158 /// The distinction has to be carried in the type: encoding self-clash as
159 /// an empty `group_b` made "the caller named a B side that matched
160 /// nothing" and "the caller named no B side" the same call, so a rule
161 /// filtered down to an empty B silently ran as A-vs-A.
162 ///
163 /// `mode`: `0` = hard, `1` = clearance. Records carry GLOBAL element
164 /// indices.
165 #[allow(clippy::too_many_arguments)]
166 pub fn run_rule(
167 &self,
168 group_a: &[u32],
169 group_b: Option<&[u32]>,
170 mode: u8,
171 tolerance: f64,
172 clearance: f64,
173 report_touch: bool,
174 ) -> RuleResult {
175 self.run_rule_with_depth_floors(group_a, group_b, mode, tolerance, clearance, report_touch)
176 .0
177 }
178
179 /// [`Self::run_rule`], plus one entry per record: for a `Hard` record, the
180 /// f32 noise floor of its `distance` along the direction that depth was
181 /// measured — the depth at or below which the pair would have been
182 /// `Touch` (the classification floor, #5405). `None` for every other
183 /// status. A consumer deciding whether a reported clash is "really a
184 /// contact" (#5639) applies this same floor instead of re-deriving a band
185 /// from the record's coordinates. A separate method, not a
186 /// `ClashRecord` field, so the public record type does not change shape.
187 #[allow(clippy::too_many_arguments)]
188 pub fn run_rule_with_depth_floors(
189 &self,
190 group_a: &[u32],
191 group_b: Option<&[u32]>,
192 mode: u8,
193 tolerance: f64,
194 clearance: f64,
195 report_touch: bool,
196 ) -> (RuleResult, Vec<Option<f64>>) {
197 let is_clearance = mode == 1;
198 let margin = tolerance.max(if is_clearance { clearance } else { 0.0 });
199
200 let pairs = self.candidate_pairs(group_a, group_b, margin);
201
202 let mut records = Vec::new();
203 let mut depth_floors = Vec::new();
204 for (a_global, b_global) in pairs {
205 let result = self.with_mesh(a_global, |mesh_a| {
206 self.with_mesh(b_global, |mesh_b| {
207 test_pair(
208 &self.elements[a_global as usize].aabb,
209 mesh_a,
210 &self.elements[b_global as usize].aabb,
211 mesh_b,
212 mode,
213 tolerance,
214 clearance,
215 report_touch,
216 )
217 })
218 });
219 if let Some(r) = result {
220 depth_floors.push(r.depth_floor);
221 records.push(ClashRecord {
222 a: a_global,
223 b: b_global,
224 status: r.status,
225 distance: r.distance,
226 distance_kind: r.distance_kind,
227 point: r.point,
228 bounds: [
229 r.bounds.min[0],
230 r.bounds.min[1],
231 r.bounds.min[2],
232 r.bounds.max[0],
233 r.bounds.max[1],
234 r.bounds.max[2],
235 ],
236 });
237 }
238 }
239
240 (RuleResult { records }, depth_floors)
241 }
242
243 /// Broad-phase candidate global-index pairs.
244 ///
245 /// Builds a BVH over `group_a`'s element AABBs. For a two-sided rule
246 /// (`Some`), each `group_b` element queries inflated by `margin`;
247 /// duplicates are removed and identical element indices are skipped. For
248 /// a self-clash (`None`) each `group_a` element queries the BVH, keeping
249 /// pairs whose position in `group_a` satisfies `i < j`.
250 fn candidate_pairs(
251 &self,
252 group_a_in: &[u32],
253 group_b_in: Option<&[u32]>,
254 margin: f64,
255 ) -> Vec<(u32, u32)> {
256 // Defensively drop any out-of-range global indices at the public boundary.
257 // The filter runs INSIDE the `Some` arm on purpose: filtering first and
258 // then branching on emptiness would turn a two-sided rule whose B
259 // indices are all out of range back into a self-clash, which is the
260 // same conflation as #5354 by another route.
261 let n = self.elements.len() as u32;
262 let group_a: Vec<u32> = group_a_in.iter().copied().filter(|&g| g < n).collect();
263 let group_b: Option<Vec<u32>> =
264 group_b_in.map(|b| b.iter().copied().filter(|&g| g < n).collect());
265 if group_a.is_empty() {
266 return Vec::new();
267 }
268
269 // BVH item id is the POSITION in group_a, so query hits map back to the
270 // group_a slot (and thus the global element index).
271 let items: Vec<(u32, Aabb)> = group_a
272 .iter()
273 .enumerate()
274 .map(|(i, &g)| (i as u32, self.elements[g as usize].aabb))
275 .collect();
276 let bvh = Bvh::build(&items);
277
278 let mut pairs: Vec<(u32, u32)> = Vec::new();
279
280 if let Some(group_b) = group_b {
281 let mut seen: HashSet<(u32, u32)> = HashSet::new();
282 for &b_global in &group_b {
283 let b_aabb = self.elements[b_global as usize].aabb;
284 let hits = bvh.query_aabb(&b_aabb.inflate(margin));
285 for i in hits {
286 let a_global = group_a[i as usize];
287 // Skip identical element index (same entity).
288 if a_global == b_global {
289 continue;
290 }
291 let dedup = if a_global < b_global {
292 (a_global, b_global)
293 } else {
294 (b_global, a_global)
295 };
296 if !seen.insert(dedup) {
297 continue;
298 }
299 pairs.push((a_global, b_global));
300 }
301 }
302 } else {
303 for (i, &a_global) in group_a.iter().enumerate() {
304 let a_aabb = self.elements[a_global as usize].aabb;
305 let hits = bvh.query_aabb(&a_aabb.inflate(margin));
306 for j in hits {
307 let j = j as usize;
308 if j <= i {
309 continue;
310 }
311 let b_global = group_a[j];
312 // Skip identical element index (same entity).
313 if a_global == b_global {
314 continue;
315 }
316 pairs.push((a_global, b_global));
317 }
318 }
319 }
320
321 pairs
322 }
323}