Skip to main content

brep_kernel/healing/
face_merge.rs

1use crate::analytic_surface::circumcenter;
2use crate::mass_properties::{face_area, face_volume_contribution, parameter_space_area};
3use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord};
4use crate::{
5    build_pcurve_on_surface, make_line, make_plane, make_revolution, KnotVector, NurbsCurve,
6    NurbsSurface, Vec3, Vec4,
7};
8use crate::{KernelRefusal, KernelStage, OrRefuse, RefusalClass};
9use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
10
11fn controls_match(first: Vec4, second: Vec4, tolerance: f64) -> bool {
12    (first.x - second.x).abs() <= tolerance
13        && (first.y - second.y).abs() <= tolerance
14        && (first.z - second.z).abs() <= tolerance
15        && (first.w - second.w).abs() <= tolerance
16}
17
18/// Whether two faces ride the SAME carrier patch — identical degrees, knots
19/// and control points inside `tolerance`. `pub(crate)` because the direct-edit
20/// cap lane asks the same question for the same reason this module does: when
21/// two faces share one carrier their coedges' pcurves are interchangeable, so
22/// loops can move between them with nothing refit.
23pub(crate) fn same_surface(first: &NurbsSurface, second: &NurbsSurface, tolerance: f64) -> bool {
24    first.degree_u == second.degree_u
25        && first.degree_v == second.degree_v
26        && first.knots_u.len() == second.knots_u.len()
27        && first.knots_v.len() == second.knots_v.len()
28        && first.control_points.len() == second.control_points.len()
29        && first
30            .control_points
31            .iter()
32            .zip(&second.control_points)
33            .all(|(a, b)| {
34                a.len() == b.len()
35                    && a.iter()
36                        .zip(b)
37                        .all(|(a, b)| controls_match(*a, *b, tolerance))
38            })
39        && first
40            .knots_u
41            .iter()
42            .zip(&second.knots_u)
43            .all(|(a, b)| (a - b).abs() <= tolerance)
44        && first
45            .knots_v
46            .iter()
47            .zip(&second.knots_v)
48            .all(|(a, b)| (a - b).abs() <= tolerance)
49}
50
51fn surface_domains(surface: &NurbsSurface) -> Result<([f64; 2], [f64; 2]), KernelRefusal> {
52    Ok((
53        KnotVector::new(surface.knots_u.clone(), surface.degree_u)
54            .or_refuse(KernelStage::Sew, "new")?
55            .domain(),
56        KnotVector::new(surface.knots_v.clone(), surface.degree_v)
57            .or_refuse(KernelStage::Sew, "new")?
58            .domain(),
59    ))
60}
61
62fn outward_normal(face: &FaceRecord) -> Result<Vec3, KernelRefusal> {
63    let (u, v) = surface_domains(&face.surface)?;
64    let normal = face
65        .surface
66        .normal((u[0] + u[1]) / 2.0, (v[0] + v[1]) / 2.0)
67        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
68    Ok(if face.same_sense {
69        normal
70    } else {
71        normal.scale(-1.0)
72    })
73}
74
75fn planar_support(
76    face: &FaceRecord,
77    tolerance: f64,
78) -> Result<Option<(Vec3, Vec3)>, KernelRefusal> {
79    let (u, v) = surface_domains(&face.surface)?;
80    let midpoint_u = (u[0] + u[1]) / 2.0;
81    let midpoint_v = (v[0] + v[1]) / 2.0;
82    let origin = face
83        .surface
84        .evaluate(midpoint_u, midpoint_v)
85        .or_refuse(KernelStage::Sew, "evaluate")?;
86    let normal = face
87        .surface
88        .normal(midpoint_u, midpoint_v)
89        .or_refuse(KernelStage::Sew, "normal")?;
90    let controls = face
91        .surface
92        .control_points
93        .iter()
94        .flatten()
95        .map(|control| control.point())
96        .collect::<Result<Vec<_>, _>>()
97        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
98    let scale = controls
99        .iter()
100        .map(|point| point.sub(origin).length())
101        .fold(1.0f64, f64::max);
102    let plane_tolerance = (tolerance * 100.0).max(1e-7) * scale;
103    if controls
104        .iter()
105        .all(|point| point.sub(origin).dot(normal).abs() <= plane_tolerance)
106    {
107        Ok(Some((origin, normal)))
108    } else {
109        Ok(None)
110    }
111}
112
113fn coplanar_with_same_outward_normal(
114    first: &FaceRecord,
115    second: &FaceRecord,
116    tolerance: f64,
117) -> Result<bool, KernelRefusal> {
118    let Some((first_origin, first_geometric_normal)) = planar_support(first, tolerance)? else {
119        return Ok(false);
120    };
121    let Some((_, second_geometric_normal)) = planar_support(second, tolerance)? else {
122        return Ok(false);
123    };
124    if first_geometric_normal.dot(second_geometric_normal).abs() < 1.0 - 1e-7 {
125        return Ok(false);
126    }
127    let first_normal = outward_normal(first)?;
128    let second_normal = outward_normal(second)?;
129    if first_normal.dot(second_normal) < 1.0 - 1e-7 {
130        return Ok(false);
131    }
132    let (second_u, second_v) = surface_domains(&second.surface)?;
133    // The merged face is emitted carrying `first`'s plane, so every point of
134    // `second` has to lie ON that plane, not merely near it.  The band is the
135    // caller's coplanarity tolerance, floored where a caller asks for less
136    // than its own fitting noise and CAPPED at the assembler weld: past that
137    // the assembler holds a point and this plane apart, so the merged face is
138    // one nothing exact downstream can consume — the STEP importer's phase
139    // 1.75 ear-clips precisely such a face back into planar triangles.
140    //
141    // The cap replaces a `* (1.0 + first_origin.length())` factor that
142    // charged the band by how far the plane's origin sits from the WORLD
143    // origin — where the part happens to have been modelled, not a property
144    // of its geometry.  On a 190-unit part 50 units out it opened a 1.9e-7
145    // request to a 9.7e-4 band, wide enough to merge adjacent facets of a
146    // tessellated curve into one "plane" whose own corners sat 5.4e-4 off it.
147    let plane_tolerance = (tolerance * 100.0)
148        .max(1e-6)
149        .min(crate::tolerance::assembler_weld(tolerance));
150    for u in second_u {
151        for v in second_v {
152            if second
153                .surface
154                .evaluate(u, v)
155                .or_refuse(KernelStage::Sew, "evaluate")?
156                .sub(first_origin)
157                .dot(first_normal)
158                .abs()
159                > plane_tolerance
160            {
161                return Ok(false);
162            }
163        }
164    }
165    Ok(true)
166}
167
168/// Circular-arc support of a curve, detected by sampling: circumcenter of
169/// three spread samples, validated against all samples for constant radius
170/// and planarity. Returns (center, unit plane normal, radius).
171fn arc_circle_support(
172    curve: &NurbsCurve,
173    tolerance: f64,
174) -> Result<Option<(Vec3, Vec3, f64)>, KernelRefusal> {
175    let [t0, t1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
176    let samples = (0..=8)
177        .map(|index| curve.evaluate(t0 + (t1 - t0) * index as f64 / 8.0))
178        .collect::<Result<Vec<_>, _>>()
179        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
180    // A full-circle carrier evaluates identical endpoints, so the sample
181    // triple must avoid (start, end); fall back across spreads until one is
182    // non-degenerate.
183    let Some(center) = circumcenter(samples[0], samples[2], samples[5])
184        .or_else(|| circumcenter(samples[0], samples[4], samples[8]))
185        .or_else(|| circumcenter(samples[1], samples[4], samples[7]))
186    else {
187        return Ok(None);
188    };
189    let radius = samples[0].sub(center).length();
190    if radius <= tolerance {
191        return Ok(None);
192    }
193    let normal = match samples[2]
194        .sub(samples[0])
195        .cross(samples[5].sub(samples[0]))
196        .normalized()
197        .or_else(|_| {
198            samples[4]
199                .sub(samples[0])
200                .cross(samples[8].sub(samples[0]))
201                .normalized()
202        }) {
203        Ok(normal) => normal,
204        Err(_) => return Ok(None),
205    };
206    let band = tolerance.max(1e-9 * radius);
207    for sample in &samples {
208        let delta = sample.sub(center);
209        if (delta.length() - radius).abs() > band || delta.dot(normal).abs() > band {
210            if std::env::var("BREP_DEBUG_MERGE").is_ok() {
211                eprintln!(
212                    "    arc reject: radius={radius} dr={} dplane={} band={band}",
213                    (delta.length() - radius).abs(),
214                    delta.dot(normal).abs()
215                );
216            }
217            return Ok(None);
218        }
219    }
220    Ok(Some((center, normal, radius)))
221}
222
223/// Merge lanes a caller opts into beyond what the solid-wide merge does.
224///
225/// The solid-wide merge runs after every boolean and keeps every face it does
226/// not have to join: the default (all off) is exactly its behaviour. A caller
227/// joining faces the USER selected together (Push Face on the halves of an
228/// imported bore) asks for more: those faces are one region of one carrier by
229/// the user's own reading, and a face the merge leaves split is a face the
230/// edit cannot move.
231#[derive(Clone, Copy, Debug, Default)]
232pub(crate) struct MergeLanes<'a> {
233    /// Admit a CONE (a ruled revolution whose two boundary circles differ in
234    /// radius) wherever the cylinder lanes run. Only the full-turn lane builds
235    /// its carrier from the radius at each end; the partial-turn lane stays
236    /// cylinder-only.
237    pub cones: bool,
238    /// Let the full-turn lane seam a ring closed by two half-turn patches on
239    /// a SHARED axial edge (the halves of a bore written as two faces). The
240    /// solid-wide merge keeps such pairs apart (see `merge_cylinder_full_turn`).
241    pub shared_seam_ring: bool,
242    /// Among equally ranked seam candidates, prefer an axial edge ending at one
243    /// of these vertices: the seam a coaxial neighbour of the same selection
244    /// already kept, so the rim the two faces share meets both seams at ONE
245    /// vertex and closes into one circle.
246    pub seam_vertices: Option<&'a HashSet<u64>>,
247}
248
249struct CylinderSupport {
250    origin: Vec3,
251    axis: Vec3,
252    /// The radius at `origin`.
253    radius: f64,
254    /// dρ per unit of axial travel along `axis`: zero for a cylinder, the
255    /// cone's slope for a cone (admitted only under `MergeLanes::cones`).
256    slope: f64,
257    /// +1 when the face's outward normal points radially away from the
258    /// axis, -1 when it points inward.
259    outward_radial_sign: f64,
260}
261
262impl CylinderSupport {
263    /// The carrier's radius `axial` along `axis` from `origin`.
264    fn radius_at(&self, axial: f64) -> f64 {
265        self.radius + self.slope * axial
266    }
267}
268
269/// Detect a ruled cylinder patch: quadratic circular arc in one parameter
270/// direction, linear in the other, both boundary arcs sharing an axis and
271/// radius. Covers extruded arcs, revolved axis-parallel lines, and
272/// primitive cylinder walls regardless of parameterization. With
273/// `allow_cone`, two coaxial boundary arcs of DIFFERENT radii are a cone
274/// patch, admitted when the patch's midpoint lies on the cone they span.
275fn ruled_cylinder_support(
276    face: &FaceRecord,
277    tolerance: f64,
278    allow_cone: bool,
279    allow_apex: bool,
280) -> Result<Option<CylinderSupport>, KernelRefusal> {
281    let surface = &face.surface;
282    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
283    let (arc_first, columns) = match (surface.degree_u, surface.degree_v) {
284        (2, 1) => (true, surface.control_points[0].len()),
285        (1, 2) => (false, surface.control_points.len()),
286        _ => {
287            if debug {
288                eprintln!(
289                    "  support face {}: degrees ({},{}) not ruled-arc",
290                    face.id, surface.degree_u, surface.degree_v
291                );
292            }
293            return Ok(None);
294        }
295    };
296    // Boolean splits can insert linear-direction knots, so a ruled carrier
297    // may hold more than two columns; the outermost columns still bound the
298    // patch and interior straightness is enforced by the final area check.
299    let last = columns - 1;
300    let iso_curve = |side: usize| -> Result<NurbsCurve, KernelRefusal> {
301        let controls = if arc_first {
302            surface
303                .control_points
304                .iter()
305                .map(|row| row[side])
306                .collect::<Vec<_>>()
307        } else {
308            surface.control_points[side].clone()
309        };
310        let knots = if arc_first {
311            surface.knots_u.clone()
312        } else {
313            surface.knots_v.clone()
314        };
315        NurbsCurve::new(2, knots, controls).or_refuse(KernelStage::Sew, "NurbsCurve::new")
316    };
317    let scale = surface
318        .control_points
319        .iter()
320        .flatten()
321        .map(|point| point.point().map(|p| p.length()).unwrap_or(0.0))
322        .fold(1.0f64, f64::max);
323    let circle_tolerance = (tolerance * 100.0).max(1e-7 * scale);
324    // An apex iso is one point: a circle of radius zero about it. Its plane is
325    // the far circle's, so the checks below read that normal against the axis.
326    let apex_of = |curve: &NurbsCurve| -> Result<Option<Vec3>, KernelRefusal> {
327        if !allow_apex {
328            return Ok(None);
329        }
330        let [t0, t1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
331        let first = curve.evaluate(t0).or_refuse(KernelStage::Sew, "evaluate")?;
332        for index in 1..=8 {
333            let point = curve.evaluate(t0 + (t1 - t0) * index as f64 / 8.0).or_refuse(KernelStage::Sew, "evaluate")?;
334            if point.sub(first).length() > circle_tolerance {
335                return Ok(None);
336            }
337        }
338        Ok(Some(first))
339    };
340    let (iso0, iso1) = (iso_curve(0)?, iso_curve(last)?);
341    let (apex0, apex1) = (apex_of(&iso0)?, apex_of(&iso1)?);
342    let far_circle = match (apex0, apex1) {
343        (Some(_), None) => arc_circle_support(&iso1, circle_tolerance)?,
344        (None, Some(_)) => arc_circle_support(&iso0, circle_tolerance)?,
345        _ => None,
346    };
347    let apex_circle = |apex: Vec3| far_circle.map(|(_, normal, _)| (apex, normal, 0.0));
348    let circle0 = match apex0 {
349        Some(apex) => apex_circle(apex),
350        None => arc_circle_support(&iso0, circle_tolerance)?,
351    };
352    let circle1 = match apex1 {
353        Some(apex) => apex_circle(apex),
354        None => arc_circle_support(&iso1, circle_tolerance)?,
355    };
356    let Some((center0, normal0, radius0)) = circle0
357    else {
358        if debug {
359            eprintln!("  support face {}: first iso not circular", face.id);
360        }
361        return Ok(None);
362    };
363    let Some((center1, normal1, radius1)) = circle1
364    else {
365        if debug {
366            eprintln!("  support face {}: last iso not circular", face.id);
367        }
368        return Ok(None);
369    };
370    let is_cone = (radius0 - radius1).abs() > circle_tolerance;
371    if is_cone && !allow_cone {
372        if debug {
373            eprintln!(
374                "  support face {}: radii differ {} vs {}",
375                face.id, radius0, radius1
376            );
377        }
378        return Ok(None);
379    }
380    let axial = center1.sub(center0);
381    if axial.length() <= circle_tolerance {
382        if debug {
383            eprintln!("  support face {}: zero axial extent", face.id);
384        }
385        return Ok(None);
386    }
387    let axis = axial
388        .normalized()
389        .or_refuse(KernelStage::Sew, "normalized")?;
390    if axis.dot(normal0).abs() < 1.0 - 1e-7 || axis.dot(normal1).abs() < 1.0 - 1e-7 {
391        if debug {
392            eprintln!(
393                "  support face {}: arc planes not perpendicular to axis",
394                face.id
395            );
396        }
397        return Ok(None);
398    }
399    let outward = outward_normal(face)?;
400    let (u, v) = surface_domains(surface)?;
401    let midpoint = surface
402        .evaluate((u[0] + u[1]) / 2.0, (v[0] + v[1]) / 2.0)
403        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
404    let foot = center0.add(axis.scale(midpoint.sub(center0).dot(axis)));
405    let radial = match midpoint.sub(foot).normalized() {
406        Ok(radial) => radial,
407        Err(_) => return Ok(None),
408    };
409    let slope = if is_cone {
410        (radius1 - radius0) / axial.length()
411    } else {
412        0.0
413    };
414    if is_cone {
415        // A twisted ruling between the two circles is a hyperboloid, not a
416        // cone: the patch's own midpoint must sit on the cone they span.
417        let axial_mid = midpoint.sub(center0).dot(axis);
418        let off = (midpoint.sub(foot).length() - (radius0 + slope * axial_mid)).abs();
419        if off > circle_tolerance {
420            if debug {
421                eprintln!("  support face {}: midpoint {off:.3e} off the spanned cone", face.id);
422            }
423            return Ok(None);
424        }
425    }
426    let alignment = outward.dot(radial);
427    // A cone's normal leans off the radial by its half-angle.
428    if alignment.abs() < 0.9 / (1.0 + slope * slope).sqrt() {
429        if debug {
430            eprintln!(
431                "  support face {}: outward not radial ({alignment})",
432                face.id
433            );
434        }
435        return Ok(None);
436    }
437    Ok(Some(CylinderSupport {
438        origin: center0,
439        axis,
440        radius: radius0,
441        slope,
442        outward_radial_sign: alignment.signum(),
443    }))
444}
445
446fn cosurface_cylinder_pair(
447    first: &FaceRecord,
448    second: &FaceRecord,
449    tolerance: f64,
450    lanes: MergeLanes<'_>,
451) -> Result<Option<CylinderSupport>, KernelRefusal> {
452    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
453    let Some(support_a) = ruled_cylinder_support(first, tolerance, lanes.cones, false)? else {
454        if debug {
455            eprintln!(
456                "merge {}x{}: first not a ruled cylinder",
457                first.id, second.id
458            );
459        }
460        return Ok(None);
461    };
462    let Some(support_b) = ruled_cylinder_support(second, tolerance, lanes.cones, false)? else {
463        if debug {
464            eprintln!(
465                "merge {}x{}: second not a ruled cylinder",
466                first.id, second.id
467            );
468        }
469        return Ok(None);
470    };
471    let scale = 1.0f64
472        .max(support_a.origin.length())
473        .max(support_b.origin.length())
474        .max(support_a.radius);
475    let band = (tolerance * 100.0).max(1e-7 * scale);
476    // Both radii read at A's origin and one radius-length further along A's
477    // axis: equal for two cylinders of one radius, and for two patches of
478    // one cone (whose slopes agree once B's axis is turned to A's).
479    let radius_mismatch = {
480        let station = support_a.radius.max(1.0);
481        let b_axial = |axial_on_a: f64| {
482            support_a
483                .origin
484                .add(support_a.axis.scale(axial_on_a))
485                .sub(support_b.origin)
486                .dot(support_b.axis)
487        };
488        (support_a.radius - support_b.radius_at(b_axial(0.0)))
489            .abs()
490            .max((support_a.radius_at(station) - support_b.radius_at(b_axial(station))).abs())
491    };
492    if support_a.axis.dot(support_b.axis).abs() < 1.0 - 1e-7
493        || radius_mismatch > band
494        || support_a.outward_radial_sign != support_b.outward_radial_sign
495    {
496        if debug {
497            eprintln!(
498                "merge {}x{}: axis/radius/sign mismatch dot={} dr={} signs=({},{})",
499                first.id,
500                second.id,
501                support_a.axis.dot(support_b.axis),
502                radius_mismatch,
503                support_a.outward_radial_sign,
504                support_b.outward_radial_sign
505            );
506        }
507        return Ok(None);
508    }
509    let offset = support_b.origin.sub(support_a.origin);
510    let perpendicular = offset.sub(support_a.axis.scale(offset.dot(support_a.axis)));
511    if perpendicular.length() > band {
512        if debug {
513            eprintln!(
514                "merge {}x{}: axes offset {}",
515                first.id,
516                second.id,
517                perpendicular.length()
518            );
519        }
520        return Ok(None);
521    }
522    if debug {
523        eprintln!(
524            "merge {}x{}: MATCH r={} axis=({:.4},{:.4},{:.4})",
525            first.id,
526            second.id,
527            support_a.radius,
528            support_a.axis.x,
529            support_a.axis.y,
530            support_a.axis.z
531        );
532    }
533    Ok(Some(support_a))
534}
535
536struct ExtrusionSupport {
537    /// Profile iso curve at the sweep start, in its actual 3D position.
538    base: NurbsCurve,
539    /// Unit sweep direction (from column 0 toward the last column).
540    direction: Vec3,
541    /// Axial interval the patch occupies along `direction`.
542    interval: [f64; 2],
543}
544
545/// Detect a linear sweep of an arbitrary profile curve: one parameter
546/// direction is degree 1 with every profile control translated by the SAME
547/// vector (weights unchanged), and the profile lies in a plane perpendicular
548/// to the sweep. This is exact at the control-point level, so it also merges
549/// swept faces whose profile is only approximately circular.
550fn ruled_extrusion_support(
551    face: &FaceRecord,
552    tolerance: f64,
553) -> Result<Option<ExtrusionSupport>, KernelRefusal> {
554    let surface = &face.surface;
555    // Planar faces are the coplanar path's business; a plane recognized as
556    // an "extrusion of a line" here could merge across a knife edge.
557    if planar_support(face, tolerance)?.is_some() {
558        return Ok(None);
559    }
560    let profile_first = if surface.degree_v == 1 && surface.degree_u >= 1 {
561        true
562    } else if surface.degree_u == 1 && surface.degree_v >= 1 {
563        false
564    } else {
565        return Ok(None);
566    };
567    let (profile_count, linear_count) = if profile_first {
568        (
569            surface.control_points.len(),
570            surface.control_points[0].len(),
571        )
572    } else {
573        (
574            surface.control_points[0].len(),
575            surface.control_points.len(),
576        )
577    };
578    if linear_count < 2 {
579        return Ok(None);
580    }
581    let control = |profile_index: usize, linear_index: usize| -> Vec4 {
582        if profile_first {
583            surface.control_points[profile_index][linear_index]
584        } else {
585            surface.control_points[linear_index][profile_index]
586        }
587    };
588    let scale = surface
589        .control_points
590        .iter()
591        .flatten()
592        .map(|point| point.point().map(|p| p.length()).unwrap_or(0.0))
593        .fold(1.0f64, f64::max);
594    let band = (tolerance * 100.0).max(1e-7 * scale);
595    let mut translation: Option<Vec3> = None;
596    for profile_index in 0..profile_count {
597        let first = control(profile_index, 0);
598        let last = control(profile_index, linear_count - 1);
599        if (first.w - last.w).abs() > 1e-9 * first.w.abs().max(1.0) {
600            return Ok(None);
601        }
602        let step = last
603            .point()
604            .or_refuse(KernelStage::Sew, "point")?
605            .sub(first.point().or_refuse(KernelStage::Sew, "point")?);
606        if let Some(reference) = translation {
607            if step.sub(reference).length() > band {
608                return Ok(None);
609            }
610        } else {
611            translation = Some(step);
612        }
613        let start = first.point().or_refuse(KernelStage::Sew, "point")?;
614        for linear_index in 1..linear_count - 1 {
615            let middle = control(profile_index, linear_index);
616            if (middle.w - first.w).abs() > 1e-9 * first.w.abs().max(1.0) {
617                return Ok(None);
618            }
619            let offset = middle
620                .point()
621                .or_refuse(KernelStage::Sew, "point")?
622                .sub(start);
623            let along = offset.dot(step) / step.dot(step).max(1e-30);
624            if offset.sub(step.scale(along)).length() > band
625                || !(-1e-9..=1.0 + 1e-9).contains(&along)
626            {
627                return Ok(None);
628            }
629        }
630    }
631    let translation = translation.ok_or_else(|| {
632        KernelRefusal::internal(
633            KernelStage::Sew,
634            "face_merge",
635            "extrusion support: empty net",
636        )
637    })?;
638    if translation.length() <= band {
639        return Ok(None);
640    }
641    let direction = translation
642        .normalized()
643        .or_refuse(KernelStage::Sew, "normalized")?;
644    let base_controls = (0..profile_count).map(|index| control(index, 0)).collect();
645    let base_knots = if profile_first {
646        surface.knots_u.clone()
647    } else {
648        surface.knots_v.clone()
649    };
650    let base_degree = if profile_first {
651        surface.degree_u
652    } else {
653        surface.degree_v
654    };
655    let base = NurbsCurve::new(base_degree, base_knots, base_controls)
656        .or_refuse(KernelStage::Sew, "new")?;
657    let [t0, t1] = base.domain().or_refuse(KernelStage::Sew, "domain")?;
658    let mut axial = [f64::INFINITY, f64::NEG_INFINITY];
659    for index in 0..=8 {
660        let z = base
661            .evaluate(t0 + (t1 - t0) * index as f64 / 8.0)
662            .or_refuse(KernelStage::Sew, "evaluate")?
663            .dot(direction);
664        axial[0] = axial[0].min(z);
665        axial[1] = axial[1].max(z);
666    }
667    if axial[1] - axial[0] > band {
668        // Slanted profiles sweep skewed slabs whose axial union is not an
669        // interval; keep the merge to perpendicular profiles.
670        return Ok(None);
671    }
672    let start = (axial[0] + axial[1]) / 2.0;
673    Ok(Some(ExtrusionSupport {
674        base,
675        direction,
676        interval: [start, start + translation.length()],
677    }))
678}
679
680fn perpendicular_profile(curve: &NurbsCurve, direction: Vec3) -> Result<NurbsCurve, KernelRefusal> {
681    let controls = curve
682        .control_points
683        .iter()
684        .map(|control| {
685            let point = control.point().or_refuse(KernelStage::Sew, "point")?;
686            Ok(Vec4::from_point(
687                point.sub(direction.scale(point.dot(direction))),
688                control.w,
689            ))
690        })
691        .collect::<Result<Vec<_>, KernelRefusal>>()?;
692    NurbsCurve::new(curve.degree, curve.knots.clone(), controls)
693        .or_refuse(KernelStage::Sew, "NurbsCurve::new")
694}
695
696fn curves_coincide(
697    first: &NurbsCurve,
698    second: &NurbsCurve,
699    band: f64,
700) -> Result<bool, KernelRefusal> {
701    for (from, to) in [(first, second), (second, first)] {
702        let [t0, t1] = from.domain().or_refuse(KernelStage::Sew, "domain")?;
703        for index in 0..=8 {
704            let point = from
705                .evaluate(t0 + (t1 - t0) * index as f64 / 8.0)
706                .or_refuse(KernelStage::Sew, "evaluate")?;
707            let projection = match crate::project_point_to_curve(to, point) {
708                Ok(projection) => projection,
709                Err(_) => return Ok(false),
710            };
711            if projection.distance > band {
712                return Ok(false);
713            }
714        }
715    }
716    Ok(true)
717}
718
719/// Matching linear sweeps: parallel directions, identical perpendicular
720/// profile, adjacent (or touching) axial intervals.
721fn coextrusion_pair(
722    older: &FaceRecord,
723    newer: &FaceRecord,
724    tolerance: f64,
725) -> Result<Option<(ExtrusionSupport, [f64; 2])>, KernelRefusal> {
726    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
727    let Some(support_a) = ruled_extrusion_support(older, tolerance)? else {
728        return Ok(None);
729    };
730    let Some(support_b) = ruled_extrusion_support(newer, tolerance)? else {
731        return Ok(None);
732    };
733    let alignment = support_a.direction.dot(support_b.direction);
734    if alignment.abs() < 1.0 - 1e-9 {
735        if debug {
736            eprintln!(
737                "coextrusion {}x{}: directions differ (dot {alignment})",
738                older.id, newer.id
739            );
740        }
741        return Ok(None);
742    }
743    let scale = 1.0f64
744        .max(support_a.interval[1].abs())
745        .max(support_b.interval[1].abs());
746    let band = (tolerance * 100.0).max(1e-7 * scale);
747    let profile_a = perpendicular_profile(&support_a.base, support_a.direction)?;
748    let profile_b = perpendicular_profile(&support_b.base, support_a.direction)?;
749    if !curves_coincide(&profile_a, &profile_b, band)? {
750        if debug {
751            eprintln!("coextrusion {}x{}: profiles differ", older.id, newer.id);
752        }
753        return Ok(None);
754    }
755    // Express B's interval along A's direction regardless of B's own sweep
756    // orientation: anti-parallel sweeps negate the axial coordinate.
757    let interval_b = if alignment < 0.0 {
758        [-support_b.interval[1], -support_b.interval[0]]
759    } else {
760        support_b.interval
761    };
762    if interval_b[0] > support_a.interval[1] + band || support_a.interval[0] > interval_b[1] + band
763    {
764        if debug {
765            eprintln!(
766                "coextrusion {}x{}: axial gap {:?} vs {:?}",
767                older.id, newer.id, support_a.interval, interval_b
768            );
769        }
770        return Ok(None);
771    }
772    let union = [
773        support_a.interval[0].min(interval_b[0]),
774        support_a.interval[1].max(interval_b[1]),
775    ];
776    Ok(Some((support_a, union)))
777}
778
779fn trimmed_curve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, KernelRefusal> {
780    crate::curve_helpers::trimmed_curve(curve, t0, t1, "face_merge trimmed")
781}
782
783/// The axial extent of `curve` about `support`'s axis, read off its control
784/// polygon: the axial coordinate is an affine functional of position and
785/// the weights are positive, so the curve's own extent lies inside its
786/// control points'. Nine samples per edge missed a sphere-cut rim's peak by
787/// 0.14 (BadBoolean, master's gate of 2026-10-13); the carrier built that
788/// short had the projector fold the rim onto its top boundary, and the body
789/// read 0.14 low in volume with the validator none the wiser.
790fn axial_hull(curve: &NurbsCurve, support: &CylinderSupport) -> [f64; 2] {
791    let origin_axial = support.origin.dot(support.axis);
792    let mut range = [f64::INFINITY, f64::NEG_INFINITY];
793    for point in &curve.control_points {
794        let axial = Vec3::new(point.x / point.w, point.y / point.w, point.z / point.w)
795            .dot(support.axis)
796            - origin_axial;
797        range[0] = range[0].min(axial);
798        range[1] = range[1].max(axial);
799    }
800    range
801}
802
803/// An affine plane chart, proven from its complete bilinear control net.
804/// Rational or warped charts need the general projection path instead.
805struct AffinePlaneChart {
806    origin: Vec3,
807    du: Vec3,
808    dv: Vec3,
809    u0: f64,
810    v0: f64,
811}
812
813fn affine_plane_chart(surface: &NurbsSurface) -> Option<AffinePlaneChart> {
814    if surface.degree_u != 1 || surface.degree_v != 1
815        || surface.control_points.len() != 2
816        || surface.control_points.iter().any(|row| row.len() != 2)
817        || surface.knots_u.len() != 4 || surface.knots_v.len() != 4
818    { return None; }
819    let ku = &surface.knots_u;
820    let kv = &surface.knots_v;
821    if ku[0] != ku[1] || ku[2] != ku[3] || kv[0] != kv[1] || kv[2] != kv[3]
822        || ku[2] <= ku[1] || kv[2] <= kv[1]
823    { return None; }
824    let weight = surface.control_points[0][0].w;
825    if !weight.is_finite() || weight.abs() <= 1e-300
826        || surface.control_points.iter().flatten().any(|point| point.w != weight)
827    { return None; }
828    let p00 = surface.control_points[0][0].point().ok()?;
829    let p10 = surface.control_points[1][0].point().ok()?;
830    let p01 = surface.control_points[0][1].point().ok()?;
831    let p11 = surface.control_points[1][1].point().ok()?;
832    let u = p10.sub(p00);
833    let v = p01.sub(p00);
834    let scale = u.length().max(v.length()).max(1.0);
835    if p11.sub(p10).sub(v).length() > 1e-12 * scale { return None; }
836    let du = u.scale(1.0 / (ku[2] - ku[1]));
837    let dv = v.scale(1.0 / (kv[2] - kv[1]));
838    let determinant = du.dot(du) * dv.dot(dv) - du.dot(dv).powi(2);
839    if !determinant.is_finite() || determinant <= 1e-12 * du.dot(du) * dv.dot(dv) {
840        return None;
841    }
842    Some(AffinePlaneChart { origin: p00, du, dv, u0: ku[1], v0: kv[1] })
843}
844
845/// Transfer the existing trimmed locus, rather than replacing it with the
846/// shared edge's potentially displaced fitted curve. Affine reparameterization
847/// preserves every rational weight, knot and degree, including controls outside
848/// the surface's finite patch.
849fn transfer_affine_planar_pcurve(
850    source: &NurbsSurface,
851    target: &NurbsSurface,
852    curve: &NurbsCurve,
853    tolerance: f64,
854) -> Option<NurbsCurve> {
855    let source = affine_plane_chart(source)?;
856    let target = affine_plane_chart(target)?;
857    let uu = target.du.dot(target.du);
858    let uv = target.du.dot(target.dv);
859    let vv = target.dv.dot(target.dv);
860    let determinant = uu * vv - uv * uv;
861    let mut transferred = curve.clone();
862    for control in &mut transferred.control_points {
863        if !control.w.is_finite() || control.w.abs() <= 1e-300 { return None; }
864        let point = source.origin
865            .add(source.du.scale(control.x / control.w - source.u0))
866            .add(source.dv.scale(control.y / control.w - source.v0));
867        let relative = point.sub(target.origin);
868        let a = relative.dot(target.du);
869        let b = relative.dot(target.dv);
870        let u = (a * vv - b * uv) / determinant;
871        let v = (b * uu - a * uv) / determinant;
872        let reconstructed = target.origin.add(target.du.scale(u)).add(target.dv.scale(v));
873        if !u.is_finite() || !v.is_finite()
874            || reconstructed.sub(point).length() > tolerance.max(1e-12)
875        { return None; }
876        control.x = (target.u0 + u) * control.w;
877        control.y = (target.v0 + v) * control.w;
878        control.z = 0.0;
879    }
880    Some(transferred)
881}
882
883fn transfer_planar_loops(
884    target: &NurbsSurface,
885    loops: &[LoopRecord],
886    older: &FaceRecord,
887    newer: &FaceRecord,
888    tolerance: f64,
889) -> Option<Vec<LoopRecord>> {
890    let mut transferred = loops.to_vec();
891    for loop_record in &mut transferred {
892        for coedge in &mut loop_record.coedges {
893            let source = [older, newer].into_iter().find(|face| {
894                face.loops.iter().flat_map(|lp| &lp.coedges)
895                    .any(|original| original.id == coedge.id && original.edge_id == coedge.edge_id)
896            })?;
897            coedge.pcurve = transfer_affine_planar_pcurve(
898                &source.surface, target, &coedge.pcurve, tolerance,
899            )?;
900        }
901    }
902    Some(transferred)
903}
904
905fn reproject_loops(
906    surface: &NurbsSurface,
907    loops: Vec<LoopRecord>,
908    edges: &HashMap<u64, &EdgeRecord>,
909) -> Result<Vec<LoopRecord>, KernelRefusal> {
910    let mut projected_loops = Vec::new();
911    for (loop_index, loop_record) in loops.into_iter().enumerate() {
912        let mut projected = Vec::new();
913        for mut coedge in loop_record.coedges {
914            let edge = edges[&coedge.edge_id];
915            let mut curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
916            if !coedge.forward {
917                curve = curve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
918            }
919            coedge.pcurve = build_pcurve_on_surface(surface, &curve)
920                .or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
921            projected.push(coedge);
922        }
923        projected_loops.push(LoopRecord {
924            id: loop_index as u64 + 1,
925            coedges: projected,
926        });
927    }
928    Ok(projected_loops)
929}
930
931/// Put the one degenerate `apex` coedge back into the loop that passes through
932/// its vertex: a parameter line at the pole from the arriving trim's longitude
933/// to the leaving trim's. Each trim's pole end takes the longitude of its next
934/// control point, since projection cannot read a longitude at the pole. False
935/// when no single loop passes through the apex.
936fn stitch_cone_apex(
937    surface: &NurbsSurface,
938    loops: &mut [LoopRecord],
939    apex: &CoedgeRecord,
940    edges: &HashMap<u64, &EdgeRecord>,
941) -> Result<bool, KernelRefusal> {
942    let vertex = edges[&apex.edge_id].start_vertex_id;
943    let mut sites = Vec::new();
944    for (loop_index, loop_record) in loops.iter().enumerate() {
945        for (index, coedge) in loop_record.coedges.iter().enumerate() {
946            let next = &loop_record.coedges[(index + 1) % loop_record.coedges.len()];
947            if traversal_vertices(edges[&coedge.edge_id], coedge).1 == vertex
948                && traversal_vertices(edges[&next.edge_id], next).0 == vertex
949            {
950                sites.push((loop_index, index));
951            }
952        }
953    }
954    let [(loop_index, index)] = sites[..] else {
955        return Ok(false);
956    };
957    let coedges = &mut loops[loop_index].coedges;
958    let next_index = (index + 1) % coedges.len();
959    let (u_domain, _) = surface_domains(surface)?;
960    let [_, t1] = coedges[index].pcurve.domain().or_refuse(KernelStage::Sew, "domain")?;
961    let pole = coedges[index].pcurve.evaluate(t1).or_refuse(KernelStage::Sew, "evaluate")?;
962    let apex_point = surface.evaluate(pole.x, pole.y).or_refuse(KernelStage::Sew, "evaluate")?;
963    let across = surface
964        .evaluate((u_domain[0] + u_domain[1]) / 2.0, pole.y)
965        .or_refuse(KernelStage::Sew, "evaluate")?;
966    let angle_is_u = across.sub(apex_point).length() <= 1e-7 * (1.0 + apex_point.length());
967    let snapped = |pcurve: &mut NurbsCurve, at_end: bool| -> Result<Vec3, KernelRefusal> {
968        let count = pcurve.control_points.len();
969        if count < 2 {
970            return Err(KernelRefusal::internal(KernelStage::Sew, "face_merge", "cone apex: trim has one control point"));
971        }
972        let (slot, inner) = if at_end { (count - 1, count - 2) } else { (0, 1) };
973        let from = pcurve.control_points[inner].point().or_refuse(KernelStage::Sew, "point")?;
974        let weight = pcurve.control_points[slot].w;
975        let mut point = pcurve.control_points[slot].point().or_refuse(KernelStage::Sew, "point")?;
976        if angle_is_u {
977            point.x = from.x;
978        } else {
979            point.y = from.y;
980        }
981        pcurve.control_points[slot] = Vec4::from_point(point, weight);
982        Ok(point)
983    };
984    let arriving = snapped(&mut coedges[index].pcurve, true)?;
985    let leaving = snapped(&mut coedges[next_index].pcurve, false)?;
986    let mut bridge = apex.clone();
987    bridge.pcurve = make_line(arriving, leaving).or_refuse(KernelStage::Sew, "healing.face_merge")?;
988    coedges.insert(index + 1, bridge);
989    Ok(true)
990}
991
992fn traversal_vertices(edge: &EdgeRecord, coedge: &CoedgeRecord) -> (u64, u64) {
993    if coedge.forward {
994        (edge.start_vertex_id, edge.end_vertex_id)
995    } else {
996        (edge.end_vertex_id, edge.start_vertex_id)
997    }
998}
999
1000/// The parameter space every leftover coedge's pcurve lives in, when the merged
1001/// faces share ONE carrier. Only then can a merge read a loop's parameter-space
1002/// continuity; a reprojecting lane walks before its pcurves share a space.
1003struct SharedParameterSpace<'a> {
1004    surface: &'a NurbsSurface,
1005    /// The radius that made two fitted endpoints one vertex.
1006    weld: f64,
1007}
1008
1009impl SharedParameterSpace<'_> {
1010    fn coincide(&self, first: Vec3, second: Vec3) -> bool {
1011        let band = crate::classification::surface_uv_band(self.surface, first.x, first.y, self.weld);
1012        (first.x - second.x).abs() <= band && (first.y - second.y).abs() <= band
1013    }
1014}
1015
1016fn shared_trim_certificate_enabled() -> bool {
1017    switch!(live "BREP_FACE_MERGE_TRIM_CERT", true)
1018}
1019
1020/// Prove closure of the complete boundary isocurves. Clamping makes these
1021/// the first/last control rows; their common positive rational basis bounds
1022/// the entire difference by the largest paired control-point displacement.
1023fn closed_control_boundary(surface: &NurbsSurface, along_u: bool, weld: f64) -> bool {
1024    let (degree, knots, lo, hi) = if along_u {
1025        let Ok([lo,hi]) = surface.domain_u() else { return false; };
1026        (surface.degree_u, &surface.knots_u, lo, hi)
1027    } else {
1028        let Ok([lo,hi]) = surface.domain_v() else { return false; };
1029        (surface.degree_v, &surface.knots_v, lo, hi)
1030    };
1031    if knots.len() < 2*(degree+1)
1032        || !knots[..=degree].iter().all(|k| *k == lo)
1033        || !knots[knots.len()-degree-1..].iter().all(|k| *k == hi)
1034    { return false; }
1035    let agree = |a: Vec4, b: Vec4| {
1036        if a.w <= 0.0 || !a.w.is_finite() || a.w != b.w { return false; }
1037        let (Ok(a), Ok(b)) = (a.point(), b.point()) else { return false; };
1038        a.sub(b).length() <= weld
1039    };
1040    if along_u {
1041        let (Some(first), Some(last)) = (surface.control_points.first(), surface.control_points.last()) else { return false; };
1042        first.len() == last.len() && first.iter().zip(last).all(|(a,b)| agree(*a,*b))
1043    } else {
1044        surface.control_points.iter().all(|row| match (row.first(), row.last()) {
1045            (Some(a), Some(b)) => agree(*a,*b),
1046            _ => false,
1047        })
1048    }
1049}
1050
1051/// Linear pcurves on affine boundary isocurves compose to polylines.
1052/// Matched positive-weight polygon controls therefore bound the COMPLETE
1053/// paired images by their largest 3D displacement, even when the other UV
1054/// coordinate differs by roundoff. No UV band or station sample is used.
1055fn affine_boundary_trim_pair(
1056    surface: &NurbsSurface,
1057    along_u: bool,
1058    first_at_low: bool,
1059    first: &NurbsCurve,
1060    reverse: &NurbsCurve,
1061    weld: f64,
1062) -> bool {
1063    let (degree, knots, bounds) = if along_u {
1064        (surface.degree_v, &surface.knots_v, surface.domain_v())
1065    } else {
1066        (surface.degree_u, &surface.knots_u, surface.domain_u())
1067    };
1068    let Ok([lo,hi]) = bounds else { return false; };
1069    if degree != 1 || knots.as_slice() != [lo,lo,hi,hi] || hi <= lo { return false; }
1070    let ends = if along_u {
1071        let (Some(a),Some(b)) = (surface.control_points.first(),surface.control_points.last()) else { return false; };
1072        if a.len()!=2 || b.len()!=2 { return false; }
1073        [a[0],a[1],b[0],b[1]]
1074    } else {
1075        if surface.control_points.len()!=2 { return false; }
1076        let (a,b) = (&surface.control_points[0],&surface.control_points[1]);
1077        let (Some(a0),Some(a1),Some(b0),Some(b1)) = (a.first(),a.last(),b.first(),b.last()) else { return false; };
1078        [*a0,*b0,*a1,*b1]
1079    };
1080    if ends[0].w<=0.0 || ends[0].w!=ends[1].w || ends[2].w<=0.0 || ends[2].w!=ends[3].w {
1081        return false;
1082    }
1083    let (Ok(a0),Ok(a1),Ok(b0),Ok(b1)) = (ends[0].point(),ends[1].point(),ends[2].point(),ends[3].point()) else { return false; };
1084    let (a0,a1,b0,b1) = if first_at_low { (a0,a1,b0,b1) } else { (b0,b1,a0,a1) };
1085    let image = |p: &Vec4, a: Vec3, b: Vec3| {
1086        let t = if along_u { p.y/p.w } else { p.x/p.w };
1087        a.add(b.sub(a).scale((t-lo)/(hi-lo)))
1088    };
1089    first.control_points.iter().zip(&reverse.control_points).all(|(a,b)| {
1090        let distance = image(a,a0,a1).sub(image(b,b0,b1)).length();
1091        distance.is_finite() && distance <= weld
1092    })
1093}
1094
1095/// Prove that a retained shared pair is a periodic seam over its whole
1096/// trim. Positive common rational bases match the other parameter, or
1097/// affine boundary images bound matched linear polygons. Every periodic
1098/// coordinate control lies on opposite closed domain sides.
1099/// This qualifies retention after the existing coincidence check fails. It
1100/// does not change cancellation policy or widen a UV band. Work is linear
1101/// in the existing control arrays, with no sampling or refinement search.
1102fn periodic_shared_trim(
1103    space: &SharedParameterSpace,
1104    first: &CoedgeRecord,
1105    second: &CoedgeRecord,
1106) -> bool {
1107    if first.forward == second.forward { return false; }
1108    let Ok(reverse) = second.pcurve.reversed() else { return false; };
1109    let curve = &first.pcurve;
1110    if curve.degree != reverse.degree || curve.control_points.len() != reverse.control_points.len()
1111        || curve.control_points.is_empty()
1112    { return false; }
1113    let polygon = |c: &NurbsCurve| {
1114        c.degree == 1 && c.knots.len() == c.control_points.len()+2
1115            && c.knots[0] == c.knots[1]
1116            && c.knots[c.knots.len()-1] == c.knots[c.knots.len()-2]
1117            && c.knots[1..c.knots.len()-1].windows(2).all(|k| k[1] > k[0])
1118    };
1119    let polygon = polygon(curve) && polygon(&reverse);
1120    if (!polygon && curve.knots != reverse.knots)
1121        || !curve.control_points.iter().zip(&reverse.control_points).all(|(a,b)| {
1122            [a,b].iter().all(|p| p.w > 0.0 && p.w.is_finite()
1123                && (p.x/p.w).is_finite() && (p.y/p.w).is_finite())
1124                && (polygon || a.w == b.w)
1125        })
1126    { return false; }
1127    let coordinate = |p: &Vec4, u: bool| if u { p.x/p.w } else { p.y/p.w };
1128    let same_coordinate = |u: bool| curve.control_points.iter().zip(&reverse.control_points)
1129        .all(|(a,b)| if polygon { coordinate(a,u) == coordinate(b,u) }
1130            else if u { a.x == b.x } else { a.y == b.y });
1131    for u in [true,false] {
1132        let bounds = if u { space.surface.domain_u() } else { space.surface.domain_v() };
1133        let Ok([lo,hi]) = bounds else { continue; };
1134        let other_bounds = if u { space.surface.domain_v() } else { space.surface.domain_u() };
1135        let Ok([other_lo,other_hi]) = other_bounds else { continue; };
1136        // The closed-boundary control bound applies on its active domain.
1137        // Positive pcurve hulls must stay inside it; extrapolated traces
1138        // need a different proof and keep their existing face partition.
1139        if !curve.control_points.iter().chain(&reverse.control_points)
1140            .all(|p| coordinate(p,!u) >= other_lo && coordinate(p,!u) <= other_hi)
1141        { continue; }
1142        let opposite = [(lo,hi),(hi,lo)].iter().any(|(a,b)|
1143            curve.control_points.iter().all(|p| coordinate(p,u) == *a)
1144                && reverse.control_points.iter().all(|p| coordinate(p,u) == *b));
1145        if opposite && closed_control_boundary(space.surface, u, space.weld)
1146            && (same_coordinate(!u) || (polygon && affine_boundary_trim_pair(
1147                space.surface, u, coordinate(&curve.control_points[0],u)==lo,
1148                curve, &reverse, space.weld)))
1149        {
1150            return true;
1151        }
1152    }
1153    false
1154}
1155
1156/// A coedge's pcurve at its start, middle and end. The pcurve runs in the
1157/// coedge's own direction, so its domain start is where the traversal starts.
1158fn pcurve_stations(coedge: &CoedgeRecord) -> Option<[Vec3; 3]> {
1159    let [t0, t1] = coedge.pcurve.domain().ok()?;
1160    Some([
1161        coedge.pcurve.evaluate(t0).ok()?,
1162        coedge.pcurve.evaluate((t0 + t1) / 2.0).ok()?,
1163        coedge.pcurve.evaluate(t1).ok()?,
1164    ])
1165}
1166
1167/// SLIT DISSOLUTION. An edge whose two leftover uses run along the SAME
1168/// parameter-space segment, one each way, separates nothing: its ring on the
1169/// other side was the shared edge the merge just cancelled (a flanged hole's
1170/// annulus, slit to the ring it bridged to). Kept, the walk can only return
1171/// along it and close a loop of zero area. Two uses a PERIOD apart are a seam
1172/// the region wraps across, and stay.
1173fn dissolve_slits(
1174    remaining: &mut Vec<CoedgeRecord>,
1175    edges: &HashMap<u64, &EdgeRecord>,
1176    space: &SharedParameterSpace,
1177) {
1178    let mut uses = HashMap::<u64, Vec<usize>>::default();
1179    for (index, coedge) in remaining.iter().enumerate() {
1180        uses.entry(coedge.edge_id).or_default().push(index);
1181    }
1182    let slits = uses
1183        .iter()
1184        .filter(|(edge_id, indices)| {
1185            if indices.len() != 2 || edges[*edge_id].degenerate {
1186                return false;
1187            }
1188            let (first, second) = (&remaining[indices[0]], &remaining[indices[1]]);
1189            if first.forward == second.forward {
1190                return false;
1191            }
1192            let (Some(first), Some(second)) = (pcurve_stations(first), pcurve_stations(second)) else {
1193                return false;
1194            };
1195            space.coincide(first[0], second[2])
1196                && space.coincide(first[1], second[1])
1197                && space.coincide(first[2], second[0])
1198        })
1199        .map(|(edge_id, _)| *edge_id)
1200        .collect::<HashSet<_>>();
1201    remaining.retain(|coedge| !slits.contains(&coedge.edge_id));
1202}
1203
1204/// Stitch a merged face's leftover coedges into loops, or `None` when the
1205/// stitching is not determined.
1206///
1207/// A loop can pass one VERTEX twice — a bridge's foot on the ring it joins —
1208/// so reaching the start vertex does not by itself close the loop, and a vertex
1209/// with several unused coedges leaving it does not name the next one. There the
1210/// walk follows the loop's PARAMETER-SPACE continuity when the coedges share a
1211/// carrier (`space`): the successor starts where the current pcurve ends, and a
1212/// seam's two images of one vertex a period apart are two different places.
1213/// Without a shared space, or with no single successor, the merge is refused
1214/// and the faces stay as they were assembled.
1215fn walk_cycles(
1216    mut remaining: Vec<CoedgeRecord>,
1217    edges: &HashMap<u64, &EdgeRecord>,
1218    space: Option<&SharedParameterSpace>,
1219) -> Option<Vec<LoopRecord>> {
1220    let maximum_cycle_length = remaining.len();
1221    let mut loops = Vec::new();
1222    while !remaining.is_empty() {
1223        let first = remaining.remove(0);
1224        let (start, mut end) = traversal_vertices(edges[&first.edge_id], &first);
1225        let mut cycle = vec![first];
1226        loop {
1227            let leaving = remaining
1228                .iter()
1229                .enumerate()
1230                .filter_map(|(index, coedge)| {
1231                    (traversal_vertices(edges[&coedge.edge_id], coedge).0 == end).then_some(index)
1232                })
1233                .collect::<Vec<_>>();
1234            let closes = end == start;
1235            let successor = match (leaving.as_slice(), closes) {
1236                ([], true) => None,
1237                ([only], false) => Some(*only),
1238                ([], false) => return None,
1239                _ => {
1240                    let space = space?;
1241                    let here = pcurve_stations(cycle.last()?)?[2];
1242                    let closing = closes && space.coincide(here, pcurve_stations(&cycle[0])?[0]);
1243                    let mut continuing = Vec::new();
1244                    for index in leaving {
1245                        if space.coincide(here, pcurve_stations(&remaining[index])?[0]) {
1246                            continuing.push(index);
1247                        }
1248                    }
1249                    match (continuing.as_slice(), closing) {
1250                        ([], true) => None,
1251                        ([only], false) => Some(*only),
1252                        _ => return None,
1253                    }
1254                }
1255            };
1256            let Some(index) = successor else {
1257                break;
1258            };
1259            let next = remaining.remove(index);
1260            end = traversal_vertices(edges[&next.edge_id], &next).1;
1261            cycle.push(next);
1262            if cycle.len() > maximum_cycle_length {
1263                return None;
1264            }
1265        }
1266        // A cycle that traverses every one of its edges both ways encloses
1267        // nothing, however its parameter-space image reads.
1268        let mut counts = HashMap::<u64, usize>::default();
1269        for coedge in &cycle {
1270            *counts.entry(coedge.edge_id).or_default() += 1;
1271        }
1272        if counts.values().all(|count| *count == 2) {
1273            return None;
1274        }
1275        loops.push(LoopRecord {
1276            id: loops.len() as u64 + 1,
1277            coedges: cycle,
1278        });
1279    }
1280    Some(loops)
1281}
1282
1283/// Whether two faces ride ONE carrier — the same geometric surface, oriented
1284/// the same way out of the material — by any of the four readings this module
1285/// merges on: bit-identical patches, coplanar planes with the same outward
1286/// normal, cosurface cylinder patches, and coextruded profiles.
1287///
1288/// `pub(crate)` because the direct-edit rejoin asks the same question for the
1289/// same reason: a survivor face on the far side of a deleted band can only
1290/// take the near side's loops when the two are one carrier. It asks GEOMETRIC
1291/// cosurface rather than [`same_surface`] alone, because a mirrored twin is
1292/// the same surface reparametrized — reflected control points, flipped
1293/// `same_sense` — and bit-identity reads that as two.
1294pub(crate) fn faces_are_cosurface(
1295    first: &FaceRecord,
1296    second: &FaceRecord,
1297    tolerance: f64,
1298) -> Result<bool, KernelRefusal> {
1299    if first.same_sense == second.same_sense
1300        && same_surface(&first.surface, &second.surface, tolerance)
1301    {
1302        return Ok(true);
1303    }
1304    if coplanar_with_same_outward_normal(first, second, tolerance)? {
1305        return Ok(true);
1306    }
1307    if cosurface_cylinder_pair(first, second, tolerance, MergeLanes::default())?.is_some() {
1308        return Ok(true);
1309    }
1310    Ok(coextrusion_pair(first, second, tolerance)?.is_some())
1311}
1312
1313/// Degree-one clamped trims follow their control polygon regardless of knot
1314/// spacing. For higher degrees, equal positive rational bases make the
1315/// difference of the two affine images a convex combination of control-point
1316/// differences. Bound the whole
1317/// shared trim, rather than relying on cancellation of its area integral.
1318fn opposed_planar_trims_agree(
1319    older: &FaceRecord,
1320    first: &CoedgeRecord,
1321    newer: &FaceRecord,
1322    second: &CoedgeRecord,
1323    tolerance: f64,
1324) -> bool {
1325    let Ok(reverse) = second.pcurve.reversed() else {
1326        return false;
1327    };
1328    let curve = &first.pcurve;
1329    if curve.degree != reverse.degree || curve.control_points.len() != reverse.control_points.len()
1330    {
1331        return false;
1332    }
1333    let linear_polygon = |c: &NurbsCurve| {
1334        c.degree == 1
1335            && c.knots.len() == c.control_points.len() + 2
1336            && c.knots[0] == c.knots[1]
1337            && c.knots[c.knots.len() - 1] == c.knots[c.knots.len() - 2]
1338            && c.knots[1..c.knots.len() - 1]
1339                .windows(2)
1340                .all(|k| k[1] > k[0])
1341    };
1342    let polygon = linear_polygon(curve) && linear_polygon(&reverse);
1343    if !polygon && curve.knots != reverse.knots {
1344        return false;
1345    }
1346    curve
1347        .control_points
1348        .iter()
1349        .zip(&reverse.control_points)
1350        .all(|(a, b)| {
1351            if !a.w.is_finite()
1352                || !b.w.is_finite()
1353                || a.w <= 0.0
1354                || b.w <= 0.0
1355                || (!polygon && a.w != b.w)
1356            {
1357                return false;
1358            }
1359            let (Ok(a), Ok(b)) = (a.point(), b.point()) else {
1360                return false;
1361            };
1362            let (Ok(a), Ok(b)) = (
1363                older.surface.evaluate_extended(a.x, a.y),
1364                newer.surface.evaluate_extended(b.x, b.y),
1365            ) else {
1366                return false;
1367            };
1368            a.sub(b).length() <= tolerance
1369        })
1370}
1371
1372/// Like [`decline!`], but leaves a labelled lane instead of the whole pair
1373/// merge, so a later lane can read the same pair again.
1374macro_rules! fall_through {
1375    ($older:expr, $newer:expr, $label:lifetime) => {{
1376        if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1377            eprintln!(
1378                "merge {}x{}: lane left at face_merge.rs:{}",
1379                $older.id,
1380                $newer.id,
1381                line!()
1382            );
1383        }
1384        break $label;
1385    }};
1386}
1387
1388/// Trace a declined pair merge under `BREP_DEBUG_MERGE` with the source line
1389/// that declined it, then return `Ok(None)`. A pair whose carriers MATCH can
1390/// still be declined a dozen lines later (shared-edge census, cycle walk,
1391/// area check); without the line the trace reads "MATCH" and then nothing,
1392/// which is how the E13 cosurface report (2026-10-13) hid its cause.
1393macro_rules! decline {
1394    ($older:expr, $newer:expr) => {{
1395        if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1396            eprintln!(
1397                "merge {}x{}: declined at face_merge.rs:{}",
1398                $older.id,
1399                $newer.id,
1400                line!()
1401            );
1402        }
1403        return Ok(None);
1404    }};
1405}
1406
1407fn try_merge_pair(
1408    older: &FaceRecord,
1409    newer: &FaceRecord,
1410    edges: &HashMap<u64, &EdgeRecord>,
1411    tolerance: f64,
1412    same_carrier_area_tolerance_ratio: f64,
1413) -> Result<Option<FaceRecord>, KernelRefusal> {
1414    try_merge_pair_lanes(
1415        older,
1416        newer,
1417        edges,
1418        tolerance,
1419        same_carrier_area_tolerance_ratio,
1420        MergeLanes::default(),
1421    )
1422}
1423
1424/// [`try_merge_pair`] with the opt-in [`MergeLanes`].
1425fn try_merge_pair_lanes(
1426    older: &FaceRecord,
1427    newer: &FaceRecord,
1428    edges: &HashMap<u64, &EdgeRecord>,
1429    tolerance: f64,
1430    same_carrier_area_tolerance_ratio: f64,
1431    lanes: MergeLanes<'_>,
1432) -> Result<Option<FaceRecord>, KernelRefusal> {
1433    let same_carrier = older.same_sense == newer.same_sense
1434        && same_surface(&older.surface, &newer.surface, tolerance);
1435    // Oppositely oriented planar patches can form a folded, zero-thickness
1436    // overlap along a multi-edge seam. Cancelling that seam preserves the
1437    // signed region, not the sum of the two unsigned face areas.
1438    let cancel_planar_fold = older.same_sense != newer.same_sense
1439        && older.loops.len() == 1
1440        && newer.loops.len() == 1
1441        && same_surface(&older.surface, &newer.surface, tolerance)
1442        && older
1443            .surface
1444            .is_affine()
1445            .or_refuse(KernelStage::Sew, "is_affine")?
1446        && newer
1447            .surface
1448            .is_affine()
1449            .or_refuse(KernelStage::Sew, "is_affine")?;
1450    let coplanar_planar =
1451        !same_carrier && coplanar_with_same_outward_normal(older, newer, tolerance)?;
1452    let cosurface_cylinder = if !same_carrier && !coplanar_planar {
1453        cosurface_cylinder_pair(older, newer, tolerance, lanes)?
1454    } else {
1455        None
1456    };
1457    let cosurface_extrusion = if !same_carrier && !coplanar_planar && cosurface_cylinder.is_none() {
1458        coextrusion_pair(older, newer, tolerance)?
1459    } else {
1460        None
1461    };
1462    let older_coedges = older
1463        .loops
1464        .iter()
1465        .flat_map(|loop_record| &loop_record.coedges)
1466        .collect::<Vec<_>>();
1467    let newer_coedges = newer
1468        .loops
1469        .iter()
1470        .flat_map(|loop_record| &loop_record.coedges)
1471        .collect::<Vec<_>>();
1472    let space = (same_carrier || cancel_planar_fold).then(|| SharedParameterSpace {
1473        surface: &older.surface,
1474        weld: crate::tolerance::assembler_weld(tolerance),
1475    });
1476    let mut shared_older = HashSet::default();
1477    let mut shared_newer = HashSet::default();
1478    let mut shared_edges = HashSet::default();
1479    // Shared edges the merged face keeps, both ways: on ONE carrier, a pair
1480    // whose two images lie a period apart is the carrier's seam (the
1481    // dissolve_slits doctrine), and the union wraps across it. Cancelling it
1482    // tore the seam out of a shelled cylinder's wall against its own strip
1483    // (the E13 report, 2026-10-13) and the area check declined the pair. A
1484    // pair that touches ONLY across the seam still declines below: its union
1485    // is two regions a period apart in this carrier's chart, one face record
1486    // holding two discs (the slab-corner mirror union read V−E+F = 1 so).
1487    for first in &older_coedges {
1488        if edges[&first.edge_id].degenerate {
1489            continue;
1490        }
1491        for second in &newer_coedges {
1492            if first.edge_id != second.edge_id {
1493                continue;
1494            }
1495            if first.forward == second.forward {
1496                decline!(older, newer);
1497            }
1498            if cancel_planar_fold
1499                && !opposed_planar_trims_agree(older, first, newer, second, tolerance)
1500            {
1501                decline!(older, newer);
1502            }
1503            if same_carrier && !cancel_planar_fold {
1504                if let (Some(space), Some(here), Some(there)) =
1505                    (&space, pcurve_stations(first), pcurve_stations(second))
1506                {
1507                    let retraced = space.coincide(here[0], there[2])
1508                        && space.coincide(here[1], there[1])
1509                        && space.coincide(here[2], there[0]);
1510                    if !retraced {
1511                        // A noncoincident pair may be retained only as a
1512                        // proved periodic seam. An interior partition stays
1513                        // between faces; retaining it here creates a slit.
1514                        if shared_trim_certificate_enabled()
1515                            && !periodic_shared_trim(space, first, second)
1516                        { decline!(older, newer); }
1517                        continue;
1518                    }
1519                } else if shared_trim_certificate_enabled() {
1520                    decline!(older, newer);
1521                }
1522            }
1523            shared_older.insert(first.id);
1524            shared_newer.insert(second.id);
1525            shared_edges.insert(first.edge_id);
1526        }
1527    }
1528    if shared_older.is_empty() || shared_older.len() != shared_newer.len() {
1529        decline!(older, newer);
1530    }
1531    if cancel_planar_fold && shared_older.len() < 2 {
1532        decline!(older, newer);
1533    }
1534    if !same_carrier
1535        && !cancel_planar_fold
1536        && !coplanar_planar
1537        && cosurface_cylinder.is_none()
1538        && cosurface_extrusion.is_none()
1539    {
1540        decline!(older, newer);
1541    }
1542    let mut remaining = older_coedges
1543        .into_iter()
1544        .filter(|coedge| {
1545            !shared_older.contains(&coedge.id)
1546                && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
1547        })
1548        .chain(newer_coedges.into_iter().filter(|coedge| {
1549            !shared_newer.contains(&coedge.id)
1550                && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
1551        }))
1552        .cloned()
1553        .collect::<Vec<_>>();
1554    if cancel_planar_fold {
1555        // The opposite fold shares this affine chart too. A retraced slit
1556        // contributes no signed boundary, but three coincident samples do
1557        // not prove that: opposite bows can match there and enclose slivers.
1558        // Use the whole-curve control bound before discarding either use.
1559        let mut uses = HashMap::<u64, Vec<&CoedgeRecord>>::default();
1560        for coedge in &remaining {
1561            uses.entry(coedge.edge_id).or_default().push(coedge);
1562        }
1563        let mut slits = HashSet::default();
1564        for (id, uses) in uses {
1565            if uses.len() == 2
1566                && !edges[&id].degenerate
1567                && uses[0].forward != uses[1].forward
1568                && opposed_planar_trims_agree(older, uses[0], older, uses[1], tolerance)
1569            {
1570                slits.insert(id);
1571            }
1572        }
1573        remaining.retain(|coedge| !slits.contains(&coedge.edge_id));
1574    } else if let Some(space) = &space {
1575        dissolve_slits(&mut remaining, edges, space);
1576    }
1577    let Some(mut loops) = walk_cycles(remaining, edges, space.as_ref()) else {
1578        // Cancelling the shared edges of a cosurface cylinder pair whose
1579        // union wraps the full turn can strand one face's seam strip at a
1580        // fork the 3D walk cannot resolve (the E13 report, 2026-10-13); the
1581        // full-turn lane walks in the merged carrier's own parameter space.
1582        if let Some(support) = &cosurface_cylinder {
1583            if let Some(merged) =
1584                merge_cylinder_full_turn(older, newer, edges, tolerance, support, &shared_edges, lanes)?
1585            {
1586                return Ok(Some(merged));
1587            }
1588        }
1589        decline!(older, newer);
1590    };
1591    if loops.iter().any(|loop_record| {
1592        loop_record
1593            .coedges
1594            .iter()
1595            .all(|coedge| edges[&coedge.edge_id].degenerate)
1596    }) {
1597        decline!(older, newer);
1598    }
1599    if loops.is_empty() {
1600        decline!(older, newer);
1601    }
1602    loops.sort_by(|first, second| {
1603        let area = |loop_record: &LoopRecord| {
1604            parameter_space_area(&FaceRecord {
1605                id: older.id,
1606                surface: older.surface.clone(),
1607                same_sense: older.same_sense,
1608                loops: vec![loop_record.clone()],
1609                name: None,
1610            })
1611            .map(f64::abs)
1612        };
1613        match (area(first), area(second)) {
1614            (Ok(a), Ok(b)) => b.total_cmp(&a),
1615            _ => std::cmp::Ordering::Equal,
1616        }
1617    });
1618    if same_carrier || cancel_planar_fold {
1619        if cancel_planar_fold && loops.len() != 1 {
1620            decline!(older, newer);
1621        }
1622        let expected = parameter_space_area(older)
1623            .or_refuse(KernelStage::Sew, "parameter_space_area")?
1624            + parameter_space_area(newer).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1625        if cancel_planar_fold && expected.abs() <= tolerance * tolerance {
1626            decline!(older, newer);
1627        }
1628        let merged = FaceRecord {
1629            id: older.id,
1630            surface: older.surface.clone(),
1631            same_sense: if cancel_planar_fold {
1632                expected > 0.0
1633            } else {
1634                older.same_sense
1635            },
1636            loops,
1637            name: older.name.clone(),
1638        };
1639
1640        let actual =
1641            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1642        let area_ratio = if cancel_planar_fold {
1643            same_carrier_area_tolerance_ratio.min(1e-7)
1644        } else {
1645            same_carrier_area_tolerance_ratio
1646        };
1647        let area_tolerance = 1e-9f64.max(expected.abs() * area_ratio);
1648        if (actual - expected).abs() > area_tolerance
1649            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
1650        {
1651            decline!(older, newer);
1652        }
1653        return Ok(Some(merged));
1654    }
1655
1656    if let Some(support) = &cosurface_cylinder {
1657        // The partial-turn lane first: a union that leaves an angular gap
1658        // takes a carrier seamed in that gap and never crosses the seam.
1659        // Whatever it declines (a sampled gap that is not one, a region that
1660        // wraps the full turn) the full-turn lane reads again on a closed
1661        // carrier seamed along an existing axial edge.
1662        'partial_turn: {
1663            // The partial-turn carrier is built at one radius: a cone goes
1664            // straight to the full-turn lane, which reads it per end.
1665            if support.slope != 0.0 {
1666                fall_through!(older, newer, 'partial_turn);
1667            }
1668            // Build one revolution carrier covering both patches: seam at the
1669            // largest angular gap so the merged region never crosses it, then
1670            // reproject every loop onto the shared cylinder.
1671            let x_reference = {
1672                let seed = loops
1673                    .iter()
1674                    .flat_map(|loop_record| &loop_record.coedges)
1675                    .find_map(|coedge| {
1676                        let edge = edges[&coedge.edge_id];
1677                        edge.curve.evaluate(edge.t0).ok()
1678                    })
1679                    .ok_or_else(|| {
1680                        KernelRefusal::internal(
1681                            KernelStage::Sew,
1682                            "face_merge",
1683                            "cylinder merge: no boundary sample",
1684                        )
1685                    })?;
1686                let delta = seed.sub(support.origin);
1687                let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
1688                match radial.normalized() {
1689                    Ok(radial) => radial,
1690                    Err(_) => fall_through!(older, newer, 'partial_turn),
1691                }
1692            };
1693            let y_reference = support.axis.cross(x_reference);
1694            let mut angles = Vec::new();
1695            // The angular sweep between consecutive samples of ONE edge: the
1696            // edge itself covers it, so no gap can open inside it.
1697            let mut swept: Vec<(f64, f64)> = Vec::new();
1698            let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
1699            for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1700                let edge = edges[&coedge.edge_id];
1701                let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1702                let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
1703                let hull = axial_hull(&curve, support);
1704                axial_range[0] = axial_range[0].min(hull[0]);
1705                axial_range[1] = axial_range[1].max(hull[1]);
1706                let mut previous: Option<f64> = None;
1707                for index in 0..=8 {
1708                    let point = curve
1709                        .evaluate(c0 + (c1 - c0) * index as f64 / 8.0)
1710                        .or_refuse(KernelStage::Sew, "evaluate")?;
1711                    let delta = point.sub(support.origin);
1712                    let axial = delta.dot(support.axis);
1713                    let radial = delta.sub(support.axis.scale(axial));
1714                    if radial.length() <= support.radius * 1e-6 {
1715                        continue;
1716                    }
1717                    let mut angle = radial.dot(y_reference).atan2(radial.dot(x_reference));
1718                    if angle < 0.0 {
1719                        angle += std::f64::consts::TAU;
1720                    }
1721                    if let Some(previous) = previous {
1722                        swept.push((previous, angle_difference(previous, angle)));
1723                    }
1724                    previous = Some(angle);
1725                    angles.push(angle);
1726                }
1727            }
1728            if angles.len() < 3 || axial_range[1] - axial_range[0] <= tolerance {
1729                fall_through!(older, newer, 'partial_turn);
1730            }
1731            // The boundary sweeps the whole turn (two half-turn walls closing a
1732            // ring between them): the largest gap between SAMPLES below would
1733            // be one sample step inside an arc, not a gap in the region, and
1734            // the carrier seamed there only fails the area check after a full
1735            // reprojection. Leave for the full-turn lane at once.
1736            if largest_uncovered_turn(&swept) <= 1e-6 {
1737                fall_through!(older, newer, 'partial_turn);
1738            }
1739            angles.sort_by(f64::total_cmp);
1740            let mut gap_start = angles.len() - 1;
1741            let mut largest_gap = angles[0] + std::f64::consts::TAU - angles[angles.len() - 1];
1742            for index in 0..angles.len() - 1 {
1743                let gap = angles[index + 1] - angles[index];
1744                if gap > largest_gap {
1745                    largest_gap = gap;
1746                    gap_start = index;
1747                }
1748            }
1749            let span = std::f64::consts::TAU - largest_gap;
1750            if span <= tolerance || span >= std::f64::consts::TAU - 1e-6 {
1751                fall_through!(older, newer, 'partial_turn);
1752            }
1753            let start_angle = angles[(gap_start + 1) % angles.len()];
1754            let start_direction = x_reference
1755                .scale(start_angle.cos())
1756                .add(y_reference.scale(start_angle.sin()));
1757            let generatrix = make_line(
1758                support
1759                    .origin
1760                    .add(support.axis.scale(axial_range[0]))
1761                    .add(start_direction.scale(support.radius)),
1762                support
1763                    .origin
1764                    .add(support.axis.scale(axial_range[1]))
1765                    .add(start_direction.scale(support.radius)),
1766            )
1767            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1768            let surface = make_revolution(support.origin, support.axis, &generatrix, span)
1769                .or_refuse(KernelStage::Sew, "make_revolution")?;
1770            let (u_domain, v_domain) = surface_domains(&surface)?;
1771            let sample = surface
1772                .evaluate(
1773                    (u_domain[0] + u_domain[1]) / 2.0,
1774                    (v_domain[0] + v_domain[1]) / 2.0,
1775                )
1776                .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1777            let geometric_normal = surface
1778                .normal(
1779                    (u_domain[0] + u_domain[1]) / 2.0,
1780                    (v_domain[0] + v_domain[1]) / 2.0,
1781                )
1782                .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1783            let delta = sample.sub(support.origin);
1784            let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
1785            let radial = match radial.normalized() {
1786                Ok(radial) => radial,
1787                Err(_) => fall_through!(older, newer, 'partial_turn),
1788            };
1789            let same_sense = geometric_normal.dot(radial).signum() == support.outward_radial_sign;
1790            let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1791            projected_loops.sort_by(|first, second| {
1792                let area = |loop_record: &LoopRecord| {
1793                    parameter_space_area(&FaceRecord {
1794                        id: older.id,
1795                        surface: surface.clone(),
1796                        same_sense,
1797                        loops: vec![loop_record.clone()],
1798                        name: None,
1799                    })
1800                    .map(f64::abs)
1801                };
1802                match (area(first), area(second)) {
1803                    (Ok(a), Ok(b)) => b.total_cmp(&a),
1804                    _ => std::cmp::Ordering::Equal,
1805                }
1806            });
1807            let merged = FaceRecord {
1808                id: older.id,
1809                surface,
1810                same_sense,
1811                loops: projected_loops,
1812                name: older.name.clone(),
1813            };
1814            let area =
1815                parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1816            if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1817                fall_through!(older, newer, 'partial_turn);
1818            }
1819            // The reprojected trims must reproduce the pieces' combined 3D
1820            // area; a projection or seam mistake shows up here immediately.
1821            let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1822                + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1823            let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1824            if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1825                fall_through!(older, newer, 'partial_turn);
1826            }
1827            return Ok(Some(merged));
1828        }
1829        if let Some(merged) =
1830            merge_cylinder_full_turn(older, newer, edges, tolerance, support, &shared_edges, lanes)?
1831        {
1832            return Ok(Some(merged));
1833        }
1834        decline!(older, newer);
1835    }
1836
1837    if let Some((support, union)) = cosurface_extrusion {
1838        // Shared linear-sweep carrier: the older profile translated to the
1839        // union's start plane, extruded across the union interval.
1840        let shift = support.direction.scale(union[0] - support.interval[0]);
1841        let step = support.direction.scale(union[1] - union[0]);
1842        let rows = support
1843            .base
1844            .control_points
1845            .iter()
1846            .map(|control| -> Result<Vec<Vec4>, KernelRefusal> {
1847                let point = control
1848                    .point()
1849                    .or_refuse(KernelStage::Sew, "point")?
1850                    .add(shift);
1851                Ok(vec![
1852                    Vec4::from_point(point, control.w),
1853                    Vec4::from_point(point.add(step), control.w),
1854                ])
1855            })
1856            .collect::<Result<Vec<_>, _>>()?;
1857        let surface = NurbsSurface::new(
1858            support.base.degree,
1859            1,
1860            support.base.knots.clone(),
1861            vec![0.0, 0.0, 1.0, 1.0],
1862            rows,
1863        )
1864        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1865        let (older_u, older_v) = surface_domains(&older.surface)?;
1866        let older_mid = older
1867            .surface
1868            .evaluate(
1869                (older_u[0] + older_u[1]) / 2.0,
1870                (older_v[0] + older_v[1]) / 2.0,
1871            )
1872            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1873        let projection = crate::project_point_to_surface(&surface, older_mid)
1874            .or_refuse(KernelStage::Sew, "project_point_to_surface")?;
1875        let carrier_normal = surface
1876            .normal(projection.u, projection.v)
1877            .or_refuse(KernelStage::Sew, "normal")?;
1878        let same_sense = carrier_normal.dot(outward_normal(older)?) > 0.0;
1879        let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1880        projected_loops.sort_by(|first, second| {
1881            let area = |loop_record: &LoopRecord| {
1882                parameter_space_area(&FaceRecord {
1883                    id: older.id,
1884                    surface: surface.clone(),
1885                    same_sense,
1886                    loops: vec![loop_record.clone()],
1887                    name: None,
1888                })
1889                .map(f64::abs)
1890            };
1891            match (area(first), area(second)) {
1892                (Ok(a), Ok(b)) => b.total_cmp(&a),
1893                _ => std::cmp::Ordering::Equal,
1894            }
1895        });
1896        let merged = FaceRecord {
1897            id: older.id,
1898            surface,
1899            same_sense,
1900            loops: projected_loops,
1901            name: older.name.clone(),
1902        };
1903        let area =
1904            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1905        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1906            decline!(older, newer);
1907        }
1908        let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1909            + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1910        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1911        if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1912            if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1913                eprintln!(
1914                    "coextrusion {}x{}: area mismatch {actual} vs {expected}",
1915                    older.id, newer.id
1916                );
1917            }
1918            decline!(older, newer);
1919        }
1920        return Ok(Some(merged));
1921    }
1922
1923    let (u_domain, v_domain) = surface_domains(&older.surface)?;
1924    let origin = older
1925        .surface
1926        .evaluate(u_domain[0], v_domain[0])
1927        .or_refuse(KernelStage::Sew, "evaluate")?;
1928    let derivatives = older
1929        .surface
1930        .derivatives(
1931            (u_domain[0] + u_domain[1]) / 2.0,
1932            (v_domain[0] + v_domain[1]) / 2.0,
1933            1,
1934        )
1935        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1936    let u_axis = derivatives[1][0]
1937        .normalized()
1938        .or_refuse(KernelStage::Sew, "normalized")?;
1939    let geometric_normal = derivatives[1][0]
1940        .cross(derivatives[0][1])
1941        .normalized()
1942        .or_refuse(KernelStage::Sew, "normalized")?;
1943    let v_axis = geometric_normal
1944        .cross(u_axis)
1945        .normalized()
1946        .or_refuse(KernelStage::Sew, "normalized")?;
1947    let mut bounds_u = [f64::INFINITY, f64::NEG_INFINITY];
1948    let mut bounds_v = [f64::INFINITY, f64::NEG_INFINITY];
1949    for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1950        let edge = edges[&coedge.edge_id];
1951        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1952        for control in &curve.control_points {
1953            let relative = control
1954                .point()
1955                .or_refuse(KernelStage::Sew, "point")?
1956                .sub(origin);
1957            let u = relative.dot(u_axis);
1958            let v = relative.dot(v_axis);
1959            bounds_u[0] = bounds_u[0].min(u);
1960            bounds_u[1] = bounds_u[1].max(u);
1961            bounds_v[0] = bounds_v[0].min(v);
1962            bounds_v[1] = bounds_v[1].max(v);
1963        }
1964    }
1965    let extent_u = bounds_u[1] - bounds_u[0];
1966    let extent_v = bounds_v[1] - bounds_v[0];
1967    if extent_u <= tolerance || extent_v <= tolerance {
1968        decline!(older, newer);
1969    }
1970    let surface_origin = origin
1971        .add(u_axis.scale(bounds_u[0]))
1972        .add(v_axis.scale(bounds_v[0]));
1973    let surface = make_plane(surface_origin, u_axis, v_axis, extent_u, extent_v)
1974        .or_refuse(KernelStage::Sew, "make_plane")?;
1975    let mut projected_loops = match transfer_planar_loops(&surface, &loops, older, newer, tolerance)
1976    {
1977        Some(transferred) => transferred,
1978        None => reproject_loops(&surface, loops, edges)?,
1979    };
1980    projected_loops.sort_by(|first, second| {
1981        let area = |loop_record: &LoopRecord| {
1982            parameter_space_area(&FaceRecord {
1983                id: older.id,
1984                surface: surface.clone(),
1985                same_sense: older.same_sense,
1986                loops: vec![loop_record.clone()],
1987                name: None,
1988            })
1989            .map(f64::abs)
1990        };
1991        match (area(first), area(second)) {
1992            (Ok(a), Ok(b)) => b.total_cmp(&a),
1993            _ => std::cmp::Ordering::Equal,
1994        }
1995    });
1996    let merged = FaceRecord {
1997        id: older.id,
1998        surface,
1999        same_sense: older.same_sense,
2000        loops: projected_loops,
2001        name: older.name.clone(),
2002    };
2003    let area = parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
2004    if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
2005        decline!(older, newer);
2006    }
2007    Ok(Some(merged))
2008}
2009
2010/// Angle of `point` about `support`'s axis in the (`x_reference`,
2011/// `y_reference`) frame, in `[0, TAU)`; `None` on the axis.
2012fn cylinder_angle(
2013    support: &CylinderSupport,
2014    x_reference: Vec3,
2015    y_reference: Vec3,
2016    point: Vec3,
2017) -> Option<f64> {
2018    let delta = point.sub(support.origin);
2019    let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
2020    if radial.length() <= support.radius * 1e-6 {
2021        return None;
2022    }
2023    let mut angle = radial.dot(y_reference).atan2(radial.dot(x_reference));
2024    if angle < 0.0 {
2025        angle += std::f64::consts::TAU;
2026    }
2027    // An atan2 a few ulp below zero rounds to TAU itself after the shift; the
2028    // range is half-open, so that reading is the seam angle 0.
2029    if angle >= std::f64::consts::TAU {
2030        angle = 0.0;
2031    }
2032    Some(angle)
2033}
2034
2035
2036/// The widest angle, in radians, that no `(start, signed sweep)` arc of
2037/// `swept` covers; `TAU` for no arcs. Arcs are read the short way round, as
2038/// [`angle_difference`] reports a sweep between two nearby samples.
2039fn largest_uncovered_turn(swept: &[(f64, f64)]) -> f64 {
2040    let tau = std::f64::consts::TAU;
2041    let mut arcs: Vec<(f64, f64)> = Vec::with_capacity(swept.len() + 1);
2042    for &(start, sweep) in swept {
2043        let (low, length) = if sweep < 0.0 { (start + sweep, -sweep) } else { (start, sweep) };
2044        let low = low.rem_euclid(tau);
2045        if low + length > tau {
2046            arcs.push((low, tau));
2047            arcs.push((0.0, low + length - tau));
2048        } else {
2049            arcs.push((low, low + length));
2050        }
2051    }
2052    if arcs.is_empty() {
2053        return tau;
2054    }
2055    arcs.sort_by(|a, b| a.0.total_cmp(&b.0));
2056    let mut largest = 0.0f64;
2057    let mut reach = arcs[0].1;
2058    for arc in &arcs[1..] {
2059        largest = largest.max(arc.0 - reach);
2060        reach = reach.max(arc.1);
2061    }
2062    // The wrap from the last reach round to the first arc's start.
2063    largest.max(arcs[0].0 + tau - reach)
2064}
2065
2066/// Signed angular distance from `from` to `to`, in `(-PI, PI]`.
2067fn angle_difference(from: f64, to: f64) -> f64 {
2068    let turn = (to - from).rem_euclid(std::f64::consts::TAU);
2069    if turn > std::f64::consts::PI {
2070        turn - std::f64::consts::TAU
2071    } else {
2072        turn
2073    }
2074}
2075
2076/// Merge two cosurface cylinder patches whose union wraps the FULL turn.
2077///
2078/// The partial-turn lane seams its carrier in the union's largest angular
2079/// gap; a union with no gap (the shelled cylinder's full outer wall against
2080/// the half-walls of a coaxial extrude, the E13 report of 2026-10-13) has no
2081/// such seam, and cancelling every shared edge there strands the full face's
2082/// seam strip at a fork the 3D walk cannot resolve. This lane builds a closed
2083/// revolution carrier seamed along an existing AXIAL edge that both faces
2084/// already traverse twice — one face's own seam pair, or a shared pair — keeps
2085/// the shared edges on that line as seam pairs instead of cancelling them,
2086/// places every seam-line trim on the side of the seam its traversal puts the
2087/// material on, and walks the leftover uses in the carrier's own parameter
2088/// space. Only a seam one of the faces ALREADY has is a candidate: a face
2089/// that wraps the turn by itself carries its own seam pair, and the merged
2090/// face keeps that seam. Two half-turn patches closing a ring between them
2091/// have no such seam; they were two faces before any boolean touched them
2092/// (a sketch circle's two arcs, a tube piece's two halves, a plate hole's
2093/// two bore segments whose names a flange picks) and they stay two faces
2094/// here — master's battery of 2026-10-13 read four regressions from merging
2095/// them. Each candidate is tried in turn, and the merged face must close
2096/// every loop in parameter space, keep every trim on its edge within the
2097/// validator's own pcurve acceptance, and reproduce the pieces'
2098/// parameter-space sign and their combined 3D area, or the candidate is
2099/// dropped; with no candidate left the pair stays as assembled.
2100///
2101/// What this lane does NOT do: two full rings seamed at different angles
2102/// (a shelled cylinder's bore wall and a coaxial extrude's hole wall whose
2103/// profile circle happened to start opposite) have no axial edge spanning
2104/// both, and merging them means re-seaming one through its far rim and the
2105/// cap on it — a solid-wide edit the face merge does not make. Such a pair
2106/// declines here with its loops open in parameter space and keeps its
2107/// circular joint.
2108fn merge_cylinder_full_turn(
2109    older: &FaceRecord,
2110    newer: &FaceRecord,
2111    edges: &HashMap<u64, &EdgeRecord>,
2112    tolerance: f64,
2113    support: &CylinderSupport,
2114    shared_edges: &HashSet<u64>,
2115    lanes: MergeLanes<'_>,
2116) -> Result<Option<FaceRecord>, KernelRefusal> {
2117    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
2118    // `BREP_FULL_TURN_MERGE=0` hatches the lane off, for attribution only.
2119    if std::env::var("BREP_FULL_TURN_MERGE").as_deref() == Ok("0") {
2120        return Ok(None);
2121    }
2122    // Every non-degenerate use of either face, tagged with which face it came
2123    // from (0 older, 1 newer) so an edge used twice by ONE face reads as its
2124    // own seam.
2125    let uses = [older, newer]
2126        .iter()
2127        .enumerate()
2128        .flat_map(|(side, face)| {
2129            face.loops
2130                .iter()
2131                .flat_map(|loop_record| &loop_record.coedges)
2132                .filter(|coedge| !edges[&coedge.edge_id].degenerate)
2133                .map(move |coedge| (side, coedge.clone()))
2134        })
2135        .collect::<Vec<_>>();
2136    let Some(x_reference) = uses.iter().find_map(|(_, coedge)| {
2137        let edge = edges[&coedge.edge_id];
2138        let seed = edge.curve.evaluate(edge.t0).ok()?;
2139        let delta = seed.sub(support.origin);
2140        let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
2141        radial.normalized().ok()
2142    }) else {
2143        return Ok(None);
2144    };
2145    let y_reference = support.axis.cross(x_reference);
2146    let scale = 1.0f64.max(support.origin.length()).max(support.radius);
2147    let band = (tolerance * 100.0).max(1e-7 * scale);
2148    // The smallest radius the patches reach bounds the angle `band` subtends
2149    // (a cylinder's one radius; a cone's radius at its narrow end, read once
2150    // the axial range is known below and checked positive there).
2151    let mut angular_band = band / support.radius;
2152
2153    // Each use's stations: axial coordinate and angle at nine samples, plus
2154    // the station of its start and end in traversal order.
2155    struct Station {
2156        axial: f64,
2157        angle: Option<f64>,
2158    }
2159    let station = |point: Vec3| Station {
2160        axial: point.sub(support.origin).dot(support.axis),
2161        angle: cylinder_angle(support, x_reference, y_reference, point),
2162    };
2163    let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
2164    let mut samples = Vec::with_capacity(uses.len());
2165    let mut bbox_lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2166    let mut bbox_hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2167    // The boundary length of both faces together: the most area a set of
2168    // trims admitted `band` off their edges can move, per unit of `band`.
2169    let mut perimeter = 0.0f64;
2170    for (_, coedge) in &uses {
2171        let edge = edges[&coedge.edge_id];
2172        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
2173        let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
2174        let hull = axial_hull(&curve, support);
2175        axial_range[0] = axial_range[0].min(hull[0]);
2176        axial_range[1] = axial_range[1].max(hull[1]);
2177        perimeter += crate::mass_properties::edge_arc_length(edge).unwrap_or(0.0);
2178        let mut stations = Vec::with_capacity(9);
2179        for index in 0..=8 {
2180            let point = curve
2181                .evaluate(c0 + (c1 - c0) * index as f64 / 8.0)
2182                .or_refuse(KernelStage::Sew, "evaluate")?;
2183            bbox_lo = Vec3::new(bbox_lo.x.min(point.x), bbox_lo.y.min(point.y), bbox_lo.z.min(point.z));
2184            bbox_hi = Vec3::new(bbox_hi.x.max(point.x), bbox_hi.y.max(point.y), bbox_hi.z.max(point.z));
2185            stations.push(station(point));
2186        }
2187        samples.push(stations);
2188    }
2189    if axial_range[1] - axial_range[0] <= tolerance {
2190        return Ok(None);
2191    }
2192    if support.slope != 0.0 {
2193        let narrow = support
2194            .radius_at(axial_range[0])
2195            .min(support.radius_at(axial_range[1]));
2196        if narrow <= band {
2197            return Ok(None);
2198        }
2199        angular_band = band / narrow;
2200    }
2201    // An edge is an axial line at angle `theta` when every sample reads that
2202    // angle: a straight generatrix of the shared cylinder.
2203    let axial_angle = |stations: &[Station]| -> Option<f64> {
2204        let first = stations.first()?.angle?;
2205        stations
2206            .iter()
2207            .all(|station| {
2208                station
2209                    .angle
2210                    .is_some_and(|angle| angle_difference(first, angle).abs() <= angular_band)
2211            })
2212            .then_some(first)
2213    };
2214    let mut use_count = HashMap::<u64, usize>::default();
2215    for (_, coedge) in &uses {
2216        *use_count.entry(coedge.edge_id).or_default() += 1;
2217    }
2218    // Candidate seam angles, ranked: a face's own seam pair (both uses from
2219    // one face) before a shared pair; a line used once bounds the region on
2220    // one side only and cannot seam a wrap by itself.
2221    let mut candidates: Vec<(u8, f64)> = Vec::new();
2222    // Candidate angles whose edge ends at a vertex `lanes.seam_vertices` names.
2223    let mut preferred: Vec<f64> = Vec::new();
2224    for (index, (side, coedge)) in uses.iter().enumerate() {
2225        if use_count.get(&coedge.edge_id).copied() != Some(2) {
2226            continue;
2227        }
2228        let Some(theta) = axial_angle(&samples[index]) else {
2229            continue;
2230        };
2231        let own_seam = uses
2232            .iter()
2233            .any(|(other_side, other)| {
2234                other.edge_id == coedge.edge_id
2235                    && other.id != coedge.id
2236                    && other_side == side
2237                    && other.forward != coedge.forward
2238            });
2239        let rank = if own_seam { 0 } else { 1 };
2240        if let Some(vertices) = lanes.seam_vertices {
2241            let edge = edges[&coedge.edge_id];
2242            if vertices.contains(&edge.start_vertex_id) || vertices.contains(&edge.end_vertex_id) {
2243                preferred.push(theta);
2244            }
2245        }
2246        if !candidates
2247            .iter()
2248            .any(|(_, known)| angle_difference(*known, theta).abs() <= angular_band)
2249        {
2250            candidates.push((rank, theta));
2251        } else if let Some(known) = candidates
2252            .iter_mut()
2253            .find(|(_, known)| angle_difference(*known, theta).abs() <= angular_band)
2254        {
2255            known.0 = known.0.min(rank);
2256        }
2257    }
2258    let is_preferred = |theta: f64| {
2259        preferred
2260            .iter()
2261            .any(|known| angle_difference(*known, theta).abs() <= angular_band)
2262    };
2263    // `false` sorts first: a preferred seam leads its rank. With no
2264    // preference every key is `true` and the order is the plain one.
2265    candidates.sort_by(|a, b| {
2266        a.0.cmp(&b.0)
2267            .then(is_preferred(b.1).cmp(&is_preferred(a.1)))
2268            .then(a.1.total_cmp(&b.1))
2269    });
2270    if debug {
2271        eprintln!(
2272            "merge {}x{}: full-turn lane, {} seam candidate(s) {:?}",
2273            older.id,
2274            newer.id,
2275            candidates.len(),
2276            candidates
2277        );
2278    }
2279    // A shared pair alone (rank 1) is two half patches closing a ring between
2280    // them: neither face wraps, and the merged face would carry a seam no
2281    // input face had. Those pairs stay as assembled (see the doc comment).
2282    // A caller that opted into `shared_seam_ring` is joining faces the user
2283    // named as one region; there the shared pair seams the ring.
2284    if !lanes.shared_seam_ring {
2285        candidates.retain(|(rank, _)| *rank == 0);
2286    }
2287    if candidates.is_empty() {
2288        if debug {
2289            eprintln!(
2290                "merge {}x{}: full-turn lane: no face wraps the turn by itself, the halves stay apart",
2291                older.id, newer.id
2292            );
2293        }
2294        return Ok(None);
2295    }
2296    let weld = crate::tolerance::assembler_weld(tolerance);
2297    // The validator's own edge/pcurve acceptance on this pair's extent (its
2298    // size-coupled ceiling floors at `pcurve_consistency`); the pair's
2299    // diagonal is at most the solid's, so nothing admitted here is refused
2300    // by the closing validation on that count.
2301    let pcurve_limit = crate::KernelTolerances::for_scale(scale, 1e-7)
2302        .pcurve_acceptance(bbox_hi.sub(bbox_lo).length());
2303    let expected_area = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
2304        + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
2305    let expected_term = face_volume_contribution(older)
2306        .or_refuse(KernelStage::Sew, "face_volume_contribution")?
2307        + face_volume_contribution(newer).or_refuse(KernelStage::Sew, "face_volume_contribution")?;
2308
2309    'candidate: for (_, theta) in candidates {
2310        let on_seam = |index: usize| {
2311            axial_angle(&samples[index])
2312                .is_some_and(|angle| angle_difference(theta, angle).abs() <= angular_band)
2313        };
2314        // Closed carrier seamed at `theta`, spanning both patches axially.
2315        let start_direction = x_reference
2316            .scale(theta.cos())
2317            .add(y_reference.scale(theta.sin()));
2318        let generatrix = make_line(
2319            support
2320                .origin
2321                .add(support.axis.scale(axial_range[0]))
2322                .add(start_direction.scale(support.radius_at(axial_range[0]))),
2323            support
2324                .origin
2325                .add(support.axis.scale(axial_range[1]))
2326                .add(start_direction.scale(support.radius_at(axial_range[1]))),
2327        )
2328        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
2329        let surface = make_revolution(
2330            support.origin,
2331            support.axis,
2332            &generatrix,
2333            std::f64::consts::TAU,
2334        )
2335        .or_refuse(KernelStage::Sew, "make_revolution")?;
2336        let (u_domain, v_domain) = surface_domains(&surface)?;
2337        let (u_mid, v_mid) = (
2338            (u_domain[0] + u_domain[1]) / 2.0,
2339            (v_domain[0] + v_domain[1]) / 2.0,
2340        );
2341        let sample = surface
2342            .evaluate(u_mid, v_mid)
2343            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
2344        let geometric_normal = surface
2345            .normal(u_mid, v_mid)
2346            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
2347        let delta = sample.sub(support.origin);
2348        let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
2349        let Ok(radial) = radial.normalized() else {
2350            continue;
2351        };
2352        let same_sense = geometric_normal.dot(radial).signum() == support.outward_radial_sign;
2353        let v_of = |axial: f64| {
2354            v_domain[0]
2355                + (axial - axial_range[0]) / (axial_range[1] - axial_range[0])
2356                    * (v_domain[1] - v_domain[0])
2357        };
2358
2359        // Leftover uses: a shared edge off the seam line is cancelled; one on
2360        // it stays, both ways, as the merged face's seam.
2361        let mut remaining = Vec::new();
2362        for (index, (_, coedge)) in uses.iter().enumerate() {
2363            let seam_line = on_seam(index);
2364            if shared_edges.contains(&coedge.edge_id) && !seam_line {
2365                continue;
2366            }
2367            let edge = edges[&coedge.edge_id];
2368            let mut coedge = coedge.clone();
2369            let mut curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
2370            if !coedge.forward {
2371                curve = curve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
2372            }
2373            if seam_line {
2374                // The material lies to the LEFT of a same-sense loop's
2375                // traversal in uv (parameter-space area positive) and to the
2376                // right of an opposite-sense one. A seam-line trim running
2377                // +v with material on its left is the domain's far side
2378                // (u = u1); running -v, its near side (u = u0).
2379                let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
2380                let start = curve.evaluate(c0).or_refuse(KernelStage::Sew, "evaluate")?;
2381                let end = curve.evaluate(c1).or_refuse(KernelStage::Sew, "evaluate")?;
2382                let rising = end.sub(start).dot(support.axis) > 0.0;
2383                let far_side = rising == same_sense;
2384                let u = if far_side { u_domain[1] } else { u_domain[0] };
2385                // The trim carries the edge's OWN parameterisation: the
2386                // axial coordinate is an affine functional of position, so
2387                // mapping each control point (dehomogenised) and keeping the
2388                // degree, knots and weights reproduces v(t) exactly. A
2389                // two-point line assumed the edge ran its axis at constant
2390                // speed, which a spline tube's straight Hermite piece does
2391                // not (max deviation 0.5 at the validator, 2026-10-13).
2392                let origin_axial = support.origin.dot(support.axis);
2393                let control_points = curve
2394                    .control_points
2395                    .iter()
2396                    .map(|point| {
2397                        let w = point.w;
2398                        let axial = Vec3::new(point.x / w, point.y / w, point.z / w)
2399                            .dot(support.axis)
2400                            - origin_axial;
2401                        Vec4 { x: u * w, y: v_of(axial) * w, z: 0.0, w }
2402                    })
2403                    .collect();
2404                coedge.pcurve = NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
2405                    .or_refuse(KernelStage::Sew, "NurbsCurve::new")?;
2406            } else {
2407                // The kernel's fit, with its report read: a station standing
2408                // off the carrier (`off_surface`) is an edge this carrier
2409                // does not hold — the projector folds such a station onto
2410                // the carrier's boundary and the face becomes the wrong
2411                // region on a body `validate()` passes — and a residual over
2412                // the band is a trim that misses its edge between stations.
2413                let fit = match crate::fit_pcurve_on_surface(&surface, &curve) {
2414                    Ok(fit) => fit,
2415                    Err(_) => continue 'candidate,
2416                };
2417                if fit.report.off_surface > band || fit.report.residual > band {
2418                    if debug {
2419                        eprintln!(
2420                            "merge {}x{}: full-turn seam {theta:.4}: edge {} stands {:.3e} off the carrier, fit residual {:.3e} (band {band:.3e})",
2421                            older.id, newer.id, coedge.edge_id, fit.report.off_surface, fit.report.residual
2422                        );
2423                    }
2424                    continue 'candidate;
2425                }
2426                coedge.pcurve = fit.curve;
2427            }
2428            remaining.push(coedge);
2429        }
2430        let space = SharedParameterSpace { surface: &surface, weld };
2431        dissolve_slits(&mut remaining, edges, &space);
2432        if debug {
2433            for coedge in &remaining {
2434                let stations = pcurve_stations(coedge)
2435                    .map(|[a, m, b]| format!("({:.4},{:.4})->({:.4},{:.4})->({:.4},{:.4})", a.x, a.y, m.x, m.y, b.x, b.y))
2436                    .unwrap_or_default();
2437                eprintln!(
2438                    "  seam {theta:.4} use coedge {} edge {} fwd={} shared={} same_sense={same_sense} uv {stations}",
2439                    coedge.id,
2440                    coedge.edge_id,
2441                    coedge.forward,
2442                    shared_edges.contains(&coedge.edge_id)
2443                );
2444            }
2445        }
2446        let Some(mut loops) = walk_cycles(remaining, edges, Some(&space)) else {
2447            if debug {
2448                eprintln!(
2449                    "merge {}x{}: full-turn seam {theta:.4}: walk undetermined",
2450                    older.id, newer.id
2451                );
2452            }
2453            continue;
2454        };
2455        if loops.is_empty() {
2456            continue;
2457        }
2458        // Every loop must CLOSE in the carrier's parameter space: the walk
2459        // closes a cycle on vertex identity when nothing else leaves its
2460        // end, which on a seamed carrier accepts a full circle running one
2461        // period from its own vertex, or a ring whose own seam pair (off the
2462        // chosen seam) just dissolved as a slit. The area oracles below
2463        // integrate per pcurve and cannot see the grouping.
2464        let closed = loops.iter().all(|loop_record| {
2465            let count = loop_record.coedges.len();
2466            (0..count).all(|index| {
2467                let next = &loop_record.coedges[(index + 1) % count];
2468                match (
2469                    pcurve_stations(&loop_record.coedges[index]),
2470                    pcurve_stations(next),
2471                ) {
2472                    (Some(here), Some(there)) => space.coincide(here[2], there[0]),
2473                    _ => false,
2474                }
2475            })
2476        });
2477        if !closed {
2478            if debug {
2479                eprintln!(
2480                    "merge {}x{}: full-turn seam {theta:.4}: a loop is open in parameter space",
2481                    older.id, newer.id
2482                );
2483            }
2484            continue;
2485        }
2486        if debug {
2487            for loop_record in &loops {
2488                eprintln!(
2489                    "  seam {theta:.4} loop {:?}",
2490                    loop_record.coedges.iter().map(|c| (c.edge_id, c.forward)).collect::<Vec<_>>()
2491                );
2492            }
2493        }
2494        loops.sort_by(|first, second| {
2495            let area = |loop_record: &LoopRecord| {
2496                parameter_space_area(&FaceRecord {
2497                    id: older.id,
2498                    surface: surface.clone(),
2499                    same_sense,
2500                    loops: vec![loop_record.clone()],
2501                    name: None,
2502                })
2503                .map(f64::abs)
2504            };
2505            match (area(first), area(second)) {
2506                (Ok(a), Ok(b)) => b.total_cmp(&a),
2507                _ => std::cmp::Ordering::Equal,
2508            }
2509        });
2510        let merged = FaceRecord {
2511            id: older.id,
2512            surface,
2513            same_sense,
2514            loops,
2515            name: older.name.clone(),
2516        };
2517        // Every trim must lie on its edge by the validator's own reading;
2518        // a candidate that fails here falls through and the pair stays,
2519        // instead of the refusal surfacing from the closing validation as a
2520        // feature failure (the spline tube, 2026-10-13).
2521        let mut worst_trim = 0.0f64;
2522        for coedge in merged.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
2523            let edge = edges[&coedge.edge_id];
2524            let error = crate::topology::coedge_error_floored(
2525                &merged.surface,
2526                &coedge.pcurve,
2527                &edge.curve,
2528                edge,
2529                coedge.forward,
2530                pcurve_limit,
2531            )
2532            .unwrap_or(f64::INFINITY);
2533            worst_trim = worst_trim.max(error);
2534        }
2535        if worst_trim > pcurve_limit {
2536            if debug {
2537                eprintln!(
2538                    "merge {}x{}: full-turn seam {theta:.4}: a trim is {worst_trim:.6} off its edge (limit {pcurve_limit:.6})",
2539                    older.id, newer.id
2540                );
2541            }
2542            continue;
2543        }
2544        let area =
2545            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
2546        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
2547            if debug {
2548                eprintln!(
2549                    "merge {}x{}: full-turn seam {theta:.4}: parameter area {area:.3e} vs same_sense {same_sense}",
2550                    older.id, newer.id
2551                );
2552            }
2553            continue;
2554        }
2555        // The merged face must reproduce the pieces' combined 3D area and
2556        // their combined volume term about the origin. The bar is what the
2557        // admitted trims can move: `band` off their edges along the whole
2558        // boundary (and that much times the farthest point for the volume
2559        // term), plus the quadrature's drift. A 5e-3 share of the area let
2560        // a 0.24 sliver through (BadBoolean, 2026-10-13); the volume term
2561        // sees a region of the right area in the wrong place, which the
2562        // area cannot.
2563        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
2564        let area_bar = band * perimeter + 1e-9 * expected_area.abs();
2565        if (actual - expected_area).abs() > area_bar {
2566            if debug {
2567                eprintln!(
2568                    "merge {}x{}: full-turn seam {theta:.4}: area {actual:.9} vs expected {expected_area:.9} (bar {area_bar:.3e})",
2569                    older.id, newer.id
2570                );
2571            }
2572            continue;
2573        }
2574        let actual_term =
2575            face_volume_contribution(&merged).or_refuse(KernelStage::Sew, "face_volume_contribution")?;
2576        let reach = bbox_lo.length().max(bbox_hi.length());
2577        let term_bar = band * perimeter * reach / 3.0 + 1e-9 * expected_term.abs().max(expected_area.abs());
2578        if (actual_term - expected_term).abs() > term_bar {
2579            if debug {
2580                eprintln!(
2581                    "merge {}x{}: full-turn seam {theta:.4}: volume term {actual_term:.9} vs expected {expected_term:.9} (bar {term_bar:.3e})",
2582                    older.id, newer.id
2583                );
2584            }
2585            continue;
2586        }
2587        if debug {
2588            eprintln!(
2589                "merge {}x{}: full-turn MERGE at seam {theta:.4}, {} loop(s); area {actual:.9} vs {expected_area:.9} (bar {area_bar:.3e}), volume term {actual_term:.9} vs {expected_term:.9} (bar {term_bar:.3e})",
2590                older.id,
2591                newer.id,
2592                merged.loops.len()
2593            );
2594        }
2595        return Ok(Some(merged));
2596    }
2597    Ok(None)
2598}
2599
2600
2601fn try_merge_connected_group(
2602    faces: &[FaceRecord],
2603    edges: &HashMap<u64, &EdgeRecord>,
2604    tolerance: f64,
2605    same_carrier_area_tolerance_ratio: f64,
2606    keep_unmerged: &[String],
2607) -> Result<Option<(Vec<usize>, FaceRecord)>, KernelRefusal> {
2608    for seed in 0..faces.len() {
2609        // A pinned face never seeds or joins a merge group.
2610        if face_is_pinned(&faces[seed], keep_unmerged) {
2611            continue;
2612        }
2613        let mut component = [seed].into_iter().collect::<HashSet<_>>();
2614        loop {
2615            let mut changed = false;
2616            for candidate in 0..faces.len() {
2617                if component.contains(&candidate)
2618                    || face_is_pinned(&faces[candidate], keep_unmerged)
2619                    || faces[candidate].same_sense != faces[seed].same_sense
2620                    || !same_surface(&faces[candidate].surface, &faces[seed].surface, 1e-7)
2621                {
2622                    continue;
2623                }
2624                let candidate_uses = faces[candidate]
2625                    .loops
2626                    .iter()
2627                    .flat_map(|loop_record| &loop_record.coedges);
2628                let adjacent = candidate_uses.into_iter().any(|candidate_use| {
2629                    !edges[&candidate_use.edge_id].degenerate
2630                        && component.iter().any(|member| {
2631                            faces[*member]
2632                                .loops
2633                                .iter()
2634                                .flat_map(|loop_record| &loop_record.coedges)
2635                                .any(|member_use| {
2636                                    member_use.edge_id == candidate_use.edge_id
2637                                        && member_use.forward != candidate_use.forward
2638                                })
2639                        })
2640                });
2641                if adjacent {
2642                    component.insert(candidate);
2643                    changed = true;
2644                }
2645            }
2646            if !changed {
2647                break;
2648            }
2649        }
2650        if component.len() < 2 {
2651            continue;
2652        }
2653
2654        let mut uses_by_edge = HashMap::<u64, Vec<CoedgeRecord>>::default();
2655        for index in &component {
2656            for coedge in faces[*index]
2657                .loops
2658                .iter()
2659                .flat_map(|loop_record| &loop_record.coedges)
2660            {
2661                uses_by_edge
2662                    .entry(coedge.edge_id)
2663                    .or_default()
2664                    .push(coedge.clone());
2665            }
2666        }
2667        if uses_by_edge.values().any(|uses| uses.len() > 2) {
2668            continue;
2669        }
2670        let internal = uses_by_edge
2671            .iter()
2672            .filter_map(|(edge_id, uses)| {
2673                (uses.len() == 2
2674                    && !edges[edge_id].degenerate
2675                    && uses[0].forward != uses[1].forward)
2676                    .then_some(*edge_id)
2677            })
2678            .collect::<HashSet<_>>();
2679        let remaining = uses_by_edge
2680            .into_iter()
2681            .filter(|(edge_id, _)| !internal.contains(edge_id))
2682            .flat_map(|(_, uses)| uses)
2683            .collect::<Vec<_>>();
2684        let space = SharedParameterSpace {
2685            surface: &faces[seed].surface,
2686            weld: crate::tolerance::assembler_weld(tolerance),
2687        };
2688        let Some(mut loops) = walk_cycles(remaining, edges, Some(&space)) else {
2689            continue;
2690        };
2691        if loops.is_empty() {
2692            continue;
2693        }
2694        loops.sort_by(|first, second| {
2695            let area = |loop_record: &LoopRecord| {
2696                parameter_space_area(&FaceRecord {
2697                    id: faces[seed].id,
2698                    surface: faces[seed].surface.clone(),
2699                    same_sense: faces[seed].same_sense,
2700                    loops: vec![loop_record.clone()],
2701                    name: None,
2702                })
2703                .map(f64::abs)
2704            };
2705            match (area(first), area(second)) {
2706                (Ok(a), Ok(b)) => b.total_cmp(&a),
2707                _ => std::cmp::Ordering::Equal,
2708            }
2709        });
2710        let merged = FaceRecord {
2711            id: faces[seed].id,
2712            surface: faces[seed].surface.clone(),
2713            same_sense: faces[seed].same_sense,
2714            loops,
2715            name: faces[seed].name.clone(),
2716        };
2717        let expected = component
2718            .iter()
2719            .map(|index| parameter_space_area(&faces[*index]))
2720            .collect::<Result<Vec<_>, _>>()
2721            .or_refuse(KernelStage::Sew, "healing.face_merge")?
2722            .into_iter()
2723            .sum::<f64>();
2724        let actual =
2725            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
2726        let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
2727        if (actual - expected).abs() > area_tolerance
2728            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
2729        {
2730            continue;
2731        }
2732        let mut indices = component.into_iter().collect::<Vec<_>>();
2733        indices.sort_unstable();
2734        return Ok(Some((indices, merged)));
2735    }
2736    Ok(None)
2737}
2738
2739/// A face is PINNED out of the merge when its `name` contains any substring in
2740/// `keep_unmerged`: it never coalesces with a neighbour (in any merge lane) and
2741/// is emitted unchanged. An empty `keep_unmerged` pins nothing — the historical
2742/// behavior.
2743fn face_is_pinned(face: &FaceRecord, keep_unmerged: &[String]) -> bool {
2744    match &face.name {
2745        Some(name) => keep_unmerged
2746            .iter()
2747            .any(|needle| name.contains(needle.as_str())),
2748        None => false,
2749    }
2750}
2751
2752/// Pairwise merge driver for one shell.
2753///
2754/// `try_merge_pair` returns `None` for any two faces that share no
2755/// non-degenerate edge, so the only candidates are edge-adjacent pairs; they
2756/// are kept in a set ordered by the faces' original positions and evaluated
2757/// smallest first. Faces stay in their original slots (a merged face keeps
2758/// the lower slot, the absorbed face's slot is emptied), so that order never
2759/// shifts. A rejected pair is dropped for good: `try_merge_pair` is a pure
2760/// function of the two face records and the constant edge map, and a pair is
2761/// re-queued only when one of its faces changes. Hence each accepted merge is
2762/// the lexicographically first accepting pair over all faces — the same pair
2763/// the former full rescan found — and the result is the same face set, in
2764/// the same order, reached without re-examining every pair per merge.
2765struct PairMerger<'a> {
2766    slots: Vec<Option<FaceRecord>>,
2767    pinned: Vec<bool>,
2768    edges: &'a HashMap<u64, &'a EdgeRecord>,
2769    /// Non-degenerate edge id → slots of the faces using it.
2770    uses: HashMap<u64, Vec<usize>>,
2771    /// Edge-adjacent pairs `(lower slot, higher slot)` not yet rejected.
2772    candidates: std::collections::BTreeSet<(usize, usize)>,
2773    /// Ids of the faces this merger built (the surviving id of each pair).
2774    merged_ids: Vec<u64>,
2775}
2776
2777impl<'a> PairMerger<'a> {
2778    fn new(
2779        faces: Vec<FaceRecord>,
2780        edges: &'a HashMap<u64, &'a EdgeRecord>,
2781        keep_unmerged: &[String],
2782    ) -> Self {
2783        let pinned = faces
2784            .iter()
2785            .map(|face| face_is_pinned(face, keep_unmerged))
2786            .collect::<Vec<_>>();
2787        let mut merger = Self {
2788            slots: faces.into_iter().map(Some).collect(),
2789            pinned,
2790            edges,
2791            uses: HashMap::default(),
2792            candidates: std::collections::BTreeSet::new(),
2793            merged_ids: Vec::new(),
2794        };
2795        for slot in 0..merger.slots.len() {
2796            merger.index_slot(slot);
2797        }
2798        merger
2799    }
2800
2801    fn slot_edge_ids(&self, slot: usize) -> Vec<u64> {
2802        let Some(face) = &self.slots[slot] else {
2803            return Vec::new();
2804        };
2805        let mut ids = face
2806            .loops
2807            .iter()
2808            .flat_map(|loop_record| &loop_record.coedges)
2809            .map(|coedge| coedge.edge_id)
2810            .filter(|edge_id| !self.edges[edge_id].degenerate)
2811            .collect::<Vec<_>>();
2812        ids.sort_unstable();
2813        ids.dedup();
2814        ids
2815    }
2816
2817    /// Register `slot`'s edges and queue it against every face already
2818    /// using one of them.
2819    fn index_slot(&mut self, slot: usize) {
2820        for edge_id in self.slot_edge_ids(slot) {
2821            let users = self.uses.entry(edge_id).or_default();
2822            for &other in users.iter() {
2823                if other != slot && !self.pinned[other] && !self.pinned[slot] {
2824                    self.candidates.insert((other.min(slot), other.max(slot)));
2825                }
2826            }
2827            users.push(slot);
2828        }
2829    }
2830
2831    fn unindex_slot(&mut self, slot: usize) {
2832        for edge_id in self.slot_edge_ids(slot) {
2833            if let Some(users) = self.uses.get_mut(&edge_id) {
2834                users.retain(|user| *user != slot);
2835            }
2836        }
2837        self.candidates
2838            .retain(|(lower, higher)| *lower != slot && *higher != slot);
2839    }
2840
2841    /// Perform up to `budget` merges; returns how many were made.
2842    fn run(
2843        &mut self,
2844        budget: usize,
2845        tolerance: f64,
2846        same_carrier_area_tolerance_ratio: f64,
2847    ) -> Result<usize, KernelRefusal> {
2848        let mut merged_count = 0;
2849        while merged_count < budget {
2850            let Some(&(lower, higher)) = self.candidates.iter().next() else {
2851                break;
2852            };
2853            self.candidates.remove(&(lower, higher));
2854            let (Some(older), Some(newer)) = (&self.slots[lower], &self.slots[higher]) else {
2855                continue;
2856            };
2857            let Some(merged) = try_merge_pair(
2858                older,
2859                newer,
2860                self.edges,
2861                tolerance,
2862                same_carrier_area_tolerance_ratio,
2863            )?
2864            else {
2865                continue;
2866            };
2867            self.unindex_slot(higher);
2868            self.unindex_slot(lower);
2869            self.slots[higher] = None;
2870            self.merged_ids.push(merged.id);
2871            self.slots[lower] = Some(merged);
2872            self.index_slot(lower);
2873            merged_count += 1;
2874        }
2875        Ok(merged_count)
2876    }
2877
2878    fn into_faces(self) -> Vec<FaceRecord> {
2879        self.slots.into_iter().flatten().collect()
2880    }
2881}
2882
2883/// Merge adjacent fragments that retain the exact same carrier surface.
2884///
2885/// Every shared edge is cancelled in opposite directions and the remaining
2886/// coedges are stitched into outer/hole cycles without refitting geometry.
2887fn merge_same_surface_faces_impl(
2888    solid: &BrepSolid,
2889    tolerance: f64,
2890    validate: bool,
2891    same_carrier_area_tolerance_ratio: f64,
2892    keep_unmerged: &[String],
2893    validation_scope: Option<&HashSet<u64>>,
2894) -> Result<BrepSolid, KernelRefusal> {
2895    let mut result = solid.clone();
2896    let mut merged_ids: Vec<u64> = Vec::new();
2897    // `result.edges` is not touched until the retain below, so the id map is
2898    // built once; every candidate pair sees the same edge records.
2899    let edges = result
2900        .edges
2901        .iter()
2902        .map(|edge| (edge.id, edge))
2903        .collect::<HashMap<_, _>>();
2904    for shell in &mut result.shells {
2905        loop {
2906            if !validate {
2907                if let Some((indices, merged)) = try_merge_connected_group(
2908                    &shell.faces,
2909                    &edges,
2910                    tolerance,
2911                    same_carrier_area_tolerance_ratio,
2912                    keep_unmerged,
2913                )? {
2914                    let first = indices[0];
2915                    shell.faces[first] = merged;
2916                    for index in indices.into_iter().skip(1).rev() {
2917                        shell.faces.remove(index);
2918                    }
2919                    continue;
2920                }
2921            }
2922            // The validating lane has no group phase, so it drains every pair
2923            // merge in one pass; the open lane merges one pair, then offers
2924            // the changed shell to the group phase again, exactly as before.
2925            let budget = if validate { usize::MAX } else { 1 };
2926            let mut merger = PairMerger::new(
2927                std::mem::take(&mut shell.faces),
2928                &edges,
2929                keep_unmerged,
2930            );
2931            let merged = merger.run(budget, tolerance, same_carrier_area_tolerance_ratio);
2932            merged_ids.extend(merger.merged_ids.iter().copied());
2933            shell.faces = merger.into_faces();
2934            if merged? == 0 || validate {
2935                break;
2936            }
2937        }
2938    }
2939    // The validation reads the geometry of the caller's scope and of every
2940    // face this merge built; a face outside both left this pass as it came.
2941    let validation_scope = validation_scope.map(|scope| {
2942        let mut scope = scope.clone();
2943        scope.extend(merged_ids);
2944        scope
2945    });
2946    finish_merge(solid, result, validate, validation_scope.as_ref())
2947}
2948
2949/// Merge each face in `absorbed` into an edge-adjacent face outside `absorbed`
2950/// that rides the same carrier — the pair [`merge_same_surface_faces`] would
2951/// merge, at the same `tolerance` — and nothing else.
2952///
2953/// The absorbing face keeps its id, its name and its slot in the shell, and
2954/// its carrier is the frame the merged face is built on; a face in `absorbed`
2955/// that shares a carrier with no such neighbour is left as it was. Two faces
2956/// outside `absorbed` are never merged with each other, however cosurface: the
2957/// caller is naming whose material a face is, not normalizing the solid, so a
2958/// coplanar pair the document kept apart stays apart. The result passes the
2959/// validating merge's Euler and `validate()` checks or the call refuses.
2960pub(crate) fn absorb_faces_into_carriers(
2961    solid: &BrepSolid,
2962    tolerance: f64,
2963    absorbed: &HashSet<u64>,
2964) -> Result<BrepSolid, KernelRefusal> {
2965    let mut result = solid.clone();
2966    let edges = result
2967        .edges
2968        .iter()
2969        .map(|edge| (edge.id, edge))
2970        .collect::<HashMap<_, _>>();
2971    let edge_ids = |face: &FaceRecord| -> HashSet<u64> {
2972        face.loops
2973            .iter()
2974            .flat_map(|loop_record| &loop_record.coedges)
2975            .map(|coedge| coedge.edge_id)
2976            .filter(|edge_id| !edges[edge_id].degenerate)
2977            .collect()
2978    };
2979    for shell in &mut result.shells {
2980        'merge: loop {
2981            for inner in 0..shell.faces.len() {
2982                if !absorbed.contains(&shell.faces[inner].id) {
2983                    continue;
2984                }
2985                let inner_edges = edge_ids(&shell.faces[inner]);
2986                for outer in 0..shell.faces.len() {
2987                    if outer == inner
2988                        || absorbed.contains(&shell.faces[outer].id)
2989                        || edge_ids(&shell.faces[outer]).is_disjoint(&inner_edges)
2990                    {
2991                        continue;
2992                    }
2993                    let Some(merged) =
2994                        try_merge_pair(&shell.faces[outer], &shell.faces[inner], &edges, tolerance, 1e-7)?
2995                    else {
2996                        continue;
2997                    };
2998                    shell.faces[outer] = merged;
2999                    shell.faces.remove(inner);
3000                    continue 'merge;
3001                }
3002            }
3003            break;
3004        }
3005    }
3006    finish_merge(solid, result, true, None)
3007}
3008
3009/// Rebuild the cone face `id` on one carrier holding all its trims, every trim
3010/// re-read from its 3D edge, with its apex pole stitched back. A blend end grows
3011/// such a face past its old chart. False when it is not a ruled cone patch or the
3012/// rebuilt face does not close.
3013pub(crate) fn recarry_cone_apex_face(solid: &mut BrepSolid, id: u64, tolerance: f64) -> Result<bool, KernelRefusal> {
3014    let Some(face) = solid.shells.iter().flat_map(|shell| &shell.faces).find(|face| face.id == id).cloned() else {
3015        return Ok(false);
3016    };
3017    let Some(support) = ruled_cylinder_support(&face, tolerance, true, true)? else {
3018        return Ok(false);
3019    };
3020    let edges = solid.edges.iter().map(|edge| (edge.id, edge)).collect::<HashMap<_, _>>();
3021    let mut apex = None;
3022    let mut loops = face.loops.clone();
3023    for loop_record in &mut loops {
3024        loop_record.coedges.retain(|coedge| {
3025            let pole = edges[&coedge.edge_id].degenerate;
3026            if pole && apex.is_none() {
3027                apex = Some(coedge.clone());
3028            }
3029            !pole
3030        });
3031    }
3032    let seed = {
3033        let Some(point) = loops.iter().flat_map(|l| &l.coedges).find_map(|coedge| {
3034            let edge = edges[&coedge.edge_id];
3035            let [a, b] = [edge.curve.evaluate(edge.t0).ok()?, edge.curve.evaluate(edge.t1).ok()?];
3036            let radial = |p: Vec3| { let d = p.sub(support.origin); d.sub(support.axis.scale(d.dot(support.axis))) };
3037            if radial(a).length() > radial(b).length() { Some(radial(a)) } else { Some(radial(b)) }
3038        }) else {
3039            return Ok(false);
3040        };
3041        point.normalized().or_refuse(KernelStage::Sew, "normalized")?
3042    };
3043    let y_reference = support.axis.cross(seed);
3044    let mut angles = Vec::new();
3045    let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
3046    for coedge in loops.iter().flat_map(|l| &l.coedges) {
3047        let edge = edges[&coedge.edge_id];
3048        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
3049        let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
3050        let hull = axial_hull(&curve, &support);
3051        axial_range[0] = axial_range[0].min(hull[0]);
3052        axial_range[1] = axial_range[1].max(hull[1]);
3053        for index in 0..=32 {
3054            let delta = curve.evaluate(c0 + (c1 - c0) * index as f64 / 32.0).or_refuse(KernelStage::Sew, "evaluate")?.sub(support.origin);
3055            let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
3056            if radial.length() > (support.radius * 1e-6).max(tolerance) {
3057                angles.push(radial.dot(y_reference).atan2(radial.dot(seed)).rem_euclid(std::f64::consts::TAU));
3058            }
3059        }
3060    }
3061    if angles.len() < 3 {
3062        return Ok(false);
3063    }
3064    angles.sort_by(f64::total_cmp);
3065    let (mut gap_start, mut largest) = (angles.len() - 1, angles[0] + std::f64::consts::TAU - angles[angles.len() - 1]);
3066    for index in 0..angles.len() - 1 {
3067        if angles[index + 1] - angles[index] > largest {
3068            largest = angles[index + 1] - angles[index];
3069            gap_start = index;
3070        }
3071    }
3072    let span = std::f64::consts::TAU - largest;
3073    if span <= tolerance || span >= std::f64::consts::TAU - 1e-6 {
3074        return Ok(false);
3075    }
3076    let start = angles[(gap_start + 1) % angles.len()];
3077    let direction = seed.scale(start.cos()).add(y_reference.scale(start.sin()));
3078    let end_radius = |axial: f64| { let radius = support.radius_at(axial); if radius.abs() <= tolerance { 0.0 } else { radius } };
3079    let at = |axial: f64| support.origin.add(support.axis.scale(axial)).add(direction.scale(end_radius(axial)));
3080    let generatrix = make_line(at(axial_range[0]), at(axial_range[1])).or_refuse(KernelStage::Sew, "healing.face_merge")?;
3081    let surface = make_revolution(support.origin, support.axis, &generatrix, span).or_refuse(KernelStage::Sew, "make_revolution")?;
3082    let (u_domain, v_domain) = surface_domains(&surface)?;
3083    let (mid_u, mid_v) = ((u_domain[0] + u_domain[1]) / 2.0, (v_domain[0] + v_domain[1]) / 2.0);
3084    let delta = surface.evaluate(mid_u, mid_v).or_refuse(KernelStage::Sew, "evaluate")?.sub(support.origin);
3085    let radial = delta.sub(support.axis.scale(delta.dot(support.axis))).normalized().or_refuse(KernelStage::Sew, "normalized")?;
3086    let same_sense = surface.normal(mid_u, mid_v).or_refuse(KernelStage::Sew, "normal")?.dot(radial).signum() == support.outward_radial_sign;
3087    let mut projected = reproject_loops(&surface, loops, &edges)?;
3088    if let Some(apex) = &apex {
3089        if !stitch_cone_apex(&surface, &mut projected, apex, &edges)? {
3090            return Ok(false);
3091        }
3092    }
3093    let rebuilt = FaceRecord { id, surface, same_sense, loops: projected, name: face.name.clone() };
3094    let area = parameter_space_area(&rebuilt).or_refuse(KernelStage::Sew, "parameter_space_area")?;
3095    if area.abs() <= tolerance * tolerance || area.is_sign_positive() != rebuilt.same_sense {
3096        return Ok(false);
3097    }
3098    drop(edges);
3099    for shell in &mut solid.shells {
3100        if let Some(slot) = shell.faces.iter_mut().find(|face| face.id == id) {
3101            *slot = rebuilt;
3102            return Ok(true);
3103        }
3104    }
3105    Ok(false)
3106}
3107
3108/// Merge edge-adjacent faces of `group` that ride the same carrier — the
3109/// pairs [`merge_same_surface_faces`] would merge, at the same `tolerance`,
3110/// plus two lanes only a user's selection opts into: cone patches, and two
3111/// half-turn patches closing a ring on a shared axial edge (see
3112/// `MergeLanes`) — and nothing else: a face outside `group` is never touched,
3113/// however cosurface with a neighbour.
3114///
3115/// Cancelling a shared edge leaves its end vertices between two pieces of one
3116/// rim (the two half circles a split bore's halves each bounded); those
3117/// pieces are joined into one edge where they continue each other exactly
3118/// (`merge_curve_continuation_edges_at`, at those vertices only), so the merged
3119/// face carries the closed rims a face built whole carries.
3120///
3121/// The surviving face of each merged pair is the one in the lower shell slot;
3122/// it keeps its id and name. Returns the merged solid and, for every face id
3123/// of `group` (sorted, deduplicated), the pair `(face, carrier)` naming the
3124/// face that now carries it (itself when it did not merge). The result passes
3125/// the validating merge's Euler and `validate()` checks, scoped to the merged
3126/// faces, or the call refuses.
3127///
3128/// Push Face uses it to treat the halves of a split carrier the user selected
3129/// together (an imported bore written as two half cylinders) as the one face
3130/// the push moves: a half pushed alone can never meet its unmoved sibling.
3131pub fn merge_faces_within(
3132    solid: &BrepSolid,
3133    tolerance: f64,
3134    group: &[u64],
3135) -> Result<(BrepSolid, Vec<(u64, u64)>), KernelRefusal> {
3136    let group: HashSet<u64> = group.iter().copied().collect();
3137    let mut carried_by: Vec<(u64, u64)> = group.iter().map(|id| (*id, *id)).collect();
3138    carried_by.sort_unstable();
3139    let mut result = solid.clone();
3140    let edges = result
3141        .edges
3142        .iter()
3143        .map(|edge| (edge.id, edge))
3144        .collect::<HashMap<_, _>>();
3145    let edge_ids = |face: &FaceRecord| -> HashSet<u64> {
3146        face.loops
3147            .iter()
3148            .flat_map(|loop_record| &loop_record.coedges)
3149            .map(|coedge| coedge.edge_id)
3150            .filter(|edge_id| !edges[edge_id].degenerate)
3151            .collect()
3152    };
3153    let mut merged_ids: HashSet<u64> = HashSet::default();
3154    let mut cancelled_edges: HashSet<u64> = HashSet::default();
3155    // End vertices of the shared edges merged faces kept as their seams.
3156    let mut seam_vertices: HashSet<u64> = HashSet::default();
3157    for shell in &mut result.shells {
3158        'merge: loop {
3159            // Only the group's own slots are read: a 1 200-face import pays
3160            // for its four selected faces, not for every pair of the shell.
3161            let slots: Vec<usize> = (0..shell.faces.len())
3162                .filter(|slot| group.contains(&shell.faces[*slot].id))
3163                .collect();
3164            for (index, &lower) in slots.iter().enumerate() {
3165                let lower_edges = edge_ids(&shell.faces[lower]);
3166                for &higher in &slots[index + 1..] {
3167                    let higher_edges = edge_ids(&shell.faces[higher]);
3168                    if higher_edges.is_disjoint(&lower_edges) {
3169                        continue;
3170                    }
3171                    let lanes = MergeLanes {
3172                        cones: true,
3173                        shared_seam_ring: true,
3174                        seam_vertices: Some(&seam_vertices),
3175                    };
3176                    let Some(mut merged) = try_merge_pair_lanes(
3177                        &shell.faces[lower],
3178                        &shell.faces[higher],
3179                        &edges,
3180                        tolerance,
3181                        1e-7,
3182                        lanes,
3183                    )?
3184                    else {
3185                        continue;
3186                    };
3187                    let survivor = shell.faces[lower].id;
3188                    let absorbed = shell.faces[higher].id;
3189                    merged.id = survivor;
3190                    merged.name = shell.faces[lower].name.clone();
3191                    let kept = edge_ids(&merged);
3192                    for edge_id in lower_edges.intersection(&higher_edges) {
3193                        if kept.contains(edge_id) {
3194                            let edge = edges[edge_id];
3195                            seam_vertices.insert(edge.start_vertex_id);
3196                            seam_vertices.insert(edge.end_vertex_id);
3197                        } else {
3198                            cancelled_edges.insert(*edge_id);
3199                        }
3200                    }
3201                    for (_, carrier) in carried_by.iter_mut() {
3202                        if *carrier == absorbed {
3203                            *carrier = survivor;
3204                        }
3205                    }
3206                    merged_ids.insert(survivor);
3207                    shell.faces[lower] = merged;
3208                    shell.faces.remove(higher);
3209                    continue 'merge;
3210                }
3211            }
3212            break;
3213        }
3214    }
3215    if merged_ids.is_empty() {
3216        return Ok((result, carried_by));
3217    }
3218    let joints: HashSet<u64> = cancelled_edges
3219        .iter()
3220        .flat_map(|edge_id| {
3221            let edge = edges[edge_id];
3222            [edge.start_vertex_id, edge.end_vertex_id]
3223        })
3224        .collect();
3225    let result = finish_merge(solid, result, true, Some(&merged_ids))?;
3226    if joints.is_empty() {
3227        return Ok((result, carried_by));
3228    }
3229    let result = crate::coalesce::merge_curve_continuation_edges_at(&result, tolerance, &joints)?;
3230    Ok((result, carried_by))
3231}
3232
3233/// The tail every merge shares: drop the edges and vertices no face uses any
3234/// more and, on the validating lane, recount the genus and validate.
3235fn finish_merge(
3236    solid: &BrepSolid,
3237    mut result: BrepSolid,
3238    validate: bool,
3239    validation_scope: Option<&HashSet<u64>>,
3240) -> Result<BrepSolid, KernelRefusal> {
3241    let used_edges = result
3242        .shells
3243        .iter()
3244        .flat_map(|shell| &shell.faces)
3245        .flat_map(|face| &face.loops)
3246        .flat_map(|loop_record| &loop_record.coedges)
3247        .map(|coedge| coedge.edge_id)
3248        .collect::<HashSet<_>>();
3249    result.edges.retain(|edge| used_edges.contains(&edge.id));
3250    let used_vertices = result
3251        .edges
3252        .iter()
3253        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
3254        .collect::<HashSet<_>>();
3255    result
3256        .vertices
3257        .retain(|vertex| used_vertices.contains(&vertex.id));
3258    if !validate {
3259        return Ok(result);
3260    }
3261    if std::env::var("BREP_DEBUG_MERGE").is_ok() {
3262        let entry_faces: i64 = solid.shells.iter().map(|s| s.faces.len() as i64).sum();
3263        let entry_holes: i64 = solid
3264            .shells
3265            .iter()
3266            .flat_map(|s| &s.faces)
3267            .map(|f| f.loops.len().saturating_sub(1) as i64)
3268            .sum();
3269        let entry_edges = solid.edges.iter().filter(|e| !e.degenerate).count() as i64;
3270        let entry_used: std::collections::HashSet<u64> = solid
3271            .shells
3272            .iter()
3273            .flat_map(|s| &s.faces)
3274            .flat_map(|f| &f.loops)
3275            .flat_map(|l| &l.coedges)
3276            .map(|c| c.edge_id)
3277            .collect();
3278        eprintln!(
3279            "merge entry census: V={} E={entry_edges} (coedge-referenced {}) F={entry_faces} H={entry_holes} S={} stored_genus={}",
3280            solid.vertices.len(),
3281            entry_used.len(),
3282            solid.shells.len(),
3283            solid.genus
3284        );
3285    }
3286    let face_count = result
3287        .shells
3288        .iter()
3289        .map(|shell| shell.faces.len() as i64)
3290        .sum::<i64>();
3291    let hole_count = result.bounding_hole_count();
3292    let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
3293    // Count only LIVE vertices (referenced by a non-degenerate edge): the
3294    // merge removes edges and faces, orphaning vertices that then must not
3295    // participate in the Euler census — mirroring refresh_derived_genus
3296    // (boolean.rs). Counting stale vertices inflated V and rejected valid
3297    // merges as "non-integral genus" (the helmet report's final layer).
3298    let live_vertices: std::collections::HashSet<u64> = result
3299        .edges
3300        .iter()
3301        .filter(|edge| !edge.degenerate)
3302        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
3303        .collect();
3304    let vertex_count = result
3305        .vertices
3306        .iter()
3307        .filter(|vertex| live_vertices.contains(&vertex.id))
3308        .count() as i64;
3309    let euler = vertex_count - edge_count + face_count - hole_count;
3310    let numerator = result.shells.len() as i64 * 2 - euler;
3311    // Mirror validate()'s convention (topology.rs): the reduced V−E+F−H
3312    // formula does not model parameter-space POLE COLLAPSES reliably, so on a
3313    // complex carrying degenerate edges the parity check must not reject —
3314    // the helmet result holds 17 legitimate degenerate (pole) edges and its
3315    // odd numerator here is a formula artifact, not a defect; validate()
3316    // below performs the real integrity checks either way. With no
3317    // degenerate edges the strict behavior is unchanged.
3318    let has_degenerate = result.edges.iter().any(|edge| edge.degenerate);
3319    if numerator < 0 || numerator % 2 != 0 {
3320        if std::env::var("BREP_DEBUG_MERGE").is_ok() {
3321            eprintln!(
3322                "merge euler reject: V={vertex_count} E={edge_count} F={face_count} H={hole_count} S={} euler={euler} numerator={numerator} degenerate={has_degenerate}",
3323                result.shells.len()
3324            );
3325        }
3326        if !has_degenerate
3327            || std::env::var("BREP_MERGE_DEGENERATE_EULER_STRICT").as_deref() == Ok("1")
3328        {
3329            return Err(KernelRefusal::new(
3330                RefusalClass::NonIntegralGenus {
3331                    shells: result.shells.len() as u32,
3332                    euler: euler as i64,
3333                },
3334                KernelStage::Validate,
3335                "merge_same_surface_faces produced non-integral genus",
3336            ));
3337        }
3338    } else {
3339        result.genus = numerator / 2;
3340    }
3341    let issues = match validation_scope {
3342        Some(scope) => result.validate_scoped(scope),
3343        None => result.validate(),
3344    };
3345    if !issues.is_empty() {
3346        return Err(KernelRefusal::new(
3347            RefusalClass::DegenerateArrangement {
3348                open_edges: 0,
3349                issues: issues.len() as u32,
3350            },
3351            KernelStage::Validate,
3352            format!("merge_same_surface_faces produced invalid topology: {issues:?}"),
3353        ));
3354    }
3355    Ok(result)
3356}
3357
3358pub fn merge_same_surface_faces(
3359    solid: &BrepSolid,
3360    tolerance: f64,
3361) -> Result<BrepSolid, KernelRefusal> {
3362    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, &[], None)
3363}
3364
3365/// [`merge_same_surface_faces_excluding`] whose closing validation reads
3366/// the geometry of the faces in `scope` and of the faces the merge built;
3367/// every pair still merges exactly as the plain call merges it (an operand's
3368/// own coplanar neighbours included: the 2026-10-10 census found a mesh
3369/// import whose five such pairs the boolean fuses). A boolean passes the
3370/// faces its operation touched and their neighbours
3371/// (`BooleanOptions::untouched_passthrough`); the rest were validated by the
3372/// assembler and left this pass as they came.
3373pub fn merge_same_surface_faces_scoped(
3374    solid: &BrepSolid,
3375    tolerance: f64,
3376    keep_unmerged: &[String],
3377    scope: &HashSet<u64>,
3378) -> Result<BrepSolid, KernelRefusal> {
3379    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, keep_unmerged, Some(scope))
3380}
3381
3382/// Like [`merge_same_surface_faces`], but any face whose `name` CONTAINS one of
3383/// `keep_unmerged` is pinned: it never merges with a coplanar/cosurface
3384/// neighbour and is emitted unchanged, while every other face merges exactly as
3385/// [`merge_same_surface_faces`] would. An empty `keep_unmerged` is identical to
3386/// the plain call.
3387pub fn merge_same_surface_faces_excluding(
3388    solid: &BrepSolid,
3389    tolerance: f64,
3390    keep_unmerged: &[String],
3391) -> Result<BrepSolid, KernelRefusal> {
3392    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, keep_unmerged, None)
3393}
3394
3395pub(crate) fn merge_same_surface_faces_open(
3396    solid: &BrepSolid,
3397    tolerance: f64,
3398) -> Result<BrepSolid, KernelRefusal> {
3399    merge_same_surface_faces_impl(solid, tolerance, false, 5e-3, &[], None)
3400}
3401