Skip to main content

brepkit_operations/
heal.rs

1//! Topology healing: repair common defects in B-Rep models.
2//!
3//! Provides repair operations for common geometry issues encountered
4//! in imported CAD files and boolean results.
5
6use std::collections::{HashMap, HashSet};
7
8use brepkit_math::tolerance::Tolerance;
9use brepkit_math::vec::{Point3, Vec3};
10use brepkit_topology::Topology;
11use brepkit_topology::edge::{Edge, EdgeId};
12use brepkit_topology::face::{Face, FaceId, FaceSurface};
13use brepkit_topology::shell::Shell;
14use brepkit_topology::solid::SolidId;
15use brepkit_topology::vertex::VertexId;
16use brepkit_topology::wire::{OrientedEdge, Wire};
17
18/// Combined result of [`repair_solid`]: validation before, healing, validation after.
19#[derive(Debug, Clone)]
20pub struct RepairReport {
21    /// Validation issues found before healing.
22    pub before: crate::validate::ValidationReport,
23    /// Healing actions performed.
24    pub healing: HealingReport,
25    /// Validation issues remaining after healing.
26    pub after: crate::validate::ValidationReport,
27}
28
29impl RepairReport {
30    /// Whether the solid is valid after repair (no remaining errors).
31    #[must_use]
32    pub fn is_valid_after(&self) -> bool {
33        self.after.is_valid()
34    }
35
36    /// Total number of repairs performed.
37    #[must_use]
38    pub fn total_repairs(&self) -> usize {
39        self.healing.vertices_merged
40            + self.healing.degenerate_edges_removed
41            + self.healing.orientations_fixed
42            + self.healing.wire_gaps_closed
43            + self.healing.small_faces_removed
44            + self.healing.duplicate_faces_removed
45    }
46}
47
48/// Validate, heal, and re-validate a solid in one pass.
49///
50/// This is the top-level convenience function for repairing imported models.
51/// It chains: `validate_solid` → `heal_solid` → `validate_solid`, returning
52/// all three reports so the caller can see what was found, what was fixed,
53/// and what remains.
54///
55/// # Errors
56/// Returns an error if topology lookups fail.
57pub fn repair_solid(
58    topo: &mut Topology,
59    solid: SolidId,
60    tolerance: f64,
61) -> Result<RepairReport, crate::OperationsError> {
62    let before = crate::validate::validate_solid(topo, solid)?;
63    let healing = heal_solid(topo, solid, tolerance)?;
64    let after = crate::validate::validate_solid(topo, solid)?;
65
66    Ok(RepairReport {
67        before,
68        healing,
69        after,
70    })
71}
72
73/// Summary of repairs performed by [`heal_solid`].
74#[derive(Debug, Default, Clone)]
75pub struct HealingReport {
76    /// Number of coincident vertices merged.
77    pub vertices_merged: usize,
78    /// Number of degenerate edges removed.
79    pub degenerate_edges_removed: usize,
80    /// Number of face orientations fixed.
81    pub orientations_fixed: usize,
82    /// Number of wire gaps closed.
83    pub wire_gaps_closed: usize,
84    /// Number of small faces removed.
85    pub small_faces_removed: usize,
86    /// Number of duplicate faces removed.
87    pub duplicate_faces_removed: usize,
88}
89
90/// Run all healing operations on a solid.
91///
92/// This is the top-level repair function. It runs:
93/// 1. Merge coincident vertices (close gaps)
94/// 2. Remove degenerate edges (shorter than tolerance)
95/// 3. Fix face orientations (ensure outward normals)
96///
97/// # Errors
98/// Returns an error if topology lookups fail.
99pub fn heal_solid(
100    topo: &mut Topology,
101    solid: SolidId,
102    tolerance: f64,
103) -> Result<HealingReport, crate::OperationsError> {
104    // Must run before vertex merging.
105    let wire_gaps_closed = close_wire_gaps(topo, solid, tolerance)?;
106    let vertices_merged = merge_coincident_vertices(topo, solid, tolerance)?;
107    let degenerate_edges_removed = remove_degenerate_edges(topo, solid, tolerance)?;
108    let small_faces_removed = remove_small_faces(topo, solid, tolerance)?;
109    let duplicate_faces_removed = remove_duplicate_faces(topo, solid, tolerance)?;
110    // Run last, after topology is clean.
111    let orientations_fixed = fix_face_orientations(topo, solid)?;
112
113    Ok(HealingReport {
114        vertices_merged,
115        degenerate_edges_removed,
116        orientations_fixed,
117        wire_gaps_closed,
118        small_faces_removed,
119        duplicate_faces_removed,
120    })
121}
122
123/// Merge near-coincident vertices in a solid.
124///
125/// Finds vertex pairs that are within `tolerance` of each other and
126/// merges them by updating all edge references to point to a single
127/// canonical vertex. This fixes small gaps caused by floating-point
128/// imprecision during modeling operations.
129///
130/// Returns the number of vertices merged.
131///
132/// # Errors
133/// Returns an error if topology lookups fail.
134pub fn merge_coincident_vertices(
135    topo: &mut Topology,
136    solid: SolidId,
137    tolerance: f64,
138) -> Result<usize, crate::OperationsError> {
139    let tol = if tolerance > 0.0 {
140        tolerance
141    } else {
142        Tolerance::new().linear
143    };
144    let tol_sq = tol * tol;
145
146    let solid_data = topo.solid(solid)?;
147    let shell = topo.shell(solid_data.outer_shell())?;
148    let face_ids: Vec<_> = shell.faces().to_vec();
149
150    let mut vertex_ids: Vec<VertexId> = Vec::new();
151    let mut positions: Vec<Point3> = Vec::new();
152    let mut seen = std::collections::HashSet::new();
153
154    for &fid in &face_ids {
155        let face = topo.face(fid)?;
156        let wire = topo.wire(face.outer_wire())?;
157        for oe in wire.edges() {
158            let edge = topo.edge(oe.edge())?;
159            for &vid in &[edge.start(), edge.end()] {
160                if seen.insert(vid.index()) {
161                    let point = topo.vertex(vid)?.point();
162                    vertex_ids.push(vid);
163                    positions.push(point);
164                }
165            }
166        }
167    }
168
169    // Build merge map: for each vertex, find the canonical (lowest-index)
170    // vertex it should merge into.
171    let num_verts = vertex_ids.len();
172    let mut merge_to: HashMap<usize, VertexId> = HashMap::new();
173    let mut merged_count = 0;
174
175    for i in 0..num_verts {
176        if merge_to.contains_key(&vertex_ids[i].index()) {
177            continue;
178        }
179        for j in (i + 1)..num_verts {
180            if merge_to.contains_key(&vertex_ids[j].index()) {
181                continue;
182            }
183            let dist_sq = (positions[i] - positions[j]).length_squared();
184            if dist_sq < tol_sq {
185                merge_to.insert(vertex_ids[j].index(), vertex_ids[i]);
186                merged_count += 1;
187            }
188        }
189    }
190
191    if merged_count == 0 {
192        return Ok(0);
193    }
194
195    let mut edge_ids = Vec::new();
196    for &fid in &face_ids {
197        let face = topo.face(fid)?;
198        let wire = topo.wire(face.outer_wire())?;
199        for oe in wire.edges() {
200            edge_ids.push(oe.edge());
201        }
202    }
203    edge_ids.sort_by_key(|e| e.index());
204    edge_ids.dedup_by_key(|e| e.index());
205
206    let updates: Vec<_> = edge_ids
207        .iter()
208        .filter_map(|&eid| {
209            let edge = topo.edge(eid).ok()?;
210            let new_start = merge_to
211                .get(&edge.start().index())
212                .copied()
213                .unwrap_or_else(|| edge.start());
214            let new_end = merge_to
215                .get(&edge.end().index())
216                .copied()
217                .unwrap_or_else(|| edge.end());
218            if new_start != edge.start() || new_end != edge.end() {
219                Some((eid, new_start, new_end))
220            } else {
221                None
222            }
223        })
224        .collect();
225
226    for (eid, new_start, new_end) in updates {
227        let edge = topo.edge_mut(eid)?;
228        *edge = brepkit_topology::edge::Edge::new(new_start, new_end, edge.curve().clone());
229    }
230
231    Ok(merged_count)
232}
233
234/// Remove degenerate edges (shorter than tolerance) from a solid.
235///
236/// An edge whose start and end vertices are within `tolerance` of each
237/// other is considered degenerate. Such edges are collapsed: their
238/// references in wires are removed, and the wire is rebuilt without them.
239///
240/// Returns the number of degenerate edges removed.
241///
242/// # Errors
243/// Returns an error if topology lookups fail.
244pub fn remove_degenerate_edges(
245    topo: &mut Topology,
246    solid: SolidId,
247    tolerance: f64,
248) -> Result<usize, crate::OperationsError> {
249    let tol = if tolerance > 0.0 {
250        tolerance
251    } else {
252        Tolerance::new().linear
253    };
254    let tol_sq = tol * tol;
255
256    let solid_data = topo.solid(solid)?;
257    let shell = topo.shell(solid_data.outer_shell())?;
258    let face_ids: Vec<_> = shell.faces().to_vec();
259
260    let mut removed_count = 0;
261
262    for &fid in &face_ids {
263        let face = topo.face(fid)?;
264        let wire_id = face.outer_wire();
265        let wire = topo.wire(wire_id)?;
266
267        let mut new_edges = Vec::new();
268        let mut any_removed = false;
269
270        for oe in wire.edges() {
271            let edge = topo.edge(oe.edge())?;
272            let start_pos = topo.vertex(edge.start())?.point();
273            let end_pos = topo.vertex(edge.end())?.point();
274            let len_sq = (end_pos - start_pos).length_squared();
275
276            if len_sq < tol_sq && edge.start() != edge.end() {
277                any_removed = true;
278                removed_count += 1;
279            } else {
280                new_edges.push(*oe);
281            }
282        }
283
284        if any_removed && !new_edges.is_empty() {
285            // Create a NEW wire instead of modifying in-place. In-place
286            // modification via wire_mut corrupts other solids that share
287            // the same wire ID (analytic_boolean shares edges/wires across
288            // faces via edge_map dedup in a single topology arena).
289            let new_wire = brepkit_topology::wire::Wire::new(new_edges, wire.is_closed())?;
290            let new_wire_id = topo.add_wire(new_wire);
291            let face = topo.face_mut(fid)?;
292            if face.outer_wire() == wire_id {
293                face.set_outer_wire(new_wire_id);
294            } else {
295                let iw = face.inner_wires().to_vec();
296                for (i, &iw_id) in iw.iter().enumerate() {
297                    if iw_id == wire_id {
298                        face.inner_wires_mut()[i] = new_wire_id;
299                    }
300                }
301            }
302        }
303    }
304
305    Ok(removed_count)
306}
307
308/// Remove out-and-back spurs from face wires.
309///
310/// A *spur* is a consecutive pair of oriented edges in a wire that reference the
311/// SAME edge with opposite orientations: the wire walks out along the edge and
312/// immediately walks back. It encloses zero area (it never changes the face's
313/// region) but it over-connects that edge — counting it twice for the face.
314///
315/// GFA's wire builder can emit a spur for a U-shaped face: when a notch opens
316/// onto a single boundary edge (e.g. `(a−b) ∪ (a∩b)` where `b` meets `a` on two
317/// faces, issue #801), the notched face's wire traverses the opening edge
318/// out-and-back instead of leaving it as the clean boundary shared with the
319/// filler face. The spur makes that edge non-manifold (3+ faces) and inflates
320/// the measured volume. Stripping it is always sound — the face region is
321/// unchanged and the edge drops to its correct neighbours.
322///
323/// Returns the number of oriented-edge occurrences removed (two per spur).
324///
325/// # Errors
326/// Returns an error if topology lookups fail.
327pub fn remove_wire_spurs(
328    topo: &mut Topology,
329    solid: SolidId,
330) -> Result<usize, crate::OperationsError> {
331    let face_ids = brepkit_topology::explorer::solid_faces(topo, solid)?;
332    let mut removed = 0;
333
334    for fid in face_ids {
335        let wire_ids: Vec<_> = {
336            let face = topo.face(fid)?;
337            std::iter::once(face.outer_wire())
338                .chain(face.inner_wires().iter().copied())
339                .collect()
340        };
341
342        for wid in wire_ids {
343            let (mut oes, closed) = {
344                let wire = topo.wire(wid)?;
345                (wire.edges().to_vec(), wire.is_closed())
346            };
347
348            let n_removed = strip_wire_spurs(&mut oes);
349            if n_removed == 0 {
350                continue;
351            }
352            // Always write the stripped wire back when it still has an edge, so
353            // the over-connected spur edge is never left behind. If stripping
354            // takes the wire below three edges the face was already degenerate
355            // (a spur wrapping a bigon or self-loop); the residual bigon is then
356            // rejected by `validate_boolean_result` and the op drops to the mesh
357            // fallback — strictly better than letting the spur survive into the
358            // result. `Wire::new` only rejects an empty edge list.
359            if oes.is_empty() {
360                continue;
361            }
362
363            let new_wire = Wire::new(oes, closed)?;
364            let new_wid = topo.add_wire(new_wire);
365            let face = topo.face_mut(fid)?;
366            if face.outer_wire() == wid {
367                face.set_outer_wire(new_wid);
368            } else {
369                let inner = face.inner_wires().to_vec();
370                for (i, &iwid) in inner.iter().enumerate() {
371                    if iwid == wid {
372                        face.inner_wires_mut()[i] = new_wid;
373                    }
374                }
375            }
376            removed += n_removed;
377        }
378    }
379
380    Ok(removed)
381}
382
383/// Strip consecutive same-edge opposite-orientation pairs (out-and-back spurs)
384/// from an oriented-edge loop, including the wrap-around pair. Iterates because
385/// removing one spur can expose another. Returns the count removed.
386fn strip_wire_spurs(oes: &mut Vec<OrientedEdge>) -> usize {
387    let mut removed = 0;
388    loop {
389        let n = oes.len();
390        if n < 2 {
391            break;
392        }
393        let spur = (0..n).find_map(|i| {
394            let j = (i + 1) % n;
395            (oes[i].edge() == oes[j].edge() && oes[i].is_forward() != oes[j].is_forward())
396                .then_some((i, j))
397        });
398        match spur {
399            Some((i, j)) => {
400                let (lo, hi) = if i < j { (i, j) } else { (j, i) };
401                oes.remove(hi);
402                oes.remove(lo);
403                removed += 2;
404            }
405            None => break,
406        }
407    }
408    removed
409}
410
411/// Fix face orientations so normals point outward from the solid.
412///
413/// Uses the signed volume test: for each face, computes the signed volume
414/// contribution. If the total signed volume is negative, the overall
415/// orientation is flipped. Then checks individual faces against the
416/// expected outward direction.
417///
418/// Returns the number of faces whose orientation was fixed.
419///
420/// # Errors
421/// Returns an error if topology lookups fail.
422pub fn fix_face_orientations(
423    topo: &mut Topology,
424    solid: SolidId,
425) -> Result<usize, crate::OperationsError> {
426    let solid_data = topo.solid(solid)?;
427    let shell = topo.shell(solid_data.outer_shell())?;
428    let face_ids: Vec<_> = shell.faces().to_vec();
429
430    let mut center = Vec3::new(0.0, 0.0, 0.0);
431    let mut total_faces: usize = 0;
432
433    for &fid in &face_ids {
434        let face = topo.face(fid)?;
435        let wire = topo.wire(face.outer_wire())?;
436        let mut face_center = Vec3::new(0.0, 0.0, 0.0);
437        let edges = wire.edges();
438        for oe in edges {
439            let edge = topo.edge(oe.edge())?;
440            let pos = topo.vertex(edge.start())?.point();
441            face_center += Vec3::new(pos.x(), pos.y(), pos.z());
442        }
443
444        let vert_count = edges.len();
445        if vert_count > 0 {
446            #[allow(clippy::cast_precision_loss)]
447            let inv = 1.0 / vert_count as f64;
448            center += face_center * inv;
449            total_faces += 1;
450        }
451    }
452
453    if total_faces == 0 {
454        return Ok(0);
455    }
456
457    #[allow(clippy::cast_precision_loss)]
458    let inv_faces = 1.0 / total_faces as f64;
459    let center_pt = Point3::new(
460        center.x() * inv_faces,
461        center.y() * inv_faces,
462        center.z() * inv_faces,
463    );
464
465    let mut fixed_count = 0;
466    let mut faces_to_flip = Vec::new();
467
468    for &fid in &face_ids {
469        let face = topo.face(fid)?;
470        let wire = topo.wire(face.outer_wire())?;
471        let first_oe = match wire.edges().first() {
472            Some(oe) => oe,
473            None => continue,
474        };
475        let edge = topo.edge(first_oe.edge())?;
476        let face_point = topo.vertex(edge.start())?.point();
477        let to_face = face_point - center_pt;
478
479        match face.surface() {
480            FaceSurface::Plane { normal, d } => {
481                if normal.dot(to_face) < 0.0 {
482                    faces_to_flip.push((fid, *normal, *d));
483                    fixed_count += 1;
484                }
485            }
486            FaceSurface::Cylinder(cyl) => {
487                // For cylinders, the outward radial direction should point away from center.
488                let to_pt = Vec3::new(
489                    face_point.x() - cyl.origin().x(),
490                    face_point.y() - cyl.origin().y(),
491                    face_point.z() - cyl.origin().z(),
492                );
493                let h = to_pt.dot(cyl.axis());
494                let radial = to_pt - cyl.axis() * h;
495                if radial.dot(to_face) < 0.0 {
496                    // Cylinder orientation is wrong — but we can only flip planar faces.
497                    // For analytic surfaces, orientation is inherent; skip.
498                }
499            }
500            // Non-planar faces: orientation is determined by surface parameterization,
501            // not a flippable normal. Skip for now.
502            _ => {}
503        }
504    }
505
506    for (fid, normal, d) in faces_to_flip {
507        let face = topo.face_mut(fid)?;
508        face.set_surface(FaceSurface::Plane {
509            normal: -normal,
510            d: -d,
511        });
512    }
513
514    Ok(fixed_count)
515}
516
517/// Close gaps between consecutive edges in face wires.
518///
519/// When two consecutive edges in a wire don't share an endpoint (the end
520/// of edge N doesn't match the start of edge N+1), this function closes
521/// the gap by merging the mismatched vertices. This is common when
522/// importing models from other CAD systems with different tolerances.
523///
524/// Returns the number of gaps closed.
525///
526/// # Errors
527/// Returns an error if topology lookups fail.
528pub fn close_wire_gaps(
529    topo: &mut Topology,
530    solid: SolidId,
531    tolerance: f64,
532) -> Result<usize, crate::OperationsError> {
533    let tol = if tolerance > 0.0 {
534        tolerance
535    } else {
536        Tolerance::new().linear
537    };
538    let tol_sq = tol * tol;
539
540    let solid_data = topo.solid(solid)?;
541    let shell = topo.shell(solid_data.outer_shell())?;
542    let face_ids: Vec<_> = shell.faces().to_vec();
543
544    let mut gaps_closed = 0;
545
546    for &fid in &face_ids {
547        let face = topo.face(fid)?;
548
549        let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
550            .chain(face.inner_wires().iter().copied())
551            .collect();
552
553        for wire_id in wire_ids {
554            let wire = topo.wire(wire_id)?;
555            let edges_list: Vec<_> = wire.edges().to_vec();
556            let n_edges = edges_list.len();
557
558            if n_edges < 2 {
559                continue;
560            }
561
562            let mut merge_pairs: Vec<(VertexId, VertexId)> = Vec::new();
563
564            for i in 0..n_edges {
565                let next_i = (i + 1) % n_edges;
566
567                let edge_i = topo.edge(edges_list[i].edge())?;
568                let edge_next = topo.edge(edges_list[next_i].edge())?;
569
570                let end_vid = if edges_list[i].is_forward() {
571                    edge_i.end()
572                } else {
573                    edge_i.start()
574                };
575
576                let start_vid = if edges_list[next_i].is_forward() {
577                    edge_next.start()
578                } else {
579                    edge_next.end()
580                };
581
582                if end_vid == start_vid {
583                    continue; // Already connected
584                }
585
586                let end_pos = topo.vertex(end_vid)?.point();
587                let start_pos = topo.vertex(start_vid)?.point();
588                let dist_sq = (end_pos - start_pos).length_squared();
589
590                if dist_sq < tol_sq {
591                    // Close the gap by merging the vertices.
592                    merge_pairs.push((start_vid, end_vid)); // merge start into end
593                }
594            }
595
596            // Apply merges using "snapshot then allocate" pattern.
597            for (merge_from, merge_to) in &merge_pairs {
598                // Snapshot: collect all edges that need updating.
599                let solid_d = topo.solid(solid)?;
600                let sh = topo.shell(solid_d.outer_shell())?;
601                let fids: Vec<_> = sh.faces().to_vec();
602
603                let mut updates = Vec::new();
604                for &fid2 in &fids {
605                    let f = topo.face(fid2)?;
606                    let w = topo.wire(f.outer_wire())?;
607                    for oe in w.edges() {
608                        let edge = topo.edge(oe.edge())?;
609                        let cur_start = edge.start();
610                        let cur_end = edge.end();
611                        let new_start = if cur_start == *merge_from {
612                            *merge_to
613                        } else {
614                            cur_start
615                        };
616                        let new_end = if cur_end == *merge_from {
617                            *merge_to
618                        } else {
619                            cur_end
620                        };
621                        if new_start != cur_start || new_end != cur_end {
622                            let curve = edge.curve().clone();
623                            updates.push((oe.edge(), new_start, new_end, curve));
624                        }
625                    }
626                }
627
628                // Allocate: apply the updates.
629                for (eid, new_start, new_end, curve) in updates {
630                    let em = topo.edge_mut(eid)?;
631                    *em = brepkit_topology::edge::Edge::new(new_start, new_end, curve);
632                }
633                gaps_closed += 1;
634            }
635        }
636    }
637
638    Ok(gaps_closed)
639}
640
641/// Remove faces smaller than a minimum area threshold.
642///
643/// Faces with a bounding-box diagonal smaller than `tolerance` are
644/// considered degenerate slivers and are removed from the shell.
645/// This is common after boolean operations that produce micro-faces
646/// at near-tangent intersections.
647///
648/// Returns the number of faces removed.
649///
650/// # Errors
651/// Returns an error if topology lookups fail.
652pub fn remove_small_faces(
653    topo: &mut Topology,
654    solid: SolidId,
655    tolerance: f64,
656) -> Result<usize, crate::OperationsError> {
657    let tol = if tolerance > 0.0 {
658        tolerance
659    } else {
660        Tolerance::new().linear
661    };
662
663    let solid_data = topo.solid(solid)?;
664    let shell_id = solid_data.outer_shell();
665    let shell = topo.shell(shell_id)?;
666    let face_ids: Vec<_> = shell.faces().to_vec();
667
668    let mut small_faces: Vec<FaceId> = Vec::new();
669
670    for &fid in &face_ids {
671        let face = topo.face(fid)?;
672        let wire = topo.wire(face.outer_wire())?;
673
674        // Compute bounding box of the face's outer wire.
675        let mut min_pt = Vec3::new(f64::MAX, f64::MAX, f64::MAX);
676        let mut max_pt = Vec3::new(f64::MIN, f64::MIN, f64::MIN);
677
678        for oe in wire.edges() {
679            let edge = topo.edge(oe.edge())?;
680            for &vid in &[edge.start(), edge.end()] {
681                let pos = topo.vertex(vid)?.point();
682                min_pt = Vec3::new(
683                    min_pt.x().min(pos.x()),
684                    min_pt.y().min(pos.y()),
685                    min_pt.z().min(pos.z()),
686                );
687                max_pt = Vec3::new(
688                    max_pt.x().max(pos.x()),
689                    max_pt.y().max(pos.y()),
690                    max_pt.z().max(pos.z()),
691                );
692            }
693        }
694
695        let diagonal = (max_pt - min_pt).length();
696        if diagonal < tol {
697            small_faces.push(fid);
698        }
699    }
700
701    if small_faces.is_empty() {
702        return Ok(0);
703    }
704
705    let removed_count = small_faces.len();
706    let small_set: std::collections::HashSet<usize> =
707        small_faces.iter().map(|f| f.index()).collect();
708
709    // Rebuild the shell without the small faces.
710    let remaining: Vec<FaceId> = face_ids
711        .into_iter()
712        .filter(|f| !small_set.contains(&f.index()))
713        .collect();
714
715    if remaining.is_empty() {
716        return Ok(0); // Don't remove ALL faces
717    }
718
719    let new_shell =
720        brepkit_topology::shell::Shell::new(remaining).map_err(crate::OperationsError::Topology)?;
721    *topo.shell_mut(shell_id)? = new_shell;
722
723    Ok(removed_count)
724}
725
726/// Remove duplicate (coincident) faces from a solid.
727///
728/// Two faces are considered duplicates if their outward normals are
729/// parallel (or anti-parallel) and all vertices of one face are within
730/// `tolerance` of the other face's plane. This happens when boolean
731/// operations create overlapping fragments.
732///
733/// Returns the number of duplicate faces removed.
734///
735/// # Errors
736/// Returns an error if topology lookups fail.
737pub fn remove_duplicate_faces(
738    topo: &mut Topology,
739    solid: SolidId,
740    tolerance: f64,
741) -> Result<usize, crate::OperationsError> {
742    let tol = if tolerance > 0.0 {
743        tolerance
744    } else {
745        Tolerance::new().linear
746    };
747
748    let solid_data = topo.solid(solid)?;
749    let shell_id = solid_data.outer_shell();
750    let shell = topo.shell(shell_id)?;
751    let face_ids: Vec<_> = shell.faces().to_vec();
752
753    // Collect face data for comparison.
754    // Tuple: (centroid, normal, vertex_count)
755    let mut face_data: Vec<(FaceId, Point3, Vec3, usize)> = Vec::new();
756
757    for &fid in &face_ids {
758        let face = topo.face(fid)?;
759        let normal = match face.surface() {
760            FaceSurface::Plane { normal, .. } => *normal,
761            FaceSurface::Cylinder(cyl) => cyl.axis(),
762            FaceSurface::Cone(cone) => cone.axis(),
763            FaceSurface::Sphere(_) => Vec3::new(0.0, 0.0, 1.0), // placeholder for comparison
764            FaceSurface::Torus(tor) => tor.z_axis(),
765            FaceSurface::Nurbs(_) => continue, // NURBS dedup needs parameter-space comparison
766        };
767
768        let wire = topo.wire(face.outer_wire())?;
769        let mut centroid = Vec3::new(0.0, 0.0, 0.0);
770        let mut count = 0;
771
772        for oe in wire.edges() {
773            let edge = topo.edge(oe.edge())?;
774            let pos = topo.vertex(edge.start())?.point();
775            centroid += Vec3::new(pos.x(), pos.y(), pos.z());
776            count += 1;
777        }
778
779        if count > 0 {
780            #[allow(clippy::cast_precision_loss)]
781            let inv = 1.0 / count as f64;
782            centroid = centroid * inv;
783        }
784
785        let centroid_pt = Point3::new(centroid.x(), centroid.y(), centroid.z());
786        face_data.push((fid, centroid_pt, normal, count));
787    }
788
789    // Find duplicate pairs: same vertex count, parallel normals, close centroids.
790    let mut duplicates: std::collections::HashSet<usize> = std::collections::HashSet::new();
791
792    for i in 0..face_data.len() {
793        if duplicates.contains(&face_data[i].0.index()) {
794            continue;
795        }
796        for j in (i + 1)..face_data.len() {
797            if duplicates.contains(&face_data[j].0.index()) {
798                continue;
799            }
800
801            let (_, centroid_a, normal_a, count_a) = &face_data[i];
802            let (fid_j, centroid_b, normal_b, count_b) = &face_data[j];
803
804            // Same vertex count.
805            if count_a != count_b {
806                continue;
807            }
808
809            // Normals parallel or anti-parallel.
810            let dot = normal_a.dot(*normal_b).abs();
811            if dot < 1.0 - tol {
812                continue;
813            }
814
815            // Centroids close.
816            let centroid_dist = (*centroid_a - *centroid_b).length();
817            if centroid_dist < tol {
818                duplicates.insert(fid_j.index());
819            }
820        }
821    }
822
823    if duplicates.is_empty() {
824        return Ok(0);
825    }
826
827    let removed_count = duplicates.len();
828
829    // Rebuild shell without duplicates.
830    let remaining: Vec<FaceId> = face_ids
831        .into_iter()
832        .filter(|f| !duplicates.contains(&f.index()))
833        .collect();
834
835    if remaining.is_empty() {
836        return Ok(0);
837    }
838
839    let new_shell =
840        brepkit_topology::shell::Shell::new(remaining).map_err(crate::OperationsError::Topology)?;
841    *topo.shell_mut(shell_id)? = new_shell;
842
843    Ok(removed_count)
844}
845
846// ── Face Unification ──────────────────────────────────────────────
847
848/// Compare two face surfaces for geometric equivalence.
849///
850/// Two surfaces are equivalent if they represent the same infinite surface
851/// (e.g., same plane, same cylinder axis/radius). This is the same logic
852/// used by wireframe edge filtering in `tessellate.rs`.
853/// Check if two surfaces are geometrically equivalent.
854#[must_use]
855pub fn surfaces_equivalent_pub(a: &FaceSurface, b: &FaceSurface) -> bool {
856    surfaces_equivalent(a, b)
857}
858
859fn surfaces_equivalent(a: &FaceSurface, b: &FaceSurface) -> bool {
860    let tol = Tolerance::new();
861    let lin = tol.linear;
862    let ang = tol.angular;
863
864    match (a, b) {
865        (FaceSurface::Plane { normal: na, d: da }, FaceSurface::Plane { normal: nb, d: db }) => {
866            // Relaxed tolerance for plane comparison. Mesh boolean and face
867            // splitting create coplanar triangles whose normals differ by
868            // varying amounts from floating-point cross-product computation.
869            // 1e-4 radians (~0.006°) and 1e-3 mm are tight enough to avoid
870            // false merges while allowing mesh-derived coplanar faces to unify.
871            let plane_ang = 1e-4_f64;
872            let plane_lin = 1e-3_f64;
873            let dot = na.dot(*nb);
874            (dot.abs() - 1.0).abs() < plane_ang && (da - db * dot.signum()).abs() < plane_lin
875        }
876        (FaceSurface::Cylinder(ca), FaceSurface::Cylinder(cb)) => {
877            (ca.radius() - cb.radius()).abs() < lin
878                && ca.axis().dot(cb.axis()).abs() > 1.0 - ang
879                && {
880                    let d = cb.origin() - ca.origin();
881                    d.cross(ca.axis()).length_squared() < lin * lin
882                }
883        }
884        (FaceSurface::Cone(ca), FaceSurface::Cone(cb)) => {
885            (ca.half_angle() - cb.half_angle()).abs() < ang
886                && ca.axis().dot(cb.axis()).abs() > 1.0 - ang
887                && {
888                    let d = cb.apex() - ca.apex();
889                    d.dot(d) < lin * lin
890                }
891        }
892        (FaceSurface::Sphere(sa), FaceSurface::Sphere(sb)) => {
893            (sa.radius() - sb.radius()).abs() < lin && {
894                let d = sb.center() - sa.center();
895                d.dot(d) < lin * lin
896            }
897        }
898        (FaceSurface::Torus(ta), FaceSurface::Torus(tb)) => {
899            (ta.major_radius() - tb.major_radius()).abs() < lin
900                && (ta.minor_radius() - tb.minor_radius()).abs() < lin
901                && ta.z_axis().dot(tb.z_axis()).abs() > 1.0 - ang
902                && {
903                    let d = tb.center() - ta.center();
904                    d.dot(d) < lin * lin
905                }
906        }
907        // Different surface types are never equivalent.
908        (
909            FaceSurface::Plane { .. }
910            | FaceSurface::Cylinder(_)
911            | FaceSurface::Cone(_)
912            | FaceSurface::Sphere(_)
913            | FaceSurface::Torus(_)
914            | FaceSurface::Nurbs(_),
915            _,
916        ) => false,
917    }
918}
919
920/// Check that two faces' normals point in the same direction at their shared edge.
921///
922/// Evaluates the surface normal on both faces at a shared boundary vertex.
923/// Returns `false` if normals point in opposite directions (dot product < 0),
924/// preventing merging of faces on opposite sides of the same surface.
925/// Also returns `false` when the check cannot be evaluated (no shared vertex
926/// found, projection failure) — safe default that prevents silent bypass.
927fn normals_compatible_at_edge(
928    topo: &Topology,
929    face_a: FaceId,
930    face_b: FaceId,
931    surface: &FaceSurface,
932) -> bool {
933    // For plane faces, compare plane normals directly.
934    if let FaceSurface::Plane { normal: na, .. } = surface {
935        let Ok(fb) = topo.face(face_b) else {
936            return false;
937        };
938        let nb = match fb.surface() {
939            FaceSurface::Plane { normal, .. } => *normal,
940            _ => return false,
941        };
942        let Ok(fa) = topo.face(face_a) else {
943            return false;
944        };
945        let eff_na = if fa.is_reversed() { -*na } else { *na };
946        let eff_nb = if fb.is_reversed() { -nb } else { nb };
947        return eff_na.dot(eff_nb) > 0.0;
948    }
949
950    // For curved surfaces, sample the normal at a shared vertex.
951    let sample_pt = find_shared_vertex(topo, face_a, face_b);
952    let Some(pt) = sample_pt else {
953        return false; // Can't verify — skip merge to be safe
954    };
955    let Ok(fa) = topo.face(face_a) else {
956        return false;
957    };
958    let Ok(fb) = topo.face(face_b) else {
959        return false;
960    };
961    let uv_a = fa.surface().project_point(pt);
962    let uv_b = fb.surface().project_point(pt);
963    let (Some((ua, va)), Some((ub, vb))) = (uv_a, uv_b) else {
964        return false;
965    };
966    let mut na = fa.surface().normal(ua, va);
967    let mut nb = fb.surface().normal(ub, vb);
968    if fa.is_reversed() {
969        na = -na;
970    }
971    if fb.is_reversed() {
972        nb = -nb;
973    }
974    na.dot(nb) > 0.0
975}
976
977/// Find a vertex shared between two faces' outer and inner wires.
978fn find_shared_vertex(
979    topo: &Topology,
980    face_a: FaceId,
981    face_b: FaceId,
982) -> Option<brepkit_math::vec::Point3> {
983    let fa = topo.face(face_a).ok()?;
984    let fb = topo.face(face_b).ok()?;
985
986    // Collect vertex indices AND quantized positions from face B.
987    let mut b_verts: std::collections::HashSet<usize> = std::collections::HashSet::new();
988    let mut b_positions: std::collections::HashSet<QVPos> = std::collections::HashSet::new();
989    for wid in std::iter::once(fb.outer_wire()).chain(fb.inner_wires().iter().copied()) {
990        let Ok(wire) = topo.wire(wid) else { continue };
991        for oe in wire.edges() {
992            let Ok(e) = topo.edge(oe.edge()) else {
993                continue;
994            };
995            for &vid in &[e.start(), e.end()] {
996                b_verts.insert(vid.index());
997                if let Ok(v) = topo.vertex(vid) {
998                    b_positions.insert(quantize_vertex(v.point()));
999                }
1000            }
1001        }
1002    }
1003
1004    // Find first matching vertex in face A.
1005    // Try VertexId matching first, then fall back to position matching
1006    // for GFA faces with different VertexIds at the same position.
1007    // Position fallback uses quantize_vertex (1e7 scale = 1/tolerance).
1008    // Only reliably matches vertices from the same computation path
1009    // (bit-identical or within one grid cell). Vertices that straddle
1010    // a grid-cell boundary may not match — this is a safe false negative
1011    // (normals_compatible_at_edge returns false, preventing merge).
1012    for wid in std::iter::once(fa.outer_wire()).chain(fa.inner_wires().iter().copied()) {
1013        let Ok(wire) = topo.wire(wid) else { continue };
1014        for oe in wire.edges() {
1015            let Ok(e) = topo.edge(oe.edge()) else {
1016                continue;
1017            };
1018            for &vid in &[e.start(), e.end()] {
1019                if b_verts.contains(&vid.index()) {
1020                    return topo
1021                        .vertex(vid)
1022                        .ok()
1023                        .map(brepkit_topology::vertex::Vertex::point);
1024                }
1025                if let Ok(v) = topo.vertex(vid) {
1026                    let qp = quantize_vertex(v.point());
1027                    if b_positions.contains(&qp) {
1028                        return Some(v.point());
1029                    }
1030                }
1031            }
1032        }
1033    }
1034    None
1035}
1036
1037/// Union-Find: find root with path compression.
1038fn uf_find(parent: &mut [usize], mut x: usize) -> usize {
1039    while parent[x] != x {
1040        parent[x] = parent[parent[x]];
1041        x = parent[x];
1042    }
1043    x
1044}
1045
1046/// Union-Find: merge two sets.
1047fn uf_union(parent: &mut [usize], a: usize, b: usize) {
1048    let ra = uf_find(parent, a);
1049    let rb = uf_find(parent, b);
1050    if ra != rb {
1051        parent[rb] = ra;
1052    }
1053}
1054
1055/// Unify adjacent faces that lie on the same geometric surface.
1056///
1057/// This merges co-surface face fragments produced by boolean operations
1058/// back into single faces, reducing face count and improving topology
1059/// quality.
1060///
1061/// The algorithm:
1062/// 1. Build an edge→face adjacency map
1063/// 2. Group faces by surface equivalence using connected-component analysis
1064/// 3. For each group of ≥2 faces, merge their outer wires by removing
1065///    internal shared edges and splicing the remaining edge chains
1066/// 4. Rebuild the shell with unified faces
1067///
1068/// Returns the number of faces removed by unification.
1069///
1070/// # Errors
1071///
1072/// Returns an error if topology lookups fail.
1073#[allow(clippy::too_many_lines)]
1074pub fn unify_faces(topo: &mut Topology, solid: SolidId) -> Result<usize, crate::OperationsError> {
1075    /// Maximum boundary edges for a merged face. Groups whose boundary
1076    /// exceeds this are skipped to prevent O(N²) slowdowns in subsequent
1077    /// boolean intersection computations. 200 edges is generous for any
1078    /// practical merged face (a merged rectangle has 4-20 edges).
1079    const MAX_BOUNDARY_EDGES: usize = 200;
1080
1081    let solid_data = topo.solid(solid)?;
1082    let shell_id = solid_data.outer_shell();
1083    let shell = topo.shell(shell_id)?;
1084    let all_face_ids: Vec<FaceId> = shell.faces().to_vec();
1085    let original_count = all_face_ids.len();
1086
1087    if original_count < 2 {
1088        return Ok(0);
1089    }
1090
1091    // Step 1: Build edge→face map (topology-shared edges).
1092    let edge_face_map = brepkit_topology::explorer::edge_to_face_map(topo, solid)?;
1093
1094    // Step 1b: Build geometric edge→face map for unshared curved edges.
1095    // Groups edges by (vertex_pair, curve_geometry) so that Circle edges with
1096    // the same center/radius/normal connecting the same vertices are treated
1097    // as the same edge for face adjacency purposes.
1098    #[allow(clippy::type_complexity)]
1099    let mut geom_edge_faces: HashMap<(usize, usize, u8, i64, i64, i64, i64), Vec<FaceId>> =
1100        HashMap::new();
1101    let q = |v: f64| -> i64 { (v * 1e5).round() as i64 };
1102    for &fid in &all_face_ids {
1103        let face = topo.face(fid)?;
1104        // Check outer wire + inner wires for geometric edge adjacency.
1105        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
1106            let wire = topo.wire(wid)?;
1107            for oe in wire.edges() {
1108                let edge = topo.edge(oe.edge())?;
1109                let si = edge.start().index();
1110                let ei = edge.end().index();
1111                let (kmin, kmax) = if si <= ei { (si, ei) } else { (ei, si) };
1112                #[allow(clippy::type_complexity)]
1113                let key: Option<(usize, usize, u8, i64, i64, i64, i64)> = match edge.curve() {
1114                    brepkit_topology::edge::EdgeCurve::Circle(c) => {
1115                        let center = c.center();
1116                        Some((
1117                            kmin,
1118                            kmax,
1119                            1, // Circle type tag.
1120                            q(center.x()),
1121                            q(center.y()),
1122                            q(center.z()),
1123                            q(c.radius()),
1124                        ))
1125                    }
1126                    brepkit_topology::edge::EdgeCurve::Ellipse(e) => {
1127                        let center = e.center();
1128                        Some((
1129                            kmin,
1130                            kmax,
1131                            2, // Ellipse type tag.
1132                            q(center.x()),
1133                            q(center.y()),
1134                            q(center.z()),
1135                            q(e.semi_major()),
1136                        ))
1137                    }
1138                    brepkit_topology::edge::EdgeCurve::Line
1139                    | brepkit_topology::edge::EdgeCurve::NurbsCurve(_) => None,
1140                };
1141                if let Some(k) = key {
1142                    geom_edge_faces.entry(k).or_default().push(fid);
1143                }
1144            }
1145        }
1146    }
1147
1148    // Step 1c: Position-based edge adjacency for GFA results with duplicate vertices.
1149    // GFA sub-faces from different original faces have different EdgeIds at the
1150    // same position. Group faces by quantized vertex-pair position to catch
1151    // adjacencies that the topology-based edge_face_map misses.
1152    let pos_scale = 1e7_f64; // 1.0 / default linear tolerance
1153    #[allow(clippy::type_complexity)]
1154    let mut pos_edge_faces: HashMap<((i64, i64, i64), (i64, i64, i64)), Vec<FaceId>> =
1155        HashMap::new();
1156    for &fid in &all_face_ids {
1157        let face = topo.face(fid)?;
1158        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
1159            let wire = topo.wire(wid)?;
1160            for oe in wire.edges() {
1161                let edge = topo.edge(oe.edge())?;
1162                let sp = topo.vertex(edge.start())?.point();
1163                let ep = topo.vertex(edge.end())?.point();
1164                let qs = (
1165                    (sp.x() * pos_scale).round() as i64,
1166                    (sp.y() * pos_scale).round() as i64,
1167                    (sp.z() * pos_scale).round() as i64,
1168                );
1169                let qe = (
1170                    (ep.x() * pos_scale).round() as i64,
1171                    (ep.y() * pos_scale).round() as i64,
1172                    (ep.z() * pos_scale).round() as i64,
1173                );
1174                let key = if qs <= qe { (qs, qe) } else { (qe, qs) };
1175                pos_edge_faces.entry(key).or_default().push(fid);
1176            }
1177        }
1178    }
1179
1180    // Step 2: Find connected components of faces sharing edges on the same surface.
1181    let face_index_map: HashMap<usize, usize> = all_face_ids
1182        .iter()
1183        .enumerate()
1184        .map(|(i, fid)| (fid.index(), i))
1185        .collect();
1186
1187    let n = all_face_ids.len();
1188    let mut parent: Vec<usize> = (0..n).collect();
1189
1190    // Union faces sharing topology edges on the same surface.
1191    // Before merging, check that face normals at a shared vertex point in
1192    // the same direction. This prevents merging faces on opposite sides of
1193    // the same surface (e.g., opposite cylinder walls, or coplanar faces
1194    // with opposite normals from a shelled solid).
1195    for faces in edge_face_map.values() {
1196        if faces.len() < 2 {
1197            continue;
1198        }
1199        for i in 0..faces.len() {
1200            for j in (i + 1)..faces.len() {
1201                let fa_idx = match face_index_map.get(&faces[i].index()) {
1202                    Some(&idx) => idx,
1203                    None => continue,
1204                };
1205                let fb_idx = match face_index_map.get(&faces[j].index()) {
1206                    Some(&idx) => idx,
1207                    None => continue,
1208                };
1209                let surface_a = topo.face(faces[i])?.surface().clone();
1210                let surface_b = topo.face(faces[j])?.surface().clone();
1211                if !surfaces_equivalent(&surface_a, &surface_b) {
1212                    continue;
1213                }
1214                // Normal direction pre-check: evaluate normals at a shared
1215                // vertex on both faces. If normals point in opposite
1216                // directions, the faces are on opposite sides of the surface
1217                // and must NOT be merged.
1218                if !normals_compatible_at_edge(topo, faces[i], faces[j], &surface_a) {
1219                    continue;
1220                }
1221                uf_union(&mut parent, fa_idx, fb_idx);
1222            }
1223        }
1224    }
1225
1226    // Union faces sharing geometrically-equivalent curved edges on the same surface.
1227    for faces in geom_edge_faces.values() {
1228        if faces.len() < 2 {
1229            continue;
1230        }
1231        for i in 0..faces.len() {
1232            for j in (i + 1)..faces.len() {
1233                let fa_idx = match face_index_map.get(&faces[i].index()) {
1234                    Some(&idx) => idx,
1235                    None => continue,
1236                };
1237                let fb_idx = match face_index_map.get(&faces[j].index()) {
1238                    Some(&idx) => idx,
1239                    None => continue,
1240                };
1241                let surface_a = topo.face(faces[i])?.surface().clone();
1242                let surface_b = topo.face(faces[j])?.surface().clone();
1243                if surfaces_equivalent(&surface_a, &surface_b)
1244                    && normals_compatible_at_edge(topo, faces[i], faces[j], &surface_a)
1245                {
1246                    uf_union(&mut parent, fa_idx, fb_idx);
1247                }
1248            }
1249        }
1250    }
1251
1252    // Union faces sharing edges at the same position (different EdgeIds).
1253    // This catches GFA sub-faces from different original faces that have
1254    // different EdgeIds at the same geometric position.
1255    for faces in pos_edge_faces.values() {
1256        if faces.len() < 2 {
1257            continue;
1258        }
1259        // Deduplicate face IDs (same face can appear multiple times)
1260        let mut unique: Vec<FaceId> = faces.clone();
1261        unique.sort_by_key(|f| f.index());
1262        unique.dedup();
1263        if unique.len() < 2 {
1264            continue;
1265        }
1266        for i in 0..unique.len() {
1267            for j in (i + 1)..unique.len() {
1268                let fa_idx = match face_index_map.get(&unique[i].index()) {
1269                    Some(&idx) => idx,
1270                    None => continue,
1271                };
1272                let fb_idx = match face_index_map.get(&unique[j].index()) {
1273                    Some(&idx) => idx,
1274                    None => continue,
1275                };
1276                let surface_a = topo.face(unique[i])?.surface().clone();
1277                let surface_b = topo.face(unique[j])?.surface().clone();
1278                if surfaces_equivalent(&surface_a, &surface_b)
1279                    && normals_compatible_at_edge(topo, unique[i], unique[j], &surface_a)
1280                {
1281                    uf_union(&mut parent, fa_idx, fb_idx);
1282                }
1283            }
1284        }
1285    }
1286
1287    // Step 3: Group faces by their root.
1288    let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
1289    for i in 0..n {
1290        let root = uf_find(&mut parent, i);
1291        groups.entry(root).or_default().push(i);
1292    }
1293
1294    // Only process groups with ≥2 faces.
1295    // Sort groups by their lowest face index so downstream processing
1296    // (especially `canonical_vtx` first-seen-wins) is deterministic.
1297    // Without the sort, `groups.into_values()` returns groups in HashMap
1298    // iteration order, and the first group to insert a vertex into
1299    // `canonical_vtx` "wins" the canonical mapping — which then drives
1300    // different edge re-allocations between runs.
1301    let mut merge_groups: Vec<Vec<usize>> = groups.into_values().filter(|g| g.len() >= 2).collect();
1302    for g in &mut merge_groups {
1303        g.sort_unstable();
1304    }
1305    merge_groups.sort_unstable_by_key(|g| g.first().copied().unwrap_or(usize::MAX));
1306
1307    if merge_groups.is_empty() {
1308        return Ok(0);
1309    }
1310
1311    // Step 4: Pre-compute boundary edges for all merge groups and build
1312    // a global edge replacement map. This ensures all merged faces share
1313    // canonical vertices at junction points where edges from different
1314    // input solids meet at the same position.
1315
1316    #[allow(clippy::items_after_statements)]
1317    struct MergeGroupData {
1318        face_ids: Vec<FaceId>,
1319        boundary_edges: Vec<OrientedEdge>,
1320        inner_wires: Vec<brepkit_topology::wire::WireId>,
1321        surface: FaceSurface,
1322        reversed: bool,
1323    }
1324
1325    let mut group_data: Vec<MergeGroupData> = Vec::new();
1326
1327    for group in &merge_groups {
1328        let group_face_ids: Vec<FaceId> = group.iter().map(|&i| all_face_ids[i]).collect();
1329
1330        let group_set: HashSet<usize> = group_face_ids.iter().map(|f| f.index()).collect();
1331        let mut internal_edges: HashSet<usize> = HashSet::new();
1332
1333        for (edge_idx, faces) in &edge_face_map {
1334            if faces.len() == 2
1335                && group_set.contains(&faces[0].index())
1336                && group_set.contains(&faces[1].index())
1337            {
1338                internal_edges.insert(*edge_idx);
1339            }
1340        }
1341
1342        let mut boundary_edges: Vec<OrientedEdge> = Vec::new();
1343        let mut all_inner_wires: Vec<brepkit_topology::wire::WireId> = Vec::new();
1344        let mut representative_surface: Option<FaceSurface> = None;
1345        let mut representative_reversed = false;
1346
1347        for &fid in &group_face_ids {
1348            let face = topo.face(fid)?;
1349            if representative_surface.is_none() {
1350                representative_surface = Some(face.surface().clone());
1351                representative_reversed = face.is_reversed();
1352            }
1353            all_inner_wires.extend_from_slice(face.inner_wires());
1354
1355            let wire = topo.wire(face.outer_wire())?;
1356            for oe in wire.edges() {
1357                if !internal_edges.contains(&oe.edge().index()) {
1358                    boundary_edges.push(*oe);
1359                }
1360            }
1361        }
1362
1363        // Skip groups whose merged boundary would be too complex.
1364        // A face with hundreds of boundary edges can cause O(N²) or worse
1365        // performance in subsequent boolean intersection computations.
1366        if boundary_edges.len() > MAX_BOUNDARY_EDGES {
1367            log::debug!(
1368                "unify_faces: skipping merge group with {} boundary edges (limit {})",
1369                boundary_edges.len(),
1370                MAX_BOUNDARY_EDGES
1371            );
1372            continue;
1373        }
1374
1375        let Some(surface) = representative_surface else {
1376            continue;
1377        };
1378
1379        group_data.push(MergeGroupData {
1380            face_ids: group_face_ids,
1381            boundary_edges,
1382            inner_wires: all_inner_wires,
1383            surface,
1384            reversed: representative_reversed,
1385        });
1386    }
1387
1388    // Build global canonical vertex map from ALL boundary edges across
1389    // ALL merge groups. First-seen VertexId at each quantized position
1390    // becomes canonical.
1391    let quantize_vtx = quantize_vertex;
1392    let mut canonical_vtx: HashMap<QVPos, VertexId> = HashMap::new();
1393    for gd in &group_data {
1394        for oe in &gd.boundary_edges {
1395            let edge = topo.edge(oe.edge())?;
1396            for &vid in &[edge.start(), edge.end()] {
1397                let pos = topo.vertex(vid)?.point();
1398                canonical_vtx.entry(quantize_vtx(pos)).or_insert(vid);
1399            }
1400        }
1401    }
1402
1403    // Build edge replacement map: old EdgeId → new EdgeId with canonical vertices.
1404    let mut edge_replace: HashMap<usize, EdgeId> = HashMap::new();
1405    for gd in &group_data {
1406        for oe in &gd.boundary_edges {
1407            let eid = oe.edge();
1408            if edge_replace.contains_key(&eid.index()) {
1409                continue;
1410            }
1411            let edge = topo.edge(eid)?;
1412            let sp = topo.vertex(edge.start())?.point();
1413            let ep = topo.vertex(edge.end())?.point();
1414            let canon_start = canonical_vtx
1415                .get(&quantize_vtx(sp))
1416                .copied()
1417                .ok_or_else(|| crate::OperationsError::InvalidInput {
1418                    reason: "canonical vertex not found for edge start".to_string(),
1419                })?;
1420            let canon_end = canonical_vtx
1421                .get(&quantize_vtx(ep))
1422                .copied()
1423                .ok_or_else(|| crate::OperationsError::InvalidInput {
1424                    reason: "canonical vertex not found for edge end".to_string(),
1425                })?;
1426            if canon_start != edge.start() || canon_end != edge.end() {
1427                let new_edge = Edge::new(canon_start, canon_end, edge.curve().clone());
1428                let new_eid = topo.add_edge(new_edge);
1429                edge_replace.insert(eid.index(), new_eid);
1430            }
1431        }
1432    }
1433
1434    // Step 5: For each merge group, form loops and build merged faces.
1435    let mut merged_face_ids: Vec<FaceId> = Vec::new();
1436    let mut consumed: HashSet<usize> = HashSet::new();
1437
1438    for gd in group_data {
1439        // Apply edge replacements to boundary edges.
1440        let replaced_edges: Vec<OrientedEdge> = gd
1441            .boundary_edges
1442            .iter()
1443            .map(|oe| {
1444                if let Some(&new_eid) = edge_replace.get(&oe.edge().index()) {
1445                    OrientedEdge::new(new_eid, oe.is_forward())
1446                } else {
1447                    *oe
1448                }
1449            })
1450            .collect();
1451
1452        let mut loops = order_edges_into_loops(topo, &replaced_edges)?;
1453
1454        if loops.is_empty() {
1455            continue;
1456        }
1457
1458        let mut all_inner_wires = gd.inner_wires;
1459
1460        // Select the outer wire by enclosed 3D area (Newell normal magnitude).
1461        // Edge count is unreliable — a hole tessellated into many short edges
1462        // would be misclassified as the outer boundary.
1463        let outer_idx = if loops.len() > 1 {
1464            loops
1465                .iter()
1466                .enumerate()
1467                .max_by(|(_, a), (_, b)| {
1468                    let area_a = loop_area_3d(topo, a);
1469                    let area_b = loop_area_3d(topo, b);
1470                    area_a
1471                        .partial_cmp(&area_b)
1472                        .unwrap_or(std::cmp::Ordering::Equal)
1473                })
1474                .map_or(0, |(i, _)| i)
1475        } else {
1476            0
1477        };
1478        let outer_loop = loops.remove(outer_idx);
1479
1480        let new_wire = Wire::new(outer_loop, true).map_err(crate::OperationsError::Topology)?;
1481        let new_wire_id = topo.add_wire(new_wire);
1482
1483        // Convert remaining loops to inner wires.
1484        for inner_loop in loops {
1485            if let Ok(iw) = Wire::new(inner_loop, true) {
1486                all_inner_wires.push(topo.add_wire(iw));
1487            }
1488        }
1489
1490        let new_face = if gd.reversed {
1491            Face::new_reversed(new_wire_id, all_inner_wires, gd.surface)
1492        } else {
1493            Face::new(new_wire_id, all_inner_wires, gd.surface)
1494        };
1495        let new_face_id = topo.add_face(new_face);
1496        merged_face_ids.push(new_face_id);
1497
1498        for &fid in &gd.face_ids {
1499            consumed.insert(fid.index());
1500        }
1501    }
1502
1503    if consumed.is_empty() {
1504        return Ok(0);
1505    }
1506
1507    // Step 6: Rebuild the shell with unmerged faces + new merged faces.
1508    let mut new_faces: Vec<FaceId> = all_face_ids
1509        .into_iter()
1510        .filter(|f| !consumed.contains(&f.index()))
1511        .collect();
1512    new_faces.extend(merged_face_ids);
1513
1514    let new_shell = Shell::new(new_faces).map_err(crate::OperationsError::Topology)?;
1515    *topo.shell_mut(shell_id)? = new_shell;
1516
1517    let final_count = topo.shell(shell_id)?.faces().len();
1518    Ok(original_count - final_count)
1519}
1520
1521/// Compute the enclosed 3D area of a loop of oriented edges using Newell's method.
1522///
1523/// Returns 0.0 if any vertex lookup fails (defensive fallback).
1524fn loop_area_3d(topo: &Topology, loop_edges: &[OrientedEdge]) -> f64 {
1525    let mut positions: Vec<Point3> = Vec::with_capacity(loop_edges.len());
1526    for oe in loop_edges {
1527        let edge = match topo.edge(oe.edge()) {
1528            Ok(e) => e,
1529            Err(_) => return 0.0,
1530        };
1531        let vid = if oe.is_forward() {
1532            edge.start()
1533        } else {
1534            edge.end()
1535        };
1536        match topo.vertex(vid) {
1537            Ok(v) => positions.push(v.point()),
1538            Err(_) => return 0.0,
1539        }
1540    }
1541    if positions.len() < 3 {
1542        return 0.0;
1543    }
1544    // Newell normal magnitude = 2× enclosed area.
1545    crate::winding::newell_normal(&positions).length() * 0.5
1546}
1547
1548/// Quantized 3D position key for vertex matching in edge chaining.
1549type QVPos = (i64, i64, i64);
1550
1551/// Quantize a vertex position for position-based edge chaining.
1552fn quantize_vertex(p: Point3) -> QVPos {
1553    let scale = 1e7; // 1 / linear tolerance (1e-7)
1554    (
1555        (p.x() * scale).round() as i64,
1556        (p.y() * scale).round() as i64,
1557        (p.z() * scale).round() as i64,
1558    )
1559}
1560
1561/// Edge info for wire ordering: oriented edge with quantized vertex positions.
1562///
1563/// Uses quantized 3D positions instead of vertex indices so that edges
1564/// from different input solids at the same geometric location can chain
1565/// correctly even when they reference different vertex entities.
1566struct EdgeInfo {
1567    oe: OrientedEdge,
1568    start_pos: QVPos,
1569    end_pos: QVPos,
1570}
1571
1572/// Order boundary edges into one or more closed loops.
1573///
1574/// Returns a `Vec<Vec<OrientedEdge>>` where each inner vec is a closed
1575/// loop with edges chained end-to-start. Empty if edges can't form any
1576/// valid loop.
1577fn order_edges_into_loops(
1578    topo: &Topology,
1579    edges: &[OrientedEdge],
1580) -> Result<Vec<Vec<OrientedEdge>>, crate::OperationsError> {
1581    if edges.is_empty() {
1582        return Ok(Vec::new());
1583    }
1584
1585    let mut infos: Vec<EdgeInfo> = Vec::with_capacity(edges.len());
1586    for oe in edges {
1587        let edge = topo.edge(oe.edge())?;
1588        let sp = topo.vertex(edge.start())?.point();
1589        let ep = topo.vertex(edge.end())?.point();
1590        let (start_pos, end_pos) = if oe.is_forward() {
1591            (quantize_vertex(sp), quantize_vertex(ep))
1592        } else {
1593            (quantize_vertex(ep), quantize_vertex(sp))
1594        };
1595        infos.push(EdgeInfo {
1596            oe: *oe,
1597            start_pos,
1598            end_pos,
1599        });
1600    }
1601
1602    // Build a map from start_position → edge index for quick lookup.
1603    let mut start_map: HashMap<QVPos, Vec<usize>> = HashMap::new();
1604    for (i, info) in infos.iter().enumerate() {
1605        start_map.entry(info.start_pos).or_default().push(i);
1606    }
1607
1608    let mut used = vec![false; edges.len()];
1609    let mut loops: Vec<Vec<OrientedEdge>> = Vec::new();
1610
1611    // Walk chains starting from each unused edge.
1612    while let Some(start_idx) = used.iter().position(|&u| !u) {
1613        let mut chain = Vec::new();
1614        chain.push(infos[start_idx].oe);
1615        used[start_idx] = true;
1616        let chain_start = infos[start_idx].start_pos;
1617        let mut current_end = infos[start_idx].end_pos;
1618
1619        let max_steps = edges.len();
1620        for _ in 1..=max_steps {
1621            if current_end == chain_start {
1622                break; // loop closed
1623            }
1624            let candidates = match start_map.get(&current_end) {
1625                Some(c) => c,
1626                None => break, // broken chain
1627            };
1628            let mut found = false;
1629            for &idx in candidates {
1630                if !used[idx] {
1631                    used[idx] = true;
1632                    chain.push(infos[idx].oe);
1633                    current_end = infos[idx].end_pos;
1634                    found = true;
1635                    break;
1636                }
1637            }
1638            if !found {
1639                break; // dead end
1640            }
1641        }
1642
1643        // Only keep the chain if it forms a closed loop.
1644        if current_end == chain_start && !chain.is_empty() {
1645            loops.push(chain);
1646        }
1647    }
1648
1649    Ok(loops)
1650}
1651
1652/// Convert all analytic geometry in a solid to NURBS (B-Spline) representation.
1653///
1654/// Replaces every analytic surface (Plane, Cylinder, Cone, Sphere, Torus) with
1655/// its NURBS equivalent and every analytic curve (Line, Circle, Ellipse) with
1656/// a NURBS curve. NURBS surfaces and curves already in the model are left
1657/// untouched.
1658///
1659/// Returns the number of faces and edges that were converted.
1660///
1661/// Converts every analytic surface and curve to a NURBS representation.
1662/// Stored pcurves are dropped on conversion — see
1663/// `brepkit_heal::custom::convert_to_bspline` for the full rationale.
1664///
1665/// # Errors
1666///
1667/// Returns an error if any topology lookup or NURBS construction fails.
1668pub fn convert_to_bspline(
1669    topo: &mut Topology,
1670    solid: SolidId,
1671) -> Result<usize, crate::OperationsError> {
1672    brepkit_heal::custom::convert_to_bspline::convert_solid_to_bspline(topo, solid).map_err(|e| {
1673        crate::OperationsError::InvalidInput {
1674            reason: format!("convert_to_bspline failed: {e}"),
1675        }
1676    })
1677}
1678
1679/// Recognize and replace NURBS surfaces and edges with their analytic
1680/// (elementary) forms wherever possible.
1681///
1682/// Runs both face-surface recognition (Plane, Cylinder, Sphere, Cone,
1683/// Torus) and edge-curve recognition (Line, Circle, Ellipse) in
1684/// sequence. Returns the combined number of replacements.
1685///
1686/// This is the inverse of [`convert_to_bspline`]: STEP/IGES imports
1687/// that came in as NURBS (e.g., from CAD systems that export
1688/// everything as B-splines) can be normalized back into the analytic
1689/// forms that brepkit's intersection / blend / boolean operators
1690/// handle most efficiently.
1691///
1692/// Hyperbola and Parabola curve types are recognized but cannot yet
1693/// be stored as analytic `EdgeCurve` variants (no
1694/// `EdgeCurve::Hyperbola`/`Parabola` exists in topology); they keep
1695/// their NURBS representation.
1696///
1697/// # Atomicity
1698///
1699/// Recognition runs in two passes (surfaces, then edges). Each pass
1700/// snapshots its inputs before mutating, and individual mutations
1701/// can't fail on valid topology — the only failure path is the
1702/// initial topology lookup at the start of each pass. A pass that
1703/// gets past its snapshot will run to completion.
1704///
1705/// As a result, an error from the *edge* pass means the surface
1706/// pass already committed its mutations: the topology is in a
1707/// partially converted state (analytic surfaces, NURBS edges).
1708/// In practice the edge-pass failure mode requires malformed
1709/// topology — a cleanly-loaded solid won't hit it. Callers that
1710/// need transactional semantics should checkpoint the topology
1711/// first and restore on error.
1712///
1713/// # Errors
1714///
1715/// Returns an error if any topology lookup fails. See the
1716/// "Atomicity" section above for partial-mutation semantics.
1717pub fn convert_to_elementary(
1718    topo: &mut Topology,
1719    solid: SolidId,
1720    tolerance: f64,
1721) -> Result<usize, crate::OperationsError> {
1722    let tol = brepkit_math::tolerance::Tolerance {
1723        linear: tolerance,
1724        ..brepkit_math::tolerance::Tolerance::new()
1725    };
1726    let surfaces =
1727        brepkit_heal::custom::convert_to_elementary::convert_to_elementary(topo, solid, &tol)
1728            .map_err(|e| crate::OperationsError::InvalidInput {
1729                reason: format!("convert_to_elementary (surfaces) failed: {e}"),
1730            })?;
1731    let edges =
1732        brepkit_heal::custom::convert_to_elementary::convert_edges_to_elementary(topo, solid, &tol)
1733            .map_err(|e| crate::OperationsError::InvalidInput {
1734                reason: format!("convert_to_elementary (edges) failed: {e}"),
1735            })?;
1736    Ok(surfaces + edges)
1737}
1738
1739#[cfg(test)]
1740mod tests;