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