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