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