brep_render/engine_state/interference.rs
1use super::*;
2use crate::camera::Aabb;
3
4// ===========================================================================
5// Interference check (assemblies build-spec §9) — pairwise NON-DESTRUCTIVE
6// kernel INTERSECT booleans over the component instances, reporting every
7// overlapping pair + its intersection volume. Zero interference is a PASS.
8//
9// Runs MAIN-SIDE like every other assembly op (the thread-local rule): the
10// component solids' kernel handles come from [`EngineState::
11// resident_solid_handles`] — a warm-cache replay of the rolled-to history on
12// THIS thread — and `brep_kernel::boolean_handle` / `mass_properties_handle_
13// native` read the SAME thread-local registry. The operand handles belong to
14// the incremental cache + scene; ONLY the boolean RESULT handle is freed.
15//
16// Scale sanity: N components = N·(N−1)/2 pairs, but a full boolean only runs
17// for pairs whose (inflated, mesh-derived) bboxes overlap — [`plan_pairs`]
18// prefilters, so disjoint pairs are PROVEN clear for the cost of a box test.
19// Bbox-overlapping pairs beyond [`MAX_BOOLEAN_PAIRS`] are SKIPPED with an
20// explicit per-pair note (never silently). Hidden components participate —
21// interference is a physical question — with the pair flagged so the window
22// can note it.
23// ===========================================================================
24
25/// Budget of component pairs allowed to run REAL booleans per check run. Full
26/// booleans cost ~10–100 ms each; the bbox prefilter keeps normal assemblies
27/// far below this. Pairs beyond the budget are reported as skipped.
28const MAX_BOOLEAN_PAIRS: usize = 64;
29
30/// Interference counts only above this intersection volume (mm³): exact face
31/// contact (mated components) integrates to ~0 and must read as clear.
32const VOLUME_EPSILON: f64 = 1e-6;
33
34/// One INTERFERING component pair: the two owning ACOMP feature ids, their
35/// summed intersection volume (mm³, over all member-solid cross pairs), and
36/// whether either participant is currently hidden (it still participates —
37/// the window notes it).
38#[derive(Debug, Clone, PartialEq)]
39pub struct InterferencePair {
40 pub a: String,
41 pub b: String,
42 pub volume: f64,
43 pub a_hidden: bool,
44 pub b_hidden: bool,
45}
46
47/// The result of one interference run — everything the results window shows.
48/// `pairs` empty + `skipped`/`unverified` empty = the green all-clear.
49#[derive(Debug, Clone, Default, PartialEq)]
50pub struct InterferenceReport {
51 /// Number of component instances that participated.
52 pub component_count: usize,
53 /// Every pair considered: N·(N−1)/2.
54 pub pair_total: usize,
55 /// Pairs that ran the boolean lane (bbox-overlapping, within budget).
56 /// The rest were PROVEN clear by the bbox prefilter (or noted below).
57 pub booleans_run: usize,
58 /// The interfering pairs, largest intersection volume first.
59 pub pairs: Vec<InterferencePair>,
60 /// Anything NOT fully checked, one human-readable line each: pairs beyond
61 /// the boolean budget, components with no resident geometry. NEVER silent.
62 pub skipped: Vec<String>,
63 /// Pairs whose boolean REFUSED (the kernel is conservative on grazing /
64 /// tangent contact): not a pass, not an interference — shown under their
65 /// own heading so a mated assembly never reads as a wall of errors.
66 pub unverified: Vec<String>,
67}
68
69// --- the test seam: count trips through the boolean lane --------------------
70
71// BREP private tests: 5a873180887b27e1
72
73/// Tally one boolean-lane invocation (test-observable; free in release).
74fn note_boolean_call() {
75 // BREP private tests: e98d9bcfb06acd3a
76}
77
78// BREP private tests: b65dea05ef00aa94
79
80// BREP private tests: 9ae3b4c40c9a97ab
81
82// --- the boolean lane -------------------------------------------------------
83
84/// Intersection volume (mm³) of two RESIDENT solids, non-destructively:
85/// `boolean_handle_native` reads both operands by reference (pipeline-default
86/// options), the result registers under a NEW handle whose exact volume is
87/// integrated, and that intermediate is freed. A legitimately-disjoint
88/// intersect yields an EMPTY solid → volume 0. The OPERAND handles are owned
89/// by the incremental cache + scene — never free them here.
90fn intersect_volume(a: u32, b: u32) -> Result<f64, String> {
91 note_boolean_call();
92 let result = brep_kernel::boolean_handle_native(
93 a,
94 b,
95 brep_kernel::BooleanOperation::Intersect,
96 &brep_kernel::BooleanOptions::default(),
97 )?;
98 let volume = brep_kernel::mass_properties_handle_native(result, 1.0).map(|p| p.volume);
99 // Free ONLY the result — the intermediate this check minted.
100 brep_kernel::free_solid(result);
101 volume
102}
103
104// --- the pure pair planner (prefilter + budget) ------------------------------
105
106/// One component as the planner sees it: id, the union bbox of its member
107/// solids (EMPTY = no resident geometry), and the hidden flag.
108pub(crate) struct PlanComponent {
109 pub id: String,
110 pub bbox: Aabb,
111 pub hidden: bool,
112}
113
114/// What [`plan_pairs`] decides: which index pairs go to the boolean lane, and
115/// the explicit notes for everything that will NOT be boolean-checked.
116pub(crate) struct PairPlan {
117 pub boolean_pairs: Vec<(usize, usize)>,
118 pub skipped: Vec<String>,
119 pub pair_total: usize,
120}
121
122/// Grow a mesh-derived bbox conservatively before the overlap test: the scene
123/// bbox is over TESSELLATED positions, which under-approximate curved BREP by
124/// up to the chord deviation — a false positive costs one boolean that comes
125/// back empty; a false negative would silently miss real interference.
126fn inflated(bbox: &Aabb) -> Aabb {
127 if bbox.is_empty() {
128 return *bbox;
129 }
130 let size = bbox.size();
131 let diagonal = (size[0] * size[0] + size[1] * size[1] + size[2] * size[2]).sqrt();
132 let margin = (diagonal * 0.01).max(1e-6);
133 let mut out = *bbox;
134 for axis in 0..3 {
135 out.min[axis] -= margin;
136 out.max[axis] += margin;
137 }
138 out
139}
140
141/// Axis-aligned overlap (empty boxes never overlap).
142fn overlaps(a: &Aabb, b: &Aabb) -> bool {
143 if a.is_empty() || b.is_empty() {
144 return false;
145 }
146 (0..3).all(|axis| a.min[axis] <= b.max[axis] && b.min[axis] <= a.max[axis])
147}
148
149/// The PURE planning pass: which pairs must pay for a boolean. Bbox-disjoint
150/// pairs are PROVEN clear (checked, not skipped); pairs past `cap` and
151/// geometry-less components get explicit notes. Deterministic id order.
152pub(crate) fn plan_pairs(components: &[PlanComponent], cap: usize) -> PairPlan {
153 let mut plan = PairPlan {
154 boolean_pairs: Vec::new(),
155 skipped: Vec::new(),
156 pair_total: components.len().saturating_sub(1) * components.len() / 2,
157 };
158 for component in components {
159 if component.bbox.is_empty() {
160 plan.skipped
161 .push(format!("{} — no resident geometry, not checked", component.id));
162 }
163 }
164 let boxes: Vec<Aabb> = components.iter().map(|c| inflated(&c.bbox)).collect();
165 for i in 0..components.len() {
166 for j in (i + 1)..components.len() {
167 if !overlaps(&boxes[i], &boxes[j]) {
168 continue; // proven clear by the prefilter — no boolean needed
169 }
170 if plan.boolean_pairs.len() >= cap {
171 plan.skipped.push(format!(
172 "{} × {} — skipped (boolean budget of {cap} pairs reached)",
173 components[i].id, components[j].id
174 ));
175 continue;
176 }
177 plan.boolean_pairs.push((i, j));
178 }
179 }
180 plan
181}
182
183// --- the engine surface ------------------------------------------------------
184
185impl EngineState {
186 /// Run the interference check over every component instance (hidden ones
187 /// included — interference is a physical question). Non-destructive: the
188 /// component solids are only READ; each pairwise INTERSECT result is
189 /// measured and freed. Returns the full report for the results window.
190 pub fn interference_check(&mut self) -> InterferenceReport {
191 self.ensure_assembly_synced();
192
193 // Gather the participants: members + union bbox + hidden, in the
194 // deterministic history order component_ids() gives.
195 let ids = self.component_ids();
196 let mut members: Vec<Vec<String>> = Vec::with_capacity(ids.len());
197 let mut plan_input: Vec<PlanComponent> = Vec::with_capacity(ids.len());
198 for id in &ids {
199 let info = self.component_info(id);
200 let solids = info.map(|info| info.members).unwrap_or_default();
201 let mut bbox = Aabb::empty();
202 let mut hidden = false;
203 for name in &solids {
204 if let Some(solid) = self.scene.solid(name) {
205 bbox.union(&solid.bbox);
206 hidden |= !solid.visible;
207 }
208 }
209 plan_input.push(PlanComponent {
210 id: id.clone(),
211 bbox,
212 hidden,
213 });
214 members.push(solids);
215 }
216
217 let plan = plan_pairs(&plan_input, MAX_BOOLEAN_PAIRS);
218 let mut report = InterferenceReport {
219 component_count: ids.len(),
220 pair_total: plan.pair_total,
221 booleans_run: 0,
222 pairs: Vec::new(),
223 skipped: plan.skipped,
224 unverified: Vec::new(),
225 };
226 if plan.boolean_pairs.is_empty() {
227 return report;
228 }
229
230 // The warm MAIN-SIDE handle map (cache-hit replay on this thread) —
231 // the same lane the Info windows' mass properties ride.
232 let handles = self.resident_solid_handles();
233 for (i, j) in plan.boolean_pairs {
234 report.booleans_run += 1;
235 let mut volume = 0.0;
236 let mut refusal: Option<String> = None;
237 for solid_a in &members[i] {
238 for solid_b in &members[j] {
239 let (Some(&ha), Some(&hb)) = (handles.get(solid_a), handles.get(solid_b))
240 else {
241 continue; // not resident (rolled back mid-frame)
242 };
243 // Member-level prefilter: within an overlapping component
244 // pair, only member solids whose own boxes overlap pay.
245 let (Some(a), Some(b)) =
246 (self.scene.solid(solid_a), self.scene.solid(solid_b))
247 else {
248 continue;
249 };
250 if !overlaps(&inflated(&a.bbox), &inflated(&b.bbox)) {
251 continue;
252 }
253 match intersect_volume(ha, hb) {
254 Ok(v) => volume += v,
255 Err(error) => {
256 // A conservative kernel refusal (grazing/tangent
257 // contact): the pair is UNVERIFIED, never a
258 // silent pass and never fake interference.
259 refusal.get_or_insert(error);
260 }
261 }
262 }
263 }
264 let (a, b) = (&plan_input[i], &plan_input[j]);
265 if let Some(error) = refusal {
266 report
267 .unverified
268 .push(format!("{} × {} — boolean refused: {error}", a.id, b.id));
269 }
270 if volume > VOLUME_EPSILON {
271 report.pairs.push(InterferencePair {
272 a: a.id.clone(),
273 b: b.id.clone(),
274 volume,
275 a_hidden: a.hidden,
276 b_hidden: b.hidden,
277 });
278 }
279 }
280 // Largest interference first (stable → id order breaks ties).
281 report
282 .pairs
283 .sort_by(|x, y| y.volume.total_cmp(&x.volume));
284 report
285 }
286}
287
288// BREP private tests: 7a7cf14f21ec0a6a