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};
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 pub point: [f64; 3],
27 /// `[minx, miny, minz, maxx, maxy, maxz]`.
28 pub bounds: [f64; 6],
29}
30
31/// The records produced by a single rule run.
32pub struct RuleResult {
33 pub records: Vec<ClashRecord>,
34}
35
36/// Per-element geometry plus a lazily built per-triangle BVH.
37struct Element {
38 aabb: Aabb,
39 positions: Vec<f64>,
40 indices: Vec<u32>,
41 /// Built on first narrow-phase use, then cached for the session lifetime.
42 mesh: RefCell<Option<TriMesh>>,
43}
44
45/// A clash session: stores per-element geometry, element AABBs, and caches the
46/// per-element triangle BVHs that the narrow phase needs.
47pub struct ClashSession {
48 elements: Vec<Element>,
49}
50
51impl Default for ClashSession {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl ClashSession {
58 pub fn new() -> Self {
59 Self {
60 elements: Vec::new(),
61 }
62 }
63
64 /// Ingest `N` elements from flat arenas.
65 ///
66 /// - `positions`: concatenated per-element vertex coords (`x, y, z, ...`).
67 /// - `pos_ranges`: 2 per element = `[float_offset, float_len]`.
68 /// - `indices`: concatenated per-element LOCAL (0-based within that
69 /// element's vertices) triangle indices.
70 /// - `idx_ranges`: 2 per element = `[idx_offset, idx_len]`.
71 /// - `aabbs`: 6 per element = `[minx, miny, minz, maxx, maxy, maxz]`.
72 ///
73 /// Vertex/AABB coords are `f32`-sourced; they are stored and computed in
74 /// `f64`.
75 pub fn ingest(
76 &mut self,
77 positions: &[f32],
78 pos_ranges: &[u32],
79 indices: &[u32],
80 idx_ranges: &[u32],
81 aabbs: &[f32],
82 ) {
83 // Reset first, so a reused session does not accumulate stale elements.
84 self.elements.clear();
85 let n = pos_ranges.len() / 2;
86 self.elements.reserve(n);
87 for e in 0..n {
88 // Default to an empty element so global indices stay aligned with the
89 // caller's arena even if this element's slices are malformed — no
90 // panic (which under `panic = abort` would poison the shared wasm
91 // module); it simply never produces a clash.
92 let mut element_positions: Vec<f64> = Vec::new();
93 let mut element_indices: Vec<u32> = Vec::new();
94 let mut aabb = Aabb::new([0.0; 3], [0.0; 3]);
95
96 let ranges_ok = e * 2 + 1 < idx_ranges.len() && e * 6 + 5 < aabbs.len();
97 if ranges_ok {
98 let pos_off = pos_ranges[e * 2] as usize;
99 let pos_len = pos_ranges[e * 2 + 1] as usize;
100 let idx_off = idx_ranges[e * 2] as usize;
101 let idx_len = idx_ranges[e * 2 + 1] as usize;
102 if pos_off
103 .checked_add(pos_len)
104 .is_some_and(|end| end <= positions.len())
105 && idx_off
106 .checked_add(idx_len)
107 .is_some_and(|end| end <= indices.len())
108 {
109 element_positions = positions[pos_off..pos_off + pos_len]
110 .iter()
111 .map(|&v| v as f64)
112 .collect();
113 element_indices = indices[idx_off..idx_off + idx_len].to_vec();
114 let ab = e * 6;
115 aabb = Aabb::new(
116 [aabbs[ab] as f64, aabbs[ab + 1] as f64, aabbs[ab + 2] as f64],
117 [aabbs[ab + 3] as f64, aabbs[ab + 4] as f64, aabbs[ab + 5] as f64],
118 );
119 }
120 }
121
122 self.elements.push(Element {
123 aabb,
124 positions: element_positions,
125 indices: element_indices,
126 mesh: RefCell::new(None),
127 });
128 }
129 }
130
131 /// Build (or reuse) the cached triangle mesh for global element `idx` and
132 /// run `f` against it. Avoids re-borrowing the `RefCell` across the call.
133 fn with_mesh<R>(&self, idx: u32, f: impl FnOnce(&TriMesh) -> R) -> R {
134 let element = &self.elements[idx as usize];
135 {
136 let mut slot = element.mesh.borrow_mut();
137 if slot.is_none() {
138 *slot = Some(TriMesh::new(
139 element.positions.clone(),
140 element.indices.clone(),
141 ));
142 }
143 }
144 let slot = element.mesh.borrow();
145 f(slot.as_ref().expect("mesh built above"))
146 }
147
148 /// Run one rule.
149 ///
150 /// `group_a` / `group_b` are GLOBAL element indices. An empty `group_b`
151 /// requests a self-clash within `group_a` (pairs with `i < j` by position
152 /// in `group_a`). `mode`: `0` = hard, `1` = clearance. Records carry GLOBAL
153 /// element indices.
154 #[allow(clippy::too_many_arguments)]
155 pub fn run_rule(
156 &self,
157 group_a: &[u32],
158 group_b: &[u32],
159 mode: u8,
160 tolerance: f64,
161 clearance: f64,
162 report_touch: bool,
163 ) -> RuleResult {
164 let is_clearance = mode == 1;
165 let margin = tolerance.max(if is_clearance { clearance } else { 0.0 });
166
167 let pairs = self.candidate_pairs(group_a, group_b, margin);
168
169 let mut records = Vec::new();
170 for (a_global, b_global) in pairs {
171 let result = self.with_mesh(a_global, |mesh_a| {
172 self.with_mesh(b_global, |mesh_b| {
173 test_pair(
174 &self.elements[a_global as usize].aabb,
175 mesh_a,
176 &self.elements[b_global as usize].aabb,
177 mesh_b,
178 mode,
179 tolerance,
180 clearance,
181 report_touch,
182 )
183 })
184 });
185 if let Some(r) = result {
186 records.push(ClashRecord {
187 a: a_global,
188 b: b_global,
189 status: r.status,
190 distance: r.distance,
191 point: r.point,
192 bounds: [
193 r.bounds.min[0],
194 r.bounds.min[1],
195 r.bounds.min[2],
196 r.bounds.max[0],
197 r.bounds.max[1],
198 r.bounds.max[2],
199 ],
200 });
201 }
202 }
203
204 RuleResult { records }
205 }
206
207 /// Broad-phase candidate global-index pairs.
208 ///
209 /// Builds a BVH over `group_a`'s element AABBs. For a group pair, each
210 /// `group_b` element queries inflated by `margin`; duplicates are removed
211 /// and identical element indices are skipped. For self-clash (`group_b`
212 /// empty) each `group_a` element queries the BVH, keeping pairs whose
213 /// position in `group_a` satisfies `i < j`.
214 fn candidate_pairs(
215 &self,
216 group_a_in: &[u32],
217 group_b_in: &[u32],
218 margin: f64,
219 ) -> Vec<(u32, u32)> {
220 // Defensively drop any out-of-range global indices at the public boundary.
221 let n = self.elements.len() as u32;
222 let group_a: Vec<u32> = group_a_in.iter().copied().filter(|&g| g < n).collect();
223 let group_b: Vec<u32> = group_b_in.iter().copied().filter(|&g| g < n).collect();
224 if group_a.is_empty() {
225 return Vec::new();
226 }
227
228 // BVH item id is the POSITION in group_a, so query hits map back to the
229 // group_a slot (and thus the global element index).
230 let items: Vec<(u32, Aabb)> = group_a
231 .iter()
232 .enumerate()
233 .map(|(i, &g)| (i as u32, self.elements[g as usize].aabb))
234 .collect();
235 let bvh = Bvh::build(&items);
236
237 let mut pairs: Vec<(u32, u32)> = Vec::new();
238
239 if !group_b.is_empty() {
240 let mut seen: HashSet<(u32, u32)> = HashSet::new();
241 for &b_global in &group_b {
242 let b_aabb = self.elements[b_global as usize].aabb;
243 let hits = bvh.query_aabb(&b_aabb.inflate(margin));
244 for i in hits {
245 let a_global = group_a[i as usize];
246 // Skip identical element index (same entity).
247 if a_global == b_global {
248 continue;
249 }
250 let dedup = if a_global < b_global {
251 (a_global, b_global)
252 } else {
253 (b_global, a_global)
254 };
255 if !seen.insert(dedup) {
256 continue;
257 }
258 pairs.push((a_global, b_global));
259 }
260 }
261 } else {
262 for (i, &a_global) in group_a.iter().enumerate() {
263 let a_aabb = self.elements[a_global as usize].aabb;
264 let hits = bvh.query_aabb(&a_aabb.inflate(margin));
265 for j in hits {
266 let j = j as usize;
267 if j <= i {
268 continue;
269 }
270 let b_global = group_a[j];
271 // Skip identical element index (same entity).
272 if a_global == b_global {
273 continue;
274 }
275 pairs.push((a_global, b_global));
276 }
277 }
278 }
279
280 pairs
281 }
282}