brep_kernel/csg/imprint/driver.rs
1use super::*;
2
3pub fn build_imprints(
4 solid_a: &BrepSolid,
5 solid_b: &BrepSolid,
6 options: &ImprintOptions,
7) -> Result<ImprintResultRecord, String> {
8 let mut section_evidence = false;
9 let edges = edge_map(solid_a, solid_b);
10 let mut builder = ImprintBuilder {
11 edges,
12 tolerance: options.tolerance,
13 barrier_edges: HashSet::default(),
14 overlap_ridden_edges: HashSet::default(),
15 scale: solid_scale(solid_a).max(solid_scale(solid_b)),
16 vertices: Vec::new(),
17 vertex_radii: HashMap::default(),
18 pieces: Vec::new(),
19 by_face: HashMap::default(),
20 edge_splits: HashMap::default(),
21 next_id: 1,
22 };
23 let first_faces = faces(solid_a, 0);
24 let second_faces = faces(solid_b, 1);
25 // Per-face trim-window carriers, computed once: tighter BVH bounds, and
26 // the seed/march stages walk the window instead of the full carrier
27 // (identical geometry and parameterization inside the window).
28 let first_restricted: Vec<Option<NurbsSurface>> = first_faces
29 .iter()
30 .map(|tagged| restricted_carrier(tagged.face))
31 .collect();
32 let second_restricted: Vec<Option<NurbsSurface>> = second_faces
33 .iter()
34 .map(|tagged| restricted_carrier(tagged.face))
35 .collect();
36 let first_bounds = first_faces
37 .iter()
38 .zip(&first_restricted)
39 .map(|(tagged, restricted)| {
40 face_bounds(
41 tagged.face,
42 restricted.as_ref(),
43 &builder.edges,
44 tagged.operand,
45 options.tolerance,
46 )
47 })
48 .collect::<Result<Vec<_>, _>>()?;
49 let second_bounds = second_faces
50 .iter()
51 .zip(&second_restricted)
52 .map(|(tagged, restricted)| {
53 face_bounds(
54 tagged.face,
55 restricted.as_ref(),
56 &builder.edges,
57 tagged.operand,
58 options.tolerance,
59 )
60 })
61 .collect::<Result<Vec<_>, _>>()?;
62 let second_bvh = Bvh::build(&second_bounds);
63 // Per-face classifier data and edge lists/subcurves, computed once and
64 // reused across every pair the face participates in.
65 let first_classify = first_faces
66 .iter()
67 .map(|tagged| SurfaceClassifyData::build(&tagged.face.surface, options.tolerance))
68 .collect::<Result<Vec<_>, _>>()?;
69 let second_classify = second_faces
70 .iter()
71 .map(|tagged| SurfaceClassifyData::build(&tagged.face.surface, options.tolerance))
72 .collect::<Result<Vec<_>, _>>()?;
73 let mut face_edge_lists: HashMap<FaceKey, Vec<&EdgeRecord>> = HashMap::default();
74 for tagged in first_faces.iter().chain(second_faces.iter()) {
75 face_edge_lists.insert(tagged.key(), face_edges(*tagged, &builder.edges)?);
76 }
77 let mut subcurves: HashMap<(u8, u64), NurbsCurve> = HashMap::default();
78 for (&key, edge) in &builder.edges {
79 if !edge.degenerate {
80 // Failures fall through: the use sites recompute and surface the
81 // original error exactly where the uncached code did.
82 if let Ok(curve) = edge_subcurve(edge) {
83 subcurves.insert(key, curve);
84 }
85 }
86 }
87 let cached_subcurve = |operand: u8, edge: &EdgeRecord| -> Result<NurbsCurve, String> {
88 match subcurves.get(&(operand, edge.id)) {
89 Some(curve) => Ok(curve.clone()),
90 None => edge_subcurve(edge),
91 }
92 };
93 let mut profile = ImprintProfile::new();
94 let mut paired = Vec::new();
95 for (first_index, first) in first_faces.iter().enumerate() {
96 let first = *first;
97 paired.clear();
98 second_bvh.overlapping(
99 first_bounds[first_index],
100 options.tolerance * 100.0,
101 &mut paired,
102 );
103 paired.sort_unstable();
104 let debug_pairs = std::env::var("BREP_DEBUG_PAIRS").is_ok();
105 if debug_pairs {
106 eprintln!(
107 "bvh first_face={} -> {} candidate pairs {:?}",
108 first.face.id,
109 paired.len(),
110 paired
111 .iter()
112 .map(|&index| second_faces[index].face.id)
113 .collect::<Vec<_>>()
114 );
115 }
116 for &second_index in &paired {
117 let second = second_faces[second_index];
118 profile.pairs += 1;
119 let mut lap_start = None;
120 profile.lap(&mut lap_start);
121 let pair_classification = classify_surface_pair_cached(
122 &first.face.surface,
123 &first_classify[first_index],
124 &second.face.surface,
125 &second_classify[second_index],
126 options.tolerance,
127 PAIR_ANGULAR_TOLERANCE,
128 )?;
129 profile.classify += profile.lap(&mut lap_start);
130 if pair_classification.relation == SurfacePairRelation::Disjoint {
131 if debug_pairs {
132 eprintln!(
133 "pair {}x{}: DISJOINT-cull sep={:.4e}",
134 first.face.id, second.face.id, pair_classification.minimum_separation
135 );
136 }
137 continue;
138 }
139 // Coincident carriers are never marched: their true intersection
140 // is a 2D region, not a curve, so anything the marcher traces on
141 // them is noise ("similar faces we do not intersect" — Golovanov
142 // §6.2). The sampled classification catches coincident pairs
143 // the strict reconstruction test misses (partial overlaps,
144 // differing parameterizations); both route to the boundary-curve
145 // exchange below.
146 let is_cosurface = pair_classification.relation == SurfacePairRelation::Cosurface
147 || cosurface_pair(&first.face.surface, &second.face.surface, options.tolerance)?;
148 profile.cosurface += profile.lap(&mut lap_start);
149 if is_cosurface {
150 if debug_pairs {
151 eprintln!("pair {}x{}: cosurface", first.face.id, second.face.id);
152 }
153 for edge in &face_edge_lists[&second.key()] {
154 if !edge.degenerate {
155 builder.process_curve(
156 cached_subcurve(second.operand, edge)?,
157 first,
158 second,
159 &[first],
160 &[first],
161 false,
162 )?;
163 }
164 }
165 for edge in &face_edge_lists[&first.key()] {
166 if !edge.degenerate {
167 builder.process_curve(
168 cached_subcurve(first.operand, edge)?,
169 first,
170 second,
171 &[second],
172 &[second],
173 false,
174 )?;
175 }
176 }
177 profile.process_curve += profile.lap(&mut lap_start);
178 continue;
179 }
180
181 for edge in &face_edge_lists[&first.key()] {
182 if !edge.degenerate {
183 let curve = cached_subcurve(first.operand, edge)?;
184 if curve_lies_on_surface(&curve, &second.face.surface, options.tolerance)? {
185 builder.process_curve(curve, first, second, &[second], &[second], false)?;
186 }
187 }
188 }
189 for edge in &face_edge_lists[&second.key()] {
190 if !edge.degenerate {
191 let curve = cached_subcurve(second.operand, edge)?;
192 if curve_lies_on_surface(&curve, &first.face.surface, options.tolerance)? {
193 builder.process_curve(curve, first, second, &[first], &[first], false)?;
194 }
195 }
196 }
197 profile.lies_on += profile.lap(&mut lap_start);
198
199 let planar_iso = planar_iso_intersection(
200 &first.face.surface,
201 &second.face.surface,
202 options.tolerance,
203 )?;
204 profile.planar_iso += profile.lap(&mut lap_start);
205 if let Some(curve) = planar_iso {
206 if debug_pairs {
207 eprintln!("pair {}x{}: planar_iso", first.face.id, second.face.id);
208 }
209 builder.process_curve(
210 curve,
211 first,
212 second,
213 &[first, second],
214 &[first, second],
215 true,
216 )?;
217 profile.process_curve += profile.lap(&mut lap_start);
218 continue;
219 }
220
221 // Recognized analytic pairs produce their exact intersection
222 // curves (lines, circles, ellipses) directly — no marching, no
223 // polyline fitting, no chord-sag drift. An empty result is a
224 // proof of non-intersection and also skips the marcher.
225 let analytic = crate::intersect_analytic_pair(
226 &first.face.surface,
227 &second.face.surface,
228 options.tolerance,
229 );
230 profile.analytic += profile.lap(&mut lap_start);
231 if let Some(curves) = analytic {
232 if debug_pairs {
233 eprintln!(
234 "pair {}x{}: analytic x{}",
235 first.face.id,
236 second.face.id,
237 curves.len()
238 );
239 }
240 for curve in curves {
241 builder.process_curve(
242 curve,
243 first,
244 second,
245 &[first, second],
246 &[first, second],
247 true,
248 )?;
249 }
250 profile.process_curve += profile.lap(&mut lap_start);
251 continue;
252 }
253
254 // A pair whose every touching sample is TANGENTIAL cannot
255 // contain a transverse intersection curve: the marcher would
256 // walk the tangency band's noise, which neither closes nor
257 // reaches a boundary (the distance-0 glue-extrude runaway).
258 // Shared topology at a tangential contact comes from the
259 // boundary-curve exchange above ("similar faces we do not
260 // intersect" extended to tangential contacts — Golovanov §6.2;
261 // where surfaces CROSS through a tangency, off-line samples
262 // have non-parallel normals and the pair still marches).
263 if pair_classification.relation == SurfacePairRelation::NearTangent
264 && pair_classification.tangential_only
265 {
266 // The coarse 5×5 classifier can flag `tangential_only` off an
267 // incidental tangential KISS between two curved carriers and
268 // miss the transverse loop where they actually cross (two
269 // overlapping tori: their tubes cross while their inner walls
270 // just touch). Before honouring the skip, a GATED supplemental
271 // detector (denser two-sided seeding, tangency-band seeds
272 // rejected, trace-exhaustion swallowed, transverse-only
273 // branches) checks whether a genuine transverse curve of
274 // meaningful length lies inside BOTH trims.
275 //
276 // Such a pair is a SINGULAR intersection: the transverse loops
277 // meet at tangent nodes (equal-radius torus-torus at
278 // (1.5, ±3.708, ±1.5)), and imprinting it needs the 4-valent
279 // tangent-vertex ("checkerboard") singular assembly the kernel
280 // does not yet do. Marching + assembling it anyway yields a
281 // cryptic downstream pcurve/open-loop error, and skipping it
282 // silently DROPS the intersection (torus∪torus double-counts;
283 // torus−torus removes nothing — the two structural bugs the
284 // semantic oracle found). So we REFUSE cleanly and early.
285 //
286 // DEFERRED CAPABILITY: when singular tangent-node assembly
287 // lands (detect the tangent nodes, split the loops there into
288 // arcs sharing 4-valent vertices, classify the checkerboard
289 // face regions), replace this refusal with that imprint.
290 let first_march = first_restricted[first_index]
291 .as_ref()
292 .unwrap_or(&first.face.surface);
293 let second_march = second_restricted[second_index]
294 .as_ref()
295 .unwrap_or(&second.face.surface);
296 let supplemental = intersect_surfaces_supplemental(
297 first_march,
298 second_march,
299 &SurfaceIntersectionOptions {
300 tolerance: options.tolerance,
301 maximum_step: options.maximum_ssi_step,
302 ..Default::default()
303 },
304 )?;
305 let mut dropped_length = 0.0f64;
306 for branch in &supplemental {
307 for run in clip_branch_to_trims(&branch.points, first, second)? {
308 if run.len() < 2 {
309 continue;
310 }
311 let length: f64 = run
312 .windows(2)
313 .map(|pair| pair[1].sub(pair[0]).length())
314 .sum();
315 dropped_length = dropped_length.max(length);
316 }
317 }
318 if dropped_length > options.tolerance * 100.0 {
319 return Err(format!(
320 "boolean: unsupported singular/tangent-node surface intersection \
321 between faces {} and {} (transverse intersection the tangential-only \
322 path cannot imprint; e.g. equal-radius torus-torus)",
323 first.face.id, second.face.id
324 ));
325 }
326 if debug_pairs {
327 eprintln!(
328 "pair {}x{}: tangential-only contact, march skipped",
329 first.face.id, second.face.id
330 );
331 }
332 continue;
333 }
334
335 // The trim-window carriers: same surface and parameterization
336 // over the window, so hit (u, v) values remain valid on the
337 // originals; out-of-window intersections could never survive
338 // clip_branch_to_trims and are not walked at all.
339 let first_march = first_restricted[first_index]
340 .as_ref()
341 .unwrap_or(&first.face.surface);
342 let second_march = second_restricted[second_index]
343 .as_ref()
344 .unwrap_or(&second.face.surface);
345 let mut seed_points = Vec::new();
346 // Smallest |edge_tangent · surface_normal| over accepted seeds — the
347 // local grazing measure at the trim crossings (near 0 = the edge
348 // pierces the other surface tangentially). Gates the near-tangent
349 // clip-order rescue below to genuinely grazing pairs.
350 let mut min_seed_tangency = f64::INFINITY;
351 for (face, other_march, other) in
352 [(first, second_march, second), (second, first_march, first)]
353 {
354 for edge in &face_edge_lists[&face.key()] {
355 if edge.degenerate {
356 continue;
357 }
358 for hit in intersect_curve_surface(&edge.curve, other_march, options.tolerance)?
359 {
360 if hit.t < edge.t0 - 1e-9 || hit.t > edge.t1 + 1e-9 {
361 continue;
362 }
363 let tangent = edge.curve.derivatives(hit.t, 1)?[1].normalized()?;
364 let normal = match other.face.surface.normal(hit.u, hit.v) {
365 Ok(normal) => normal,
366 Err(_) => continue,
367 };
368 if debug_pairs {
369 eprintln!(
370 "pair {}x{}: seed edge {} t={:.6} p=({:.5},{:.5},{:.5}) |tan.n|={:.4} {}",
371 first.face.id,
372 second.face.id,
373 edge.id,
374 hit.t,
375 hit.point.x,
376 hit.point.y,
377 hit.point.z,
378 tangent.dot(normal).abs(),
379 if tangent.dot(normal).abs() >= 0.1 { "ACCEPT" } else { "reject" }
380 );
381 }
382 if tangent.dot(normal).abs() >= 0.1 {
383 seed_points.push(hit.point);
384 min_seed_tangency = min_seed_tangency.min(tangent.dot(normal).abs());
385 section_evidence = true;
386 }
387 }
388 }
389 }
390 profile.seeds += profile.lap(&mut lap_start);
391 profile.marched_pairs += 1;
392 if debug_pairs {
393 eprintln!(
394 "pair {}x{}: marching (relation {:?})...",
395 first.face.id, second.face.id, pair_classification.relation
396 );
397 }
398 let mut branches = intersect_surfaces(
399 first_march,
400 second_march,
401 &SurfaceIntersectionOptions {
402 tolerance: options.tolerance,
403 maximum_step: march_maximum_step(
404 &pair_classification,
405 options.maximum_ssi_step,
406 builder.scale,
407 ),
408 seed_points: seed_points.clone(),
409 ..Default::default()
410 },
411 )
412 .map_err(|error| {
413 format!(
414 "{error} (marching faces {} and {})",
415 first.face.id, second.face.id
416 )
417 })?;
418 profile.march += profile.lap(&mut lap_start);
419 // MARCH-ORDER SWAP RESCUE (hatch BREP_MARCH_SWAP_RESCUE=0).
420 // `intersect_surfaces` is not order-symmetric: the coupled Newton
421 // trace can fail to start/continue from a valid seed when the two
422 // surfaces are presented in one operand order yet succeed in the
423 // other. This is the sole reason t217's `subtract(sphere, step)`
424 // fails while every other op passes — pair 118x842 (sphere-first)
425 // marches ZERO branches, while 842x118 (step-first, the working
426 // a\b order) marches the section from the IDENTICAL accepted pierce
427 // seeds. When the forward order returns no branch AND an accepted
428 // transverse pierce seed exists (so a real section provably crosses
429 // both trims), retry the march with the surfaces swapped — i.e.
430 // reproduce the exact call the working operand order makes for this
431 // pair (measured: seed_only alone does NOT recover it — the section
432 // is found from an auto-grid start, not from the near-tangent pierce
433 // seeds, which the seed normal-cross gate rejects). The returned
434 // branch points are 3D and therefore order-independent, so no
435 // parameter remap is needed; they flow through the identical clip /
436 // process_curve gates below, which drop any out-of-trim or
437 // sub-length run exactly as today. The rescue only ever runs when
438 // the forward order found NOTHING, so it cannot alter a pair that
439 // already marched.
440 if branches.is_empty()
441 && !seed_points.is_empty()
442 && pair_classification.relation == SurfacePairRelation::Candidate
443 && std::env::var("BREP_MARCH_SWAP_RESCUE").as_deref() != Ok("0")
444 {
445 // FAIL-SOFT: the forward order already returned Ok(empty); a
446 // swapped-march error (trace-exhaustion is a real error class —
447 // `intersect_surfaces_supplemental` swallows it for exactly this
448 // reason) must NOT convert that graceful empty into a hard error.
449 // On Err, keep the (empty) forward result and carry on.
450 let swapped = intersect_surfaces(
451 second_march,
452 first_march,
453 &SurfaceIntersectionOptions {
454 tolerance: options.tolerance,
455 maximum_step: march_maximum_step(
456 &pair_classification,
457 options.maximum_ssi_step,
458 builder.scale,
459 ),
460 seed_points: seed_points.clone(),
461 ..Default::default()
462 },
463 )
464 .unwrap_or_default();
465 if debug_pairs {
466 eprintln!(
467 "pair {}x{}: MARCH-SWAP RESCUE attempt -> {} branches, pts {:?}",
468 first.face.id,
469 second.face.id,
470 swapped.len(),
471 swapped.iter().map(|b| b.points.len()).collect::<Vec<_>>()
472 );
473 }
474 if !swapped.is_empty() {
475 branches = swapped;
476 }
477 }
478 // NEAR-TANGENT CLIP-ORDER RESCUE (hatch BREP_MARCH_SWAP_CLIP_RESCUE=0).
479 // Companion to the empty-branch MARCH-SWAP RESCUE above:
480 // `intersect_surfaces` is order-asymmetric not only in WHETHER it
481 // marches, but in the exact sample positions of the section
482 // polyline. On a NEAR-TANGENT graze, `clip_branch_to_trims`
483 // classifies those samples against the mutual trims, and a sample
484 // landing just past the near-tangent boundary is dropped as a
485 // false-Outside that TRUNCATES the clipped section. The two operand
486 // orders drop DIFFERENT near-tangent tail samples, so one order
487 // yields a section ~0.1-0.2mm shorter at ONE endpoint (t660: b\a's
488 // step-first order clips pairs 223x105/399x105 ~0.22/0.14mm short of
489 // the section a\b's cyl-first order keeps, stranding edge 173
490 // one-use). Near-tangent clip errors are almost exclusively
491 // false-Outside (a point truly outside a trim rarely projects to an
492 // in-trim uv), so the order with the LONGER clipped section suffered
493 // fewer drops and is the more complete one. When the swapped order
494 // marches the SAME branch structure with a meaningfully longer
495 // clipped total, adopt its branches (3D points, so they flow through
496 // the identical clip/process_curve gates below). Gated to Candidate
497 // pairs (the near-tangent/ambiguous class; clean Transverse
498 // crossings clip identically in both orders and never differ) with
499 // an accepted GRAZING pierce seed (min |tan·n| < 0.5 — a real
500 // section provably crosses, and does so near-tangentially, the only
501 // regime where the clip wobbles; this also bounds the extra march to
502 // grazing pairs), and requires a >0.1%-of-length margin so
503 // raw-sampling jitter (t660: 0.02%) cannot flip a clean pair.
504 // Fail-soft: any swapped-march or comparison-clip error keeps the
505 // forward result.
506 if !branches.is_empty()
507 && !seed_points.is_empty()
508 && min_seed_tangency < 0.5
509 && pair_classification.relation == SurfacePairRelation::Candidate
510 && std::env::var("BREP_MARCH_SWAP_CLIP_RESCUE").as_deref() != Ok("0")
511 {
512 let swapped = intersect_surfaces(
513 second_march,
514 first_march,
515 &SurfaceIntersectionOptions {
516 tolerance: options.tolerance,
517 maximum_step: march_maximum_step(
518 &pair_classification,
519 options.maximum_ssi_step,
520 builder.scale,
521 ),
522 seed_points: seed_points.clone(),
523 ..Default::default()
524 },
525 )
526 .unwrap_or_default();
527 // Only a swap that reproduces the SAME branch count — a
528 // refined-endpoint variant of the same section, not a different
529 // branch decomposition (guards against adopting a spurious
530 // extra branch as "longer").
531 if !swapped.is_empty() && swapped.len() == branches.len() {
532 let totals = (|| -> Result<(f64, f64), String> {
533 let mut fwd = 0.0;
534 for b in &branches {
535 let refined = insert_seed_points_into_branch(
536 &b.points,
537 &seed_points,
538 options.tolerance,
539 );
540 for run in clip_branch_to_trims(&refined, first, second)? {
541 fwd += run
542 .windows(2)
543 .map(|p| p[1].sub(p[0]).length())
544 .sum::<f64>();
545 }
546 }
547 let mut swp = 0.0;
548 for b in &swapped {
549 let refined = insert_seed_points_into_branch(
550 &b.points,
551 &seed_points,
552 options.tolerance,
553 );
554 for run in clip_branch_to_trims(&refined, first, second)? {
555 swp += run
556 .windows(2)
557 .map(|p| p[1].sub(p[0]).length())
558 .sum::<f64>();
559 }
560 }
561 Ok((fwd, swp))
562 })();
563 if let Ok((fwd_clip, swp_clip)) = totals {
564 let margin = fwd_clip.max(swp_clip) * 1.0e-3;
565 let adopt = swp_clip > fwd_clip + margin;
566 if debug_pairs {
567 eprintln!(
568 "pair {}x{}: CLIP-SWAP RESCUE fwd_clip={:.6} swp_clip={:.6} margin={:.6}{}",
569 first.face.id,
570 second.face.id,
571 fwd_clip,
572 swp_clip,
573 margin,
574 if adopt { " ADOPT" } else { "" }
575 );
576 }
577 if adopt {
578 branches = swapped;
579 }
580 }
581 }
582 }
583 if branches.iter().any(|branch| branch.points.len() >= 2) {
584 section_evidence = true;
585 }
586 if debug_pairs {
587 eprintln!(
588 "pair {}x{}: MARCH {} branches, pts {:?}",
589 first.face.id,
590 second.face.id,
591 branches.len(),
592 branches.iter().map(|b| b.points.len()).collect::<Vec<_>>()
593 );
594 }
595 for branch in branches {
596 // The pierce seeds are the section's exact trim-crossing
597 // points — insert them so no trim interval shorter than the
598 // march step is invisible to the point-classification clip.
599 let refined_points =
600 insert_seed_points_into_branch(&branch.points, &seed_points, options.tolerance);
601 for run in clip_branch_to_trims(&refined_points, first, second)? {
602 if debug_pairs {
603 eprintln!(
604 "pair {}x{}: clip run len_pts={} length={:.4e}",
605 first.face.id,
606 second.face.id,
607 run.len(),
608 run.windows(2)
609 .map(|pair| pair[1].sub(pair[0]).length())
610 .sum::<f64>()
611 );
612 }
613 if run.len() < 2 {
614 continue;
615 }
616 let length: f64 = run
617 .windows(2)
618 .map(|pair| pair[1].sub(pair[0]).length())
619 .sum();
620 if length <= options.tolerance * 100.0 {
621 continue;
622 }
623 if branch_follows_shared_boundary(
624 &run,
625 first,
626 second,
627 &builder.edges,
628 options.tolerance,
629 builder.scale,
630 )? {
631 if debug_pairs {
632 eprintln!(
633 "pair {}x{}: run dropped (follows shared boundary)",
634 first.face.id, second.face.id
635 );
636 }
637 continue;
638 }
639 let pieces_before = builder.pieces.len();
640 let chunk_points = options.fit_chunk_points.unwrap_or(run.len()).max(2);
641 let mut start = 0;
642 while start + 1 < run.len() {
643 let end = (start + chunk_points - 1).min(run.len() - 1);
644 let fit = fit_polyline(
645 &run[start..=end],
646 options.tolerance.max(1e-7),
647 options.maximum_fit_points,
648 options.local_fit,
649 )?;
650 builder.process_curve(
651 fit.curve,
652 first,
653 second,
654 &[first, second],
655 &[first, second],
656 true,
657 )?;
658 start = end;
659 }
660 if debug_pairs {
661 eprintln!(
662 "pair {}x{}: run -> {} pieces",
663 first.face.id,
664 second.face.id,
665 builder.pieces.len() - pieces_before
666 );
667 }
668 }
669 }
670 profile.clip_and_fit += profile.lap(&mut lap_start);
671 }
672 }
673 profile.report();
674 // PIECE-ENDPOINT EXCHANGE post-pass: with every pair's process_curve
675 // done, the piece set is final for the ridden-edge class — imprint each
676 // open riding piece's junction endpoints onto the ridden edges (see the
677 // method doc; hatch BREP_OVERLAP_PIECE_ENDPOINT_SPLIT=0).
678 builder.exchange_piece_endpoint_junctions()?;
679 let mut edge_splits = builder
680 .edge_splits
681 .into_iter()
682 .map(|((operand, edge_id), mut parameters)| {
683 parameters.sort_by(f64::total_cmp);
684 EdgeSplitRecord {
685 operand,
686 edge_id,
687 parameters,
688 }
689 })
690 .collect::<Vec<_>>();
691 edge_splits.sort_by_key(|record| (record.operand, record.edge_id));
692 let mut by_face = builder
693 .by_face
694 .into_iter()
695 .map(|(face, piece_ids)| FaceImprints {
696 operand: face.operand,
697 face_id: face.face_id,
698 piece_ids,
699 })
700 .collect::<Vec<_>>();
701 by_face.sort_by_key(|record| (record.operand, record.face_id));
702 let section_evidence = section_evidence || !builder.pieces.is_empty();
703 let mut result = ImprintResultRecord {
704 vertices: builder.vertices,
705 pieces: builder.pieces,
706 by_face,
707 edge_splits,
708 barrier_edges: builder.barrier_edges.into_iter().collect(),
709 section_evidence,
710 };
711 // COINCIDENT-PIECE MERGE (problemInbox equator-tangent, and the generic
712 // one-circle-from-many-pairs class): the SAME section curve can be minted
713 // by several pairs — a cosurface boundary-edge copy (the cylinder cap
714 // ring lying ON the inscribed sphere) AND the cap-plane's analytic
715 // section ring are one circle minted twice, each carrying only its own
716 // pair's supports/pcurves. Assembly then builds duplicate edges that
717 // cannot both be two-use → one-use strands. Merge pieces whose curves
718 // coincide along their whole span (bidirectional max deviation within
719 // the weld band): keep the first, union the supports/pcurves/by_face
720 // registrations of the rest into it. Escape hatch:
721 // BREP_COINCIDENT_PIECE_MERGE=0.
722 if std::env::var("BREP_COINCIDENT_PIECE_MERGE").as_deref() != Ok("0") {
723 let weld = assembler_weld(options.tolerance).max(options.tolerance * 10.0);
724 let mut removed: Vec<u64> = Vec::new();
725 let mut index = 0;
726 while index < result.pieces.len() {
727 let mut other = index + 1;
728 while other < result.pieces.len() {
729 let coincide = {
730 let a = &result.pieces[index];
731 let b = &result.pieces[other];
732 max_curve_deviation(&a.curve, &b.curve)? <= weld
733 && max_curve_deviation(&b.curve, &a.curve)? <= weld
734 };
735 if coincide {
736 let absorbed = result.pieces.remove(other);
737 removed.push(absorbed.id);
738 let keeper = &mut result.pieces[index];
739 for pcurve in absorbed.pcurves {
740 if !keeper
741 .pcurves
742 .iter()
743 .any(|existing| {
744 existing.operand == pcurve.operand
745 && existing.face_id == pcurve.face_id
746 })
747 {
748 keeper.pcurves.push(pcurve);
749 }
750 }
751 let keeper_id = keeper.id;
752 for record in &mut result.by_face {
753 if let Some(position) =
754 record.piece_ids.iter().position(|&id| id == absorbed.id)
755 {
756 if record.piece_ids.contains(&keeper_id) {
757 record.piece_ids.remove(position);
758 } else {
759 record.piece_ids[position] = keeper_id;
760 }
761 }
762 }
763 } else {
764 other += 1;
765 }
766 }
767 index += 1;
768 }
769 if !removed.is_empty() && std::env::var("BREP_DEBUG_BOOL").is_ok() {
770 eprintln!("coincident-piece merge: absorbed {:?}", removed);
771 }
772 }
773 // Rescue near-tangent SSI truncations BEFORE canonicalization so the added
774 // bridge pieces' endpoints (existing crossing/stub vertices) fold into the
775 // same junction merges as every other section.
776 extend_truncated_sections(
777 &mut result,
778 &face_edge_lists,
779 solid_a,
780 solid_b,
781 options.tolerance,
782 )?;
783 canonicalize_imprint_junctions(&mut result, solid_a, solid_b, options.tolerance)?;
784 // B2: reuse an existing boundary edge as the shared section edge wherever a
785 // section coincides with one along its whole span (vertices are final after
786 // canonicalization; `face_edge_lists` holds each face's boundary edges on
787 // the healed operands). Runs here so both operands reference ONE edge.
788 reuse_boundary_section_edges(
789 &mut result,
790 &face_edge_lists,
791 solid_a,
792 solid_b,
793 options.tolerance,
794 )?;
795 // CAPSTONE step 1 (CAPSTONE-DESIGN.md §5): debug-only graze-band survey —
796 // report every (section piece × boundary edge) contact where the piece
797 // runs within the scale-derived band of the edge over a real span. Zero
798 // behavior change; validates the graze-contact model on the acceptance
799 // suite before any common-block machinery lands.
800 report_graze_contacts(&result, &face_edge_lists, solid_a, solid_b, options.tolerance)?;
801 Ok(result)
802}
803
804/// Debug-only graze-contact survey (`BREP_DEBUG_GRAZE=1`): for each section
805/// piece and each boundary edge of its support faces, sample the piece and
806/// measure distance to the edge; report contacts whose in-band span exceeds
807/// both the weld scale and 4× the minimum deviation (span-wise proximity, not
808/// a point touch). `band_cap` reuses the residual-merge `sep_cap` ceiling —
809/// measured, never grown. The output is the raw material for capstone step 2
810/// (partial-span common-block): which contacts exist, their spans, and their
811/// measured bands.
812fn report_graze_contacts(
813 result: &ImprintResultRecord,
814 face_edge_lists: &HashMap<FaceKey, Vec<&EdgeRecord>>,
815 solid_a: &BrepSolid,
816 solid_b: &BrepSolid,
817 tolerance: f64,
818) -> Result<(), String> {
819 if std::env::var("BREP_DEBUG_GRAZE").as_deref() != Ok("1") {
820 return Ok(());
821 }
822 let raw_extent = raw_solid_extent(solid_a).max(raw_solid_extent(solid_b));
823 let (_, band_cap) = residual_merge_bands(raw_extent, tolerance);
824 const SAMPLES: usize = 17;
825 for piece in &result.pieces {
826 let [t0, t1] = [piece.t0, piece.t1];
827 if !(t1 > t0) {
828 continue;
829 }
830 for key in piece.support_faces {
831 let Some(edges) = face_edge_lists.get(&key) else {
832 continue;
833 };
834 for edge in edges {
835 if edge.degenerate {
836 continue;
837 }
838 let mut in_band = 0usize;
839 let mut min_dev = f64::INFINITY;
840 let mut max_dev_in_band = 0.0f64;
841 let mut span = 0.0f64;
842 let mut prev: Option<(bool, Vec3)> = None;
843 for k in 0..SAMPLES {
844 let t = t0 + (t1 - t0) * k as f64 / (SAMPLES - 1) as f64;
845 let point = piece.curve.evaluate(t)?;
846 let deviation = project_point_to_curve(&edge.curve, point)?.distance;
847 min_dev = min_dev.min(deviation);
848 let inside = deviation <= band_cap;
849 if inside {
850 in_band += 1;
851 max_dev_in_band = max_dev_in_band.max(deviation);
852 if let Some((true, prev_point)) = prev {
853 span += point.sub(prev_point).length();
854 }
855 }
856 prev = Some((inside, point));
857 }
858 // Span-wise contact: several consecutive samples in band and a
859 // span that dwarfs the closest-approach (not a transversal
860 // crossing, which dips in and out at one sample).
861 if in_band >= 3 && span > (4.0 * min_dev).max(assembler_weld(tolerance)) {
862 eprintln!(
863 "graze: piece {} sup=[{}:{},{}:{}] ~ edge {}:{} span={:.3e} band=[{:.3e},{:.3e}] samples_in_band={}/{}",
864 piece.id,
865 piece.support_faces[0].operand,
866 piece.support_faces[0].face_id,
867 piece.support_faces[1].operand,
868 piece.support_faces[1].face_id,
869 key.operand,
870 edge.id,
871 span,
872 min_dev,
873 max_dev_in_band,
874 in_band,
875 SAMPLES
876 );
877 }
878 }
879 }
880 }
881 Ok(())
882}