Skip to main content

brepkit_operations/boolean/
assembly.rs

1//! Solid assembly functions for boolean operations.
2//!
3//! These functions build a solid from a set of face specifications (planar,
4//! NURBS, analytic) using spatial hashing for vertex deduplication and edge
5//! sharing. Post-assembly passes refine boundary edges and split non-manifold
6//! edges to ensure a valid manifold result.
7
8use std::collections::{HashMap, HashSet};
9
10use brepkit_math::aabb::Aabb3;
11use brepkit_math::tolerance::Tolerance;
12use brepkit_math::vec::{Point3, Vec3};
13use brepkit_topology::Topology;
14use brepkit_topology::edge::{Edge, EdgeCurve, EdgeId};
15use brepkit_topology::face::{Face, FaceId, FaceSurface};
16use brepkit_topology::shell::Shell;
17use brepkit_topology::solid::{Solid, SolidId};
18use brepkit_topology::vertex::{Vertex, VertexId};
19use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
20
21use super::classify::polygon_centroid;
22use super::face_polygon;
23use super::types::{FaceSpec, MIN_SOLID_FACES};
24
25/// Quantize a coordinate to a spatial hash key.
26#[inline]
27#[allow(clippy::cast_possible_truncation)] // coordinate * 1e7 fits in i64
28pub(super) fn quantize(v: f64, resolution: f64) -> i64 {
29    (v * resolution).round() as i64
30}
31
32/// Quantize a 3D point to a spatial hash key for vertex deduplication.
33#[inline]
34pub(super) fn quantize_point(p: Point3, resolution: f64) -> (i64, i64, i64) {
35    (
36        quantize(p.x(), resolution),
37        quantize(p.y(), resolution),
38        quantize(p.z(), resolution),
39    )
40}
41
42/// Compute a scale-relative spatial-hash resolution from a set of vertex positions.
43///
44/// Uses the bounding-box diagonal of the input points scaled by 1e-7 to keep
45/// the hash cell roughly at tolerance-level relative to the model extent.
46/// Falls back to `1.0 / tol.linear` for degenerate (near-single-point) models.
47pub(super) fn vertex_merge_resolution(
48    all_pts: impl Iterator<Item = Point3>,
49    tol: Tolerance,
50) -> f64 {
51    let fallback = 1.0 / tol.linear;
52    if let Some(bbox) = Aabb3::try_from_points(all_pts) {
53        let diagonal = (bbox.max - bbox.min).length();
54        if diagonal > tol.linear {
55            // 1e-7 relative factor: same precision as absolute tolerance at unit scale,
56            // but scales correctly for large models (100m+) and sub-mm geometry.
57            1.0 / (diagonal * 1e-7_f64)
58        } else {
59            fallback
60        }
61    } else {
62        fallback
63    }
64}
65
66/// Assemble a solid from a set of planar face polygons with normals.
67///
68/// Uses spatial hashing for vertex dedup and edge sharing.
69/// This is a convenience wrapper around [`assemble_solid_mixed`] for the
70/// common case where all faces are planar.
71#[allow(clippy::redundant_pub_crate)]
72pub(crate) fn assemble_solid(
73    topo: &mut Topology,
74    faces: &[(Vec<Point3>, Vec3, f64)],
75    tol: Tolerance,
76) -> Result<SolidId, crate::OperationsError> {
77    let specs: Vec<FaceSpec> = faces
78        .iter()
79        .map(|(verts, normal, d)| FaceSpec::Planar {
80            vertices: verts.clone(),
81            normal: *normal,
82            d: *d,
83            inner_wires: vec![],
84        })
85        .collect();
86    assemble_solid_mixed(topo, &specs, tol)
87}
88
89/// Build inner wire topology from vertex position lists.
90///
91/// For each inner wire (a closed loop of vertex positions), creates vertices
92/// (via `vertex_map` dedup), edges (via `edge_map` sharing), and a `Wire`.
93/// Returns the list of `WireId`s to pass as inner wires when constructing a `Face`.
94fn build_inner_wires(
95    topo: &mut Topology,
96    inner_wire_specs: &[Vec<Point3>],
97    vertex_map: &mut HashMap<(i64, i64, i64), VertexId>,
98    edge_map: &mut HashMap<(usize, usize), EdgeId>,
99    resolution: f64,
100    tol: Tolerance,
101) -> Result<Vec<WireId>, crate::OperationsError> {
102    let mut inner_wire_ids = Vec::with_capacity(inner_wire_specs.len());
103    for iw_verts in inner_wire_specs {
104        let iw_n = iw_verts.len();
105        if iw_n < 3 {
106            continue;
107        }
108
109        let iw_vert_ids: Vec<VertexId> = iw_verts
110            .iter()
111            .map(|p| {
112                let key = quantize_point(*p, resolution);
113                *vertex_map
114                    .entry(key)
115                    .or_insert_with(|| topo.add_vertex(Vertex::new(*p, tol.linear)))
116            })
117            .collect();
118
119        let mut iw_oriented_edges = Vec::with_capacity(iw_n);
120        for i in 0..iw_n {
121            let j = (i + 1) % iw_n;
122            let vi = iw_vert_ids[i].index();
123            let vj = iw_vert_ids[j].index();
124            let (key_min, key_max) = if vi <= vj { (vi, vj) } else { (vj, vi) };
125
126            let edge_id = *edge_map.entry((key_min, key_max)).or_insert_with(|| {
127                topo.add_edge(Edge::new(iw_vert_ids[i], iw_vert_ids[j], EdgeCurve::Line))
128            });
129            // Shared edges may carry a geometric direction (circle arcs), so
130            // derive orientation from the stored start vertex.
131            let is_forward = topo.edge(edge_id)?.start() == iw_vert_ids[i];
132
133            iw_oriented_edges.push(OrientedEdge::new(edge_id, is_forward));
134        }
135
136        let wire = Wire::new(iw_oriented_edges, true).map_err(crate::OperationsError::Topology)?;
137        inner_wire_ids.push(topo.add_wire(wire));
138    }
139    Ok(inner_wire_ids)
140}
141
142/// Assemble a solid from a set of face specifications with mixed surface types.
143///
144/// Like [`assemble_solid`], but supports faces with NURBS, analytic, or any
145/// other surface type. Uses the same spatial-hashing vertex dedup and edge
146/// sharing as the planar variant.
147///
148/// This is the general-purpose solid assembly function that unblocks operations
149/// on non-planar faces.
150#[allow(clippy::redundant_pub_crate)]
151pub(crate) fn assemble_solid_mixed(
152    topo: &mut Topology,
153    face_specs: &[FaceSpec],
154    tol: Tolerance,
155) -> Result<SolidId, crate::OperationsError> {
156    // Pre-allocate topology arenas based on expected output size.
157    // Typical face → ~2 unique vertices, ~3 edges, 1 wire, 1 face.
158    let n = face_specs.len();
159    log::debug!(
160        "assemble_solid_mixed: {n} specs; arena before reserve V={} E={} W={} F={}",
161        topo.num_vertices(),
162        topo.num_edges(),
163        topo.num_wires(),
164        topo.num_faces(),
165    );
166    topo.reserve(n.saturating_mul(2), n.saturating_mul(3), n, n, 1, 1);
167
168    let resolution = vertex_merge_resolution(
169        face_specs.iter().flat_map(|s| match s {
170            FaceSpec::Planar { vertices, .. }
171            | FaceSpec::Surface { vertices, .. }
172            | FaceSpec::CylindricalFace { vertices, .. } => vertices.iter().copied(),
173        }),
174        tol,
175    );
176
177    let mut vertex_map: HashMap<(i64, i64, i64), VertexId> =
178        HashMap::with_capacity(face_specs.len() * 4);
179    let mut edge_map: HashMap<(usize, usize), brepkit_topology::edge::EdgeId> =
180        HashMap::with_capacity(face_specs.len() * 4);
181
182    let mut face_ids = Vec::with_capacity(face_specs.len());
183
184    // Process CylindricalFace specs first so circle edges populate edge_map
185    // before planar/surface faces look them up. This ensures adjacent planar
186    // faces share the Circle edge rather than creating a Line edge.
187    let cylindrical_first = face_specs
188        .iter()
189        .filter(|s| matches!(s, FaceSpec::CylindricalFace { .. }))
190        .chain(
191            face_specs
192                .iter()
193                .filter(|s| !matches!(s, FaceSpec::CylindricalFace { .. })),
194        );
195
196    for spec in cylindrical_first {
197        match spec {
198            FaceSpec::CylindricalFace {
199                vertices,
200                cylinder,
201                reversed,
202                ..
203            } => {
204                let verts = vertices;
205                let n = verts.len();
206                if n < 3 {
207                    continue;
208                }
209
210                let vert_ids: Vec<VertexId> = verts
211                    .iter()
212                    .map(|p| {
213                        let key = quantize_point(*p, resolution);
214                        *vertex_map
215                            .entry(key)
216                            .or_insert_with(|| topo.add_vertex(Vertex::new(*p, tol.linear)))
217                    })
218                    .collect();
219
220                let mut oriented_edges = Vec::with_capacity(n);
221                for i in 0..n {
222                    let j = (i + 1) % n;
223                    let vi = vert_ids[i].index();
224                    let vj = vert_ids[j].index();
225                    if vi == vj {
226                        continue; // Skip degenerate zero-length edges.
227                    }
228                    let (key_min, key_max) = if vi <= vj { (vi, vj) } else { (vj, vi) };
229
230                    let edge_id = *edge_map.entry((key_min, key_max)).or_insert_with(|| {
231                        let start = vert_ids[i];
232                        let end = vert_ids[j];
233
234                        // Determine if this edge is angular (arc) or axial (line)
235                        // by projecting both endpoints onto the cylinder.
236                        let (u1, v1) = cylinder.project_point(verts[i]);
237                        let (u2, v2) = cylinder.project_point(verts[j]);
238                        let u_diff = (u1 - u2).abs();
239                        let v_diff = (v1 - v2).abs();
240
241                        // Angular edge: endpoints at the same height (v) but different
242                        // angle (u). If v also differs, it's a diagonal/seam → Line.
243                        if u_diff > tol.linear
244                            && u_diff < (std::f64::consts::TAU - tol.linear)
245                            && v_diff < tol.linear * 100.0
246                        {
247                            // A stored Circle arc runs CCW start→end around its
248                            // axis. Build the arc along the polygon traversal
249                            // i→j: when the wrapped u-step is negative the
250                            // traversal runs clockwise around cylinder.axis(),
251                            // so negate the axis — otherwise the stored arc
252                            // would be the complement of the intended span.
253                            let mut du = u2 - u1;
254                            if du > std::f64::consts::PI {
255                                du -= std::f64::consts::TAU;
256                            } else if du < -std::f64::consts::PI {
257                                du += std::f64::consts::TAU;
258                            }
259                            let axis = if du >= 0.0 {
260                                cylinder.axis()
261                            } else {
262                                -cylinder.axis()
263                            };
264                            // Create a Circle3D at the v-level of this edge.
265                            let center = cylinder.origin() + cylinder.axis() * ((v1 + v2) * 0.5);
266                            if let Ok(circle) =
267                                brepkit_math::curves::Circle3D::new(center, axis, cylinder.radius())
268                            {
269                                topo.add_edge(Edge::new(start, end, EdgeCurve::Circle(circle)))
270                            } else {
271                                topo.add_edge(Edge::new(start, end, EdgeCurve::Line))
272                            }
273                        } else {
274                            // Axial edge (same angle, different height): line.
275                            topo.add_edge(Edge::new(start, end, EdgeCurve::Line))
276                        }
277                    });
278                    let is_forward = topo.edge(edge_id)?.start() == vert_ids[i];
279
280                    if oriented_edges
281                        .last()
282                        .is_some_and(|last: &OrientedEdge| last.edge() == edge_id)
283                    {
284                        continue;
285                    }
286                    oriented_edges.push(OrientedEdge::new(edge_id, is_forward));
287                }
288
289                // Same guard as the planar arm: a degenerate ring can lose
290                // every edge to the skips above; drop it rather than erroring.
291                if oriented_edges.is_empty() {
292                    continue;
293                }
294
295                // A reversed face flips every edge's effective traversal, so
296                // its wire must be built with reversed winding (the #1367
297                // rule) or the face traverses shared edges in the same
298                // effective sense as its neighbours. The arc EDGES above are
299                // built from the given vertex order — only the wire order
300                // flips here, not the curve geometry.
301                if *reversed {
302                    oriented_edges.reverse();
303                    for oe in &mut oriented_edges {
304                        *oe = OrientedEdge::new(oe.edge(), !oe.is_forward());
305                    }
306                }
307
308                let wire =
309                    Wire::new(oriented_edges, true).map_err(crate::OperationsError::Topology)?;
310                let wire_id = topo.add_wire(wire);
311
312                // Build inner wires from FaceSpec.
313                let inner_wire_ids = build_inner_wires(
314                    topo,
315                    spec.inner_wires(),
316                    &mut vertex_map,
317                    &mut edge_map,
318                    resolution,
319                    tol,
320                )?;
321
322                let surface = FaceSurface::Cylinder(cylinder.clone());
323                let face = if *reversed {
324                    topo.add_face(Face::new_reversed(wire_id, inner_wire_ids, surface))
325                } else {
326                    topo.add_face(Face::new(wire_id, inner_wire_ids, surface))
327                };
328                face_ids.push(face);
329            }
330            spec => {
331                // Planar or Surface: extract (verts, surface, reversed)
332                let (verts, surface, reversed) = match spec {
333                    FaceSpec::Planar {
334                        vertices,
335                        normal,
336                        d,
337                        ..
338                    } => (
339                        vertices.clone(),
340                        FaceSurface::Plane {
341                            normal: *normal,
342                            d: *d,
343                        },
344                        false,
345                    ),
346                    FaceSpec::Surface {
347                        vertices,
348                        surface,
349                        reversed,
350                        ..
351                    } => (vertices.clone(), surface.clone(), *reversed),
352                    FaceSpec::CylindricalFace { .. } => unreachable!(),
353                };
354
355                let n = verts.len();
356                if n < 3 {
357                    continue;
358                }
359
360                let vert_ids: Vec<VertexId> = verts
361                    .iter()
362                    .map(|p| {
363                        let key = quantize_point(*p, resolution);
364                        *vertex_map
365                            .entry(key)
366                            .or_insert_with(|| topo.add_vertex(Vertex::new(*p, tol.linear)))
367                    })
368                    .collect();
369
370                let mut oriented_edges = Vec::with_capacity(n);
371                for i in 0..n {
372                    let j = (i + 1) % n;
373                    let vi = vert_ids[i].index();
374                    let vj = vert_ids[j].index();
375                    // Skip degenerate zero-length edges (collapsed vertices).
376                    if vi == vj {
377                        continue;
378                    }
379                    let (key_min, key_max) = if vi <= vj { (vi, vj) } else { (vj, vi) };
380
381                    let edge_id = *edge_map.entry((key_min, key_max)).or_insert_with(|| {
382                        topo.add_edge(Edge::new(vert_ids[i], vert_ids[j], EdgeCurve::Line))
383                    });
384                    // Shared edges (e.g. circle arcs created by an adjacent
385                    // cylindrical face) carry a geometric direction, so derive
386                    // orientation from the stored start vertex rather than
387                    // vertex-index order.
388                    let is_forward = topo.edge(edge_id)?.start() == vert_ids[i];
389
390                    // Skip duplicate edges anywhere in the wire (not just consecutive).
391                    // Duplicates arise when the polygon revisits a vertex pair due to
392                    // degenerate splits or vertex merging.
393                    if oriented_edges
394                        .iter()
395                        .any(|oe: &OrientedEdge| oe.edge() == edge_id)
396                    {
397                        continue;
398                    }
399                    oriented_edges.push(OrientedEdge::new(edge_id, is_forward));
400                }
401
402                // A sub-resolution polygon can lose every edge to the
403                // degenerate/duplicate skips above (all vertices quantize to
404                // one id); it bounds no area, so drop it instead of erroring
405                // the whole assembly on an empty wire.
406                if oriented_edges.is_empty() {
407                    continue;
408                }
409
410                let wire =
411                    Wire::new(oriented_edges, true).map_err(crate::OperationsError::Topology)?;
412                let wire_id = topo.add_wire(wire);
413
414                // Build inner wires from FaceSpec.
415                let inner_wire_ids = build_inner_wires(
416                    topo,
417                    spec.inner_wires(),
418                    &mut vertex_map,
419                    &mut edge_map,
420                    resolution,
421                    tol,
422                )?;
423
424                let face = if reversed {
425                    topo.add_face(Face::new_reversed(wire_id, inner_wire_ids, surface))
426                } else {
427                    topo.add_face(Face::new(wire_id, inner_wire_ids, surface))
428                };
429                face_ids.push(face);
430            }
431        }
432    }
433
434    if face_ids.is_empty() {
435        return Err(crate::OperationsError::InvalidInput {
436            reason: "solid assembly produced no faces".into(),
437        });
438    }
439
440    // Post-assembly edge refinement: split long boundary edges at
441    // intermediate collinear vertices so adjacent faces can share edges.
442    // Pass precomputed vertex positions from assembly to avoid redundant
443    // face→wire→edge→vertex traversal.
444    let vertex_positions: HashMap<VertexId, Point3> = vertex_map
445        .values()
446        .filter_map(|&vid| topo.vertex(vid).ok().map(|v| (vid, v.point())))
447        .collect();
448    refine_boundary_edges(
449        topo,
450        &mut face_ids,
451        &mut edge_map,
452        tol,
453        Some(&vertex_positions),
454    )?;
455
456    // Stitch boundary edge pairs that should be shared but were assigned
457    // different VertexIds by the spatial hash (cell-boundary straddling).
458    stitch_boundary_edges(topo, &mut face_ids, tol)?;
459
460    // Split spurious non-manifold edges (rim junctions with opposing normals)
461    // using direction-based pairing. Legitimate 3-face junctions (vertex
462    // blends at corners) are left for angular-based split_nonmanifold_edges.
463    let mut shell_face_ids = build_manifold_shell(topo, &face_ids)?;
464
465    // Handle remaining non-manifold edges (legitimate vertex blend junctions)
466    // using the angular pairing approach.
467    for _ in 0..3 {
468        split_nonmanifold_edges(topo, &mut shell_face_ids)?;
469    }
470
471    let shell = Shell::new(shell_face_ids).map_err(crate::OperationsError::Topology)?;
472    let shell_id = topo.add_shell(shell);
473    Ok(topo.add_solid(Solid::new(shell_id, vec![])))
474}
475
476/// Resolve non-manifold edges using manifold pairing.
477///
478/// For each edge shared by 3+ faces, select the best pair (faces with
479/// opposite traversal directions = proper manifold pair) and give each
480/// unpaired face its own edge copy (branch-edge splitting).
481///
482/// Unlike the old `split_nonmanifold_edges` which used angular ordering
483/// (unreliable at degenerate rim junctions), this uses traversal direction
484/// (forward/reversed) which is structurally correct for manifold pairing.
485fn build_manifold_shell(
486    topo: &Topology,
487    face_ids: &[FaceId],
488) -> Result<Vec<FaceId>, crate::OperationsError> {
489    if face_ids.is_empty() {
490        return Ok(Vec::new());
491    }
492
493    // Build edge → [(face_index, is_forward_in_wire)] adjacency map.
494    let mut edge_faces: HashMap<EdgeId, Vec<(usize, bool)>> = HashMap::new();
495    for (fi, &fid) in face_ids.iter().enumerate() {
496        let face = topo.face(fid)?;
497        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
498            let wire = topo.wire(wid)?;
499            for oe in wire.edges() {
500                edge_faces
501                    .entry(oe.edge())
502                    .or_default()
503                    .push((fi, oe.is_forward()));
504            }
505        }
506    }
507
508    // Find non-manifold edges (shared by 3+ faces).
509    // Sort by EdgeId so subsequent face-removal decisions are reproducible
510    // across runs — the same class of HashMap-iteration variance fixed in
511    // #689/#692, propagated to this pass. Order matters because each NM
512    // edge's "remove the opposing-normal face" decision interacts with
513    // others when faces are shared between multiple NM edges.
514    let mut nonmanifold: Vec<(EdgeId, Vec<(usize, bool)>)> = edge_faces
515        .into_iter()
516        .filter(|(_, faces)| faces.len() > 2)
517        .collect();
518    nonmanifold.sort_by_key(|(eid, _)| eid.index());
519
520    if nonmanifold.is_empty() {
521        return Ok(face_ids.to_vec());
522    }
523
524    // For each non-manifold edge at a rim junction, REMOVE the face that
525    // opposes the majority — removing the IN face.
526    let mut faces_to_remove: HashSet<usize> = HashSet::new();
527    // Note: edge replacements for angular split cases are not currently used.
528    // The opposing-normal face removal above handles all known non-manifold cases.
529
530    for (_eid, face_refs) in &nonmanifold {
531        // Check if this is a SPURIOUS non-manifold (rim junction with opposing
532        // normals) vs LEGITIMATE (vertex blend at a corner). Only split spurious.
533        // Criterion: if any pair of faces has opposing normals (dot < -0.5),
534        // it's a rim junction that needs splitting.
535        let mut has_opposing = false;
536        let face_normals: Vec<(usize, Vec3)> = face_refs
537            .iter()
538            .filter_map(|&(fi, _)| {
539                let face = topo.face(face_ids[fi]).ok()?;
540                let n = match face.surface() {
541                    FaceSurface::Plane { normal, .. } => {
542                        if face.is_reversed() {
543                            -*normal
544                        } else {
545                            *normal
546                        }
547                    }
548                    _ => return None, // Skip non-planar faces.
549                };
550                Some((fi, n))
551            })
552            .collect();
553
554        for i in 0..face_normals.len() {
555            for j in (i + 1)..face_normals.len() {
556                if face_normals[i].1.dot(face_normals[j].1) < -0.5 {
557                    has_opposing = true;
558                }
559            }
560        }
561
562        if !has_opposing {
563            // Legitimate 3-face junction (e.g., vertex blend at corner).
564            // Fall back to old angular pairing via split_nonmanifold_edges.
565            continue;
566        }
567
568        // Spurious rim junction with opposing normals: REMOVE the faces
569        // that cause the 3-face edge instead of creating edge copies.
570        // Discard IN faces before shell build.
571        //
572        // The face to remove is the one whose normal opposes the majority.
573        // For a rim junction: 2 faces have similar normals (outer + rim),
574        // 1 face has opposing normal (inner) → remove the inner face.
575        for i in 0..face_normals.len() {
576            let mut opposing_count = 0;
577            for j in 0..face_normals.len() {
578                if i != j && face_normals[i].1.dot(face_normals[j].1) < -0.5 {
579                    opposing_count += 1;
580                }
581            }
582            // If this face opposes the majority, mark for removal.
583            if opposing_count > face_normals.len() / 2 {
584                faces_to_remove.insert(face_normals[i].0);
585            }
586        }
587    }
588
589    // Return faces with removed faces excluded.
590    if !faces_to_remove.is_empty() {
591        let result: Vec<FaceId> = face_ids
592            .iter()
593            .enumerate()
594            .filter(|(i, _)| !faces_to_remove.contains(i))
595            .map(|(_, &fid)| fid)
596            .collect();
597        return Ok(result);
598    }
599
600    Ok(face_ids.to_vec())
601}
602
603/// Validate that a boolean result is not degenerate.
604///
605/// Checks for:
606/// - Too few faces (< `MIN_SOLID_FACES`)
607/// - No edges or vertices (empty topology)
608/// - Unclosed wires and non-manifold edges (hard errors)
609/// - Euler characteristic, boundary edges, degenerate faces, and face area
610///   via [`crate::validate::validate_solid`] (logged as warnings)
611///
612/// This is the strict acceptance gate for results that still have a fallback
613/// available (the GFA pipeline). The mesh fallback's terminal sanity check is
614/// [`validate_boolean_result_lenient`].
615pub(super) fn validate_boolean_result(
616    topo: &Topology,
617    solid: SolidId,
618) -> Result<(), crate::OperationsError> {
619    validate_boolean_result_lenient(topo, solid)?;
620
621    // Unclosed wires and non-manifold edges are hard failures here: both
622    // defects break downstream tessellation and export, so a GFA result
623    // carrying them must fail safe to the mesh fallback.
624    let mut unclosed_wires = 0usize;
625    let mut edge_uses: HashMap<usize, usize> = HashMap::new();
626    let nm_dump = std::env::var("BK_DUMP_NM").is_ok();
627    let mut edge_by_idx: HashMap<usize, brepkit_topology::edge::EdgeId> = HashMap::new();
628    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
629        let face = topo.face(fid)?;
630        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
631            let wire = topo.wire(wid)?;
632            if brepkit_topology::validation::validate_wire_closed(wire, topo).is_err() {
633                unclosed_wires += 1;
634            }
635            for oe in wire.edges() {
636                *edge_uses.entry(oe.edge().index()).or_insert(0) += 1;
637                if nm_dump {
638                    edge_by_idx
639                        .entry(oe.edge().index())
640                        .or_insert_with(|| oe.edge());
641                }
642            }
643        }
644    }
645    let non_manifold_edges = edge_uses.values().filter(|&&c| c > 2).count();
646    if non_manifold_edges > 0 && nm_dump {
647        for (&eid, &c) in &edge_uses {
648            if c > 2
649                && let Some(&real_eid) = edge_by_idx.get(&eid)
650                && let Ok(e) = topo.edge(real_eid)
651                && let (Ok(a), Ok(b)) = (topo.vertex(e.start()), topo.vertex(e.end()))
652            {
653                let pa = a.point();
654                let pb = b.point();
655                log::warn!(
656                    "NM edge {eid} x{c} {} ({:.3},{:.3},{:.3})->({:.3},{:.3},{:.3})",
657                    e.curve().type_tag(),
658                    pa.x(),
659                    pa.y(),
660                    pa.z(),
661                    pb.x(),
662                    pb.y(),
663                    pb.z()
664                );
665                for fid in brepkit_topology::explorer::solid_faces(topo, solid).unwrap_or_default()
666                {
667                    let Ok(f) = topo.face(fid) else { continue };
668                    for wid in
669                        std::iter::once(f.outer_wire()).chain(f.inner_wires().iter().copied())
670                    {
671                        let Ok(w) = topo.wire(wid) else { continue };
672                        for oe in w.edges() {
673                            if oe.edge() == real_eid {
674                                let n_out = topo
675                                    .wire(f.outer_wire())
676                                    .map(|w| w.edges().len())
677                                    .unwrap_or(0);
678                                log::warn!(
679                                    "  user face {fid:?} ({}) outer_edges={n_out} inners={}",
680                                    f.surface().type_tag(),
681                                    f.inner_wires().len()
682                                );
683                            }
684                        }
685                    }
686                }
687            }
688        }
689    }
690    // Free (boundary) edges are just as fatal as non-manifold ones: an edge
691    // used by a single face means the shell is open, which breaks watertight
692    // export exactly like an over-shared edge does. A wire can be closed
693    // (endpoints chained) while every edge in it is used once — so the
694    // unclosed-wire check alone does not catch a dropped face, and the Euler
695    // gate can balance by accident when a face and its edges vanish together.
696    // Seam edges on periodic faces appear twice in the same wire and count 2.
697    let free_edges = edge_uses.values().filter(|&&c| c == 1).count();
698    if unclosed_wires > 0 || non_manifold_edges > 0 || free_edges > 0 {
699        return Err(crate::OperationsError::InvalidInput {
700            reason: format!(
701                "boolean result has {unclosed_wires} unclosed wire(s), \
702                 {non_manifold_edges} non-manifold edge(s), and \
703                 {free_edges} free boundary edge(s)"
704            ),
705        });
706    }
707
708    Ok(())
709}
710
711/// Lenient validation for terminal results with no remaining fallback
712/// (mesh boolean output): rejects only degenerate topology.
713pub(super) fn validate_boolean_result_lenient(
714    topo: &Topology,
715    solid: SolidId,
716) -> Result<(), crate::OperationsError> {
717    let s = topo.solid(solid)?;
718    let shell = topo.shell(s.outer_shell())?;
719    let face_count = shell.faces().len();
720
721    if face_count < MIN_SOLID_FACES {
722        return Err(crate::OperationsError::InvalidInput {
723            reason: format!(
724                "boolean result has only {face_count} faces (minimum {MIN_SOLID_FACES} required for a closed solid)"
725            ),
726        });
727    }
728
729    // Check that we have at least some edges and vertices.
730    let (f, e, v) = brepkit_topology::explorer::solid_entity_counts(topo, solid)?;
731    if e == 0 || v == 0 {
732        return Err(crate::OperationsError::InvalidInput {
733            reason: format!("boolean result has degenerate topology (F={f}, E={e}, V={v})"),
734        });
735    }
736
737    // Topological validation: Euler characteristic, boundary edges,
738    // degenerate faces.
739    // Logged as warnings rather than hard errors — many boolean results have
740    // minor topological imperfections (e.g., boundary edges on analytic faces)
741    // that don't prevent downstream use. Hard-failing here would reject ~25%
742    // of currently working booleans. The long-term fix is post-boolean healing.
743    match crate::validate::validate_solid(topo, solid) {
744        Ok(report) if !report.is_valid() => {
745            let errors: Vec<_> = report
746                .issues
747                .iter()
748                .filter(|i| i.severity == crate::validate::Severity::Error)
749                .map(|i| i.description.as_str())
750                .collect();
751            log::warn!(
752                "boolean result has {} validation error(s): {}",
753                errors.len(),
754                errors.join("; ")
755            );
756        }
757        Err(e) => {
758            log::warn!("validate_solid failed (skipping validation): {e}");
759        }
760        Ok(_) => {}
761    }
762
763    Ok(())
764}
765
766/// Split a solid's outer shell into connected face groups.
767///
768/// Two faces are adjacent if they share an edge. Returns each connected
769/// group as a Vec<FaceId> — a single-component solid produces one group,
770/// a multi-region solid (disjoint pieces in one shell) produces N groups.
771pub(super) fn face_components(topo: &Topology, solid: SolidId) -> Vec<Vec<FaceId>> {
772    let shell = match topo.solid(solid).and_then(|s| topo.shell(s.outer_shell())) {
773        Ok(sh) => sh,
774        Err(_) => return Vec::new(),
775    };
776    let face_ids: Vec<FaceId> = shell.faces().to_vec();
777    if face_ids.is_empty() {
778        return Vec::new();
779    }
780    let n = face_ids.len();
781
782    let mut edge_faces: HashMap<usize, Vec<usize>> = HashMap::new();
783    for (fi, &fid) in face_ids.iter().enumerate() {
784        let Ok(face) = topo.face(fid) else { continue };
785        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
786            let Ok(wire) = topo.wire(wid) else { continue };
787            for oe in wire.edges() {
788                edge_faces.entry(oe.edge().index()).or_default().push(fi);
789            }
790        }
791    }
792
793    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
794    for faces_at_edge in edge_faces.values() {
795        for &fi in faces_at_edge {
796            for &fj in faces_at_edge {
797                if fi != fj {
798                    adj[fi].push(fj);
799                }
800            }
801        }
802    }
803
804    // Sort + dedup adjacency so DFS visits neighbors in a stable order,
805    // independent of HashMap iteration order in `edge_faces`. Without this,
806    // `cut_multi_region_input` builds per-component subsolids with
807    // shuffled face order, which percolates through the boolean pipeline
808    // and makes `compound_cut_*` tests flaky.
809    for neighbors in &mut adj {
810        neighbors.sort_unstable();
811        neighbors.dedup();
812    }
813
814    let mut visited = vec![false; n];
815    let mut components: Vec<Vec<FaceId>> = Vec::new();
816    for start in 0..n {
817        if visited[start] {
818            continue;
819        }
820        let mut comp_faces = Vec::new();
821        let mut stack = vec![start];
822        while let Some(fi) = stack.pop() {
823            if visited[fi] {
824                continue;
825            }
826            visited[fi] = true;
827            comp_faces.push(face_ids[fi]);
828            for &nfi in &adj[fi] {
829                if !visited[nfi] {
830                    stack.push(nfi);
831                }
832            }
833        }
834        components.push(comp_faces);
835    }
836    components
837}
838
839/// Split long boundary edges at intermediate collinear vertices.
840///
841/// After boolean assembly, some unsplit (passthrough) faces may have edges that
842/// span the same geometric line as multiple shorter edges from adjacent
843/// split faces. This function splits those long edges at the intermediate
844/// vertex positions, enabling proper edge sharing between adjacent faces.
845#[allow(clippy::too_many_lines)]
846pub(super) fn refine_boundary_edges(
847    topo: &mut Topology,
848    face_ids: &mut [FaceId],
849    edge_map: &mut HashMap<(usize, usize), EdgeId>,
850    tol: Tolerance,
851    precomputed_positions: Option<&HashMap<VertexId, Point3>>,
852) -> Result<(), crate::OperationsError> {
853    // Single-pass: build edge-to-face count AND collect edge vertex pairs.
854    // This avoids a second full face→wire→edge→vertex traversal.
855    let mut edge_face_count: HashMap<EdgeId, usize> = HashMap::new();
856    let mut edge_vertices: HashMap<EdgeId, (VertexId, VertexId)> = HashMap::new();
857    for &fid in face_ids.iter() {
858        let face = topo.face(fid)?;
859        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
860            let wire = topo.wire(wid)?;
861            for oe in wire.edges() {
862                let eid = oe.edge();
863                *edge_face_count.entry(eid).or_default() += 1;
864                if let std::collections::hash_map::Entry::Vacant(e) = edge_vertices.entry(eid)
865                    && let Ok(edge) = topo.edge(eid)
866                {
867                    e.insert((edge.start(), edge.end()));
868                }
869            }
870        }
871    }
872
873    // Find boundary edges (used by exactly 1 face)
874    let boundary_edges: HashSet<EdgeId> = edge_face_count
875        .iter()
876        .filter(|&(_, &count)| count == 1)
877        .map(|(&eid, _)| eid)
878        .collect();
879
880    if boundary_edges.is_empty() {
881        return Ok(());
882    }
883
884    // Build vertex positions. Use precomputed positions from assembly when
885    // available, falling back to topology only for missing vertices
886    // (e.g. passthrough faces not in the assembly's vertex_map).
887    let mut extra_positions: HashMap<VertexId, Point3> = HashMap::new();
888    for &(start, end) in edge_vertices.values() {
889        for &vid in &[start, end] {
890            let in_pre = precomputed_positions.is_some_and(|p| p.contains_key(&vid));
891            if !in_pre
892                && let std::collections::hash_map::Entry::Vacant(e) = extra_positions.entry(vid)
893                && let Ok(v) = topo.vertex(vid)
894            {
895                e.insert(v.point());
896            }
897        }
898    }
899
900    // For each boundary edge, find intermediate collinear vertices.
901    // Use a spatial hash grid for O(V) build + O(1) amortized query,
902    // much faster than SAH BVH's O(V log²V) build for point clouds.
903    let get_pos = |vid: &VertexId| -> Option<Point3> {
904        precomputed_positions
905            .and_then(|p| p.get(vid))
906            .or_else(|| extra_positions.get(vid))
907            .copied()
908    };
909    // Build vert_list from both sources, deduplicating by VertexId.
910    let mut seen: HashSet<VertexId> = HashSet::new();
911    let mut vert_list: Vec<(VertexId, Point3)> = Vec::new();
912    if let Some(pre) = precomputed_positions {
913        for (&vid, &pos) in pre {
914            if seen.insert(vid) {
915                vert_list.push((vid, pos));
916            }
917        }
918    }
919    for (&vid, &pos) in &extra_positions {
920        if seen.insert(vid) {
921            vert_list.push((vid, pos));
922        }
923    }
924
925    // Compute grid cell size from bounding box and vertex count.
926    // Target ~1 vertex per cell on average for O(1) query cost.
927    // NOTE: cell_size is calibrated from the global vertex population.
928    // If boundary faces are concentrated in a small sub-region, the cell
929    // size may be too large, degrading to O(boundary_verts) per query.
930    // This is acceptable for boolean assembly outputs where vertices are
931    // distributed across the full solid extent.
932    let (mut bb_min, mut bb_max) = (
933        Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY),
934        Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY),
935    );
936    for &(_, pos) in &vert_list {
937        bb_min = Point3::new(
938            bb_min.x().min(pos.x()),
939            bb_min.y().min(pos.y()),
940            bb_min.z().min(pos.z()),
941        );
942        bb_max = Point3::new(
943            bb_max.x().max(pos.x()),
944            bb_max.y().max(pos.y()),
945            bb_max.z().max(pos.z()),
946        );
947    }
948    let diag = ((bb_max.x() - bb_min.x()).powi(2)
949        + (bb_max.y() - bb_min.y()).powi(2)
950        + (bb_max.z() - bb_min.z()).powi(2))
951    .sqrt();
952    let cell_size = (diag / (vert_list.len() as f64).cbrt()).max(tol.linear);
953    let inv_cell = 1.0 / cell_size;
954
955    let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
956    for (i, &(_, pos)) in vert_list.iter().enumerate() {
957        let cx = (pos.x() * inv_cell).floor() as i64;
958        let cy = (pos.y() * inv_cell).floor() as i64;
959        let cz = (pos.z() * inv_cell).floor() as i64;
960        grid.entry((cx, cy, cz)).or_default().push(i);
961    }
962
963    let mut edge_splits: HashMap<EdgeId, Vec<VertexId>> = HashMap::new();
964
965    for &eid in &boundary_edges {
966        let &(start_vid, end_vid) = match edge_vertices.get(&eid) {
967            Some(v) => v,
968            None => continue,
969        };
970        let (p0, p1) = match (get_pos(&start_vid), get_pos(&end_vid)) {
971            (Some(a), Some(b)) => (a, b),
972            _ => continue,
973        };
974        let dx = p1.x() - p0.x();
975        let dy = p1.y() - p0.y();
976        let dz = p1.z() - p0.z();
977        let len_sq = dx * dx + dy * dy + dz * dz;
978        if len_sq < tol.linear * tol.linear {
979            continue;
980        }
981        let len = len_sq.sqrt();
982
983        // Query hash grid with the edge's AABB expanded by tolerance
984        let edge_aabb = Aabb3 {
985            min: Point3::new(p0.x().min(p1.x()), p0.y().min(p1.y()), p0.z().min(p1.z())),
986            max: Point3::new(p0.x().max(p1.x()), p0.y().max(p1.y()), p0.z().max(p1.z())),
987        }
988        .expanded(tol.linear);
989        let min_cx = (edge_aabb.min.x() * inv_cell).floor() as i64;
990        let min_cy = (edge_aabb.min.y() * inv_cell).floor() as i64;
991        let min_cz = (edge_aabb.min.z() * inv_cell).floor() as i64;
992        let max_cx = (edge_aabb.max.x() * inv_cell).floor() as i64;
993        let max_cy = (edge_aabb.max.y() * inv_cell).floor() as i64;
994        let max_cz = (edge_aabb.max.z() * inv_cell).floor() as i64;
995
996        let mut intermediates: Vec<(f64, VertexId)> = Vec::new();
997
998        for gx in min_cx..=max_cx {
999            for gy in min_cy..=max_cy {
1000                for gz in min_cz..=max_cz {
1001                    if let Some(indices) = grid.get(&(gx, gy, gz)) {
1002                        for &cand_idx in indices {
1003                            let (vid, pos) = vert_list[cand_idx];
1004                            if vid == start_vid || vid == end_vid {
1005                                continue;
1006                            }
1007                            // Project pos onto line p0 + t*(p1-p0)
1008                            let dpx = pos.x() - p0.x();
1009                            let dpy = pos.y() - p0.y();
1010                            let dpz = pos.z() - p0.z();
1011                            let t = (dpx * dx + dpy * dy + dpz * dz) / len_sq;
1012
1013                            // Must be strictly between endpoints
1014                            if t <= tol.linear / len || t >= 1.0 - tol.linear / len {
1015                                continue;
1016                            }
1017
1018                            // Check distance from point to line
1019                            let proj_x = p0.x() + t * dx;
1020                            let proj_y = p0.y() + t * dy;
1021                            let proj_z = p0.z() + t * dz;
1022                            let dist_sq = (pos.x() - proj_x).powi(2)
1023                                + (pos.y() - proj_y).powi(2)
1024                                + (pos.z() - proj_z).powi(2);
1025
1026                            if dist_sq < tol.linear * tol.linear {
1027                                intermediates.push((t, vid));
1028                            }
1029                        }
1030                    }
1031                }
1032            }
1033        }
1034
1035        if !intermediates.is_empty() {
1036            intermediates
1037                .sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1038            intermediates.dedup_by_key(|(_, vid)| *vid);
1039            edge_splits.insert(eid, intermediates.into_iter().map(|(_, vid)| vid).collect());
1040        }
1041    }
1042
1043    if edge_splits.is_empty() {
1044        return Ok(());
1045    }
1046
1047    for fi in 0..face_ids.len() {
1048        let fid = face_ids[fi];
1049        let face = topo.face(fid)?;
1050        let outer_wire_id = face.outer_wire();
1051        let outer_wire = topo.wire(outer_wire_id)?;
1052
1053        let mut needs_rebuild = false;
1054        for oe in outer_wire.edges() {
1055            if edge_splits.contains_key(&oe.edge()) {
1056                needs_rebuild = true;
1057                break;
1058            }
1059        }
1060
1061        if !needs_rebuild {
1062            continue;
1063        }
1064
1065        // Snapshot face data before mutable borrow
1066        let surface = face.surface().clone();
1067        let inner_wires = face.inner_wires().to_vec();
1068        let is_reversed = face.is_reversed();
1069        let old_edges: Vec<OrientedEdge> = outer_wire.edges().to_vec();
1070
1071        let mut new_oriented_edges = Vec::new();
1072        for oe in &old_edges {
1073            if let Some(intermediates) = edge_splits.get(&oe.edge()) {
1074                let (start_vid, end_vid) = match edge_vertices.get(&oe.edge()) {
1075                    Some(&v) => v,
1076                    None => continue,
1077                };
1078                let original_curve = topo.edge(oe.edge())?.curve().clone();
1079
1080                // Build vertex chain in traversal order
1081                let chain: Vec<VertexId> = if oe.is_forward() {
1082                    let mut c = vec![start_vid];
1083                    c.extend(intermediates.iter().copied());
1084                    c.push(end_vid);
1085                    c
1086                } else {
1087                    let mut c = vec![end_vid];
1088                    c.extend(intermediates.iter().rev().copied());
1089                    c.push(start_vid);
1090                    c
1091                };
1092
1093                // Create sub-edges (reusing from edge_map when possible).
1094                // Preserve the original edge's curve type so curved edges
1095                // (Circle, Ellipse) are not silently replaced with lines.
1096                for k in 0..chain.len() - 1 {
1097                    let va = chain[k];
1098                    let vb = chain[k + 1];
1099                    let va_idx = va.index();
1100                    let vb_idx = vb.index();
1101                    let (key_min, key_max) = if va_idx <= vb_idx {
1102                        (va_idx, vb_idx)
1103                    } else {
1104                        (vb_idx, va_idx)
1105                    };
1106                    let sub_eid = *edge_map.entry((key_min, key_max)).or_insert_with(|| {
1107                        // The chain runs in this wire's traversal order; a
1108                        // stored Circle arc means CCW start→end around its
1109                        // axis, so a sub-edge of a reversed traversal must be
1110                        // stored with swapped endpoints to keep the sub-arc
1111                        // on the parent arc's span.
1112                        let (s, e) = if oe.is_forward() { (va, vb) } else { (vb, va) };
1113                        topo.add_edge(Edge::new(s, e, original_curve.clone()))
1114                    });
1115                    let fwd = topo.edge(sub_eid)?.start() == va;
1116                    // Skip if edge already in wire (prevents duplicates from
1117                    // vertex merging creating overlapping segments).
1118                    if !new_oriented_edges
1119                        .iter()
1120                        .any(|e: &OrientedEdge| e.edge() == sub_eid)
1121                    {
1122                        new_oriented_edges.push(OrientedEdge::new(sub_eid, fwd));
1123                    }
1124                }
1125            } else {
1126                // Skip if unsplit edge already added by a prior split expansion.
1127                if !new_oriented_edges
1128                    .iter()
1129                    .any(|e: &OrientedEdge| e.edge() == oe.edge())
1130                {
1131                    new_oriented_edges.push(*oe);
1132                }
1133            }
1134        }
1135
1136        let new_wire =
1137            Wire::new(new_oriented_edges, true).map_err(crate::OperationsError::Topology)?;
1138        let new_wire_id = topo.add_wire(new_wire);
1139
1140        let new_face = if is_reversed {
1141            Face::new_reversed(new_wire_id, inner_wires, surface)
1142        } else {
1143            Face::new(new_wire_id, inner_wires, surface)
1144        };
1145        face_ids[fi] = topo.add_face(new_face);
1146    }
1147
1148    Ok(())
1149}
1150
1151/// Merge geometrically-coincident boundary edge pairs.
1152///
1153/// After boolean assembly, the spatial-hash vertex deduplication may map
1154/// coincident vertices to different hash cells when positions straddle a
1155/// cell boundary. This creates separate `VertexId`s → separate `EdgeId`s →
1156/// boundary edges even though the geometry matches. This function finds
1157/// such pairs and stitches them by rewriting one face's wire to reference
1158/// the other face's edge.
1159///
1160/// Returns the number of edges stitched.
1161#[allow(clippy::too_many_lines)]
1162pub(super) fn stitch_boundary_edges(
1163    topo: &mut Topology,
1164    face_ids: &mut [FaceId],
1165    tol: Tolerance,
1166) -> Result<usize, crate::OperationsError> {
1167    struct BoundaryEdgeInfo {
1168        edge_id: EdgeId,
1169        start_vid: VertexId,
1170        end_vid: VertexId,
1171        start_pos: Point3,
1172        end_pos: Point3,
1173        midpoint: Point3,
1174        face_idx: usize,
1175    }
1176
1177    let mut edge_face_count: HashMap<EdgeId, usize> = HashMap::new();
1178    let mut edge_vertices: HashMap<EdgeId, (VertexId, VertexId)> = HashMap::new();
1179    // Track which face and wire own each edge for later rewriting.
1180    let mut edge_owner: HashMap<EdgeId, (usize, WireId)> = HashMap::new();
1181
1182    for (fi, &fid) in face_ids.iter().enumerate() {
1183        let face = topo.face(fid)?;
1184        let outer_wire_id = face.outer_wire();
1185        // Traverse outer wire and all inner wires for edge counting.
1186        for wid in std::iter::once(outer_wire_id).chain(face.inner_wires().iter().copied()) {
1187            let wire = topo.wire(wid)?;
1188            for oe in wire.edges() {
1189                let eid = oe.edge();
1190                *edge_face_count.entry(eid).or_default() += 1;
1191                if let std::collections::hash_map::Entry::Vacant(e) = edge_vertices.entry(eid)
1192                    && let Ok(edge) = topo.edge(eid)
1193                {
1194                    e.insert((edge.start(), edge.end()));
1195                }
1196                edge_owner.entry(eid).or_insert((fi, outer_wire_id));
1197            }
1198        }
1199    }
1200
1201    let mut boundary_edges: Vec<BoundaryEdgeInfo> = Vec::new();
1202    for (&eid, &count) in &edge_face_count {
1203        if count != 1 {
1204            continue;
1205        }
1206        let &(sv, ev) = match edge_vertices.get(&eid) {
1207            Some(v) => v,
1208            None => continue,
1209        };
1210        let sp = topo.vertex(sv)?.point();
1211        let ep = topo.vertex(ev)?.point();
1212        let mid = Point3::new(
1213            (sp.x() + ep.x()) * 0.5,
1214            (sp.y() + ep.y()) * 0.5,
1215            (sp.z() + ep.z()) * 0.5,
1216        );
1217        let &(fi, _wid) = match edge_owner.get(&eid) {
1218            Some(v) => v,
1219            None => continue,
1220        };
1221        boundary_edges.push(BoundaryEdgeInfo {
1222            edge_id: eid,
1223            start_vid: sv,
1224            end_vid: ev,
1225            start_pos: sp,
1226            end_pos: ep,
1227            midpoint: mid,
1228            face_idx: fi,
1229        });
1230    }
1231
1232    if boundary_edges.len() < 2 {
1233        return Ok(0);
1234    }
1235
1236    let tol_linear = tol.linear;
1237    let cell_size = boundary_edges
1238        .iter()
1239        .map(|be| {
1240            let dx = be.end_pos.x() - be.start_pos.x();
1241            let dy = be.end_pos.y() - be.start_pos.y();
1242            let dz = be.end_pos.z() - be.start_pos.z();
1243            (dx * dx + dy * dy + dz * dz).sqrt() * 0.5
1244        })
1245        .fold(f64::INFINITY, f64::min)
1246        .max(tol_linear * 10.0);
1247    let inv_cell = 1.0 / cell_size;
1248
1249    let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
1250    for (i, be) in boundary_edges.iter().enumerate() {
1251        let cx = (be.midpoint.x() * inv_cell).floor() as i64;
1252        let cy = (be.midpoint.y() * inv_cell).floor() as i64;
1253        let cz = (be.midpoint.z() * inv_cell).floor() as i64;
1254        grid.entry((cx, cy, cz)).or_default().push(i);
1255    }
1256
1257    let mut stitched: HashSet<EdgeId> = HashSet::new();
1258    // Map: (face_idx, old_edge_id) → replacement_edge_id
1259    let mut replacements: HashMap<(usize, EdgeId), EdgeId> = HashMap::new();
1260    // Map: old_vertex → new_vertex for cascading vertex remaps
1261    let mut vertex_remap: HashMap<VertexId, VertexId> = HashMap::new();
1262    let mut stitch_count = 0;
1263
1264    let tol_sq = tol_linear * tol_linear;
1265
1266    for i in 0..boundary_edges.len() {
1267        let be1 = &boundary_edges[i];
1268        if stitched.contains(&be1.edge_id) {
1269            continue;
1270        }
1271
1272        let mid = be1.midpoint;
1273        let cx = (mid.x() * inv_cell).floor() as i64;
1274        let cy = (mid.y() * inv_cell).floor() as i64;
1275        let cz = (mid.z() * inv_cell).floor() as i64;
1276
1277        let mut best_match: Option<usize> = None;
1278        let mut best_dist_sq = f64::INFINITY;
1279
1280        for dx in -1..=1 {
1281            for dy in -1..=1 {
1282                for dz in -1..=1 {
1283                    if let Some(indices) = grid.get(&(cx + dx, cy + dy, cz + dz)) {
1284                        for &j in indices {
1285                            if j <= i {
1286                                continue;
1287                            }
1288                            let be2 = &boundary_edges[j];
1289                            if stitched.contains(&be2.edge_id) {
1290                                continue;
1291                            }
1292                            // Must be from different faces
1293                            if be1.face_idx == be2.face_idx {
1294                                continue;
1295                            }
1296
1297                            // Check endpoint matching (same or reversed direction)
1298                            let same_dir = (be1.start_pos - be2.start_pos).length_squared()
1299                                < tol_sq
1300                                && (be1.end_pos - be2.end_pos).length_squared() < tol_sq;
1301                            let rev_dir = (be1.start_pos - be2.end_pos).length_squared() < tol_sq
1302                                && (be1.end_pos - be2.start_pos).length_squared() < tol_sq;
1303
1304                            if !same_dir && !rev_dir {
1305                                continue;
1306                            }
1307
1308                            let mid_dist_sq = (be1.midpoint - be2.midpoint).length_squared();
1309                            if mid_dist_sq < best_dist_sq {
1310                                best_dist_sq = mid_dist_sq;
1311                                best_match = Some(j);
1312                            }
1313                        }
1314                    }
1315                }
1316            }
1317        }
1318
1319        if let Some(j) = best_match {
1320            let be2 = &boundary_edges[j];
1321
1322            // E1 (from be1) is the "keeper". E2 (from be2) gets replaced.
1323            // Remap be2's vertices to be1's vertices.
1324            let same_dir = (be1.start_pos - be2.start_pos).length_squared() < tol_sq;
1325
1326            if same_dir {
1327                // be2.start → be1.start, be2.end → be1.end
1328                if be2.start_vid != be1.start_vid {
1329                    vertex_remap.insert(be2.start_vid, be1.start_vid);
1330                }
1331                if be2.end_vid != be1.end_vid {
1332                    vertex_remap.insert(be2.end_vid, be1.end_vid);
1333                }
1334            } else {
1335                // Reversed: be2.start → be1.end, be2.end → be1.start
1336                if be2.start_vid != be1.end_vid {
1337                    vertex_remap.insert(be2.start_vid, be1.end_vid);
1338                }
1339                if be2.end_vid != be1.start_vid {
1340                    vertex_remap.insert(be2.end_vid, be1.start_vid);
1341                }
1342            }
1343
1344            // Replace be2's edge with be1's edge in be2's face wire
1345            replacements.insert((be2.face_idx, be2.edge_id), be1.edge_id);
1346
1347            stitched.insert(be1.edge_id);
1348            stitched.insert(be2.edge_id);
1349            stitch_count += 1;
1350        }
1351    }
1352
1353    if stitch_count == 0 {
1354        return Ok(0);
1355    }
1356
1357    log::debug!(
1358        "[boolean] stitch_boundary_edges: {} pairs, {} vertex remaps",
1359        stitch_count,
1360        vertex_remap.len()
1361    );
1362
1363    // Cascade vertex remaps: if A→B and B→C, then A→C.
1364    let mut resolved_remap: HashMap<VertexId, VertexId> = HashMap::new();
1365    for (&from, &to) in &vertex_remap {
1366        let mut target = to;
1367        let mut depth = 0;
1368        while let Some(&next) = vertex_remap.get(&target) {
1369            if next == target || depth > 10 {
1370                break;
1371            }
1372            target = next;
1373            depth += 1;
1374        }
1375        resolved_remap.insert(from, target);
1376    }
1377
1378    // Collect which faces need rebuilding (faces that have edge replacements
1379    // OR contain edges with remapped vertices).
1380    let affected_face_indices: HashSet<usize> = replacements.keys().map(|(fi, _)| *fi).collect();
1381
1382    for &fi in &affected_face_indices {
1383        let fid = face_ids[fi];
1384        let face = topo.face(fid)?;
1385        let outer_wire_id = face.outer_wire();
1386        let wire = topo.wire(outer_wire_id)?;
1387        let surface = face.surface().clone();
1388        let is_reversed = face.is_reversed();
1389        let inner_wires: Vec<WireId> = face.inner_wires().to_vec();
1390        let old_edges: Vec<OrientedEdge> = wire.edges().to_vec();
1391
1392        let mut new_oriented_edges: Vec<OrientedEdge> = Vec::with_capacity(old_edges.len());
1393
1394        for oe in &old_edges {
1395            if let Some(&replacement_eid) = replacements.get(&(fi, oe.edge())) {
1396                // This edge is being replaced by the keeper edge.
1397                // The keeper edge's canonical direction may differ from
1398                // the replaced edge's direction in this wire, so we need
1399                // to compute the correct orientation.
1400                let keeper = topo.edge(replacement_eid)?;
1401                let keeper_start = keeper.start();
1402                let keeper_end = keeper.end();
1403
1404                // What vertex does this wire position expect at the start
1405                // of traversal for this oriented edge?
1406                let old_edge = topo.edge(oe.edge())?;
1407                let expected_start = if oe.is_forward() {
1408                    old_edge.start()
1409                } else {
1410                    old_edge.end()
1411                };
1412                let resolved_expected = resolved_remap
1413                    .get(&expected_start)
1414                    .copied()
1415                    .unwrap_or(expected_start);
1416
1417                // If keeper's start matches expected start, traverse forward;
1418                // otherwise traverse reversed.
1419                let is_forward =
1420                    keeper_start == resolved_expected || keeper_end != resolved_expected;
1421                new_oriented_edges.push(OrientedEdge::new(replacement_eid, is_forward));
1422            } else {
1423                // Keep the original edge, but remap its vertices if needed.
1424                let edge = topo.edge(oe.edge())?;
1425                let old_start = edge.start();
1426                let old_end = edge.end();
1427                let new_start = resolved_remap.get(&old_start).copied();
1428                let new_end = resolved_remap.get(&old_end).copied();
1429
1430                if new_start.is_some() || new_end.is_some() {
1431                    let curve = edge.curve().clone();
1432                    let s = new_start.unwrap_or(old_start);
1433                    let e = new_end.unwrap_or(old_end);
1434                    let new_eid = topo.add_edge(Edge::new(s, e, curve));
1435                    new_oriented_edges.push(OrientedEdge::new(new_eid, oe.is_forward()));
1436                } else {
1437                    new_oriented_edges.push(*oe);
1438                }
1439            }
1440        }
1441
1442        let new_wire =
1443            Wire::new(new_oriented_edges, true).map_err(crate::OperationsError::Topology)?;
1444        let new_wire_id = topo.add_wire(new_wire);
1445        let new_face = if is_reversed {
1446            Face::new_reversed(new_wire_id, inner_wires, surface)
1447        } else {
1448            Face::new(new_wire_id, inner_wires, surface)
1449        };
1450        face_ids[fi] = topo.add_face(new_face);
1451    }
1452
1453    Ok(stitch_count)
1454}
1455
1456/// Split non-manifold edges into multiple coincident copies.
1457///
1458/// After boolean assembly, some edges may be shared by more than 2 faces.
1459/// This happens when two solids share an edge or a vertex exactly, creating
1460/// an L-shaped junction. A manifold solid requires every edge to be shared
1461/// by exactly 2 faces.
1462///
1463/// This function detects non-manifold edges and duplicates them, assigning
1464/// each copy to a pair of faces based on angular ordering around the edge.
1465/// Faces are sorted by the angle of their outward normal projected onto
1466/// the plane perpendicular to the edge, then paired consecutively.
1467#[allow(clippy::too_many_lines)]
1468pub(super) fn split_nonmanifold_edges(
1469    topo: &mut Topology,
1470    face_ids: &mut [FaceId],
1471) -> Result<(), crate::OperationsError> {
1472    // Build edge → [(face_index, is_forward)] map.
1473    let mut edge_faces: HashMap<usize, Vec<(usize, bool)>> = HashMap::new();
1474    for (fi, &fid) in face_ids.iter().enumerate() {
1475        let face = topo.face(fid)?;
1476        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
1477            let wire = topo.wire(wid)?;
1478            for oe in wire.edges() {
1479                edge_faces
1480                    .entry(oe.edge().index())
1481                    .or_default()
1482                    .push((fi, oe.is_forward()));
1483            }
1484        }
1485    }
1486
1487    // Find non-manifold edges (shared by > 2 faces).
1488    // Sort by edge index for deterministic processing order — each NM-edge
1489    // split mutates the topology (via edge_replacements), so order changes
1490    // the assembly outcome when edges share faces. Same pattern fixed in
1491    // #689/#692 for earlier GFA iteration sites.
1492    let mut nonmanifold: Vec<(usize, Vec<(usize, bool)>)> = edge_faces
1493        .into_iter()
1494        .filter(|(_, faces)| faces.len() > 2)
1495        .collect();
1496    nonmanifold.sort_by_key(|(eid, _)| *eid);
1497
1498    if nonmanifold.is_empty() {
1499        return Ok(());
1500    }
1501
1502    // For each non-manifold edge, sort faces by angle and create edge copies.
1503    // Map: (face_index, old_edge_index) → new_edge_id
1504    let mut edge_replacements: HashMap<(usize, usize), EdgeId> = HashMap::new();
1505
1506    for (edge_idx, face_refs) in &nonmanifold {
1507        let edge_id = topo.edge_id_from_index(*edge_idx).ok_or_else(|| {
1508            crate::OperationsError::InvalidInput {
1509                reason: format!("edge index {edge_idx} not found"),
1510            }
1511        })?;
1512        // Snapshot edge data before any mutable borrows (borrow checker).
1513        let edge_start = topo.edge(edge_id)?.start();
1514        let edge_end = topo.edge(edge_id)?.end();
1515        let edge_curve = topo.edge(edge_id)?.curve().clone();
1516        let start_pos = topo.vertex(edge_start)?.point();
1517        let end_pos = topo.vertex(edge_end)?.point();
1518
1519        let edge_dir = Vec3::new(
1520            end_pos.x() - start_pos.x(),
1521            end_pos.y() - start_pos.y(),
1522            end_pos.z() - start_pos.z(),
1523        );
1524        let edge_len = edge_dir.length();
1525        // Numerical-zero guard: skip degenerate zero-length edges that would
1526        // cause division-by-zero when normalizing the edge direction below.
1527        if edge_len < 1e-15 {
1528            continue;
1529        }
1530        let edge_axis = Vec3::new(
1531            edge_dir.x() / edge_len,
1532            edge_dir.y() / edge_len,
1533            edge_dir.z() / edge_len,
1534        );
1535
1536        // Build a local 2D frame perpendicular to the edge.
1537        let perp = if edge_axis.x().abs() < 0.9 {
1538            Vec3::new(1.0, 0.0, 0.0)
1539        } else {
1540            Vec3::new(0.0, 1.0, 0.0)
1541        };
1542        let u_axis = edge_axis.cross(perp);
1543        let u_len = u_axis.length();
1544        // Numerical-zero guard: edge_axis nearly parallel to perp — cross
1545        // product is degenerate. Skip rather than produce a garbage frame.
1546        if u_len < 1e-15 {
1547            continue;
1548        }
1549        let u_axis = Vec3::new(u_axis.x() / u_len, u_axis.y() / u_len, u_axis.z() / u_len);
1550        let v_axis = edge_axis.cross(u_axis);
1551
1552        // Compute angle for each face's normal projected onto the perpendicular plane.
1553        let mut face_angles: Vec<(usize, bool, f64)> = Vec::new();
1554        for &(fi, is_fwd) in face_refs {
1555            let face = topo.face(face_ids[fi])?;
1556            let normal = if let FaceSurface::Plane { normal, .. } = face.surface() {
1557                *normal
1558            } else {
1559                // For non-planar faces, evaluate the actual surface normal at
1560                // the edge midpoint by projecting to UV. This matches the
1561                // standard approach of evaluating the surface at the edge
1562                // point's parametric coordinates, rather than approximating
1563                // from the wire polygon centroid (which is inaccurate for
1564                // curved surfaces and produces wrong angular ordering).
1565                let mid = Point3::new(
1566                    (start_pos.x() + end_pos.x()) * 0.5,
1567                    (start_pos.y() + end_pos.y()) * 0.5,
1568                    (start_pos.z() + end_pos.z()) * 0.5,
1569                );
1570                if let Some((u, v)) = face.surface().project_point(mid) {
1571                    face.surface().normal(u, v)
1572                } else {
1573                    // Projection failed — fall back to centroid direction.
1574                    let wire = topo.wire(face.outer_wire())?;
1575                    let mut sum = Vec3::new(0.0, 0.0, 0.0);
1576                    let mut count = 0usize;
1577                    for oe in wire.edges() {
1578                        if let Ok(e) = topo.edge(oe.edge())
1579                            && let Ok(vx) = topo.vertex(e.start())
1580                        {
1581                            let p = vx.point();
1582                            sum = Vec3::new(sum.x() + p.x(), sum.y() + p.y(), sum.z() + p.z());
1583                            count += 1;
1584                        }
1585                    }
1586                    if count == 0 {
1587                        continue;
1588                    }
1589                    #[allow(clippy::cast_precision_loss)]
1590                    let inv = 1.0 / count as f64;
1591                    let centroid = Vec3::new(sum.x() * inv, sum.y() * inv, sum.z() * inv);
1592                    Vec3::new(
1593                        centroid.x() - mid.x(),
1594                        centroid.y() - mid.y(),
1595                        centroid.z() - mid.z(),
1596                    )
1597                }
1598            };
1599
1600            // If face is reversed, flip the effective normal for sorting.
1601            let effective_normal = if face.is_reversed() { -normal } else { normal };
1602
1603            // Project normal onto perpendicular plane and compute angle.
1604            let proj_u = effective_normal.dot(u_axis);
1605            let proj_v = effective_normal.dot(v_axis);
1606            let angle = proj_v.atan2(proj_u);
1607            face_angles.push((fi, is_fwd, angle));
1608        }
1609
1610        face_angles.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
1611
1612        // Pair consecutive faces (in angular order) and assign edge copies.
1613        let n = face_angles.len();
1614        for pair_idx in 0..(n / 2) {
1615            let i = pair_idx * 2;
1616            let j = i + 1;
1617            if j >= n {
1618                break;
1619            }
1620            let new_edge_id = if pair_idx == 0 {
1621                edge_id
1622            } else {
1623                topo.add_edge(Edge::new(edge_start, edge_end, edge_curve.clone()))
1624            };
1625            edge_replacements.insert((face_angles[i].0, *edge_idx), new_edge_id);
1626            edge_replacements.insert((face_angles[j].0, *edge_idx), new_edge_id);
1627        }
1628        // Handle odd face (keeps the original edge — still non-manifold but
1629        // the iterative loop will process it on the next pass).
1630        if n % 2 == 1 {
1631            let last = &face_angles[n - 1];
1632            edge_replacements.insert((last.0, *edge_idx), edge_id);
1633        }
1634    }
1635
1636    if edge_replacements.is_empty() {
1637        return Ok(());
1638    }
1639
1640    let affected_faces: HashSet<usize> = edge_replacements.keys().map(|(fi, _)| *fi).collect();
1641    for fi in affected_faces {
1642        let fid = face_ids[fi];
1643        let face = topo.face(fid)?;
1644        let wire = topo.wire(face.outer_wire())?;
1645        let surface = face.surface().clone();
1646        let is_reversed = face.is_reversed();
1647        let inner_wires: Vec<WireId> = face.inner_wires().to_vec();
1648
1649        let new_edges: Vec<OrientedEdge> = wire
1650            .edges()
1651            .iter()
1652            .map(|oe| {
1653                if let Some(&new_eid) = edge_replacements.get(&(fi, oe.edge().index())) {
1654                    OrientedEdge::new(new_eid, oe.is_forward())
1655                } else {
1656                    *oe
1657                }
1658            })
1659            .collect();
1660
1661        let new_wire = Wire::new(new_edges, true).map_err(crate::OperationsError::Topology)?;
1662        let new_wire_id = topo.add_wire(new_wire);
1663        let new_face = if is_reversed {
1664            Face::new_reversed(new_wire_id, inner_wires, surface)
1665        } else {
1666            Face::new(new_wire_id, inner_wires, surface)
1667        };
1668        face_ids[fi] = topo.add_face(new_face);
1669    }
1670
1671    Ok(())
1672}
1673
1674/// Compute a representative normal and d-value for a face surface.
1675#[allow(dead_code)]
1676fn analytic_face_normal_d(surface: &FaceSurface, verts: &[Point3]) -> (Vec3, f64) {
1677    match surface {
1678        FaceSurface::Plane { normal, d } => (*normal, *d),
1679        _ => {
1680            if verts.len() >= 3 {
1681                let e1 = verts[1] - verts[0];
1682                let e2 = verts[2] - verts[0];
1683                let n = e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0));
1684                (n, crate::dot_normal_point(n, verts[0]))
1685            } else {
1686                (Vec3::new(0.0, 0.0, 1.0), 0.0)
1687            }
1688        }
1689    }
1690}
1691
1692/// If solids A and B share a face (opposite normals, coplanar, overlapping
1693/// extent), merge them by removing the shared face pair and combining
1694/// remaining faces into a new solid via `assemble_solid_mixed`. Returns
1695/// `None` if the fast path doesn't apply.
1696#[allow(dead_code, clippy::too_many_lines)]
1697pub(super) fn try_shared_boundary_fuse(
1698    topo: &mut Topology,
1699    _a: SolidId,
1700    _b: SolidId,
1701    face_ids_a: &[FaceId],
1702    face_ids_b: &[FaceId],
1703    tol: Tolerance,
1704) -> Result<Option<SolidId>, crate::OperationsError> {
1705    struct PlaneInfo {
1706        normal: Vec3,
1707        d: f64,
1708        vertices: Vec<Point3>,
1709    }
1710
1711    /// Area ratio below which two faces are not considered extent-matching.
1712    const SHARED_FACE_AREA_RATIO_MIN: f64 = 0.99;
1713
1714    // Only worth it for small solids (avoids pathological cases).
1715    if face_ids_a.len() > 20 || face_ids_b.len() > 20 {
1716        return Ok(None);
1717    }
1718
1719    // Require all faces to be planar.
1720    for &fid in face_ids_a.iter().chain(face_ids_b.iter()) {
1721        if !matches!(topo.face(fid)?.surface(), FaceSurface::Plane { .. }) {
1722            return Ok(None);
1723        }
1724    }
1725
1726    // Snapshot each face: (normal, d, vertices).
1727    let snapshot = |fid: FaceId| -> Result<PlaneInfo, crate::OperationsError> {
1728        let face = topo.face(fid)?;
1729        let surface = face.surface().clone();
1730        let reversed = face.is_reversed();
1731        let verts = face_polygon(topo, fid)?;
1732        let (mut normal, mut d) = analytic_face_normal_d(&surface, &verts);
1733        if reversed {
1734            normal = -normal;
1735            d = -d;
1736        }
1737        Ok(PlaneInfo {
1738            normal,
1739            d,
1740            vertices: verts,
1741        })
1742    };
1743
1744    let infos_a: Vec<PlaneInfo> = face_ids_a
1745        .iter()
1746        .map(|&fid| snapshot(fid))
1747        .collect::<Result<Vec<_>, _>>()?;
1748    let infos_b: Vec<PlaneInfo> = face_ids_b
1749        .iter()
1750        .map(|&fid| snapshot(fid))
1751        .collect::<Result<Vec<_>, _>>()?;
1752
1753    // Find shared face pair: coplanar with opposite normals and overlapping extent.
1754    let mut shared_a = None;
1755    let mut shared_b = None;
1756    let mut shared_count = 0;
1757
1758    for (ia, pa) in infos_a.iter().enumerate() {
1759        for (ib, pb) in infos_b.iter().enumerate() {
1760            // Opposite normals, same plane (n_a ≈ -n_b, d_a ≈ -d_b).
1761            let dot = pa.normal.dot(pb.normal);
1762            if dot > -1.0 + tol.angular {
1763                continue;
1764            }
1765            if !tol.approx_eq(pa.d, -pb.d) {
1766                continue;
1767            }
1768
1769            // Verify matching extent: both face polygons must have
1770            // approximately equal area.
1771            let area_a = polygon_area_3d(&pa.vertices, pa.normal);
1772            let area_b = polygon_area_3d(&pb.vertices, pb.normal);
1773            let area_ratio = if area_a > area_b {
1774                area_b / area_a
1775            } else {
1776                area_a / area_b
1777            };
1778            if area_ratio < SHARED_FACE_AREA_RATIO_MIN {
1779                continue;
1780            }
1781
1782            // Centroids should be within a geometry-scaled tolerance.
1783            // Use sqrt(area) as the face extent scale.
1784            let centroid_a = polygon_centroid(&pa.vertices);
1785            let centroid_b = polygon_centroid(&pb.vertices);
1786            let dist = (centroid_a - centroid_b).length();
1787            let face_extent = area_a.sqrt().max(tol.linear);
1788            // Geometry-scaled centroid coincidence test: centroids must be within
1789            // 1e-6 * face_extent (i.e., within one millionth of the face size).
1790            // This relative threshold adapts to model scale — a 1m face allows
1791            // 1 micron drift, a 1mm face allows 1 nm.
1792            if dist > face_extent * 1e-6 {
1793                continue;
1794            }
1795
1796            shared_a = Some(ia);
1797            shared_b = Some(ib);
1798            shared_count += 1;
1799
1800            if shared_count > 1 {
1801                // Multiple shared faces → too complex for fast path.
1802                return Ok(None);
1803            }
1804        }
1805    }
1806
1807    let (skip_a, skip_b) = match (shared_a, shared_b) {
1808        (Some(a), Some(b)) => (a, b),
1809        _ => return Ok(None),
1810    };
1811
1812    // Build face specs from all faces except the shared pair.
1813    let mut face_specs: Vec<FaceSpec> = Vec::with_capacity(face_ids_a.len() + face_ids_b.len() - 2);
1814
1815    for (i, info) in infos_a.iter().enumerate() {
1816        if i == skip_a {
1817            continue;
1818        }
1819        face_specs.push(FaceSpec::Planar {
1820            vertices: info.vertices.clone(),
1821            normal: info.normal,
1822            d: info.d,
1823            inner_wires: vec![],
1824        });
1825    }
1826    for (i, info) in infos_b.iter().enumerate() {
1827        if i == skip_b {
1828            continue;
1829        }
1830        face_specs.push(FaceSpec::Planar {
1831            vertices: info.vertices.clone(),
1832            normal: info.normal,
1833            d: info.d,
1834            inner_wires: vec![],
1835        });
1836    }
1837
1838    let result = assemble_solid_mixed(topo, &face_specs, tol)?;
1839    Ok(Some(result))
1840}
1841
1842/// Compute the area of a 3D polygon given its vertices and face normal.
1843#[allow(dead_code)]
1844pub(super) fn polygon_area_3d(vertices: &[Point3], normal: Vec3) -> f64 {
1845    if vertices.len() < 3 {
1846        return 0.0;
1847    }
1848    let mut area = Vec3::new(0.0, 0.0, 0.0);
1849    let v0 = vertices[0];
1850    for i in 1..vertices.len() - 1 {
1851        let e1 = vertices[i] - v0;
1852        let e2 = vertices[i + 1] - v0;
1853        area += e1.cross(e2);
1854    }
1855    (area.dot(normal) * 0.5).abs()
1856}
1857
1858/// Build manifold shells from an unordered list of faces.
1859///
1860/// Groups faces into connected manifold shells using angular face selection
1861/// at non-manifold edges (edges shared by >2 faces). At each such edge, the
1862/// algorithm selects the angular neighbor (tightest dihedral angle) to grow
1863/// the shell, ensuring each edge is shared by exactly 2 faces.
1864///
1865/// Returns a solid with the largest shell as outer and smaller shells as
1866/// inner (cavities). Falls back to a single shell if the algorithm can't
1867/// produce manifold shells.
1868///
1869/// Not yet wired into the boolean pipeline — needs edge orientation
1870/// compatibility checking (FORWARD in one face, REVERSED in the other)
1871/// before it can replace `split_nonmanifold_edges`.
1872#[allow(clippy::too_many_lines, dead_code)]
1873pub(super) fn build_manifold_shells(
1874    topo: &mut Topology,
1875    face_ids: &[FaceId],
1876) -> Result<SolidId, crate::OperationsError> {
1877    if face_ids.is_empty() {
1878        return Err(crate::OperationsError::InvalidInput {
1879            reason: "build_manifold_shells: no faces".into(),
1880        });
1881    }
1882
1883    // Only count OUTER wire edges — inner wire edges are internal to the
1884    // face and don't participate in face-face adjacency for shell building.
1885    let mut edge_faces: HashMap<usize, Vec<(usize, bool)>> = HashMap::new();
1886    for (fi, &fid) in face_ids.iter().enumerate() {
1887        let face = topo.face(fid)?;
1888        let wire = topo.wire(face.outer_wire())?;
1889        for oe in wire.edges() {
1890            edge_faces
1891                .entry(oe.edge().index())
1892                .or_default()
1893                .push((fi, oe.is_forward()));
1894        }
1895    }
1896
1897    // Manifold check: ≤3 residual nm edges are treated as manifold.
1898    // split_nonmanifold_edges handles most nm edges; the remaining 1-3
1899    // are edge cases at curved surface junctions that don't significantly
1900    // affect topology (they're already paired, just with 3 faces instead
1901    // of 2 at the junction).
1902    let nm_count = edge_faces.values().filter(|fs| fs.len() > 2).count();
1903    // Threshold: treat as manifold if ≤30 residual nm edges. The BFS
1904    // shell building doesn't handle all cases correctly yet, so use
1905    // the single-shell fast path for anything that split_nonmanifold_edges
1906    // mostly resolved.
1907    if nm_count <= 30 {
1908        // All manifold — single shell.
1909        let shell = Shell::new(face_ids.to_vec()).map_err(crate::OperationsError::Topology)?;
1910        let shell_id = topo.add_shell(shell);
1911        return Ok(topo.add_solid(Solid::new(shell_id, vec![])));
1912    }
1913
1914    let mut added: HashSet<usize> = HashSet::new();
1915    let mut shells: Vec<Vec<FaceId>> = Vec::new();
1916
1917    for seed_fi in 0..face_ids.len() {
1918        if added.contains(&seed_fi) {
1919            continue;
1920        }
1921        added.insert(seed_fi);
1922
1923        let mut shell_faces: Vec<usize> = vec![seed_fi];
1924        // Track edges within this shell: edge_idx → count of faces using it.
1925        let mut shell_edge_count: HashMap<usize, u32> = HashMap::new();
1926
1927        count_face_edges(topo, face_ids[seed_fi], &mut shell_edge_count)?;
1928
1929        let mut queue_idx = 0;
1930        while queue_idx < shell_faces.len() {
1931            let current_fi = shell_faces[queue_idx];
1932            queue_idx += 1;
1933
1934            let face = topo.face(face_ids[current_fi])?;
1935            let mut face_edge_list: Vec<(usize, bool)> = Vec::new();
1936            // Only traverse outer wire edges for face-face connectivity.
1937            let wire = topo.wire(face.outer_wire())?;
1938            for oe in wire.edges() {
1939                face_edge_list.push((oe.edge().index(), oe.is_forward()));
1940            }
1941
1942            for (edge_idx, edge_fwd) in face_edge_list {
1943                // Skip if this edge already has 2 faces in the shell.
1944                if shell_edge_count.get(&edge_idx).copied().unwrap_or(0) >= 2 {
1945                    continue;
1946                }
1947
1948                // Find candidate neighbors: faces sharing this edge, not yet added.
1949                let Some(neighbors) = edge_faces.get(&edge_idx) else {
1950                    continue;
1951                };
1952
1953                // Manifold condition: at a shared edge, the edge must appear
1954                // FORWARD in one face and REVERSED in the other. Filter
1955                // candidates to only those with opposite edge orientation.
1956                let candidates: Vec<(usize, bool)> = neighbors
1957                    .iter()
1958                    .filter(|(fi, fwd)| {
1959                        *fi != current_fi && !added.contains(fi) && *fwd != edge_fwd
1960                    })
1961                    .copied()
1962                    .collect();
1963
1964                if candidates.is_empty() {
1965                    continue;
1966                }
1967
1968                // Select neighbor: if only 1, take it. If >1, use angular selection.
1969                let selected_fi = if candidates.len() == 1 {
1970                    candidates[0].0
1971                } else {
1972                    // Angular face-off: select tightest dihedral angle.
1973                    select_angular_neighbor(
1974                        topo,
1975                        face_ids,
1976                        edge_idx,
1977                        current_fi,
1978                        edge_fwd,
1979                        &candidates,
1980                    )?
1981                    .unwrap_or(candidates[0].0)
1982                };
1983
1984                if added.insert(selected_fi) {
1985                    shell_faces.push(selected_fi);
1986                    count_face_edges(topo, face_ids[selected_fi], &mut shell_edge_count)?;
1987                }
1988            }
1989        }
1990
1991        shells.push(shell_faces.into_iter().map(|fi| face_ids[fi]).collect());
1992    }
1993
1994    // Largest shell is outer, rest are inner.
1995    if shells.is_empty() {
1996        return Err(crate::OperationsError::InvalidInput {
1997            reason: "build_manifold_shells: no shells produced".into(),
1998        });
1999    }
2000
2001    // Sort by face count descending — largest first (outer shell).
2002    shells.sort_by_key(|s| std::cmp::Reverse(s.len()));
2003
2004    let outer_shell = Shell::new(shells[0].clone()).map_err(crate::OperationsError::Topology)?;
2005    let outer_id = topo.add_shell(outer_shell);
2006
2007    let mut inner_ids = Vec::new();
2008    for inner_faces in &shells[1..] {
2009        if !inner_faces.is_empty()
2010            && let Ok(inner_shell) = Shell::new(inner_faces.clone())
2011        {
2012            inner_ids.push(topo.add_shell(inner_shell));
2013        }
2014    }
2015
2016    Ok(topo.add_solid(Solid::new(outer_id, inner_ids)))
2017}
2018
2019/// Count edges in a face and add to the shell edge count map.
2020fn count_face_edges(
2021    topo: &Topology,
2022    fid: FaceId,
2023    edge_count: &mut HashMap<usize, u32>,
2024) -> Result<(), crate::OperationsError> {
2025    let face = topo.face(fid)?;
2026    let wire = topo.wire(face.outer_wire())?;
2027    for oe in wire.edges() {
2028        *edge_count.entry(oe.edge().index()).or_default() += 1;
2029    }
2030    Ok(())
2031}
2032
2033/// Select the angular neighbor at a non-manifold edge.
2034///
2035/// Evaluates surface normals at the edge midpoint on the current face
2036/// and each candidate, computes binormal directions, and selects the
2037/// candidate with the smallest positive dihedral angle (tightest CCW
2038/// angular neighbor when viewed along the edge tangent).
2039fn select_angular_neighbor(
2040    topo: &Topology,
2041    face_ids: &[FaceId],
2042    edge_idx: usize,
2043    current_fi: usize,
2044    current_fwd: bool,
2045    candidates: &[(usize, bool)],
2046) -> Result<Option<usize>, crate::OperationsError> {
2047    let edge_id =
2048        topo.edge_id_from_index(edge_idx)
2049            .ok_or_else(|| crate::OperationsError::InvalidInput {
2050                reason: format!("edge index {edge_idx} not found"),
2051            })?;
2052
2053    let edge = topo.edge(edge_id)?;
2054    let start_pos = topo.vertex(edge.start())?.point();
2055    let end_pos = topo.vertex(edge.end())?.point();
2056    let edge_dir = end_pos - start_pos;
2057    let edge_len = edge_dir.length();
2058    if edge_len < 1e-12 {
2059        return Ok(None);
2060    }
2061    let tangent = edge_dir * (1.0 / edge_len);
2062    // Flip tangent if edge is reversed in the current face.
2063    let tangent = if current_fwd { tangent } else { -tangent };
2064    let mid = Point3::new(
2065        (start_pos.x() + end_pos.x()) * 0.5,
2066        (start_pos.y() + end_pos.y()) * 0.5,
2067        (start_pos.z() + end_pos.z()) * 0.5,
2068    );
2069
2070    // Current face's binormal at the edge — use pcurve if available.
2071    let current_face = topo.face(face_ids[current_fi])?;
2072    let normal1 = face_normal_at_point(current_face, mid);
2073    let binormal1 = pcurve_binormal(
2074        topo,
2075        edge_id,
2076        face_ids[current_fi],
2077        current_face,
2078        mid,
2079        tangent,
2080        normal1,
2081        current_fwd,
2082    );
2083    let ref_dir = normal1.cross(binormal1);
2084
2085    // For each candidate, compute angle and select tightest.
2086    let mut best_angle = f64::MAX;
2087    let mut best_fi = None;
2088
2089    for &(cand_fi, cand_fwd) in candidates {
2090        let cand_face = topo.face(face_ids[cand_fi])?;
2091        let tangent2 = if cand_fwd == current_fwd {
2092            tangent
2093        } else {
2094            -tangent
2095        };
2096        let normal2 = face_normal_at_point(cand_face, mid);
2097        let binormal2 = pcurve_binormal(
2098            topo,
2099            edge_id,
2100            face_ids[cand_fi],
2101            cand_face,
2102            mid,
2103            tangent2,
2104            normal2,
2105            cand_fwd,
2106        );
2107
2108        // Signed angle from binormal1 to binormal2 around ref_dir.
2109        let cross = binormal1.cross(binormal2);
2110        let cos_val = binormal1.dot(binormal2);
2111        let sin_sign = cross.dot(ref_dir);
2112
2113        // Angle in [0, 2*PI): cos → beta, sign from cross product.
2114        let beta = std::f64::consts::FRAC_PI_2 * (1.0 - cos_val);
2115        let mut angle = if sin_sign < 0.0 { -beta } else { beta };
2116        if angle < 1e-10 {
2117            angle += std::f64::consts::TAU;
2118        }
2119
2120        if angle < best_angle {
2121            best_angle = angle;
2122            best_fi = Some(cand_fi);
2123        }
2124    }
2125
2126    Ok(best_fi)
2127}
2128
2129/// Evaluate a face's effective normal at a 3D point.
2130///
2131/// For plane faces, returns the plane normal (flipped if reversed).
2132/// For parametric surfaces, projects the point to UV and evaluates.
2133fn face_normal_at_point(face: &Face, point: Point3) -> Vec3 {
2134    let raw_normal = match face.surface() {
2135        FaceSurface::Plane { normal, .. } => *normal,
2136        surface => {
2137            if let Some((u, v)) = surface.project_point(point) {
2138                surface.normal(u, v)
2139            } else {
2140                Vec3::new(0.0, 0.0, 1.0) // fallback
2141            }
2142        }
2143    };
2144    if face.is_reversed() {
2145        -raw_normal
2146    } else {
2147        raw_normal
2148    }
2149}
2150
2151/// Register pcurves for all edges on their faces.
2152///
2153/// For each face, iterates its outer and inner wires, and for each edge,
2154/// computes the 2D pcurve (projection of the 3D edge curve into the face's
2155/// surface parameter space) and stores it in the topology's pcurve registry.
2156///
2157/// This enables `build_manifold_shells` to look up pcurves for validated
2158/// binormal computation on curved surfaces.
2159#[allow(dead_code)]
2160pub(super) fn register_pcurves(
2161    topo: &mut Topology,
2162    face_ids: &[FaceId],
2163) -> Result<(), crate::OperationsError> {
2164    use brepkit_algo::compute_pcurve_on_surface;
2165    use brepkit_topology::pcurve::PCurve;
2166
2167    for &fid in face_ids {
2168        let face = topo.face(fid)?;
2169        let surface = face.surface().clone();
2170
2171        // Collect wire points for PlaneFrame construction (plane faces only).
2172        let wire_pts: Vec<Point3> = {
2173            let wire = topo.wire(face.outer_wire())?;
2174            wire.edges()
2175                .iter()
2176                .filter_map(|oe| {
2177                    topo.edge(oe.edge()).ok().and_then(|e| {
2178                        topo.vertex(e.start())
2179                            .ok()
2180                            .map(brepkit_topology::vertex::Vertex::point)
2181                    })
2182                })
2183                .collect()
2184        };
2185
2186        let wire_ids: Vec<_> = {
2187            let f = topo.face(fid)?;
2188            std::iter::once(f.outer_wire())
2189                .chain(f.inner_wires().iter().copied())
2190                .collect()
2191        };
2192
2193        for wid in wire_ids {
2194            let wire = topo.wire(wid)?;
2195            let edges: Vec<_> = wire.edges().to_vec();
2196            for oe in &edges {
2197                let eid = oe.edge();
2198                // Skip if already registered.
2199                if topo.pcurves().contains(eid, fid) {
2200                    continue;
2201                }
2202
2203                let edge = topo.edge(eid)?;
2204                let start = topo.vertex(edge.start())?.point();
2205                let end = topo.vertex(edge.end())?.point();
2206                let curve_3d = edge.curve();
2207
2208                let pcurve_2d =
2209                    compute_pcurve_on_surface(curve_3d, start, end, &surface, &wire_pts, None);
2210
2211                // Parameter range: [0, 1] for the pcurve.
2212                let pc = PCurve::new(pcurve_2d, 0.0, 1.0);
2213                topo.pcurves_mut().set(eid, fid, pc);
2214            }
2215        }
2216    }
2217    Ok(())
2218}
2219
2220/// Compute the binormal direction using a pcurve from the registry.
2221///
2222/// Looks up the pcurve for (edge, face), evaluates the 2D tangent at the
2223/// midpoint, rotates 90° to get the inward direction, steps in UV space,
2224/// evaluates the surface at the stepped UV, and uses the 3D direction
2225/// from the edge point to the stepped point as the binormal.
2226///
2227/// Falls back to the simple `normal.cross(tangent)` if no pcurve is found.
2228#[allow(clippy::too_many_arguments)]
2229fn pcurve_binormal(
2230    topo: &Topology,
2231    edge_id: EdgeId,
2232    face_id: FaceId,
2233    face: &Face,
2234    edge_point: Point3,
2235    tangent_3d: Vec3,
2236    normal: Vec3,
2237    is_edge_forward: bool,
2238) -> Vec3 {
2239    let initial = normal.cross(tangent_3d);
2240    let initial_len = initial.length();
2241    if initial_len < 1e-12 {
2242        return initial;
2243    }
2244    let initial_dir = initial * (1.0 / initial_len);
2245
2246    // For plane faces, the initial estimate is exact.
2247    if matches!(face.surface(), FaceSurface::Plane { .. }) {
2248        return initial_dir;
2249    }
2250
2251    // Look up the pcurve for this (edge, face).
2252    let Some(pcurve) = topo.pcurves().get(edge_id, face_id) else {
2253        return initial_dir;
2254    };
2255
2256    // Evaluate the pcurve at the midpoint to get the 2D tangent.
2257    let t_mid = 0.5 * (pcurve.t_start() + pcurve.t_end());
2258    let uv_mid = pcurve.evaluate(t_mid);
2259
2260    // Compute 2D tangent by finite difference.
2261    let dt = 1e-5;
2262    let t_near = t_mid + dt;
2263    let uv_near = pcurve.evaluate(t_near);
2264    let du = uv_near.x() - uv_mid.x();
2265    let dv = uv_near.y() - uv_mid.y();
2266    let uv_len = (du * du + dv * dv).sqrt();
2267    if uv_len < 1e-15 {
2268        return initial_dir;
2269    }
2270
2271    // Inward 2D normal: rotate tangent 90° CCW → (-dv, du).
2272    // Flip based on edge/face orientation (matching PointNearEdge).
2273    let mut inward_u = -dv / uv_len;
2274    let mut inward_v = du / uv_len;
2275    if !is_edge_forward {
2276        inward_u = -inward_u;
2277        inward_v = -inward_v;
2278    }
2279    if face.is_reversed() {
2280        inward_u = -inward_u;
2281        inward_v = -inward_v;
2282    }
2283
2284    // Step in UV space into the face interior.
2285    let uv_step = 1e-4;
2286    let u_inside = uv_mid.x() + inward_u * uv_step;
2287    let v_inside = uv_mid.y() + inward_v * uv_step;
2288
2289    // Evaluate surface at the interior UV point.
2290    let Some(pt_inside) = face.surface().evaluate(u_inside, v_inside) else {
2291        return initial_dir;
2292    };
2293
2294    // Binormal: direction from edge point to interior point,
2295    // with tangent component removed.
2296    let dir = pt_inside - edge_point;
2297    let along = dir.dot(tangent_3d);
2298    let perp = dir - tangent_3d * along;
2299    let perp_len = perp.length();
2300    if perp_len < 1e-15 {
2301        return initial_dir;
2302    }
2303    perp * (1.0 / perp_len)
2304}