brep_render/engine_state/
interference.rs1use super::*;
2use crate::camera::Aabb;
3
4const MAX_BOOLEAN_PAIRS: usize = 64;
29
30const VOLUME_EPSILON: f64 = 1e-6;
33
34#[derive(Debug, Clone, PartialEq)]
39pub struct InterferencePair {
40 pub a: String,
41 pub b: String,
42 pub volume: f64,
43 pub a_hidden: bool,
44 pub b_hidden: bool,
45}
46
47#[derive(Debug, Clone, Default, PartialEq)]
50pub struct InterferenceReport {
51 pub component_count: usize,
53 pub pair_total: usize,
55 pub booleans_run: usize,
58 pub pairs: Vec<InterferencePair>,
60 pub skipped: Vec<String>,
63 pub unverified: Vec<String>,
67}
68
69#[cfg(test)]
72thread_local! {
73 static BOOLEAN_LANE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
74}
75
76fn note_boolean_call() {
78 #[cfg(test)]
79 BOOLEAN_LANE_CALLS.with(|calls| calls.set(calls.get() + 1));
80}
81
82#[cfg(test)]
83pub(crate) fn boolean_lane_calls() -> usize {
84 BOOLEAN_LANE_CALLS.with(|calls| calls.get())
85}
86
87#[cfg(test)]
88pub(crate) fn reset_boolean_lane_calls() {
89 BOOLEAN_LANE_CALLS.with(|calls| calls.set(0));
90}
91
92fn intersect_volume(a: u32, b: u32) -> Result<f64, String> {
101 note_boolean_call();
102 let result = brep_kernel::boolean_handle_native(
103 a,
104 b,
105 brep_kernel::BooleanOperation::Intersect,
106 &brep_kernel::BooleanOptions::default(),
107 )?;
108 let volume = brep_kernel::mass_properties_handle_native(result, 1.0).map(|p| p.volume);
109 brep_kernel::free_solid(result);
111 volume
112}
113
114pub(crate) struct PlanComponent {
119 pub id: String,
120 pub bbox: Aabb,
121 pub hidden: bool,
122}
123
124pub(crate) struct PairPlan {
127 pub boolean_pairs: Vec<(usize, usize)>,
128 pub skipped: Vec<String>,
129 pub pair_total: usize,
130}
131
132fn inflated(bbox: &Aabb) -> Aabb {
137 if bbox.is_empty() {
138 return *bbox;
139 }
140 let size = bbox.size();
141 let diagonal = (size[0] * size[0] + size[1] * size[1] + size[2] * size[2]).sqrt();
142 let margin = (diagonal * 0.01).max(1e-6);
143 let mut out = *bbox;
144 for axis in 0..3 {
145 out.min[axis] -= margin;
146 out.max[axis] += margin;
147 }
148 out
149}
150
151fn overlaps(a: &Aabb, b: &Aabb) -> bool {
153 if a.is_empty() || b.is_empty() {
154 return false;
155 }
156 (0..3).all(|axis| a.min[axis] <= b.max[axis] && b.min[axis] <= a.max[axis])
157}
158
159pub(crate) fn plan_pairs(components: &[PlanComponent], cap: usize) -> PairPlan {
163 let mut plan = PairPlan {
164 boolean_pairs: Vec::new(),
165 skipped: Vec::new(),
166 pair_total: components.len().saturating_sub(1) * components.len() / 2,
167 };
168 for component in components {
169 if component.bbox.is_empty() {
170 plan.skipped
171 .push(format!("{} — no resident geometry, not checked", component.id));
172 }
173 }
174 let boxes: Vec<Aabb> = components.iter().map(|c| inflated(&c.bbox)).collect();
175 for i in 0..components.len() {
176 for j in (i + 1)..components.len() {
177 if !overlaps(&boxes[i], &boxes[j]) {
178 continue; }
180 if plan.boolean_pairs.len() >= cap {
181 plan.skipped.push(format!(
182 "{} × {} — skipped (boolean budget of {cap} pairs reached)",
183 components[i].id, components[j].id
184 ));
185 continue;
186 }
187 plan.boolean_pairs.push((i, j));
188 }
189 }
190 plan
191}
192
193impl EngineState {
196 pub fn interference_check(&mut self) -> InterferenceReport {
201 self.ensure_assembly_synced();
202
203 let ids = self.component_ids();
206 let mut members: Vec<Vec<String>> = Vec::with_capacity(ids.len());
207 let mut plan_input: Vec<PlanComponent> = Vec::with_capacity(ids.len());
208 for id in &ids {
209 let info = self.component_info(id);
210 let solids = info.map(|info| info.members).unwrap_or_default();
211 let mut bbox = Aabb::empty();
212 let mut hidden = false;
213 for name in &solids {
214 if let Some(solid) = self.scene.solid(name) {
215 bbox.union(&solid.bbox);
216 hidden |= !solid.visible;
217 }
218 }
219 plan_input.push(PlanComponent {
220 id: id.clone(),
221 bbox,
222 hidden,
223 });
224 members.push(solids);
225 }
226
227 let plan = plan_pairs(&plan_input, MAX_BOOLEAN_PAIRS);
228 let mut report = InterferenceReport {
229 component_count: ids.len(),
230 pair_total: plan.pair_total,
231 booleans_run: 0,
232 pairs: Vec::new(),
233 skipped: plan.skipped,
234 unverified: Vec::new(),
235 };
236 if plan.boolean_pairs.is_empty() {
237 return report;
238 }
239
240 let handles = self.resident_solid_handles();
243 for (i, j) in plan.boolean_pairs {
244 report.booleans_run += 1;
245 let mut volume = 0.0;
246 let mut refusal: Option<String> = None;
247 for solid_a in &members[i] {
248 for solid_b in &members[j] {
249 let (Some(&ha), Some(&hb)) = (handles.get(solid_a), handles.get(solid_b))
250 else {
251 continue; };
253 let (Some(a), Some(b)) =
256 (self.scene.solid(solid_a), self.scene.solid(solid_b))
257 else {
258 continue;
259 };
260 if !overlaps(&inflated(&a.bbox), &inflated(&b.bbox)) {
261 continue;
262 }
263 match intersect_volume(ha, hb) {
264 Ok(v) => volume += v,
265 Err(error) => {
266 refusal.get_or_insert(error);
270 }
271 }
272 }
273 }
274 let (a, b) = (&plan_input[i], &plan_input[j]);
275 if let Some(error) = refusal {
276 report
277 .unverified
278 .push(format!("{} × {} — boolean refused: {error}", a.id, b.id));
279 }
280 if volume > VOLUME_EPSILON {
281 report.pairs.push(InterferencePair {
282 a: a.id.clone(),
283 b: b.id.clone(),
284 volume,
285 a_hidden: a.hidden,
286 b_hidden: b.hidden,
287 });
288 }
289 }
290 report
292 .pairs
293 .sort_by(|x, y| y.volume.total_cmp(&x.volume));
294 report
295 }
296}
297
298#[cfg(test)]
305mod tests {
306 use super::super::components::component_fixtures::two_instance_assembly_json;
307 use super::*;
308
309 fn two_cube_engine(second_translate: [f64; 3]) -> EngineState {
312 brep_kernel::clear_history_cache();
313 reset_boolean_lane_calls();
314 let mut doc: serde_json::Value =
315 serde_json::from_str(&two_instance_assembly_json()).unwrap();
316 doc["features"][1]["inputParams"]["transform"]["translate"] =
317 serde_json::json!(second_translate);
318 let mut engine = EngineState::new();
319 engine.set_history_json(&doc.to_string()).expect("fixture loads");
320 engine
321 }
322
323 #[test]
327 fn overlapping_cubes_report_the_pair_with_the_known_volume() {
328 let mut engine = two_cube_engine([5.0, 0.0, 0.0]);
329 let report = engine.interference_check();
330 assert_eq!(report.component_count, 2);
331 assert_eq!(report.pair_total, 1);
332 assert_eq!(report.booleans_run, 1);
333 assert_eq!(report.pairs.len(), 1, "{report:?}");
334 let pair = &report.pairs[0];
335 assert_eq!((pair.a.as_str(), pair.b.as_str()), ("ACOMP1", "ACOMP2"));
336 assert!(
337 (pair.volume - 500.0).abs() < 1e-6,
338 "5×10×10 overlap: {}",
339 pair.volume
340 );
341 assert!(!pair.a_hidden && !pair.b_hidden);
342 assert!(report.skipped.is_empty() && report.unverified.is_empty());
343 }
344
345 #[test]
348 fn separated_cubes_are_all_clear_without_any_boolean() {
349 let mut engine = two_cube_engine([20.0, 0.0, 0.0]);
350 let report = engine.interference_check();
351 assert_eq!(report.pair_total, 1);
352 assert!(report.pairs.is_empty(), "{report:?}");
353 assert_eq!(report.booleans_run, 0, "prefilter proves the pair clear");
354 assert_eq!(boolean_lane_calls(), 0, "the boolean lane was not invoked");
355 assert!(report.skipped.is_empty() && report.unverified.is_empty());
356 }
357
358 #[test]
360 fn three_components_check_three_pairs() {
361 brep_kernel::clear_history_cache();
362 reset_boolean_lane_calls();
363 let mut doc: serde_json::Value =
364 serde_json::from_str(&two_instance_assembly_json()).unwrap();
365 doc["features"][1]["inputParams"]["transform"]["translate"] =
366 serde_json::json!([5.0, 0.0, 0.0]);
367 let mut third = doc["features"][1].clone();
368 third["inputParams"]["id"] = serde_json::json!("ACOMP3");
369 third["inputParams"]["transform"]["translate"] = serde_json::json!([2.0, 0.0, 0.0]);
370 doc["features"].as_array_mut().unwrap().push(third);
371 let mut engine = EngineState::new();
372 engine.set_history_json(&doc.to_string()).expect("fixture loads");
373
374 let report = engine.interference_check();
375 assert_eq!(report.component_count, 3);
376 assert_eq!(report.pair_total, 3, "N(N−1)/2");
377 assert_eq!(report.booleans_run, 3);
378 assert_eq!(report.pairs.len(), 3, "{report:?}");
379 let key = |p: &InterferencePair| (p.a.clone(), p.b.clone(), p.volume);
381 let got: Vec<_> = report.pairs.iter().map(key).collect();
382 assert_eq!(got[0].0, "ACOMP1");
383 assert_eq!(got[0].1, "ACOMP3");
384 assert!((got[0].2 - 800.0).abs() < 1e-6, "{got:?}");
385 assert_eq!(got[1].0, "ACOMP2");
386 assert_eq!(got[1].1, "ACOMP3");
387 assert!((got[1].2 - 700.0).abs() < 1e-6, "{got:?}");
388 assert_eq!(got[2].0, "ACOMP1");
389 assert_eq!(got[2].1, "ACOMP2");
390 assert!((got[2].2 - 500.0).abs() < 1e-6, "{got:?}");
391 }
392
393 #[test]
396 fn hidden_participant_still_checked_and_flagged() {
397 let mut engine = two_cube_engine([5.0, 0.0, 0.0]);
398 assert!(engine.scene.set_visible("ACOMP2:Part", false));
399 let report = engine.interference_check();
400 assert_eq!(report.pairs.len(), 1, "hidden ACOMP2 still participates");
401 let pair = &report.pairs[0];
402 assert!((pair.volume - 500.0).abs() < 1e-6);
403 assert!(!pair.a_hidden, "ACOMP1 is visible");
404 assert!(pair.b_hidden, "ACOMP2 is hidden and flagged");
405 }
406
407 #[test]
412 fn exact_face_contact_is_never_false_interference() {
413 let mut engine = two_cube_engine([10.0, 0.0, 0.0]);
414 let report = engine.interference_check();
415 assert_eq!(report.booleans_run, 1, "inflated boxes overlap at contact");
416 assert!(
417 report.pairs.is_empty(),
418 "face contact must not read as interference: {report:?}"
419 );
420 assert!(report.skipped.is_empty());
421 assert_eq!(report.unverified.len(), 1, "{report:?}");
422 assert!(
423 report.unverified[0].starts_with("ACOMP1 × ACOMP2 — boolean refused:"),
424 "{:?}",
425 report.unverified
426 );
427 }
428
429 #[test]
433 fn planner_prefilters_caps_and_notes() {
434 let cube = |origin: [f64; 3]| Aabb {
435 min: origin,
436 max: [origin[0] + 1.0, origin[1] + 1.0, origin[2] + 1.0],
437 };
438 let component = |id: &str, bbox: Aabb| PlanComponent {
439 id: id.to_string(),
440 bbox,
441 hidden: false,
442 };
443
444 let plan = plan_pairs(
446 &[
447 component("ACOMP1", cube([0.0; 3])),
448 component("ACOMP2", cube([10.0, 0.0, 0.0])),
449 ],
450 8,
451 );
452 assert_eq!(plan.pair_total, 1);
453 assert!(plan.boolean_pairs.is_empty());
454 assert!(plan.skipped.is_empty());
455
456 let coincident: Vec<PlanComponent> = (1..=4)
459 .map(|n| component(&format!("ACOMP{n}"), cube([0.0; 3])))
460 .collect();
461 let plan = plan_pairs(&coincident, 2);
462 assert_eq!(plan.pair_total, 6);
463 assert_eq!(plan.boolean_pairs, vec![(0, 1), (0, 2)]);
464 assert_eq!(plan.skipped.len(), 4);
465 assert!(
466 plan.skipped
467 .iter()
468 .all(|note| note.contains("boolean budget of 2")),
469 "{:?}",
470 plan.skipped
471 );
472
473 let plan = plan_pairs(
475 &[
476 component("ACOMP1", cube([0.0; 3])),
477 component("ACOMP2", Aabb::empty()),
478 ],
479 8,
480 );
481 assert!(plan.boolean_pairs.is_empty());
482 assert_eq!(plan.skipped.len(), 1);
483 assert!(plan.skipped[0].contains("no resident geometry"));
484 }
485}