Skip to main content

brep_render/engine_state/
interference.rs

1use super::*;
2use crate::camera::Aabb;
3
4// ===========================================================================
5// Interference check — pairwise NON-DESTRUCTIVE kernel INTERSECT booleans
6// over the component instances, reporting every overlapping pair + its
7// 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: N components = N·(N−1)/2 pairs, but a full boolean only runs for
17// 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// There is no pair budget: every box-overlapping pair is checked. Hidden
20// components participate — interference is a physical question — with the
21// pair flagged so the window can note it.
22//
23// Clearance (near miss): with a threshold > 0, pairs whose boxes come within
24// it get a minimum distance over the DISPLAY tessellation (triangle–triangle,
25// so approximate by the chord deviation) and are reported when the gap is at
26// or under the threshold. The interference volumes and the near-miss gaps are
27// drawn in the viewport overlay group [`INTERFERENCE_OVERLAY_GROUP`].
28// ===========================================================================
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/// The viewport overlay group carrying the interference volumes (red
35/// triangles) and the near-miss gaps (amber segment + end points).
36pub const INTERFERENCE_OVERLAY_GROUP: &str = "assembly-interference";
37
38/// One INTERFERING component pair: the two owning ACOMP feature ids, their
39/// summed intersection volume (mm³, over all member-solid cross pairs), and
40/// whether either participant is currently hidden (it still participates —
41/// the window notes it).
42#[derive(Debug, Clone, PartialEq)]
43pub struct InterferencePair {
44    pub a: String,
45    pub b: String,
46    pub volume: f64,
47    pub a_hidden: bool,
48    pub b_hidden: bool,
49}
50
51/// One NEAR-MISS component pair: no interference, but the gap between them
52/// is at or under the clearance threshold. `distance` (mm) is measured over
53/// the display tessellation; `witness` is the closest point on `a` then `b`.
54#[derive(Debug, Clone, PartialEq)]
55pub struct ClearancePair {
56    pub a: String,
57    pub b: String,
58    pub distance: f64,
59    pub witness: [[f64; 3]; 2],
60    pub a_hidden: bool,
61    pub b_hidden: bool,
62}
63
64/// The result of one interference run — everything the results window shows.
65/// `pairs` empty + `skipped`/`unverified` empty = the green all-clear.
66#[derive(Debug, Clone, Default, PartialEq)]
67pub struct InterferenceReport {
68    /// Number of component instances that participated.
69    pub component_count: usize,
70    /// Every pair considered: N·(N−1)/2.
71    pub pair_total: usize,
72    /// Pairs that ran the boolean lane (bbox-overlapping). The rest were
73    /// PROVEN clear by the bbox prefilter (or noted below).
74    pub booleans_run: usize,
75    /// The interfering pairs, largest intersection volume first.
76    pub pairs: Vec<InterferencePair>,
77    /// The clearance threshold this run used (mm); 0 = no near-miss check.
78    pub clearance: f64,
79    /// The near-miss pairs (gap ≤ `clearance`), smallest gap first.
80    pub near_misses: Vec<ClearancePair>,
81    /// Anything NOT fully checked, one human-readable line each (components
82    /// with no resident geometry). NEVER silent.
83    pub skipped: Vec<String>,
84    /// Pairs whose boolean REFUSED (the kernel is conservative on grazing /
85    /// tangent contact): not a pass, not an interference — shown under their
86    /// own heading so a mated assembly never reads as a wall of errors.
87    pub unverified: Vec<String>,
88    /// The model generation the run measured ([`EngineState::
89    /// applied_generation`]); a later rebuild makes the report stale.
90    pub generation: u64,
91    /// The interference volumes as a world-space triangle soup (3 points per
92    /// triangle) — what the overlay draws.
93    pub volume_triangles: Vec<[f32; 3]>,
94}
95
96// --- the test seam: count trips through the boolean lane --------------------
97
98
99/// Tally one boolean-lane invocation (test-observable; free in release).
100fn note_boolean_call() {
101}
102
103
104
105// --- the boolean lane -------------------------------------------------------
106
107/// Intersection volume (mm³) of two RESIDENT solids, non-destructively:
108/// `boolean_handle_native` reads both operands by reference (pipeline-default
109/// options), the result registers under a NEW handle whose exact volume is
110/// integrated, and that intermediate is freed. A legitimately-disjoint
111/// intersect yields an EMPTY solid → volume 0. With `mesh_lod`, an
112/// interfering result is tessellated (world-space triangle soup) before it is
113/// freed. The OPERAND handles are owned by the incremental cache + scene —
114/// never free them here.
115fn intersect_volume(a: u32, b: u32, mesh_lod: Option<f64>) -> Result<(f64, Vec<[f32; 3]>), String> {
116    note_boolean_call();
117    let result = brep_kernel::boolean_handle_native(
118        a,
119        b,
120        brep_kernel::BooleanOperation::Intersect,
121        &brep_kernel::BooleanOptions::default(),
122    )?;
123    let volume = brep_kernel::mass_properties_handle_native(result, 1.0).map(|p| p.volume);
124    let mut triangles = Vec::new();
125    if let (Ok(v), Some(lod)) = (&volume, mesh_lod) {
126        if *v > VOLUME_EPSILON {
127            // A display-only extra: a tessellation failure leaves the volume
128            // reported, just not drawn.
129            if let Ok(payload) = brep_kernel::display_payload_handle_native(result, lod) {
130                let mesh = crate::scene::solid_display_from_payload("interference", payload).mesh;
131                triangles.extend(mesh.indices.iter().filter_map(|&i| mesh.positions.get(i as usize).copied()));
132            }
133        }
134    }
135    // Free ONLY the result — the intermediate this check minted.
136    brep_kernel::free_solid(result);
137    volume.map(|v| (v, triangles))
138}
139
140// --- the pure pair planner (prefilter) ---------------------------------------
141
142/// One component as the planner sees it: id, the union bbox of its member
143/// solids (EMPTY = no resident geometry), and the hidden flag.
144pub(crate) struct PlanComponent {
145    pub id: String,
146    pub bbox: Aabb,
147    pub hidden: bool,
148}
149
150/// What [`plan_pairs`] decides: which index pairs need a closer look, and the
151/// explicit notes for everything that will NOT be checked.
152pub(crate) struct PairPlan {
153    pub candidate_pairs: Vec<(usize, usize)>,
154    pub skipped: Vec<String>,
155    pub pair_total: usize,
156}
157
158/// Grow a mesh-derived bbox conservatively before the overlap test: the scene
159/// bbox is over TESSELLATED positions, which under-approximate curved BREP by
160/// up to the chord deviation — a false positive costs one boolean that comes
161/// back empty; a false negative would silently miss real interference. `extra`
162/// (the clearance threshold) widens it further for the near-miss check.
163fn inflated(bbox: &Aabb, extra: f64) -> Aabb {
164    if bbox.is_empty() {
165        return *bbox;
166    }
167    let size = bbox.size();
168    let diagonal = (size[0] * size[0] + size[1] * size[1] + size[2] * size[2]).sqrt();
169    let margin = (diagonal * 0.01).max(1e-6) + extra.max(0.0);
170    let mut out = *bbox;
171    for axis in 0..3 {
172        out.min[axis] -= margin;
173        out.max[axis] += margin;
174    }
175    out
176}
177
178/// Axis-aligned overlap (empty boxes never overlap).
179pub(crate) fn overlaps(a: &Aabb, b: &Aabb) -> bool {
180    if a.is_empty() || b.is_empty() {
181        return false;
182    }
183    (0..3).all(|axis| a.min[axis] <= b.max[axis] && b.min[axis] <= a.max[axis])
184}
185
186/// The PURE planning pass: which pairs need a boolean or a distance. Pairs
187/// whose boxes (inflated, plus `clearance`) are disjoint are PROVEN clear
188/// (checked, not skipped); geometry-less components get explicit notes.
189/// Deterministic id order. Sweep-and-prune on x keeps large assemblies cheap.
190pub(crate) fn plan_pairs(components: &[PlanComponent], clearance: f64) -> PairPlan {
191    let mut plan = PairPlan {
192        candidate_pairs: Vec::new(),
193        skipped: Vec::new(),
194        pair_total: components.len().saturating_sub(1) * components.len() / 2,
195    };
196    for component in components {
197        if component.bbox.is_empty() {
198            plan.skipped
199                .push(format!("{} — no resident geometry, not checked", component.id));
200        }
201    }
202    let boxes: Vec<Aabb> = components.iter().map(|c| inflated(&c.bbox, clearance)).collect();
203    let mut order: Vec<usize> = (0..components.len()).filter(|&i| !boxes[i].is_empty()).collect();
204    order.sort_by(|&x, &y| boxes[x].min[0].total_cmp(&boxes[y].min[0]));
205    for (k, &i) in order.iter().enumerate() {
206        for &j in &order[k + 1..] {
207            if boxes[j].min[0] > boxes[i].max[0] {
208                break; // sorted on min x: nothing later can overlap `i`
209            }
210            if overlaps(&boxes[i], &boxes[j]) {
211                plan.candidate_pairs.push((i.min(j), i.max(j)));
212            }
213        }
214    }
215    plan.candidate_pairs.sort_unstable();
216    plan
217}
218
219// --- the near-miss distance (display tessellation) ---------------------------
220
221type Triangle = [[f64; 3]; 3];
222
223fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
224    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
225}
226fn add_scaled(a: [f64; 3], d: [f64; 3], t: f64) -> [f64; 3] {
227    [a[0] + d[0] * t, a[1] + d[1] * t, a[2] + d[2] * t]
228}
229fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
230    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
231}
232fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
233    [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
234}
235fn dist2(a: [f64; 3], b: [f64; 3]) -> f64 {
236    let d = sub(a, b);
237    dot(d, d)
238}
239
240/// Closest point on triangle `t` to `p` (Ericson, Real-Time Collision
241/// Detection §5.1.5).
242fn closest_on_triangle(p: [f64; 3], t: &Triangle) -> [f64; 3] {
243    let [a, b, c] = *t;
244    let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
245    let (d1, d2) = (dot(ab, ap), dot(ac, ap));
246    if d1 <= 0.0 && d2 <= 0.0 {
247        return a;
248    }
249    let bp = sub(p, b);
250    let (d3, d4) = (dot(ab, bp), dot(ac, bp));
251    if d3 >= 0.0 && d4 <= d3 {
252        return b;
253    }
254    let vc = d1 * d4 - d3 * d2;
255    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
256        return add_scaled(a, ab, d1 / (d1 - d3));
257    }
258    let cp = sub(p, c);
259    let (d5, d6) = (dot(ab, cp), dot(ac, cp));
260    if d6 >= 0.0 && d5 <= d6 {
261        return c;
262    }
263    let vb = d5 * d2 - d1 * d6;
264    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
265        return add_scaled(a, ac, d2 / (d2 - d6));
266    }
267    let va = d3 * d6 - d5 * d4;
268    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
269        return add_scaled(b, sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6)));
270    }
271    let denom = 1.0 / (va + vb + vc);
272    add_scaled(add_scaled(a, ab, vb * denom), ac, vc * denom)
273}
274
275/// Closest points between segments `p1q1` and `p2q2` (Ericson §5.1.9).
276fn closest_segment_segment(p1: [f64; 3], q1: [f64; 3], p2: [f64; 3], q2: [f64; 3]) -> ([f64; 3], [f64; 3]) {
277    let (d1, d2, r) = (sub(q1, p1), sub(q2, p2), sub(p1, p2));
278    let (a, e, f) = (dot(d1, d1), dot(d2, d2), dot(d2, r));
279    const EPS: f64 = 1e-18;
280    let (s, t);
281    if a <= EPS && e <= EPS {
282        return (p1, p2);
283    }
284    if a <= EPS {
285        s = 0.0;
286        t = (f / e).clamp(0.0, 1.0);
287    } else {
288        let c = dot(d1, r);
289        if e <= EPS {
290            t = 0.0;
291            s = (-c / a).clamp(0.0, 1.0);
292        } else {
293            let b = dot(d1, d2);
294            let denom = a * e - b * b;
295            let s0 = if denom > EPS { ((b * f - c * e) / denom).clamp(0.0, 1.0) } else { 0.0 };
296            let t0 = (b * s0 + f) / e;
297            if t0 < 0.0 {
298                t = 0.0;
299                s = (-c / a).clamp(0.0, 1.0);
300            } else if t0 > 1.0 {
301                t = 1.0;
302                s = ((b - c) / a).clamp(0.0, 1.0);
303            } else {
304                t = t0;
305                s = s0;
306            }
307        }
308    }
309    (add_scaled(p1, d1, s), add_scaled(p2, d2, t))
310}
311
312/// Where segment `p→q` pierces triangle `t`, if it does (Möller–Trumbore,
313/// parameter clamped to the segment). Coplanar contact returns `None`; the
314/// vertex/edge terms of [`triangle_distance`] cover it.
315fn segment_hits_triangle(p: [f64; 3], q: [f64; 3], t: &Triangle) -> Option<[f64; 3]> {
316    let dir = sub(q, p);
317    let (e1, e2) = (sub(t[1], t[0]), sub(t[2], t[0]));
318    let h = cross(dir, e2);
319    let det = dot(e1, h);
320    if det.abs() < 1e-18 {
321        return None;
322    }
323    let inv = 1.0 / det;
324    let s = sub(p, t[0]);
325    let u = inv * dot(s, h);
326    if !(0.0..=1.0).contains(&u) {
327        return None;
328    }
329    let qv = cross(s, e1);
330    let v = inv * dot(dir, qv);
331    if v < 0.0 || u + v > 1.0 {
332        return None;
333    }
334    let w = inv * dot(e2, qv);
335    (0.0..=1.0).contains(&w).then(|| add_scaled(p, dir, w))
336}
337
338/// Exact distance between two triangles with its witness points (on `a`,
339/// on `b`): zero where an edge of one pierces the other, else the least of
340/// the six vertex–triangle and nine edge–edge distances.
341pub(crate) fn triangle_distance(a: &Triangle, b: &Triangle) -> (f64, [f64; 3], [f64; 3]) {
342    for (s, t) in [(a, b), (b, a)] {
343        for k in 0..3 {
344            if let Some(hit) = segment_hits_triangle(s[k], s[(k + 1) % 3], t) {
345                return (0.0, hit, hit);
346            }
347        }
348    }
349    let mut best = (f64::INFINITY, a[0], b[0]);
350    let mut consider = |p: [f64; 3], q: [f64; 3]| {
351        let d = dist2(p, q);
352        if d < best.0 {
353            best = (d, p, q);
354        }
355    };
356    for k in 0..3 {
357        consider(a[k], closest_on_triangle(a[k], b));
358        consider(closest_on_triangle(b[k], a), b[k]);
359        for m in 0..3 {
360            let (p, q) = closest_segment_segment(a[k], a[(k + 1) % 3], b[m], b[(m + 1) % 3]);
361            consider(p, q);
362        }
363    }
364    (best.0.sqrt(), best.1, best.2)
365}
366
367fn triangle_box(t: &Triangle) -> Aabb {
368    let mut bbox = Aabb::empty();
369    for p in t {
370        bbox.expand(*p);
371    }
372    bbox
373}
374
375/// Lower bound on the distance between two boxes (0 when they overlap).
376fn box_gap(a: &Aabb, b: &Aabb) -> f64 {
377    let mut sum = 0.0;
378    for axis in 0..3 {
379        let gap = (a.min[axis] - b.max[axis]).max(b.min[axis] - a.max[axis]).max(0.0);
380        sum += gap * gap;
381    }
382    sum.sqrt()
383}
384
385/// The display triangles of `solid` whose boxes touch `window`.
386fn triangles_near(solid: &crate::scene::SolidDisplay, window: &Aabb) -> Vec<(Triangle, Aabb)> {
387    let point = |i: u32| solid.mesh.positions.get(i as usize).map(|p| p.map(f64::from));
388    solid
389        .mesh
390        .indices
391        .chunks_exact(3)
392        .filter_map(|tri| Some([point(tri[0])?, point(tri[1])?, point(tri[2])?]))
393        .map(|t| (t, triangle_box(&t)))
394        .filter(|(_, bbox)| overlaps(bbox, window))
395        .collect()
396}
397
398/// Minimum distance between two display solids, considering only geometry
399/// within `reach` of the other solid's box. `None` = nothing within reach.
400pub(crate) fn display_min_distance(
401    a: &crate::scene::SolidDisplay,
402    b: &crate::scene::SolidDisplay,
403    reach: f64,
404) -> Option<(f64, [f64; 3], [f64; 3])> {
405    let near_a = triangles_near(a, &inflated(&b.bbox, reach));
406    let near_b = triangles_near(b, &inflated(&a.bbox, reach));
407    let mut best: Option<(f64, [f64; 3], [f64; 3])> = None;
408    for (ta, box_a) in &near_a {
409        for (tb, box_b) in &near_b {
410            let bound = best.map_or(f64::INFINITY, |b| b.0);
411            if box_gap(box_a, box_b) >= bound {
412                continue;
413            }
414            let found = triangle_distance(ta, tb);
415            if found.0 < bound {
416                best = Some(found);
417                if found.0 == 0.0 {
418                    return best;
419                }
420            }
421        }
422    }
423    best
424}
425
426/// `p` through the row-major 4×4 `m` (a rigid pose).
427fn transform_point(m: &[f64; 16], p: [f64; 3]) -> [f64; 3] {
428    [
429        m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
430        m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
431        m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11],
432    ]
433}
434
435/// `bbox` posed by `m`: the box of its eight corners.
436pub(crate) fn posed_bbox(bbox: &Aabb, m: &[f64; 16]) -> Aabb {
437    let mut out = Aabb::empty();
438    if bbox.is_empty() {
439        return out;
440    }
441    for corner in 0..8 {
442        let p = [
443            if corner & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
444            if corner & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
445            if corner & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
446        ];
447        out.expand(transform_point(m, p));
448    }
449    out
450}
451
452/// The display triangles of `solid`, posed by `m`, whose boxes touch `window`.
453fn posed_triangles_near(solid: &crate::scene::SolidDisplay, m: &[f64; 16], window: &Aabb) -> Vec<(Triangle, Aabb)> {
454    let point = |i: u32| solid.mesh.positions.get(i as usize).map(|p| transform_point(m, p.map(f64::from)));
455    solid
456        .mesh
457        .indices
458        .chunks_exact(3)
459        .filter_map(|tri| Some([point(tri[0])?, point(tri[1])?, point(tri[2])?]))
460        .map(|t| (t, triangle_box(&t)))
461        .filter(|(_, bbox)| overlaps(bbox, window))
462        .collect()
463}
464
465/// Whether display `a` posed by `ma` penetrates display `b` posed by `mb`: an
466/// edge of one pierces a face of the other. Face-to-face contact is not a
467/// penetration (coplanar edges return no hit), and a part wholly inside
468/// another is not seen — the motion-study check is a frame-by-frame screen,
469/// not the boolean interference check.
470pub(crate) fn displays_pierce(a: &crate::scene::SolidDisplay, ma: &[f64; 16], b: &crate::scene::SolidDisplay, mb: &[f64; 16]) -> bool {
471    let (box_a, box_b) = (posed_bbox(&a.bbox, ma), posed_bbox(&b.bbox, mb));
472    if !overlaps(&box_a, &box_b) {
473        return false;
474    }
475    let near_a = posed_triangles_near(a, ma, &box_b);
476    let near_b = posed_triangles_near(b, mb, &box_a);
477    for (ta, bx_a) in &near_a {
478        for (tb, bx_b) in &near_b {
479            if !overlaps(bx_a, bx_b) {
480                continue;
481            }
482            for (s, t) in [(ta, tb), (tb, ta)] {
483                for k in 0..3 {
484                    if segment_hits_triangle(s[k], s[(k + 1) % 3], t).is_some() {
485                        return true;
486                    }
487                }
488            }
489        }
490    }
491    false
492}
493
494/// The part occurrence a member solid belongs to: its leading run of
495/// component-id segments (`ACOMP2:ACOMP1:ACOMP1` of
496/// `ACOMP2:ACOMP1:ACOMP1:Part`), never shorter than the top component `top`.
497/// A solid's own name may hold `::` (a union), so it is not the last `:`.
498fn part_occurrence(solid: &str, top: &str) -> String {
499    let rest = solid.strip_prefix(top).and_then(|r| r.strip_prefix(':')).unwrap_or("");
500    let mut leaf = top.to_string();
501    let segments: Vec<&str> = rest.split(':').collect();
502    // The last segment is the solid's own name; the ones before it that are
503    // component ids are the nested occurrences.
504    for segment in segments.iter().take(segments.len().saturating_sub(1)) {
505        let id = segment.strip_prefix("ACOMP").is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()));
506        if !id {
507            break;
508        }
509        leaf.push(':');
510        leaf.push_str(segment);
511    }
512    leaf
513}
514
515// --- the engine surface ------------------------------------------------------
516
517impl EngineState {
518    /// Every PART occurrence, at any depth, with its member solids, in
519    /// history order (Pattern Component instances after the placed ones): a
520    /// sub-assembly's parts are checked against each other and against
521    /// everything else. (Only top-level components were, so two parts inside
522    /// one sub-assembly were never checked: on l500, 10 module pairs.) A part
523    /// placed at the top is its own occurrence, as before.
524    /// Also the motion-study clash check's grouping (`simulation_play`).
525    pub(crate) fn interference_participants(&self) -> Vec<(String, Vec<String>)> {
526        let mut tops = self.component_ids();
527        tops.extend(self.pattern_instance_ids());
528        let mut out: Vec<(String, Vec<String>)> = Vec::new();
529        for top in &tops {
530            let members = self.component_members(top);
531            if members.is_empty() {
532                // No geometry (not built yet, rolled back): still a
533                // participant, so it reads as unchecked, never as clear.
534                out.push((top.clone(), Vec::new()));
535            }
536            for name in members {
537                let leaf = part_occurrence(&name, top);
538                match out.iter_mut().find(|(id, _)| *id == leaf) {
539                    Some((_, solids)) => solids.push(name),
540                    None => out.push((leaf, vec![name])),
541                }
542            }
543        }
544        out
545    }
546
547    /// [`Self::interference_check_with`] without a near-miss check.
548    pub fn interference_check(&mut self) -> InterferenceReport {
549        self.interference_check_with(0.0)
550    }
551
552    /// Run the interference check over every component instance (hidden ones
553    /// included — interference is a physical question), plus the near-miss
554    /// check for gaps up to `clearance` mm (0 = off). Non-destructive: the
555    /// component solids are only READ; each pairwise INTERSECT result is
556    /// measured, tessellated for the overlay, and freed. Draws the overlay
557    /// and returns the full report for the results window.
558    pub fn interference_check_with(&mut self, clearance: f64) -> InterferenceReport {
559        let clearance = if clearance.is_finite() { clearance.max(0.0) } else { 0.0 };
560
561        // Gather the participants: members + union bbox + hidden, in the
562        // deterministic history order component_ids() gives.
563        // Pattern Component instances are components without a feature of
564        // their own: they take part after the placed ones.
565        let (ids, grouped): (Vec<String>, Vec<Vec<String>>) = self.interference_participants().into_iter().unzip();
566        let mut members: Vec<Vec<String>> = Vec::with_capacity(ids.len());
567        let mut plan_input: Vec<PlanComponent> = Vec::with_capacity(ids.len());
568        for (id, solids) in ids.iter().zip(grouped) {
569            let mut bbox = Aabb::empty();
570            let mut hidden = false;
571            for name in &solids {
572                if let Some(solid) = self.scene.solid(name) {
573                    bbox.union(&solid.bbox);
574                    hidden |= !solid.visible;
575                }
576            }
577            plan_input.push(PlanComponent {
578                id: id.clone(),
579                bbox,
580                hidden,
581            });
582            members.push(solids);
583        }
584
585        let plan = plan_pairs(&plan_input, clearance);
586        let mut report = InterferenceReport {
587            component_count: ids.len(),
588            pair_total: plan.pair_total,
589            clearance,
590            skipped: plan.skipped,
591            generation: self.applied_generation(),
592            ..Default::default()
593        };
594        let lod = if self.settings.lod_factor.is_finite() && self.settings.lod_factor > 0.0 {
595            self.settings.lod_factor
596        } else {
597            1.0
598        };
599
600        // The warm MAIN-SIDE handle map (cache-hit replay on this thread) —
601        // the same lane the Info windows' mass properties ride.
602        let handles = if plan.candidate_pairs.is_empty() {
603            Default::default()
604        } else {
605            self.resident_solid_handles()
606        };
607        for (i, j) in plan.candidate_pairs {
608            let (a, b) = (&plan_input[i], &plan_input[j]);
609            let mut volume = 0.0;
610            let mut refusal: Option<String> = None;
611            let mut ran_boolean = false;
612            let mut gap: Option<(f64, [f64; 3], [f64; 3])> = None;
613            for solid_a in &members[i] {
614                for solid_b in &members[j] {
615                    let (Some(da), Some(db)) =
616                        (self.scene.solid(solid_a), self.scene.solid(solid_b))
617                    else {
618                        refusal.get_or_insert_with(|| format!("missing display operand: {solid_a} or {solid_b}"));
619                        continue;
620                    };
621                    // Member-level prefilter: within a candidate component
622                    // pair, only member solids whose own boxes overlap pay
623                    // for a boolean; those within the clearance pay for a
624                    // distance.
625                    if overlaps(&inflated(&da.bbox, 0.0), &inflated(&db.bbox, 0.0)) {
626                        let (Some(&ha), Some(&hb)) = (handles.get(solid_a), handles.get(solid_b))
627                        else {
628                            refusal.get_or_insert_with(|| format!("missing resident operand: {solid_a} or {solid_b}"));
629                            continue;
630                        };
631                        ran_boolean = true;
632                        match intersect_volume(ha, hb, Some(lod)) {
633                            Ok((v, triangles)) => {
634                                volume += v;
635                                report.volume_triangles.extend(triangles);
636                            }
637                            Err(error) => {
638                                // A conservative kernel refusal (grazing/tangent
639                                // contact): the pair is UNVERIFIED, never a
640                                // silent pass and never fake interference.
641                                refusal.get_or_insert(error);
642                            }
643                        }
644                    }
645                    if clearance > 0.0 && overlaps(&inflated(&da.bbox, clearance), &db.bbox) {
646                        if let Some(found) = display_min_distance(da, db, clearance) {
647                            if gap.is_none_or(|g| found.0 < g.0) {
648                                gap = Some(found);
649                            }
650                        }
651                    }
652                }
653            }
654            if ran_boolean {
655                report.booleans_run += 1;
656            }
657            if let Some(error) = &refusal {
658                report
659                    .unverified
660                    .push(format!("{} × {} — boolean refused: {error}", a.id, b.id));
661            }
662            if volume > VOLUME_EPSILON {
663                report.pairs.push(InterferencePair {
664                    a: a.id.clone(),
665                    b: b.id.clone(),
666                    volume,
667                    a_hidden: a.hidden,
668                    b_hidden: b.hidden,
669                });
670            } else if let Some((distance, pa, pb)) = gap.filter(|g| g.0 <= clearance) {
671                report.near_misses.push(ClearancePair {
672                    a: a.id.clone(),
673                    b: b.id.clone(),
674                    distance,
675                    witness: [pa, pb],
676                    a_hidden: a.hidden,
677                    b_hidden: b.hidden,
678                });
679            }
680        }
681        // Largest interference first, smallest gap first (stable → id order
682        // breaks ties).
683        report
684            .pairs
685            .sort_by(|x, y| y.volume.total_cmp(&x.volume));
686        report
687            .near_misses
688            .sort_by(|x, y| x.distance.total_cmp(&y.distance));
689        self.show_interference_overlay(&report);
690        report
691    }
692
693    /// Draw `report` in the viewport: interference volumes as red triangles,
694    /// near-miss gaps as an amber segment with its two end points. An empty
695    /// report removes the group.
696    pub fn show_interference_overlay(&mut self, report: &InterferenceReport) {
697        let rgb = |status: &str| crate::assembly_status::status_color_rgb(status).map(|c| f32::from(c) / 255.0);
698        let (red, amber) = (rgb("error"), rgb("unsupported-selection"));
699        let tri_positions: Vec<f32> = report.volume_triangles.iter().flatten().copied().collect();
700        let tri_colors: Vec<f32> = report.volume_triangles.iter().flat_map(|_| red).collect();
701        let ends: Vec<f32> = report
702            .near_misses
703            .iter()
704            .flat_map(|pair| pair.witness)
705            .flatten()
706            .map(|v| v as f32)
707            .collect();
708        let end_colors: Vec<f32> = (0..ends.len() / 3).flat_map(|_| amber).collect();
709        let _ = self.set_overlay_json(
710            &serde_json::json!({
711                "groups": [{
712                    "name": INTERFERENCE_OVERLAY_GROUP,
713                    "renderOrder": 10001,
714                    "tris": { "positions": tri_positions, "colors": tri_colors },
715                    "lines": { "positions": ends, "colors": end_colors },
716                    "points": { "positions": ends, "colors": end_colors },
717                }]
718            })
719            .to_string(),
720        );
721    }
722
723    /// Remove the interference overlay (window closed, report stale).
724    pub fn clear_interference_overlay(&mut self) {
725        let _ = self.set_overlay_json(
726            &serde_json::json!({ "groups": [{ "name": INTERFERENCE_OVERLAY_GROUP }] }).to_string(),
727        );
728    }
729
730    /// Frame the camera on the union of the given components' member solids
731    /// (the interference window's row click). Unknown ids are ignored.
732    pub fn zoom_to_components(&mut self, component_ids: &[String]) {
733        let mut bbox = Aabb::empty();
734        for id in component_ids {
735            for name in self.component_info(id).map(|info| info.members).unwrap_or_default() {
736                if let Some(solid) = self.scene.solid(&name) {
737                    bbox.union(&solid.bbox);
738                }
739            }
740        }
741        if !bbox.is_empty() {
742            self.camera.zoom_to_fit(&bbox, 1.5);
743            self.dirty = true;
744        }
745    }
746}
747
748
749/// A positive-weight rational surface lies inside its Euclidean control hull.
750/// Unlike tessellation bounds this cannot miss a curved extremum. Refuse the
751/// shortcut when any weight is nonpositive or geometry is unavailable.
752fn conservative_bounds(solid: &brep_kernel::BrepSolid) -> Option<Aabb> {
753    let mut bounds = Aabb::empty();
754    let mut count = 0;
755    for face in solid.shells.iter().flat_map(|s| &s.faces) {
756        if face.surface.control_points.is_empty() { return None; }
757        for cp in face.surface.control_points.iter().flatten() {
758            if cp.w <= 0.0 { return None; }
759            let p = [cp.x / cp.w, cp.y / cp.w, cp.z / cp.w];
760            if !p.iter().all(|x| x.is_finite()) { return None; }
761            for axis in 0..3 { bounds.min[axis] = bounds.min[axis].min(p[axis] - 1e-6); bounds.max[axis] = bounds.max[axis].max(p[axis] + 1e-6); }
762            count += 1;
763        }
764    }
765    (count > 0).then_some(bounds)
766}
767
768/// Positive evidence only: a topological vertex on the other solid's boundary
769/// within 1e-6 mm. No witness is not evidence of clearance.
770fn contact_witness(a: &brep_kernel::BrepSolid, b: &brep_kernel::BrepSolid) -> Result<bool, String> {
771    for (vertices, target) in [(&a.vertices, b), (&b.vertices, a)] {
772        for v in vertices {
773            if brep_kernel::classify_point(v.point, target, 1e-6)?.class == brep_kernel::PointClass::On { return Ok(true); }
774        }
775    }
776    Ok(false)
777}
778
779/// Continuations are server-owned, scoped to one engine and one applied model.
780/// Keep only a bounded number; expired tokens fail explicitly.
781#[derive(Clone)]
782pub(super) struct InterferenceCursor {
783    revision: u64,
784    document: String,
785    scope: Vec<String>,
786    next: usize,
787    unresolved: usize,
788    interfering: usize,
789}
790
791impl EngineState {
792    /// A pair budget bounds progress even for assemblies whose boxes are disjoint.
793    /// Zero-volume intersections do not establish contact: without an exact
794    /// distance witness they remain unresolved (never an all-clear).
795    pub fn interference_page(&mut self, scope: Option<Vec<String>>, budget: usize, token: Option<String>, clearance: Option<f64>) -> Result<serde_json::Value, String> {
796        use serde_json::json;
797        if self.run_pending() { return Err("model rebuild is pending; wait before checking interference".into()); }
798        if !(1..=4096).contains(&budget) { return Err("budget must be in 1..=4096 component pairs".into()); }
799        let clearance = clearance.unwrap_or(0.0);
800        if !clearance.is_finite() || clearance < 0.0 { return Err("clearanceMm must be a finite distance >= 0".into()); }
801        let revision = self.applied_generation();
802        let document = self.history.request_json();
803        let mut cursor = if let Some(token) = &token {
804            let c = self.interference_sessions.get(token).ok_or("unknown or expired continuation token")?.clone();
805            if c.revision != revision || c.document != document { return Err("stale continuation: model changed; restart without a token".into()); }
806            if scope.as_ref().is_some_and(|s| s != &c.scope) { return Err("continuation scope differs from requested components".into()); }
807            c
808        } else {
809            let known: Vec<String> = self.interference_participants().into_iter().map(|(id, _)| id).collect();
810            let mut tops = self.component_ids();
811            tops.extend(self.pattern_instance_ids());
812            // A component named in the scope stands for its part occurrences
813            // (a sub-assembly for every part inside it).
814            let ids: Vec<String> = match scope {
815                None => known.clone(),
816                Some(requested) => {
817                    let mut seen = std::collections::HashSet::new();
818                    for id in &requested {
819                        if !known.contains(id) && !tops.contains(id) { return Err(format!("unknown component `{id}`")); }
820                        if !seen.insert(id.clone()) { return Err(format!("duplicate component `{id}`")); }
821                    }
822                    let mut expanded: Vec<String> = Vec::new();
823                    for id in requested {
824                        let prefix = format!("{id}:");
825                        for leaf in known.iter().filter(|leaf| **leaf == id || leaf.starts_with(&prefix)) {
826                            if !expanded.contains(leaf) { expanded.push(leaf.clone()); }
827                        }
828                    }
829                    expanded
830                }
831            };
832            InterferenceCursor { revision, document, scope: ids, next: 0, unresolved: 0, interfering: 0 }
833        };
834        let total = cursor.scope.len() * cursor.scope.len().saturating_sub(1) / 2;
835        let start = cursor.next;
836        let end = start.saturating_add(budget).min(total);
837        let handles = self.resident_solid_handles();
838        let participants: std::collections::HashMap<String, Vec<String>> = self.interference_participants().into_iter().collect();
839        // Each solid's conservative bounds, once (they were computed from a
840        // clone of both exact solids for every solid pair: on l500, 10 module
841        // pairs cloned their 100×100 solid pairs, ~3 s).
842        let mut bounds: std::collections::HashMap<String, Option<Aabb>> = std::collections::HashMap::new();
843        let mut outcomes = Vec::new();
844        let mut booleans_run = 0usize;
845        let mut boolean_operations = 0usize;
846        let mut pair_index = 0;
847        for i in 0..cursor.scope.len() {
848            for j in i+1..cursor.scope.len() {
849                let index = pair_index;
850                pair_index += 1;
851                if index < start || index >= end { continue; }
852                let a = &cursor.scope[i];
853                let b = &cursor.scope[j];
854                let ma = participants.get(a).cloned().unwrap_or_default();
855                let mb = participants.get(b).cloned().unwrap_or_default();
856                let mut volume = 0.0;
857                let mut errors = Vec::new();
858                let mut ambiguous = false;
859                let mut touching = false;
860                let mut ran_boolean = false;
861                if ma.is_empty() || mb.is_empty() { errors.push(json!({"operation":"resolve_operands", "category":"missing_geometry", "operands":[a,b]})); }
862                for sa in &ma {
863                    for sb in &mb {
864                        let (Some(ha), Some(hb), Some(da), Some(db)) = (handles.get(sa), handles.get(sb), self.scene.solid(sa), self.scene.solid(sb)) else {
865                            errors.push(json!({"operation":"resolve_operands", "category":"missing_geometry", "operands":[sa,sb]}));
866                            continue;
867                        };
868                        let _ = (da, db); // Display presence is coverage, not geometric evidence.
869                        let mut bound = |name: &String, handle: u32| -> Option<Aabb> {
870                            *bounds.entry(name.clone()).or_insert_with(|| {
871                                brep_kernel::registered_solid_clone(handle).ok().and_then(|solid| conservative_bounds(&solid))
872                            })
873                        };
874                        if let (Some(ba), Some(bb)) = (bound(sa, *ha), bound(sb, *hb)) {
875                            if !overlaps(&ba, &bb) { continue; }
876                        }
877                        let exact_a = brep_kernel::registered_solid_clone(*ha);
878                        let exact_b = brep_kernel::registered_solid_clone(*hb);
879                        ran_boolean = true;
880                        boolean_operations += 1;
881                        match intersect_volume(*ha, *hb, None).map(|(v, _)| v) {
882                            Ok(v) if v.is_finite() && v >= 0.0 => {
883                                volume += v;
884                                if v <= VOLUME_EPSILON {
885                                    match (&exact_a, &exact_b) {
886                                        (Ok(a), Ok(b)) => match contact_witness(a, b) {
887                                            Ok(true) => touching = true,
888                                            Ok(false) => ambiguous = true,
889                                            Err(e) => errors.push(json!({"operation":"contact_verification", "category":"kernel_refusal", "operands":[sa,sb], "detail":e})),
890                                        },
891                                        _ => ambiguous = true,
892                                    }
893                                }
894                            },
895                            Ok(_) => errors.push(json!({"operation":"intersection_volume", "category":"numerical", "operands":[sa,sb]})),
896                            Err(e) => errors.push(json!({"operation":"intersect", "category":"kernel_refusal", "operands":[sa,sb], "detail":e})),
897                        }
898                    }
899                }
900                for (n, diagnostic) in errors.iter_mut().enumerate() {
901                    if let Some(detail) = diagnostic.get("detail").and_then(serde_json::Value::as_str).map(str::to_owned) {
902                        let id = format!("interference:{revision}:{a}:{b}:{n}");
903                        self.geometry_diagnostics.insert(id.clone(), diagnostic.clone());
904                        diagnostic.as_object_mut().unwrap().remove("detail");
905                        diagnostic["message"] = json!(detail.chars().take(240).collect::<String>());
906                        diagnostic["detailId"] = json!(id);
907                    }
908                }
909                if ran_boolean { booleans_run += 1; }
910                // Near miss: the gap over the display tessellation (approximate
911                // by the chord deviation), only for non-interfering pairs.
912                let mut min_distance: Option<f64> = None;
913                if clearance > 0.0 && volume <= VOLUME_EPSILON {
914                    for sa in &ma {
915                        for sb in &mb {
916                            let (Some(da), Some(db)) = (self.scene.solid(sa), self.scene.solid(sb)) else { continue };
917                            if !overlaps(&inflated(&da.bbox, clearance), &db.bbox) { continue; }
918                            if let Some((d, _, _)) = display_min_distance(da, db, clearance) {
919                                min_distance = Some(min_distance.map_or(d, |m: f64| m.min(d)));
920                            }
921                        }
922                    }
923                }
924                let status = if !errors.is_empty() { "unverified" }
925                    else if volume > VOLUME_EPSILON { "verified_interference" }
926                    else if ambiguous { "unverified" } else if touching { "verified_touching" } else { "verified_clearance" };
927                if status == "unverified" { cursor.unresolved += 1; }
928                if volume > VOLUME_EPSILON { cursor.interfering += 1; }
929                outcomes.push(json!({"index":index, "a":a, "b":b, "status":status, "volume":volume,
930                    "aHidden":ma.iter().any(|n| self.scene.solid(n).is_some_and(|s| !s.visible)),
931                    "bHidden":mb.iter().any(|n| self.scene.solid(n).is_some_and(|s| !s.visible)),
932                    "minDistance":min_distance, "nearMiss":min_distance.is_some_and(|d| d <= clearance),
933                    "diagnostics":errors, "reason":if ambiguous && errors.is_empty() { Some("zero_volume_does_not_distinguish_contact_from_clearance") } else { None }}));
934            }
935        }
936        cursor.next = end;
937        let complete = end == total;
938        let next_token = if complete { None } else {
939            self.interference_sequence += 1;
940            Some(format!("interference-{revision}-{}", self.interference_sequence))
941        };
942        if let Some(old) = token { self.interference_sessions.remove(&old); }
943        if let Some(next) = &next_token {
944            if self.interference_sessions.len() >= 16 {
945                let oldest = self.interference_sessions.keys().min_by_key(|key| key.rsplit('-').next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)).cloned();
946                if let Some(oldest) = oldest { self.interference_sessions.remove(&oldest); }
947            }
948            self.interference_sessions.insert(next.clone(), cursor.clone());
949        }
950        let mut interfering: Vec<&serde_json::Value> = outcomes.iter().filter(|p| p["volume"].as_f64().is_some_and(|v| v > VOLUME_EPSILON)).collect();
951        interfering.sort_by(|a,b| b["volume"].as_f64().unwrap().total_cmp(&a["volume"].as_f64().unwrap()));
952        let mut near_misses: Vec<&serde_json::Value> = outcomes.iter().filter(|p| p["nearMiss"] == true).collect();
953        near_misses.sort_by(|a,b| a["minDistance"].as_f64().unwrap().total_cmp(&b["minDistance"].as_f64().unwrap()));
954        let skipped: Vec<String> = if complete { vec![] } else { vec![format!("{} component pairs not checked: pair budget exhausted", total-end)] };
955        let unverified: Vec<String> = outcomes.iter().filter(|p| p["status"] == "unverified").map(|p| format!("{} × {}: unverified (see outcomes)",p["a"].as_str().unwrap_or(""),p["b"].as_str().unwrap_or(""))).collect();
956        Ok(json!({"modelRevision":revision, "scope":cursor.scope, "componentCount":cursor.scope.len(),
957            "budget":budget, "pairTotal":total, "checkedPairs":end, "pageStart":start, "pairs":interfering, "clearanceMm":clearance, "nearMisses":near_misses, "outcomes":outcomes, "skipped":skipped, "unverified":unverified, "booleansRun":booleans_run, "booleanOperations":boolean_operations,
958            "notChecked": {"count":total-end, "fromPairIndex":end, "status":"not_checked_budget_exhausted", "reason":if complete { serde_json::Value::Null } else { json!("budget_exhausted") }},
959            "unresolvedCount":cursor.unresolved, "interferenceCount":cursor.interfering,
960            "complete":complete, "allClear":complete && cursor.unresolved == 0 && cursor.interfering == 0,
961            "continuationToken":next_token, "volumeToleranceMm3":VOLUME_EPSILON,
962            "contactToleranceMm":1e-6, "contactVerification":"boundary vertex witness within tolerance after successful intersection; unwitnessed zero-volume pairs remain unverified"}))
963    }
964}
965