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
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
234fn 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
269fn 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
306fn 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
332pub(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
369fn 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
379fn 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
392pub(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
420fn 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
429pub(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
446fn 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
459pub(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
488fn 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 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
509impl EngineState {
512 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 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 pub fn interference_check(&mut self) -> InterferenceReport {
543 self.interference_check_with(0.0)
544 }
545
546 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 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 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 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 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 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 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 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 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
743fn 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
762fn 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#[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 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 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 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); 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 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