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