1use super::*;
2use crate::camera::Aabb;
3
4const VOLUME_EPSILON: f64 = 1e-6;
33
34pub const INTERFERENCE_OVERLAY_GROUP: &str = "assembly-interference";
37
38#[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#[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#[derive(Debug, Clone, Default, PartialEq)]
67pub struct InterferenceReport {
68 pub component_count: usize,
70 pub pair_total: usize,
72 pub booleans_run: usize,
75 pub pairs: Vec<InterferencePair>,
77 pub clearance: f64,
79 pub near_misses: Vec<ClearancePair>,
81 pub skipped: Vec<String>,
84 pub unverified: Vec<String>,
88 pub generation: u64,
91 pub volume_triangles: Vec<[f32; 3]>,
94}
95
96fn note_boolean_call() {
101}
102
103
104
105fn 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 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 brep_kernel::free_solid(result);
137 volume.map(|v| (v, triangles))
138}
139
140pub(crate) struct PlanComponent {
145 pub id: String,
146 pub bbox: Aabb,
147 pub hidden: bool,
148}
149
150pub(crate) struct PairPlan {
153 pub candidate_pairs: Vec<(usize, usize)>,
154 pub skipped: Vec<String>,
155 pub pair_total: usize,
156}
157
158fn 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
178pub(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
186pub(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; }
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
219type Triangle = [[f64; 3]; 3];
222
223fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
224 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
225}
226fn add_scaled(a: [f64; 3], d: [f64; 3], t: f64) -> [f64; 3] {
227 [a[0] + d[0] * t, a[1] + d[1] * t, a[2] + d[2] * t]
228}
229fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
230 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
231}
232fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
233 [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
234}
235fn dist2(a: [f64; 3], b: [f64; 3]) -> f64 {
236 let d = sub(a, b);
237 dot(d, d)
238}
239
240fn closest_on_triangle(p: [f64; 3], t: &Triangle) -> [f64; 3] {
243 let [a, b, c] = *t;
244 let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
245 let (d1, d2) = (dot(ab, ap), dot(ac, ap));
246 if d1 <= 0.0 && d2 <= 0.0 {
247 return a;
248 }
249 let bp = sub(p, b);
250 let (d3, d4) = (dot(ab, bp), dot(ac, bp));
251 if d3 >= 0.0 && d4 <= d3 {
252 return b;
253 }
254 let vc = d1 * d4 - d3 * d2;
255 if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
256 return add_scaled(a, ab, d1 / (d1 - d3));
257 }
258 let cp = sub(p, c);
259 let (d5, d6) = (dot(ab, cp), dot(ac, cp));
260 if d6 >= 0.0 && d5 <= d6 {
261 return c;
262 }
263 let vb = d5 * d2 - d1 * d6;
264 if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
265 return add_scaled(a, ac, d2 / (d2 - d6));
266 }
267 let va = d3 * d6 - d5 * d4;
268 if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
269 return add_scaled(b, sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6)));
270 }
271 let denom = 1.0 / (va + vb + vc);
272 add_scaled(add_scaled(a, ab, vb * denom), ac, vc * denom)
273}
274
275fn closest_segment_segment(p1: [f64; 3], q1: [f64; 3], p2: [f64; 3], q2: [f64; 3]) -> ([f64; 3], [f64; 3]) {
277 let (d1, d2, r) = (sub(q1, p1), sub(q2, p2), sub(p1, p2));
278 let (a, e, f) = (dot(d1, d1), dot(d2, d2), dot(d2, r));
279 const EPS: f64 = 1e-18;
280 let (s, t);
281 if a <= EPS && e <= EPS {
282 return (p1, p2);
283 }
284 if a <= EPS {
285 s = 0.0;
286 t = (f / e).clamp(0.0, 1.0);
287 } else {
288 let c = dot(d1, r);
289 if e <= EPS {
290 t = 0.0;
291 s = (-c / a).clamp(0.0, 1.0);
292 } else {
293 let b = dot(d1, d2);
294 let denom = a * e - b * b;
295 let s0 = if denom > EPS { ((b * f - c * e) / denom).clamp(0.0, 1.0) } else { 0.0 };
296 let t0 = (b * s0 + f) / e;
297 if t0 < 0.0 {
298 t = 0.0;
299 s = (-c / a).clamp(0.0, 1.0);
300 } else if t0 > 1.0 {
301 t = 1.0;
302 s = ((b - c) / a).clamp(0.0, 1.0);
303 } else {
304 t = t0;
305 s = s0;
306 }
307 }
308 }
309 (add_scaled(p1, d1, s), add_scaled(p2, d2, t))
310}
311
312fn segment_hits_triangle(p: [f64; 3], q: [f64; 3], t: &Triangle) -> Option<[f64; 3]> {
316 let dir = sub(q, p);
317 let (e1, e2) = (sub(t[1], t[0]), sub(t[2], t[0]));
318 let h = cross(dir, e2);
319 let det = dot(e1, h);
320 if det.abs() < 1e-18 {
321 return None;
322 }
323 let inv = 1.0 / det;
324 let s = sub(p, t[0]);
325 let u = inv * dot(s, h);
326 if !(0.0..=1.0).contains(&u) {
327 return None;
328 }
329 let qv = cross(s, e1);
330 let v = inv * dot(dir, qv);
331 if v < 0.0 || u + v > 1.0 {
332 return None;
333 }
334 let w = inv * dot(e2, qv);
335 (0.0..=1.0).contains(&w).then(|| add_scaled(p, dir, w))
336}
337
338pub(crate) fn triangle_distance(a: &Triangle, b: &Triangle) -> (f64, [f64; 3], [f64; 3]) {
342 for (s, t) in [(a, b), (b, a)] {
343 for k in 0..3 {
344 if let Some(hit) = segment_hits_triangle(s[k], s[(k + 1) % 3], t) {
345 return (0.0, hit, hit);
346 }
347 }
348 }
349 let mut best = (f64::INFINITY, a[0], b[0]);
350 let mut consider = |p: [f64; 3], q: [f64; 3]| {
351 let d = dist2(p, q);
352 if d < best.0 {
353 best = (d, p, q);
354 }
355 };
356 for k in 0..3 {
357 consider(a[k], closest_on_triangle(a[k], b));
358 consider(closest_on_triangle(b[k], a), b[k]);
359 for m in 0..3 {
360 let (p, q) = closest_segment_segment(a[k], a[(k + 1) % 3], b[m], b[(m + 1) % 3]);
361 consider(p, q);
362 }
363 }
364 (best.0.sqrt(), best.1, best.2)
365}
366
367fn triangle_box(t: &Triangle) -> Aabb {
368 let mut bbox = Aabb::empty();
369 for p in t {
370 bbox.expand(*p);
371 }
372 bbox
373}
374
375fn box_gap(a: &Aabb, b: &Aabb) -> f64 {
377 let mut sum = 0.0;
378 for axis in 0..3 {
379 let gap = (a.min[axis] - b.max[axis]).max(b.min[axis] - a.max[axis]).max(0.0);
380 sum += gap * gap;
381 }
382 sum.sqrt()
383}
384
385fn triangles_near(solid: &crate::scene::SolidDisplay, window: &Aabb) -> Vec<(Triangle, Aabb)> {
387 let point = |i: u32| solid.mesh.positions.get(i as usize).map(|p| p.map(f64::from));
388 solid
389 .mesh
390 .indices
391 .chunks_exact(3)
392 .filter_map(|tri| Some([point(tri[0])?, point(tri[1])?, point(tri[2])?]))
393 .map(|t| (t, triangle_box(&t)))
394 .filter(|(_, bbox)| overlaps(bbox, window))
395 .collect()
396}
397
398pub(crate) fn display_min_distance(
401 a: &crate::scene::SolidDisplay,
402 b: &crate::scene::SolidDisplay,
403 reach: f64,
404) -> Option<(f64, [f64; 3], [f64; 3])> {
405 let near_a = triangles_near(a, &inflated(&b.bbox, reach));
406 let near_b = triangles_near(b, &inflated(&a.bbox, reach));
407 let mut best: Option<(f64, [f64; 3], [f64; 3])> = None;
408 for (ta, box_a) in &near_a {
409 for (tb, box_b) in &near_b {
410 let bound = best.map_or(f64::INFINITY, |b| b.0);
411 if box_gap(box_a, box_b) >= bound {
412 continue;
413 }
414 let found = triangle_distance(ta, tb);
415 if found.0 < bound {
416 best = Some(found);
417 if found.0 == 0.0 {
418 return best;
419 }
420 }
421 }
422 }
423 best
424}
425
426fn transform_point(m: &[f64; 16], p: [f64; 3]) -> [f64; 3] {
428 [
429 m[0] * p[0] + m[1] * p[1] + m[2] * p[2] + m[3],
430 m[4] * p[0] + m[5] * p[1] + m[6] * p[2] + m[7],
431 m[8] * p[0] + m[9] * p[1] + m[10] * p[2] + m[11],
432 ]
433}
434
435pub(crate) fn posed_bbox(bbox: &Aabb, m: &[f64; 16]) -> Aabb {
437 let mut out = Aabb::empty();
438 if bbox.is_empty() {
439 return out;
440 }
441 for corner in 0..8 {
442 let p = [
443 if corner & 1 == 0 { bbox.min[0] } else { bbox.max[0] },
444 if corner & 2 == 0 { bbox.min[1] } else { bbox.max[1] },
445 if corner & 4 == 0 { bbox.min[2] } else { bbox.max[2] },
446 ];
447 out.expand(transform_point(m, p));
448 }
449 out
450}
451
452fn posed_triangles_near(solid: &crate::scene::SolidDisplay, m: &[f64; 16], window: &Aabb) -> Vec<(Triangle, Aabb)> {
454 let point = |i: u32| solid.mesh.positions.get(i as usize).map(|p| transform_point(m, p.map(f64::from)));
455 solid
456 .mesh
457 .indices
458 .chunks_exact(3)
459 .filter_map(|tri| Some([point(tri[0])?, point(tri[1])?, point(tri[2])?]))
460 .map(|t| (t, triangle_box(&t)))
461 .filter(|(_, bbox)| overlaps(bbox, window))
462 .collect()
463}
464
465pub(crate) fn displays_pierce(a: &crate::scene::SolidDisplay, ma: &[f64; 16], b: &crate::scene::SolidDisplay, mb: &[f64; 16]) -> bool {
471 let (box_a, box_b) = (posed_bbox(&a.bbox, ma), posed_bbox(&b.bbox, mb));
472 if !overlaps(&box_a, &box_b) {
473 return false;
474 }
475 let near_a = posed_triangles_near(a, ma, &box_b);
476 let near_b = posed_triangles_near(b, mb, &box_a);
477 for (ta, bx_a) in &near_a {
478 for (tb, bx_b) in &near_b {
479 if !overlaps(bx_a, bx_b) {
480 continue;
481 }
482 for (s, t) in [(ta, tb), (tb, ta)] {
483 for k in 0..3 {
484 if segment_hits_triangle(s[k], s[(k + 1) % 3], t).is_some() {
485 return true;
486 }
487 }
488 }
489 }
490 }
491 false
492}
493
494fn part_occurrence(solid: &str, top: &str) -> String {
499 let rest = solid.strip_prefix(top).and_then(|r| r.strip_prefix(':')).unwrap_or("");
500 let mut leaf = top.to_string();
501 let segments: Vec<&str> = rest.split(':').collect();
502 for segment in segments.iter().take(segments.len().saturating_sub(1)) {
505 let id = segment.strip_prefix("ACOMP").is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()));
506 if !id {
507 break;
508 }
509 leaf.push(':');
510 leaf.push_str(segment);
511 }
512 leaf
513}
514
515impl EngineState {
518 pub(crate) fn interference_participants(&self) -> Vec<(String, Vec<String>)> {
526 let mut tops = self.component_ids();
527 tops.extend(self.pattern_instance_ids());
528 let mut out: Vec<(String, Vec<String>)> = Vec::new();
529 for top in &tops {
530 let members = self.component_members(top);
531 if members.is_empty() {
532 out.push((top.clone(), Vec::new()));
535 }
536 for name in members {
537 let leaf = part_occurrence(&name, top);
538 match out.iter_mut().find(|(id, _)| *id == leaf) {
539 Some((_, solids)) => solids.push(name),
540 None => out.push((leaf, vec![name])),
541 }
542 }
543 }
544 out
545 }
546
547 pub fn interference_check(&mut self) -> InterferenceReport {
549 self.interference_check_with(0.0)
550 }
551
552 pub fn interference_check_with(&mut self, clearance: f64) -> InterferenceReport {
559 let clearance = if clearance.is_finite() { clearance.max(0.0) } else { 0.0 };
560
561 let (ids, grouped): (Vec<String>, Vec<Vec<String>>) = self.interference_participants().into_iter().unzip();
566 let mut members: Vec<Vec<String>> = Vec::with_capacity(ids.len());
567 let mut plan_input: Vec<PlanComponent> = Vec::with_capacity(ids.len());
568 for (id, solids) in ids.iter().zip(grouped) {
569 let mut bbox = Aabb::empty();
570 let mut hidden = false;
571 for name in &solids {
572 if let Some(solid) = self.scene.solid(name) {
573 bbox.union(&solid.bbox);
574 hidden |= !solid.visible;
575 }
576 }
577 plan_input.push(PlanComponent {
578 id: id.clone(),
579 bbox,
580 hidden,
581 });
582 members.push(solids);
583 }
584
585 let plan = plan_pairs(&plan_input, clearance);
586 let mut report = InterferenceReport {
587 component_count: ids.len(),
588 pair_total: plan.pair_total,
589 clearance,
590 skipped: plan.skipped,
591 generation: self.applied_generation(),
592 ..Default::default()
593 };
594 let lod = if self.settings.lod_factor.is_finite() && self.settings.lod_factor > 0.0 {
595 self.settings.lod_factor
596 } else {
597 1.0
598 };
599
600 let handles = if plan.candidate_pairs.is_empty() {
603 Default::default()
604 } else {
605 self.resident_solid_handles()
606 };
607 for (i, j) in plan.candidate_pairs {
608 let (a, b) = (&plan_input[i], &plan_input[j]);
609 let mut volume = 0.0;
610 let mut refusal: Option<String> = None;
611 let mut ran_boolean = false;
612 let mut gap: Option<(f64, [f64; 3], [f64; 3])> = None;
613 for solid_a in &members[i] {
614 for solid_b in &members[j] {
615 let (Some(da), Some(db)) =
616 (self.scene.solid(solid_a), self.scene.solid(solid_b))
617 else {
618 refusal.get_or_insert_with(|| format!("missing display operand: {solid_a} or {solid_b}"));
619 continue;
620 };
621 if overlaps(&inflated(&da.bbox, 0.0), &inflated(&db.bbox, 0.0)) {
626 let (Some(&ha), Some(&hb)) = (handles.get(solid_a), handles.get(solid_b))
627 else {
628 refusal.get_or_insert_with(|| format!("missing resident operand: {solid_a} or {solid_b}"));
629 continue;
630 };
631 ran_boolean = true;
632 match intersect_volume(ha, hb, Some(lod)) {
633 Ok((v, triangles)) => {
634 volume += v;
635 report.volume_triangles.extend(triangles);
636 }
637 Err(error) => {
638 refusal.get_or_insert(error);
642 }
643 }
644 }
645 if clearance > 0.0 && overlaps(&inflated(&da.bbox, clearance), &db.bbox) {
646 if let Some(found) = display_min_distance(da, db, clearance) {
647 if gap.is_none_or(|g| found.0 < g.0) {
648 gap = Some(found);
649 }
650 }
651 }
652 }
653 }
654 if ran_boolean {
655 report.booleans_run += 1;
656 }
657 if let Some(error) = &refusal {
658 report
659 .unverified
660 .push(format!("{} × {} — boolean refused: {error}", a.id, b.id));
661 }
662 if volume > VOLUME_EPSILON {
663 report.pairs.push(InterferencePair {
664 a: a.id.clone(),
665 b: b.id.clone(),
666 volume,
667 a_hidden: a.hidden,
668 b_hidden: b.hidden,
669 });
670 } else if let Some((distance, pa, pb)) = gap.filter(|g| g.0 <= clearance) {
671 report.near_misses.push(ClearancePair {
672 a: a.id.clone(),
673 b: b.id.clone(),
674 distance,
675 witness: [pa, pb],
676 a_hidden: a.hidden,
677 b_hidden: b.hidden,
678 });
679 }
680 }
681 report
684 .pairs
685 .sort_by(|x, y| y.volume.total_cmp(&x.volume));
686 report
687 .near_misses
688 .sort_by(|x, y| x.distance.total_cmp(&y.distance));
689 self.show_interference_overlay(&report);
690 report
691 }
692
693 pub fn show_interference_overlay(&mut self, report: &InterferenceReport) {
697 let rgb = |status: &str| crate::assembly_status::status_color_rgb(status).map(|c| f32::from(c) / 255.0);
698 let (red, amber) = (rgb("error"), rgb("unsupported-selection"));
699 let tri_positions: Vec<f32> = report.volume_triangles.iter().flatten().copied().collect();
700 let tri_colors: Vec<f32> = report.volume_triangles.iter().flat_map(|_| red).collect();
701 let ends: Vec<f32> = report
702 .near_misses
703 .iter()
704 .flat_map(|pair| pair.witness)
705 .flatten()
706 .map(|v| v as f32)
707 .collect();
708 let end_colors: Vec<f32> = (0..ends.len() / 3).flat_map(|_| amber).collect();
709 let _ = self.set_overlay_json(
710 &serde_json::json!({
711 "groups": [{
712 "name": INTERFERENCE_OVERLAY_GROUP,
713 "renderOrder": 10001,
714 "tris": { "positions": tri_positions, "colors": tri_colors },
715 "lines": { "positions": ends, "colors": end_colors },
716 "points": { "positions": ends, "colors": end_colors },
717 }]
718 })
719 .to_string(),
720 );
721 }
722
723 pub fn clear_interference_overlay(&mut self) {
725 let _ = self.set_overlay_json(
726 &serde_json::json!({ "groups": [{ "name": INTERFERENCE_OVERLAY_GROUP }] }).to_string(),
727 );
728 }
729
730 pub fn zoom_to_components(&mut self, component_ids: &[String]) {
733 let mut bbox = Aabb::empty();
734 for id in component_ids {
735 for name in self.component_info(id).map(|info| info.members).unwrap_or_default() {
736 if let Some(solid) = self.scene.solid(&name) {
737 bbox.union(&solid.bbox);
738 }
739 }
740 }
741 if !bbox.is_empty() {
742 self.camera.zoom_to_fit(&bbox, 1.5);
743 self.dirty = true;
744 }
745 }
746}
747
748
749fn conservative_bounds(solid: &brep_kernel::BrepSolid) -> Option<Aabb> {
753 let mut bounds = Aabb::empty();
754 let mut count = 0;
755 for face in solid.shells.iter().flat_map(|s| &s.faces) {
756 if face.surface.control_points.is_empty() { return None; }
757 for cp in face.surface.control_points.iter().flatten() {
758 if cp.w <= 0.0 { return None; }
759 let p = [cp.x / cp.w, cp.y / cp.w, cp.z / cp.w];
760 if !p.iter().all(|x| x.is_finite()) { return None; }
761 for axis in 0..3 { bounds.min[axis] = bounds.min[axis].min(p[axis] - 1e-6); bounds.max[axis] = bounds.max[axis].max(p[axis] + 1e-6); }
762 count += 1;
763 }
764 }
765 (count > 0).then_some(bounds)
766}
767
768fn contact_witness(a: &brep_kernel::BrepSolid, b: &brep_kernel::BrepSolid) -> Result<bool, String> {
771 for (vertices, target) in [(&a.vertices, b), (&b.vertices, a)] {
772 for v in vertices {
773 if brep_kernel::classify_point(v.point, target, 1e-6)?.class == brep_kernel::PointClass::On { return Ok(true); }
774 }
775 }
776 Ok(false)
777}
778
779#[derive(Clone)]
782pub(super) struct InterferenceCursor {
783 revision: u64,
784 document: String,
785 scope: Vec<String>,
786 next: usize,
787 unresolved: usize,
788 interfering: usize,
789}
790
791impl EngineState {
792 pub fn interference_page(&mut self, scope: Option<Vec<String>>, budget: usize, token: Option<String>, clearance: Option<f64>) -> Result<serde_json::Value, String> {
796 use serde_json::json;
797 if self.run_pending() { return Err("model rebuild is pending; wait before checking interference".into()); }
798 if !(1..=4096).contains(&budget) { return Err("budget must be in 1..=4096 component pairs".into()); }
799 let clearance = clearance.unwrap_or(0.0);
800 if !clearance.is_finite() || clearance < 0.0 { return Err("clearanceMm must be a finite distance >= 0".into()); }
801 let revision = self.applied_generation();
802 let document = self.history.request_json();
803 let mut cursor = if let Some(token) = &token {
804 let c = self.interference_sessions.get(token).ok_or("unknown or expired continuation token")?.clone();
805 if c.revision != revision || c.document != document { return Err("stale continuation: model changed; restart without a token".into()); }
806 if scope.as_ref().is_some_and(|s| s != &c.scope) { return Err("continuation scope differs from requested components".into()); }
807 c
808 } else {
809 let known: Vec<String> = self.interference_participants().into_iter().map(|(id, _)| id).collect();
810 let mut tops = self.component_ids();
811 tops.extend(self.pattern_instance_ids());
812 let ids: Vec<String> = match scope {
815 None => known.clone(),
816 Some(requested) => {
817 let mut seen = std::collections::HashSet::new();
818 for id in &requested {
819 if !known.contains(id) && !tops.contains(id) { return Err(format!("unknown component `{id}`")); }
820 if !seen.insert(id.clone()) { return Err(format!("duplicate component `{id}`")); }
821 }
822 let mut expanded: Vec<String> = Vec::new();
823 for id in requested {
824 let prefix = format!("{id}:");
825 for leaf in known.iter().filter(|leaf| **leaf == id || leaf.starts_with(&prefix)) {
826 if !expanded.contains(leaf) { expanded.push(leaf.clone()); }
827 }
828 }
829 expanded
830 }
831 };
832 InterferenceCursor { revision, document, scope: ids, next: 0, unresolved: 0, interfering: 0 }
833 };
834 let total = cursor.scope.len() * cursor.scope.len().saturating_sub(1) / 2;
835 let start = cursor.next;
836 let end = start.saturating_add(budget).min(total);
837 let handles = self.resident_solid_handles();
838 let participants: std::collections::HashMap<String, Vec<String>> = self.interference_participants().into_iter().collect();
839 let mut bounds: std::collections::HashMap<String, Option<Aabb>> = std::collections::HashMap::new();
843 let mut outcomes = Vec::new();
844 let mut booleans_run = 0usize;
845 let mut boolean_operations = 0usize;
846 let mut pair_index = 0;
847 for i in 0..cursor.scope.len() {
848 for j in i+1..cursor.scope.len() {
849 let index = pair_index;
850 pair_index += 1;
851 if index < start || index >= end { continue; }
852 let a = &cursor.scope[i];
853 let b = &cursor.scope[j];
854 let ma = participants.get(a).cloned().unwrap_or_default();
855 let mb = participants.get(b).cloned().unwrap_or_default();
856 let mut volume = 0.0;
857 let mut errors = Vec::new();
858 let mut ambiguous = false;
859 let mut touching = false;
860 let mut ran_boolean = false;
861 if ma.is_empty() || mb.is_empty() { errors.push(json!({"operation":"resolve_operands", "category":"missing_geometry", "operands":[a,b]})); }
862 for sa in &ma {
863 for sb in &mb {
864 let (Some(ha), Some(hb), Some(da), Some(db)) = (handles.get(sa), handles.get(sb), self.scene.solid(sa), self.scene.solid(sb)) else {
865 errors.push(json!({"operation":"resolve_operands", "category":"missing_geometry", "operands":[sa,sb]}));
866 continue;
867 };
868 let _ = (da, db); let mut bound = |name: &String, handle: u32| -> Option<Aabb> {
870 *bounds.entry(name.clone()).or_insert_with(|| {
871 brep_kernel::registered_solid_clone(handle).ok().and_then(|solid| conservative_bounds(&solid))
872 })
873 };
874 if let (Some(ba), Some(bb)) = (bound(sa, *ha), bound(sb, *hb)) {
875 if !overlaps(&ba, &bb) { continue; }
876 }
877 let exact_a = brep_kernel::registered_solid_clone(*ha);
878 let exact_b = brep_kernel::registered_solid_clone(*hb);
879 ran_boolean = true;
880 boolean_operations += 1;
881 match intersect_volume(*ha, *hb, None).map(|(v, _)| v) {
882 Ok(v) if v.is_finite() && v >= 0.0 => {
883 volume += v;
884 if v <= VOLUME_EPSILON {
885 match (&exact_a, &exact_b) {
886 (Ok(a), Ok(b)) => match contact_witness(a, b) {
887 Ok(true) => touching = true,
888 Ok(false) => ambiguous = true,
889 Err(e) => errors.push(json!({"operation":"contact_verification", "category":"kernel_refusal", "operands":[sa,sb], "detail":e})),
890 },
891 _ => ambiguous = true,
892 }
893 }
894 },
895 Ok(_) => errors.push(json!({"operation":"intersection_volume", "category":"numerical", "operands":[sa,sb]})),
896 Err(e) => errors.push(json!({"operation":"intersect", "category":"kernel_refusal", "operands":[sa,sb], "detail":e})),
897 }
898 }
899 }
900 for (n, diagnostic) in errors.iter_mut().enumerate() {
901 if let Some(detail) = diagnostic.get("detail").and_then(serde_json::Value::as_str).map(str::to_owned) {
902 let id = format!("interference:{revision}:{a}:{b}:{n}");
903 self.geometry_diagnostics.insert(id.clone(), diagnostic.clone());
904 diagnostic.as_object_mut().unwrap().remove("detail");
905 diagnostic["message"] = json!(detail.chars().take(240).collect::<String>());
906 diagnostic["detailId"] = json!(id);
907 }
908 }
909 if ran_boolean { booleans_run += 1; }
910 let mut min_distance: Option<f64> = None;
913 if clearance > 0.0 && volume <= VOLUME_EPSILON {
914 for sa in &ma {
915 for sb in &mb {
916 let (Some(da), Some(db)) = (self.scene.solid(sa), self.scene.solid(sb)) else { continue };
917 if !overlaps(&inflated(&da.bbox, clearance), &db.bbox) { continue; }
918 if let Some((d, _, _)) = display_min_distance(da, db, clearance) {
919 min_distance = Some(min_distance.map_or(d, |m: f64| m.min(d)));
920 }
921 }
922 }
923 }
924 let status = if !errors.is_empty() { "unverified" }
925 else if volume > VOLUME_EPSILON { "verified_interference" }
926 else if ambiguous { "unverified" } else if touching { "verified_touching" } else { "verified_clearance" };
927 if status == "unverified" { cursor.unresolved += 1; }
928 if volume > VOLUME_EPSILON { cursor.interfering += 1; }
929 outcomes.push(json!({"index":index, "a":a, "b":b, "status":status, "volume":volume,
930 "aHidden":ma.iter().any(|n| self.scene.solid(n).is_some_and(|s| !s.visible)),
931 "bHidden":mb.iter().any(|n| self.scene.solid(n).is_some_and(|s| !s.visible)),
932 "minDistance":min_distance, "nearMiss":min_distance.is_some_and(|d| d <= clearance),
933 "diagnostics":errors, "reason":if ambiguous && errors.is_empty() { Some("zero_volume_does_not_distinguish_contact_from_clearance") } else { None }}));
934 }
935 }
936 cursor.next = end;
937 let complete = end == total;
938 let next_token = if complete { None } else {
939 self.interference_sequence += 1;
940 Some(format!("interference-{revision}-{}", self.interference_sequence))
941 };
942 if let Some(old) = token { self.interference_sessions.remove(&old); }
943 if let Some(next) = &next_token {
944 if self.interference_sessions.len() >= 16 {
945 let oldest = self.interference_sessions.keys().min_by_key(|key| key.rsplit('-').next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)).cloned();
946 if let Some(oldest) = oldest { self.interference_sessions.remove(&oldest); }
947 }
948 self.interference_sessions.insert(next.clone(), cursor.clone());
949 }
950 let mut interfering: Vec<&serde_json::Value> = outcomes.iter().filter(|p| p["volume"].as_f64().is_some_and(|v| v > VOLUME_EPSILON)).collect();
951 interfering.sort_by(|a,b| b["volume"].as_f64().unwrap().total_cmp(&a["volume"].as_f64().unwrap()));
952 let mut near_misses: Vec<&serde_json::Value> = outcomes.iter().filter(|p| p["nearMiss"] == true).collect();
953 near_misses.sort_by(|a,b| a["minDistance"].as_f64().unwrap().total_cmp(&b["minDistance"].as_f64().unwrap()));
954 let skipped: Vec<String> = if complete { vec![] } else { vec![format!("{} component pairs not checked: pair budget exhausted", total-end)] };
955 let unverified: Vec<String> = outcomes.iter().filter(|p| p["status"] == "unverified").map(|p| format!("{} × {}: unverified (see outcomes)",p["a"].as_str().unwrap_or(""),p["b"].as_str().unwrap_or(""))).collect();
956 Ok(json!({"modelRevision":revision, "scope":cursor.scope, "componentCount":cursor.scope.len(),
957 "budget":budget, "pairTotal":total, "checkedPairs":end, "pageStart":start, "pairs":interfering, "clearanceMm":clearance, "nearMisses":near_misses, "outcomes":outcomes, "skipped":skipped, "unverified":unverified, "booleansRun":booleans_run, "booleanOperations":boolean_operations,
958 "notChecked": {"count":total-end, "fromPairIndex":end, "status":"not_checked_budget_exhausted", "reason":if complete { serde_json::Value::Null } else { json!("budget_exhausted") }},
959 "unresolvedCount":cursor.unresolved, "interferenceCount":cursor.interfering,
960 "complete":complete, "allClear":complete && cursor.unresolved == 0 && cursor.interfering == 0,
961 "continuationToken":next_token, "volumeToleranceMm3":VOLUME_EPSILON,
962 "contactToleranceMm":1e-6, "contactVerification":"boundary vertex witness within tolerance after successful intersection; unwitnessed zero-volume pairs remain unverified"}))
963 }
964}
965