Skip to main content

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}