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