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    // 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
223struct CylinderSupport {
224    origin: Vec3,
225    axis: Vec3,
226    radius: f64,
227    /// +1 when the face's outward normal points radially away from the
228    /// axis, -1 when it points inward.
229    outward_radial_sign: f64,
230}
231
232/// Detect a ruled cylinder patch: quadratic circular arc in one parameter
233/// direction, linear in the other, both boundary arcs sharing an axis and
234/// radius. Covers extruded arcs, revolved axis-parallel lines, and
235/// primitive cylinder walls regardless of parameterization.
236fn ruled_cylinder_support(
237    face: &FaceRecord,
238    tolerance: f64,
239) -> Result<Option<CylinderSupport>, KernelRefusal> {
240    let surface = &face.surface;
241    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
242    let (arc_first, columns) = match (surface.degree_u, surface.degree_v) {
243        (2, 1) => (true, surface.control_points[0].len()),
244        (1, 2) => (false, surface.control_points.len()),
245        _ => {
246            if debug {
247                eprintln!(
248                    "  support face {}: degrees ({},{}) not ruled-arc",
249                    face.id, surface.degree_u, surface.degree_v
250                );
251            }
252            return Ok(None);
253        }
254    };
255    // Boolean splits can insert linear-direction knots, so a ruled carrier
256    // may hold more than two columns; the outermost columns still bound the
257    // patch and interior straightness is enforced by the final area check.
258    let last = columns - 1;
259    let iso_curve = |side: usize| -> Result<NurbsCurve, KernelRefusal> {
260        let controls = if arc_first {
261            surface
262                .control_points
263                .iter()
264                .map(|row| row[side])
265                .collect::<Vec<_>>()
266        } else {
267            surface.control_points[side].clone()
268        };
269        let knots = if arc_first {
270            surface.knots_u.clone()
271        } else {
272            surface.knots_v.clone()
273        };
274        NurbsCurve::new(2, knots, controls).or_refuse(KernelStage::Sew, "NurbsCurve::new")
275    };
276    let scale = surface
277        .control_points
278        .iter()
279        .flatten()
280        .map(|point| point.point().map(|p| p.length()).unwrap_or(0.0))
281        .fold(1.0f64, f64::max);
282    let circle_tolerance = (tolerance * 100.0).max(1e-7 * scale);
283    let Some((center0, normal0, radius0)) = arc_circle_support(&iso_curve(0)?, circle_tolerance)?
284    else {
285        if debug {
286            eprintln!("  support face {}: first iso not circular", face.id);
287        }
288        return Ok(None);
289    };
290    let Some((center1, normal1, radius1)) =
291        arc_circle_support(&iso_curve(last)?, circle_tolerance)?
292    else {
293        if debug {
294            eprintln!("  support face {}: last iso not circular", face.id);
295        }
296        return Ok(None);
297    };
298    if (radius0 - radius1).abs() > circle_tolerance {
299        if debug {
300            eprintln!(
301                "  support face {}: radii differ {} vs {}",
302                face.id, radius0, radius1
303            );
304        }
305        return Ok(None);
306    }
307    let axial = center1.sub(center0);
308    if axial.length() <= circle_tolerance {
309        if debug {
310            eprintln!("  support face {}: zero axial extent", face.id);
311        }
312        return Ok(None);
313    }
314    let axis = axial
315        .normalized()
316        .or_refuse(KernelStage::Sew, "normalized")?;
317    if axis.dot(normal0).abs() < 1.0 - 1e-7 || axis.dot(normal1).abs() < 1.0 - 1e-7 {
318        if debug {
319            eprintln!(
320                "  support face {}: arc planes not perpendicular to axis",
321                face.id
322            );
323        }
324        return Ok(None);
325    }
326    let outward = outward_normal(face)?;
327    let (u, v) = surface_domains(surface)?;
328    let midpoint = surface
329        .evaluate((u[0] + u[1]) / 2.0, (v[0] + v[1]) / 2.0)
330        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
331    let foot = center0.add(axis.scale(midpoint.sub(center0).dot(axis)));
332    let radial = match midpoint.sub(foot).normalized() {
333        Ok(radial) => radial,
334        Err(_) => return Ok(None),
335    };
336    let alignment = outward.dot(radial);
337    if alignment.abs() < 0.9 {
338        if debug {
339            eprintln!(
340                "  support face {}: outward not radial ({alignment})",
341                face.id
342            );
343        }
344        return Ok(None);
345    }
346    Ok(Some(CylinderSupport {
347        origin: center0,
348        axis,
349        radius: radius0,
350        outward_radial_sign: alignment.signum(),
351    }))
352}
353
354fn cosurface_cylinder_pair(
355    first: &FaceRecord,
356    second: &FaceRecord,
357    tolerance: f64,
358) -> Result<Option<CylinderSupport>, KernelRefusal> {
359    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
360    let Some(support_a) = ruled_cylinder_support(first, tolerance)? else {
361        if debug {
362            eprintln!(
363                "merge {}x{}: first not a ruled cylinder",
364                first.id, second.id
365            );
366        }
367        return Ok(None);
368    };
369    let Some(support_b) = ruled_cylinder_support(second, tolerance)? else {
370        if debug {
371            eprintln!(
372                "merge {}x{}: second not a ruled cylinder",
373                first.id, second.id
374            );
375        }
376        return Ok(None);
377    };
378    let scale = 1.0f64
379        .max(support_a.origin.length())
380        .max(support_b.origin.length())
381        .max(support_a.radius);
382    let band = (tolerance * 100.0).max(1e-7 * scale);
383    if support_a.axis.dot(support_b.axis).abs() < 1.0 - 1e-7
384        || (support_a.radius - support_b.radius).abs() > band
385        || support_a.outward_radial_sign != support_b.outward_radial_sign
386    {
387        if debug {
388            eprintln!(
389                "merge {}x{}: axis/radius/sign mismatch dot={} dr={} signs=({},{})",
390                first.id,
391                second.id,
392                support_a.axis.dot(support_b.axis),
393                (support_a.radius - support_b.radius).abs(),
394                support_a.outward_radial_sign,
395                support_b.outward_radial_sign
396            );
397        }
398        return Ok(None);
399    }
400    let offset = support_b.origin.sub(support_a.origin);
401    let perpendicular = offset.sub(support_a.axis.scale(offset.dot(support_a.axis)));
402    if perpendicular.length() > band {
403        if debug {
404            eprintln!(
405                "merge {}x{}: axes offset {}",
406                first.id,
407                second.id,
408                perpendicular.length()
409            );
410        }
411        return Ok(None);
412    }
413    if debug {
414        eprintln!(
415            "merge {}x{}: MATCH r={} axis=({:.4},{:.4},{:.4})",
416            first.id,
417            second.id,
418            support_a.radius,
419            support_a.axis.x,
420            support_a.axis.y,
421            support_a.axis.z
422        );
423    }
424    Ok(Some(support_a))
425}
426
427struct ExtrusionSupport {
428    /// Profile iso curve at the sweep start, in its actual 3D position.
429    base: NurbsCurve,
430    /// Unit sweep direction (from column 0 toward the last column).
431    direction: Vec3,
432    /// Axial interval the patch occupies along `direction`.
433    interval: [f64; 2],
434}
435
436/// Detect a linear sweep of an arbitrary profile curve: one parameter
437/// direction is degree 1 with every profile control translated by the SAME
438/// vector (weights unchanged), and the profile lies in a plane perpendicular
439/// to the sweep. This is exact at the control-point level, so it also merges
440/// swept faces whose profile is only approximately circular.
441fn ruled_extrusion_support(
442    face: &FaceRecord,
443    tolerance: f64,
444) -> Result<Option<ExtrusionSupport>, KernelRefusal> {
445    let surface = &face.surface;
446    // Planar faces are the coplanar path's business; a plane recognized as
447    // an "extrusion of a line" here could merge across a knife edge.
448    if planar_support(face, tolerance)?.is_some() {
449        return Ok(None);
450    }
451    let profile_first = if surface.degree_v == 1 && surface.degree_u >= 1 {
452        true
453    } else if surface.degree_u == 1 && surface.degree_v >= 1 {
454        false
455    } else {
456        return Ok(None);
457    };
458    let (profile_count, linear_count) = if profile_first {
459        (
460            surface.control_points.len(),
461            surface.control_points[0].len(),
462        )
463    } else {
464        (
465            surface.control_points[0].len(),
466            surface.control_points.len(),
467        )
468    };
469    if linear_count < 2 {
470        return Ok(None);
471    }
472    let control = |profile_index: usize, linear_index: usize| -> Vec4 {
473        if profile_first {
474            surface.control_points[profile_index][linear_index]
475        } else {
476            surface.control_points[linear_index][profile_index]
477        }
478    };
479    let scale = surface
480        .control_points
481        .iter()
482        .flatten()
483        .map(|point| point.point().map(|p| p.length()).unwrap_or(0.0))
484        .fold(1.0f64, f64::max);
485    let band = (tolerance * 100.0).max(1e-7 * scale);
486    let mut translation: Option<Vec3> = None;
487    for profile_index in 0..profile_count {
488        let first = control(profile_index, 0);
489        let last = control(profile_index, linear_count - 1);
490        if (first.w - last.w).abs() > 1e-9 * first.w.abs().max(1.0) {
491            return Ok(None);
492        }
493        let step = last
494            .point()
495            .or_refuse(KernelStage::Sew, "point")?
496            .sub(first.point().or_refuse(KernelStage::Sew, "point")?);
497        if let Some(reference) = translation {
498            if step.sub(reference).length() > band {
499                return Ok(None);
500            }
501        } else {
502            translation = Some(step);
503        }
504        let start = first.point().or_refuse(KernelStage::Sew, "point")?;
505        for linear_index in 1..linear_count - 1 {
506            let middle = control(profile_index, linear_index);
507            if (middle.w - first.w).abs() > 1e-9 * first.w.abs().max(1.0) {
508                return Ok(None);
509            }
510            let offset = middle
511                .point()
512                .or_refuse(KernelStage::Sew, "point")?
513                .sub(start);
514            let along = offset.dot(step) / step.dot(step).max(1e-30);
515            if offset.sub(step.scale(along)).length() > band
516                || !(-1e-9..=1.0 + 1e-9).contains(&along)
517            {
518                return Ok(None);
519            }
520        }
521    }
522    let translation = translation.ok_or_else(|| {
523        KernelRefusal::internal(
524            KernelStage::Sew,
525            "face_merge",
526            "extrusion support: empty net",
527        )
528    })?;
529    if translation.length() <= band {
530        return Ok(None);
531    }
532    let direction = translation
533        .normalized()
534        .or_refuse(KernelStage::Sew, "normalized")?;
535    let base_controls = (0..profile_count).map(|index| control(index, 0)).collect();
536    let base_knots = if profile_first {
537        surface.knots_u.clone()
538    } else {
539        surface.knots_v.clone()
540    };
541    let base_degree = if profile_first {
542        surface.degree_u
543    } else {
544        surface.degree_v
545    };
546    let base = NurbsCurve::new(base_degree, base_knots, base_controls)
547        .or_refuse(KernelStage::Sew, "new")?;
548    let [t0, t1] = base.domain().or_refuse(KernelStage::Sew, "domain")?;
549    let mut axial = [f64::INFINITY, f64::NEG_INFINITY];
550    for index in 0..=8 {
551        let z = base
552            .evaluate(t0 + (t1 - t0) * index as f64 / 8.0)
553            .or_refuse(KernelStage::Sew, "evaluate")?
554            .dot(direction);
555        axial[0] = axial[0].min(z);
556        axial[1] = axial[1].max(z);
557    }
558    if axial[1] - axial[0] > band {
559        // Slanted profiles sweep skewed slabs whose axial union is not an
560        // interval; keep the merge to perpendicular profiles.
561        return Ok(None);
562    }
563    let start = (axial[0] + axial[1]) / 2.0;
564    Ok(Some(ExtrusionSupport {
565        base,
566        direction,
567        interval: [start, start + translation.length()],
568    }))
569}
570
571fn perpendicular_profile(curve: &NurbsCurve, direction: Vec3) -> Result<NurbsCurve, KernelRefusal> {
572    let controls = curve
573        .control_points
574        .iter()
575        .map(|control| {
576            let point = control.point().or_refuse(KernelStage::Sew, "point")?;
577            Ok(Vec4::from_point(
578                point.sub(direction.scale(point.dot(direction))),
579                control.w,
580            ))
581        })
582        .collect::<Result<Vec<_>, KernelRefusal>>()?;
583    NurbsCurve::new(curve.degree, curve.knots.clone(), controls)
584        .or_refuse(KernelStage::Sew, "NurbsCurve::new")
585}
586
587fn curves_coincide(
588    first: &NurbsCurve,
589    second: &NurbsCurve,
590    band: f64,
591) -> Result<bool, KernelRefusal> {
592    for (from, to) in [(first, second), (second, first)] {
593        let [t0, t1] = from.domain().or_refuse(KernelStage::Sew, "domain")?;
594        for index in 0..=8 {
595            let point = from
596                .evaluate(t0 + (t1 - t0) * index as f64 / 8.0)
597                .or_refuse(KernelStage::Sew, "evaluate")?;
598            let projection = match crate::project_point_to_curve(to, point) {
599                Ok(projection) => projection,
600                Err(_) => return Ok(false),
601            };
602            if projection.distance > band {
603                return Ok(false);
604            }
605        }
606    }
607    Ok(true)
608}
609
610/// Matching linear sweeps: parallel directions, identical perpendicular
611/// profile, adjacent (or touching) axial intervals.
612fn coextrusion_pair(
613    older: &FaceRecord,
614    newer: &FaceRecord,
615    tolerance: f64,
616) -> Result<Option<(ExtrusionSupport, [f64; 2])>, KernelRefusal> {
617    let debug = std::env::var("BREP_DEBUG_MERGE").is_ok();
618    let Some(support_a) = ruled_extrusion_support(older, tolerance)? else {
619        return Ok(None);
620    };
621    let Some(support_b) = ruled_extrusion_support(newer, tolerance)? else {
622        return Ok(None);
623    };
624    let alignment = support_a.direction.dot(support_b.direction);
625    if alignment.abs() < 1.0 - 1e-9 {
626        if debug {
627            eprintln!(
628                "coextrusion {}x{}: directions differ (dot {alignment})",
629                older.id, newer.id
630            );
631        }
632        return Ok(None);
633    }
634    let scale = 1.0f64
635        .max(support_a.interval[1].abs())
636        .max(support_b.interval[1].abs());
637    let band = (tolerance * 100.0).max(1e-7 * scale);
638    let profile_a = perpendicular_profile(&support_a.base, support_a.direction)?;
639    let profile_b = perpendicular_profile(&support_b.base, support_a.direction)?;
640    if !curves_coincide(&profile_a, &profile_b, band)? {
641        if debug {
642            eprintln!("coextrusion {}x{}: profiles differ", older.id, newer.id);
643        }
644        return Ok(None);
645    }
646    // Express B's interval along A's direction regardless of B's own sweep
647    // orientation: anti-parallel sweeps negate the axial coordinate.
648    let interval_b = if alignment < 0.0 {
649        [-support_b.interval[1], -support_b.interval[0]]
650    } else {
651        support_b.interval
652    };
653    if interval_b[0] > support_a.interval[1] + band || support_a.interval[0] > interval_b[1] + band
654    {
655        if debug {
656            eprintln!(
657                "coextrusion {}x{}: axial gap {:?} vs {:?}",
658                older.id, newer.id, support_a.interval, interval_b
659            );
660        }
661        return Ok(None);
662    }
663    let union = [
664        support_a.interval[0].min(interval_b[0]),
665        support_a.interval[1].max(interval_b[1]),
666    ];
667    Ok(Some((support_a, union)))
668}
669
670fn trimmed_curve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, KernelRefusal> {
671    let [start, end] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
672    let epsilon = (1e-9 * (end - start)).max(2e-9);
673    let mut result = curve.clone();
674    if t0 > start + epsilon && t0 < end - epsilon {
675        result = result.split(t0).or_refuse(KernelStage::Sew, "split")?.1;
676    }
677    let domain = result.domain().or_refuse(KernelStage::Sew, "domain")?;
678    if t1 < domain[1] - epsilon && t1 > domain[0] + epsilon {
679        result = result.split(t1).or_refuse(KernelStage::Sew, "split")?.0;
680    } else if std::env::var("BREP_DEBUG_SUBRANGE").is_ok() && t1 < domain[1] - epsilon {
681        eprintln!("abnormal subrange skip in face_merge trimmed");
682    }
683    Ok(result)
684}
685
686fn reproject_loops(
687    surface: &NurbsSurface,
688    loops: Vec<LoopRecord>,
689    edges: &HashMap<u64, &EdgeRecord>,
690) -> Result<Vec<LoopRecord>, KernelRefusal> {
691    let mut projected_loops = Vec::new();
692    for (loop_index, loop_record) in loops.into_iter().enumerate() {
693        let mut projected = Vec::new();
694        for mut coedge in loop_record.coedges {
695            let edge = edges[&coedge.edge_id];
696            let mut curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
697            if !coedge.forward {
698                curve = curve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
699            }
700            coedge.pcurve = build_pcurve_on_surface(surface, &curve)
701                .or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
702            projected.push(coedge);
703        }
704        projected_loops.push(LoopRecord {
705            id: loop_index as u64 + 1,
706            coedges: projected,
707        });
708    }
709    Ok(projected_loops)
710}
711
712fn traversal_vertices(edge: &EdgeRecord, coedge: &CoedgeRecord) -> (u64, u64) {
713    if coedge.forward {
714        (edge.start_vertex_id, edge.end_vertex_id)
715    } else {
716        (edge.end_vertex_id, edge.start_vertex_id)
717    }
718}
719
720/// The parameter space every leftover coedge's pcurve lives in, when the merged
721/// faces share ONE carrier. Only then can a merge read a loop's parameter-space
722/// continuity; a reprojecting lane walks before its pcurves share a space.
723struct SharedParameterSpace<'a> {
724    surface: &'a NurbsSurface,
725    /// The radius that made two fitted endpoints one vertex.
726    weld: f64,
727}
728
729impl SharedParameterSpace<'_> {
730    fn coincide(&self, first: Vec3, second: Vec3) -> bool {
731        let band = crate::classification::surface_uv_band(self.surface, first.x, first.y, self.weld);
732        (first.x - second.x).abs() <= band && (first.y - second.y).abs() <= band
733    }
734}
735
736/// A coedge's pcurve at its start, middle and end. The pcurve runs in the
737/// coedge's own direction, so its domain start is where the traversal starts.
738fn pcurve_stations(coedge: &CoedgeRecord) -> Option<[Vec3; 3]> {
739    let [t0, t1] = coedge.pcurve.domain().ok()?;
740    Some([
741        coedge.pcurve.evaluate(t0).ok()?,
742        coedge.pcurve.evaluate((t0 + t1) / 2.0).ok()?,
743        coedge.pcurve.evaluate(t1).ok()?,
744    ])
745}
746
747/// SLIT DISSOLUTION. An edge whose two leftover uses run along the SAME
748/// parameter-space segment, one each way, separates nothing: its ring on the
749/// other side was the shared edge the merge just cancelled (a flanged hole's
750/// annulus, slit to the ring it bridged to). Kept, the walk can only return
751/// along it and close a loop of zero area. Two uses a PERIOD apart are a seam
752/// the region wraps across, and stay.
753fn dissolve_slits(
754    remaining: &mut Vec<CoedgeRecord>,
755    edges: &HashMap<u64, &EdgeRecord>,
756    space: &SharedParameterSpace,
757) {
758    let mut uses = HashMap::<u64, Vec<usize>>::default();
759    for (index, coedge) in remaining.iter().enumerate() {
760        uses.entry(coedge.edge_id).or_default().push(index);
761    }
762    let slits = uses
763        .iter()
764        .filter(|(edge_id, indices)| {
765            if indices.len() != 2 || edges[*edge_id].degenerate {
766                return false;
767            }
768            let (first, second) = (&remaining[indices[0]], &remaining[indices[1]]);
769            if first.forward == second.forward {
770                return false;
771            }
772            let (Some(first), Some(second)) = (pcurve_stations(first), pcurve_stations(second)) else {
773                return false;
774            };
775            space.coincide(first[0], second[2])
776                && space.coincide(first[1], second[1])
777                && space.coincide(first[2], second[0])
778        })
779        .map(|(edge_id, _)| *edge_id)
780        .collect::<HashSet<_>>();
781    remaining.retain(|coedge| !slits.contains(&coedge.edge_id));
782}
783
784/// Stitch a merged face's leftover coedges into loops, or `None` when the
785/// stitching is not determined.
786///
787/// A loop can pass one VERTEX twice — a bridge's foot on the ring it joins —
788/// so reaching the start vertex does not by itself close the loop, and a vertex
789/// with several unused coedges leaving it does not name the next one. There the
790/// walk follows the loop's PARAMETER-SPACE continuity when the coedges share a
791/// carrier (`space`): the successor starts where the current pcurve ends, and a
792/// seam's two images of one vertex a period apart are two different places.
793/// Without a shared space, or with no single successor, the merge is refused
794/// and the faces stay as they were assembled.
795fn walk_cycles(
796    mut remaining: Vec<CoedgeRecord>,
797    edges: &HashMap<u64, &EdgeRecord>,
798    space: Option<&SharedParameterSpace>,
799) -> Option<Vec<LoopRecord>> {
800    let maximum_cycle_length = remaining.len();
801    let mut loops = Vec::new();
802    while !remaining.is_empty() {
803        let first = remaining.remove(0);
804        let (start, mut end) = traversal_vertices(edges[&first.edge_id], &first);
805        let mut cycle = vec![first];
806        loop {
807            let leaving = remaining
808                .iter()
809                .enumerate()
810                .filter_map(|(index, coedge)| {
811                    (traversal_vertices(edges[&coedge.edge_id], coedge).0 == end).then_some(index)
812                })
813                .collect::<Vec<_>>();
814            let closes = end == start;
815            let successor = match (leaving.as_slice(), closes) {
816                ([], true) => None,
817                ([only], false) => Some(*only),
818                ([], false) => return None,
819                _ => {
820                    let space = space?;
821                    let here = pcurve_stations(cycle.last()?)?[2];
822                    let closing = closes && space.coincide(here, pcurve_stations(&cycle[0])?[0]);
823                    let mut continuing = Vec::new();
824                    for index in leaving {
825                        if space.coincide(here, pcurve_stations(&remaining[index])?[0]) {
826                            continuing.push(index);
827                        }
828                    }
829                    match (continuing.as_slice(), closing) {
830                        ([], true) => None,
831                        ([only], false) => Some(*only),
832                        _ => return None,
833                    }
834                }
835            };
836            let Some(index) = successor else {
837                break;
838            };
839            let next = remaining.remove(index);
840            end = traversal_vertices(edges[&next.edge_id], &next).1;
841            cycle.push(next);
842            if cycle.len() > maximum_cycle_length {
843                return None;
844            }
845        }
846        // A cycle that traverses every one of its edges both ways encloses
847        // nothing, however its parameter-space image reads.
848        let mut counts = HashMap::<u64, usize>::default();
849        for coedge in &cycle {
850            *counts.entry(coedge.edge_id).or_default() += 1;
851        }
852        if counts.values().all(|count| *count == 2) {
853            return None;
854        }
855        loops.push(LoopRecord {
856            id: loops.len() as u64 + 1,
857            coedges: cycle,
858        });
859    }
860    Some(loops)
861}
862
863/// Whether two faces ride ONE carrier — the same geometric surface, oriented
864/// the same way out of the material — by any of the four readings this module
865/// merges on: bit-identical patches, coplanar planes with the same outward
866/// normal, cosurface cylinder patches, and coextruded profiles.
867///
868/// `pub(crate)` because the direct-edit rejoin asks the same question for the
869/// same reason: a survivor face on the far side of a deleted band can only
870/// take the near side's loops when the two are one carrier. It asks GEOMETRIC
871/// cosurface rather than [`same_surface`] alone, because a mirrored twin is
872/// the same surface reparametrized — reflected control points, flipped
873/// `same_sense` — and bit-identity reads that as two.
874pub(crate) fn faces_are_cosurface(
875    first: &FaceRecord,
876    second: &FaceRecord,
877    tolerance: f64,
878) -> Result<bool, KernelRefusal> {
879    if first.same_sense == second.same_sense
880        && same_surface(&first.surface, &second.surface, tolerance)
881    {
882        return Ok(true);
883    }
884    if coplanar_with_same_outward_normal(first, second, tolerance)? {
885        return Ok(true);
886    }
887    if cosurface_cylinder_pair(first, second, tolerance)?.is_some() {
888        return Ok(true);
889    }
890    Ok(coextrusion_pair(first, second, tolerance)?.is_some())
891}
892
893fn try_merge_pair(
894    older: &FaceRecord,
895    newer: &FaceRecord,
896    edges: &HashMap<u64, &EdgeRecord>,
897    tolerance: f64,
898    same_carrier_area_tolerance_ratio: f64,
899) -> Result<Option<FaceRecord>, KernelRefusal> {
900    let same_carrier = older.same_sense == newer.same_sense
901        && same_surface(&older.surface, &newer.surface, tolerance);
902    let coplanar_planar =
903        !same_carrier && coplanar_with_same_outward_normal(older, newer, tolerance)?;
904    let cosurface_cylinder = if !same_carrier && !coplanar_planar {
905        cosurface_cylinder_pair(older, newer, tolerance)?
906    } else {
907        None
908    };
909    let cosurface_extrusion = if !same_carrier && !coplanar_planar && cosurface_cylinder.is_none() {
910        coextrusion_pair(older, newer, tolerance)?
911    } else {
912        None
913    };
914    let older_coedges = older
915        .loops
916        .iter()
917        .flat_map(|loop_record| &loop_record.coedges)
918        .collect::<Vec<_>>();
919    let newer_coedges = newer
920        .loops
921        .iter()
922        .flat_map(|loop_record| &loop_record.coedges)
923        .collect::<Vec<_>>();
924    let mut shared_older = HashSet::default();
925    let mut shared_newer = HashSet::default();
926    for first in &older_coedges {
927        if edges[&first.edge_id].degenerate {
928            continue;
929        }
930        for second in &newer_coedges {
931            if first.edge_id != second.edge_id {
932                continue;
933            }
934            if first.forward == second.forward {
935                return Ok(None);
936            }
937            shared_older.insert(first.id);
938            shared_newer.insert(second.id);
939        }
940    }
941    if shared_older.is_empty() || shared_older.len() != shared_newer.len() {
942        return Ok(None);
943    }
944    if !same_carrier
945        && !coplanar_planar
946        && cosurface_cylinder.is_none()
947        && cosurface_extrusion.is_none()
948    {
949        return Ok(None);
950    }
951    let mut remaining = older_coedges
952        .into_iter()
953        .filter(|coedge| {
954            !shared_older.contains(&coedge.id)
955                && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
956        })
957        .chain(newer_coedges.into_iter().filter(|coedge| {
958            !shared_newer.contains(&coedge.id)
959                && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
960        }))
961        .cloned()
962        .collect::<Vec<_>>();
963    let space = same_carrier.then(|| SharedParameterSpace {
964        surface: &older.surface,
965        weld: crate::tolerance::assembler_weld(tolerance),
966    });
967    if let Some(space) = &space {
968        dissolve_slits(&mut remaining, edges, space);
969    }
970    let Some(mut loops) = walk_cycles(remaining, edges, space.as_ref()) else {
971        return Ok(None);
972    };
973    if loops.iter().any(|loop_record| {
974        loop_record
975            .coedges
976            .iter()
977            .all(|coedge| edges[&coedge.edge_id].degenerate)
978    }) {
979        return Ok(None);
980    }
981    if loops.is_empty() {
982        return Ok(None);
983    }
984    loops.sort_by(|first, second| {
985        let area = |loop_record: &LoopRecord| {
986            parameter_space_area(&FaceRecord {
987                id: older.id,
988                surface: older.surface.clone(),
989                same_sense: older.same_sense,
990                loops: vec![loop_record.clone()],
991                name: None,
992            })
993            .map(f64::abs)
994        };
995        match (area(first), area(second)) {
996            (Ok(a), Ok(b)) => b.total_cmp(&a),
997            _ => std::cmp::Ordering::Equal,
998        }
999    });
1000    if same_carrier {
1001        let merged = FaceRecord {
1002            id: older.id,
1003            surface: older.surface.clone(),
1004            same_sense: older.same_sense,
1005            loops,
1006            name: older.name.clone(),
1007        };
1008        let expected = parameter_space_area(older)
1009            .or_refuse(KernelStage::Sew, "parameter_space_area")?
1010            + parameter_space_area(newer).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1011        let actual =
1012            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1013        let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
1014        if (actual - expected).abs() > area_tolerance
1015            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
1016        {
1017            return Ok(None);
1018        }
1019        return Ok(Some(merged));
1020    }
1021
1022    if let Some(support) = cosurface_cylinder {
1023        // Build one revolution carrier covering both patches: seam at the
1024        // largest angular gap so the merged region never crosses it, then
1025        // reproject every loop onto the shared cylinder.
1026        let x_reference = {
1027            let seed = loops
1028                .iter()
1029                .flat_map(|loop_record| &loop_record.coedges)
1030                .find_map(|coedge| {
1031                    let edge = edges[&coedge.edge_id];
1032                    edge.curve.evaluate(edge.t0).ok()
1033                })
1034                .ok_or_else(|| {
1035                    KernelRefusal::internal(
1036                        KernelStage::Sew,
1037                        "face_merge",
1038                        "cylinder merge: no boundary sample",
1039                    )
1040                })?;
1041            let delta = seed.sub(support.origin);
1042            let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
1043            match radial.normalized() {
1044                Ok(radial) => radial,
1045                Err(_) => return Ok(None),
1046            }
1047        };
1048        let y_reference = support.axis.cross(x_reference);
1049        let mut angles = Vec::new();
1050        let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
1051        for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1052            let edge = edges[&coedge.edge_id];
1053            let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1054            let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
1055            for index in 0..=8 {
1056                let point = curve
1057                    .evaluate(c0 + (c1 - c0) * index as f64 / 8.0)
1058                    .or_refuse(KernelStage::Sew, "evaluate")?;
1059                let delta = point.sub(support.origin);
1060                let axial = delta.dot(support.axis);
1061                axial_range[0] = axial_range[0].min(axial);
1062                axial_range[1] = axial_range[1].max(axial);
1063                let radial = delta.sub(support.axis.scale(axial));
1064                if radial.length() <= support.radius * 1e-6 {
1065                    continue;
1066                }
1067                let mut angle = radial.dot(y_reference).atan2(radial.dot(x_reference));
1068                if angle < 0.0 {
1069                    angle += std::f64::consts::TAU;
1070                }
1071                angles.push(angle);
1072            }
1073        }
1074        if angles.len() < 3 || axial_range[1] - axial_range[0] <= tolerance {
1075            return Ok(None);
1076        }
1077        angles.sort_by(f64::total_cmp);
1078        let mut gap_start = angles.len() - 1;
1079        let mut largest_gap = angles[0] + std::f64::consts::TAU - angles[angles.len() - 1];
1080        for index in 0..angles.len() - 1 {
1081            let gap = angles[index + 1] - angles[index];
1082            if gap > largest_gap {
1083                largest_gap = gap;
1084                gap_start = index;
1085            }
1086        }
1087        let span = std::f64::consts::TAU - largest_gap;
1088        if span <= tolerance || span >= std::f64::consts::TAU - 1e-6 {
1089            return Ok(None);
1090        }
1091        let start_angle = angles[(gap_start + 1) % angles.len()];
1092        let start_direction = x_reference
1093            .scale(start_angle.cos())
1094            .add(y_reference.scale(start_angle.sin()));
1095        let generatrix = make_line(
1096            support
1097                .origin
1098                .add(support.axis.scale(axial_range[0]))
1099                .add(start_direction.scale(support.radius)),
1100            support
1101                .origin
1102                .add(support.axis.scale(axial_range[1]))
1103                .add(start_direction.scale(support.radius)),
1104        )
1105        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1106        let surface = make_revolution(support.origin, support.axis, &generatrix, span)
1107            .or_refuse(KernelStage::Sew, "make_revolution")?;
1108        let (u_domain, v_domain) = surface_domains(&surface)?;
1109        let sample = surface
1110            .evaluate(
1111                (u_domain[0] + u_domain[1]) / 2.0,
1112                (v_domain[0] + v_domain[1]) / 2.0,
1113            )
1114            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1115        let geometric_normal = surface
1116            .normal(
1117                (u_domain[0] + u_domain[1]) / 2.0,
1118                (v_domain[0] + v_domain[1]) / 2.0,
1119            )
1120            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1121        let delta = sample.sub(support.origin);
1122        let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
1123        let radial = match radial.normalized() {
1124            Ok(radial) => radial,
1125            Err(_) => return Ok(None),
1126        };
1127        let same_sense = geometric_normal.dot(radial).signum() == support.outward_radial_sign;
1128        let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1129        projected_loops.sort_by(|first, second| {
1130            let area = |loop_record: &LoopRecord| {
1131                parameter_space_area(&FaceRecord {
1132                    id: older.id,
1133                    surface: surface.clone(),
1134                    same_sense,
1135                    loops: vec![loop_record.clone()],
1136                    name: None,
1137                })
1138                .map(f64::abs)
1139            };
1140            match (area(first), area(second)) {
1141                (Ok(a), Ok(b)) => b.total_cmp(&a),
1142                _ => std::cmp::Ordering::Equal,
1143            }
1144        });
1145        let merged = FaceRecord {
1146            id: older.id,
1147            surface,
1148            same_sense,
1149            loops: projected_loops,
1150            name: older.name.clone(),
1151        };
1152        let area =
1153            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1154        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1155            return Ok(None);
1156        }
1157        // The reprojected trims must reproduce the pieces' combined 3D
1158        // area; a projection or seam mistake shows up here immediately.
1159        let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1160            + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1161        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1162        if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1163            return Ok(None);
1164        }
1165        return Ok(Some(merged));
1166    }
1167
1168    if let Some((support, union)) = cosurface_extrusion {
1169        // Shared linear-sweep carrier: the older profile translated to the
1170        // union's start plane, extruded across the union interval.
1171        let shift = support.direction.scale(union[0] - support.interval[0]);
1172        let step = support.direction.scale(union[1] - union[0]);
1173        let rows = support
1174            .base
1175            .control_points
1176            .iter()
1177            .map(|control| -> Result<Vec<Vec4>, KernelRefusal> {
1178                let point = control
1179                    .point()
1180                    .or_refuse(KernelStage::Sew, "point")?
1181                    .add(shift);
1182                Ok(vec![
1183                    Vec4::from_point(point, control.w),
1184                    Vec4::from_point(point.add(step), control.w),
1185                ])
1186            })
1187            .collect::<Result<Vec<_>, _>>()?;
1188        let surface = NurbsSurface::new(
1189            support.base.degree,
1190            1,
1191            support.base.knots.clone(),
1192            vec![0.0, 0.0, 1.0, 1.0],
1193            rows,
1194        )
1195        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1196        let (older_u, older_v) = surface_domains(&older.surface)?;
1197        let older_mid = older
1198            .surface
1199            .evaluate(
1200                (older_u[0] + older_u[1]) / 2.0,
1201                (older_v[0] + older_v[1]) / 2.0,
1202            )
1203            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1204        let projection = crate::project_point_to_surface(&surface, older_mid)
1205            .or_refuse(KernelStage::Sew, "project_point_to_surface")?;
1206        let carrier_normal = surface
1207            .normal(projection.u, projection.v)
1208            .or_refuse(KernelStage::Sew, "normal")?;
1209        let same_sense = carrier_normal.dot(outward_normal(older)?) > 0.0;
1210        let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1211        projected_loops.sort_by(|first, second| {
1212            let area = |loop_record: &LoopRecord| {
1213                parameter_space_area(&FaceRecord {
1214                    id: older.id,
1215                    surface: surface.clone(),
1216                    same_sense,
1217                    loops: vec![loop_record.clone()],
1218                    name: None,
1219                })
1220                .map(f64::abs)
1221            };
1222            match (area(first), area(second)) {
1223                (Ok(a), Ok(b)) => b.total_cmp(&a),
1224                _ => std::cmp::Ordering::Equal,
1225            }
1226        });
1227        let merged = FaceRecord {
1228            id: older.id,
1229            surface,
1230            same_sense,
1231            loops: projected_loops,
1232            name: older.name.clone(),
1233        };
1234        let area =
1235            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1236        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1237            return Ok(None);
1238        }
1239        let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1240            + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1241        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1242        if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1243            if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1244                eprintln!(
1245                    "coextrusion {}x{}: area mismatch {actual} vs {expected}",
1246                    older.id, newer.id
1247                );
1248            }
1249            return Ok(None);
1250        }
1251        return Ok(Some(merged));
1252    }
1253
1254    let (u_domain, v_domain) = surface_domains(&older.surface)?;
1255    let origin = older
1256        .surface
1257        .evaluate(u_domain[0], v_domain[0])
1258        .or_refuse(KernelStage::Sew, "evaluate")?;
1259    let derivatives = older
1260        .surface
1261        .derivatives(
1262            (u_domain[0] + u_domain[1]) / 2.0,
1263            (v_domain[0] + v_domain[1]) / 2.0,
1264            1,
1265        )
1266        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1267    let u_axis = derivatives[1][0]
1268        .normalized()
1269        .or_refuse(KernelStage::Sew, "normalized")?;
1270    let geometric_normal = derivatives[1][0]
1271        .cross(derivatives[0][1])
1272        .normalized()
1273        .or_refuse(KernelStage::Sew, "normalized")?;
1274    let v_axis = geometric_normal
1275        .cross(u_axis)
1276        .normalized()
1277        .or_refuse(KernelStage::Sew, "normalized")?;
1278    let mut bounds_u = [f64::INFINITY, f64::NEG_INFINITY];
1279    let mut bounds_v = [f64::INFINITY, f64::NEG_INFINITY];
1280    for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1281        let edge = edges[&coedge.edge_id];
1282        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1283        for control in &curve.control_points {
1284            let relative = control
1285                .point()
1286                .or_refuse(KernelStage::Sew, "point")?
1287                .sub(origin);
1288            let u = relative.dot(u_axis);
1289            let v = relative.dot(v_axis);
1290            bounds_u[0] = bounds_u[0].min(u);
1291            bounds_u[1] = bounds_u[1].max(u);
1292            bounds_v[0] = bounds_v[0].min(v);
1293            bounds_v[1] = bounds_v[1].max(v);
1294        }
1295    }
1296    let extent_u = bounds_u[1] - bounds_u[0];
1297    let extent_v = bounds_v[1] - bounds_v[0];
1298    if extent_u <= tolerance || extent_v <= tolerance {
1299        return Ok(None);
1300    }
1301    let surface_origin = origin
1302        .add(u_axis.scale(bounds_u[0]))
1303        .add(v_axis.scale(bounds_v[0]));
1304    let surface = make_plane(surface_origin, u_axis, v_axis, extent_u, extent_v)
1305        .or_refuse(KernelStage::Sew, "make_plane")?;
1306    let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1307    projected_loops.sort_by(|first, second| {
1308        let area = |loop_record: &LoopRecord| {
1309            parameter_space_area(&FaceRecord {
1310                id: older.id,
1311                surface: surface.clone(),
1312                same_sense: older.same_sense,
1313                loops: vec![loop_record.clone()],
1314                name: None,
1315            })
1316            .map(f64::abs)
1317        };
1318        match (area(first), area(second)) {
1319            (Ok(a), Ok(b)) => b.total_cmp(&a),
1320            _ => std::cmp::Ordering::Equal,
1321        }
1322    });
1323    let merged = FaceRecord {
1324        id: older.id,
1325        surface,
1326        same_sense: older.same_sense,
1327        loops: projected_loops,
1328        name: older.name.clone(),
1329    };
1330    let area = parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1331    if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1332        return Ok(None);
1333    }
1334    Ok(Some(merged))
1335}
1336
1337fn try_merge_connected_group(
1338    faces: &[FaceRecord],
1339    edges: &HashMap<u64, &EdgeRecord>,
1340    tolerance: f64,
1341    same_carrier_area_tolerance_ratio: f64,
1342    keep_unmerged: &[String],
1343) -> Result<Option<(Vec<usize>, FaceRecord)>, KernelRefusal> {
1344    for seed in 0..faces.len() {
1345        // A pinned face never seeds or joins a merge group.
1346        if face_is_pinned(&faces[seed], keep_unmerged) {
1347            continue;
1348        }
1349        let mut component = [seed].into_iter().collect::<HashSet<_>>();
1350        loop {
1351            let mut changed = false;
1352            for candidate in 0..faces.len() {
1353                if component.contains(&candidate)
1354                    || face_is_pinned(&faces[candidate], keep_unmerged)
1355                    || faces[candidate].same_sense != faces[seed].same_sense
1356                    || !same_surface(&faces[candidate].surface, &faces[seed].surface, 1e-7)
1357                {
1358                    continue;
1359                }
1360                let candidate_uses = faces[candidate]
1361                    .loops
1362                    .iter()
1363                    .flat_map(|loop_record| &loop_record.coedges);
1364                let adjacent = candidate_uses.into_iter().any(|candidate_use| {
1365                    !edges[&candidate_use.edge_id].degenerate
1366                        && component.iter().any(|member| {
1367                            faces[*member]
1368                                .loops
1369                                .iter()
1370                                .flat_map(|loop_record| &loop_record.coedges)
1371                                .any(|member_use| {
1372                                    member_use.edge_id == candidate_use.edge_id
1373                                        && member_use.forward != candidate_use.forward
1374                                })
1375                        })
1376                });
1377                if adjacent {
1378                    component.insert(candidate);
1379                    changed = true;
1380                }
1381            }
1382            if !changed {
1383                break;
1384            }
1385        }
1386        if component.len() < 2 {
1387            continue;
1388        }
1389
1390        let mut uses_by_edge = HashMap::<u64, Vec<CoedgeRecord>>::default();
1391        for index in &component {
1392            for coedge in faces[*index]
1393                .loops
1394                .iter()
1395                .flat_map(|loop_record| &loop_record.coedges)
1396            {
1397                uses_by_edge
1398                    .entry(coedge.edge_id)
1399                    .or_default()
1400                    .push(coedge.clone());
1401            }
1402        }
1403        if uses_by_edge.values().any(|uses| uses.len() > 2) {
1404            continue;
1405        }
1406        let internal = uses_by_edge
1407            .iter()
1408            .filter_map(|(edge_id, uses)| {
1409                (uses.len() == 2
1410                    && !edges[edge_id].degenerate
1411                    && uses[0].forward != uses[1].forward)
1412                    .then_some(*edge_id)
1413            })
1414            .collect::<HashSet<_>>();
1415        let remaining = uses_by_edge
1416            .into_iter()
1417            .filter(|(edge_id, _)| !internal.contains(edge_id))
1418            .flat_map(|(_, uses)| uses)
1419            .collect::<Vec<_>>();
1420        let space = SharedParameterSpace {
1421            surface: &faces[seed].surface,
1422            weld: crate::tolerance::assembler_weld(tolerance),
1423        };
1424        let Some(mut loops) = walk_cycles(remaining, edges, Some(&space)) else {
1425            continue;
1426        };
1427        if loops.is_empty() {
1428            continue;
1429        }
1430        loops.sort_by(|first, second| {
1431            let area = |loop_record: &LoopRecord| {
1432                parameter_space_area(&FaceRecord {
1433                    id: faces[seed].id,
1434                    surface: faces[seed].surface.clone(),
1435                    same_sense: faces[seed].same_sense,
1436                    loops: vec![loop_record.clone()],
1437                    name: None,
1438                })
1439                .map(f64::abs)
1440            };
1441            match (area(first), area(second)) {
1442                (Ok(a), Ok(b)) => b.total_cmp(&a),
1443                _ => std::cmp::Ordering::Equal,
1444            }
1445        });
1446        let merged = FaceRecord {
1447            id: faces[seed].id,
1448            surface: faces[seed].surface.clone(),
1449            same_sense: faces[seed].same_sense,
1450            loops,
1451            name: faces[seed].name.clone(),
1452        };
1453        let expected = component
1454            .iter()
1455            .map(|index| parameter_space_area(&faces[*index]))
1456            .collect::<Result<Vec<_>, _>>()
1457            .or_refuse(KernelStage::Sew, "healing.face_merge")?
1458            .into_iter()
1459            .sum::<f64>();
1460        let actual =
1461            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1462        let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
1463        if (actual - expected).abs() > area_tolerance
1464            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
1465        {
1466            continue;
1467        }
1468        let mut indices = component.into_iter().collect::<Vec<_>>();
1469        indices.sort_unstable();
1470        return Ok(Some((indices, merged)));
1471    }
1472    Ok(None)
1473}
1474
1475/// A face is PINNED out of the merge when its `name` contains any substring in
1476/// `keep_unmerged`: it never coalesces with a neighbour (in any merge lane) and
1477/// is emitted unchanged. An empty `keep_unmerged` pins nothing — the historical
1478/// behavior.
1479fn face_is_pinned(face: &FaceRecord, keep_unmerged: &[String]) -> bool {
1480    match &face.name {
1481        Some(name) => keep_unmerged
1482            .iter()
1483            .any(|needle| name.contains(needle.as_str())),
1484        None => false,
1485    }
1486}
1487
1488/// Pairwise merge driver for one shell.
1489///
1490/// `try_merge_pair` returns `None` for any two faces that share no
1491/// non-degenerate edge, so the only candidates are edge-adjacent pairs; they
1492/// are kept in a set ordered by the faces' original positions and evaluated
1493/// smallest first. Faces stay in their original slots (a merged face keeps
1494/// the lower slot, the absorbed face's slot is emptied), so that order never
1495/// shifts. A rejected pair is dropped for good: `try_merge_pair` is a pure
1496/// function of the two face records and the constant edge map, and a pair is
1497/// re-queued only when one of its faces changes. Hence each accepted merge is
1498/// the lexicographically first accepting pair over all faces — the same pair
1499/// the former full rescan found — and the result is the same face set, in
1500/// the same order, reached without re-examining every pair per merge.
1501struct PairMerger<'a> {
1502    slots: Vec<Option<FaceRecord>>,
1503    pinned: Vec<bool>,
1504    edges: &'a HashMap<u64, &'a EdgeRecord>,
1505    /// Non-degenerate edge id → slots of the faces using it.
1506    uses: HashMap<u64, Vec<usize>>,
1507    /// Edge-adjacent pairs `(lower slot, higher slot)` not yet rejected.
1508    candidates: std::collections::BTreeSet<(usize, usize)>,
1509}
1510
1511impl<'a> PairMerger<'a> {
1512    fn new(
1513        faces: Vec<FaceRecord>,
1514        edges: &'a HashMap<u64, &'a EdgeRecord>,
1515        keep_unmerged: &[String],
1516    ) -> Self {
1517        let pinned = faces
1518            .iter()
1519            .map(|face| face_is_pinned(face, keep_unmerged))
1520            .collect::<Vec<_>>();
1521        let mut merger = Self {
1522            slots: faces.into_iter().map(Some).collect(),
1523            pinned,
1524            edges,
1525            uses: HashMap::default(),
1526            candidates: std::collections::BTreeSet::new(),
1527        };
1528        for slot in 0..merger.slots.len() {
1529            merger.index_slot(slot);
1530        }
1531        merger
1532    }
1533
1534    fn slot_edge_ids(&self, slot: usize) -> Vec<u64> {
1535        let Some(face) = &self.slots[slot] else {
1536            return Vec::new();
1537        };
1538        let mut ids = face
1539            .loops
1540            .iter()
1541            .flat_map(|loop_record| &loop_record.coedges)
1542            .map(|coedge| coedge.edge_id)
1543            .filter(|edge_id| !self.edges[edge_id].degenerate)
1544            .collect::<Vec<_>>();
1545        ids.sort_unstable();
1546        ids.dedup();
1547        ids
1548    }
1549
1550    /// Register `slot`'s edges and queue it against every face already
1551    /// using one of them.
1552    fn index_slot(&mut self, slot: usize) {
1553        for edge_id in self.slot_edge_ids(slot) {
1554            let users = self.uses.entry(edge_id).or_default();
1555            for &other in users.iter() {
1556                if other != slot && !self.pinned[other] && !self.pinned[slot] {
1557                    self.candidates.insert((other.min(slot), other.max(slot)));
1558                }
1559            }
1560            users.push(slot);
1561        }
1562    }
1563
1564    fn unindex_slot(&mut self, slot: usize) {
1565        for edge_id in self.slot_edge_ids(slot) {
1566            if let Some(users) = self.uses.get_mut(&edge_id) {
1567                users.retain(|user| *user != slot);
1568            }
1569        }
1570        self.candidates
1571            .retain(|(lower, higher)| *lower != slot && *higher != slot);
1572    }
1573
1574    /// Perform up to `budget` merges; returns how many were made.
1575    fn run(
1576        &mut self,
1577        budget: usize,
1578        tolerance: f64,
1579        same_carrier_area_tolerance_ratio: f64,
1580    ) -> Result<usize, KernelRefusal> {
1581        let mut merged_count = 0;
1582        while merged_count < budget {
1583            let Some(&(lower, higher)) = self.candidates.iter().next() else {
1584                break;
1585            };
1586            self.candidates.remove(&(lower, higher));
1587            let (Some(older), Some(newer)) = (&self.slots[lower], &self.slots[higher]) else {
1588                continue;
1589            };
1590            let Some(merged) = try_merge_pair(
1591                older,
1592                newer,
1593                self.edges,
1594                tolerance,
1595                same_carrier_area_tolerance_ratio,
1596            )?
1597            else {
1598                continue;
1599            };
1600            self.unindex_slot(higher);
1601            self.unindex_slot(lower);
1602            self.slots[higher] = None;
1603            self.slots[lower] = Some(merged);
1604            self.index_slot(lower);
1605            merged_count += 1;
1606        }
1607        Ok(merged_count)
1608    }
1609
1610    fn into_faces(self) -> Vec<FaceRecord> {
1611        self.slots.into_iter().flatten().collect()
1612    }
1613}
1614
1615/// Merge adjacent fragments that retain the exact same carrier surface.
1616///
1617/// Every shared edge is cancelled in opposite directions and the remaining
1618/// coedges are stitched into outer/hole cycles without refitting geometry.
1619fn merge_same_surface_faces_impl(
1620    solid: &BrepSolid,
1621    tolerance: f64,
1622    validate: bool,
1623    same_carrier_area_tolerance_ratio: f64,
1624    keep_unmerged: &[String],
1625) -> Result<BrepSolid, KernelRefusal> {
1626    let mut result = solid.clone();
1627    // `result.edges` is not touched until the retain below, so the id map is
1628    // built once; every candidate pair sees the same edge records.
1629    let edges = result
1630        .edges
1631        .iter()
1632        .map(|edge| (edge.id, edge))
1633        .collect::<HashMap<_, _>>();
1634    for shell in &mut result.shells {
1635        loop {
1636            if !validate {
1637                if let Some((indices, merged)) = try_merge_connected_group(
1638                    &shell.faces,
1639                    &edges,
1640                    tolerance,
1641                    same_carrier_area_tolerance_ratio,
1642                    keep_unmerged,
1643                )? {
1644                    let first = indices[0];
1645                    shell.faces[first] = merged;
1646                    for index in indices.into_iter().skip(1).rev() {
1647                        shell.faces.remove(index);
1648                    }
1649                    continue;
1650                }
1651            }
1652            // The validating lane has no group phase, so it drains every pair
1653            // merge in one pass; the open lane merges one pair, then offers
1654            // the changed shell to the group phase again, exactly as before.
1655            let budget = if validate { usize::MAX } else { 1 };
1656            let mut merger = PairMerger::new(
1657                std::mem::take(&mut shell.faces),
1658                &edges,
1659                keep_unmerged,
1660            );
1661            let merged = merger.run(budget, tolerance, same_carrier_area_tolerance_ratio);
1662            shell.faces = merger.into_faces();
1663            if merged? == 0 || validate {
1664                break;
1665            }
1666        }
1667    }
1668    finish_merge(solid, result, validate)
1669}
1670
1671/// Merge each face in `absorbed` into an edge-adjacent face outside `absorbed`
1672/// that rides the same carrier — the pair [`merge_same_surface_faces`] would
1673/// merge, at the same `tolerance` — and nothing else.
1674///
1675/// The absorbing face keeps its id, its name and its slot in the shell, and
1676/// its carrier is the frame the merged face is built on; a face in `absorbed`
1677/// that shares a carrier with no such neighbour is left as it was. Two faces
1678/// outside `absorbed` are never merged with each other, however cosurface: the
1679/// caller is naming whose material a face is, not normalizing the solid, so a
1680/// coplanar pair the document kept apart stays apart. The result passes the
1681/// validating merge's Euler and `validate()` checks or the call refuses.
1682pub(crate) fn absorb_faces_into_carriers(
1683    solid: &BrepSolid,
1684    tolerance: f64,
1685    absorbed: &HashSet<u64>,
1686) -> Result<BrepSolid, KernelRefusal> {
1687    let mut result = solid.clone();
1688    let edges = result
1689        .edges
1690        .iter()
1691        .map(|edge| (edge.id, edge))
1692        .collect::<HashMap<_, _>>();
1693    let edge_ids = |face: &FaceRecord| -> HashSet<u64> {
1694        face.loops
1695            .iter()
1696            .flat_map(|loop_record| &loop_record.coedges)
1697            .map(|coedge| coedge.edge_id)
1698            .filter(|edge_id| !edges[edge_id].degenerate)
1699            .collect()
1700    };
1701    for shell in &mut result.shells {
1702        'merge: loop {
1703            for inner in 0..shell.faces.len() {
1704                if !absorbed.contains(&shell.faces[inner].id) {
1705                    continue;
1706                }
1707                let inner_edges = edge_ids(&shell.faces[inner]);
1708                for outer in 0..shell.faces.len() {
1709                    if outer == inner
1710                        || absorbed.contains(&shell.faces[outer].id)
1711                        || edge_ids(&shell.faces[outer]).is_disjoint(&inner_edges)
1712                    {
1713                        continue;
1714                    }
1715                    let Some(merged) =
1716                        try_merge_pair(&shell.faces[outer], &shell.faces[inner], &edges, tolerance, 1e-7)?
1717                    else {
1718                        continue;
1719                    };
1720                    shell.faces[outer] = merged;
1721                    shell.faces.remove(inner);
1722                    continue 'merge;
1723                }
1724            }
1725            break;
1726        }
1727    }
1728    finish_merge(solid, result, true)
1729}
1730
1731/// The tail every merge shares: drop the edges and vertices no face uses any
1732/// more and, on the validating lane, recount the genus and validate.
1733fn finish_merge(
1734    solid: &BrepSolid,
1735    mut result: BrepSolid,
1736    validate: bool,
1737) -> Result<BrepSolid, KernelRefusal> {
1738    let used_edges = result
1739        .shells
1740        .iter()
1741        .flat_map(|shell| &shell.faces)
1742        .flat_map(|face| &face.loops)
1743        .flat_map(|loop_record| &loop_record.coedges)
1744        .map(|coedge| coedge.edge_id)
1745        .collect::<HashSet<_>>();
1746    result.edges.retain(|edge| used_edges.contains(&edge.id));
1747    let used_vertices = result
1748        .edges
1749        .iter()
1750        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1751        .collect::<HashSet<_>>();
1752    result
1753        .vertices
1754        .retain(|vertex| used_vertices.contains(&vertex.id));
1755    if !validate {
1756        return Ok(result);
1757    }
1758    if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1759        let entry_faces: i64 = solid.shells.iter().map(|s| s.faces.len() as i64).sum();
1760        let entry_holes: i64 = solid
1761            .shells
1762            .iter()
1763            .flat_map(|s| &s.faces)
1764            .map(|f| f.loops.len().saturating_sub(1) as i64)
1765            .sum();
1766        let entry_edges = solid.edges.iter().filter(|e| !e.degenerate).count() as i64;
1767        let entry_used: std::collections::HashSet<u64> = solid
1768            .shells
1769            .iter()
1770            .flat_map(|s| &s.faces)
1771            .flat_map(|f| &f.loops)
1772            .flat_map(|l| &l.coedges)
1773            .map(|c| c.edge_id)
1774            .collect();
1775        eprintln!(
1776            "merge entry census: V={} E={entry_edges} (coedge-referenced {}) F={entry_faces} H={entry_holes} S={} stored_genus={}",
1777            solid.vertices.len(),
1778            entry_used.len(),
1779            solid.shells.len(),
1780            solid.genus
1781        );
1782    }
1783    let face_count = result
1784        .shells
1785        .iter()
1786        .map(|shell| shell.faces.len() as i64)
1787        .sum::<i64>();
1788    let hole_count = result.bounding_hole_count();
1789    let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
1790    // Count only LIVE vertices (referenced by a non-degenerate edge): the
1791    // merge removes edges and faces, orphaning vertices that then must not
1792    // participate in the Euler census — mirroring refresh_derived_genus
1793    // (boolean.rs). Counting stale vertices inflated V and rejected valid
1794    // merges as "non-integral genus" (the helmet report's final layer).
1795    let live_vertices: std::collections::HashSet<u64> = result
1796        .edges
1797        .iter()
1798        .filter(|edge| !edge.degenerate)
1799        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1800        .collect();
1801    let vertex_count = result
1802        .vertices
1803        .iter()
1804        .filter(|vertex| live_vertices.contains(&vertex.id))
1805        .count() as i64;
1806    let euler = vertex_count - edge_count + face_count - hole_count;
1807    let numerator = result.shells.len() as i64 * 2 - euler;
1808    // Mirror validate()'s convention (topology.rs): the reduced V−E+F−H
1809    // formula does not model parameter-space POLE COLLAPSES reliably, so on a
1810    // complex carrying degenerate edges the parity check must not reject —
1811    // the helmet result holds 17 legitimate degenerate (pole) edges and its
1812    // odd numerator here is a formula artifact, not a defect; validate()
1813    // below performs the real integrity checks either way. With no
1814    // degenerate edges the strict behavior is unchanged.
1815    let has_degenerate = result.edges.iter().any(|edge| edge.degenerate);
1816    if numerator < 0 || numerator % 2 != 0 {
1817        if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1818            eprintln!(
1819                "merge euler reject: V={vertex_count} E={edge_count} F={face_count} H={hole_count} S={} euler={euler} numerator={numerator} degenerate={has_degenerate}",
1820                result.shells.len()
1821            );
1822        }
1823        if !has_degenerate
1824            || std::env::var("BREP_MERGE_DEGENERATE_EULER_STRICT").as_deref() == Ok("1")
1825        {
1826            return Err(KernelRefusal::new(
1827                RefusalClass::NonIntegralGenus {
1828                    shells: result.shells.len() as u32,
1829                    euler: euler as i64,
1830                },
1831                KernelStage::Validate,
1832                "merge_same_surface_faces produced non-integral genus",
1833            ));
1834        }
1835    } else {
1836        result.genus = numerator / 2;
1837    }
1838    let issues = result.validate();
1839    if !issues.is_empty() {
1840        return Err(KernelRefusal::new(
1841            RefusalClass::DegenerateArrangement {
1842                open_edges: 0,
1843                issues: issues.len() as u32,
1844            },
1845            KernelStage::Validate,
1846            format!("merge_same_surface_faces produced invalid topology: {issues:?}"),
1847        ));
1848    }
1849    Ok(result)
1850}
1851
1852pub fn merge_same_surface_faces(
1853    solid: &BrepSolid,
1854    tolerance: f64,
1855) -> Result<BrepSolid, KernelRefusal> {
1856    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, &[])
1857}
1858
1859/// Like [`merge_same_surface_faces`], but any face whose `name` CONTAINS one of
1860/// `keep_unmerged` is pinned: it never merges with a coplanar/cosurface
1861/// neighbour and is emitted unchanged, while every other face merges exactly as
1862/// [`merge_same_surface_faces`] would. An empty `keep_unmerged` is identical to
1863/// the plain call.
1864pub fn merge_same_surface_faces_excluding(
1865    solid: &BrepSolid,
1866    tolerance: f64,
1867    keep_unmerged: &[String],
1868) -> Result<BrepSolid, KernelRefusal> {
1869    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, keep_unmerged)
1870}
1871
1872pub(crate) fn merge_same_surface_faces_open(
1873    solid: &BrepSolid,
1874    tolerance: f64,
1875) -> Result<BrepSolid, KernelRefusal> {
1876    merge_same_surface_faces_impl(solid, tolerance, false, 5e-3, &[])
1877}
1878