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}