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
686/// An affine plane chart, proven from its complete bilinear control net.
687/// Rational or warped charts need the general projection path instead.
688struct AffinePlaneChart {
689    origin: Vec3,
690    du: Vec3,
691    dv: Vec3,
692    u0: f64,
693    v0: f64,
694}
695
696fn affine_plane_chart(surface: &NurbsSurface) -> Option<AffinePlaneChart> {
697    if surface.degree_u != 1 || surface.degree_v != 1
698        || surface.control_points.len() != 2
699        || surface.control_points.iter().any(|row| row.len() != 2)
700        || surface.knots_u.len() != 4 || surface.knots_v.len() != 4
701    { return None; }
702    let ku = &surface.knots_u;
703    let kv = &surface.knots_v;
704    if ku[0] != ku[1] || ku[2] != ku[3] || kv[0] != kv[1] || kv[2] != kv[3]
705        || ku[2] <= ku[1] || kv[2] <= kv[1]
706    { return None; }
707    let weight = surface.control_points[0][0].w;
708    if !weight.is_finite() || weight.abs() <= 1e-300
709        || surface.control_points.iter().flatten().any(|point| point.w != weight)
710    { return None; }
711    let p00 = surface.control_points[0][0].point().ok()?;
712    let p10 = surface.control_points[1][0].point().ok()?;
713    let p01 = surface.control_points[0][1].point().ok()?;
714    let p11 = surface.control_points[1][1].point().ok()?;
715    let u = p10.sub(p00);
716    let v = p01.sub(p00);
717    let scale = u.length().max(v.length()).max(1.0);
718    if p11.sub(p10).sub(v).length() > 1e-12 * scale { return None; }
719    let du = u.scale(1.0 / (ku[2] - ku[1]));
720    let dv = v.scale(1.0 / (kv[2] - kv[1]));
721    let determinant = du.dot(du) * dv.dot(dv) - du.dot(dv).powi(2);
722    if !determinant.is_finite() || determinant <= 1e-12 * du.dot(du) * dv.dot(dv) {
723        return None;
724    }
725    Some(AffinePlaneChart { origin: p00, du, dv, u0: ku[1], v0: kv[1] })
726}
727
728/// Transfer the existing trimmed locus, rather than replacing it with the
729/// shared edge's potentially displaced fitted curve. Affine reparameterization
730/// preserves every rational weight, knot and degree, including controls outside
731/// the surface's finite patch.
732fn transfer_affine_planar_pcurve(
733    source: &NurbsSurface,
734    target: &NurbsSurface,
735    curve: &NurbsCurve,
736    tolerance: f64,
737) -> Option<NurbsCurve> {
738    let source = affine_plane_chart(source)?;
739    let target = affine_plane_chart(target)?;
740    let uu = target.du.dot(target.du);
741    let uv = target.du.dot(target.dv);
742    let vv = target.dv.dot(target.dv);
743    let determinant = uu * vv - uv * uv;
744    let mut transferred = curve.clone();
745    for control in &mut transferred.control_points {
746        if !control.w.is_finite() || control.w.abs() <= 1e-300 { return None; }
747        let point = source.origin
748            .add(source.du.scale(control.x / control.w - source.u0))
749            .add(source.dv.scale(control.y / control.w - source.v0));
750        let relative = point.sub(target.origin);
751        let a = relative.dot(target.du);
752        let b = relative.dot(target.dv);
753        let u = (a * vv - b * uv) / determinant;
754        let v = (b * uu - a * uv) / determinant;
755        let reconstructed = target.origin.add(target.du.scale(u)).add(target.dv.scale(v));
756        if !u.is_finite() || !v.is_finite()
757            || reconstructed.sub(point).length() > tolerance.max(1e-12)
758        { return None; }
759        control.x = (target.u0 + u) * control.w;
760        control.y = (target.v0 + v) * control.w;
761        control.z = 0.0;
762    }
763    Some(transferred)
764}
765
766fn transfer_planar_loops(
767    target: &NurbsSurface,
768    loops: &[LoopRecord],
769    older: &FaceRecord,
770    newer: &FaceRecord,
771    tolerance: f64,
772) -> Option<Vec<LoopRecord>> {
773    let mut transferred = loops.to_vec();
774    for loop_record in &mut transferred {
775        for coedge in &mut loop_record.coedges {
776            let source = [older, newer].into_iter().find(|face| {
777                face.loops.iter().flat_map(|lp| &lp.coedges)
778                    .any(|original| original.id == coedge.id && original.edge_id == coedge.edge_id)
779            })?;
780            coedge.pcurve = transfer_affine_planar_pcurve(
781                &source.surface, target, &coedge.pcurve, tolerance,
782            )?;
783        }
784    }
785    Some(transferred)
786}
787
788fn reproject_loops(
789    surface: &NurbsSurface,
790    loops: Vec<LoopRecord>,
791    edges: &HashMap<u64, &EdgeRecord>,
792) -> Result<Vec<LoopRecord>, KernelRefusal> {
793    let mut projected_loops = Vec::new();
794    for (loop_index, loop_record) in loops.into_iter().enumerate() {
795        let mut projected = Vec::new();
796        for mut coedge in loop_record.coedges {
797            let edge = edges[&coedge.edge_id];
798            let mut curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
799            if !coedge.forward {
800                curve = curve.reversed().or_refuse(KernelStage::Sew, "reversed")?;
801            }
802            coedge.pcurve = build_pcurve_on_surface(surface, &curve)
803                .or_refuse(KernelStage::Sew, "build_pcurve_on_surface")?;
804            projected.push(coedge);
805        }
806        projected_loops.push(LoopRecord {
807            id: loop_index as u64 + 1,
808            coedges: projected,
809        });
810    }
811    Ok(projected_loops)
812}
813
814fn traversal_vertices(edge: &EdgeRecord, coedge: &CoedgeRecord) -> (u64, u64) {
815    if coedge.forward {
816        (edge.start_vertex_id, edge.end_vertex_id)
817    } else {
818        (edge.end_vertex_id, edge.start_vertex_id)
819    }
820}
821
822/// The parameter space every leftover coedge's pcurve lives in, when the merged
823/// faces share ONE carrier. Only then can a merge read a loop's parameter-space
824/// continuity; a reprojecting lane walks before its pcurves share a space.
825struct SharedParameterSpace<'a> {
826    surface: &'a NurbsSurface,
827    /// The radius that made two fitted endpoints one vertex.
828    weld: f64,
829}
830
831impl SharedParameterSpace<'_> {
832    fn coincide(&self, first: Vec3, second: Vec3) -> bool {
833        let band = crate::classification::surface_uv_band(self.surface, first.x, first.y, self.weld);
834        (first.x - second.x).abs() <= band && (first.y - second.y).abs() <= band
835    }
836}
837
838/// A coedge's pcurve at its start, middle and end. The pcurve runs in the
839/// coedge's own direction, so its domain start is where the traversal starts.
840fn pcurve_stations(coedge: &CoedgeRecord) -> Option<[Vec3; 3]> {
841    let [t0, t1] = coedge.pcurve.domain().ok()?;
842    Some([
843        coedge.pcurve.evaluate(t0).ok()?,
844        coedge.pcurve.evaluate((t0 + t1) / 2.0).ok()?,
845        coedge.pcurve.evaluate(t1).ok()?,
846    ])
847}
848
849/// SLIT DISSOLUTION. An edge whose two leftover uses run along the SAME
850/// parameter-space segment, one each way, separates nothing: its ring on the
851/// other side was the shared edge the merge just cancelled (a flanged hole's
852/// annulus, slit to the ring it bridged to). Kept, the walk can only return
853/// along it and close a loop of zero area. Two uses a PERIOD apart are a seam
854/// the region wraps across, and stay.
855fn dissolve_slits(
856    remaining: &mut Vec<CoedgeRecord>,
857    edges: &HashMap<u64, &EdgeRecord>,
858    space: &SharedParameterSpace,
859) {
860    let mut uses = HashMap::<u64, Vec<usize>>::default();
861    for (index, coedge) in remaining.iter().enumerate() {
862        uses.entry(coedge.edge_id).or_default().push(index);
863    }
864    let slits = uses
865        .iter()
866        .filter(|(edge_id, indices)| {
867            if indices.len() != 2 || edges[*edge_id].degenerate {
868                return false;
869            }
870            let (first, second) = (&remaining[indices[0]], &remaining[indices[1]]);
871            if first.forward == second.forward {
872                return false;
873            }
874            let (Some(first), Some(second)) = (pcurve_stations(first), pcurve_stations(second)) else {
875                return false;
876            };
877            space.coincide(first[0], second[2])
878                && space.coincide(first[1], second[1])
879                && space.coincide(first[2], second[0])
880        })
881        .map(|(edge_id, _)| *edge_id)
882        .collect::<HashSet<_>>();
883    remaining.retain(|coedge| !slits.contains(&coedge.edge_id));
884}
885
886/// Stitch a merged face's leftover coedges into loops, or `None` when the
887/// stitching is not determined.
888///
889/// A loop can pass one VERTEX twice — a bridge's foot on the ring it joins —
890/// so reaching the start vertex does not by itself close the loop, and a vertex
891/// with several unused coedges leaving it does not name the next one. There the
892/// walk follows the loop's PARAMETER-SPACE continuity when the coedges share a
893/// carrier (`space`): the successor starts where the current pcurve ends, and a
894/// seam's two images of one vertex a period apart are two different places.
895/// Without a shared space, or with no single successor, the merge is refused
896/// and the faces stay as they were assembled.
897fn walk_cycles(
898    mut remaining: Vec<CoedgeRecord>,
899    edges: &HashMap<u64, &EdgeRecord>,
900    space: Option<&SharedParameterSpace>,
901) -> Option<Vec<LoopRecord>> {
902    let maximum_cycle_length = remaining.len();
903    let mut loops = Vec::new();
904    while !remaining.is_empty() {
905        let first = remaining.remove(0);
906        let (start, mut end) = traversal_vertices(edges[&first.edge_id], &first);
907        let mut cycle = vec![first];
908        loop {
909            let leaving = remaining
910                .iter()
911                .enumerate()
912                .filter_map(|(index, coedge)| {
913                    (traversal_vertices(edges[&coedge.edge_id], coedge).0 == end).then_some(index)
914                })
915                .collect::<Vec<_>>();
916            let closes = end == start;
917            let successor = match (leaving.as_slice(), closes) {
918                ([], true) => None,
919                ([only], false) => Some(*only),
920                ([], false) => return None,
921                _ => {
922                    let space = space?;
923                    let here = pcurve_stations(cycle.last()?)?[2];
924                    let closing = closes && space.coincide(here, pcurve_stations(&cycle[0])?[0]);
925                    let mut continuing = Vec::new();
926                    for index in leaving {
927                        if space.coincide(here, pcurve_stations(&remaining[index])?[0]) {
928                            continuing.push(index);
929                        }
930                    }
931                    match (continuing.as_slice(), closing) {
932                        ([], true) => None,
933                        ([only], false) => Some(*only),
934                        _ => return None,
935                    }
936                }
937            };
938            let Some(index) = successor else {
939                break;
940            };
941            let next = remaining.remove(index);
942            end = traversal_vertices(edges[&next.edge_id], &next).1;
943            cycle.push(next);
944            if cycle.len() > maximum_cycle_length {
945                return None;
946            }
947        }
948        // A cycle that traverses every one of its edges both ways encloses
949        // nothing, however its parameter-space image reads.
950        let mut counts = HashMap::<u64, usize>::default();
951        for coedge in &cycle {
952            *counts.entry(coedge.edge_id).or_default() += 1;
953        }
954        if counts.values().all(|count| *count == 2) {
955            return None;
956        }
957        loops.push(LoopRecord {
958            id: loops.len() as u64 + 1,
959            coedges: cycle,
960        });
961    }
962    Some(loops)
963}
964
965/// Whether two faces ride ONE carrier — the same geometric surface, oriented
966/// the same way out of the material — by any of the four readings this module
967/// merges on: bit-identical patches, coplanar planes with the same outward
968/// normal, cosurface cylinder patches, and coextruded profiles.
969///
970/// `pub(crate)` because the direct-edit rejoin asks the same question for the
971/// same reason: a survivor face on the far side of a deleted band can only
972/// take the near side's loops when the two are one carrier. It asks GEOMETRIC
973/// cosurface rather than [`same_surface`] alone, because a mirrored twin is
974/// the same surface reparametrized — reflected control points, flipped
975/// `same_sense` — and bit-identity reads that as two.
976pub(crate) fn faces_are_cosurface(
977    first: &FaceRecord,
978    second: &FaceRecord,
979    tolerance: f64,
980) -> Result<bool, KernelRefusal> {
981    if first.same_sense == second.same_sense
982        && same_surface(&first.surface, &second.surface, tolerance)
983    {
984        return Ok(true);
985    }
986    if coplanar_with_same_outward_normal(first, second, tolerance)? {
987        return Ok(true);
988    }
989    if cosurface_cylinder_pair(first, second, tolerance)?.is_some() {
990        return Ok(true);
991    }
992    Ok(coextrusion_pair(first, second, tolerance)?.is_some())
993}
994
995/// Degree-one clamped trims follow their control polygon regardless of knot
996/// spacing. For higher degrees, equal positive rational bases make the
997/// difference of the two affine images a convex combination of control-point
998/// differences. Bound the whole
999/// shared trim, rather than relying on cancellation of its area integral.
1000fn opposed_planar_trims_agree(
1001    older: &FaceRecord,
1002    first: &CoedgeRecord,
1003    newer: &FaceRecord,
1004    second: &CoedgeRecord,
1005    tolerance: f64,
1006) -> bool {
1007    let Ok(reverse) = second.pcurve.reversed() else {
1008        return false;
1009    };
1010    let curve = &first.pcurve;
1011    if curve.degree != reverse.degree || curve.control_points.len() != reverse.control_points.len()
1012    {
1013        return false;
1014    }
1015    let linear_polygon = |c: &NurbsCurve| {
1016        c.degree == 1
1017            && c.knots.len() == c.control_points.len() + 2
1018            && c.knots[0] == c.knots[1]
1019            && c.knots[c.knots.len() - 1] == c.knots[c.knots.len() - 2]
1020            && c.knots[1..c.knots.len() - 1]
1021                .windows(2)
1022                .all(|k| k[1] > k[0])
1023    };
1024    let polygon = linear_polygon(curve) && linear_polygon(&reverse);
1025    if !polygon && curve.knots != reverse.knots {
1026        return false;
1027    }
1028    curve
1029        .control_points
1030        .iter()
1031        .zip(&reverse.control_points)
1032        .all(|(a, b)| {
1033            if !a.w.is_finite()
1034                || !b.w.is_finite()
1035                || a.w <= 0.0
1036                || b.w <= 0.0
1037                || (!polygon && a.w != b.w)
1038            {
1039                return false;
1040            }
1041            let (Ok(a), Ok(b)) = (a.point(), b.point()) else {
1042                return false;
1043            };
1044            let (Ok(a), Ok(b)) = (
1045                older.surface.evaluate_extended(a.x, a.y),
1046                newer.surface.evaluate_extended(b.x, b.y),
1047            ) else {
1048                return false;
1049            };
1050            a.sub(b).length() <= tolerance
1051        })
1052}
1053
1054fn try_merge_pair(
1055    older: &FaceRecord,
1056    newer: &FaceRecord,
1057    edges: &HashMap<u64, &EdgeRecord>,
1058    tolerance: f64,
1059    same_carrier_area_tolerance_ratio: f64,
1060) -> Result<Option<FaceRecord>, KernelRefusal> {
1061    let same_carrier = older.same_sense == newer.same_sense
1062        && same_surface(&older.surface, &newer.surface, tolerance);
1063    // Oppositely oriented planar patches can form a folded, zero-thickness
1064    // overlap along a multi-edge seam. Cancelling that seam preserves the
1065    // signed region, not the sum of the two unsigned face areas.
1066    let cancel_planar_fold = older.same_sense != newer.same_sense
1067        && older.loops.len() == 1
1068        && newer.loops.len() == 1
1069        && same_surface(&older.surface, &newer.surface, tolerance)
1070        && older
1071            .surface
1072            .is_affine()
1073            .or_refuse(KernelStage::Sew, "is_affine")?
1074        && newer
1075            .surface
1076            .is_affine()
1077            .or_refuse(KernelStage::Sew, "is_affine")?;
1078    let coplanar_planar =
1079        !same_carrier && coplanar_with_same_outward_normal(older, newer, tolerance)?;
1080    let cosurface_cylinder = if !same_carrier && !coplanar_planar {
1081        cosurface_cylinder_pair(older, newer, tolerance)?
1082    } else {
1083        None
1084    };
1085    let cosurface_extrusion = if !same_carrier && !coplanar_planar && cosurface_cylinder.is_none() {
1086        coextrusion_pair(older, newer, tolerance)?
1087    } else {
1088        None
1089    };
1090    let older_coedges = older
1091        .loops
1092        .iter()
1093        .flat_map(|loop_record| &loop_record.coedges)
1094        .collect::<Vec<_>>();
1095    let newer_coedges = newer
1096        .loops
1097        .iter()
1098        .flat_map(|loop_record| &loop_record.coedges)
1099        .collect::<Vec<_>>();
1100    let mut shared_older = HashSet::default();
1101    let mut shared_newer = HashSet::default();
1102    for first in &older_coedges {
1103        if edges[&first.edge_id].degenerate {
1104            continue;
1105        }
1106        for second in &newer_coedges {
1107            if first.edge_id != second.edge_id {
1108                continue;
1109            }
1110            if first.forward == second.forward {
1111                return Ok(None);
1112            }
1113            if cancel_planar_fold
1114                && !opposed_planar_trims_agree(older, first, newer, second, tolerance)
1115            {
1116                return Ok(None);
1117            }
1118            shared_older.insert(first.id);
1119            shared_newer.insert(second.id);
1120        }
1121    }
1122    if shared_older.is_empty() || shared_older.len() != shared_newer.len() {
1123        return Ok(None);
1124    }
1125    if cancel_planar_fold && shared_older.len() < 2 {
1126        return Ok(None);
1127    }
1128    if !same_carrier
1129        && !cancel_planar_fold
1130        && !coplanar_planar
1131        && cosurface_cylinder.is_none()
1132        && cosurface_extrusion.is_none()
1133    {
1134        return Ok(None);
1135    }
1136    let mut remaining = older_coedges
1137        .into_iter()
1138        .filter(|coedge| {
1139            !shared_older.contains(&coedge.id)
1140                && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
1141        })
1142        .chain(newer_coedges.into_iter().filter(|coedge| {
1143            !shared_newer.contains(&coedge.id)
1144                && !(coplanar_planar && edges[&coedge.edge_id].degenerate)
1145        }))
1146        .cloned()
1147        .collect::<Vec<_>>();
1148    let space = (same_carrier || cancel_planar_fold).then(|| SharedParameterSpace {
1149        surface: &older.surface,
1150        weld: crate::tolerance::assembler_weld(tolerance),
1151    });
1152    if cancel_planar_fold {
1153        // The opposite fold shares this affine chart too. A retraced slit
1154        // contributes no signed boundary, but three coincident samples do
1155        // not prove that: opposite bows can match there and enclose slivers.
1156        // Use the whole-curve control bound before discarding either use.
1157        let mut uses = HashMap::<u64, Vec<&CoedgeRecord>>::default();
1158        for coedge in &remaining {
1159            uses.entry(coedge.edge_id).or_default().push(coedge);
1160        }
1161        let mut slits = HashSet::default();
1162        for (id, uses) in uses {
1163            if uses.len() == 2
1164                && !edges[&id].degenerate
1165                && uses[0].forward != uses[1].forward
1166                && opposed_planar_trims_agree(older, uses[0], older, uses[1], tolerance)
1167            {
1168                slits.insert(id);
1169            }
1170        }
1171        remaining.retain(|coedge| !slits.contains(&coedge.edge_id));
1172    } else if let Some(space) = &space {
1173        dissolve_slits(&mut remaining, edges, space);
1174    }
1175    let Some(mut loops) = walk_cycles(remaining, edges, space.as_ref()) else {
1176        return Ok(None);
1177    };
1178    if loops.iter().any(|loop_record| {
1179        loop_record
1180            .coedges
1181            .iter()
1182            .all(|coedge| edges[&coedge.edge_id].degenerate)
1183    }) {
1184        return Ok(None);
1185    }
1186    if loops.is_empty() {
1187        return Ok(None);
1188    }
1189    loops.sort_by(|first, second| {
1190        let area = |loop_record: &LoopRecord| {
1191            parameter_space_area(&FaceRecord {
1192                id: older.id,
1193                surface: older.surface.clone(),
1194                same_sense: older.same_sense,
1195                loops: vec![loop_record.clone()],
1196                name: None,
1197            })
1198            .map(f64::abs)
1199        };
1200        match (area(first), area(second)) {
1201            (Ok(a), Ok(b)) => b.total_cmp(&a),
1202            _ => std::cmp::Ordering::Equal,
1203        }
1204    });
1205    if same_carrier || cancel_planar_fold {
1206        if cancel_planar_fold && loops.len() != 1 {
1207            return Ok(None);
1208        }
1209        let expected = parameter_space_area(older)
1210            .or_refuse(KernelStage::Sew, "parameter_space_area")?
1211            + parameter_space_area(newer).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1212        if cancel_planar_fold && expected.abs() <= tolerance * tolerance {
1213            return Ok(None);
1214        }
1215        let merged = FaceRecord {
1216            id: older.id,
1217            surface: older.surface.clone(),
1218            same_sense: if cancel_planar_fold {
1219                expected > 0.0
1220            } else {
1221                older.same_sense
1222            },
1223            loops,
1224            name: older.name.clone(),
1225        };
1226
1227        let actual =
1228            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1229        let area_ratio = if cancel_planar_fold {
1230            same_carrier_area_tolerance_ratio.min(1e-7)
1231        } else {
1232            same_carrier_area_tolerance_ratio
1233        };
1234        let area_tolerance = 1e-9f64.max(expected.abs() * area_ratio);
1235        if (actual - expected).abs() > area_tolerance
1236            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
1237        {
1238            return Ok(None);
1239        }
1240        return Ok(Some(merged));
1241    }
1242
1243    if let Some(support) = cosurface_cylinder {
1244        // Build one revolution carrier covering both patches: seam at the
1245        // largest angular gap so the merged region never crosses it, then
1246        // reproject every loop onto the shared cylinder.
1247        let x_reference = {
1248            let seed = loops
1249                .iter()
1250                .flat_map(|loop_record| &loop_record.coedges)
1251                .find_map(|coedge| {
1252                    let edge = edges[&coedge.edge_id];
1253                    edge.curve.evaluate(edge.t0).ok()
1254                })
1255                .ok_or_else(|| {
1256                    KernelRefusal::internal(
1257                        KernelStage::Sew,
1258                        "face_merge",
1259                        "cylinder merge: no boundary sample",
1260                    )
1261                })?;
1262            let delta = seed.sub(support.origin);
1263            let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
1264            match radial.normalized() {
1265                Ok(radial) => radial,
1266                Err(_) => return Ok(None),
1267            }
1268        };
1269        let y_reference = support.axis.cross(x_reference);
1270        let mut angles = Vec::new();
1271        let mut axial_range = [f64::INFINITY, f64::NEG_INFINITY];
1272        for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1273            let edge = edges[&coedge.edge_id];
1274            let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1275            let [c0, c1] = curve.domain().or_refuse(KernelStage::Sew, "domain")?;
1276            for index in 0..=8 {
1277                let point = curve
1278                    .evaluate(c0 + (c1 - c0) * index as f64 / 8.0)
1279                    .or_refuse(KernelStage::Sew, "evaluate")?;
1280                let delta = point.sub(support.origin);
1281                let axial = delta.dot(support.axis);
1282                axial_range[0] = axial_range[0].min(axial);
1283                axial_range[1] = axial_range[1].max(axial);
1284                let radial = delta.sub(support.axis.scale(axial));
1285                if radial.length() <= support.radius * 1e-6 {
1286                    continue;
1287                }
1288                let mut angle = radial.dot(y_reference).atan2(radial.dot(x_reference));
1289                if angle < 0.0 {
1290                    angle += std::f64::consts::TAU;
1291                }
1292                angles.push(angle);
1293            }
1294        }
1295        if angles.len() < 3 || axial_range[1] - axial_range[0] <= tolerance {
1296            return Ok(None);
1297        }
1298        angles.sort_by(f64::total_cmp);
1299        let mut gap_start = angles.len() - 1;
1300        let mut largest_gap = angles[0] + std::f64::consts::TAU - angles[angles.len() - 1];
1301        for index in 0..angles.len() - 1 {
1302            let gap = angles[index + 1] - angles[index];
1303            if gap > largest_gap {
1304                largest_gap = gap;
1305                gap_start = index;
1306            }
1307        }
1308        let span = std::f64::consts::TAU - largest_gap;
1309        if span <= tolerance || span >= std::f64::consts::TAU - 1e-6 {
1310            return Ok(None);
1311        }
1312        let start_angle = angles[(gap_start + 1) % angles.len()];
1313        let start_direction = x_reference
1314            .scale(start_angle.cos())
1315            .add(y_reference.scale(start_angle.sin()));
1316        let generatrix = make_line(
1317            support
1318                .origin
1319                .add(support.axis.scale(axial_range[0]))
1320                .add(start_direction.scale(support.radius)),
1321            support
1322                .origin
1323                .add(support.axis.scale(axial_range[1]))
1324                .add(start_direction.scale(support.radius)),
1325        )
1326        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1327        let surface = make_revolution(support.origin, support.axis, &generatrix, span)
1328            .or_refuse(KernelStage::Sew, "make_revolution")?;
1329        let (u_domain, v_domain) = surface_domains(&surface)?;
1330        let sample = surface
1331            .evaluate(
1332                (u_domain[0] + u_domain[1]) / 2.0,
1333                (v_domain[0] + v_domain[1]) / 2.0,
1334            )
1335            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1336        let geometric_normal = surface
1337            .normal(
1338                (u_domain[0] + u_domain[1]) / 2.0,
1339                (v_domain[0] + v_domain[1]) / 2.0,
1340            )
1341            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1342        let delta = sample.sub(support.origin);
1343        let radial = delta.sub(support.axis.scale(delta.dot(support.axis)));
1344        let radial = match radial.normalized() {
1345            Ok(radial) => radial,
1346            Err(_) => return Ok(None),
1347        };
1348        let same_sense = geometric_normal.dot(radial).signum() == support.outward_radial_sign;
1349        let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1350        projected_loops.sort_by(|first, second| {
1351            let area = |loop_record: &LoopRecord| {
1352                parameter_space_area(&FaceRecord {
1353                    id: older.id,
1354                    surface: surface.clone(),
1355                    same_sense,
1356                    loops: vec![loop_record.clone()],
1357                    name: None,
1358                })
1359                .map(f64::abs)
1360            };
1361            match (area(first), area(second)) {
1362                (Ok(a), Ok(b)) => b.total_cmp(&a),
1363                _ => std::cmp::Ordering::Equal,
1364            }
1365        });
1366        let merged = FaceRecord {
1367            id: older.id,
1368            surface,
1369            same_sense,
1370            loops: projected_loops,
1371            name: older.name.clone(),
1372        };
1373        let area =
1374            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1375        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1376            return Ok(None);
1377        }
1378        // The reprojected trims must reproduce the pieces' combined 3D
1379        // area; a projection or seam mistake shows up here immediately.
1380        let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1381            + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1382        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1383        if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1384            return Ok(None);
1385        }
1386        return Ok(Some(merged));
1387    }
1388
1389    if let Some((support, union)) = cosurface_extrusion {
1390        // Shared linear-sweep carrier: the older profile translated to the
1391        // union's start plane, extruded across the union interval.
1392        let shift = support.direction.scale(union[0] - support.interval[0]);
1393        let step = support.direction.scale(union[1] - union[0]);
1394        let rows = support
1395            .base
1396            .control_points
1397            .iter()
1398            .map(|control| -> Result<Vec<Vec4>, KernelRefusal> {
1399                let point = control
1400                    .point()
1401                    .or_refuse(KernelStage::Sew, "point")?
1402                    .add(shift);
1403                Ok(vec![
1404                    Vec4::from_point(point, control.w),
1405                    Vec4::from_point(point.add(step), control.w),
1406                ])
1407            })
1408            .collect::<Result<Vec<_>, _>>()?;
1409        let surface = NurbsSurface::new(
1410            support.base.degree,
1411            1,
1412            support.base.knots.clone(),
1413            vec![0.0, 0.0, 1.0, 1.0],
1414            rows,
1415        )
1416        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1417        let (older_u, older_v) = surface_domains(&older.surface)?;
1418        let older_mid = older
1419            .surface
1420            .evaluate(
1421                (older_u[0] + older_u[1]) / 2.0,
1422                (older_v[0] + older_v[1]) / 2.0,
1423            )
1424            .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1425        let projection = crate::project_point_to_surface(&surface, older_mid)
1426            .or_refuse(KernelStage::Sew, "project_point_to_surface")?;
1427        let carrier_normal = surface
1428            .normal(projection.u, projection.v)
1429            .or_refuse(KernelStage::Sew, "normal")?;
1430        let same_sense = carrier_normal.dot(outward_normal(older)?) > 0.0;
1431        let mut projected_loops = reproject_loops(&surface, loops, edges)?;
1432        projected_loops.sort_by(|first, second| {
1433            let area = |loop_record: &LoopRecord| {
1434                parameter_space_area(&FaceRecord {
1435                    id: older.id,
1436                    surface: surface.clone(),
1437                    same_sense,
1438                    loops: vec![loop_record.clone()],
1439                    name: None,
1440                })
1441                .map(f64::abs)
1442            };
1443            match (area(first), area(second)) {
1444                (Ok(a), Ok(b)) => b.total_cmp(&a),
1445                _ => std::cmp::Ordering::Equal,
1446            }
1447        });
1448        let merged = FaceRecord {
1449            id: older.id,
1450            surface,
1451            same_sense,
1452            loops: projected_loops,
1453            name: older.name.clone(),
1454        };
1455        let area =
1456            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1457        if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1458            return Ok(None);
1459        }
1460        let expected = face_area(older).or_refuse(KernelStage::Sew, "face_area")?
1461            + face_area(newer).or_refuse(KernelStage::Sew, "face_area")?;
1462        let actual = face_area(&merged).or_refuse(KernelStage::Sew, "face_area")?;
1463        if (actual - expected).abs() > expected.abs().max(1e-9) * 5e-3 {
1464            if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1465                eprintln!(
1466                    "coextrusion {}x{}: area mismatch {actual} vs {expected}",
1467                    older.id, newer.id
1468                );
1469            }
1470            return Ok(None);
1471        }
1472        return Ok(Some(merged));
1473    }
1474
1475    let (u_domain, v_domain) = surface_domains(&older.surface)?;
1476    let origin = older
1477        .surface
1478        .evaluate(u_domain[0], v_domain[0])
1479        .or_refuse(KernelStage::Sew, "evaluate")?;
1480    let derivatives = older
1481        .surface
1482        .derivatives(
1483            (u_domain[0] + u_domain[1]) / 2.0,
1484            (v_domain[0] + v_domain[1]) / 2.0,
1485            1,
1486        )
1487        .or_refuse(KernelStage::Sew, "healing.face_merge")?;
1488    let u_axis = derivatives[1][0]
1489        .normalized()
1490        .or_refuse(KernelStage::Sew, "normalized")?;
1491    let geometric_normal = derivatives[1][0]
1492        .cross(derivatives[0][1])
1493        .normalized()
1494        .or_refuse(KernelStage::Sew, "normalized")?;
1495    let v_axis = geometric_normal
1496        .cross(u_axis)
1497        .normalized()
1498        .or_refuse(KernelStage::Sew, "normalized")?;
1499    let mut bounds_u = [f64::INFINITY, f64::NEG_INFINITY];
1500    let mut bounds_v = [f64::INFINITY, f64::NEG_INFINITY];
1501    for coedge in loops.iter().flat_map(|loop_record| &loop_record.coedges) {
1502        let edge = edges[&coedge.edge_id];
1503        let curve = trimmed_curve(&edge.curve, edge.t0, edge.t1)?;
1504        for control in &curve.control_points {
1505            let relative = control
1506                .point()
1507                .or_refuse(KernelStage::Sew, "point")?
1508                .sub(origin);
1509            let u = relative.dot(u_axis);
1510            let v = relative.dot(v_axis);
1511            bounds_u[0] = bounds_u[0].min(u);
1512            bounds_u[1] = bounds_u[1].max(u);
1513            bounds_v[0] = bounds_v[0].min(v);
1514            bounds_v[1] = bounds_v[1].max(v);
1515        }
1516    }
1517    let extent_u = bounds_u[1] - bounds_u[0];
1518    let extent_v = bounds_v[1] - bounds_v[0];
1519    if extent_u <= tolerance || extent_v <= tolerance {
1520        return Ok(None);
1521    }
1522    let surface_origin = origin
1523        .add(u_axis.scale(bounds_u[0]))
1524        .add(v_axis.scale(bounds_v[0]));
1525    let surface = make_plane(surface_origin, u_axis, v_axis, extent_u, extent_v)
1526        .or_refuse(KernelStage::Sew, "make_plane")?;
1527    let mut projected_loops = match transfer_planar_loops(&surface, &loops, older, newer, tolerance)
1528    {
1529        Some(transferred) => transferred,
1530        None => reproject_loops(&surface, loops, edges)?,
1531    };
1532    projected_loops.sort_by(|first, second| {
1533        let area = |loop_record: &LoopRecord| {
1534            parameter_space_area(&FaceRecord {
1535                id: older.id,
1536                surface: surface.clone(),
1537                same_sense: older.same_sense,
1538                loops: vec![loop_record.clone()],
1539                name: None,
1540            })
1541            .map(f64::abs)
1542        };
1543        match (area(first), area(second)) {
1544            (Ok(a), Ok(b)) => b.total_cmp(&a),
1545            _ => std::cmp::Ordering::Equal,
1546        }
1547    });
1548    let merged = FaceRecord {
1549        id: older.id,
1550        surface,
1551        same_sense: older.same_sense,
1552        loops: projected_loops,
1553        name: older.name.clone(),
1554    };
1555    let area = parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1556    if area.abs() <= tolerance * tolerance || area.is_sign_positive() != merged.same_sense {
1557        return Ok(None);
1558    }
1559    Ok(Some(merged))
1560}
1561
1562fn try_merge_connected_group(
1563    faces: &[FaceRecord],
1564    edges: &HashMap<u64, &EdgeRecord>,
1565    tolerance: f64,
1566    same_carrier_area_tolerance_ratio: f64,
1567    keep_unmerged: &[String],
1568) -> Result<Option<(Vec<usize>, FaceRecord)>, KernelRefusal> {
1569    for seed in 0..faces.len() {
1570        // A pinned face never seeds or joins a merge group.
1571        if face_is_pinned(&faces[seed], keep_unmerged) {
1572            continue;
1573        }
1574        let mut component = [seed].into_iter().collect::<HashSet<_>>();
1575        loop {
1576            let mut changed = false;
1577            for candidate in 0..faces.len() {
1578                if component.contains(&candidate)
1579                    || face_is_pinned(&faces[candidate], keep_unmerged)
1580                    || faces[candidate].same_sense != faces[seed].same_sense
1581                    || !same_surface(&faces[candidate].surface, &faces[seed].surface, 1e-7)
1582                {
1583                    continue;
1584                }
1585                let candidate_uses = faces[candidate]
1586                    .loops
1587                    .iter()
1588                    .flat_map(|loop_record| &loop_record.coedges);
1589                let adjacent = candidate_uses.into_iter().any(|candidate_use| {
1590                    !edges[&candidate_use.edge_id].degenerate
1591                        && component.iter().any(|member| {
1592                            faces[*member]
1593                                .loops
1594                                .iter()
1595                                .flat_map(|loop_record| &loop_record.coedges)
1596                                .any(|member_use| {
1597                                    member_use.edge_id == candidate_use.edge_id
1598                                        && member_use.forward != candidate_use.forward
1599                                })
1600                        })
1601                });
1602                if adjacent {
1603                    component.insert(candidate);
1604                    changed = true;
1605                }
1606            }
1607            if !changed {
1608                break;
1609            }
1610        }
1611        if component.len() < 2 {
1612            continue;
1613        }
1614
1615        let mut uses_by_edge = HashMap::<u64, Vec<CoedgeRecord>>::default();
1616        for index in &component {
1617            for coedge in faces[*index]
1618                .loops
1619                .iter()
1620                .flat_map(|loop_record| &loop_record.coedges)
1621            {
1622                uses_by_edge
1623                    .entry(coedge.edge_id)
1624                    .or_default()
1625                    .push(coedge.clone());
1626            }
1627        }
1628        if uses_by_edge.values().any(|uses| uses.len() > 2) {
1629            continue;
1630        }
1631        let internal = uses_by_edge
1632            .iter()
1633            .filter_map(|(edge_id, uses)| {
1634                (uses.len() == 2
1635                    && !edges[edge_id].degenerate
1636                    && uses[0].forward != uses[1].forward)
1637                    .then_some(*edge_id)
1638            })
1639            .collect::<HashSet<_>>();
1640        let remaining = uses_by_edge
1641            .into_iter()
1642            .filter(|(edge_id, _)| !internal.contains(edge_id))
1643            .flat_map(|(_, uses)| uses)
1644            .collect::<Vec<_>>();
1645        let space = SharedParameterSpace {
1646            surface: &faces[seed].surface,
1647            weld: crate::tolerance::assembler_weld(tolerance),
1648        };
1649        let Some(mut loops) = walk_cycles(remaining, edges, Some(&space)) else {
1650            continue;
1651        };
1652        if loops.is_empty() {
1653            continue;
1654        }
1655        loops.sort_by(|first, second| {
1656            let area = |loop_record: &LoopRecord| {
1657                parameter_space_area(&FaceRecord {
1658                    id: faces[seed].id,
1659                    surface: faces[seed].surface.clone(),
1660                    same_sense: faces[seed].same_sense,
1661                    loops: vec![loop_record.clone()],
1662                    name: None,
1663                })
1664                .map(f64::abs)
1665            };
1666            match (area(first), area(second)) {
1667                (Ok(a), Ok(b)) => b.total_cmp(&a),
1668                _ => std::cmp::Ordering::Equal,
1669            }
1670        });
1671        let merged = FaceRecord {
1672            id: faces[seed].id,
1673            surface: faces[seed].surface.clone(),
1674            same_sense: faces[seed].same_sense,
1675            loops,
1676            name: faces[seed].name.clone(),
1677        };
1678        let expected = component
1679            .iter()
1680            .map(|index| parameter_space_area(&faces[*index]))
1681            .collect::<Result<Vec<_>, _>>()
1682            .or_refuse(KernelStage::Sew, "healing.face_merge")?
1683            .into_iter()
1684            .sum::<f64>();
1685        let actual =
1686            parameter_space_area(&merged).or_refuse(KernelStage::Sew, "parameter_space_area")?;
1687        let area_tolerance = 1e-9f64.max(expected.abs() * same_carrier_area_tolerance_ratio);
1688        if (actual - expected).abs() > area_tolerance
1689            || (actual.abs() > 1e-12 && actual.is_sign_positive() != merged.same_sense)
1690        {
1691            continue;
1692        }
1693        let mut indices = component.into_iter().collect::<Vec<_>>();
1694        indices.sort_unstable();
1695        return Ok(Some((indices, merged)));
1696    }
1697    Ok(None)
1698}
1699
1700/// A face is PINNED out of the merge when its `name` contains any substring in
1701/// `keep_unmerged`: it never coalesces with a neighbour (in any merge lane) and
1702/// is emitted unchanged. An empty `keep_unmerged` pins nothing — the historical
1703/// behavior.
1704fn face_is_pinned(face: &FaceRecord, keep_unmerged: &[String]) -> bool {
1705    match &face.name {
1706        Some(name) => keep_unmerged
1707            .iter()
1708            .any(|needle| name.contains(needle.as_str())),
1709        None => false,
1710    }
1711}
1712
1713/// Pairwise merge driver for one shell.
1714///
1715/// `try_merge_pair` returns `None` for any two faces that share no
1716/// non-degenerate edge, so the only candidates are edge-adjacent pairs; they
1717/// are kept in a set ordered by the faces' original positions and evaluated
1718/// smallest first. Faces stay in their original slots (a merged face keeps
1719/// the lower slot, the absorbed face's slot is emptied), so that order never
1720/// shifts. A rejected pair is dropped for good: `try_merge_pair` is a pure
1721/// function of the two face records and the constant edge map, and a pair is
1722/// re-queued only when one of its faces changes. Hence each accepted merge is
1723/// the lexicographically first accepting pair over all faces — the same pair
1724/// the former full rescan found — and the result is the same face set, in
1725/// the same order, reached without re-examining every pair per merge.
1726struct PairMerger<'a> {
1727    slots: Vec<Option<FaceRecord>>,
1728    pinned: Vec<bool>,
1729    edges: &'a HashMap<u64, &'a EdgeRecord>,
1730    /// Non-degenerate edge id → slots of the faces using it.
1731    uses: HashMap<u64, Vec<usize>>,
1732    /// Edge-adjacent pairs `(lower slot, higher slot)` not yet rejected.
1733    candidates: std::collections::BTreeSet<(usize, usize)>,
1734}
1735
1736impl<'a> PairMerger<'a> {
1737    fn new(
1738        faces: Vec<FaceRecord>,
1739        edges: &'a HashMap<u64, &'a EdgeRecord>,
1740        keep_unmerged: &[String],
1741    ) -> Self {
1742        let pinned = faces
1743            .iter()
1744            .map(|face| face_is_pinned(face, keep_unmerged))
1745            .collect::<Vec<_>>();
1746        let mut merger = Self {
1747            slots: faces.into_iter().map(Some).collect(),
1748            pinned,
1749            edges,
1750            uses: HashMap::default(),
1751            candidates: std::collections::BTreeSet::new(),
1752        };
1753        for slot in 0..merger.slots.len() {
1754            merger.index_slot(slot);
1755        }
1756        merger
1757    }
1758
1759    fn slot_edge_ids(&self, slot: usize) -> Vec<u64> {
1760        let Some(face) = &self.slots[slot] else {
1761            return Vec::new();
1762        };
1763        let mut ids = face
1764            .loops
1765            .iter()
1766            .flat_map(|loop_record| &loop_record.coedges)
1767            .map(|coedge| coedge.edge_id)
1768            .filter(|edge_id| !self.edges[edge_id].degenerate)
1769            .collect::<Vec<_>>();
1770        ids.sort_unstable();
1771        ids.dedup();
1772        ids
1773    }
1774
1775    /// Register `slot`'s edges and queue it against every face already
1776    /// using one of them.
1777    fn index_slot(&mut self, slot: usize) {
1778        for edge_id in self.slot_edge_ids(slot) {
1779            let users = self.uses.entry(edge_id).or_default();
1780            for &other in users.iter() {
1781                if other != slot && !self.pinned[other] && !self.pinned[slot] {
1782                    self.candidates.insert((other.min(slot), other.max(slot)));
1783                }
1784            }
1785            users.push(slot);
1786        }
1787    }
1788
1789    fn unindex_slot(&mut self, slot: usize) {
1790        for edge_id in self.slot_edge_ids(slot) {
1791            if let Some(users) = self.uses.get_mut(&edge_id) {
1792                users.retain(|user| *user != slot);
1793            }
1794        }
1795        self.candidates
1796            .retain(|(lower, higher)| *lower != slot && *higher != slot);
1797    }
1798
1799    /// Perform up to `budget` merges; returns how many were made.
1800    fn run(
1801        &mut self,
1802        budget: usize,
1803        tolerance: f64,
1804        same_carrier_area_tolerance_ratio: f64,
1805    ) -> Result<usize, KernelRefusal> {
1806        let mut merged_count = 0;
1807        while merged_count < budget {
1808            let Some(&(lower, higher)) = self.candidates.iter().next() else {
1809                break;
1810            };
1811            self.candidates.remove(&(lower, higher));
1812            let (Some(older), Some(newer)) = (&self.slots[lower], &self.slots[higher]) else {
1813                continue;
1814            };
1815            let Some(merged) = try_merge_pair(
1816                older,
1817                newer,
1818                self.edges,
1819                tolerance,
1820                same_carrier_area_tolerance_ratio,
1821            )?
1822            else {
1823                continue;
1824            };
1825            self.unindex_slot(higher);
1826            self.unindex_slot(lower);
1827            self.slots[higher] = None;
1828            self.slots[lower] = Some(merged);
1829            self.index_slot(lower);
1830            merged_count += 1;
1831        }
1832        Ok(merged_count)
1833    }
1834
1835    fn into_faces(self) -> Vec<FaceRecord> {
1836        self.slots.into_iter().flatten().collect()
1837    }
1838}
1839
1840/// Merge adjacent fragments that retain the exact same carrier surface.
1841///
1842/// Every shared edge is cancelled in opposite directions and the remaining
1843/// coedges are stitched into outer/hole cycles without refitting geometry.
1844fn merge_same_surface_faces_impl(
1845    solid: &BrepSolid,
1846    tolerance: f64,
1847    validate: bool,
1848    same_carrier_area_tolerance_ratio: f64,
1849    keep_unmerged: &[String],
1850) -> Result<BrepSolid, KernelRefusal> {
1851    let mut result = solid.clone();
1852    // `result.edges` is not touched until the retain below, so the id map is
1853    // built once; every candidate pair sees the same edge records.
1854    let edges = result
1855        .edges
1856        .iter()
1857        .map(|edge| (edge.id, edge))
1858        .collect::<HashMap<_, _>>();
1859    for shell in &mut result.shells {
1860        loop {
1861            if !validate {
1862                if let Some((indices, merged)) = try_merge_connected_group(
1863                    &shell.faces,
1864                    &edges,
1865                    tolerance,
1866                    same_carrier_area_tolerance_ratio,
1867                    keep_unmerged,
1868                )? {
1869                    let first = indices[0];
1870                    shell.faces[first] = merged;
1871                    for index in indices.into_iter().skip(1).rev() {
1872                        shell.faces.remove(index);
1873                    }
1874                    continue;
1875                }
1876            }
1877            // The validating lane has no group phase, so it drains every pair
1878            // merge in one pass; the open lane merges one pair, then offers
1879            // the changed shell to the group phase again, exactly as before.
1880            let budget = if validate { usize::MAX } else { 1 };
1881            let mut merger = PairMerger::new(
1882                std::mem::take(&mut shell.faces),
1883                &edges,
1884                keep_unmerged,
1885            );
1886            let merged = merger.run(budget, tolerance, same_carrier_area_tolerance_ratio);
1887            shell.faces = merger.into_faces();
1888            if merged? == 0 || validate {
1889                break;
1890            }
1891        }
1892    }
1893    finish_merge(solid, result, validate)
1894}
1895
1896/// Merge each face in `absorbed` into an edge-adjacent face outside `absorbed`
1897/// that rides the same carrier — the pair [`merge_same_surface_faces`] would
1898/// merge, at the same `tolerance` — and nothing else.
1899///
1900/// The absorbing face keeps its id, its name and its slot in the shell, and
1901/// its carrier is the frame the merged face is built on; a face in `absorbed`
1902/// that shares a carrier with no such neighbour is left as it was. Two faces
1903/// outside `absorbed` are never merged with each other, however cosurface: the
1904/// caller is naming whose material a face is, not normalizing the solid, so a
1905/// coplanar pair the document kept apart stays apart. The result passes the
1906/// validating merge's Euler and `validate()` checks or the call refuses.
1907pub(crate) fn absorb_faces_into_carriers(
1908    solid: &BrepSolid,
1909    tolerance: f64,
1910    absorbed: &HashSet<u64>,
1911) -> Result<BrepSolid, KernelRefusal> {
1912    let mut result = solid.clone();
1913    let edges = result
1914        .edges
1915        .iter()
1916        .map(|edge| (edge.id, edge))
1917        .collect::<HashMap<_, _>>();
1918    let edge_ids = |face: &FaceRecord| -> HashSet<u64> {
1919        face.loops
1920            .iter()
1921            .flat_map(|loop_record| &loop_record.coedges)
1922            .map(|coedge| coedge.edge_id)
1923            .filter(|edge_id| !edges[edge_id].degenerate)
1924            .collect()
1925    };
1926    for shell in &mut result.shells {
1927        'merge: loop {
1928            for inner in 0..shell.faces.len() {
1929                if !absorbed.contains(&shell.faces[inner].id) {
1930                    continue;
1931                }
1932                let inner_edges = edge_ids(&shell.faces[inner]);
1933                for outer in 0..shell.faces.len() {
1934                    if outer == inner
1935                        || absorbed.contains(&shell.faces[outer].id)
1936                        || edge_ids(&shell.faces[outer]).is_disjoint(&inner_edges)
1937                    {
1938                        continue;
1939                    }
1940                    let Some(merged) =
1941                        try_merge_pair(&shell.faces[outer], &shell.faces[inner], &edges, tolerance, 1e-7)?
1942                    else {
1943                        continue;
1944                    };
1945                    shell.faces[outer] = merged;
1946                    shell.faces.remove(inner);
1947                    continue 'merge;
1948                }
1949            }
1950            break;
1951        }
1952    }
1953    finish_merge(solid, result, true)
1954}
1955
1956/// The tail every merge shares: drop the edges and vertices no face uses any
1957/// more and, on the validating lane, recount the genus and validate.
1958fn finish_merge(
1959    solid: &BrepSolid,
1960    mut result: BrepSolid,
1961    validate: bool,
1962) -> Result<BrepSolid, KernelRefusal> {
1963    let used_edges = result
1964        .shells
1965        .iter()
1966        .flat_map(|shell| &shell.faces)
1967        .flat_map(|face| &face.loops)
1968        .flat_map(|loop_record| &loop_record.coedges)
1969        .map(|coedge| coedge.edge_id)
1970        .collect::<HashSet<_>>();
1971    result.edges.retain(|edge| used_edges.contains(&edge.id));
1972    let used_vertices = result
1973        .edges
1974        .iter()
1975        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
1976        .collect::<HashSet<_>>();
1977    result
1978        .vertices
1979        .retain(|vertex| used_vertices.contains(&vertex.id));
1980    if !validate {
1981        return Ok(result);
1982    }
1983    if std::env::var("BREP_DEBUG_MERGE").is_ok() {
1984        let entry_faces: i64 = solid.shells.iter().map(|s| s.faces.len() as i64).sum();
1985        let entry_holes: i64 = solid
1986            .shells
1987            .iter()
1988            .flat_map(|s| &s.faces)
1989            .map(|f| f.loops.len().saturating_sub(1) as i64)
1990            .sum();
1991        let entry_edges = solid.edges.iter().filter(|e| !e.degenerate).count() as i64;
1992        let entry_used: std::collections::HashSet<u64> = solid
1993            .shells
1994            .iter()
1995            .flat_map(|s| &s.faces)
1996            .flat_map(|f| &f.loops)
1997            .flat_map(|l| &l.coedges)
1998            .map(|c| c.edge_id)
1999            .collect();
2000        eprintln!(
2001            "merge entry census: V={} E={entry_edges} (coedge-referenced {}) F={entry_faces} H={entry_holes} S={} stored_genus={}",
2002            solid.vertices.len(),
2003            entry_used.len(),
2004            solid.shells.len(),
2005            solid.genus
2006        );
2007    }
2008    let face_count = result
2009        .shells
2010        .iter()
2011        .map(|shell| shell.faces.len() as i64)
2012        .sum::<i64>();
2013    let hole_count = result.bounding_hole_count();
2014    let edge_count = result.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
2015    // Count only LIVE vertices (referenced by a non-degenerate edge): the
2016    // merge removes edges and faces, orphaning vertices that then must not
2017    // participate in the Euler census — mirroring refresh_derived_genus
2018    // (boolean.rs). Counting stale vertices inflated V and rejected valid
2019    // merges as "non-integral genus" (the helmet report's final layer).
2020    let live_vertices: std::collections::HashSet<u64> = result
2021        .edges
2022        .iter()
2023        .filter(|edge| !edge.degenerate)
2024        .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
2025        .collect();
2026    let vertex_count = result
2027        .vertices
2028        .iter()
2029        .filter(|vertex| live_vertices.contains(&vertex.id))
2030        .count() as i64;
2031    let euler = vertex_count - edge_count + face_count - hole_count;
2032    let numerator = result.shells.len() as i64 * 2 - euler;
2033    // Mirror validate()'s convention (topology.rs): the reduced V−E+F−H
2034    // formula does not model parameter-space POLE COLLAPSES reliably, so on a
2035    // complex carrying degenerate edges the parity check must not reject —
2036    // the helmet result holds 17 legitimate degenerate (pole) edges and its
2037    // odd numerator here is a formula artifact, not a defect; validate()
2038    // below performs the real integrity checks either way. With no
2039    // degenerate edges the strict behavior is unchanged.
2040    let has_degenerate = result.edges.iter().any(|edge| edge.degenerate);
2041    if numerator < 0 || numerator % 2 != 0 {
2042        if std::env::var("BREP_DEBUG_MERGE").is_ok() {
2043            eprintln!(
2044                "merge euler reject: V={vertex_count} E={edge_count} F={face_count} H={hole_count} S={} euler={euler} numerator={numerator} degenerate={has_degenerate}",
2045                result.shells.len()
2046            );
2047        }
2048        if !has_degenerate
2049            || std::env::var("BREP_MERGE_DEGENERATE_EULER_STRICT").as_deref() == Ok("1")
2050        {
2051            return Err(KernelRefusal::new(
2052                RefusalClass::NonIntegralGenus {
2053                    shells: result.shells.len() as u32,
2054                    euler: euler as i64,
2055                },
2056                KernelStage::Validate,
2057                "merge_same_surface_faces produced non-integral genus",
2058            ));
2059        }
2060    } else {
2061        result.genus = numerator / 2;
2062    }
2063    let issues = result.validate();
2064    if !issues.is_empty() {
2065        return Err(KernelRefusal::new(
2066            RefusalClass::DegenerateArrangement {
2067                open_edges: 0,
2068                issues: issues.len() as u32,
2069            },
2070            KernelStage::Validate,
2071            format!("merge_same_surface_faces produced invalid topology: {issues:?}"),
2072        ));
2073    }
2074    Ok(result)
2075}
2076
2077pub fn merge_same_surface_faces(
2078    solid: &BrepSolid,
2079    tolerance: f64,
2080) -> Result<BrepSolid, KernelRefusal> {
2081    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, &[])
2082}
2083
2084/// Like [`merge_same_surface_faces`], but any face whose `name` CONTAINS one of
2085/// `keep_unmerged` is pinned: it never merges with a coplanar/cosurface
2086/// neighbour and is emitted unchanged, while every other face merges exactly as
2087/// [`merge_same_surface_faces`] would. An empty `keep_unmerged` is identical to
2088/// the plain call.
2089pub fn merge_same_surface_faces_excluding(
2090    solid: &BrepSolid,
2091    tolerance: f64,
2092    keep_unmerged: &[String],
2093) -> Result<BrepSolid, KernelRefusal> {
2094    merge_same_surface_faces_impl(solid, tolerance, true, 1e-7, keep_unmerged)
2095}
2096
2097pub(crate) fn merge_same_surface_faces_open(
2098    solid: &BrepSolid,
2099    tolerance: f64,
2100) -> Result<BrepSolid, KernelRefusal> {
2101    merge_same_surface_faces_impl(solid, tolerance, false, 5e-3, &[])
2102}
2103