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            let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
1696            for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1697                let edge = edges[&coedge.edge_id];
1698                let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1699                let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
1700                let hull = axial_hull(&curve, support);
1701                axial_range[0] = axial_range[0].min(hull[0]);
1702                axial_range[1] = axial_range[1].max(hull[1]);
1703                for index in 0..=8 {
1704                    let point = curve
1705                        .evaluate(c0 + (c1 - c0) * index as f64 / 8.0)
1706                        .or_refuse(KernelStage::Sew, "evaluate")?;
1707                    let delta = point.sub(support.origin);
1708                    let axial = delta.dot(support.axis);
1709                    let radial = delta.sub(support.axis.scale(axial));
1710                    if radial.length() <= support.radius * 1e-6 {
1711                        continue;
1712                    }
1713                    let mut angle = radial.dot(y_reference).atan2(radial.dot(x_reference));
1714                    if angle < 0.0 {
1715                        angle += std::f64::consts::TAU;
1716                    }
1717                    angles.push(angle);
1718                }
1719            }
1720            if angles.len() < 3 || axial_range[1] - axial_range[0] <= tolerance {
1721                fall_through!(older, newer, 'partial_turn);
1722            }
1723            angles.sort_by(f64::total_cmp);
1724            let mut gap_start = angles.len() - 1;
1725            let mut largest_gap = angles[0] + std::f64::consts::TAU - angles[angles.len() - 1];
1726            for index in 0..angles.len() - 1 {
1727                let gap = angles[index + 1] - angles[index];
1728                if gap > largest_gap {
1729                    largest_gap = gap;
1730                    gap_start = index;
1731                }
1732            }
1733            let span = std::f64::consts::TAU - largest_gap;
1734            if span <= tolerance || span >= std::f64::consts::TAU - 1e-6 {
1735                fall_through!(older, newer, 'partial_turn);
1736            }
1737            let start_angle = angles[(gap_start + 1) % angles.len()];
1738            let start_direction = x_reference
1739                .scale(start_angle.cos())
1740                .add(y_reference.scale(start_angle.sin()));
1741            let generatrix = make_line(
1742                support
1743                    .origin
1744                    .add(support.axis.scale(axial_range[0]))
1745                    .add(start_direction.scale(support.radius)),
1746                support
1747                    .origin
1748                    .add(support.axis.scale(axial_range[1]))
1749                    .add(start_direction.scale(support.radius)),
1750            )
1751            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1752            let surface = make_revolution(support.origin, support.axis, &generatrix, span)
1753                .or_refuse(KernelStage::Sew, "make_revolution")?;
1754            let (u_domain, v_domain) = surface_domains(&surface)?;
1755            let sample = surface
1756                .evaluate(
1757                    (u_domain[0] + u_domain[1]) / 2.0,
1758                    (v_domain[0] + v_domain[1]) / 2.0,
1759                )
1760                .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1761            let geometric_normal = surface
1762                .normal(
1763                    (u_domain[0] + u_domain[1]) / 2.0,
1764                    (v_domain[0] + v_domain[1]) / 2.0,
1765                )
1766                .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1767            let delta = sample.sub(support.origin);
1768            let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
1769            let radial = match radial.normalized() {
1770                Ok(radial) => radial,
1771                Err(_) => fall_through!(older, newer, 'partial_turn),
1772            };
1773            let same_sense = geometric_normal.dot(radial).signum() == support.outward_radial_sign;
1774            let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1775            projected_loops.sort_by(|first, second| {
1776                let area = |loop_record: &LoopRecord| {
1777                    parameter_space_area(&FaceRecord {
1778                        id: older.id,
1779                        surface: surface.clone(),
1780                        same_sense,
1781                        loops: vec![loop_record.clone()],
1782                        name: None,
1783                    })
1784                    .map(f64::abs)
1785                };
1786                match (area(first), area(second)) {
1787                    (Ok(a), Ok(b)) => b.total_cmp(&a),
1788                    _ => std::cmp::Ordering::Equal,
1789                }
1790            });
1791            let merged = FaceRecord {
1792                id: older.id,
1793                surface,
1794                same_sense,
1795                loops: projected_loops,
1796                name: older.name.clone(),
1797            };
1798            let area =
1799                parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1800            if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1801                fall_through!(older, newer, 'partial_turn);
1802            }
1803            // The reprojected trims must reproduce the pieces' combined 3D
1804            // area; a projection or seam mistake shows up here immediately.
1805            let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1806                + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1807            let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1808            if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1809                fall_through!(older, newer, 'partial_turn);
1810            }
1811            return Ok(Some(merged));
1812        }
1813        if let Some(merged) =
1814            merge_cylinder_full_turn(older, newer, edges, tolerance, support, &shared_edges, lanes)?
1815        {
1816            return Ok(Some(merged));
1817        }
1818        decline!(older, newer);
1819    }
1820
1821    if let Some((support, union)) = cosurface_extrusion {
1822        // Shared linear-sweep carrier: the older profile translated to the
1823        // union's start plane, extruded across the union interval.
1824        let shift = support.direction.scale(union[0] - support.interval[0]);
1825        let step = support.direction.scale(union[1] - union[0]);
1826        let rows = support
1827            .base
1828            .control_points
1829            .iter()
1830            .map(|control| -> Result<Vec<Vec4>, KernelRefusal> {
1831                let point = control
1832                    .point()
1833                    .or_refuse(KernelStage::Sew, "point")?
1834                    .add(shift);
1835                Ok(vec![
1836                    Vec4::from_point(point, control.w),
1837                    Vec4::from_point(point.add(step), control.w),
1838                ])
1839            })
1840            .collect::<Result<Vec<_>, _>>()?;
1841        let surface = NurbsSurface::new(
1842            support.base.degree,
1843            1,
1844            support.base.knots.clone(),
1845            vec![0.0, 0.0, 1.0, 1.0],
1846            rows,
1847        )
1848        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1849        let (older_u, older_v) = surface_domains(&older.surface)?;
1850        let older_mid = older
1851            .surface
1852            .evaluate(
1853                (older_u[0] + older_u[1]) / 2.0,
1854                (older_v[0] + older_v[1]) / 2.0,
1855            )
1856            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1857        let projection = crate::project_point_to_surface(&surface, older_mid)
1858            .or_refuse(KernelStage::Sew, "project_point_to_surface")?;
1859        let carrier_normal = surface
1860            .normal(projection.u, projection.v)
1861            .or_refuse(KernelStage::Sew, "normal")?;
1862        let same_sense = carrier_normal.dot(outward_normal(older)?) > 0.0;
1863        let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1864        projected_loops.sort_by(|first, second| {
1865            let area = |loop_record: &LoopRecord| {
1866                parameter_space_area(&FaceRecord {
1867                    id: older.id,
1868                    surface: surface.clone(),
1869                    same_sense,
1870                    loops: vec![loop_record.clone()],
1871                    name: None,
1872                })
1873                .map(f64::abs)
1874            };
1875            match (area(first), area(second)) {
1876                (Ok(a), Ok(b)) => b.total_cmp(&a),
1877                _ => std::cmp::Ordering::Equal,
1878            }
1879        });
1880        let merged = FaceRecord {
1881            id: older.id,
1882            surface,
1883            same_sense,
1884            loops: projected_loops,
1885            name: older.name.clone(),
1886        };
1887        let area =
1888            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1889        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1890            decline!(older, newer);
1891        }
1892        let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1893            + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1894        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1895        if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1896            if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1897                eprintln!(
1898                    "coextrusion {}x{}: area mismatch {actual} vs {expected}",
1899                    older.id, newer.id
1900                );
1901            }
1902            decline!(older, newer);
1903        }
1904        return Ok(Some(merged));
1905    }
1906
1907    let (u_domain, v_domain) = surface_domains(&older.surface)?;
1908    let origin = older
1909        .surface
1910        .evaluate(u_domain[0], v_domain[0])
1911        .or_refuse(KernelStage::Sew, "evaluate")?;
1912    let derivatives = older
1913        .surface
1914        .derivatives(
1915            (u_domain[0] + u_domain[1]) / 2.0,
1916            (v_domain[0] + v_domain[1]) / 2.0,
1917            1,
1918        )
1919        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1920    let u_axis = derivatives[1][0]
1921        .normalized()
1922        .or_refuse(KernelStage::Sew, "normalized")?;
1923    let geometric_normal = derivatives[1][0]
1924        .cross(derivatives[0][1])
1925        .normalized()
1926        .or_refuse(KernelStage::Sew, "normalized")?;
1927    let v_axis = geometric_normal
1928        .cross(u_axis)
1929        .normalized()
1930        .or_refuse(KernelStage::Sew, "normalized")?;
1931    let mut bounds_u = [f64::INFINITY, f64::NEG_INFINITY];
1932    let mut bounds_v = [f64::INFINITY, f64::NEG_INFINITY];
1933    for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1934        let edge = edges[&coedge.edge_id];
1935        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1936        for control in &curve.control_points {
1937            let relative = control
1938                .point()
1939                .or_refuse(KernelStage::Sew, "point")?
1940                .sub(origin);
1941            let u = relative.dot(u_axis);
1942            let v = relative.dot(v_axis);
1943            bounds_u[0] = bounds_u[0].min(u);
1944            bounds_u[1] = bounds_u[1].max(u);
1945            bounds_v[0] = bounds_v[0].min(v);
1946            bounds_v[1] = bounds_v[1].max(v);
1947        }
1948    }
1949    let extent_u = bounds_u[1] - bounds_u[0];
1950    let extent_v = bounds_v[1] - bounds_v[0];
1951    if extent_u <= tolerance || extent_v <= tolerance {
1952        decline!(older, newer);
1953    }
1954    let surface_origin = origin
1955        .add(u_axis.scale(bounds_u[0]))
1956        .add(v_axis.scale(bounds_v[0]));
1957    let surface = make_plane(surface_origin, u_axis, v_axis, extent_u, extent_v)
1958        .or_refuse(KernelStage::Sew, "make_plane")?;
1959    let mut projected_loops = match transfer_planar_loops(&surface, &loops, older, newer, tolerance)
1960    {
1961        Some(transferred) => transferred,
1962        None => reproject_loops(&surface, loops, edges)?,
1963    };
1964    projected_loops.sort_by(|first, second| {
1965        let area = |loop_record: &LoopRecord| {
1966            parameter_space_area(&FaceRecord {
1967                id: older.id,
1968                surface: surface.clone(),
1969                same_sense: older.same_sense,
1970                loops: vec![loop_record.clone()],
1971                name: None,
1972            })
1973            .map(f64::abs)
1974        };
1975        match (area(first), area(second)) {
1976            (Ok(a), Ok(b)) => b.total_cmp(&a),
1977            _ => std::cmp::Ordering::Equal,
1978        }
1979    });
1980    let merged = FaceRecord {
1981        id: older.id,
1982        surface,
1983        same_sense: older.same_sense,
1984        loops: projected_loops,
1985        name: older.name.clone(),
1986    };
1987    let area = parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1988    if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1989        decline!(older, newer);
1990    }
1991    Ok(Some(merged))
1992}
1993
1994/// Angle of `point` about `support`'s axis in the (`x_reference`,
1995/// `y_reference`) frame, in `[0, TAU)`; `None` on the axis.
1996fn cylinder_angle(
1997    support: &CylinderSupport,
1998    x_reference: Vec3,
1999    y_reference: Vec3,
2000    point: Vec3,
2001) -> Option<f64> {
2002    let delta = point.sub(support.origin);
2003    let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
2004    if radial.length() <= support.radius * 1e-6 {
2005        return None;
2006    }
2007    let mut angle = radial.dot(y_reference).atan2(radial.dot(x_reference));
2008    if angle < 0.0 {
2009        angle += std::f64::consts::TAU;
2010    }
2011    // An atan2 a few ulp below zero rounds to TAU itself after the shift; the
2012    // range is half-open, so that reading is the seam angle 0.
2013    if angle >= std::f64::consts::TAU {
2014        angle = 0.0;
2015    }
2016    Some(angle)
2017}
2018
2019/// Signed angular distance from `from` to `to`, in `(-PI, PI]`.
2020fn angle_difference(from: f64, to: f64) -> f64 {
2021    let turn = (to - from).rem_euclid(std::f64::consts::TAU);
2022    if turn > std::f64::consts::PI {
2023        turn - std::f64::consts::TAU
2024    } else {
2025        turn
2026    }
2027}
2028
2029/// Merge two cosurface cylinder patches whose union wraps the FULL turn.
2030///
2031/// The partial-turn lane seams its carrier in the union's largest angular
2032/// gap; a union with no gap (the shelled cylinder's full outer wall against
2033/// the half-walls of a coaxial extrude, the E13 report of 2026-10-13) has no
2034/// such seam, and cancelling every shared edge there strands the full face's
2035/// seam strip at a fork the 3D walk cannot resolve. This lane builds a closed
2036/// revolution carrier seamed along an existing AXIAL edge that both faces
2037/// already traverse twice — one face's own seam pair, or a shared pair — keeps
2038/// the shared edges on that line as seam pairs instead of cancelling them,
2039/// places every seam-line trim on the side of the seam its traversal puts the
2040/// material on, and walks the leftover uses in the carrier's own parameter
2041/// space. Only a seam one of the faces ALREADY has is a candidate: a face
2042/// that wraps the turn by itself carries its own seam pair, and the merged
2043/// face keeps that seam. Two half-turn patches closing a ring between them
2044/// have no such seam; they were two faces before any boolean touched them
2045/// (a sketch circle's two arcs, a tube piece's two halves, a plate hole's
2046/// two bore segments whose names a flange picks) and they stay two faces
2047/// here — master's battery of 2026-10-13 read four regressions from merging
2048/// them. Each candidate is tried in turn, and the merged face must close
2049/// every loop in parameter space, keep every trim on its edge within the
2050/// validator's own pcurve acceptance, and reproduce the pieces'
2051/// parameter-space sign and their combined 3D area, or the candidate is
2052/// dropped; with no candidate left the pair stays as assembled.
2053///
2054/// What this lane does NOT do: two full rings seamed at different angles
2055/// (a shelled cylinder's bore wall and a coaxial extrude's hole wall whose
2056/// profile circle happened to start opposite) have no axial edge spanning
2057/// both, and merging them means re-seaming one through its far rim and the
2058/// cap on it — a solid-wide edit the face merge does not make. Such a pair
2059/// declines here with its loops open in parameter space and keeps its
2060/// circular joint.
2061fn merge_cylinder_full_turn(
2062    older: &FaceRecord,
2063    newer: &FaceRecord,
2064    edges: &HashMap<u64, &EdgeRecord>,
2065    tolerance: f64,
2066    support: &CylinderSupport,
2067    shared_edges: &HashSet<u64>,
2068    lanes: MergeLanes<'_>,
2069) -> Result<Option<FaceRecord>, KernelRefusal> {
2070    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
2071    // `BREP_FULL_TURN_MERGE=0` hatches the lane off, for attribution only.
2072    if std::env::var("BREP_FULL_TURN_MERGE").as_deref() == Ok("0") {
2073        return Ok(None);
2074    }
2075    // Every non-degenerate use of either face, tagged with which face it came
2076    // from (0 older, 1 newer) so an edge used twice by ONE face reads as its
2077    // own seam.
2078    let uses = [older, newer]
2079        .iter()
2080        .enumerate()
2081        .flat_map(|(side, face)| {
2082            face.loops
2083                .iter()
2084                .flat_map(|loop_record| &loop_record.coedges)
2085                .filter(|coedge| !edges[&coedge.edge_id].degenerate)
2086                .map(move |coedge| (side, coedge.clone()))
2087        })
2088        .collect::<Vec<_>>();
2089    let Some(x_reference) = uses.iter().find_map(|(_, coedge)| {
2090        let edge = edges[&coedge.edge_id];
2091        let seed = edge.curve.evaluate(edge.t0).ok()?;
2092        let delta = seed.sub(support.origin);
2093        let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
2094        radial.normalized().ok()
2095    }) else {
2096        return Ok(None);
2097    };
2098    let y_reference = support.axis.cross(x_reference);
2099    let scale = 1.0f64.max(support.origin.length()).max(support.radius);
2100    let band = (tolerance * 100.0).max(1e-7 * scale);
2101    // The smallest radius the patches reach bounds the angle `band` subtends
2102    // (a cylinder's one radius; a cone's radius at its narrow end, read once
2103    // the axial range is known below and checked positive there).
2104    let mut angular_band = band / support.radius;
2105
2106    // Each use's stations: axial coordinate and angle at nine samples, plus
2107    // the station of its start and end in traversal order.
2108    struct Station {
2109        axial: f64,
2110        angle: Option<f64>,
2111    }
2112    let station = |point: Vec3| Station {
2113        axial: point.sub(support.origin).dot(support.axis),
2114        angle: cylinder_angle(support, x_reference, y_reference, point),
2115    };
2116    let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
2117    let mut samples = Vec::with_capacity(uses.len());
2118    let mut bbox_lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2119    let mut bbox_hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2120    // The boundary length of both faces together: the most area a set of
2121    // trims admitted `band` off their edges can move, per unit of `band`.
2122    let mut perimeter = 0.0f64;
2123    for (_, coedge) in &uses {
2124        let edge = edges[&coedge.edge_id];
2125        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
2126        let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
2127        let hull = axial_hull(&curve, support);
2128        axial_range[0] = axial_range[0].min(hull[0]);
2129        axial_range[1] = axial_range[1].max(hull[1]);
2130        perimeter += crate::mass_properties::edge_arc_length(edge).unwrap_or(0.0);
2131        let mut stations = Vec::with_capacity(9);
2132        for index in 0..=8 {
2133            let point = curve
2134                .evaluate(c0 + (c1 - c0) * index as f64 / 8.0)
2135                .or_refuse(KernelStage::Sew, "evaluate")?;
2136            bbox_lo = Vec3::new(bbox_lo.x.min(point.x), bbox_lo.y.min(point.y), bbox_lo.z.min(point.z));
2137            bbox_hi = Vec3::new(bbox_hi.x.max(point.x), bbox_hi.y.max(point.y), bbox_hi.z.max(point.z));
2138            stations.push(station(point));
2139        }
2140        samples.push(stations);
2141    }
2142    if axial_range[1] - axial_range[0] <= tolerance {
2143        return Ok(None);
2144    }
2145    if support.slope != 0.0 {
2146        let narrow = support
2147            .radius_at(axial_range[0])
2148            .min(support.radius_at(axial_range[1]));
2149        if narrow <= band {
2150            return Ok(None);
2151        }
2152        angular_band = band / narrow;
2153    }
2154    // An edge is an axial line at angle `theta` when every sample reads that
2155    // angle: a straight generatrix of the shared cylinder.
2156    let axial_angle = |stations: &[Station]| -> Option<f64> {
2157        let first = stations.first()?.angle?;
2158        stations
2159            .iter()
2160            .all(|station| {
2161                station
2162                    .angle
2163                    .is_some_and(|angle| angle_difference(first, angle).abs() <= angular_band)
2164            })
2165            .then_some(first)
2166    };
2167    let mut use_count = HashMap::<u64, usize>::default();
2168    for (_, coedge) in &uses {
2169        *use_count.entry(coedge.edge_id).or_default() += 1;
2170    }
2171    // Candidate seam angles, ranked: a face's own seam pair (both uses from
2172    // one face) before a shared pair; a line used once bounds the region on
2173    // one side only and cannot seam a wrap by itself.
2174    let mut candidates: Vec<(u8, f64)> = Vec::new();
2175    // Candidate angles whose edge ends at a vertex `lanes.seam_vertices` names.
2176    let mut preferred: Vec<f64> = Vec::new();
2177    for (index, (side, coedge)) in uses.iter().enumerate() {
2178        if use_count.get(&coedge.edge_id).copied() != Some(2) {
2179            continue;
2180        }
2181        let Some(theta) = axial_angle(&samples[index]) else {
2182            continue;
2183        };
2184        let own_seam = uses
2185            .iter()
2186            .any(|(other_side, other)| {
2187                other.edge_id == coedge.edge_id
2188                    && other.id != coedge.id
2189                    && other_side == side
2190                    && other.forward != coedge.forward
2191            });
2192        let rank = if own_seam { 0 } else { 1 };
2193        if let Some(vertices) = lanes.seam_vertices {
2194            let edge = edges[&coedge.edge_id];
2195            if vertices.contains(&edge.start_vertex_id) || vertices.contains(&edge.end_vertex_id) {
2196                preferred.push(theta);
2197            }
2198        }
2199        if !candidates
2200            .iter()
2201            .any(|(_, known)| angle_difference(*known, theta).abs() <= angular_band)
2202        {
2203            candidates.push((rank, theta));
2204        } else if let Some(known) = candidates
2205            .iter_mut()
2206            .find(|(_, known)| angle_difference(*known, theta).abs() <= angular_band)
2207        {
2208            known.0 = known.0.min(rank);
2209        }
2210    }
2211    let is_preferred = |theta: f64| {
2212        preferred
2213            .iter()
2214            .any(|known| angle_difference(*known, theta).abs() <= angular_band)
2215    };
2216    // `false` sorts first: a preferred seam leads its rank. With no
2217    // preference every key is `true` and the order is the plain one.
2218    candidates.sort_by(|a, b| {
2219        a.0.cmp(&b.0)
2220            .then(is_preferred(b.1).cmp(&is_preferred(a.1)))
2221            .then(a.1.total_cmp(&b.1))
2222    });
2223    if debug {
2224        eprintln!(
2225            "merge {}x{}: full-turn lane, {} seam candidate(s) {:?}",
2226            older.id,
2227            newer.id,
2228            candidates.len(),
2229            candidates
2230        );
2231    }
2232    // A shared pair alone (rank 1) is two half patches closing a ring between
2233    // them: neither face wraps, and the merged face would carry a seam no
2234    // input face had. Those pairs stay as assembled (see the doc comment).
2235    // A caller that opted into `shared_seam_ring` is joining faces the user
2236    // named as one region; there the shared pair seams the ring.
2237    if !lanes.shared_seam_ring {
2238        candidates.retain(|(rank, _)| *rank == 0);
2239    }
2240    if candidates.is_empty() {
2241        if debug {
2242            eprintln!(
2243                "merge {}x{}: full-turn lane: no face wraps the turn by itself, the halves stay apart",
2244                older.id, newer.id
2245            );
2246        }
2247        return Ok(None);
2248    }
2249    let weld = crate::tolerance::assembler_weld(tolerance);
2250    // The validator's own edge/pcurve acceptance on this pair's extent (its
2251    // size-coupled ceiling floors at `pcurve_consistency`); the pair's
2252    // diagonal is at most the solid's, so nothing admitted here is refused
2253    // by the closing validation on that count.
2254    let pcurve_limit = crate::KernelTolerances::for_scale(scale, 1e-7)
2255        .pcurve_acceptance(bbox_hi.sub(bbox_lo).length());
2256    let expected_area = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
2257        + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
2258    let expected_term = face_volume_contribution(older)
2259        .or_refuse(KernelStage::Sew, "face_volume_contribution")?
2260        + face_volume_contribution(newer).or_refuse(KernelStage::Sew, "face_volume_contribution")?;
2261
2262    'candidate: for (_, theta) in candidates {
2263        let on_seam = |index: usize| {
2264            axial_angle(&samples[index])
2265                .is_some_and(|angle| angle_difference(theta, angle).abs() <= angular_band)
2266        };
2267        // Closed carrier seamed at `theta`, spanning both patches axially.
2268        let start_direction = x_reference
2269            .scale(theta.cos())
2270            .add(y_reference.scale(theta.sin()));
2271        let generatrix = make_line(
2272            support
2273                .origin
2274                .add(support.axis.scale(axial_range[0]))
2275                .add(start_direction.scale(support.radius_at(axial_range[0]))),
2276            support
2277                .origin
2278                .add(support.axis.scale(axial_range[1]))
2279                .add(start_direction.scale(support.radius_at(axial_range[1]))),
2280        )
2281        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
2282        let surface = make_revolution(
2283            support.origin,
2284            support.axis,
2285            &generatrix,
2286            std::f64::consts::TAU,
2287        )
2288        .or_refuse(KernelStage::Sew, "make_revolution")?;
2289        let (u_domain, v_domain) = surface_domains(&surface)?;
2290        let (u_mid, v_mid) = (
2291            (u_domain[0] + u_domain[1]) / 2.0,
2292            (v_domain[0] + v_domain[1]) / 2.0,
2293        );
2294        let sample = surface
2295            .evaluate(u_mid, v_mid)
2296            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
2297        let geometric_normal = surface
2298            .normal(u_mid, v_mid)
2299            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
2300        let delta = sample.sub(support.origin);
2301        let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
2302        let Ok(radial) = radial.normalized() else {
2303            continue;
2304        };
2305        let same_sense = geometric_normal.dot(radial).signum() == support.outward_radial_sign;
2306        let v_of = |axial: f64| {
2307            v_domain[0]
2308                + (axial - axial_range[0]) / (axial_range[1] - axial_range[0])
2309                    * (v_domain[1] - v_domain[0])
2310        };
2311
2312        // Leftover uses: a shared edge off the seam line is cancelled; one on
2313        // it stays, both ways, as the merged face's seam.
2314        let mut remaining = Vec::new();
2315        for (index, (_, coedge)) in uses.iter().enumerate() {
2316            let seam_line = on_seam(index);
2317            if shared_edges.contains(&coedge.edge_id) && !seam_line {
2318                continue;
2319            }
2320            let edge = edges[&coedge.edge_id];
2321            let mut coedge = coedge.clone();
2322            let mut curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
2323            if !coedge.forward {
2324                curve = curve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
2325            }
2326            if seam_line {
2327                // The material lies to the LEFT of a same-sense loop's
2328                // traversal in uv (parameter-space area positive) and to the
2329                // right of an opposite-sense one. A seam-line trim running
2330                // +v with material on its left is the domain's far side
2331                // (u = u1); running -v, its near side (u = u0).
2332                let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
2333                let start = curve.evaluate(c0).or_refuse(KernelStage::Sew, "evaluate")?;
2334                let end = curve.evaluate(c1).or_refuse(KernelStage::Sew, "evaluate")?;
2335                let rising = end.sub(start).dot(support.axis) > 0.0;
2336                let far_side = rising == same_sense;
2337                let u = if far_side { u_domain[1] } else { u_domain[0] };
2338                // The trim carries the edge's OWN parameterisation: the
2339                // axial coordinate is an affine functional of position, so
2340                // mapping each control point (dehomogenised) and keeping the
2341                // degree, knots and weights reproduces v(t) exactly. A
2342                // two-point line assumed the edge ran its axis at constant
2343                // speed, which a spline tube's straight Hermite piece does
2344                // not (max deviation 0.5 at the validator, 2026-10-13).
2345                let origin_axial = support.origin.dot(support.axis);
2346                let control_points = curve
2347                    .control_points
2348                    .iter()
2349                    .map(|point| {
2350                        let w = point.w;
2351                        let axial = Vec3::new(point.x / w, point.y / w, point.z / w)
2352                            .dot(support.axis)
2353                            - origin_axial;
2354                        Vec4 { x: u * w, y: v_of(axial) * w, z: 0.0, w }
2355                    })
2356                    .collect();
2357                coedge.pcurve = NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
2358                    .or_refuse(KernelStage::Sew, "NurbsCurve::new")?;
2359            } else {
2360                // The kernel's fit, with its report read: a station standing
2361                // off the carrier (`off_surface`) is an edge this carrier
2362                // does not hold — the projector folds such a station onto
2363                // the carrier's boundary and the face becomes the wrong
2364                // region on a body `validate()` passes — and a residual over
2365                // the band is a trim that misses its edge between stations.
2366                let fit = match crate::fit_pcurve_on_surface(&surface, &curve) {
2367                    Ok(fit) => fit,
2368                    Err(_) => continue 'candidate,
2369                };
2370                if fit.report.off_surface > band || fit.report.residual > band {
2371                    if debug {
2372                        eprintln!(
2373                            "merge {}x{}: full-turn seam {theta:.4}: edge {} stands {:.3e} off the carrier, fit residual {:.3e} (band {band:.3e})",
2374                            older.id, newer.id, coedge.edge_id, fit.report.off_surface, fit.report.residual
2375                        );
2376                    }
2377                    continue 'candidate;
2378                }
2379                coedge.pcurve = fit.curve;
2380            }
2381            remaining.push(coedge);
2382        }
2383        let space = SharedParameterSpace { surface: &surface, weld };
2384        dissolve_slits(&mut remaining, edges, &space);
2385        if debug {
2386            for coedge in &remaining {
2387                let stations = pcurve_stations(coedge)
2388                    .map(|[a, m, b]| format!("({:.4},{:.4})->({:.4},{:.4})->({:.4},{:.4})", a.x, a.y, m.x, m.y, b.x, b.y))
2389                    .unwrap_or_default();
2390                eprintln!(
2391                    "  seam {theta:.4} use coedge {} edge {} fwd={} shared={} same_sense={same_sense} uv {stations}",
2392                    coedge.id,
2393                    coedge.edge_id,
2394                    coedge.forward,
2395                    shared_edges.contains(&coedge.edge_id)
2396                );
2397            }
2398        }
2399        let Some(mut loops) = walk_cycles(remaining, edges, Some(&space)) else {
2400            if debug {
2401                eprintln!(
2402                    "merge {}x{}: full-turn seam {theta:.4}: walk undetermined",
2403                    older.id, newer.id
2404                );
2405            }
2406            continue;
2407        };
2408        if loops.is_empty() {
2409            continue;
2410        }
2411        // Every loop must CLOSE in the carrier's parameter space: the walk
2412        // closes a cycle on vertex identity when nothing else leaves its
2413        // end, which on a seamed carrier accepts a full circle running one
2414        // period from its own vertex, or a ring whose own seam pair (off the
2415        // chosen seam) just dissolved as a slit. The area oracles below
2416        // integrate per pcurve and cannot see the grouping.
2417        let closed = loops.iter().all(|loop_record| {
2418            let count = loop_record.coedges.len();
2419            (0..count).all(|index| {
2420                let next = &loop_record.coedges[(index + 1) % count];
2421                match (
2422                    pcurve_stations(&loop_record.coedges[index]),
2423                    pcurve_stations(next),
2424                ) {
2425                    (Some(here), Some(there)) => space.coincide(here[2], there[0]),
2426                    _ => false,
2427                }
2428            })
2429        });
2430        if !closed {
2431            if debug {
2432                eprintln!(
2433                    "merge {}x{}: full-turn seam {theta:.4}: a loop is open in parameter space",
2434                    older.id, newer.id
2435                );
2436            }
2437            continue;
2438        }
2439        if debug {
2440            for loop_record in &loops {
2441                eprintln!(
2442                    "  seam {theta:.4} loop {:?}",
2443                    loop_record.coedges.iter().map(|c| (c.edge_id, c.forward)).collect::<Vec<_>>()
2444                );
2445            }
2446        }
2447        loops.sort_by(|first, second| {
2448            let area = |loop_record: &LoopRecord| {
2449                parameter_space_area(&FaceRecord {
2450                    id: older.id,
2451                    surface: surface.clone(),
2452                    same_sense,
2453                    loops: vec![loop_record.clone()],
2454                    name: None,
2455                })
2456                .map(f64::abs)
2457            };
2458            match (area(first), area(second)) {
2459                (Ok(a), Ok(b)) => b.total_cmp(&a),
2460                _ => std::cmp::Ordering::Equal,
2461            }
2462        });
2463        let merged = FaceRecord {
2464            id: older.id,
2465            surface,
2466            same_sense,
2467            loops,
2468            name: older.name.clone(),
2469        };
2470        // Every trim must lie on its edge by the validator's own reading;
2471        // a candidate that fails here falls through and the pair stays,
2472        // instead of the refusal surfacing from the closing validation as a
2473        // feature failure (the spline tube, 2026-10-13).
2474        let mut worst_trim = 0.0f64;
2475        for coedge in merged.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
2476            let edge = edges[&coedge.edge_id];
2477            let error = crate::topology::coedge_error_floored(
2478                &merged.surface,
2479                &coedge.pcurve,
2480                &edge.curve,
2481                edge,
2482                coedge.forward,
2483                pcurve_limit,
2484            )
2485            .unwrap_or(f64::INFINITY);
2486            worst_trim = worst_trim.max(error);
2487        }
2488        if worst_trim > pcurve_limit {
2489            if debug {
2490                eprintln!(
2491                    "merge {}x{}: full-turn seam {theta:.4}: a trim is {worst_trim:.6} off its edge (limit {pcurve_limit:.6})",
2492                    older.id, newer.id
2493                );
2494            }
2495            continue;
2496        }
2497        let area =
2498            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
2499        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
2500            if debug {
2501                eprintln!(
2502                    "merge {}x{}: full-turn seam {theta:.4}: parameter area {area:.3e} vs same_sense {same_sense}",
2503                    older.id, newer.id
2504                );
2505            }
2506            continue;
2507        }
2508        // The merged face must reproduce the pieces' combined 3D area and
2509        // their combined volume term about the origin. The bar is what the
2510        // admitted trims can move: `band` off their edges along the whole
2511        // boundary (and that much times the farthest point for the volume
2512        // term), plus the quadrature's drift. A 5e-3 share of the area let
2513        // a 0.24 sliver through (BadBoolean, 2026-10-13); the volume term
2514        // sees a region of the right area in the wrong place, which the
2515        // area cannot.
2516        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
2517        let area_bar = band * perimeter + 1e-9 * expected_area.abs();
2518        if (actual - expected_area).abs() > area_bar {
2519            if debug {
2520                eprintln!(
2521                    "merge {}x{}: full-turn seam {theta:.4}: area {actual:.9} vs expected {expected_area:.9} (bar {area_bar:.3e})",
2522                    older.id, newer.id
2523                );
2524            }
2525            continue;
2526        }
2527        let actual_term =
2528            face_volume_contribution(&merged).or_refuse(KernelStage::Sew, "face_volume_contribution")?;
2529        let reach = bbox_lo.length().max(bbox_hi.length());
2530        let term_bar = band * perimeter * reach / 3.0 + 1e-9 * expected_term.abs().max(expected_area.abs());
2531        if (actual_term - expected_term).abs() > term_bar {
2532            if debug {
2533                eprintln!(
2534                    "merge {}x{}: full-turn seam {theta:.4}: volume term {actual_term:.9} vs expected {expected_term:.9} (bar {term_bar:.3e})",
2535                    older.id, newer.id
2536                );
2537            }
2538            continue;
2539        }
2540        if debug {
2541            eprintln!(
2542                "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})",
2543                older.id,
2544                newer.id,
2545                merged.loops.len()
2546            );
2547        }
2548        return Ok(Some(merged));
2549    }
2550    Ok(None)
2551}
2552
2553
2554fn try_merge_connected_group(
2555    faces: &[FaceRecord],
2556    edges: &HashMap<u64, &EdgeRecord>,
2557    tolerance: f64,
2558    same_carrier_area_tolerance_ratio: f64,
2559    keep_unmerged: &[String],
2560) -> Result<Option<(Vec<usize>, FaceRecord)>, KernelRefusal> {
2561    for seed in 0..faces.len() {
2562        // A pinned face never seeds or joins a merge group.
2563        if face_is_pinned(&faces[seed], keep_unmerged) {
2564            continue;
2565        }
2566        let mut component = [seed].into_iter().collect::<HashSet<_>>();
2567        loop {
2568            let mut changed = false;
2569            for candidate in 0..faces.len() {
2570                if component.contains(&candidate)
2571                    || face_is_pinned(&faces[candidate], keep_unmerged)
2572                    || faces[candidate].same_sense != faces[seed].same_sense
2573                    || !same_surface(&faces[candidate].surface, &faces[seed].surface, 1e-7)
2574                {
2575                    continue;
2576                }
2577                let candidate_uses = faces[candidate]
2578                    .loops
2579                    .iter()
2580                    .flat_map(|loop_record| &loop_record.coedges);
2581                let adjacent = candidate_uses.into_iter().any(|candidate_use| {
2582                    !edges[&candidate_use.edge_id].degenerate
2583                        && component.iter().any(|member| {
2584                            faces[*member]
2585                                .loops
2586                                .iter()
2587                                .flat_map(|loop_record| &loop_record.coedges)
2588                                .any(|member_use| {
2589                                    member_use.edge_id == candidate_use.edge_id
2590                                        && member_use.forward != candidate_use.forward
2591                                })
2592                        })
2593                });
2594                if adjacent {
2595                    component.insert(candidate);
2596                    changed = true;
2597                }
2598            }
2599            if !changed {
2600                break;
2601            }
2602        }
2603        if component.len() < 2 {
2604            continue;
2605        }
2606
2607        let mut uses_by_edge = HashMap::<u64, Vec<CoedgeRecord>>::default();
2608        for index in &component {
2609            for coedge in faces[*index]
2610                .loops
2611                .iter()
2612                .flat_map(|loop_record| &loop_record.coedges)
2613            {
2614                uses_by_edge
2615                    .entry(coedge.edge_id)
2616                    .or_default()
2617                    .push(coedge.clone());
2618            }
2619        }
2620        if uses_by_edge.values().any(|uses| uses.len() > 2) {
2621            continue;
2622        }
2623        let internal = uses_by_edge
2624            .iter()
2625            .filter_map(|(edge_id, uses)| {
2626                (uses.len() == 2
2627                    && !edges[edge_id].degenerate
2628                    && uses[0].forward != uses[1].forward)
2629                    .then_some(*edge_id)
2630            })
2631            .collect::<HashSet<_>>();
2632        let remaining = uses_by_edge
2633            .into_iter()
2634            .filter(|(edge_id, _)| !internal.contains(edge_id))
2635            .flat_map(|(_, uses)| uses)
2636            .collect::<Vec<_>>();
2637        let space = SharedParameterSpace {
2638            surface: &faces[seed].surface,
2639            weld: crate::tolerance::assembler_weld(tolerance),
2640        };
2641        let Some(mut loops) = walk_cycles(remaining, edges, Some(&space)) else {
2642            continue;
2643        };
2644        if loops.is_empty() {
2645            continue;
2646        }
2647        loops.sort_by(|first, second| {
2648            let area = |loop_record: &LoopRecord| {
2649                parameter_space_area(&FaceRecord {
2650                    id: faces[seed].id,
2651                    surface: faces[seed].surface.clone(),
2652                    same_sense: faces[seed].same_sense,
2653                    loops: vec![loop_record.clone()],
2654                    name: None,
2655                })
2656                .map(f64::abs)
2657            };
2658            match (area(first), area(second)) {
2659                (Ok(a), Ok(b)) => b.total_cmp(&a),
2660                _ => std::cmp::Ordering::Equal,
2661            }
2662        });
2663        let merged = FaceRecord {
2664            id: faces[seed].id,
2665            surface: faces[seed].surface.clone(),
2666            same_sense: faces[seed].same_sense,
2667            loops,
2668            name: faces[seed].name.clone(),
2669        };
2670        let expected = component
2671            .iter()
2672            .map(|index| parameter_space_area(&faces[*index]))
2673            .collect::<Result<Vec<_>, _>>()
2674            .or_refuse(KernelStage::Sew, "healing.face_merge")?
2675            .into_iter()
2676            .sum::<f64>();
2677        let actual =
2678            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
2679        let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
2680        if (actual - expected).abs() > area_tolerance
2681            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
2682        {
2683            continue;
2684        }
2685        let mut indices = component.into_iter().collect::<Vec<_>>();
2686        indices.sort_unstable();
2687        return Ok(Some((indices, merged)));
2688    }
2689    Ok(None)
2690}
2691
2692/// A face is PINNED out of the merge when its `name` contains any substring in
2693/// `keep_unmerged`: it never coalesces with a neighbour (in any merge lane) and
2694/// is emitted unchanged. An empty `keep_unmerged` pins nothing — the historical
2695/// behavior.
2696fn face_is_pinned(face: &FaceRecord, keep_unmerged: &[String]) -> bool {
2697    match &face.name {
2698        Some(name) => keep_unmerged
2699            .iter()
2700            .any(|needle| name.contains(needle.as_str())),
2701        None => false,
2702    }
2703}
2704
2705/// Pairwise merge driver for one shell.
2706///
2707/// `try_merge_pair` returns `None` for any two faces that share no
2708/// non-degenerate edge, so the only candidates are edge-adjacent pairs; they
2709/// are kept in a set ordered by the faces' original positions and evaluated
2710/// smallest first. Faces stay in their original slots (a merged face keeps
2711/// the lower slot, the absorbed face's slot is emptied), so that order never
2712/// shifts. A rejected pair is dropped for good: `try_merge_pair` is a pure
2713/// function of the two face records and the constant edge map, and a pair is
2714/// re-queued only when one of its faces changes. Hence each accepted merge is
2715/// the lexicographically first accepting pair over all faces — the same pair
2716/// the former full rescan found — and the result is the same face set, in
2717/// the same order, reached without re-examining every pair per merge.
2718struct PairMerger<'a> {
2719    slots: Vec<Option<FaceRecord>>,
2720    pinned: Vec<bool>,
2721    edges: &'a HashMap<u64, &'a EdgeRecord>,
2722    /// Non-degenerate edge id → slots of the faces using it.
2723    uses: HashMap<u64, Vec<usize>>,
2724    /// Edge-adjacent pairs `(lower slot, higher slot)` not yet rejected.
2725    candidates: std::collections::BTreeSet<(usize, usize)>,
2726    /// Ids of the faces this merger built (the surviving id of each pair).
2727    merged_ids: Vec<u64>,
2728}
2729
2730impl<'a> PairMerger<'a> {
2731    fn new(
2732        faces: Vec<FaceRecord>,
2733        edges: &'a HashMap<u64, &'a EdgeRecord>,
2734        keep_unmerged: &[String],
2735    ) -> Self {
2736        let pinned = faces
2737            .iter()
2738            .map(|face| face_is_pinned(face, keep_unmerged))
2739            .collect::<Vec<_>>();
2740        let mut merger = Self {
2741            slots: faces.into_iter().map(Some).collect(),
2742            pinned,
2743            edges,
2744            uses: HashMap::default(),
2745            candidates: std::collections::BTreeSet::new(),
2746            merged_ids: Vec::new(),
2747        };
2748        for slot in 0..merger.slots.len() {
2749            merger.index_slot(slot);
2750        }
2751        merger
2752    }
2753
2754    fn slot_edge_ids(&self, slot: usize) -> Vec<u64> {
2755        let Some(face) = &self.slots[slot] else {
2756            return Vec::new();
2757        };
2758        let mut ids = face
2759            .loops
2760            .iter()
2761            .flat_map(|loop_record| &loop_record.coedges)
2762            .map(|coedge| coedge.edge_id)
2763            .filter(|edge_id| !self.edges[edge_id].degenerate)
2764            .collect::<Vec<_>>();
2765        ids.sort_unstable();
2766        ids.dedup();
2767        ids
2768    }
2769
2770    /// Register `slot`'s edges and queue it against every face already
2771    /// using one of them.
2772    fn index_slot(&mut self, slot: usize) {
2773        for edge_id in self.slot_edge_ids(slot) {
2774            let users = self.uses.entry(edge_id).or_default();
2775            for &other in users.iter() {
2776                if other != slot && !self.pinned[other] && !self.pinned[slot] {
2777                    self.candidates.insert((other.min(slot), other.max(slot)));
2778                }
2779            }
2780            users.push(slot);
2781        }
2782    }
2783
2784    fn unindex_slot(&mut self, slot: usize) {
2785        for edge_id in self.slot_edge_ids(slot) {
2786            if let Some(users) = self.uses.get_mut(&edge_id) {
2787                users.retain(|user| *user != slot);
2788            }
2789        }
2790        self.candidates
2791            .retain(|(lower, higher)| *lower != slot && *higher != slot);
2792    }
2793
2794    /// Perform up to `budget` merges; returns how many were made.
2795    fn run(
2796        &mut self,
2797        budget: usize,
2798        tolerance: f64,
2799        same_carrier_area_tolerance_ratio: f64,
2800    ) -> Result<usize, KernelRefusal> {
2801        let mut merged_count = 0;
2802        while merged_count < budget {
2803            let Some(&(lower, higher)) = self.candidates.iter().next() else {
2804                break;
2805            };
2806            self.candidates.remove(&(lower, higher));
2807            let (Some(older), Some(newer)) = (&self.slots[lower], &self.slots[higher]) else {
2808                continue;
2809            };
2810            let Some(merged) = try_merge_pair(
2811                older,
2812                newer,
2813                self.edges,
2814                tolerance,
2815                same_carrier_area_tolerance_ratio,
2816            )?
2817            else {
2818                continue;
2819            };
2820            self.unindex_slot(higher);
2821            self.unindex_slot(lower);
2822            self.slots[higher] = None;
2823            self.merged_ids.push(merged.id);
2824            self.slots[lower] = Some(merged);
2825            self.index_slot(lower);
2826            merged_count += 1;
2827        }
2828        Ok(merged_count)
2829    }
2830
2831    fn into_faces(self) -> Vec<FaceRecord> {
2832        self.slots.into_iter().flatten().collect()
2833    }
2834}
2835
2836/// Merge adjacent fragments that retain the exact same carrier surface.
2837///
2838/// Every shared edge is cancelled in opposite directions and the remaining
2839/// coedges are stitched into outer/hole cycles without refitting geometry.
2840fn merge_same_surface_faces_impl(
2841    solid: &BrepSolid,
2842    tolerance: f64,
2843    validate: bool,
2844    same_carrier_area_tolerance_ratio: f64,
2845    keep_unmerged: &[String],
2846    validation_scope: Option<&HashSet<u64>>,
2847) -> Result<BrepSolid, KernelRefusal> {
2848    let mut result = solid.clone();
2849    let mut merged_ids: Vec<u64> = Vec::new();
2850    // `result.edges` is not touched until the retain below, so the id map is
2851    // built once; every candidate pair sees the same edge records.
2852    let edges = result
2853        .edges
2854        .iter()
2855        .map(|edge| (edge.id, edge))
2856        .collect::<HashMap<_, _>>();
2857    for shell in &mut result.shells {
2858        loop {
2859            if !validate {
2860                if let Some((indices, merged)) = try_merge_connected_group(
2861                    &shell.faces,
2862                    &edges,
2863                    tolerance,
2864                    same_carrier_area_tolerance_ratio,
2865                    keep_unmerged,
2866                )? {
2867                    let first = indices[0];
2868                    shell.faces[first] = merged;
2869                    for index in indices.into_iter().skip(1).rev() {
2870                        shell.faces.remove(index);
2871                    }
2872                    continue;
2873                }
2874            }
2875            // The validating lane has no group phase, so it drains every pair
2876            // merge in one pass; the open lane merges one pair, then offers
2877            // the changed shell to the group phase again, exactly as before.
2878            let budget = if validate { usize::MAX } else { 1 };
2879            let mut merger = PairMerger::new(
2880                std::mem::take(&mut shell.faces),
2881                &edges,
2882                keep_unmerged,
2883            );
2884            let merged = merger.run(budget, tolerance, same_carrier_area_tolerance_ratio);
2885            merged_ids.extend(merger.merged_ids.iter().copied());
2886            shell.faces = merger.into_faces();
2887            if merged? == 0 || validate {
2888                break;
2889            }
2890        }
2891    }
2892    // The validation reads the geometry of the caller's scope and of every
2893    // face this merge built; a face outside both left this pass as it came.
2894    let validation_scope = validation_scope.map(|scope| {
2895        let mut scope = scope.clone();
2896        scope.extend(merged_ids);
2897        scope
2898    });
2899    finish_merge(solid, result, validate, validation_scope.as_ref())
2900}
2901
2902/// Merge each face in `absorbed` into an edge-adjacent face outside `absorbed`
2903/// that rides the same carrier — the pair [`merge_same_surface_faces`] would
2904/// merge, at the same `tolerance` — and nothing else.
2905///
2906/// The absorbing face keeps its id, its name and its slot in the shell, and
2907/// its carrier is the frame the merged face is built on; a face in `absorbed`
2908/// that shares a carrier with no such neighbour is left as it was. Two faces
2909/// outside `absorbed` are never merged with each other, however cosurface: the
2910/// caller is naming whose material a face is, not normalizing the solid, so a
2911/// coplanar pair the document kept apart stays apart. The result passes the
2912/// validating merge's Euler and `validate()` checks or the call refuses.
2913pub(crate) fn absorb_faces_into_carriers(
2914    solid: &BrepSolid,
2915    tolerance: f64,
2916    absorbed: &HashSet<u64>,
2917) -> Result<BrepSolid, KernelRefusal> {
2918    let mut result = solid.clone();
2919    let edges = result
2920        .edges
2921        .iter()
2922        .map(|edge| (edge.id, edge))
2923        .collect::<HashMap<_, _>>();
2924    let edge_ids = |face: &FaceRecord| -> HashSet<u64> {
2925        face.loops
2926            .iter()
2927            .flat_map(|loop_record| &loop_record.coedges)
2928            .map(|coedge| coedge.edge_id)
2929            .filter(|edge_id| !edges[edge_id].degenerate)
2930            .collect()
2931    };
2932    for shell in &mut result.shells {
2933        'merge: loop {
2934            for inner in 0..shell.faces.len() {
2935                if !absorbed.contains(&shell.faces[inner].id) {
2936                    continue;
2937                }
2938                let inner_edges = edge_ids(&shell.faces[inner]);
2939                for outer in 0..shell.faces.len() {
2940                    if outer == inner
2941                        || absorbed.contains(&shell.faces[outer].id)
2942                        || edge_ids(&shell.faces[outer]).is_disjoint(&inner_edges)
2943                    {
2944                        continue;
2945                    }
2946                    let Some(merged) =
2947                        try_merge_pair(&shell.faces[outer], &shell.faces[inner], &edges, tolerance, 1e-7)?
2948                    else {
2949                        continue;
2950                    };
2951                    shell.faces[outer] = merged;
2952                    shell.faces.remove(inner);
2953                    continue 'merge;
2954                }
2955            }
2956            break;
2957        }
2958    }
2959    finish_merge(solid, result, true, None)
2960}
2961
2962/// Rebuild the cone face `id` on one carrier holding all its trims, every trim
2963/// re-read from its 3D edge, with its apex pole stitched back. A blend end grows
2964/// such a face past its old chart. False when it is not a ruled cone patch or the
2965/// rebuilt face does not close.
2966pub(crate) fn recarry_cone_apex_face(solid: &mut BrepSolid, id: u64, tolerance: f64) -> Result<bool, KernelRefusal> {
2967    let Some(face) = solid.shells.iter().flat_map(|shell| &shell.faces).find(|face| face.id == id).cloned() else {
2968        return Ok(false);
2969    };
2970    let Some(support) = ruled_cylinder_support(&face, tolerance, true, true)? else {
2971        return Ok(false);
2972    };
2973    let edges = solid.edges.iter().map(|edge| (edge.id, edge)).collect::<HashMap<_, _>>();
2974    let mut apex = None;
2975    let mut loops = face.loops.clone();
2976    for loop_record in &mut loops {
2977        loop_record.coedges.retain(|coedge| {
2978            let pole = edges[&coedge.edge_id].degenerate;
2979            if pole && apex.is_none() {
2980                apex = Some(coedge.clone());
2981            }
2982            !pole
2983        });
2984    }
2985    let seed = {
2986        let Some(point) = loops.iter().flat_map(|l| &l.coedges).find_map(|coedge| {
2987            let edge = edges[&coedge.edge_id];
2988            let [a, b] = [edge.curve.evaluate(edge.t0).ok()?, edge.curve.evaluate(edge.t1).ok()?];
2989            let radial = |p: Vec3| { let d = p.sub(support.origin); d.sub(support.axis.scale(d.dot(support.axis))) };
2990            if radial(a).length() > radial(b).length() { Some(radial(a)) } else { Some(radial(b)) }
2991        }) else {
2992            return Ok(false);
2993        };
2994        point.normalized().or_refuse(KernelStage::Sew, "normalized")?
2995    };
2996    let y_reference = support.axis.cross(seed);
2997    let mut angles = Vec::new();
2998    let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
2999    for coedge in loops.iter().flat_map(|l| &l.coedges) {
3000        let edge = edges[&coedge.edge_id];
3001        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
3002        let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
3003        let hull = axial_hull(&curve, &support);
3004        axial_range[0] = axial_range[0].min(hull[0]);
3005        axial_range[1] = axial_range[1].max(hull[1]);
3006        for index in 0..=32 {
3007            let delta = curve.evaluate(c0 + (c1 - c0) * index as f64 / 32.0).or_refuse(KernelStage::Sew, "evaluate")?.sub(support.origin);
3008            let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
3009            if radial.length() > (support.radius * 1e-6).max(tolerance) {
3010                angles.push(radial.dot(y_reference).atan2(radial.dot(seed)).rem_euclid(std::f64::consts::TAU));
3011            }
3012        }
3013    }
3014    if angles.len() < 3 {
3015        return Ok(false);
3016    }
3017    angles.sort_by(f64::total_cmp);
3018    let (mut gap_start, mut largest) = (angles.len() - 1, angles[0] + std::f64::consts::TAU - angles[angles.len() - 1]);
3019    for index in 0..angles.len() - 1 {
3020        if angles[index + 1] - angles[index] > largest {
3021            largest = angles[index + 1] - angles[index];
3022            gap_start = index;
3023        }
3024    }
3025    let span = std::f64::consts::TAU - largest;
3026    if span <= tolerance || span >= std::f64::consts::TAU - 1e-6 {
3027        return Ok(false);
3028    }
3029    let start = angles[(gap_start + 1) % angles.len()];
3030    let direction = seed.scale(start.cos()).add(y_reference.scale(start.sin()));
3031    let end_radius = |axial: f64| { let radius = support.radius_at(axial); if radius.abs() <= tolerance { 0.0 } else { radius } };
3032    let at = |axial: f64| support.origin.add(support.axis.scale(axial)).add(direction.scale(end_radius(axial)));
3033    let generatrix = make_line(at(axial_range[0]), at(axial_range[1])).or_refuse(KernelStage::Sew, "healing.face_merge")?;
3034    let surface = make_revolution(support.origin, support.axis, &generatrix, span).or_refuse(KernelStage::Sew, "make_revolution")?;
3035    let (u_domain, v_domain) = surface_domains(&surface)?;
3036    let (mid_u, mid_v) = ((u_domain[0] + u_domain[1]) / 2.0, (v_domain[0] + v_domain[1]) / 2.0);
3037    let delta = surface.evaluate(mid_u, mid_v).or_refuse(KernelStage::Sew, "evaluate")?.sub(support.origin);
3038    let radial = delta.sub(support.axis.scale(delta.dot(support.axis))).normalized().or_refuse(KernelStage::Sew, "normalized")?;
3039    let same_sense = surface.normal(mid_u, mid_v).or_refuse(KernelStage::Sew, "normal")?.dot(radial).signum() == support.outward_radial_sign;
3040    let mut projected = reproject_loops(&surface, loops, &edges)?;
3041    if let Some(apex) = &apex {
3042        if !stitch_cone_apex(&surface, &mut projected, apex, &edges)? {
3043            return Ok(false);
3044        }
3045    }
3046    let rebuilt = FaceRecord { id, surface, same_sense, loops: projected, name: face.name.clone() };
3047    let area = parameter_space_area(&rebuilt).or_refuse(KernelStage::Sew, "parameter_space_area")?;
3048    if area.abs() <= tolerance * tolerance || area.is_sign_positive() != rebuilt.same_sense {
3049        return Ok(false);
3050    }
3051    drop(edges);
3052    for shell in &mut solid.shells {
3053        if let Some(slot) = shell.faces.iter_mut().find(|face| face.id == id) {
3054            *slot = rebuilt;
3055            return Ok(true);
3056        }
3057    }
3058    Ok(false)
3059}
3060
3061/// Merge edge-adjacent faces of `group` that ride the same carrier — the
3062/// pairs [`merge_same_surface_faces`] would merge, at the same `tolerance`,
3063/// plus two lanes only a user's selection opts into: cone patches, and two
3064/// half-turn patches closing a ring on a shared axial edge (see
3065/// `MergeLanes`) — and nothing else: a face outside `group` is never touched,
3066/// however cosurface with a neighbour.
3067///
3068/// Cancelling a shared edge leaves its end vertices between two pieces of one
3069/// rim (the two half circles a split bore's halves each bounded); those
3070/// pieces are joined into one edge where they continue each other exactly
3071/// (`merge_curve_continuation_edges_at`, at those vertices only), so the merged
3072/// face carries the closed rims a face built whole carries.
3073///
3074/// The surviving face of each merged pair is the one in the lower shell slot;
3075/// it keeps its id and name. Returns the merged solid and, for every face id
3076/// of `group` (sorted, deduplicated), the pair `(face, carrier)` naming the
3077/// face that now carries it (itself when it did not merge). The result passes
3078/// the validating merge's Euler and `validate()` checks, scoped to the merged
3079/// faces, or the call refuses.
3080///
3081/// Push Face uses it to treat the halves of a split carrier the user selected
3082/// together (an imported bore written as two half cylinders) as the one face
3083/// the push moves: a half pushed alone can never meet its unmoved sibling.
3084pub fn merge_faces_within(
3085    solid: &BrepSolid,
3086    tolerance: f64,
3087    group: &[u64],
3088) -> Result<(BrepSolid, Vec<(u64, u64)>), KernelRefusal> {
3089    let group: HashSet<u64> = group.iter().copied().collect();
3090    let mut carried_by: Vec<(u64, u64)> = group.iter().map(|id| (*id, *id)).collect();
3091    carried_by.sort_unstable();
3092    let mut result = solid.clone();
3093    let edges = result
3094        .edges
3095        .iter()
3096        .map(|edge| (edge.id, edge))
3097        .collect::<HashMap<_, _>>();
3098    let edge_ids = |face: &FaceRecord| -> HashSet<u64> {
3099        face.loops
3100            .iter()
3101            .flat_map(|loop_record| &loop_record.coedges)
3102            .map(|coedge| coedge.edge_id)
3103            .filter(|edge_id| !edges[edge_id].degenerate)
3104            .collect()
3105    };
3106    let mut merged_ids: HashSet<u64> = HashSet::default();
3107    let mut cancelled_edges: HashSet<u64> = HashSet::default();
3108    // End vertices of the shared edges merged faces kept as their seams.
3109    let mut seam_vertices: HashSet<u64> = HashSet::default();
3110    for shell in &mut result.shells {
3111        'merge: loop {
3112            // Only the group's own slots are read: a 1 200-face import pays
3113            // for its four selected faces, not for every pair of the shell.
3114            let slots: Vec<usize> = (0..shell.faces.len())
3115                .filter(|slot| group.contains(&shell.faces[*slot].id))
3116                .collect();
3117            for (index, &lower) in slots.iter().enumerate() {
3118                let lower_edges = edge_ids(&shell.faces[lower]);
3119                for &higher in &slots[index + 1..] {
3120                    let higher_edges = edge_ids(&shell.faces[higher]);
3121                    if higher_edges.is_disjoint(&lower_edges) {
3122                        continue;
3123                    }
3124                    let lanes = MergeLanes {
3125                        cones: true,
3126                        shared_seam_ring: true,
3127                        seam_vertices: Some(&seam_vertices),
3128                    };
3129                    let Some(mut merged) = try_merge_pair_lanes(
3130                        &shell.faces[lower],
3131                        &shell.faces[higher],
3132                        &edges,
3133                        tolerance,
3134                        1e-7,
3135                        lanes,
3136                    )?
3137                    else {
3138                        continue;
3139                    };
3140                    let survivor = shell.faces[lower].id;
3141                    let absorbed = shell.faces[higher].id;
3142                    merged.id = survivor;
3143                    merged.name = shell.faces[lower].name.clone();
3144                    let kept = edge_ids(&merged);
3145                    for edge_id in lower_edges.intersection(&higher_edges) {
3146                        if kept.contains(edge_id) {
3147                            let edge = edges[edge_id];
3148                            seam_vertices.insert(edge.start_vertex_id);
3149                            seam_vertices.insert(edge.end_vertex_id);
3150                        } else {
3151                            cancelled_edges.insert(*edge_id);
3152                        }
3153                    }
3154                    for (_, carrier) in carried_by.iter_mut() {
3155                        if *carrier == absorbed {
3156                            *carrier = survivor;
3157                        }
3158                    }
3159                    merged_ids.insert(survivor);
3160                    shell.faces[lower] = merged;
3161                    shell.faces.remove(higher);
3162                    continue 'merge;
3163                }
3164            }
3165            break;
3166        }
3167    }
3168    if merged_ids.is_empty() {
3169        return Ok((result, carried_by));
3170    }
3171    let joints: HashSet<u64> = cancelled_edges
3172        .iter()
3173        .flat_map(|edge_id| {
3174            let edge = edges[edge_id];
3175            [edge.start_vertex_id, edge.end_vertex_id]
3176        })
3177        .collect();
3178    let result = finish_merge(solid, result, true, Some(&merged_ids))?;
3179    if joints.is_empty() {
3180        return Ok((result, carried_by));
3181    }
3182    let result = crate::coalesce::merge_curve_continuation_edges_at(&result, tolerance, &joints)?;
3183    Ok((result, carried_by))
3184}
3185
3186/// The tail every merge shares: drop the edges and vertices no face uses any
3187/// more and, on the validating lane, recount the genus and validate.
3188fn finish_merge(
3189    solid: &BrepSolid,
3190    mut result: BrepSolid,
3191    validate: bool,
3192    validation_scope: Option<&HashSet<u64>>,
3193) -> Result<BrepSolid, KernelRefusal> {
3194    let used_edges = result
3195        .shells
3196        .iter()
3197        .flat_map(|shell| &shell.faces)
3198        .flat_map(|face| &face.loops)
3199        .flat_map(|loop_record| &loop_record.coedges)
3200        .map(|coedge| coedge.edge_id)
3201        .collect::<HashSet<_>>();
3202    result.edges.retain(|edge| used_edges.contains(&edge.id));
3203    let used_vertices = result
3204        .edges
3205        .iter()
3206        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
3207        .collect::<HashSet<_>>();
3208    result
3209        .vertices
3210        .retain(|vertex| used_vertices.contains(&vertex.id));
3211    if !validate {
3212        return Ok(result);
3213    }
3214    if std::env::var("BREP_DEBUG_MERGE").is_ok() {
3215        let entry_faces: i64 = solid.shells.iter().map(|s| s.faces.len() as i64).sum();
3216        let entry_holes: i64 = solid
3217            .shells
3218            .iter()
3219            .flat_map(|s| &s.faces)
3220            .map(|f| f.loops.len().saturating_sub(1) as i64)
3221            .sum();
3222        let entry_edges = solid.edges.iter().filter(|e| !e.degenerate).count() as i64;
3223        let entry_used: std::collections::HashSet<u64> = solid
3224            .shells
3225            .iter()
3226            .flat_map(|s| &s.faces)
3227            .flat_map(|f| &f.loops)
3228            .flat_map(|l| &l.coedges)
3229            .map(|c| c.edge_id)
3230            .collect();
3231        eprintln!(
3232            "merge entry census: V={} E={entry_edges} (coedge-referenced {}) F={entry_faces} H={entry_holes} S={} stored_genus={}",
3233            solid.vertices.len(),
3234            entry_used.len(),
3235            solid.shells.len(),
3236            solid.genus
3237        );
3238    }
3239    let face_count = result
3240        .shells
3241        .iter()
3242        .map(|shell| shell.faces.len() as i64)
3243        .sum::<i64>();
3244    let hole_count = result.bounding_hole_count();
3245    let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
3246    // Count only LIVE vertices (referenced by a non-degenerate edge): the
3247    // merge removes edges and faces, orphaning vertices that then must not
3248    // participate in the Euler census — mirroring refresh_derived_genus
3249    // (boolean.rs). Counting stale vertices inflated V and rejected valid
3250    // merges as "non-integral genus" (the helmet report's final layer).
3251    let live_vertices: std::collections::HashSet<u64> = result
3252        .edges
3253        .iter()
3254        .filter(|edge| !edge.degenerate)
3255        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
3256        .collect();
3257    let vertex_count = result
3258        .vertices
3259        .iter()
3260        .filter(|vertex| live_vertices.contains(&vertex.id))
3261        .count() as i64;
3262    let euler = vertex_count - edge_count + face_count - hole_count;
3263    let numerator = result.shells.len() as i64 * 2 - euler;
3264    // Mirror validate()'s convention (topology.rs): the reduced V−E+F−H
3265    // formula does not model parameter-space POLE COLLAPSES reliably, so on a
3266    // complex carrying degenerate edges the parity check must not reject —
3267    // the helmet result holds 17 legitimate degenerate (pole) edges and its
3268    // odd numerator here is a formula artifact, not a defect; validate()
3269    // below performs the real integrity checks either way. With no
3270    // degenerate edges the strict behavior is unchanged.
3271    let has_degenerate = result.edges.iter().any(|edge| edge.degenerate);
3272    if numerator < 0 || numerator % 2 != 0 {
3273        if std::env::var("BREP_DEBUG_MERGE").is_ok() {
3274            eprintln!(
3275                "merge euler reject: V={vertex_count} E={edge_count} F={face_count} H={hole_count} S={} euler={euler} numerator={numerator} degenerate={has_degenerate}",
3276                result.shells.len()
3277            );
3278        }
3279        if !has_degenerate
3280            || std::env::var("BREP_MERGE_DEGENERATE_EULER_STRICT").as_deref() == Ok("1")
3281        {
3282            return Err(KernelRefusal::new(
3283                RefusalClass::NonIntegralGenus {
3284                    shells: result.shells.len() as u32,
3285                    euler: euler as i64,
3286                },
3287                KernelStage::Validate,
3288                "merge_same_surface_faces produced non-integral genus",
3289            ));
3290        }
3291    } else {
3292        result.genus = numerator / 2;
3293    }
3294    let issues = match validation_scope {
3295        Some(scope) => result.validate_scoped(scope),
3296        None => result.validate(),
3297    };
3298    if !issues.is_empty() {
3299        return Err(KernelRefusal::new(
3300            RefusalClass::DegenerateArrangement {
3301                open_edges: 0,
3302                issues: issues.len() as u32,
3303            },
3304            KernelStage::Validate,
3305            format!("merge_same_surface_faces produced invalid topology: {issues:?}"),
3306        ));
3307    }
3308    Ok(result)
3309}
3310
3311pub fn merge_same_surface_faces(
3312    solid: &BrepSolid,
3313    tolerance: f64,
3314) -> Result<BrepSolid, KernelRefusal> {
3315    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, &[], None)
3316}
3317
3318/// [`merge_same_surface_faces_excluding`] whose closing validation reads
3319/// the geometry of the faces in `scope` and of the faces the merge built;
3320/// every pair still merges exactly as the plain call merges it (an operand's
3321/// own coplanar neighbours included: the 2026-10-10 census found a mesh
3322/// import whose five such pairs the boolean fuses). A boolean passes the
3323/// faces its operation touched and their neighbours
3324/// (`BooleanOptions::untouched_passthrough`); the rest were validated by the
3325/// assembler and left this pass as they came.
3326pub fn merge_same_surface_faces_scoped(
3327    solid: &BrepSolid,
3328    tolerance: f64,
3329    keep_unmerged: &[String],
3330    scope: &HashSet<u64>,
3331) -> Result<BrepSolid, KernelRefusal> {
3332    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, keep_unmerged, Some(scope))
3333}
3334
3335/// Like [`merge_same_surface_faces`], but any face whose `name` CONTAINS one of
3336/// `keep_unmerged` is pinned: it never merges with a coplanar/cosurface
3337/// neighbour and is emitted unchanged, while every other face merges exactly as
3338/// [`merge_same_surface_faces`] would. An empty `keep_unmerged` is identical to
3339/// the plain call.
3340pub fn merge_same_surface_faces_excluding(
3341    solid: &BrepSolid,
3342    tolerance: f64,
3343    keep_unmerged: &[String],
3344) -> Result<BrepSolid, KernelRefusal> {
3345    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, keep_unmerged, None)
3346}
3347
3348pub(crate) fn merge_same_surface_faces_open(
3349    solid: &BrepSolid,
3350    tolerance: f64,
3351) -> Result<BrepSolid, KernelRefusal> {
3352    merge_same_surface_faces_impl(solid, tolerance, false, 5e-3, &[], None)
3353}
3354