1use super::*;
2use crate::camera::Aabb;
3
4const MAX_BOOLEAN_PAIRS: usize = 64;
29
30const VOLUME_EPSILON: f64 = 1e-6;
33
34#[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#[derive(Debug, Clone, Default, PartialEq)]
50pub struct InterferenceReport {
51 pub component_count: usize,
53 pub pair_total: usize,
55 pub booleans_run: usize,
58 pub pairs: Vec<InterferencePair>,
60 pub skipped: Vec<String>,
63 pub unverified: Vec<String>,
67}
68
69fn note_boolean_call() {
74}
75
76
77
78fn 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 brep_kernel::free_solid(result);
97 volume
98}
99
100pub(crate) struct PlanComponent {
105 pub id: String,
106 pub bbox: Aabb,
107 pub hidden: bool,
108}
109
110pub(crate) struct PairPlan {
113 pub boolean_pairs: Vec<(usize, usize)>,
114 pub skipped: Vec<String>,
115 pub pair_total: usize,
116}
117
118fn 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
137fn 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
145pub(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; }
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
179impl EngineState {
182 pub fn interference_check(&mut self) -> InterferenceReport {
187
188 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 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 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 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 report
279 .pairs
280 .sort_by(|x, y| y.volume.total_cmp(&x.volume));
281 report
282 }
283}
284
285
286fn 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
305fn 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#[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 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); 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