Skip to main content

brep_kernel/healing/
face_merge.rs

1use crate::analytic_surface::circumcenter;
2use crate::mass_properties::{face_area, 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    let plane_tolerance = (tolerance * 100.0).max(1e-6) * (1.0 + first_origin.length());
134    for u in second_u {
135        for v in second_v {
136            if second
137                .surface
138                .evaluate(u, v)
139                .or_refuse(KernelStage::Sew, "evaluate")?
140                .sub(first_origin)
141                .dot(first_normal)
142                .abs()
143                > plane_tolerance
144            {
145                return Ok(false);
146            }
147        }
148    }
149    Ok(true)
150}
151
152/// Circular-arc support of a curve, detected by sampling: circumcenter of
153/// three spread samples, validated against all samples for constant radius
154/// and planarity. Returns (center, unit plane normal, radius).
155fn arc_circle_support(
156    curve: &NurbsCurve,
157    tolerance: f64,
158) -> Result<Option<(Vec3, Vec3, f64)>, KernelRefusal> {
159    let [t0, t1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
160    let samples = (0..=8)
161        .map(|index| curve.evaluate(t0 + (t1 - t0) * index as f64 / 8.0))
162        .collect::<Result<Vec<_>, _>>()
163        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
164    // A full-circle carrier evaluates identical endpoints, so the sample
165    // triple must avoid (start, end); fall back across spreads until one is
166    // non-degenerate.
167    let Some(center) = circumcenter(samples[0], samples[2], samples[5])
168        .or_else(|| circumcenter(samples[0], samples[4], samples[8]))
169        .or_else(|| circumcenter(samples[1], samples[4], samples[7]))
170    else {
171        return Ok(None);
172    };
173    let radius = samples[0].sub(center).length();
174    if radius <= tolerance {
175        return Ok(None);
176    }
177    let normal = match samples[2]
178        .sub(samples[0])
179        .cross(samples[5].sub(samples[0]))
180        .normalized()
181        .or_else(|_| {
182            samples[4]
183                .sub(samples[0])
184                .cross(samples[8].sub(samples[0]))
185                .normalized()
186        }) {
187        Ok(normal) => normal,
188        Err(_) => return Ok(None),
189    };
190    let band = tolerance.max(1e-9 * radius);
191    for sample in &samples {
192        let delta = sample.sub(center);
193        if (delta.length() - radius).abs() > band || delta.dot(normal).abs() > band {
194            if std::env::var("BREP_DEBUG_MERGE").is_ok() {
195                eprintln!(
196                    "    arc reject: radius={radius} dr={} dplane={} band={band}",
197                    (delta.length() - radius).abs(),
198                    delta.dot(normal).abs()
199                );
200            }
201            return Ok(None);
202        }
203    }
204    Ok(Some((center, normal, radius)))
205}
206
207struct CylinderSupport {
208    origin: Vec3,
209    axis: Vec3,
210    radius: f64,
211    /// +1 when the face's outward normal points radially away from the
212    /// axis, -1 when it points inward.
213    outward_radial_sign: f64,
214}
215
216/// Detect a ruled cylinder patch: quadratic circular arc in one parameter
217/// direction, linear in the other, both boundary arcs sharing an axis and
218/// radius. Covers extruded arcs, revolved axis-parallel lines, and
219/// primitive cylinder walls regardless of parameterization.
220fn ruled_cylinder_support(
221    face: &FaceRecord,
222    tolerance: f64,
223) -> Result<Option<CylinderSupport>, KernelRefusal> {
224    let surface = &face.surface;
225    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
226    let (arc_first, columns) = match (surface.degree_u, surface.degree_v) {
227        (2, 1) => (true, surface.control_points[0].len()),
228        (1, 2) => (false, surface.control_points.len()),
229        _ => {
230            if debug {
231                eprintln!(
232                    "  support face {}: degrees ({},{}) not ruled-arc",
233                    face.id, surface.degree_u, surface.degree_v
234                );
235            }
236            return Ok(None);
237        }
238    };
239    // Boolean splits can insert linear-direction knots, so a ruled carrier
240    // may hold more than two columns; the outermost columns still bound the
241    // patch and interior straightness is enforced by the final area check.
242    let last = columns - 1;
243    let iso_curve = |side: usize| -> Result<NurbsCurve, KernelRefusal> {
244        let controls = if arc_first {
245            surface
246                .control_points
247                .iter()
248                .map(|row| row[side])
249                .collect::<Vec<_>>()
250        } else {
251            surface.control_points[side].clone()
252        };
253        let knots = if arc_first {
254            surface.knots_u.clone()
255        } else {
256            surface.knots_v.clone()
257        };
258        NurbsCurve::new(2, knots, controls).or_refuse(KernelStage::Sew, "NurbsCurve::new")
259    };
260    let scale = surface
261        .control_points
262        .iter()
263        .flatten()
264        .map(|point| point.point().map(|p| p.length()).unwrap_or(0.0))
265        .fold(1.0f64, f64::max);
266    let circle_tolerance = (tolerance * 100.0).max(1e-7 * scale);
267    let Some((center0, normal0, radius0)) = arc_circle_support(&iso_curve(0)?, circle_tolerance)?
268    else {
269        if debug {
270            eprintln!("  support face {}: first iso not circular", face.id);
271        }
272        return Ok(None);
273    };
274    let Some((center1, normal1, radius1)) =
275        arc_circle_support(&iso_curve(last)?, circle_tolerance)?
276    else {
277        if debug {
278            eprintln!("  support face {}: last iso not circular", face.id);
279        }
280        return Ok(None);
281    };
282    if (radius0 - radius1).abs() > circle_tolerance {
283        if debug {
284            eprintln!(
285                "  support face {}: radii differ {} vs {}",
286                face.id, radius0, radius1
287            );
288        }
289        return Ok(None);
290    }
291    let axial = center1.sub(center0);
292    if axial.length() <= circle_tolerance {
293        if debug {
294            eprintln!("  support face {}: zero axial extent", face.id);
295        }
296        return Ok(None);
297    }
298    let axis = axial
299        .normalized()
300        .or_refuse(KernelStage::Sew, "normalized")?;
301    if axis.dot(normal0).abs() < 1.0 - 1e-7 || axis.dot(normal1).abs() < 1.0 - 1e-7 {
302        if debug {
303            eprintln!(
304                "  support face {}: arc planes not perpendicular to axis",
305                face.id
306            );
307        }
308        return Ok(None);
309    }
310    let outward = outward_normal(face)?;
311    let (u, v) = surface_domains(surface)?;
312    let midpoint = surface
313        .evaluate((u[0] + u[1]) / 2.0, (v[0] + v[1]) / 2.0)
314        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
315    let foot = center0.add(axis.scale(midpoint.sub(center0).dot(axis)));
316    let radial = match midpoint.sub(foot).normalized() {
317        Ok(radial) => radial,
318        Err(_) => return Ok(None),
319    };
320    let alignment = outward.dot(radial);
321    if alignment.abs() < 0.9 {
322        if debug {
323            eprintln!(
324                "  support face {}: outward not radial ({alignment})",
325                face.id
326            );
327        }
328        return Ok(None);
329    }
330    Ok(Some(CylinderSupport {
331        origin: center0,
332        axis,
333        radius: radius0,
334        outward_radial_sign: alignment.signum(),
335    }))
336}
337
338fn cosurface_cylinder_pair(
339    first: &FaceRecord,
340    second: &FaceRecord,
341    tolerance: f64,
342) -> Result<Option<CylinderSupport>, KernelRefusal> {
343    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
344    let Some(support_a) = ruled_cylinder_support(first, tolerance)? else {
345        if debug {
346            eprintln!(
347                "merge {}x{}: first not a ruled cylinder",
348                first.id, second.id
349            );
350        }
351        return Ok(None);
352    };
353    let Some(support_b) = ruled_cylinder_support(second, tolerance)? else {
354        if debug {
355            eprintln!(
356                "merge {}x{}: second not a ruled cylinder",
357                first.id, second.id
358            );
359        }
360        return Ok(None);
361    };
362    let scale = 1.0f64
363        .max(support_a.origin.length())
364        .max(support_b.origin.length())
365        .max(support_a.radius);
366    let band = (tolerance * 100.0).max(1e-7 * scale);
367    if support_a.axis.dot(support_b.axis).abs() < 1.0 - 1e-7
368        || (support_a.radius - support_b.radius).abs() > band
369        || support_a.outward_radial_sign != support_b.outward_radial_sign
370    {
371        if debug {
372            eprintln!(
373                "merge {}x{}: axis/radius/sign mismatch dot={} dr={} signs=({},{})",
374                first.id,
375                second.id,
376                support_a.axis.dot(support_b.axis),
377                (support_a.radius - support_b.radius).abs(),
378                support_a.outward_radial_sign,
379                support_b.outward_radial_sign
380            );
381        }
382        return Ok(None);
383    }
384    let offset = support_b.origin.sub(support_a.origin);
385    let perpendicular = offset.sub(support_a.axis.scale(offset.dot(support_a.axis)));
386    if perpendicular.length() > band {
387        if debug {
388            eprintln!(
389                "merge {}x{}: axes offset {}",
390                first.id,
391                second.id,
392                perpendicular.length()
393            );
394        }
395        return Ok(None);
396    }
397    if debug {
398        eprintln!(
399            "merge {}x{}: MATCH r={} axis=({:.4},{:.4},{:.4})",
400            first.id,
401            second.id,
402            support_a.radius,
403            support_a.axis.x,
404            support_a.axis.y,
405            support_a.axis.z
406        );
407    }
408    Ok(Some(support_a))
409}
410
411struct ExtrusionSupport {
412    /// Profile iso curve at the sweep start, in its actual 3D position.
413    base: NurbsCurve,
414    /// Unit sweep direction (from column 0 toward the last column).
415    direction: Vec3,
416    /// Axial interval the patch occupies along `direction`.
417    interval: [f64; 2],
418}
419
420/// Detect a linear sweep of an arbitrary profile curve: one parameter
421/// direction is degree 1 with every profile control translated by the SAME
422/// vector (weights unchanged), and the profile lies in a plane perpendicular
423/// to the sweep. This is exact at the control-point level, so it also merges
424/// swept faces whose profile is only approximately circular.
425fn ruled_extrusion_support(
426    face: &FaceRecord,
427    tolerance: f64,
428) -> Result<Option<ExtrusionSupport>, KernelRefusal> {
429    let surface = &face.surface;
430    // Planar faces are the coplanar path's business; a plane recognized as
431    // an "extrusion of a line" here could merge across a knife edge.
432    if planar_support(face, tolerance)?.is_some() {
433        return Ok(None);
434    }
435    let profile_first = if surface.degree_v == 1 && surface.degree_u >= 1 {
436        true
437    } else if surface.degree_u == 1 && surface.degree_v >= 1 {
438        false
439    } else {
440        return Ok(None);
441    };
442    let (profile_count, linear_count) = if profile_first {
443        (
444            surface.control_points.len(),
445            surface.control_points[0].len(),
446        )
447    } else {
448        (
449            surface.control_points[0].len(),
450            surface.control_points.len(),
451        )
452    };
453    if linear_count < 2 {
454        return Ok(None);
455    }
456    let control = |profile_index: usize, linear_index: usize| -> Vec4 {
457        if profile_first {
458            surface.control_points[profile_index][linear_index]
459        } else {
460            surface.control_points[linear_index][profile_index]
461        }
462    };
463    let scale = surface
464        .control_points
465        .iter()
466        .flatten()
467        .map(|point| point.point().map(|p| p.length()).unwrap_or(0.0))
468        .fold(1.0f64, f64::max);
469    let band = (tolerance * 100.0).max(1e-7 * scale);
470    let mut translation: Option<Vec3> = None;
471    for profile_index in 0..profile_count {
472        let first = control(profile_index, 0);
473        let last = control(profile_index, linear_count - 1);
474        if (first.w - last.w).abs() > 1e-9 * first.w.abs().max(1.0) {
475            return Ok(None);
476        }
477        let step = last
478            .point()
479            .or_refuse(KernelStage::Sew, "point")?
480            .sub(first.point().or_refuse(KernelStage::Sew, "point")?);
481        if let Some(reference) = translation {
482            if step.sub(reference).length() > band {
483                return Ok(None);
484            }
485        } else {
486            translation = Some(step);
487        }
488        let start = first.point().or_refuse(KernelStage::Sew, "point")?;
489        for linear_index in 1..linear_count - 1 {
490            let middle = control(profile_index, linear_index);
491            if (middle.w - first.w).abs() > 1e-9 * first.w.abs().max(1.0) {
492                return Ok(None);
493            }
494            let offset = middle
495                .point()
496                .or_refuse(KernelStage::Sew, "point")?
497                .sub(start);
498            let along = offset.dot(step) / step.dot(step).max(1e-30);
499            if offset.sub(step.scale(along)).length() > band
500                || !(-1e-9..=1.0 + 1e-9).contains(&along)
501            {
502                return Ok(None);
503            }
504        }
505    }
506    let translation = translation.ok_or_else(|| {
507        KernelRefusal::internal(
508            KernelStage::Sew,
509            "face_merge",
510            "extrusion support: empty net",
511        )
512    })?;
513    if translation.length() <= band {
514        return Ok(None);
515    }
516    let direction = translation
517        .normalized()
518        .or_refuse(KernelStage::Sew, "normalized")?;
519    let base_controls = (0..profile_count).map(|index| control(index, 0)).collect();
520    let base_knots = if profile_first {
521        surface.knots_u.clone()
522    } else {
523        surface.knots_v.clone()
524    };
525    let base_degree = if profile_first {
526        surface.degree_u
527    } else {
528        surface.degree_v
529    };
530    let base = NurbsCurve::new(base_degree, base_knots, base_controls)
531        .or_refuse(KernelStage::Sew, "new")?;
532    let [t0, t1] = base.domain().or_refuse(KernelStage::Sew, "domain")?;
533    let mut axial = [f64::INFINITY, f64::NEG_INFINITY];
534    for index in 0..=8 {
535        let z = base
536            .evaluate(t0 + (t1 - t0) * index as f64 / 8.0)
537            .or_refuse(KernelStage::Sew, "evaluate")?
538            .dot(direction);
539        axial[0] = axial[0].min(z);
540        axial[1] = axial[1].max(z);
541    }
542    if axial[1] - axial[0] > band {
543        // Slanted profiles sweep skewed slabs whose axial union is not an
544        // interval; keep the merge to perpendicular profiles.
545        return Ok(None);
546    }
547    let start = (axial[0] + axial[1]) / 2.0;
548    Ok(Some(ExtrusionSupport {
549        base,
550        direction,
551        interval: [start, start + translation.length()],
552    }))
553}
554
555fn perpendicular_profile(curve: &NurbsCurve, direction: Vec3) -> Result<NurbsCurve, KernelRefusal> {
556    let controls = curve
557        .control_points
558        .iter()
559        .map(|control| {
560            let point = control.point().or_refuse(KernelStage::Sew, "point")?;
561            Ok(Vec4::from_point(
562                point.sub(direction.scale(point.dot(direction))),
563                control.w,
564            ))
565        })
566        .collect::<Result<Vec<_>, KernelRefusal>>()?;
567    NurbsCurve::new(curve.degree, curve.knots.clone(), controls)
568        .or_refuse(KernelStage::Sew, "NurbsCurve::new")
569}
570
571fn curves_coincide(
572    first: &NurbsCurve,
573    second: &NurbsCurve,
574    band: f64,
575) -> Result<bool, KernelRefusal> {
576    for (from, to) in [(first, second), (second, first)] {
577        let [t0, t1] = from.domain().or_refuse(KernelStage::Sew, "domain")?;
578        for index in 0..=8 {
579            let point = from
580                .evaluate(t0 + (t1 - t0) * index as f64 / 8.0)
581                .or_refuse(KernelStage::Sew, "evaluate")?;
582            let projection = match crate::project_point_to_curve(to, point) {
583                Ok(projection) => projection,
584                Err(_) => return Ok(false),
585            };
586            if projection.distance > band {
587                return Ok(false);
588            }
589        }
590    }
591    Ok(true)
592}
593
594/// Matching linear sweeps: parallel directions, identical perpendicular
595/// profile, adjacent (or touching) axial intervals.
596fn coextrusion_pair(
597    older: &FaceRecord,
598    newer: &FaceRecord,
599    tolerance: f64,
600) -> Result<Option<(ExtrusionSupport, [f64; 2])>, KernelRefusal> {
601    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
602    let Some(support_a) = ruled_extrusion_support(older, tolerance)? else {
603        return Ok(None);
604    };
605    let Some(support_b) = ruled_extrusion_support(newer, tolerance)? else {
606        return Ok(None);
607    };
608    let alignment = support_a.direction.dot(support_b.direction);
609    if alignment.abs() < 1.0 - 1e-9 {
610        if debug {
611            eprintln!(
612                "coextrusion {}x{}: directions differ (dot {alignment})",
613                older.id, newer.id
614            );
615        }
616        return Ok(None);
617    }
618    let scale = 1.0f64
619        .max(support_a.interval[1].abs())
620        .max(support_b.interval[1].abs());
621    let band = (tolerance * 100.0).max(1e-7 * scale);
622    let profile_a = perpendicular_profile(&support_a.base, support_a.direction)?;
623    let profile_b = perpendicular_profile(&support_b.base, support_a.direction)?;
624    if !curves_coincide(&profile_a, &profile_b, band)? {
625        if debug {
626            eprintln!("coextrusion {}x{}: profiles differ", older.id, newer.id);
627        }
628        return Ok(None);
629    }
630    // Express B's interval along A's direction regardless of B's own sweep
631    // orientation: anti-parallel sweeps negate the axial coordinate.
632    let interval_b = if alignment < 0.0 {
633        [-support_b.interval[1], -support_b.interval[0]]
634    } else {
635        support_b.interval
636    };
637    if interval_b[0] > support_a.interval[1] + band || support_a.interval[0] > interval_b[1] + band
638    {
639        if debug {
640            eprintln!(
641                "coextrusion {}x{}: axial gap {:?} vs {:?}",
642                older.id, newer.id, support_a.interval, interval_b
643            );
644        }
645        return Ok(None);
646    }
647    let union = [
648        support_a.interval[0].min(interval_b[0]),
649        support_a.interval[1].max(interval_b[1]),
650    ];
651    Ok(Some((support_a, union)))
652}
653
654fn trimmed_curve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, KernelRefusal> {
655    let [start, end] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
656    let epsilon = (1e-9 * (end - start)).max(2e-9);
657    let mut result = curve.clone();
658    if t0 > start + epsilon && t0 < end - epsilon {
659        result = result.split(t0).or_refuse(KernelStage::Sew, "split")?.1;
660    }
661    let domain = result.domain().or_refuse(KernelStage::Sew, "domain")?;
662    if t1 < domain[1] - epsilon && t1 > domain[0] + epsilon {
663        result = result.split(t1).or_refuse(KernelStage::Sew, "split")?.0;
664    } else if std::env::var("BREP_DEBUG_SUBRANGE").is_ok() && t1 < domain[1] - epsilon {
665        eprintln!("abnormal subrange skip in face_merge trimmed");
666    }
667    Ok(result)
668}
669
670fn reproject_loops(
671    surface: &NurbsSurface,
672    loops: Vec<LoopRecord>,
673    edges: &HashMap<u64, &EdgeRecord>,
674) -> Result<Vec<LoopRecord>, KernelRefusal> {
675    let mut projected_loops = Vec::new();
676    for (loop_index, loop_record) in loops.into_iter().enumerate() {
677        let mut projected = Vec::new();
678        for mut coedge in loop_record.coedges {
679            let edge = edges[&coedge.edge_id];
680            let mut curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
681            if !coedge.forward {
682                curve = curve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
683            }
684            coedge.pcurve = build_pcurve_on_surface(surface, &curve)
685                .or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
686            projected.push(coedge);
687        }
688        projected_loops.push(LoopRecord {
689            id: loop_index as u64 + 1,
690            coedges: projected,
691        });
692    }
693    Ok(projected_loops)
694}
695
696fn traversal_vertices(edge: &EdgeRecord, coedge: &CoedgeRecord) -> (u64, u64) {
697    if coedge.forward {
698        (edge.start_vertex_id, edge.end_vertex_id)
699    } else {
700        (edge.end_vertex_id, edge.start_vertex_id)
701    }
702}
703
704/// Whether two faces ride ONE carrier — the same geometric surface, oriented
705/// the same way out of the material — by any of the four readings this module
706/// merges on: bit-identical patches, coplanar planes with the same outward
707/// normal, cosurface cylinder patches, and coextruded profiles.
708///
709/// `pub(crate)` because the direct-edit rejoin asks the same question for the
710/// same reason: a survivor face on the far side of a deleted band can only
711/// take the near side's loops when the two are one carrier. It asks GEOMETRIC
712/// cosurface rather than [`same_surface`] alone, because a mirrored twin is
713/// the same surface reparametrized — reflected control points, flipped
714/// `same_sense` — and bit-identity reads that as two.
715pub(crate) fn faces_are_cosurface(
716    first: &FaceRecord,
717    second: &FaceRecord,
718    tolerance: f64,
719) -> Result<bool, KernelRefusal> {
720    if first.same_sense == second.same_sense
721        && same_surface(&first.surface, &second.surface, tolerance)
722    {
723        return Ok(true);
724    }
725    if coplanar_with_same_outward_normal(first, second, tolerance)? {
726        return Ok(true);
727    }
728    if cosurface_cylinder_pair(first, second, tolerance)?.is_some() {
729        return Ok(true);
730    }
731    Ok(coextrusion_pair(first, second, tolerance)?.is_some())
732}
733
734fn try_merge_pair(
735    older: &FaceRecord,
736    newer: &FaceRecord,
737    edges: &HashMap<u64, &EdgeRecord>,
738    tolerance: f64,
739    same_carrier_area_tolerance_ratio: f64,
740) -> Result<Option<FaceRecord>, KernelRefusal> {
741    let same_carrier = older.same_sense == newer.same_sense
742        && same_surface(&older.surface, &newer.surface, tolerance);
743    let coplanar_planar =
744        !same_carrier && coplanar_with_same_outward_normal(older, newer, tolerance)?;
745    let cosurface_cylinder = if !same_carrier && !coplanar_planar {
746        cosurface_cylinder_pair(older, newer, tolerance)?
747    } else {
748        None
749    };
750    let cosurface_extrusion = if !same_carrier && !coplanar_planar && cosurface_cylinder.is_none() {
751        coextrusion_pair(older, newer, tolerance)?
752    } else {
753        None
754    };
755    let older_coedges = older
756        .loops
757        .iter()
758        .flat_map(|loop_record| &loop_record.coedges)
759        .collect::<Vec<_>>();
760    let newer_coedges = newer
761        .loops
762        .iter()
763        .flat_map(|loop_record| &loop_record.coedges)
764        .collect::<Vec<_>>();
765    let mut shared_older = HashSet::default();
766    let mut shared_newer = HashSet::default();
767    for first in &older_coedges {
768        if edges[&first.edge_id].degenerate {
769            continue;
770        }
771        for second in &newer_coedges {
772            if first.edge_id != second.edge_id {
773                continue;
774            }
775            if first.forward == second.forward {
776                return Ok(None);
777            }
778            shared_older.insert(first.id);
779            shared_newer.insert(second.id);
780        }
781    }
782    if shared_older.is_empty() || shared_older.len() != shared_newer.len() {
783        return Ok(None);
784    }
785    if !same_carrier
786        && !coplanar_planar
787        && cosurface_cylinder.is_none()
788        && cosurface_extrusion.is_none()
789    {
790        return Ok(None);
791    }
792    let maximum_cycle_length = older_coedges.len() + newer_coedges.len();
793    let mut remaining = older_coedges
794        .into_iter()
795        .filter(|coedge| {
796            !shared_older.contains(&coedge.id)
797                && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
798        })
799        .chain(newer_coedges.into_iter().filter(|coedge| {
800            !shared_newer.contains(&coedge.id)
801                && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
802        }))
803        .cloned()
804        .collect::<Vec<_>>();
805    let mut loops = Vec::new();
806    while !remaining.is_empty() {
807        let first = remaining.remove(0);
808        let (start, mut end) = traversal_vertices(edges[&first.edge_id], &first);
809        let mut cycle = vec![first];
810        while end != start {
811            let matches = remaining
812                .iter()
813                .enumerate()
814                .filter_map(|(index, coedge)| {
815                    (traversal_vertices(edges[&coedge.edge_id], coedge).0 == end).then_some(index)
816                })
817                .collect::<Vec<_>>();
818            if matches.len() != 1 {
819                return Ok(None);
820            }
821            let next = remaining.remove(matches[0]);
822            end = traversal_vertices(edges[&next.edge_id], &next).1;
823            cycle.push(next);
824            if cycle.len() > maximum_cycle_length {
825                return Ok(None);
826            }
827        }
828        if !cycle
829            .iter()
830            .any(|coedge| !edges[&coedge.edge_id].degenerate)
831        {
832            return Ok(None);
833        }
834        loops.push(LoopRecord {
835            id: loops.len() as u64 + 1,
836            coedges: cycle,
837        });
838    }
839    if loops.is_empty() {
840        return Ok(None);
841    }
842    loops.sort_by(|first, second| {
843        let area = |loop_record: &LoopRecord| {
844            parameter_space_area(&FaceRecord {
845                id: older.id,
846                surface: older.surface.clone(),
847                same_sense: older.same_sense,
848                loops: vec![loop_record.clone()],
849                name: None,
850            })
851            .map(f64::abs)
852        };
853        match (area(first), area(second)) {
854            (Ok(a), Ok(b)) => b.total_cmp(&a),
855            _ => std::cmp::Ordering::Equal,
856        }
857    });
858    if same_carrier {
859        let merged = FaceRecord {
860            id: older.id,
861            surface: older.surface.clone(),
862            same_sense: older.same_sense,
863            loops,
864            name: older.name.clone(),
865        };
866        let expected = parameter_space_area(older)
867            .or_refuse(KernelStage::Sew, "parameter_space_area")?
868            + parameter_space_area(newer).or_refuse(KernelStage::Sew, "parameter_space_area")?;
869        let actual =
870            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
871        let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
872        if (actual - expected).abs() > area_tolerance
873            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
874        {
875            return Ok(None);
876        }
877        return Ok(Some(merged));
878    }
879
880    if let Some(support) = cosurface_cylinder {
881        // Build one revolution carrier covering both patches: seam at the
882        // largest angular gap so the merged region never crosses it, then
883        // reproject every loop onto the shared cylinder.
884        let x_reference = {
885            let seed = loops
886                .iter()
887                .flat_map(|loop_record| &loop_record.coedges)
888                .find_map(|coedge| {
889                    let edge = edges[&coedge.edge_id];
890                    edge.curve.evaluate(edge.t0).ok()
891                })
892                .ok_or_else(|| {
893                    KernelRefusal::internal(
894                        KernelStage::Sew,
895                        "face_merge",
896                        "cylinder merge: no boundary sample",
897                    )
898                })?;
899            let delta = seed.sub(support.origin);
900            let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
901            match radial.normalized() {
902                Ok(radial) => radial,
903                Err(_) => return Ok(None),
904            }
905        };
906        let y_reference = support.axis.cross(x_reference);
907        let mut angles = Vec::new();
908        let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
909        for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
910            let edge = edges[&coedge.edge_id];
911            let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
912            let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
913            for index in 0..=8 {
914                let point = curve
915                    .evaluate(c0 + (c1 - c0) * index as f64 / 8.0)
916                    .or_refuse(KernelStage::Sew, "evaluate")?;
917                let delta = point.sub(support.origin);
918                let axial = delta.dot(support.axis);
919                axial_range[0] = axial_range[0].min(axial);
920                axial_range[1] = axial_range[1].max(axial);
921                let radial = delta.sub(support.axis.scale(axial));
922                if radial.length() <= support.radius * 1e-6 {
923                    continue;
924                }
925                let mut angle = radial.dot(y_reference).atan2(radial.dot(x_reference));
926                if angle < 0.0 {
927                    angle += std::f64::consts::TAU;
928                }
929                angles.push(angle);
930            }
931        }
932        if angles.len() < 3 || axial_range[1] - axial_range[0] <= tolerance {
933            return Ok(None);
934        }
935        angles.sort_by(f64::total_cmp);
936        let mut gap_start = angles.len() - 1;
937        let mut largest_gap = angles[0] + std::f64::consts::TAU - angles[angles.len() - 1];
938        for index in 0..angles.len() - 1 {
939            let gap = angles[index + 1] - angles[index];
940            if gap > largest_gap {
941                largest_gap = gap;
942                gap_start = index;
943            }
944        }
945        let span = std::f64::consts::TAU - largest_gap;
946        if span <= tolerance || span >= std::f64::consts::TAU - 1e-6 {
947            return Ok(None);
948        }
949        let start_angle = angles[(gap_start + 1) % angles.len()];
950        let start_direction = x_reference
951            .scale(start_angle.cos())
952            .add(y_reference.scale(start_angle.sin()));
953        let generatrix = make_line(
954            support
955                .origin
956                .add(support.axis.scale(axial_range[0]))
957                .add(start_direction.scale(support.radius)),
958            support
959                .origin
960                .add(support.axis.scale(axial_range[1]))
961                .add(start_direction.scale(support.radius)),
962        )
963        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
964        let surface = make_revolution(support.origin, support.axis, &generatrix, span)
965            .or_refuse(KernelStage::Sew, "make_revolution")?;
966        let (u_domain, v_domain) = surface_domains(&surface)?;
967        let sample = surface
968            .evaluate(
969                (u_domain[0] + u_domain[1]) / 2.0,
970                (v_domain[0] + v_domain[1]) / 2.0,
971            )
972            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
973        let geometric_normal = surface
974            .normal(
975                (u_domain[0] + u_domain[1]) / 2.0,
976                (v_domain[0] + v_domain[1]) / 2.0,
977            )
978            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
979        let delta = sample.sub(support.origin);
980        let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
981        let radial = match radial.normalized() {
982            Ok(radial) => radial,
983            Err(_) => return Ok(None),
984        };
985        let same_sense = geometric_normal.dot(radial).signum() == support.outward_radial_sign;
986        let mut projected_loops = reproject_loops(&surface, loops, edges)?;
987        projected_loops.sort_by(|first, second| {
988            let area = |loop_record: &LoopRecord| {
989                parameter_space_area(&FaceRecord {
990                    id: older.id,
991                    surface: surface.clone(),
992                    same_sense,
993                    loops: vec![loop_record.clone()],
994                    name: None,
995                })
996                .map(f64::abs)
997            };
998            match (area(first), area(second)) {
999                (Ok(a), Ok(b)) => b.total_cmp(&a),
1000                _ => std::cmp::Ordering::Equal,
1001            }
1002        });
1003        let merged = FaceRecord {
1004            id: older.id,
1005            surface,
1006            same_sense,
1007            loops: projected_loops,
1008            name: older.name.clone(),
1009        };
1010        let area =
1011            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1012        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1013            return Ok(None);
1014        }
1015        // The reprojected trims must reproduce the pieces' combined 3D
1016        // area; a projection or seam mistake shows up here immediately.
1017        let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1018            + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1019        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1020        if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1021            return Ok(None);
1022        }
1023        return Ok(Some(merged));
1024    }
1025
1026    if let Some((support, union)) = cosurface_extrusion {
1027        // Shared linear-sweep carrier: the older profile translated to the
1028        // union's start plane, extruded across the union interval.
1029        let shift = support.direction.scale(union[0] - support.interval[0]);
1030        let step = support.direction.scale(union[1] - union[0]);
1031        let rows = support
1032            .base
1033            .control_points
1034            .iter()
1035            .map(|control| -> Result<Vec<Vec4>, KernelRefusal> {
1036                let point = control
1037                    .point()
1038                    .or_refuse(KernelStage::Sew, "point")?
1039                    .add(shift);
1040                Ok(vec![
1041                    Vec4::from_point(point, control.w),
1042                    Vec4::from_point(point.add(step), control.w),
1043                ])
1044            })
1045            .collect::<Result<Vec<_>, _>>()?;
1046        let surface = NurbsSurface::new(
1047            support.base.degree,
1048            1,
1049            support.base.knots.clone(),
1050            vec![0.0, 0.0, 1.0, 1.0],
1051            rows,
1052        )
1053        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1054        let (older_u, older_v) = surface_domains(&older.surface)?;
1055        let older_mid = older
1056            .surface
1057            .evaluate(
1058                (older_u[0] + older_u[1]) / 2.0,
1059                (older_v[0] + older_v[1]) / 2.0,
1060            )
1061            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1062        let projection = crate::project_point_to_surface(&surface, older_mid)
1063            .or_refuse(KernelStage::Sew, "project_point_to_surface")?;
1064        let carrier_normal = surface
1065            .normal(projection.u, projection.v)
1066            .or_refuse(KernelStage::Sew, "normal")?;
1067        let same_sense = carrier_normal.dot(outward_normal(older)?) > 0.0;
1068        let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1069        projected_loops.sort_by(|first, second| {
1070            let area = |loop_record: &LoopRecord| {
1071                parameter_space_area(&FaceRecord {
1072                    id: older.id,
1073                    surface: surface.clone(),
1074                    same_sense,
1075                    loops: vec![loop_record.clone()],
1076                    name: None,
1077                })
1078                .map(f64::abs)
1079            };
1080            match (area(first), area(second)) {
1081                (Ok(a), Ok(b)) => b.total_cmp(&a),
1082                _ => std::cmp::Ordering::Equal,
1083            }
1084        });
1085        let merged = FaceRecord {
1086            id: older.id,
1087            surface,
1088            same_sense,
1089            loops: projected_loops,
1090            name: older.name.clone(),
1091        };
1092        let area =
1093            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1094        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1095            return Ok(None);
1096        }
1097        let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1098            + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1099        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1100        if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1101            if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1102                eprintln!(
1103                    "coextrusion {}x{}: area mismatch {actual} vs {expected}",
1104                    older.id, newer.id
1105                );
1106            }
1107            return Ok(None);
1108        }
1109        return Ok(Some(merged));
1110    }
1111
1112    let (u_domain, v_domain) = surface_domains(&older.surface)?;
1113    let origin = older
1114        .surface
1115        .evaluate(u_domain[0], v_domain[0])
1116        .or_refuse(KernelStage::Sew, "evaluate")?;
1117    let derivatives = older
1118        .surface
1119        .derivatives(
1120            (u_domain[0] + u_domain[1]) / 2.0,
1121            (v_domain[0] + v_domain[1]) / 2.0,
1122            1,
1123        )
1124        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1125    let u_axis = derivatives[1][0]
1126        .normalized()
1127        .or_refuse(KernelStage::Sew, "normalized")?;
1128    let geometric_normal = derivatives[1][0]
1129        .cross(derivatives[0][1])
1130        .normalized()
1131        .or_refuse(KernelStage::Sew, "normalized")?;
1132    let v_axis = geometric_normal
1133        .cross(u_axis)
1134        .normalized()
1135        .or_refuse(KernelStage::Sew, "normalized")?;
1136    let mut bounds_u = [f64::INFINITY, f64::NEG_INFINITY];
1137    let mut bounds_v = [f64::INFINITY, f64::NEG_INFINITY];
1138    for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1139        let edge = edges[&coedge.edge_id];
1140        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1141        for control in &curve.control_points {
1142            let relative = control
1143                .point()
1144                .or_refuse(KernelStage::Sew, "point")?
1145                .sub(origin);
1146            let u = relative.dot(u_axis);
1147            let v = relative.dot(v_axis);
1148            bounds_u[0] = bounds_u[0].min(u);
1149            bounds_u[1] = bounds_u[1].max(u);
1150            bounds_v[0] = bounds_v[0].min(v);
1151            bounds_v[1] = bounds_v[1].max(v);
1152        }
1153    }
1154    let extent_u = bounds_u[1] - bounds_u[0];
1155    let extent_v = bounds_v[1] - bounds_v[0];
1156    if extent_u <= tolerance || extent_v <= tolerance {
1157        return Ok(None);
1158    }
1159    let surface_origin = origin
1160        .add(u_axis.scale(bounds_u[0]))
1161        .add(v_axis.scale(bounds_v[0]));
1162    let surface = make_plane(surface_origin, u_axis, v_axis, extent_u, extent_v)
1163        .or_refuse(KernelStage::Sew, "make_plane")?;
1164    let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1165    projected_loops.sort_by(|first, second| {
1166        let area = |loop_record: &LoopRecord| {
1167            parameter_space_area(&FaceRecord {
1168                id: older.id,
1169                surface: surface.clone(),
1170                same_sense: older.same_sense,
1171                loops: vec![loop_record.clone()],
1172                name: None,
1173            })
1174            .map(f64::abs)
1175        };
1176        match (area(first), area(second)) {
1177            (Ok(a), Ok(b)) => b.total_cmp(&a),
1178            _ => std::cmp::Ordering::Equal,
1179        }
1180    });
1181    let merged = FaceRecord {
1182        id: older.id,
1183        surface,
1184        same_sense: older.same_sense,
1185        loops: projected_loops,
1186        name: older.name.clone(),
1187    };
1188    let area = parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1189    if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1190        return Ok(None);
1191    }
1192    Ok(Some(merged))
1193}
1194
1195fn try_merge_connected_group(
1196    faces: &[FaceRecord],
1197    edges: &HashMap<u64, &EdgeRecord>,
1198    same_carrier_area_tolerance_ratio: f64,
1199    keep_unmerged: &[String],
1200) -> Result<Option<(Vec<usize>, FaceRecord)>, KernelRefusal> {
1201    for seed in 0..faces.len() {
1202        // A pinned face never seeds or joins a merge group.
1203        if face_is_pinned(&faces[seed], keep_unmerged) {
1204            continue;
1205        }
1206        let mut component = [seed].into_iter().collect::<HashSet<_>>();
1207        loop {
1208            let mut changed = false;
1209            for candidate in 0..faces.len() {
1210                if component.contains(&candidate)
1211                    || face_is_pinned(&faces[candidate], keep_unmerged)
1212                    || faces[candidate].same_sense != faces[seed].same_sense
1213                    || !same_surface(&faces[candidate].surface, &faces[seed].surface, 1e-7)
1214                {
1215                    continue;
1216                }
1217                let candidate_uses = faces[candidate]
1218                    .loops
1219                    .iter()
1220                    .flat_map(|loop_record| &loop_record.coedges);
1221                let adjacent = candidate_uses.into_iter().any(|candidate_use| {
1222                    !edges[&candidate_use.edge_id].degenerate
1223                        && component.iter().any(|member| {
1224                            faces[*member]
1225                                .loops
1226                                .iter()
1227                                .flat_map(|loop_record| &loop_record.coedges)
1228                                .any(|member_use| {
1229                                    member_use.edge_id == candidate_use.edge_id
1230                                        && member_use.forward != candidate_use.forward
1231                                })
1232                        })
1233                });
1234                if adjacent {
1235                    component.insert(candidate);
1236                    changed = true;
1237                }
1238            }
1239            if !changed {
1240                break;
1241            }
1242        }
1243        if component.len() < 2 {
1244            continue;
1245        }
1246
1247        let mut uses_by_edge = HashMap::<u64, Vec<CoedgeRecord>>::default();
1248        for index in &component {
1249            for coedge in faces[*index]
1250                .loops
1251                .iter()
1252                .flat_map(|loop_record| &loop_record.coedges)
1253            {
1254                uses_by_edge
1255                    .entry(coedge.edge_id)
1256                    .or_default()
1257                    .push(coedge.clone());
1258            }
1259        }
1260        if uses_by_edge.values().any(|uses| uses.len() > 2) {
1261            continue;
1262        }
1263        let internal = uses_by_edge
1264            .iter()
1265            .filter_map(|(edge_id, uses)| {
1266                (uses.len() == 2
1267                    && !edges[edge_id].degenerate
1268                    && uses[0].forward != uses[1].forward)
1269                    .then_some(*edge_id)
1270            })
1271            .collect::<HashSet<_>>();
1272        let mut remaining = uses_by_edge
1273            .into_iter()
1274            .filter(|(edge_id, _)| !internal.contains(edge_id))
1275            .flat_map(|(_, uses)| uses)
1276            .collect::<Vec<_>>();
1277        let maximum_cycle_length = remaining.len();
1278        let mut loops = Vec::new();
1279        let mut failed = false;
1280        while !remaining.is_empty() {
1281            let first = remaining.remove(0);
1282            let (start, mut end) = traversal_vertices(edges[&first.edge_id], &first);
1283            let mut cycle = vec![first];
1284            while end != start {
1285                let matches = remaining
1286                    .iter()
1287                    .enumerate()
1288                    .filter_map(|(index, coedge)| {
1289                        (traversal_vertices(edges[&coedge.edge_id], coedge).0 == end)
1290                            .then_some(index)
1291                    })
1292                    .collect::<Vec<_>>();
1293                if matches.len() != 1 {
1294                    failed = true;
1295                    break;
1296                }
1297                let next = remaining.remove(matches[0]);
1298                end = traversal_vertices(edges[&next.edge_id], &next).1;
1299                cycle.push(next);
1300                if cycle.len() > maximum_cycle_length {
1301                    failed = true;
1302                    break;
1303                }
1304            }
1305            if failed {
1306                break;
1307            }
1308            loops.push(LoopRecord {
1309                id: loops.len() as u64 + 1,
1310                coedges: cycle,
1311            });
1312        }
1313        if failed || loops.is_empty() {
1314            continue;
1315        }
1316        loops.sort_by(|first, second| {
1317            let area = |loop_record: &LoopRecord| {
1318                parameter_space_area(&FaceRecord {
1319                    id: faces[seed].id,
1320                    surface: faces[seed].surface.clone(),
1321                    same_sense: faces[seed].same_sense,
1322                    loops: vec![loop_record.clone()],
1323                    name: None,
1324                })
1325                .map(f64::abs)
1326            };
1327            match (area(first), area(second)) {
1328                (Ok(a), Ok(b)) => b.total_cmp(&a),
1329                _ => std::cmp::Ordering::Equal,
1330            }
1331        });
1332        let merged = FaceRecord {
1333            id: faces[seed].id,
1334            surface: faces[seed].surface.clone(),
1335            same_sense: faces[seed].same_sense,
1336            loops,
1337            name: faces[seed].name.clone(),
1338        };
1339        let expected = component
1340            .iter()
1341            .map(|index| parameter_space_area(&faces[*index]))
1342            .collect::<Result<Vec<_>, _>>()
1343            .or_refuse(KernelStage::Sew, "healing.face_merge")?
1344            .into_iter()
1345            .sum::<f64>();
1346        let actual =
1347            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1348        let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
1349        if (actual - expected).abs() > area_tolerance
1350            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
1351        {
1352            continue;
1353        }
1354        let mut indices = component.into_iter().collect::<Vec<_>>();
1355        indices.sort_unstable();
1356        return Ok(Some((indices, merged)));
1357    }
1358    Ok(None)
1359}
1360
1361/// A face is PINNED out of the merge when its `name` contains any substring in
1362/// `keep_unmerged`: it never coalesces with a neighbour (in any merge lane) and
1363/// is emitted unchanged. An empty `keep_unmerged` pins nothing — the historical
1364/// behavior.
1365fn face_is_pinned(face: &FaceRecord, keep_unmerged: &[String]) -> bool {
1366    match &face.name {
1367        Some(name) => keep_unmerged
1368            .iter()
1369            .any(|needle| name.contains(needle.as_str())),
1370        None => false,
1371    }
1372}
1373
1374/// Merge adjacent fragments that retain the exact same carrier surface.
1375///
1376/// Every shared edge is cancelled in opposite directions and the remaining
1377/// coedges are stitched into outer/hole cycles without refitting geometry.
1378fn merge_same_surface_faces_impl(
1379    solid: &BrepSolid,
1380    tolerance: f64,
1381    validate: bool,
1382    same_carrier_area_tolerance_ratio: f64,
1383    keep_unmerged: &[String],
1384) -> Result<BrepSolid, KernelRefusal> {
1385    let mut result = solid.clone();
1386    for shell in &mut result.shells {
1387        loop {
1388            let edges = result
1389                .edges
1390                .iter()
1391                .map(|edge| (edge.id, edge))
1392                .collect::<HashMap<_, _>>();
1393            if !validate {
1394                if let Some((indices, merged)) = try_merge_connected_group(
1395                    &shell.faces,
1396                    &edges,
1397                    same_carrier_area_tolerance_ratio,
1398                    keep_unmerged,
1399                )? {
1400                    let first = indices[0];
1401                    shell.faces[first] = merged;
1402                    for index in indices.into_iter().skip(1).rev() {
1403                        shell.faces.remove(index);
1404                    }
1405                    continue;
1406                }
1407            }
1408            let mut accepted = None;
1409            'pairs: for first in 0..shell.faces.len() {
1410                if face_is_pinned(&shell.faces[first], keep_unmerged) {
1411                    continue;
1412                }
1413                for second in first + 1..shell.faces.len() {
1414                    if face_is_pinned(&shell.faces[second], keep_unmerged) {
1415                        continue;
1416                    }
1417                    if let Some(merged) = try_merge_pair(
1418                        &shell.faces[first],
1419                        &shell.faces[second],
1420                        &edges,
1421                        tolerance,
1422                        same_carrier_area_tolerance_ratio,
1423                    )? {
1424                        accepted = Some((first, second, merged));
1425                        break 'pairs;
1426                    }
1427                }
1428            }
1429            let Some((first, second, merged)) = accepted else {
1430                break;
1431            };
1432            shell.faces[first] = merged;
1433            shell.faces.remove(second);
1434        }
1435    }
1436    let used_edges = result
1437        .shells
1438        .iter()
1439        .flat_map(|shell| &shell.faces)
1440        .flat_map(|face| &face.loops)
1441        .flat_map(|loop_record| &loop_record.coedges)
1442        .map(|coedge| coedge.edge_id)
1443        .collect::<HashSet<_>>();
1444    result.edges.retain(|edge| used_edges.contains(&edge.id));
1445    let used_vertices = result
1446        .edges
1447        .iter()
1448        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1449        .collect::<HashSet<_>>();
1450    result
1451        .vertices
1452        .retain(|vertex| used_vertices.contains(&vertex.id));
1453    if !validate {
1454        return Ok(result);
1455    }
1456    if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1457        let entry_faces: i64 = solid.shells.iter().map(|s| s.faces.len() as i64).sum();
1458        let entry_holes: i64 = solid
1459            .shells
1460            .iter()
1461            .flat_map(|s| &s.faces)
1462            .map(|f| f.loops.len().saturating_sub(1) as i64)
1463            .sum();
1464        let entry_edges = solid.edges.iter().filter(|e| !e.degenerate).count() as i64;
1465        let entry_used: std::collections::HashSet<u64> = solid
1466            .shells
1467            .iter()
1468            .flat_map(|s| &s.faces)
1469            .flat_map(|f| &f.loops)
1470            .flat_map(|l| &l.coedges)
1471            .map(|c| c.edge_id)
1472            .collect();
1473        eprintln!(
1474            "merge entry census: V={} E={entry_edges} (coedge-referenced {}) F={entry_faces} H={entry_holes} S={} stored_genus={}",
1475            solid.vertices.len(),
1476            entry_used.len(),
1477            solid.shells.len(),
1478            solid.genus
1479        );
1480    }
1481    let face_count = result
1482        .shells
1483        .iter()
1484        .map(|shell| shell.faces.len() as i64)
1485        .sum::<i64>();
1486    let hole_count = result
1487        .shells
1488        .iter()
1489        .flat_map(|shell| &shell.faces)
1490        .map(|face| face.loops.len().saturating_sub(1) as i64)
1491        .sum::<i64>();
1492    let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
1493    // Count only LIVE vertices (referenced by a non-degenerate edge): the
1494    // merge removes edges and faces, orphaning vertices that then must not
1495    // participate in the Euler census — mirroring refresh_derived_genus
1496    // (boolean.rs). Counting stale vertices inflated V and rejected valid
1497    // merges as "non-integral genus" (the helmet report's final layer).
1498    let live_vertices: std::collections::HashSet<u64> = result
1499        .edges
1500        .iter()
1501        .filter(|edge| !edge.degenerate)
1502        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1503        .collect();
1504    let vertex_count = result
1505        .vertices
1506        .iter()
1507        .filter(|vertex| live_vertices.contains(&vertex.id))
1508        .count() as i64;
1509    let euler = vertex_count - edge_count + face_count - hole_count;
1510    let numerator = result.shells.len() as i64 * 2 - euler;
1511    // Mirror validate()'s convention (topology.rs): the reduced V−E+F−H
1512    // formula does not model parameter-space POLE COLLAPSES reliably, so on a
1513    // complex carrying degenerate edges the parity check must not reject —
1514    // the helmet result holds 17 legitimate degenerate (pole) edges and its
1515    // odd numerator here is a formula artifact, not a defect; validate()
1516    // below performs the real integrity checks either way. With no
1517    // degenerate edges the strict behavior is unchanged.
1518    let has_degenerate = result.edges.iter().any(|edge| edge.degenerate);
1519    if numerator < 0 || numerator % 2 != 0 {
1520        if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1521            eprintln!(
1522                "merge euler reject: V={vertex_count} E={edge_count} F={face_count} H={hole_count} S={} euler={euler} numerator={numerator} degenerate={has_degenerate}",
1523                result.shells.len()
1524            );
1525        }
1526        if !has_degenerate
1527            || std::env::var("BREP_MERGE_DEGENERATE_EULER_STRICT").as_deref() == Ok("1")
1528        {
1529            return Err(KernelRefusal::new(
1530                RefusalClass::NonIntegralGenus {
1531                    shells: result.shells.len() as u32,
1532                    euler: euler as i64,
1533                },
1534                KernelStage::Validate,
1535                "merge_same_surface_faces produced non-integral genus",
1536            ));
1537        }
1538    } else {
1539        result.genus = numerator / 2;
1540    }
1541    let issues = result.validate();
1542    if !issues.is_empty() {
1543        return Err(KernelRefusal::new(
1544            RefusalClass::DegenerateArrangement {
1545                open_edges: 0,
1546                issues: issues.len() as u32,
1547            },
1548            KernelStage::Validate,
1549            format!("merge_same_surface_faces produced invalid topology: {issues:?}"),
1550        ));
1551    }
1552    Ok(result)
1553}
1554
1555pub fn merge_same_surface_faces(
1556    solid: &BrepSolid,
1557    tolerance: f64,
1558) -> Result<BrepSolid, KernelRefusal> {
1559    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, &[])
1560}
1561
1562/// Like [`merge_same_surface_faces`], but any face whose `name` CONTAINS one of
1563/// `keep_unmerged` is pinned: it never merges with a coplanar/cosurface
1564/// neighbour and is emitted unchanged, while every other face merges exactly as
1565/// [`merge_same_surface_faces`] would. An empty `keep_unmerged` is identical to
1566/// the plain call.
1567pub fn merge_same_surface_faces_excluding(
1568    solid: &BrepSolid,
1569    tolerance: f64,
1570    keep_unmerged: &[String],
1571) -> Result<BrepSolid, KernelRefusal> {
1572    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, keep_unmerged)
1573}
1574
1575pub(crate) fn merge_same_surface_faces_open(
1576    solid: &BrepSolid,
1577    tolerance: f64,
1578) -> Result<BrepSolid, KernelRefusal> {
1579    merge_same_surface_faces_impl(solid, tolerance, false, 5e-3, &[])
1580}
1581
1582// BREP private tests: cebf6e4d7f8e005f