Skip to main content

brep_render/engine_state/
interference.rs

1use super::*;
2use crate::camera::Aabb;
3
4// ===========================================================================
5// Interference check (assemblies build-spec §9) — pairwise NON-DESTRUCTIVE
6// kernel INTERSECT booleans over the component instances, reporting every
7// overlapping pair + its intersection volume. Zero interference is a PASS.
8//
9// Runs MAIN-SIDE like every other assembly op (the thread-local rule): the
10// component solids' kernel handles come from [`EngineState::
11// resident_solid_handles`] — a warm-cache replay of the rolled-to history on
12// THIS thread — and `brep_kernel::boolean_handle` / `mass_properties_handle_
13// native` read the SAME thread-local registry. The operand handles belong to
14// the incremental cache + scene; ONLY the boolean RESULT handle is freed.
15//
16// Scale sanity: N components = N·(N−1)/2 pairs, but a full boolean only runs
17// for pairs whose (inflated, mesh-derived) bboxes overlap — [`plan_pairs`]
18// prefilters, so disjoint pairs are PROVEN clear for the cost of a box test.
19// Bbox-overlapping pairs beyond [`MAX_BOOLEAN_PAIRS`] are SKIPPED with an
20// explicit per-pair note (never silently). Hidden components participate —
21// interference is a physical question — with the pair flagged so the window
22// can note it.
23// ===========================================================================
24
25/// Budget of component pairs allowed to run REAL booleans per check run. Full
26/// booleans cost ~10–100 ms each; the bbox prefilter keeps normal assemblies
27/// far below this. Pairs beyond the budget are reported as skipped.
28const MAX_BOOLEAN_PAIRS: usize = 64;
29
30/// Interference counts only above this intersection volume (mm³): exact face
31/// contact (mated components) integrates to ~0 and must read as clear.
32const VOLUME_EPSILON: f64 = 1e-6;
33
34/// One INTERFERING component pair: the two owning ACOMP feature ids, their
35/// summed intersection volume (mm³, over all member-solid cross pairs), and
36/// whether either participant is currently hidden (it still participates —
37/// the window notes it).
38#[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/// The result of one interference run — everything the results window shows.
48/// `pairs` empty + `skipped`/`unverified` empty = the green all-clear.
49#[derive(Debug, Clone, Default, PartialEq)]
50pub struct InterferenceReport {
51    /// Number of component instances that participated.
52    pub component_count: usize,
53    /// Every pair considered: N·(N−1)/2.
54    pub pair_total: usize,
55    /// Pairs that ran the boolean lane (bbox-overlapping, within budget).
56    /// The rest were PROVEN clear by the bbox prefilter (or noted below).
57    pub booleans_run: usize,
58    /// The interfering pairs, largest intersection volume first.
59    pub pairs: Vec<InterferencePair>,
60    /// Anything NOT fully checked, one human-readable line each: pairs beyond
61    /// the boolean budget, components with no resident geometry. NEVER silent.
62    pub skipped: Vec<String>,
63    /// Pairs whose boolean REFUSED (the kernel is conservative on grazing /
64    /// tangent contact): not a pass, not an interference — shown under their
65    /// own heading so a mated assembly never reads as a wall of errors.
66    pub unverified: Vec<String>,
67}
68
69// --- the test seam: count trips through the boolean lane --------------------
70
71#[cfg(test)]
72thread_local! {
73    static BOOLEAN_LANE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
74}
75
76/// Tally one boolean-lane invocation (test-observable; free in release).
77fn 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
92// --- the boolean lane -------------------------------------------------------
93
94/// Intersection volume (mm³) of two RESIDENT solids, non-destructively:
95/// `boolean_handle_native` reads both operands by reference (pipeline-default
96/// options), the result registers under a NEW handle whose exact volume is
97/// integrated, and that intermediate is freed. A legitimately-disjoint
98/// intersect yields an EMPTY solid → volume 0. The OPERAND handles are owned
99/// by the incremental cache + scene — never free them here.
100fn 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    // Free ONLY the result — the intermediate this check minted.
110    brep_kernel::free_solid(result);
111    volume
112}
113
114// --- the pure pair planner (prefilter + budget) ------------------------------
115
116/// One component as the planner sees it: id, the union bbox of its member
117/// solids (EMPTY = no resident geometry), and the hidden flag.
118pub(crate) struct PlanComponent {
119    pub id: String,
120    pub bbox: Aabb,
121    pub hidden: bool,
122}
123
124/// What [`plan_pairs`] decides: which index pairs go to the boolean lane, and
125/// the explicit notes for everything that will NOT be boolean-checked.
126pub(crate) struct PairPlan {
127    pub boolean_pairs: Vec<(usize, usize)>,
128    pub skipped: Vec<String>,
129    pub pair_total: usize,
130}
131
132/// Grow a mesh-derived bbox conservatively before the overlap test: the scene
133/// bbox is over TESSELLATED positions, which under-approximate curved BREP by
134/// up to the chord deviation — a false positive costs one boolean that comes
135/// back empty; a false negative would silently miss real interference.
136fn 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
151/// Axis-aligned overlap (empty boxes never overlap).
152fn 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
159/// The PURE planning pass: which pairs must pay for a boolean. Bbox-disjoint
160/// pairs are PROVEN clear (checked, not skipped); pairs past `cap` and
161/// geometry-less components get explicit notes. Deterministic id order.
162pub(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; // proven clear by the prefilter — no boolean needed
179            }
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
193// --- the engine surface ------------------------------------------------------
194
195impl EngineState {
196    /// Run the interference check over every component instance (hidden ones
197    /// included — interference is a physical question). Non-destructive: the
198    /// component solids are only READ; each pairwise INTERSECT result is
199    /// measured and freed. Returns the full report for the results window.
200    pub fn interference_check(&mut self) -> InterferenceReport {
201        self.ensure_assembly_synced();
202
203        // Gather the participants: members + union bbox + hidden, in the
204        // deterministic history order component_ids() gives.
205        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        // The warm MAIN-SIDE handle map (cache-hit replay on this thread) —
241        // the same lane the Info windows' mass properties ride.
242        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; // not resident (rolled back mid-frame)
252                    };
253                    // Member-level prefilter: within an overlapping component
254                    // pair, only member solids whose own boxes overlap pay.
255                    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                            // A conservative kernel refusal (grazing/tangent
267                            // contact): the pair is UNVERIFIED, never a
268                            // silent pass and never fake interference.
269                            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        // Largest interference first (stable → id order breaks ties).
291        report
292            .pairs
293            .sort_by(|x, y| y.volume.total_cmp(&x.volume));
294        report
295    }
296}
297
298// ===========================================================================
299// Tests — the lane's spec battery: overlap volume, all-clear + prefilter
300// (boolean lane NOT invoked, via the call counter), three components → three
301// pairs, hidden participant flagged, exact face contact pinned, and the pure
302// planner's budget notes.
303// ===========================================================================
304#[cfg(test)]
305mod tests {
306    use super::super::components::component_fixtures::two_instance_assembly_json;
307    use super::*;
308
309    /// The shared two-cube fixture (10 mm cubes, ACOMP1 at origin) with
310    /// ACOMP2's translate overridden.
311    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    /// TWO OVERLAPPING CUBES: 10 mm cubes offset 5 mm in X intersect in a
324    /// 5×10×10 slab — the pair is reported with THAT volume (the kernel's
325    /// exact integrator, so near-exact).
326    #[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    /// SEPARATED CUBES: all-clear (a PASS state) — and the bbox prefilter
346    /// means the boolean lane is NEVER invoked (the call-counter seam).
347    #[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    /// THREE COMPONENTS → THREE PAIRS, each with its own exact volume.
359    #[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        // Largest overlap first: 1×3 = 8·10·10, 2×3 = 7·10·10, 1×2 = 5·10·10.
380        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    /// A HIDDEN participant still participates (physical question) and the
394    /// pair is FLAGGED so the window can note it.
395    #[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    /// EXACT FACE CONTACT (the mated-assembly norm): PINNED — never false
408    /// interference. The kernel refuses the graze conservatively ("operation
409    /// produced no boundary faces"), which lands in the UNVERIFIED bucket:
410    /// visible in the window, not a silent pass, not fake interference.
411    #[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    /// The PURE planner: bbox-disjoint pairs are proven clear (no boolean, no
430    /// note); pairs past the budget get explicit per-pair notes; geometry-less
431    /// components are noted.
432    #[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        // Disjoint pair: no boolean, no skipped note (proof, not omission).
445        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        // Four coincident cubes = 6 overlapping pairs, budget 2: the first two
457        // run, the other four are each named in a skipped note.
458        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        // A geometry-less component is noted and never paired.
474        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}