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