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 sanity: N components = N·(N−1)/2 pairs, but a full boolean only runs
17// for pairs whose (inflated, mesh-derived) bboxes overlap — [`plan_pairs`]
18// prefilters, so disjoint pairs are PROVEN clear for the cost of a box test.
19// Bbox-overlapping pairs beyond [`MAX_BOOLEAN_PAIRS`] are SKIPPED with an
20// explicit per-pair note (never silently). Hidden components participate —
21// interference is a physical question — with the pair flagged so the window
22// can note it.
23// ===========================================================================
24
25/// Budget of component pairs allowed to run REAL booleans per check run. Full
26/// booleans cost ~10–100 ms each; the bbox prefilter keeps normal assemblies
27/// far below this. Pairs beyond the budget are reported as skipped.
28const MAX_BOOLEAN_PAIRS: usize = 64;
29
30/// Interference counts only above this intersection volume (mm³): exact face
31/// contact (mated components) integrates to ~0 and must read as clear.
32const VOLUME_EPSILON: f64 = 1e-6;
33
34/// One INTERFERING component pair: the two owning ACOMP feature ids, their
35/// summed intersection volume (mm³, over all member-solid cross pairs), and
36/// whether either participant is currently hidden (it still participates —
37/// the window notes it).
38#[derive(Debug, Clone, PartialEq)]
39pub struct InterferencePair {
40    pub a: String,
41    pub b: String,
42    pub volume: f64,
43    pub a_hidden: bool,
44    pub b_hidden: bool,
45}
46
47/// The result of one interference run — everything the results window shows.
48/// `pairs` empty + `skipped`/`unverified` empty = the green all-clear.
49#[derive(Debug, Clone, Default, PartialEq)]
50pub struct InterferenceReport {
51    /// Number of component instances that participated.
52    pub component_count: usize,
53    /// Every pair considered: N·(N−1)/2.
54    pub pair_total: usize,
55    /// Pairs that ran the boolean lane (bbox-overlapping, within budget).
56    /// The rest were PROVEN clear by the bbox prefilter (or noted below).
57    pub booleans_run: usize,
58    /// The interfering pairs, largest intersection volume first.
59    pub pairs: Vec<InterferencePair>,
60    /// Anything NOT fully checked, one human-readable line each: pairs beyond
61    /// the boolean budget, components with no resident geometry. NEVER silent.
62    pub skipped: Vec<String>,
63    /// Pairs whose boolean REFUSED (the kernel is conservative on grazing /
64    /// tangent contact): not a pass, not an interference — shown under their
65    /// own heading so a mated assembly never reads as a wall of errors.
66    pub unverified: Vec<String>,
67}
68
69// --- the test seam: count trips through the boolean lane --------------------
70
71
72/// Tally one boolean-lane invocation (test-observable; free in release).
73fn note_boolean_call() {
74}
75
76
77
78// --- the boolean lane -------------------------------------------------------
79
80/// Intersection volume (mm³) of two RESIDENT solids, non-destructively:
81/// `boolean_handle_native` reads both operands by reference (pipeline-default
82/// options), the result registers under a NEW handle whose exact volume is
83/// integrated, and that intermediate is freed. A legitimately-disjoint
84/// intersect yields an EMPTY solid → volume 0. The OPERAND handles are owned
85/// by the incremental cache + scene — never free them here.
86fn intersect_volume(a: u32, b: u32) -> Result<f64, String> {
87    note_boolean_call();
88    let result = brep_kernel::boolean_handle_native(
89        a,
90        b,
91        brep_kernel::BooleanOperation::Intersect,
92        &brep_kernel::BooleanOptions::default(),
93    )?;
94    let volume = brep_kernel::mass_properties_handle_native(result, 1.0).map(|p| p.volume);
95    // Free ONLY the result — the intermediate this check minted.
96    brep_kernel::free_solid(result);
97    volume
98}
99
100// --- the pure pair planner (prefilter + budget) ------------------------------
101
102/// One component as the planner sees it: id, the union bbox of its member
103/// solids (EMPTY = no resident geometry), and the hidden flag.
104pub(crate) struct PlanComponent {
105    pub id: String,
106    pub bbox: Aabb,
107    pub hidden: bool,
108}
109
110/// What [`plan_pairs`] decides: which index pairs go to the boolean lane, and
111/// the explicit notes for everything that will NOT be boolean-checked.
112pub(crate) struct PairPlan {
113    pub boolean_pairs: Vec<(usize, usize)>,
114    pub skipped: Vec<String>,
115    pub pair_total: usize,
116}
117
118/// Grow a mesh-derived bbox conservatively before the overlap test: the scene
119/// bbox is over TESSELLATED positions, which under-approximate curved BREP by
120/// up to the chord deviation — a false positive costs one boolean that comes
121/// back empty; a false negative would silently miss real interference.
122fn inflated(bbox: &Aabb) -> Aabb {
123    if bbox.is_empty() {
124        return *bbox;
125    }
126    let size = bbox.size();
127    let diagonal = (size[0] * size[0] + size[1] * size[1] + size[2] * size[2]).sqrt();
128    let margin = (diagonal * 0.01).max(1e-6);
129    let mut out = *bbox;
130    for axis in 0..3 {
131        out.min[axis] -= margin;
132        out.max[axis] += margin;
133    }
134    out
135}
136
137/// Axis-aligned overlap (empty boxes never overlap).
138fn overlaps(a: &Aabb, b: &Aabb) -> bool {
139    if a.is_empty() || b.is_empty() {
140        return false;
141    }
142    (0..3).all(|axis| a.min[axis] <= b.max[axis] && b.min[axis] <= a.max[axis])
143}
144
145/// The PURE planning pass: which pairs must pay for a boolean. Bbox-disjoint
146/// pairs are PROVEN clear (checked, not skipped); pairs past `cap` and
147/// geometry-less components get explicit notes. Deterministic id order.
148pub(crate) fn plan_pairs(components: &[PlanComponent], cap: usize) -> PairPlan {
149    let mut plan = PairPlan {
150        boolean_pairs: Vec::new(),
151        skipped: Vec::new(),
152        pair_total: components.len().saturating_sub(1) * components.len() / 2,
153    };
154    for component in components {
155        if component.bbox.is_empty() {
156            plan.skipped
157                .push(format!("{} — no resident geometry, not checked", component.id));
158        }
159    }
160    let boxes: Vec<Aabb> = components.iter().map(|c| inflated(&c.bbox)).collect();
161    for i in 0..components.len() {
162        for j in (i + 1)..components.len() {
163            if !overlaps(&boxes[i], &boxes[j]) {
164                continue; // proven clear by the prefilter — no boolean needed
165            }
166            if plan.boolean_pairs.len() >= cap {
167                plan.skipped.push(format!(
168                    "{} × {} — skipped (boolean budget of {cap} pairs reached)",
169                    components[i].id, components[j].id
170                ));
171                continue;
172            }
173            plan.boolean_pairs.push((i, j));
174        }
175    }
176    plan
177}
178
179// --- the engine surface ------------------------------------------------------
180
181impl EngineState {
182    /// Run the interference check over every component instance (hidden ones
183    /// included — interference is a physical question). Non-destructive: the
184    /// component solids are only READ; each pairwise INTERSECT result is
185    /// measured and freed. Returns the full report for the results window.
186    pub fn interference_check(&mut self) -> InterferenceReport {
187
188        // Gather the participants: members + union bbox + hidden, in the
189        // deterministic history order component_ids() gives.
190        let ids = self.component_ids();
191        let mut members: Vec<Vec<String>> = Vec::with_capacity(ids.len());
192        let mut plan_input: Vec<PlanComponent> = Vec::with_capacity(ids.len());
193        for id in &ids {
194            let info = self.component_info(id);
195            let solids = info.map(|info| info.members).unwrap_or_default();
196            let mut bbox = Aabb::empty();
197            let mut hidden = false;
198            for name in &solids {
199                if let Some(solid) = self.scene.solid(name) {
200                    bbox.union(&solid.bbox);
201                    hidden |= !solid.visible;
202                }
203            }
204            plan_input.push(PlanComponent {
205                id: id.clone(),
206                bbox,
207                hidden,
208            });
209            members.push(solids);
210        }
211
212        let plan = plan_pairs(&plan_input, MAX_BOOLEAN_PAIRS);
213        let mut report = InterferenceReport {
214            component_count: ids.len(),
215            pair_total: plan.pair_total,
216            booleans_run: 0,
217            pairs: Vec::new(),
218            skipped: plan.skipped,
219            unverified: Vec::new(),
220        };
221        if plan.boolean_pairs.is_empty() {
222            return report;
223        }
224
225        // The warm MAIN-SIDE handle map (cache-hit replay on this thread) —
226        // the same lane the Info windows' mass properties ride.
227        let handles = self.resident_solid_handles();
228        for (i, j) in plan.boolean_pairs {
229            report.booleans_run += 1;
230            let mut volume = 0.0;
231            let mut refusal: Option<String> = None;
232            for solid_a in &members[i] {
233                for solid_b in &members[j] {
234                    let (Some(&ha), Some(&hb)) = (handles.get(solid_a), handles.get(solid_b))
235                    else {
236                        refusal.get_or_insert_with(|| format!("missing resident operand: {solid_a} or {solid_b}"));
237                        continue;
238                    };
239                    // Member-level prefilter: within an overlapping component
240                    // pair, only member solids whose own boxes overlap pay.
241                    let (Some(a), Some(b)) =
242                        (self.scene.solid(solid_a), self.scene.solid(solid_b))
243                    else {
244                        refusal.get_or_insert_with(|| format!("missing display operand: {solid_a} or {solid_b}"));
245                        continue;
246                    };
247                    if !overlaps(&inflated(&a.bbox), &inflated(&b.bbox)) {
248                        continue;
249                    }
250                    match intersect_volume(ha, hb) {
251                        Ok(v) => volume += v,
252                        Err(error) => {
253                            // A conservative kernel refusal (grazing/tangent
254                            // contact): the pair is UNVERIFIED, never a
255                            // silent pass and never fake interference.
256                            refusal.get_or_insert(error);
257                        }
258                    }
259                }
260            }
261            let (a, b) = (&plan_input[i], &plan_input[j]);
262            if let Some(error) = refusal {
263                report
264                    .unverified
265                    .push(format!("{} × {} — boolean refused: {error}", a.id, b.id));
266            }
267            if volume > VOLUME_EPSILON {
268                report.pairs.push(InterferencePair {
269                    a: a.id.clone(),
270                    b: b.id.clone(),
271                    volume,
272                    a_hidden: a.hidden,
273                    b_hidden: b.hidden,
274                });
275            }
276        }
277        // Largest interference first (stable → id order breaks ties).
278        report
279            .pairs
280            .sort_by(|x, y| y.volume.total_cmp(&x.volume));
281        report
282    }
283}
284
285
286/// A positive-weight rational surface lies inside its Euclidean control hull.
287/// Unlike tessellation bounds this cannot miss a curved extremum. Refuse the
288/// shortcut when any weight is nonpositive or geometry is unavailable.
289fn conservative_bounds(solid: &brep_kernel::BrepSolid) -> Option<Aabb> {
290    let mut bounds = Aabb::empty();
291    let mut count = 0;
292    for face in solid.shells.iter().flat_map(|s| &s.faces) {
293        if face.surface.control_points.is_empty() { return None; }
294        for cp in face.surface.control_points.iter().flatten() {
295            if cp.w <= 0.0 { return None; }
296            let p = [cp.x / cp.w, cp.y / cp.w, cp.z / cp.w];
297            if !p.iter().all(|x| x.is_finite()) { return None; }
298            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); }
299            count += 1;
300        }
301    }
302    (count > 0).then_some(bounds)
303}
304
305/// Positive evidence only: a topological vertex on the other solid's boundary
306/// within 1e-6 mm. No witness is not evidence of clearance.
307fn contact_witness(a: &brep_kernel::BrepSolid, b: &brep_kernel::BrepSolid) -> Result<bool, String> {
308    for (vertices, target) in [(&a.vertices, b), (&b.vertices, a)] {
309        for v in vertices {
310            if brep_kernel::classify_point(v.point, target, 1e-6)?.class == brep_kernel::PointClass::On { return Ok(true); }
311        }
312    }
313    Ok(false)
314}
315
316/// Continuations are server-owned, scoped to one engine and one applied model.
317/// Keep only a bounded number; expired tokens fail explicitly.
318#[derive(Clone)]
319pub(super) struct InterferenceCursor {
320    revision: u64,
321    document: String,
322    scope: Vec<String>,
323    next: usize,
324    unresolved: usize,
325    interfering: usize,
326}
327
328impl EngineState {
329    /// A pair budget bounds progress even for assemblies whose boxes are disjoint.
330    /// Zero-volume intersections do not establish contact: without an exact
331    /// distance witness they remain unresolved (never an all-clear).
332    pub fn interference_page(&mut self, scope: Option<Vec<String>>, budget: usize, token: Option<String>) -> Result<serde_json::Value, String> {
333        use serde_json::json;
334        if self.run_pending() { return Err("model rebuild is pending; wait before checking interference".into()); }
335        if !(1..=4096).contains(&budget) { return Err("budget must be in 1..=4096 component pairs".into()); }
336        let revision = self.applied_generation();
337        let document = self.history.request_json();
338        let mut cursor = if let Some(token) = &token {
339            let c = self.interference_sessions.get(token).ok_or("unknown or expired continuation token")?.clone();
340            if c.revision != revision || c.document != document { return Err("stale continuation: model changed; restart without a token".into()); }
341            if scope.as_ref().is_some_and(|s| s != &c.scope) { return Err("continuation scope differs from requested components".into()); }
342            c
343        } else {
344            let known = self.component_ids();
345            let ids = scope.unwrap_or_else(|| known.clone());
346            let mut seen = std::collections::HashSet::new();
347            for id in &ids {
348                if !known.contains(id) { return Err(format!("unknown component `{id}`")); }
349                if !seen.insert(id) { return Err(format!("duplicate component `{id}`")); }
350            }
351            InterferenceCursor { revision, document, scope: ids, next: 0, unresolved: 0, interfering: 0 }
352        };
353        let total = cursor.scope.len() * cursor.scope.len().saturating_sub(1) / 2;
354        let start = cursor.next;
355        let end = start.saturating_add(budget).min(total);
356        let handles = self.resident_solid_handles();
357        let mut outcomes = Vec::new();
358        let mut booleans_run = 0usize;
359        let mut boolean_operations = 0usize;
360        let mut pair_index = 0;
361        for i in 0..cursor.scope.len() {
362            for j in i+1..cursor.scope.len() {
363                let index = pair_index;
364                pair_index += 1;
365                if index < start || index >= end { continue; }
366                let a = &cursor.scope[i];
367                let b = &cursor.scope[j];
368                let ma = self.component_info(a).map(|c| c.members).unwrap_or_default();
369                let mb = self.component_info(b).map(|c| c.members).unwrap_or_default();
370                let mut volume = 0.0;
371                let mut errors = Vec::new();
372                let mut ambiguous = false;
373                let mut touching = false;
374                let mut ran_boolean = false;
375                if ma.is_empty() || mb.is_empty() { errors.push(json!({"operation":"resolve_operands", "category":"missing_geometry", "operands":[a,b]})); }
376                for sa in &ma {
377                    for sb in &mb {
378                        let (Some(ha), Some(hb), Some(da), Some(db)) = (handles.get(sa), handles.get(sb), self.scene.solid(sa), self.scene.solid(sb)) else {
379                            errors.push(json!({"operation":"resolve_operands", "category":"missing_geometry", "operands":[sa,sb]}));
380                            continue;
381                        };
382                        let _ = (da, db); // Display presence is coverage, not geometric evidence.
383                        let exact_a = brep_kernel::registered_solid_clone(*ha);
384                        let exact_b = brep_kernel::registered_solid_clone(*hb);
385                        if let (Ok(a), Ok(b)) = (&exact_a, &exact_b) {
386                            if let (Some(ba), Some(bb)) = (conservative_bounds(a), conservative_bounds(b)) {
387                                if !overlaps(&ba, &bb) { continue; }
388                            }
389                        }
390                        ran_boolean = true;
391                        boolean_operations += 1;
392                        match intersect_volume(*ha, *hb) {
393                            Ok(v) if v.is_finite() && v >= 0.0 => {
394                                volume += v;
395                                if v <= VOLUME_EPSILON {
396                                    match (&exact_a, &exact_b) {
397                                        (Ok(a), Ok(b)) => match contact_witness(a, b) {
398                                            Ok(true) => touching = true,
399                                            Ok(false) => ambiguous = true,
400                                            Err(e) => errors.push(json!({"operation":"contact_verification", "category":"kernel_refusal", "operands":[sa,sb], "detail":e})),
401                                        },
402                                        _ => ambiguous = true,
403                                    }
404                                }
405                            },
406                            Ok(_) => errors.push(json!({"operation":"intersection_volume", "category":"numerical", "operands":[sa,sb]})),
407                            Err(e) => errors.push(json!({"operation":"intersect", "category":"kernel_refusal", "operands":[sa,sb], "detail":e})),
408                        }
409                    }
410                }
411                for (n, diagnostic) in errors.iter_mut().enumerate() {
412                    if let Some(detail) = diagnostic.get("detail").and_then(serde_json::Value::as_str).map(str::to_owned) {
413                        let id = format!("interference:{revision}:{a}:{b}:{n}");
414                        self.geometry_diagnostics.insert(id.clone(), diagnostic.clone());
415                        diagnostic.as_object_mut().unwrap().remove("detail");
416                        diagnostic["message"] = json!(detail.chars().take(240).collect::<String>());
417                        diagnostic["detailId"] = json!(id);
418                    }
419                }
420                if ran_boolean { booleans_run += 1; }
421                let status = if !errors.is_empty() { "unverified" }
422                    else if volume > VOLUME_EPSILON { "verified_interference" }
423                    else if ambiguous { "unverified" } else if touching { "verified_touching" } else { "verified_clearance" };
424                if status == "unverified" { cursor.unresolved += 1; }
425                if volume > VOLUME_EPSILON { cursor.interfering += 1; }
426                outcomes.push(json!({"index":index, "a":a, "b":b, "status":status, "volume":volume,
427                    "aHidden":ma.iter().any(|n| self.scene.solid(n).is_some_and(|s| !s.visible)),
428                    "bHidden":mb.iter().any(|n| self.scene.solid(n).is_some_and(|s| !s.visible)),
429                    "diagnostics":errors, "reason":if ambiguous && errors.is_empty() { Some("zero_volume_does_not_distinguish_contact_from_clearance") } else { None }}));
430            }
431        }
432        cursor.next = end;
433        let complete = end == total;
434        let next_token = if complete { None } else {
435            self.interference_sequence += 1;
436            Some(format!("interference-{revision}-{}", self.interference_sequence))
437        };
438        if let Some(old) = token { self.interference_sessions.remove(&old); }
439        if let Some(next) = &next_token {
440            if self.interference_sessions.len() >= 16 {
441                let oldest = self.interference_sessions.keys().min_by_key(|key| key.rsplit('-').next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)).cloned();
442                if let Some(oldest) = oldest { self.interference_sessions.remove(&oldest); }
443            }
444            self.interference_sessions.insert(next.clone(), cursor.clone());
445        }
446        let mut interfering: Vec<&serde_json::Value> = outcomes.iter().filter(|p| p["volume"].as_f64().is_some_and(|v| v > VOLUME_EPSILON)).collect();
447        interfering.sort_by(|a,b| b["volume"].as_f64().unwrap().total_cmp(&a["volume"].as_f64().unwrap()));
448        let skipped: Vec<String> = if complete { vec![] } else { vec![format!("{} component pairs not checked: pair budget exhausted", total-end)] };
449        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();
450        Ok(json!({"modelRevision":revision, "scope":cursor.scope, "componentCount":cursor.scope.len(),
451            "budget":budget, "pairTotal":total, "checkedPairs":end, "pageStart":start, "pairs":interfering, "outcomes":outcomes, "skipped":skipped, "unverified":unverified, "booleansRun":booleans_run, "booleanOperations":boolean_operations,
452            "notChecked": {"count":total-end, "fromPairIndex":end, "status":"not_checked_budget_exhausted", "reason":if complete { serde_json::Value::Null } else { json!("budget_exhausted") }},
453            "unresolvedCount":cursor.unresolved, "interferenceCount":cursor.interfering,
454            "complete":complete, "allClear":complete && cursor.unresolved == 0 && cursor.interfering == 0,
455            "continuationToken":next_token, "volumeToleranceMm3":VOLUME_EPSILON,
456            "contactToleranceMm":1e-6, "contactVerification":"boundary vertex witness within tolerance after successful intersection; unwitnessed zero-volume pairs remain unverified"}))
457    }
458}
459