Skip to main content

brepkit_operations/boolean/
mod.rs

1//! Boolean operations on solids: fuse, cut, and intersect.
2//!
3//! Uses the GFA pipeline (`brepkit_algo::gfa`) as the primary boolean engine,
4//! with mesh boolean (co-refinement) as a fallback when GFA fails or produces
5//! invalid results.
6
7pub mod assembly;
8mod classify;
9mod types;
10use assembly::validate_boolean_result;
11pub(crate) use assembly::{assemble_solid, assemble_solid_mixed};
12pub use types::{BooleanOp, BooleanOptions, FaceSpec};
13
14// WASM-compatible timer: `std::time::Instant` panics on wasm32 targets.
15#[cfg(not(target_arch = "wasm32"))]
16pub(super) fn timer_now() -> std::time::Instant {
17    std::time::Instant::now()
18}
19#[cfg(not(target_arch = "wasm32"))]
20pub(super) fn timer_elapsed_ms(t: std::time::Instant) -> f64 {
21    t.elapsed().as_secs_f64() * 1000.0
22}
23#[cfg(target_arch = "wasm32")]
24pub(super) fn timer_now() -> () {}
25#[cfg(target_arch = "wasm32")]
26pub(super) fn timer_elapsed_ms(_t: ()) -> f64 {
27    0.0
28}
29
30use brepkit_math::vec::{Point3, Vec3};
31use brepkit_topology::Topology;
32use brepkit_topology::edge::EdgeCurve;
33use brepkit_topology::face::{FaceId, FaceSurface};
34use brepkit_topology::solid::SolidId;
35
36static MESH_FALLBACK_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
37
38/// Number of boolean operations that have used the mesh (co-refinement)
39/// fallback since process start.
40///
41/// The fallback loses analytic surface types and does not guarantee a
42/// watertight result, so callers that require exact geometry (export
43/// pipelines in particular) can snapshot this counter around an operation
44/// chain and refuse the output when it grew.
45pub fn mesh_fallback_count() -> u64 {
46    MESH_FALLBACK_COUNT.load(std::sync::atomic::Ordering::Relaxed)
47}
48
49thread_local! {
50    /// Whether the innermost `boolean_inner` call routed through the mesh
51    /// fallback. Set at the fallback site, consumed by the callers that
52    /// decide whether the result is DELIVERED (counted) or DISCARDED (a
53    /// batching probe in `fuse_cluster`) — the public counter only records
54    /// fallbacks whose output actually reaches a caller, keeping its
55    /// monotonic snapshot-and-diff contract exact (#1445).
56    static LAST_USED_MESH_FALLBACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
57}
58
59/// Perform a boolean operation on two solids.
60///
61/// Uses the GFA pipeline as the primary engine, with mesh boolean
62/// (co-refinement) as a fallback when GFA fails or produces invalid results.
63///
64/// # Errors
65///
66/// Returns an error if either solid is invalid or the operation produces
67/// an empty or non-manifold result.
68pub fn boolean(
69    topo: &mut Topology,
70    op: BooleanOp,
71    a: SolidId,
72    b: SolidId,
73) -> Result<SolidId, crate::OperationsError> {
74    let result = boolean_inner(topo, op, a, b);
75    if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
76        MESH_FALLBACK_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
77    }
78    result
79}
80
81#[allow(clippy::too_many_lines)]
82fn boolean_inner(
83    topo: &mut Topology,
84    op: BooleanOp,
85    a: SolidId,
86    b: SolidId,
87) -> Result<SolidId, crate::OperationsError> {
88    LAST_USED_MESH_FALLBACK.with(|f| f.set(false));
89    let tol = brepkit_math::tolerance::Tolerance::new();
90
91    // Detect A⊂B or B⊂A (including A=B) and handle directly.
92    // Only applies when BOTH solids have simple analytic classifiers.
93    {
94        use brepkit_algo::classifier::try_build_analytic_classifier;
95        let ca = try_build_analytic_classifier(topo, a);
96        let cb = try_build_analytic_classifier(topo, b);
97        let TrivialRelation {
98            identical,
99            a_in_b,
100            b_in_a,
101        } = detect_trivial_relation(topo, a, b, ca.as_ref(), cb.as_ref(), tol);
102
103        // Identical-solid shortcut: matching AABBs AND every boundary
104        // vertex of each solid classifies as inside-or-on the other's
105        // analytic classifier. Stronger than a center test (a cube
106        // inscribed in a sphere has matching AABBs but cube corners fall
107        // outside the sphere) and works for non-convex solids like tori.
108        if identical {
109            return match op {
110                BooleanOp::Fuse | BooleanOp::Intersect => Ok(crate::copy::copy_solid(topo, a)?),
111                BooleanOp::Cut => Err(crate::OperationsError::EmptyResult {
112                    reason: "Cut of identical solids".into(),
113                }),
114            };
115        }
116        // Containment shortcuts:
117        // - Fuse/Intersect with either containment direction: copy the
118        //   appropriate solid.
119        // - Cut with A ⊆ B: result is empty (A is fully removed). Return
120        //   EmptyResult explicitly — without this short-circuit, GFA falls
121        //   back to producing a degenerate vol=0 solid that callers
122        //   mistake for a real result, breaking volume invariants like
123        //   `vol((A-B) ∪ (A∩B)) = vol(A)`.
124        // - Cut with B ⊂ A: defer to GFA (produces hollow solid).
125        if op == BooleanOp::Cut && a_in_b && !b_in_a {
126            return Err(crate::OperationsError::EmptyResult {
127                reason: "Cut with target fully contained in tool".into(),
128            });
129        }
130        // Cut with the tool strictly inside the blank: build the hollow result
131        // (blank + a reversed copy of the tool as a cavity shell) directly.
132        // GFA's no-intersection assembly drops fully-contained cone/torus
133        // tools; the cavity is exactly the tool's reversed shell, so construct
134        // it here for any simple tool whose vertices are all strictly inside.
135        if op == BooleanOp::Cut
136            && b_in_a
137            && !a_in_b
138            && let Some(classifier) = ca.as_ref()
139        {
140            let tool_simple = topo
141                .solid(b)
142                .map(|s| s.inner_shells().is_empty())
143                .unwrap_or(false);
144            if tool_simple
145                && solid_strictly_inside(topo, b, classifier, tol)
146                && let Ok(result) = build_contained_cut_hollow(topo, a, b)
147                && validate_boolean_result(topo, result).is_ok()
148            {
149                return Ok(result);
150            }
151        }
152        if (b_in_a || a_in_b) && op != BooleanOp::Cut {
153            return match (op, b_in_a, a_in_b) {
154                (BooleanOp::Fuse, true, _) => Ok(crate::copy::copy_solid(topo, a)?),
155                (BooleanOp::Fuse, _, true) => Ok(crate::copy::copy_solid(topo, b)?),
156                (BooleanOp::Intersect, true, _) => Ok(crate::copy::copy_solid(topo, b)?),
157                (BooleanOp::Intersect, _, true) => Ok(crate::copy::copy_solid(topo, a)?),
158                _ => Err(crate::OperationsError::InvalidInput {
159                    reason: "containment shortcut: unexpected state".into(),
160                }),
161            };
162        }
163
164        // Coaxial-cylinder merge shortcut: when both A and B are simple
165        // cylinder solids (cylinder + 2 planar caps) with the same axis,
166        // origin and radius, fuse/intersect collapse to a single cylinder
167        // spanning the combined / overlapping axial range. Bypasses GFA's
168        // cap-on-cap and lateral-SD coplanar handling, which currently
169        // falls through to a non-manifold mesh fallback.
170        if let (
171            Some(brepkit_algo::classifier::AnalyticClassifier::Cylinder {
172                origin: oa,
173                axis: aa,
174                radius: ra,
175                z_min: za_min,
176                z_max: za_max,
177            }),
178            Some(brepkit_algo::classifier::AnalyticClassifier::Cylinder {
179                origin: ob,
180                axis: ab,
181                radius: rb,
182                z_min: zb_min,
183                z_max: zb_max,
184            }),
185        ) = (ca.as_ref(), cb.as_ref())
186        {
187            // Axes coincide (same line) when directions are parallel AND
188            // the origin offset is parallel to the axis (no perpendicular
189            // component beyond linear tolerance).
190            let same_axis_dir = aa.dot(*ab) > 1.0 - tol.angular;
191            let origin_offset = *ob - *oa;
192            let along_axis = origin_offset.dot(*aa);
193            let perpendicular = origin_offset - *aa * along_axis;
194            let coaxial = same_axis_dir && perpendicular.length() < tol.linear;
195            let same_radius = (ra - rb).abs() < tol.linear;
196            if coaxial && same_radius {
197                // Translate B's z-range into A's axis frame.
198                let za = (*za_min, *za_max);
199                let zb = (*zb_min + along_axis, *zb_max + along_axis);
200                if let Some(result) =
201                    coaxial_cylinder_shortcut(topo, op, *oa, *aa, *ra, za, zb, tol)?
202                {
203                    return Ok(result);
204                }
205            }
206        }
207
208        // Coaxial-cone merge shortcut: two frustums on the same conical
209        // surface (shared apex, axis, and tan(half_angle) = r/z ratio)
210        // collapse to a single frustum spanning the combined axial range.
211        if let (
212            Some(brepkit_algo::classifier::AnalyticClassifier::Cone {
213                origin: oa,
214                axis: aa,
215                z_min: za_min,
216                z_max: za_max,
217                r_at_z_min: rmin_a,
218                r_at_z_max: rmax_a,
219            }),
220            Some(brepkit_algo::classifier::AnalyticClassifier::Cone {
221                origin: ob,
222                axis: ab,
223                z_min: zb_min,
224                z_max: zb_max,
225                r_at_z_min: rmin_b,
226                r_at_z_max: rmax_b,
227            }),
228        ) = (ca.as_ref(), cb.as_ref())
229        {
230            let same_axis_dir = aa.dot(*ab) > 1.0 - tol.angular;
231            let same_apex = (*oa - *ob).length() < tol.linear;
232            // Half-angle slope: dimensionless r/z. Use whichever endpoint has
233            // |z| above tol.linear (compared against tol.linear because slope
234            // is a length ratio, not an angle — `tol.angular` is a radian
235            // threshold, wrong unit). When both endpoints of a frustum are
236            // sub-tol (degenerate apex-pinned cone), skip the shortcut and
237            // let GFA handle it rather than dividing by near-zero.
238            let slope_a = if za_max.abs() > tol.linear {
239                Some(rmax_a / *za_max)
240            } else if za_min.abs() > tol.linear {
241                Some(rmin_a / *za_min)
242            } else {
243                None
244            };
245            let slope_b = if zb_max.abs() > tol.linear {
246                Some(rmax_b / *zb_max)
247            } else if zb_min.abs() > tol.linear {
248                Some(rmin_b / *zb_min)
249            } else {
250                None
251            };
252            let same_half_angle = match (slope_a, slope_b) {
253                (Some(sa), Some(sb)) => (sa - sb).abs() < tol.linear,
254                _ => false,
255            };
256            if let (true, Some(slope)) = (same_axis_dir && same_apex && same_half_angle, slope_a)
257                && let Some(result) = coaxial_cone_shortcut(
258                    topo,
259                    op,
260                    *oa,
261                    *aa,
262                    slope,
263                    (*za_min, *za_max),
264                    (*zb_min, *zb_max),
265                    tol,
266                )?
267            {
268                return Ok(result);
269            }
270        }
271
272        // Axis-aligned box-pair shortcut: when both A and B classify as
273        // Box (analytic classifier infers axis-aligned bounds), Fuse and
274        // Intersect can be computed exactly via AABB algebra. Bypasses
275        // GFA so chained operations get clean fresh-primitive topology
276        // rather than residual GFA splits that confuse subsequent steps.
277        if let (
278            Some(brepkit_algo::classifier::AnalyticClassifier::Box {
279                min: a_min,
280                max: a_max,
281            }),
282            Some(brepkit_algo::classifier::AnalyticClassifier::Box {
283                min: b_min,
284                max: b_max,
285            }),
286        ) = (ca.as_ref(), cb.as_ref())
287            && let Some(result) = box_pair_shortcut(topo, op, *a_min, *a_max, *b_min, *b_max, tol)?
288        {
289            return Ok(result);
290        }
291
292        // Box-sphere intersect shortcut: when one input classifies as an
293        // axis-aligned `Box` and the other as a `Sphere`, the Intersect
294        // result has a closed analytic form in two common cases:
295        //   - sphere fully inside box → result is a copy of the sphere
296        //   - exactly 3 of the 6 box planes cut the sphere (their meeting
297        //     corner sits at or inside the sphere) → spherical "octant"
298        //     bounded by 3 quarter-disc box sub-faces + 1 spherical patch
299        // Other configurations fall through to GFA.
300        //
301        // Cut/Fuse aren't covered here yet — they need outer/inner shell
302        // construction (Cut: box with spherical hole) or full periodic-
303        // sphere handling (Fuse: box with spherical bulge), both larger
304        // than this shortcut warrants.
305        if op == BooleanOp::Intersect {
306            let (box_args, sphere_args) = match (ca.as_ref(), cb.as_ref()) {
307                (
308                    Some(brepkit_algo::classifier::AnalyticClassifier::Box {
309                        min: bmin,
310                        max: bmax,
311                    }),
312                    Some(brepkit_algo::classifier::AnalyticClassifier::Sphere { center, radius }),
313                ) => (Some((*bmin, *bmax)), Some((*center, *radius))),
314                (
315                    Some(brepkit_algo::classifier::AnalyticClassifier::Sphere { center, radius }),
316                    Some(brepkit_algo::classifier::AnalyticClassifier::Box {
317                        min: bmin,
318                        max: bmax,
319                    }),
320                ) => (Some((*bmin, *bmax)), Some((*center, *radius))),
321                _ => (None, None),
322            };
323            if let (Some((bmin, bmax)), Some((sc, sr))) = (box_args, sphere_args) {
324                let segs = brepkit_topology::explorer::solid_vertices(topo, a)
325                    .map(|v| v.len())
326                    .unwrap_or(0)
327                    .max(
328                        brepkit_topology::explorer::solid_vertices(topo, b)
329                            .map(|v| v.len())
330                            .unwrap_or(0),
331                    )
332                    .max(16);
333                if let Some(result) =
334                    box_sphere_intersect_shortcut(topo, bmin, bmax, sc, sr, segs, tol)?
335                {
336                    return Ok(result);
337                }
338            }
339        }
340
341        // Concentric-sphere merge shortcut: when both A and B classify as
342        // Sphere with coincident centers, Fuse and Intersect collapse to a
343        // single sphere by radius algebra. Bypasses GFA's coplanar-pole
344        // handling (which currently routes spheres through the same SD
345        // pipeline that flakes on coaxial cylinders pre-#541).
346        //
347        // Cut intentionally falls through to GFA: subtracting an inner
348        // sphere from an outer one yields a hollow ball, whose topology
349        // (outer shell + inner shell) requires builder support beyond the
350        // single-sphere primitive used here.
351        if let (
352            Some(brepkit_algo::classifier::AnalyticClassifier::Sphere {
353                center: ca_center,
354                radius: ra,
355            }),
356            Some(brepkit_algo::classifier::AnalyticClassifier::Sphere {
357                center: cb_center,
358                radius: rb,
359            }),
360        ) = (ca.as_ref(), cb.as_ref())
361        {
362            let coincident = (*ca_center - *cb_center).length() < tol.linear;
363            if coincident
364                && let Some(result) =
365                    concentric_sphere_shortcut(topo, op, a, b, *ca_center, *ra, *rb, tol)?
366            {
367                return Ok(result);
368            }
369        }
370
371        // Coaxial-torus merge shortcut: when both A and B classify as Torus
372        // with the same center, axis (parallel/antiparallel), and major
373        // radius, Fuse and Intersect collapse to a single torus by minor
374        // radius algebra. Same family as the concentric-sphere shortcut
375        // above; sidesteps GFA's torus same-domain handling for the
376        // common shared-major case.
377        if let (
378            Some(brepkit_algo::classifier::AnalyticClassifier::Torus {
379                center: ca_center,
380                axis: aa,
381                major_radius: maj_a,
382                minor_radius: min_a,
383            }),
384            Some(brepkit_algo::classifier::AnalyticClassifier::Torus {
385                center: cb_center,
386                axis: ab,
387                major_radius: maj_b,
388                minor_radius: min_b,
389            }),
390        ) = (ca.as_ref(), cb.as_ref())
391        {
392            let coincident = (*ca_center - *cb_center).length() < tol.linear;
393            // Allow either axis orientation — a torus with axis +z is the
394            // same surface as the same torus with axis -z (the small-circle
395            // sweep is symmetric about the central plane).
396            let coaxial = aa.dot(*ab).abs() > 1.0 - tol.angular;
397            let same_major = (maj_a - maj_b).abs() < tol.linear;
398            if coincident
399                && coaxial
400                && same_major
401                && let Some(result) = coaxial_torus_shortcut(
402                    topo, op, a, b, *ca_center, *aa, *maj_a, *min_a, *min_b, tol,
403                )?
404            {
405                return Ok(result);
406            }
407        }
408    }
409
410    // If the curvature-aware AABBs of A and B are separated on any axis
411    // by more than linear tolerance, the solids provably do not overlap
412    // and their intersection is the empty set. Containment shortcuts have
413    // already run above (a contained solid has overlapping, not separated,
414    // AABBs), so reaching here with separated boxes is an exact witness.
415    // The boxes are conservative outer bounds, so box non-overlap implies
416    // solid non-overlap. Symmetric in A and B by construction.
417    if op == BooleanOp::Intersect {
418        let bb_a = crate::measure::solid_bounding_box(topo, a).ok();
419        let bb_b = crate::measure::solid_bounding_box(topo, b).ok();
420        if let Some((a_box, b_box)) = bb_a.zip(bb_b)
421            && aabbs_separated(&a_box, &b_box, tol.linear)
422        {
423            return Ok(topo.add_empty_solid());
424        }
425    }
426
427    // Disjoint-fuse fast path: when A and B are provably spatially disjoint,
428    // their union is a multi-region solid — the same result GFA produces for
429    // disjoint inputs, but built by a cheap shell merge instead of the full
430    // pavefiller/assembly pipeline. This is what makes a pairwise-accumulate
431    // loop over many disjoint pieces (e.g. one tapered foot per gridfinity
432    // cell) scale linearly: each fuse onto the growing accumulator short-
433    // circuits here.
434    //
435    // Disjointness is decided per connected component (not per whole-solid
436    // bbox): the accumulator spans many pieces, so its overall box overlaps
437    // the next piece's box even when no piece actually touches. Component
438    // boxes are conservative outer bounds, and the gap test uses a positive
439    // tolerance margin, so the path only fires on a clear gap — touching or
440    // overlapping operands fall through to GFA, which welds the shared
441    // geometry. The result is independent of the inputs (each operand is
442    // deep-copied before merging), preserving the boolean contract.
443    if op == BooleanOp::Fuse && solids_provably_disjoint(topo, a, b, tol.linear) {
444        let copy_a = crate::copy::copy_solid(topo, a)?;
445        let copy_b = crate::copy::copy_solid(topo, b)?;
446        let merged = crate::compound_ops::merge_disjoint_solids(topo, &[copy_a, copy_b])?;
447        log::debug!("Fuse short-circuited via disjoint shell merge");
448        return Ok(merged);
449    }
450
451    // Disjoint-cut fast path: a tool with a clear gap from every component of
452    // the target removes nothing, so A − B is exactly A. Same disjointness
453    // witness as the fuse path above (per-component conservative boxes, strict
454    // positive gap), so a touching or overlapping tool still routes to GFA.
455    // A tool floating inside the target can never reach here: its boxes nest
456    // inside the target's, which is overlap, not separation. The copy keeps
457    // the result independent of the inputs, preserving the boolean contract.
458    if op == BooleanOp::Cut && solids_provably_disjoint(topo, a, b, tol.linear) {
459        let copy_a = crate::copy::copy_solid(topo, a)?;
460        log::debug!("Cut short-circuited: disjoint tool removes nothing");
461        return Ok(copy_a);
462    }
463
464    let algo_op = match op {
465        BooleanOp::Fuse => brepkit_algo::bop::BooleanOp::Fuse,
466        BooleanOp::Cut => brepkit_algo::bop::BooleanOp::Cut,
467        BooleanOp::Intersect => brepkit_algo::bop::BooleanOp::Intersect,
468    };
469    // Recognise flat NURBS walls/edges as analytic planes/lines so the engine's
470    // face-face intersections take the exact plane×plane path (the tool's
471    // rounded-rect extrude emits straight cavity walls as planar B-splines).
472    // Only an operand that actually carries flattenable NURBS is deep-copied
473    // and rewritten; operands without any (the common case — primitives and
474    // already-analytic solids) are passed through unchanged. This matters for
475    // correctness, not just speed: the engine's downstream ordering is keyed on
476    // entity ids, so needlessly deep-copying an operand (which renumbers its
477    // ids) can perturb volume-sensitive cut/fuse results.
478    let gfa_a = if solid_has_flattenable_nurbs(topo, a, tol.linear)? {
479        let copy_a = crate::copy::copy_solid(topo, a)?;
480        let _ = flatten_planar_nurbs_faces(topo, copy_a, tol.linear)?;
481        copy_a
482    } else {
483        a
484    };
485    let gfa_b = if solid_has_flattenable_nurbs(topo, b, tol.linear)? {
486        let copy_b = crate::copy::copy_solid(topo, b)?;
487        let _ = flatten_planar_nurbs_faces(topo, copy_b, tol.linear)?;
488        copy_b
489    } else {
490        b
491    };
492    let gfa_start = timer_now();
493    match brepkit_algo::gfa::boolean(topo, algo_op, gfa_a, gfa_b) {
494        Ok(result) => {
495            let result_faces = brepkit_topology::explorer::solid_faces(topo, result)
496                .map(|f| f.len())
497                .unwrap_or(0);
498            // Narrow-phase empty intersect: overlapping AABBs but the engine
499            // selected no faces for the common region (e.g. boxes whose boxes
500            // overlap by tolerance but whose interiors do not). This is the
501            // authoritative witness of an empty intersection.
502            if op == BooleanOp::Intersect && result_faces == 0 {
503                log::info!(
504                    "GFA intersect empty in {:.1}ms (no common faces)",
505                    timer_elapsed_ms(gfa_start)
506                );
507                return Ok(topo.add_empty_solid());
508            }
509            if result_faces > 0 {
510                let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
511                // Strip out-and-back wire spurs left by the GFA wire builder on
512                // U-shaped (single-opening-notch) faces — they over-connect the
513                // opening edge and inflate volume (issue #801 slot fuse).
514                let _ = crate::heal::remove_wire_spurs(topo, result)?;
515                // A coincident-junction fuse can leave duplicate junction-wire
516                // edges (one per argument) that differ by sub-micron loft noise
517                // → free edges. Merge those coincident duplicates. Gated on the
518                // shell actually being open so clean results keep exact topology.
519                if has_free_edges(topo, result).unwrap_or(false) {
520                    // Best-effort: an error here shouldn't abort the boolean,
521                    // but it's useful signal on an already-broken shell.
522                    if let Err(e) =
523                        unify_coincident_boundary_edges(topo, result, (tol.linear * 10.0).max(1e-6))
524                    {
525                        log::debug!("unify_coincident_boundary_edges failed: {e}");
526                    }
527                }
528                // Check Euler before unify_faces — if already valid, skip
529                // unify to avoid its face-merging bugs (non-manifold edges).
530                let (f_pre, e_pre, v_pre) =
531                    brepkit_topology::explorer::solid_entity_counts(topo, result)?;
532                #[allow(clippy::cast_possible_wrap)]
533                let euler_pre = (v_pre as i64) - (e_pre as i64) + (f_pre as i64);
534
535                // If Euler>2, try merging duplicate vertices before unify.
536                // This fixes the flush-face case where duplicate vertices at
537                // cross-rank positions inflate V.
538                let merged_vertices = euler_pre > 2;
539                if merged_vertices {
540                    // Best-effort: don't abort on merge failure
541                    let _ = merge_result_vertices(topo, result, tol);
542                }
543
544                // Re-count only when the merge above ran; otherwise the counts
545                // are unchanged from the pre-merge measurement (the merge is the
546                // only mutation in between).
547                let (f2, e2, v2) = if merged_vertices {
548                    brepkit_topology::explorer::solid_entity_counts(topo, result)?
549                } else {
550                    (f_pre, e_pre, v_pre)
551                };
552                #[allow(clippy::cast_possible_wrap)]
553                let euler_pre2 = (v2 as i64) - (e2 as i64) + (f2 as i64);
554
555                // Hollow results (a Cut whose tool sits strictly inside the
556                // target) arrive from GFA with the cavity assembled as inner
557                // shells. Each closed genus-0 cavity shell adds 2 to V-E+F,
558                // so the Euler acceptance below must compare against
559                // 2 + 2*K instead of 2. Entity counts above already include
560                // inner-shell entities via `solid_entity_counts`.
561                #[allow(clippy::cast_possible_wrap)]
562                let inner_shell_surplus = 2 * (topo.solid(result)?.inner_shells().len() as i64);
563
564                // Hole-aware Euler: a face with L inner wire loops raises V-E+F
565                // by L (Euler-Poincare: V-E+F-L = 2(1-g)), so a valid genus-0
566                // result with holed faces (e.g. a fuse leaving circular holes in
567                // box faces) has euler = 2 + L. Compute the inner-wire surplus
568                // once here so both the unify decision and the acceptance gate
569                // use the same hole-aware balance — otherwise a result that
570                // deviates from euler==2 solely because of inner wires would
571                // still trigger an unnecessary unify_faces pass.
572                let inner_wire_count_pre = solid_inner_wire_count(topo, result)?;
573                // Deliberately single-component: this only decides whether to
574                // run `unify_faces`, and that pass can mangle a legitimate
575                // N-piece result, so widening the bound here would change which
576                // multi-region results get unified — a separate question from
577                // acceptance, and one the calibrated foils cover.
578                let euler_balanced_pre = euler_pre2 - inner_shell_surplus == 2
579                    || euler_balanced(euler_pre2 - inner_shell_surplus, inner_wire_count_pre, 1);
580
581                // Run unify_faces if the (hole-aware) Euler is off OR if the
582                // topology has 3+-face junctions, which can occur with a
583                // balanced Euler when overlapping coplanar faces cancel in
584                // V-E+F counting. The same-domain detection in the assembler
585                // only pairs faces across opposing ranks with identical edge
586                // sets, so within-rank or different-boundary overlaps slip
587                // through; unify_faces is the safety net for those (issue #696).
588                // `is_closed_manifold` is a whole-solid walk. It is needed both
589                // here (to decide unify) and again after unify (the acceptance
590                // gate). Compute the pre-unify value at most once, and reuse it
591                // for the gate when unify changes nothing. It is only evaluated
592                // when `euler_balanced_pre` holds (otherwise `||` short-circuits
593                // and `needs_unify` is already true).
594                let manifold_pre = if euler_balanced_pre {
595                    Some(is_closed_manifold(topo, result)?)
596                } else {
597                    None
598                };
599                // Multi-component operands (e.g. the lite base's 16 disjoint
600                // feet before their web joins them) balance at 2*N, which the
601                // single-component check above can never see — without this,
602                // `unify_faces` runs on a perfectly clean N-piece result and
603                // its edits break the manifold it was meant to repair.
604                let (multi_balanced_pre, manifold_pre) = if euler_balanced_pre {
605                    (false, manifold_pre)
606                } else {
607                    let comps = crate::boolean::assembly::face_components(topo, result);
608                    #[allow(clippy::cast_possible_wrap)]
609                    let expected = (comps.len() as i64) * 2;
610                    if comps.len() >= 2
611                        && euler_pre2 - inner_shell_surplus - inner_wire_count_pre == expected
612                        && components_are_disjoint_pieces(topo, &comps)
613                    {
614                        let m = is_closed_manifold(topo, result)?;
615                        (m, Some(m))
616                    } else {
617                        (false, None)
618                    }
619                };
620                let needs_unify =
621                    !(euler_balanced_pre || multi_balanced_pre) || manifold_pre == Some(false);
622                let mut unified = false;
623                if needs_unify {
624                    for _ in 0..3 {
625                        if crate::heal::unify_faces(topo, result)? == 0 {
626                            break;
627                        }
628                        unified = true;
629                    }
630                }
631                // Re-count only when unify actually merged faces; otherwise the
632                // counts are unchanged from the (post-merge) measurement above.
633                let (f, e, v) = if unified {
634                    brepkit_topology::explorer::solid_entity_counts(topo, result)?
635                } else {
636                    (f2, e2, v2)
637                };
638                #[allow(clippy::cast_possible_wrap)]
639                let euler = (v as i64) - (e as i64) + (f as i64);
640                // Free edges in an Intersect result mean faces were dropped
641                // (e.g. a tolerance-thin sliver kept only some of its
642                // bounding faces) — reject even when Euler accidentally
643                // balances. Cut and Fuse keep the legacy lenient gate: some
644                // coplanar cut/fuse results carry boundary edges yet are
645                // still the best available output (the mesh fallback loses
646                // more volume than the open GFA shell does).
647                let open_shell_ok = op != BooleanOp::Intersect || !has_free_edges(topo, result)?;
648                // Hole-aware Euler acceptance: re-measure the inner-wire surplus
649                // after unify (which can merge faces and change wire counts) and
650                // accept euler - L == 2 - 2g for genus g >= 0. The holed/genus
651                // acceptance additionally requires a closed manifold so that
652                // accidental cancellations (open shells whose missing faces
653                // offset the inner-wire surplus) still fail safe to the mesh
654                // fallback. Reuse the pre-unify count when unify made no change.
655                let inner_wire_count = if unified {
656                    solid_inner_wire_count(topo, result)?
657                } else {
658                    inner_wire_count_pre
659                };
660                // `is_closed_manifold` walks every face/edge of the result; the
661                // hollow gate, the genus-acceptance gate, and the multi-region
662                // gate below all need it on the same (post-unify) topology, so
663                // compute it once. Reuse the pre-unify value when it was already
664                // computed AND unify changed nothing — the only intervening
665                // mutation. Propagating a topology-query error with `?` here is
666                // equivalent to the old multi-region `unwrap_or(false)`: that
667                // call ran on this same solid, so an error would have surfaced
668                // at the hollow gate (reached first) regardless.
669                let closed_manifold = match manifold_pre {
670                    Some(m) if !unified => m,
671                    _ => is_closed_manifold(topo, result)?,
672                };
673                // A hollow result must additionally have every shell closed:
674                // a missing cavity face could otherwise cancel against the
675                // inner-shell surplus and balance Euler by accident.
676                let hollow_ok = inner_shell_surplus == 0 || closed_manifold;
677                let euler_eff = euler - inner_shell_surplus;
678                let euler_ok = hollow_ok
679                    && (euler_eff == 2
680                        || (euler_balanced(euler_eff, inner_wire_count, 1) && closed_manifold));
681                if euler_ok && open_shell_ok && validate_boolean_result(topo, result).is_ok() {
682                    log::info!(
683                        "GFA boolean succeeded in {:.1}ms ({result_faces} faces)",
684                        timer_elapsed_ms(gfa_start)
685                    );
686                    return Ok(result);
687                }
688                // Multi-region manifold result (e.g., a Cut that splits a
689                // solid into N spatially-disjoint pieces). N independently
690                // closed manifolds have combined Euler = 2*N. Falling back
691                // to mesh boolean would collapse the disjoint pieces into
692                // a single region's volume (the `cut with simplify`
693                // returning vol 166 instead of 1000 symptom).
694                //
695                // Gate: every edge must be shared by exactly 2 faces
696                // (closed-manifold) AND the components must be pairwise
697                // spatially disjoint (AABBs do not overlap). The latter
698                // distinguishes a "cut into N pieces" from a hollow solid
699                // (outer surface + cavity surface — same number of
700                // components, same Euler relation, but AABBs overlap).
701                // N closed manifolds satisfy `V - E + F - inner_wires =
702                // 2 * (N - genus)`, which this gate pins at the genus-0 case
703                // `... = 2 * N` (as it always has — a handled piece is left to
704                // the mesh fallback). The hole term, however, is NOT optional:
705                // a piece carrying a blind pocket (a face with an inner wire)
706                // shifts raw Euler away from 2*N even at genus 0, so comparing
707                // raw Euler here rejected every pocketed piece. This mirrors the
708                // `euler_balanced` correction the single-component gate above
709                // applies — which is why the bound below is `2 * components`
710                // rather than an equality against it.
711                let components_vec = crate::boolean::assembly::face_components(topo, result);
712                let components = components_vec.len();
713                // For Cut, also verify no component is a "B-interior piece" —
714                // GFA can produce N closed manifolds where one of them is the
715                // tool's interior (sphere - cylinder example: 3 pieces =
716                // top cap + bottom cap + cylinder interior). Sample a point
717                // inside each component's AABB and classify against B; if any
718                // sits inside B, the GFA result included the cut-out piece
719                // and should be rejected. Fuse/Intersect don't have this
720                // failure mode.
721                let cut_safe = op != BooleanOp::Cut
722                    || brepkit_algo::classifier::try_build_analytic_classifier(topo, b)
723                        .as_ref()
724                        .is_none_or(|cls_b| {
725                            all_component_centers_outside(topo, &components_vec, cls_b, tol)
726                        });
727                // Intersect's mirror hazard: GFA could emit a piece that is not
728                // part of A∩B at all. Reject when any component's AABB-centre
729                // sample classifies OUTSIDE either operand — an intersection
730                // piece must lie inside both. The winding-number classifier
731                // (unlike the analytic one) handles multi-piece operands, the
732                // very case this acceptance exists for; a classification error
733                // rejects (this acceptance is purely an optimization, so
734                // unclassifiable geometry keeps the old fallback behaviour).
735                // `OnBoundary` passes — thin clip pieces legitimately touch
736                // the operand boundaries. The centre need not be interior to a
737                // concave piece, but that failure direction only REJECTS a
738                // valid result into the mesh fallback (the status quo), the
739                // same posture `cut_safe` already accepts.
740                let intersect_safe = op != BooleanOp::Intersect
741                    || components_vec.iter().all(|comp| {
742                        let Some(centre) = component_aabb_centre(topo, comp) else {
743                            return true;
744                        };
745                        [a, b].iter().all(|&operand| {
746                            !matches!(
747                                crate::classify::classify_point_robust(
748                                    topo, operand, centre, 0.1, tol.linear,
749                                ),
750                                Ok(crate::classify::PointClassification::Outside) | Err(_)
751                            )
752                        })
753                    });
754                // Fuse shares this gate: fusing a tool into ONE piece of a
755                // multi-component operand (the lite base's 16 disjoint feet
756                // before their web joins them) legitimately leaves N disjoint
757                // closed manifolds, which the single-component Euler gate above
758                // can never accept. The same conditions apply; `cut_safe`'s
759                // B-interior probe is Cut-specific and passes vacuously here.
760                // Intersect joins for the same reason: clipping a multi-piece
761                // operand (the lite void against a divider-column prism)
762                // legitimately yields N disjoint chunks.
763                if matches!(op, BooleanOp::Cut | BooleanOp::Fuse | BooleanOp::Intersect)
764                    && components >= 2
765                    && euler_balanced(euler, inner_wire_count, i64::try_from(components).unwrap_or(i64::MAX))
766                    && components_are_disjoint_pieces(topo, &components_vec)
767                    && cut_safe
768                    && intersect_safe
769                    // Reuse the `closed_manifold` computed above: nothing between
770                    // it and here mutates the result (only read-only component
771                    // and classifier queries run in between).
772                    && closed_manifold
773                    && validate_boolean_result(topo, result).is_ok()
774                {
775                    log::info!(
776                        "GFA multi-region succeeded in {:.1}ms ({result_faces} faces, {components} pieces)",
777                        timer_elapsed_ms(gfa_start)
778                    );
779                    return Ok(result);
780                }
781                // Which gate refused? Both acceptance paths are conjunctions,
782                // so the bare rejection below says nothing about the cause —
783                // and when `validate` is None the result is topologically fine
784                // and something else declined it.
785                log::debug!(
786                    "GFA reject detail {op:?}: euler={euler} euler_eff={euler_eff} \
787                     inner_wires={inner_wire_count} inner_shell_surplus={inner_shell_surplus} \
788                     euler_ok={euler_ok} open_shell_ok={open_shell_ok} \
789                     closed_manifold={closed_manifold} components={components} \
790                     cut_safe={cut_safe} intersect_safe={intersect_safe} \
791                     euler_multi_ok={} surplus={} bound={} disjoint={}",
792                    euler_balanced(
793                        euler,
794                        inner_wire_count,
795                        i64::try_from(components).unwrap_or(i64::MAX)
796                    ),
797                    euler - inner_wire_count,
798                    i64::try_from(components)
799                        .unwrap_or(i64::MAX)
800                        .saturating_mul(2),
801                    components_are_disjoint_pieces(topo, &components_vec)
802                );
803            }
804            log::warn!(
805                "GFA result not accepted in {:.1}ms (faces={result_faces}, \
806                 validate={:?}), falling back",
807                timer_elapsed_ms(gfa_start),
808                validate_boolean_result(topo, result).err()
809            );
810        }
811        Err(e) => {
812            log::warn!(
813                "GFA boolean failed in {:.1}ms ({e}), falling back",
814                timer_elapsed_ms(gfa_start)
815            );
816        }
817    }
818
819    // When the input solid carries multiple disjoint pieces (a previous
820    // cut split a solid into N parts), GFA's pavefiller can't process
821    // them together — feeding the whole thing in loses regions. Splitting
822    // into per-component cuts and recombining preserves the missing
823    // pieces. Cut distributes over disjoint union; Fuse/Intersect have
824    // more complex interaction semantics so we leave those to mesh.
825    if op == BooleanOp::Cut {
826        let components = crate::boolean::assembly::face_components(topo, a);
827        if components.len() >= 2
828            && components_are_disjoint_pieces(topo, &components)
829            && let Ok(result) = cut_multi_region_input(topo, a, b, components.len())
830        {
831            return Ok(result);
832        }
833    }
834
835    // A Fuse whose TOOL carries many disjoint pieces (the lite base's 64
836    // magnet pads arrive as one 64-component union) also defeats the
837    // pavefiller when fed whole. Fuse distributes over a disjoint-union
838    // tool, so fold the pieces in one at a time — each per-piece fuse is
839    // the configuration the engine handles analytically.
840    // Gated to tools WITHOUT inner (cavity) shells: `face_components` walks
841    // the outer shell only, so a hollow piece would silently lose its cavity.
842    if op == BooleanOp::Fuse && topo.solid(b).is_ok_and(|s| s.inner_shells().is_empty()) {
843        let tool_components = crate::boolean::assembly::face_components(topo, b);
844        if tool_components.len() >= 2
845            && components_are_disjoint_pieces(topo, &tool_components)
846            && let Ok(result) = fuse_multi_component_tool(topo, a, tool_components)
847        {
848            return Ok(result);
849        }
850    }
851
852    // Mesh boolean fallback (no recursion).
853    log::debug!(
854        target: "brepkit_approx",
855        "boolean {op:?}: GFA unusable — using mesh (co-refinement) fallback; analytic surface types will be lost"
856    );
857    LAST_USED_MESH_FALLBACK.with(|f| f.set(true));
858    let opts = BooleanOptions::default();
859    let raw = match mesh_boolean_fallback(topo, op, a, b, opts.deflection, tol, &opts) {
860        Ok(raw) => raw,
861        // An empty mesh-boolean output for an intersect means the common
862        // region is empty — return the empty-result sentinel rather than
863        // surfacing the empty set as an error.
864        Err(crate::OperationsError::EmptyResult { .. }) if op == BooleanOp::Intersect => {
865            return Ok(topo.add_empty_solid());
866        }
867        Err(e) => return Err(e),
868    };
869    let result = crate::copy::copy_solid(topo, raw)?;
870    let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
871    for _ in 0..3 {
872        if crate::heal::unify_faces(topo, result)? == 0 {
873            break;
874        }
875    }
876    Ok(enforce_manifold_shell(topo, result).unwrap_or(result))
877}
878
879/// Perform a boolean operation with custom options.
880///
881/// Runs the standard GFA boolean pipeline, then applies post-processing
882/// options. Currently supported: `unify_faces` (merges co-surface face
883/// fragments via `brepkit_heal::unify_same_domain`).
884///
885/// # Errors
886///
887/// Returns the same errors as [`boolean`].
888pub fn boolean_with_options(
889    topo: &mut Topology,
890    op: BooleanOp,
891    a: SolidId,
892    b: SolidId,
893    opts: BooleanOptions,
894) -> Result<SolidId, crate::OperationsError> {
895    let result = boolean(topo, op, a, b)?;
896    if opts.unify_faces {
897        let unify_opts = brepkit_heal::upgrade::unify_same_domain::UnifyOptions::default();
898        if let Err(e) =
899            brepkit_heal::upgrade::unify_same_domain::unify_same_domain(topo, result, &unify_opts)
900        {
901            log::debug!("boolean unify_faces post-processing failed: {e}");
902        }
903    }
904    Ok(result)
905}
906
907/// Sequential compound cut via GFA.
908///
909/// Cuts the `target` solid by each tool in order using sequential
910/// `boolean(Cut)` calls.
911///
912/// # Errors
913///
914/// Returns an error if any individual cut fails.
915pub fn compound_cut(
916    topo: &mut Topology,
917    target: SolidId,
918    tools: &[SolidId],
919    opts: BooleanOptions,
920) -> Result<SolidId, crate::OperationsError> {
921    // Batched fast path: merge the tools into one multi-piece solid and cut
922    // ONCE. Sequential cutting re-runs the full boolean pipeline against the
923    // whole target per tool — O(target × tools); the lite magnet-drill pass
924    // was 8.4s sequential vs 0.75s batched for the exact same result volume.
925    // A ∖ (T₁ ∪ T₂ ∪ …) ≡ (A ∖ T₁) ∖ T₂ ∖ …, so the batch is semantically
926    // identical. Tools are first grouped into AABB-overlap clusters
927    // (union-find): tools in one cluster get a real fuse (the coaxial
928    // magnet+screw drill pair), while the pairwise-disjoint cluster
929    // representatives merge via the free disjoint-shell shortcut.
930    //
931    // A SINGLE cluster batches too. That case used to fall through to the
932    // sequential loop on the assumption that fusing one overlapping blob costs
933    // more than it saves, but a connected lattice of many small tools refutes
934    // it — fusing scales with the tools, while the sequential loop re-cuts the
935    // whole target once per tool, and the target only grows more fragmented.
936    // Measured on the kumiko wall lattice (180 strut prisms, one cluster):
937    // batching is comfortably faster for an identical result. Replay it with
938    // the captured operands under `kumiko-goma` in the parity-capture cache.
939    // Any failure falls back to the sequential loop.
940    let mut result = target;
941    let mut batched = false;
942    if tools.len() >= 2
943        && let Some(clusters) = cluster_tools_by_aabb(topo, tools)
944        && !clusters.is_empty()
945    {
946        // Tools that merely TOUCH (a plate's edge-tangent preview pockets)
947        // cluster together by AABB, and their union is genuinely non-manifold,
948        // so every pairwise fuse below "succeeds" through the mesh fallback —
949        // the batch then proceeds with a degraded all-planar tool and the cut
950        // grinds against it (11 s and lost cones on a 4x4 baseplate, #1488).
951        // A fallback-tainted merge is a FAILED merge for batching purposes:
952        // the sequential per-tool cuts are exact and never see the tangency.
953        // Taint propagates as Err from `fuse_cluster` and from the cross-
954        // cluster merge fuses below, so a discarded merge never touches the
955        // public fallback counter.
956        let merged = clusters.iter().try_fold(None::<SolidId>, |acc, cluster| {
957            let fused = fuse_cluster(topo, cluster)?;
958            match acc {
959                None => Ok(Some(fused)),
960                Some(prev) => {
961                    let m = boolean_inner(topo, BooleanOp::Fuse, prev, fused)?;
962                    if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
963                        return Err(crate::OperationsError::InvalidInput {
964                            reason: "cluster merge degraded to mesh fallback".to_string(),
965                        });
966                    }
967                    Ok(Some(m))
968                }
969            }
970        });
971        if let Ok(Some(tool)) = merged
972            && let Ok(cut) = boolean(topo, BooleanOp::Cut, target, tool)
973        {
974            result = cut;
975            batched = true;
976        } else {
977            log::debug!("compound_cut: batched tool path failed, using sequential cuts");
978        }
979    }
980    if !batched {
981        for &tool in tools {
982            result = boolean(topo, BooleanOp::Cut, result, tool)?;
983        }
984    }
985    if opts.unify_faces {
986        let unify_opts = brepkit_heal::upgrade::unify_same_domain::UnifyOptions::default();
987        if let Err(e) =
988            brepkit_heal::upgrade::unify_same_domain::unify_same_domain(topo, result, &unify_opts)
989        {
990            log::debug!("compound_cut unify_faces failed: {e}");
991        }
992    }
993    Ok(result)
994}
995
996/// Fuse one AABB-overlap cluster into a single solid.
997///
998/// For a cluster of 3+ interpenetrating/touching tools, tries the single-pass
999/// N-way GFA fuse (`brepkit_algo::gfa::fuse_n`) — one arrangement over all tools
1000/// instead of the sequential pairwise fuse's O(n²) re-processing of a growing
1001/// accumulator. Falls back to the sequential fuse when the N-way path errors
1002/// (e.g. a non-planar coincident contact it does not yet handle) or yields an
1003/// invalid result. Clusters of 1–2 tools go straight to the sequential path,
1004/// where the N-way arrangement has nothing to save. The cluster must be
1005/// non-empty.
1006pub(crate) fn fuse_cluster(
1007    topo: &mut Topology,
1008    cluster: &[SolidId],
1009) -> Result<SolidId, crate::OperationsError> {
1010    let Some((&first, rest)) = cluster.split_first() else {
1011        return Err(crate::OperationsError::InvalidInput {
1012            reason: "fuse_cluster requires a non-empty cluster".into(),
1013        });
1014    };
1015    if cluster.len() >= 3
1016        && let Ok(fused) = brepkit_algo::gfa::fuse_n(topo, cluster)
1017        && validate_boolean_result(topo, fused).is_ok()
1018    {
1019        return Ok(fused);
1020    }
1021    // A pairwise fuse that degrades to the mesh fallback poisons the whole
1022    // batch; bail at the first one instead of paying the fallback for every
1023    // remaining pair. `boolean_inner` + the taint flag keeps the discarded
1024    // probe out of the public fallback counter entirely.
1025    rest.iter().try_fold(first, |a, &t| {
1026        let fused = boolean_inner(topo, BooleanOp::Fuse, a, t)?;
1027        if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
1028            return Err(crate::OperationsError::InvalidInput {
1029                reason: "cluster fuse degraded to mesh fallback".to_string(),
1030            });
1031        }
1032        Ok(fused)
1033    })
1034}
1035
1036/// Group tools into AABB-overlap clusters (union-find over tolerance-
1037/// expanded boxes). Tools within a cluster may interpenetrate; distinct
1038/// clusters are pairwise disjoint. `None` when any AABB is unavailable.
1039fn cluster_tools_by_aabb(topo: &Topology, tools: &[SolidId]) -> Option<Vec<Vec<SolidId>>> {
1040    fn find(parent: &mut Vec<usize>, i: usize) -> usize {
1041        if parent[i] != i {
1042            let root = find(parent, parent[i]);
1043            parent[i] = root;
1044        }
1045        parent[i]
1046    }
1047    let tol = brepkit_math::tolerance::Tolerance::new().linear;
1048    let mut boxes = Vec::with_capacity(tools.len());
1049    for &t in tools {
1050        boxes.push(crate::measure::solid_bounding_box(topo, t).ok()?);
1051    }
1052    let mut parent: Vec<usize> = (0..tools.len()).collect();
1053    for i in 0..boxes.len() {
1054        for j in (i + 1)..boxes.len() {
1055            if boxes[i].expanded(tol).intersects(boxes[j]) {
1056                let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
1057                if ri != rj {
1058                    parent[ri] = rj;
1059                }
1060            }
1061        }
1062    }
1063    let mut clusters: std::collections::BTreeMap<usize, Vec<SolidId>> =
1064        std::collections::BTreeMap::new();
1065    for i in 0..tools.len() {
1066        let root = find(&mut parent, i);
1067        clusters.entry(root).or_default().push(tools[i]);
1068    }
1069    Some(clusters.into_values().collect())
1070}
1071
1072/// Perform a boolean operation and return an [`crate::evolution::EvolutionMap`]
1073/// tracking face provenance.
1074///
1075/// Prefers **faithful** provenance from the GFA builder — each result face
1076/// records the input face it was split/derived from
1077/// (`brepkit_algo::gfa::boolean_with_face_origins`). Because that path runs the
1078/// GFA directly, it can take a different route than [`boolean`] (which
1079/// short-circuits some cases via AABB/containment fast paths), so its result is
1080/// validated; on a GFA error or an invalid result — and for identical or
1081/// fully-contained operand pairs (`detect_trivial_relation`) — it falls back to
1082/// [`boolean`] with the geometry heuristic (normal + centroid). Either way,
1083/// unmatched input faces are classified as "deleted"; synthesised result faces
1084/// with no input origin are left unattributed.
1085///
1086/// # Errors
1087///
1088/// Returns the same errors as [`boolean`].
1089pub fn boolean_with_evolution(
1090    topo: &mut Topology,
1091    op: BooleanOp,
1092    a: SolidId,
1093    b: SolidId,
1094) -> Result<(SolidId, crate::evolution::EvolutionMap), crate::OperationsError> {
1095    use brepkit_topology::explorer::solid_faces;
1096
1097    // Faithful path: the GFA reports each result face's true input source.
1098    // Identical/contained operand pairs must NOT take it: those are the
1099    // fully-coincident-boundary configurations `boolean` short-circuits
1100    // precisely because the raw GFA mis-splits them — coincident walls drop
1101    // into an open shell whose position-duplicate free edges pass the
1102    // by-edge-id validation gate (every edge id used ≤ 2×), so the broken
1103    // result would be returned as "valid". Route them through `boolean`'s
1104    // shortcuts below; the geometry heuristic attributes a copied result's
1105    // faces exactly (normal + centroid match 1:1). Detection only runs for
1106    // a != b (a == b skips the faithful path regardless) and its cost is
1107    // O(faces + vertices) per call; `boolean` re-runs it on the fallback,
1108    // which is accepted — deduplicating would mean threading the relation
1109    // through `boolean`'s public signature.
1110    let trivial = a != b && {
1111        use brepkit_algo::classifier::try_build_analytic_classifier;
1112        let tol = brepkit_math::tolerance::Tolerance::new();
1113        let ca = try_build_analytic_classifier(topo, a);
1114        let cb = try_build_analytic_classifier(topo, b);
1115        let rel = detect_trivial_relation(topo, a, b, ca.as_ref(), cb.as_ref(), tol);
1116        rel.identical || rel.a_in_b || rel.b_in_a
1117    };
1118    if a != b && !trivial {
1119        let input_indices: Vec<usize> = solid_faces(topo, a)?
1120            .into_iter()
1121            .chain(solid_faces(topo, b)?)
1122            .map(brepkit_topology::arena::Id::index)
1123            .collect();
1124        let algo_op = match op {
1125            BooleanOp::Fuse => brepkit_algo::bop::BooleanOp::Fuse,
1126            BooleanOp::Cut => brepkit_algo::bop::BooleanOp::Cut,
1127            BooleanOp::Intersect => brepkit_algo::bop::BooleanOp::Intersect,
1128        };
1129        if let Ok((result, origins)) =
1130            brepkit_algo::gfa::boolean_with_face_origins(topo, algo_op, a, b)
1131        {
1132            // Apply the face-id-preserving result heals so the evolution result
1133            // is as correct as the standard boolean (manifold, no #801 wire
1134            // spurs). These rewrite wires in place, so the provenance — keyed by
1135            // face ID — survives. `unify_faces` is intentionally NOT run here:
1136            // it merges coplanar faces into new entities, discarding the
1137            // per-face provenance this path exists to track.
1138            // A heal failure here is not fatal: fall through to boolean()'s
1139            // full pipeline rather than propagating (the result solid stays as
1140            // orphaned topology, which is harmless in the arena).
1141            let tol = brepkit_math::tolerance::Tolerance::default();
1142            let healed_ok = crate::heal::remove_degenerate_edges(topo, result, tol.linear).is_ok()
1143                && crate::heal::remove_wire_spurs(topo, result).is_ok();
1144
1145            // Trust the faithful path only if its result is valid; otherwise
1146            // fall through to boolean()'s full pipeline (fast paths + mesh
1147            // fallback + validation), matching boolean()'s contract.
1148            if healed_ok && validate_boolean_result(topo, result).is_ok() {
1149                let mut evo = crate::evolution::EvolutionMap::new();
1150                let mut sourced: std::collections::HashSet<usize> =
1151                    std::collections::HashSet::new();
1152                for (out_idx, src) in origins {
1153                    if let Some(in_idx) = src {
1154                        evo.add_modified(in_idx, out_idx);
1155                        sourced.insert(in_idx);
1156                    }
1157                }
1158                for in_idx in input_indices {
1159                    if !sourced.contains(&in_idx) {
1160                        evo.add_deleted(in_idx);
1161                    }
1162                }
1163                return Ok((result, evo));
1164            }
1165        }
1166    }
1167
1168    // Fallback: geometry heuristic over the standard boolean result. Reached
1169    // for identical operands, a GFA error, or a GFA result that failed
1170    // validation — the EvolutionMap is then approximate, not faithful.
1171    log::debug!("boolean_with_evolution: faithful GFA provenance unavailable, using heuristic");
1172    let input_faces_a = collect_face_signatures(topo, a)?;
1173    let input_faces_b = collect_face_signatures(topo, b)?;
1174
1175    let mut input_faces: Vec<(usize, Vec3, Point3)> =
1176        Vec::with_capacity(input_faces_a.len() + input_faces_b.len());
1177    input_faces.extend(input_faces_a);
1178    input_faces.extend(input_faces_b);
1179
1180    let result = boolean(topo, op, a, b)?;
1181
1182    let output_faces = collect_face_signatures(topo, result)?;
1183
1184    let evo = crate::evolution::build_evolution_by_geometry(&input_faces, &output_faces);
1185
1186    Ok((result, evo))
1187}
1188
1189/// Compute the boolean of two axis-aligned boxes via AABB algebra.
1190///
1191/// Returns `Ok(None)` when the result isn't a single box:
1192/// - Fuse: requires two of three dims to match exactly AND the boxes to
1193///   overlap or touch in the third dim. Otherwise the union is L-shaped.
1194/// - Intersect: any non-empty AABB intersection is a box.
1195/// - Cut: skipped — the general case is L-shaped, defer to GFA.
1196fn box_pair_shortcut(
1197    topo: &mut Topology,
1198    op: BooleanOp,
1199    a_min: Point3,
1200    a_max: Point3,
1201    b_min: Point3,
1202    b_max: Point3,
1203    tol: brepkit_math::tolerance::Tolerance,
1204) -> Result<Option<SolidId>, crate::OperationsError> {
1205    let eps = tol.linear;
1206    let (min, max) = match op {
1207        BooleanOp::Intersect => {
1208            let lo = Point3::new(
1209                a_min.x().max(b_min.x()),
1210                a_min.y().max(b_min.y()),
1211                a_min.z().max(b_min.z()),
1212            );
1213            let hi = Point3::new(
1214                a_max.x().min(b_max.x()),
1215                a_max.y().min(b_max.y()),
1216                a_max.z().min(b_max.z()),
1217            );
1218            // Empty intersection — let general path return an error.
1219            if hi.x() <= lo.x() + eps || hi.y() <= lo.y() + eps || hi.z() <= lo.z() + eps {
1220                return Ok(None);
1221            }
1222            (lo, hi)
1223        }
1224        BooleanOp::Fuse => {
1225            // The union of two axis-aligned boxes is itself a box only
1226            // when two of three dimensions match exactly AND the boxes
1227            // overlap or touch in the third dim.
1228            let x_match =
1229                (a_min.x() - b_min.x()).abs() < eps && (a_max.x() - b_max.x()).abs() < eps;
1230            let y_match =
1231                (a_min.y() - b_min.y()).abs() < eps && (a_max.y() - b_max.y()).abs() < eps;
1232            let z_match =
1233                (a_min.z() - b_min.z()).abs() < eps && (a_max.z() - b_max.z()).abs() < eps;
1234            let matched = u8::from(x_match) + u8::from(y_match) + u8::from(z_match);
1235            if matched < 2 {
1236                return Ok(None);
1237            }
1238            // Verify overlap/touch in all three dims (the unmatched dim
1239            // must overlap; matched dims trivially do).
1240            if a_max.x() < b_min.x() - eps
1241                || b_max.x() < a_min.x() - eps
1242                || a_max.y() < b_min.y() - eps
1243                || b_max.y() < a_min.y() - eps
1244                || a_max.z() < b_min.z() - eps
1245                || b_max.z() < a_min.z() - eps
1246            {
1247                return Ok(None);
1248            }
1249            (
1250                Point3::new(
1251                    a_min.x().min(b_min.x()),
1252                    a_min.y().min(b_min.y()),
1253                    a_min.z().min(b_min.z()),
1254                ),
1255                Point3::new(
1256                    a_max.x().max(b_max.x()),
1257                    a_max.y().max(b_max.y()),
1258                    a_max.z().max(b_max.z()),
1259                ),
1260            )
1261        }
1262        BooleanOp::Cut => {
1263            // Cut shortcut: when B spans A in 2 of 3 dims (≥ A's extent
1264            // on both sides) and overlaps in the third, the result is
1265            // up-to-2 axis-aligned boxes (the leftover slabs on either
1266            // side of B in the cutting dim). This avoids routing through
1267            // GFA's same-domain handling which currently mishandles the
1268            // 4-coincident-face case (target's lateral walls + tool's
1269            // matching walls).
1270            return box_pair_cut_shortcut(topo, a_min, a_max, b_min, b_max, eps);
1271        }
1272    };
1273    let dx = max.x() - min.x();
1274    let dy = max.y() - min.y();
1275    let dz = max.z() - min.z();
1276    if dx <= eps || dy <= eps || dz <= eps {
1277        return Ok(None);
1278    }
1279    let bx = crate::primitives::make_box(topo, dx, dy, dz)?;
1280    if min.x().abs() > eps || min.y().abs() > eps || min.z().abs() > eps {
1281        let xform = brepkit_math::mat::Mat4::translation(min.x(), min.y(), min.z());
1282        crate::transform::transform_solid(topo, bx, &xform)?;
1283    }
1284    Ok(Some(bx))
1285}
1286
1287/// Cut shortcut for two axis-aligned boxes: returns the leftover
1288/// portion(s) when B slices through A in one dimension while spanning
1289/// A in the other two dimensions. The result is 0, 1, or 2 axis-aligned
1290/// boxes packaged into a single multi-region Solid.
1291///
1292/// Returns `Ok(None)` when the shortcut doesn't fit — e.g., B doesn't
1293/// span A in any 2 dims, B touches only a corner, etc. The general path
1294/// (GFA) handles those cases.
1295fn box_pair_cut_shortcut(
1296    topo: &mut Topology,
1297    a_min: Point3,
1298    a_max: Point3,
1299    b_min: Point3,
1300    b_max: Point3,
1301    eps: f64,
1302) -> Result<Option<SolidId>, crate::OperationsError> {
1303    // B must span A in 2 of 3 dims (B_min ≤ A_min - eps AND B_max ≥ A_max + eps,
1304    // i.e., B's extent covers A's extent in that dim).
1305    let x_spans = b_min.x() <= a_min.x() + eps && b_max.x() >= a_max.x() - eps;
1306    let y_spans = b_min.y() <= a_min.y() + eps && b_max.y() >= a_max.y() - eps;
1307    let z_spans = b_min.z() <= a_min.z() + eps && b_max.z() >= a_max.z() - eps;
1308    let spans_count = u8::from(x_spans) + u8::from(y_spans) + u8::from(z_spans);
1309    if spans_count != 2 {
1310        return Ok(None);
1311    }
1312    // In the non-spanning dim, B must actually intersect A.
1313    let (a_lo, a_hi, b_lo, b_hi) = if !x_spans {
1314        (a_min.x(), a_max.x(), b_min.x(), b_max.x())
1315    } else if !y_spans {
1316        (a_min.y(), a_max.y(), b_min.y(), b_max.y())
1317    } else {
1318        (a_min.z(), a_max.z(), b_min.z(), b_max.z())
1319    };
1320    if b_hi <= a_lo + eps || b_lo >= a_hi - eps {
1321        return Ok(None);
1322    }
1323
1324    // Build the leftover slabs. There are 0, 1, or 2 pieces depending on
1325    // whether B extends past A on each side in the cutting dim.
1326    let cuts: Vec<(f64, f64)> = {
1327        let mut pieces = Vec::with_capacity(2);
1328        if b_lo > a_lo + eps {
1329            pieces.push((a_lo, b_lo)); // slab before B
1330        }
1331        if b_hi < a_hi - eps {
1332            pieces.push((b_hi, a_hi)); // slab after B
1333        }
1334        pieces
1335    };
1336    if cuts.is_empty() {
1337        // B fully covers A in the cutting dim → cut leaves nothing.
1338        // Let the general path handle this (it errors).
1339        return Ok(None);
1340    }
1341
1342    let piece_solids: Vec<SolidId> = cuts
1343        .iter()
1344        .map(|&(lo, hi)| -> Result<SolidId, crate::OperationsError> {
1345            let (dx, dy, dz, tx, ty, tz) = if !x_spans {
1346                (
1347                    hi - lo,
1348                    a_max.y() - a_min.y(),
1349                    a_max.z() - a_min.z(),
1350                    lo,
1351                    a_min.y(),
1352                    a_min.z(),
1353                )
1354            } else if !y_spans {
1355                (
1356                    a_max.x() - a_min.x(),
1357                    hi - lo,
1358                    a_max.z() - a_min.z(),
1359                    a_min.x(),
1360                    lo,
1361                    a_min.z(),
1362                )
1363            } else {
1364                (
1365                    a_max.x() - a_min.x(),
1366                    a_max.y() - a_min.y(),
1367                    hi - lo,
1368                    a_min.x(),
1369                    a_min.y(),
1370                    lo,
1371                )
1372            };
1373            let bx = crate::primitives::make_box(topo, dx, dy, dz)?;
1374            if tx.abs() > eps || ty.abs() > eps || tz.abs() > eps {
1375                let xform = brepkit_math::mat::Mat4::translation(tx, ty, tz);
1376                crate::transform::transform_solid(topo, bx, &xform)?;
1377            }
1378            Ok(bx)
1379        })
1380        .collect::<Result<_, _>>()?;
1381
1382    if piece_solids.len() == 1 {
1383        return Ok(Some(piece_solids[0]));
1384    }
1385
1386    // Combine pieces into a single multi-region solid.
1387    let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
1388    for &p in &piece_solids {
1389        let p_data = topo.solid(p)?;
1390        for &fid in topo.shell(p_data.outer_shell())?.faces() {
1391            all_faces.push(fid);
1392        }
1393    }
1394    Ok(Some(make_solid_from_face_subset(topo, &all_faces)?))
1395}
1396
1397/// Compute the coaxial-cylinder boolean for two cylinders sharing axis,
1398/// origin, and radius. Returns `Ok(None)` when the shortcut doesn't apply
1399/// (disjoint along axis for fuse/intersect; cut requires general handling).
1400#[allow(clippy::too_many_arguments)]
1401fn coaxial_cylinder_shortcut(
1402    topo: &mut Topology,
1403    op: BooleanOp,
1404    origin: Point3,
1405    axis: Vec3,
1406    radius: f64,
1407    a_range: (f64, f64),
1408    b_range: (f64, f64),
1409    tol: brepkit_math::tolerance::Tolerance,
1410) -> Result<Option<SolidId>, crate::OperationsError> {
1411    let (za_min, za_max) = a_range;
1412    let (zb_min, zb_max) = b_range;
1413    // For fuse: ranges must touch or overlap. Disjoint cylinders would
1414    // produce a compound, which the boolean API doesn't return.
1415    let touches_or_overlaps = zb_min <= za_max + tol.linear && za_min <= zb_max + tol.linear;
1416    let (z_min, z_max) = match op {
1417        BooleanOp::Fuse => {
1418            if !touches_or_overlaps {
1419                return Ok(None);
1420            }
1421            (za_min.min(zb_min), za_max.max(zb_max))
1422        }
1423        BooleanOp::Intersect => {
1424            // Strict overlap (not just touching) for non-degenerate result.
1425            let lo = za_min.max(zb_min);
1426            let hi = za_max.min(zb_max);
1427            if hi <= lo + tol.linear {
1428                return Ok(None);
1429            }
1430            (lo, hi)
1431        }
1432        BooleanOp::Cut => return Ok(None), // Defer to GFA / general path.
1433    };
1434    let height = z_max - z_min;
1435    if height <= tol.linear {
1436        return Ok(None);
1437    }
1438    // Build a fresh cylinder at axis-origin + axis*z_min, oriented along
1439    // axis. make_cylinder produces the canonical (0,0,0)→(0,0,h) cylinder;
1440    // then transform to the world axis frame.
1441    let cyl = crate::primitives::make_cylinder(topo, radius, height)?;
1442    let world_origin = Point3::new(
1443        origin.x() + axis.x() * z_min,
1444        origin.y() + axis.y() * z_min,
1445        origin.z() + axis.z() * z_min,
1446    );
1447    let xform = xform_from_canonical_z(world_origin, axis, tol);
1448    crate::transform::transform_solid(topo, cyl, &xform)?;
1449    Ok(Some(cyl))
1450}
1451
1452/// Compute the coaxial-cone boolean for two frustums on the same conical
1453/// surface (shared apex, axis, and half-angle). Returns `Ok(None)` when
1454/// the shortcut doesn't apply.
1455#[allow(clippy::too_many_arguments)]
1456fn coaxial_cone_shortcut(
1457    topo: &mut Topology,
1458    op: BooleanOp,
1459    apex: Point3,
1460    axis: Vec3,
1461    slope: f64,
1462    a_range: (f64, f64),
1463    b_range: (f64, f64),
1464    tol: brepkit_math::tolerance::Tolerance,
1465) -> Result<Option<SolidId>, crate::OperationsError> {
1466    let (za_min, za_max) = a_range;
1467    let (zb_min, zb_max) = b_range;
1468    let touches_or_overlaps = zb_min <= za_max + tol.linear && za_min <= zb_max + tol.linear;
1469    let (z_min, z_max) = match op {
1470        BooleanOp::Fuse => {
1471            if !touches_or_overlaps {
1472                return Ok(None);
1473            }
1474            (za_min.min(zb_min), za_max.max(zb_max))
1475        }
1476        BooleanOp::Intersect => {
1477            let lo = za_min.max(zb_min);
1478            let hi = za_max.min(zb_max);
1479            if hi <= lo + tol.linear {
1480                return Ok(None);
1481            }
1482            (lo, hi)
1483        }
1484        BooleanOp::Cut => return Ok(None),
1485    };
1486    let height = z_max - z_min;
1487    if height <= tol.linear {
1488        return Ok(None);
1489    }
1490    // r at axial position z (apex-relative) = slope * z. For frustums on
1491    // the +axis nappe, both z values are positive; if either becomes ≤ 0
1492    // (apex inclusion), bail out so we don't construct a degenerate cone.
1493    let r_at_z_min = slope * z_min;
1494    let r_at_z_max = slope * z_max;
1495    if r_at_z_min < -tol.linear || r_at_z_max < -tol.linear {
1496        return Ok(None);
1497    }
1498    let r_bot = r_at_z_min.abs();
1499    let r_top = r_at_z_max.abs();
1500    if r_bot <= tol.linear && r_top <= tol.linear {
1501        return Ok(None);
1502    }
1503    let cone = crate::primitives::make_cone(topo, r_bot, r_top, height)?;
1504    let world_origin = Point3::new(
1505        apex.x() + axis.x() * z_min,
1506        apex.y() + axis.y() * z_min,
1507        apex.z() + axis.z() * z_min,
1508    );
1509    // Cone shortcut keeps to axis-aligned cases for now (test corpus does
1510    // not yet cover off-axis cones). Detect parallel/antiparallel via the
1511    // dot product (the canonical-axis Z-component is the only term that
1512    // survives `canonical · axis` since canonical = ẑ).
1513    let dot = axis.z().clamp(-1.0, 1.0);
1514    if 1.0 - dot.abs() > tol.angular {
1515        return Ok(None);
1516    }
1517    let xform = xform_from_canonical_z(world_origin, axis, tol);
1518    crate::transform::transform_solid(topo, cone, &xform)?;
1519    Ok(Some(cone))
1520}
1521
1522/// Compute the concentric-sphere boolean for two spheres sharing a
1523/// Box-sphere `Intersect` shortcut. Handles two configurations exactly,
1524/// returning `Ok(None)` to fall through to GFA otherwise:
1525///
1526/// 1. **Sphere fully inside box** — every box face plane has the sphere
1527///    on the box-interior side with margin ≥ `R` (`s ≤ -R + eps`). The
1528///    result is a fresh sphere primitive at `sphere_center` with radius
1529///    `sphere_radius`.
1530/// 2. **Spherical "octant"** — exactly 3 of the 6 box face planes cut
1531///    the sphere (`|s| < R - eps`) and the other 3 leave the sphere on
1532///    the box-interior side. The 3 cutting planes are mutually orthogonal
1533///    (axis-aligned box invariant) and meet at a single box corner `O`.
1534///    The result is the sphere region in the box-interior octant of `O`,
1535///    bounded by 3 quarter-disc box sub-faces and 1 spherical patch.
1536///
1537/// `s` is the signed distance from `sphere_center` to a face plane along
1538/// the face's outward normal (positive = sphere on box-exterior side).
1539/// If any face has `s ≥ R - eps` the result is empty (sphere doesn't
1540/// reach into the box from that side) — we return `None` rather than an
1541/// empty solid so the caller can produce the canonical `EmptyResult`
1542/// error via the regular path.
1543#[allow(clippy::too_many_arguments)]
1544fn box_sphere_intersect_shortcut(
1545    topo: &mut Topology,
1546    box_min: Point3,
1547    box_max: Point3,
1548    sphere_center: Point3,
1549    sphere_radius: f64,
1550    sphere_segments: usize,
1551    tol: brepkit_math::tolerance::Tolerance,
1552) -> Result<Option<SolidId>, crate::OperationsError> {
1553    let r = sphere_radius;
1554    let eps = tol.linear;
1555    if r <= eps {
1556        return Ok(None);
1557    }
1558    // Sanity: degenerate or inverted box.
1559    if box_max.x() <= box_min.x() + eps
1560        || box_max.y() <= box_min.y() + eps
1561        || box_max.z() <= box_min.z() + eps
1562    {
1563        return Ok(None);
1564    }
1565
1566    // For each of 6 box face planes, compute `s` (signed distance from
1567    // sphere center along outward normal). Classify each plane.
1568    let faces: [(Vec3, f64); 6] = [
1569        (Vec3::new(-1.0, 0.0, 0.0), -box_min.x()),
1570        (Vec3::new(1.0, 0.0, 0.0), box_max.x()),
1571        (Vec3::new(0.0, -1.0, 0.0), -box_min.y()),
1572        (Vec3::new(0.0, 1.0, 0.0), box_max.y()),
1573        (Vec3::new(0.0, 0.0, -1.0), -box_min.z()),
1574        (Vec3::new(0.0, 0.0, 1.0), box_max.z()),
1575    ];
1576    let signed_dist = |n: Vec3, d: f64| -> f64 {
1577        n.x() * sphere_center.x() + n.y() * sphere_center.y() + n.z() * sphere_center.z() - d
1578    };
1579
1580    let mut cuts: Vec<usize> = Vec::new();
1581    for (i, &(n, d)) in faces.iter().enumerate() {
1582        let s = signed_dist(n, d);
1583        if s >= r - eps {
1584            // Sphere is fully on the exterior side of this plane → box ∩
1585            // sphere = empty. Defer to GFA which will surface an
1586            // EmptyResult error in its usual form.
1587            return Ok(None);
1588        }
1589        if s.abs() < r - eps {
1590            cuts.push(i);
1591        }
1592        // else: s ≤ -r + eps → sphere fully inside this plane, face
1593        // doesn't bound the result; nothing to do.
1594    }
1595
1596    // Case 1: sphere fully inside box (no cutting planes).
1597    if cuts.is_empty() {
1598        let sphere = crate::primitives::make_sphere(topo, r, sphere_segments)?;
1599        if sphere_center.x().abs() > eps
1600            || sphere_center.y().abs() > eps
1601            || sphere_center.z().abs() > eps
1602        {
1603            let xform = brepkit_math::mat::Mat4::translation(
1604                sphere_center.x(),
1605                sphere_center.y(),
1606                sphere_center.z(),
1607            );
1608            crate::transform::transform_solid(topo, sphere, &xform)?;
1609        }
1610        return Ok(Some(sphere));
1611    }
1612
1613    // Case 2: 3 cutting planes meeting at a box corner → spherical
1614    // octant. The 3 cut planes' outward normals are mutually orthogonal
1615    // (axis-aligned box invariant) so the in-box direction perpendicular
1616    // to each is the negated outward normal.
1617    if cuts.len() == 3 {
1618        return build_box_sphere_octant(topo, &faces, &cuts, sphere_center, r, tol);
1619    }
1620
1621    // 1, 2, 4, 5, 6 cutting planes — more complex geometries (caps,
1622    // lenses, etc.). Out of scope for this shortcut; fall through.
1623    Ok(None)
1624}
1625
1626/// Construct the result of `box ∩ sphere` when exactly 3 box face planes
1627/// cut the sphere and meet at a single corner `O`. The result topology
1628/// is 4 faces (3 quarter-discs + 1 spherical patch), 6 edges, 4 vertices.
1629fn build_box_sphere_octant(
1630    topo: &mut Topology,
1631    faces: &[(Vec3, f64); 6],
1632    cuts: &[usize],
1633    sphere_center: Point3,
1634    r: f64,
1635    tol: brepkit_math::tolerance::Tolerance,
1636) -> Result<Option<SolidId>, crate::OperationsError> {
1637    use brepkit_math::curves::Circle3D;
1638    use brepkit_math::surfaces::SphericalSurface;
1639    use brepkit_topology::edge::{Edge, EdgeCurve};
1640    use brepkit_topology::face::{Face, FaceSurface};
1641    use brepkit_topology::shell::Shell;
1642    use brepkit_topology::solid::Solid;
1643    use brepkit_topology::vertex::Vertex;
1644    use brepkit_topology::wire::{OrientedEdge, Wire};
1645
1646    // Cutting plane normals + their box-plane-d values.
1647    let cut_planes: Vec<(Vec3, f64)> = cuts.iter().map(|&i| faces[i]).collect();
1648    // The 3 outward normals must be mutually orthogonal (axis-aligned box).
1649    let n0 = cut_planes[0].0;
1650    let n1 = cut_planes[1].0;
1651    let n2 = cut_planes[2].0;
1652    if n0.dot(n1).abs() > tol.angular
1653        || n0.dot(n2).abs() > tol.angular
1654        || n1.dot(n2).abs() > tol.angular
1655    {
1656        // Not orthogonal — defer to GFA.
1657        return Ok(None);
1658    }
1659    // The corner O is at the intersection of the 3 cutting planes:
1660    //   n_i · O = d_i  for all 3 i.
1661    // Since the normals are axis-aligned (±x, ±y, ±z), we can pull each
1662    // coordinate of O directly off the matching plane's d.
1663    let coord_from_axis = |axis: Vec3, d: f64| -> f64 {
1664        if axis.x().abs() > 0.5 {
1665            d * axis.x().signum()
1666        } else if axis.y().abs() > 0.5 {
1667            d * axis.y().signum()
1668        } else {
1669            d * axis.z().signum()
1670        }
1671    };
1672    let mut o = [0.0_f64; 3];
1673    for &(n, d) in &cut_planes {
1674        if n.x().abs() > 0.5 {
1675            o[0] = coord_from_axis(n, d);
1676        } else if n.y().abs() > 0.5 {
1677            o[1] = coord_from_axis(n, d);
1678        } else {
1679            o[2] = coord_from_axis(n, d);
1680        }
1681    }
1682    let o = Point3::new(o[0], o[1], o[2]);
1683
1684    // In-box direction perpendicular to each cutting plane = -n_i.
1685    let in_dirs: Vec<Vec3> = cut_planes
1686        .iter()
1687        .map(|&(n, _)| Vec3::new(-n.x(), -n.y(), -n.z()))
1688        .collect();
1689
1690    // For each cutting plane i, the box edge from O in direction in_dirs[i]
1691    // is the intersection of the other two cutting planes. Find the sphere
1692    // intersection with this edge — the vertex on the sphere along the box
1693    // edge.
1694    //
1695    // Edge parameterised as O + t·d_i for t ≥ 0. Sphere: |P - C|² = R².
1696    //   (O + t·d_i - C) · (O + t·d_i - C) = R²
1697    //   Let v = O - C; expand:
1698    //     t² + 2 t (v · d_i) + |v|² - R² = 0
1699    //   So t = -v·d_i ± sqrt((v·d_i)² - |v|² + R²)
1700    let mut sphere_pts: [Point3; 3] = [Point3::new(0.0, 0.0, 0.0); 3];
1701    for (idx, &dir) in in_dirs.iter().enumerate() {
1702        let vx = o.x() - sphere_center.x();
1703        let vy = o.y() - sphere_center.y();
1704        let vz = o.z() - sphere_center.z();
1705        let v_dot_d = vx * dir.x() + vy * dir.y() + vz * dir.z();
1706        let v_sq = vx * vx + vy * vy + vz * vz;
1707        let disc = v_dot_d * v_dot_d - v_sq + r * r;
1708        if disc < -tol.linear * tol.linear {
1709            return Ok(None);
1710        }
1711        let t = -v_dot_d + disc.max(0.0).sqrt();
1712        if t <= tol.linear {
1713            return Ok(None);
1714        }
1715        sphere_pts[idx] = Point3::new(
1716            o.x() + t * dir.x(),
1717            o.y() + t * dir.y(),
1718            o.z() + t * dir.z(),
1719        );
1720    }
1721
1722    // Topology: 4 vertices, 6 edges, 4 faces.
1723    let v_o = topo.add_vertex(Vertex::new(o, tol.linear));
1724    let v_x = topo.add_vertex(Vertex::new(sphere_pts[0], tol.linear));
1725    let v_y = topo.add_vertex(Vertex::new(sphere_pts[1], tol.linear));
1726    let v_z = topo.add_vertex(Vertex::new(sphere_pts[2], tol.linear));
1727
1728    // 3 line edges from O along the box edges.
1729    let e_ox = topo.add_edge(Edge::new(v_o, v_x, EdgeCurve::Line));
1730    let e_oy = topo.add_edge(Edge::new(v_o, v_y, EdgeCurve::Line));
1731    let e_oz = topo.add_edge(Edge::new(v_o, v_z, EdgeCurve::Line));
1732
1733    // 3 arc edges on the sphere. Each arc lies on one of the cutting planes:
1734    // the arc opposite vertex `i` (i.e., between the other two vertices)
1735    // sits on cutting plane `i` (normal `n_i`), because those two vertices
1736    // lie on edges perpendicular to the remaining two normals — and both
1737    // of those edges lie within the plane perpendicular to `n_i`.
1738    let mut build_arc_edge =
1739        |n: Vec3,
1740         p_start: Point3,
1741         p_end: Point3,
1742         start_vid,
1743         end_vid|
1744         -> Result<brepkit_topology::edge::EdgeId, crate::OperationsError> {
1745            let dist = n.x() * (sphere_center.x() - p_start.x())
1746                + n.y() * (sphere_center.y() - p_start.y())
1747                + n.z() * (sphere_center.z() - p_start.z());
1748            let circle_center = Point3::new(
1749                sphere_center.x() - dist * n.x(),
1750                sphere_center.y() - dist * n.y(),
1751                sphere_center.z() - dist * n.z(),
1752            );
1753            let circle_r = (r * r - dist * dist).max(0.0).sqrt();
1754            if circle_r <= tol.linear {
1755                return Err(crate::OperationsError::InvalidInput {
1756                    reason: "box-sphere octant: degenerate arc radius".into(),
1757                });
1758            }
1759            let dx = p_start.x() - circle_center.x();
1760            let dy = p_start.y() - circle_center.y();
1761            let dz = p_start.z() - circle_center.z();
1762            let len = (dx * dx + dy * dy + dz * dz).sqrt();
1763            if len <= tol.linear {
1764                return Err(crate::OperationsError::InvalidInput {
1765                    reason: "box-sphere octant: degenerate arc reference".into(),
1766                });
1767            }
1768            let u_ref = Vec3::new(dx / len, dy / len, dz / len);
1769            // The circle's CCW direction must take start -> end the SHORT
1770            // way (the quarter arc bounding the octant). About the cutting
1771            // plane's OUTWARD normal that span is the 270-degree
1772            // complement (the wrong-region 1304.8 volume); the INWARD
1773            // normal makes it the intended quarter.
1774            let inward = Vec3::new(-n.x(), -n.y(), -n.z());
1775            let circle =
1776                Circle3D::new_with_ref(circle_center, inward, circle_r, u_ref).map_err(|e| {
1777                    crate::OperationsError::InvalidInput {
1778                        reason: format!("box-sphere octant: circle construction failed: {e}"),
1779                    }
1780                })?;
1781            let _ = p_end; // p_end is used only via end_vid (already pre-placed at the correct sphere point)
1782            Ok(topo.add_edge(Edge::new(start_vid, end_vid, EdgeCurve::Circle(circle))))
1783        };
1784
1785    // Arc on cut plane 0 (between v_y and v_z, i.e., the edge "opposite" v_x).
1786    let arc_yz = build_arc_edge(n0, sphere_pts[1], sphere_pts[2], v_y, v_z)?;
1787    // Arc on cut plane 1 (between v_z and v_x).
1788    let arc_zx = build_arc_edge(n1, sphere_pts[2], sphere_pts[0], v_z, v_x)?;
1789    // Arc on cut plane 2 (between v_x and v_y).
1790    let arc_xy = build_arc_edge(n2, sphere_pts[0], sphere_pts[1], v_x, v_y)?;
1791
1792    // Quarter-disc face on cut plane 0 (perpendicular to n0): bounded by
1793    // box edges O-Y and O-Z + arc Y→Z.
1794    let qd0_wire = Wire::new(
1795        vec![
1796            OrientedEdge::new(e_oy, true),   // O → Y
1797            OrientedEdge::new(arc_yz, true), // Y → Z (arc)
1798            OrientedEdge::new(e_oz, false),  // Z → O (reversed)
1799        ],
1800        true,
1801    )
1802    .map_err(crate::OperationsError::Topology)?;
1803    let qd0_id = topo.add_wire(qd0_wire);
1804    let qd0_face = topo.add_face(Face::new(
1805        qd0_id,
1806        Vec::new(),
1807        FaceSurface::Plane {
1808            normal: n0,
1809            d: cut_planes[0].1,
1810        },
1811    ));
1812
1813    let qd1_wire = Wire::new(
1814        vec![
1815            OrientedEdge::new(e_oz, true),   // O → Z
1816            OrientedEdge::new(arc_zx, true), // Z → X (arc)
1817            OrientedEdge::new(e_ox, false),  // X → O (reversed)
1818        ],
1819        true,
1820    )
1821    .map_err(crate::OperationsError::Topology)?;
1822    let qd1_id = topo.add_wire(qd1_wire);
1823    let qd1_face = topo.add_face(Face::new(
1824        qd1_id,
1825        Vec::new(),
1826        FaceSurface::Plane {
1827            normal: n1,
1828            d: cut_planes[1].1,
1829        },
1830    ));
1831
1832    let qd2_wire = Wire::new(
1833        vec![
1834            OrientedEdge::new(e_ox, true),   // O → X
1835            OrientedEdge::new(arc_xy, true), // X → Y (arc)
1836            OrientedEdge::new(e_oy, false),  // Y → O (reversed)
1837        ],
1838        true,
1839    )
1840    .map_err(crate::OperationsError::Topology)?;
1841    let qd2_id = topo.add_wire(qd2_wire);
1842    let qd2_face = topo.add_face(Face::new(
1843        qd2_id,
1844        Vec::new(),
1845        FaceSurface::Plane {
1846            normal: n2,
1847            d: cut_planes[2].1,
1848        },
1849    ));
1850
1851    // Spherical patch: bounded by the 3 arcs.
1852    // Wind so the sphere's outward normal matches the resulting volume
1853    // (outside the octant). With arcs going X→Y→Z→X around the patch,
1854    // the right-hand rule gives an outward normal pointing AWAY from O.
1855    // Each arc is traversed forward by its quarter-disc, so the patch must
1856    // traverse all three reversed for consistent edge senses: X → Z → Y → X.
1857    let sph_wire = Wire::new(
1858        vec![
1859            OrientedEdge::new(arc_zx, false), // X → Z
1860            OrientedEdge::new(arc_yz, false), // Z → Y
1861            OrientedEdge::new(arc_xy, false), // Y → X
1862        ],
1863        true,
1864    )
1865    .map_err(crate::OperationsError::Topology)?;
1866    let sph_wire_id = topo.add_wire(sph_wire);
1867    let sphere_surface = SphericalSurface::new(sphere_center, r).map_err(|e| {
1868        crate::OperationsError::InvalidInput {
1869            reason: format!("box-sphere octant: sphere surface construction failed: {e}"),
1870        }
1871    })?;
1872    let sphere_face = topo.add_face(Face::new(
1873        sph_wire_id,
1874        Vec::new(),
1875        FaceSurface::Sphere(sphere_surface),
1876    ));
1877
1878    let shell = Shell::new(vec![qd0_face, qd1_face, qd2_face, sphere_face])
1879        .map_err(crate::OperationsError::Topology)?;
1880    let shell_id = topo.add_shell(shell);
1881    let solid = topo.add_solid(Solid::new(shell_id, Vec::new()));
1882    Ok(Some(solid))
1883}
1884
1885/// center. Returns `Ok(None)` when the shortcut doesn't apply (Cut, or
1886/// degenerate radii).
1887///
1888/// Sphere-sphere is simpler than the cylinder/cone analogues because
1889/// there's no axial range — the result radius is just `max(r_a, r_b)`
1890/// for Fuse and `min(r_a, r_b)` for Intersect.
1891///
1892/// The new sphere's tessellation density (segment count) is inherited from
1893/// whichever input has a higher equatorial vertex count, so a
1894/// 64-segment input never silently downgrades to a coarse default. This
1895/// relies on `make_sphere` allocating exactly `segments` equatorial
1896/// vertices and no pole vertices — see `crates/operations/src/primitives.rs`.
1897#[allow(clippy::too_many_arguments)]
1898fn concentric_sphere_shortcut(
1899    topo: &mut Topology,
1900    op: BooleanOp,
1901    a: SolidId,
1902    b: SolidId,
1903    center: Point3,
1904    r_a: f64,
1905    r_b: f64,
1906    tol: brepkit_math::tolerance::Tolerance,
1907) -> Result<Option<SolidId>, crate::OperationsError> {
1908    if r_a <= tol.linear || r_b <= tol.linear {
1909        return Ok(None);
1910    }
1911    let r_result = match op {
1912        BooleanOp::Fuse => r_a.max(r_b),
1913        BooleanOp::Intersect => {
1914            // Both r_a and r_b are guaranteed > tol.linear by the guard above,
1915            // so `min(r_a, r_b)` is always positive here.
1916            r_a.min(r_b)
1917        }
1918        // Cut(A, B) on concentric spheres yields a hollow ball when r_a > r_b;
1919        // empty when r_a ≤ r_b. The hollow-ball case needs an outer + inner
1920        // shell, which `make_sphere` doesn't produce — defer to GFA.
1921        BooleanOp::Cut => return Ok(None),
1922    };
1923
1924    // Inherit segment count from whichever input was tessellated more finely.
1925    // `make_sphere(r, n)` allocates exactly `n` equatorial vertices; because
1926    // sphere primitives are fully describe by (center, radius), all vertices
1927    // belong to that ring. Floor at 4 to satisfy `make_sphere`'s lower bound.
1928    let segments_a = brepkit_topology::explorer::solid_vertices(topo, a)
1929        .map(|v| v.len())
1930        .unwrap_or(0);
1931    let segments_b = brepkit_topology::explorer::solid_vertices(topo, b)
1932        .map(|v| v.len())
1933        .unwrap_or(0);
1934    let segments = segments_a.max(segments_b).max(4);
1935
1936    let sphere = crate::primitives::make_sphere(topo, r_result, segments)?;
1937    if center.x().abs() > tol.linear
1938        || center.y().abs() > tol.linear
1939        || center.z().abs() > tol.linear
1940    {
1941        let xform = brepkit_math::mat::Mat4::translation(center.x(), center.y(), center.z());
1942        crate::transform::transform_solid(topo, sphere, &xform)?;
1943    }
1944    Ok(Some(sphere))
1945}
1946
1947/// Compute the coaxial-torus boolean for two tori sharing center, axis,
1948/// and major radius. Returns `Ok(None)` when the shortcut doesn't apply
1949/// (Cut, or degenerate radii / overlap).
1950///
1951/// Like the concentric-sphere shortcut, the result tessellation density
1952/// is inherited from the higher-quality input so a 64-segment input
1953/// torus never silently downgrades.
1954#[allow(clippy::too_many_arguments)]
1955fn coaxial_torus_shortcut(
1956    topo: &mut Topology,
1957    op: BooleanOp,
1958    a: SolidId,
1959    b: SolidId,
1960    center: Point3,
1961    axis: Vec3,
1962    major_radius: f64,
1963    minor_a: f64,
1964    minor_b: f64,
1965    tol: brepkit_math::tolerance::Tolerance,
1966) -> Result<Option<SolidId>, crate::OperationsError> {
1967    if minor_a <= tol.linear || minor_b <= tol.linear || major_radius <= tol.linear {
1968        return Ok(None);
1969    }
1970    let minor_result = match op {
1971        BooleanOp::Fuse => minor_a.max(minor_b),
1972        BooleanOp::Intersect => {
1973            // Both minors are guaranteed > tol by the guard above.
1974            minor_a.min(minor_b)
1975        }
1976        // Cut on coaxial tori with shared major produces a hollow torus
1977        // (outer + inner small-circle shells) when minor_a > minor_b.
1978        // `make_torus` doesn't build that topology — defer to GFA.
1979        BooleanOp::Cut => return Ok(None),
1980    };
1981    if minor_result >= major_radius {
1982        // make_torus rejects self-intersecting tori (minor >= major).
1983        return Ok(None);
1984    }
1985
1986    // Inherit segment count from the higher-quality input. `make_torus`
1987    // accepts a `segments` param controlling u-direction discretization.
1988    // We'd ideally extract this from each input solid's vertex count, but
1989    // unlike make_sphere torus topology has internal seam vertices that
1990    // make the relationship less clean. Approximate by the larger vertex
1991    // count.
1992    let segments_a = brepkit_topology::explorer::solid_vertices(topo, a)
1993        .map(|v| v.len())
1994        .unwrap_or(0);
1995    let segments_b = brepkit_topology::explorer::solid_vertices(topo, b)
1996        .map(|v| v.len())
1997        .unwrap_or(0);
1998    let segments = segments_a.max(segments_b).max(8);
1999
2000    // Build a fresh torus at the origin then transform to the shared
2001    // center / axis. `make_torus` builds with axis = +z by default.
2002    let torus = crate::primitives::make_torus(topo, major_radius, minor_result, segments)?;
2003    let xform = xform_from_canonical_z(center, axis, tol);
2004    crate::transform::transform_solid(topo, torus, &xform)?;
2005    Ok(Some(torus))
2006}
2007
2008/// Build the world-frame transform that maps a primitive built in the
2009/// canonical Z-up local frame (origin at world origin, axis = +Z) to a
2010/// world frame at `world_origin` with up-axis `axis` (assumed
2011/// unit-length). Uses Rodrigues' rotation formula for the general case.
2012///
2013/// Comparisons use `1.0 - axis.dot(canonical) < tol.angular` rather than
2014/// vector-length deltas, because for unit vectors `|u−v| ≈ √2·θ`, so a
2015/// length comparison against `tol.angular` would correspond to
2016/// `θ ≈ 7×10⁻¹³` rad — effectively bit-identity.
2017fn xform_from_canonical_z(
2018    world_origin: Point3,
2019    axis: Vec3,
2020    tol: brepkit_math::tolerance::Tolerance,
2021) -> brepkit_math::mat::Mat4 {
2022    let translate =
2023        brepkit_math::mat::Mat4::translation(world_origin.x(), world_origin.y(), world_origin.z());
2024    let canonical = Vec3::new(0.0, 0.0, 1.0);
2025    let dot = canonical.dot(axis).clamp(-1.0, 1.0);
2026    // Parallel to +Z: pure translation.
2027    if 1.0 - dot < tol.angular {
2028        return translate;
2029    }
2030    // Antiparallel: rotate canonical (+z) by π around X to flip to −z.
2031    if 1.0 + dot < tol.angular {
2032        return translate * brepkit_math::mat::Mat4::rotation_x(std::f64::consts::PI);
2033    }
2034    // Rotate canonical (0,0,1) → axis via Rodrigues' formula:
2035    //   R = I + sin(θ) K + (1 - cos(θ)) K²,  K = [k]× for k = ẑ × axis / sin(θ).
2036    // k.z = 0 by construction, so K's z-row/z-column have a known structure.
2037    let sin_t = (1.0 - dot * dot).sqrt();
2038    let kx = -axis.y() / sin_t;
2039    let ky = axis.x() / sin_t;
2040    let one_minus_cos = 1.0 - dot;
2041    let r00 = one_minus_cos.mul_add(kx * kx, dot);
2042    let r01 = one_minus_cos * kx * ky;
2043    let r02 = sin_t * ky;
2044    let r10 = one_minus_cos * kx * ky;
2045    let r11 = one_minus_cos.mul_add(ky * ky, dot);
2046    let r12 = -sin_t * kx;
2047    let r20 = -sin_t * ky;
2048    let r21 = sin_t * kx;
2049    let r22 = dot;
2050    let rot = brepkit_math::mat::Mat4([
2051        [r00, r01, r02, 0.0],
2052        [r10, r11, r12, 0.0],
2053        [r20, r21, r22, 0.0],
2054        [0.0, 0.0, 0.0, 1.0],
2055    ]);
2056    translate * rot
2057}
2058
2059/// Returns `true` when two axis-aligned boxes are separated on at least
2060/// one axis by more than `margin` — i.e. their (margin-expanded) extents
2061/// do not overlap and the solids they bound provably do not intersect.
2062///
2063/// The `margin` shrinks the overlap test so boxes that only touch (or
2064/// nearly touch) within `margin` are treated as separated: a shared
2065/// face/edge/corner has zero overlap volume.
2066fn aabbs_separated(
2067    a: &brepkit_math::aabb::Aabb3,
2068    b: &brepkit_math::aabb::Aabb3,
2069    margin: f64,
2070) -> bool {
2071    a.max.x() < b.min.x() + margin
2072        || b.max.x() < a.min.x() + margin
2073        || a.max.y() < b.min.y() + margin
2074        || b.max.y() < a.min.y() + margin
2075        || a.max.z() < b.min.z() + margin
2076        || b.max.z() < a.min.z() + margin
2077}
2078
2079/// Returns `true` when two axis-aligned boxes have a *clear gap* exceeding
2080/// `margin` on at least one axis — i.e. they are separated by a real positive
2081/// distance, not merely touching.
2082///
2083/// This is intentionally stricter than [`aabbs_separated`]: a shared
2084/// face/edge/corner (zero gap) returns `false` here. Touching solids must NOT
2085/// be treated as disjoint by the fuse fast path — their shared geometry has to
2086/// be welded by GFA.
2087fn aabbs_clear_gap(
2088    a: &brepkit_math::aabb::Aabb3,
2089    b: &brepkit_math::aabb::Aabb3,
2090    margin: f64,
2091) -> bool {
2092    b.min.x() - a.max.x() > margin
2093        || a.min.x() - b.max.x() > margin
2094        || b.min.y() - a.max.y() > margin
2095        || a.min.y() - b.max.y() > margin
2096        || b.min.z() - a.max.z() > margin
2097        || a.min.z() - b.max.z() > margin
2098}
2099
2100/// Returns `true` when solids `a` and `b` are provably spatially disjoint with
2101/// a clear gap: every connected face component of `a` is separated from every
2102/// connected face component of `b` by more than `margin` on some axis.
2103///
2104/// Soundness: component AABBs come from [`crate::measure::face_set_bounding_box`],
2105/// which is a conservative *outer* bound (vertices plus surface-curvature
2106/// expansion). If two components' true geometry overlapped or touched, their
2107/// boxes would touch or overlap and [`aabbs_clear_gap`] would (correctly)
2108/// return `false`. So a `true` result guarantees a real positive gap between
2109/// the two solids — never a false "disjoint" for touching/coincident inputs,
2110/// which must still go through GFA to weld shared geometry.
2111///
2112/// Component-level (rather than whole-solid) granularity is essential: a
2113/// multi-region solid (e.g. an accumulator of several already-merged disjoint
2114/// pieces) has a single outer shell whose overall box overlaps a nearby piece,
2115/// yet none of its pieces actually touch that piece. [`assembly::face_components`]
2116/// recovers the individual pieces from the merged shell.
2117///
2118/// Returns `false` on any topology error or empty operand (fall through to the
2119/// general path) rather than risking an unsound merge.
2120fn solids_provably_disjoint(topo: &Topology, a: SolidId, b: SolidId, margin: f64) -> bool {
2121    let comps_a = assembly::face_components(topo, a);
2122    let comps_b = assembly::face_components(topo, b);
2123    if comps_a.is_empty() || comps_b.is_empty() {
2124        return false;
2125    }
2126    let boxes = |comps: &[Vec<FaceId>]| -> Option<Vec<brepkit_math::aabb::Aabb3>> {
2127        comps
2128            .iter()
2129            .map(|faces| crate::measure::face_set_bounding_box(topo, faces).ok())
2130            .collect()
2131    };
2132    let (Some(boxes_a), Some(boxes_b)) = (boxes(&comps_a), boxes(&comps_b)) else {
2133        return false;
2134    };
2135    boxes_a
2136        .iter()
2137        .all(|ba| boxes_b.iter().all(|bb| aabbs_clear_gap(ba, bb, margin)))
2138}
2139
2140/// The trivial operand relationships that let [`boolean`] short-circuit
2141/// without running the GFA: identical solids and full containment.
2142struct TrivialRelation {
2143    /// Matching AABBs AND every boundary vertex of each solid classifies
2144    /// as inside-or-on the other's analytic classifier.
2145    identical: bool,
2146    /// A is fully contained in B.
2147    a_in_b: bool,
2148    /// B is fully contained in A.
2149    b_in_a: bool,
2150}
2151
2152/// Detect the trivial operand relationships (identical / contained).
2153///
2154/// [`boolean`] uses this to take copy/empty shortcuts. [`boolean_with_evolution`]
2155/// consults the same detection BEFORE its faithful raw-GFA provenance path:
2156/// these are exactly the fully-coincident-boundary configurations the raw GFA
2157/// mis-splits (coincident walls dropped into an open shell whose
2158/// position-duplicate free edges slip past the by-edge-id validation gate), so
2159/// the evolution path must route them through [`boolean`]'s shortcuts instead.
2160fn detect_trivial_relation(
2161    topo: &Topology,
2162    a: SolidId,
2163    b: SolidId,
2164    ca: Option<&brepkit_algo::classifier::AnalyticClassifier>,
2165    cb: Option<&brepkit_algo::classifier::AnalyticClassifier>,
2166    tol: brepkit_math::tolerance::Tolerance,
2167) -> TrivialRelation {
2168    // Use measure::solid_bounding_box — it expands for surface curvature
2169    // (cylinder vertex projection, sphere/torus analytic). The naive
2170    // edge-vertex sampler missed cylinder lateral extents because cylinders
2171    // only have seam vertices, leaving the AABB center on the lateral
2172    // surface where the analytic classifier returns None.
2173    let sample_aabb = |topo: &Topology, solid: SolidId| -> Option<(Point3, Point3)> {
2174        let bb = crate::measure::solid_bounding_box(topo, solid).ok()?;
2175        Some((bb.min, bb.max))
2176    };
2177    let aabb_a = sample_aabb(topo, a);
2178    let aabb_b = sample_aabb(topo, b);
2179    // AABB-encloses check (lenient): does `inner` fit inside `outer`?
2180    let aabb_encloses =
2181        |inner: &Option<(Point3, Point3)>, outer: &Option<(Point3, Point3)>| -> bool {
2182            let Some(((i_min, i_max), (o_min, o_max))) = inner.zip(*outer) else {
2183                return false;
2184            };
2185            let margin = tol.linear;
2186            i_min.x() >= o_min.x() - margin
2187                && i_min.y() >= o_min.y() - margin
2188                && i_min.z() >= o_min.z() - margin
2189                && i_max.x() <= o_max.x() + margin
2190                && i_max.y() <= o_max.y() + margin
2191                && i_max.z() <= o_max.z() + margin
2192        };
2193    // AABB-strictly-contains (strict): outer must also be ≥10% larger in
2194    // ALL 3 dims. Used as the no-classifier fallback to detect true
2195    // nested containment (e.g., a ring fully inside a shell's cavity)
2196    // without false-positives on sparse multi-shell solids (e.g., a
2197    // fuse of disjoint boxes whose AABB technically encloses another
2198    // solid's AABB while mostly being empty space).
2199    let aabb_strictly_contains =
2200        |inner: &Option<(Point3, Point3)>, outer: &Option<(Point3, Point3)>| -> bool {
2201            if !aabb_encloses(inner, outer) {
2202                return false;
2203            }
2204            let Some(((i_min, i_max), (o_min, o_max))) = inner.zip(*outer) else {
2205                return false;
2206            };
2207            let dims = [
2208                (o_max.x() - o_min.x(), i_max.x() - i_min.x()),
2209                (o_max.y() - o_min.y(), i_max.y() - i_min.y()),
2210                (o_max.z() - o_min.z(), i_max.z() - i_min.z()),
2211            ];
2212            dims.iter()
2213                .all(|(outer_d, inner_d)| *outer_d > *inner_d * 1.1)
2214        };
2215
2216    // AABB enclosure is necessary but NOT sufficient for solid
2217    // containment: a non-convex container (notched or hollow) can
2218    // AABB-enclose a solid that actually lies in its empty region.
2219    // Issue #801: `(a − b) ∪ (a ∩ b)` dropped the `a ∩ b` operand
2220    // because the unit cube's bbox fits inside the notched `a − b`'s
2221    // bbox, yet the cube lives in the carved-out notch. Confirm the
2222    // AABB-only fallback with a real point-in-solid test: reject when
2223    // the inner solid's center is provably inside `inner` yet outside
2224    // `outer`. By the containment lemma (inner ⊆ outer ⇒ every point
2225    // of inner is in outer), that witness can only occur for genuine
2226    // non-containment, so it never rejects a true containment.
2227    let center_outside =
2228        |topo: &Topology, inner: SolidId, outer: SolidId, bb: &Option<(Point3, Point3)>| -> bool {
2229            let Some((lo, hi)) = *bb else { return false };
2230            let c = Point3::new(
2231                0.5 * (lo.x() + hi.x()),
2232                0.5 * (lo.y() + hi.y()),
2233                0.5 * (lo.z() + hi.z()),
2234            );
2235            let (dx, dy, dz) = (hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z());
2236            let defl = (dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() * 0.01).max(1e-6);
2237            // Conservative by design: when the AABB center falls in `inner`'s own
2238            // concavity (a C/U-shaped solid), `inside_inner` is false and the
2239            // witness is disabled, so a false-positive containment could still
2240            // slip through. That only ever fails to *reject* — it never rejects a
2241            // true containment — so the shortcut stays sound, just not complete.
2242            let inside_inner = matches!(
2243                crate::classify::classify_point(topo, inner, c, defl, tol.linear),
2244                Ok(crate::classify::PointClassification::Inside)
2245            );
2246            let outside_outer = matches!(
2247                crate::classify::classify_point(topo, outer, c, defl, tol.linear),
2248                Ok(crate::classify::PointClassification::Outside)
2249            );
2250            inside_inner && outside_outer
2251        };
2252
2253    // Volume witness for the AABB-only fallback: `inner ⊆ outer` implies
2254    // `vol(inner) ≤ vol(outer)`, so a decisively larger inner volume proves
2255    // non-containment. This catches what `center_outside` cannot: the AABB
2256    // expansion for partial cylinder/cone faces is a conservative full-circle
2257    // bound, so a thin angular wedge's box balloons to the whole cylinder
2258    // footprint and can "strictly contain" a much bigger solid's box, while
2259    // the bigger solid's own AABB center sits in its annular hole and
2260    // disables the center witness (gh #1499, the kumiko corner cutter).
2261    // Volumes are immune to that inflation. The 1.05 factor absorbs
2262    // deflection under-counting on curved faces so a true containment is
2263    // never rejected; errors fall through to "no refutation" (shortcut
2264    // soundness is then up to the remaining witnesses, as before).
2265    let volume_refutes = |topo: &Topology, inner: SolidId, outer: SolidId| -> bool {
2266        let defl = |bb: &Option<(Point3, Point3)>| {
2267            let Some((lo, hi)) = *bb else { return 1e-3 };
2268            let (dx, dy, dz) = (hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z());
2269            (dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() * 0.01).max(1e-6)
2270        };
2271        let d = defl(&aabb_a).min(defl(&aabb_b));
2272        let (Ok(vi), Ok(vo)) = (
2273            crate::measure::solid_volume(topo, inner, d),
2274            crate::measure::solid_volume(topo, outer, d),
2275        ) else {
2276            return false;
2277        };
2278        vi > vo * 1.05
2279    };
2280
2281    // Bidirectional vertex check via the analytic classifier — the
2282    // primary signal for identical/containment classification. A vertex
2283    // classifying as inside-or-on (None within tolerance band counts
2284    // as on) means it sits within the solid's region.
2285    let all_b_verts_in_a = ca.is_some_and(|c| all_vertices_inside_or_on(topo, b, c, tol));
2286    let all_a_verts_in_b = cb.is_some_and(|c| all_vertices_inside_or_on(topo, a, c, tol));
2287    let aabbs_match = aabb_a
2288        .zip(aabb_b)
2289        .map(|((a_min, a_max), (b_min, b_max))| {
2290            let eps = tol.linear;
2291            (a_min.x() - b_min.x()).abs() < eps
2292                && (a_min.y() - b_min.y()).abs() < eps
2293                && (a_min.z() - b_min.z()).abs() < eps
2294                && (a_max.x() - b_max.x()).abs() < eps
2295                && (a_max.y() - b_max.y()).abs() < eps
2296                && (a_max.z() - b_max.z()).abs() < eps
2297        })
2298        .unwrap_or(false);
2299
2300    // Containment: A contains B when all B vertices are inside-or-on A AND
2301    // A's AABB encloses B's. Falls back to a strict AABB-only check when the
2302    // containing solid has no classifier — the strict check requires ≥10%
2303    // larger in ALL three dims so that sparse multi-shell solids (e.g., a
2304    // fuse of two disjoint boxes) don't false-positive as "contains another
2305    // solid".
2306    // Both the analytic-classifier term and the AABB-only fallback can
2307    // false-positive when the container is non-convex: the analytic
2308    // classifier may mis-report notch points as inside-or-on, and an
2309    // AABB encloses a notch's empty volume. Guard the whole determination
2310    // with the `center_outside` witness — sound for every path because it
2311    // only fires on proven non-containment (see the lemma above).
2312    let b_in_a = ((all_b_verts_in_a && aabb_encloses(&aabb_b, &aabb_a))
2313        || (ca.is_none()
2314            && aabb_strictly_contains(&aabb_b, &aabb_a)
2315            && !volume_refutes(topo, b, a)))
2316        && !center_outside(topo, b, a, &aabb_b);
2317    let a_in_b = ((all_a_verts_in_b && aabb_encloses(&aabb_a, &aabb_b))
2318        || (cb.is_none()
2319            && aabb_strictly_contains(&aabb_a, &aabb_b)
2320            && !volume_refutes(topo, a, b)))
2321        && !center_outside(topo, a, b, &aabb_a);
2322
2323    TrivialRelation {
2324        identical: aabbs_match && all_b_verts_in_a && all_a_verts_in_b,
2325        a_in_b,
2326        b_in_a,
2327    }
2328}
2329
2330/// Check whether every boundary vertex of `solid` is classified as
2331/// `Inside` or `On` by `classifier`. Used by the identical-solid shortcut
2332/// to distinguish truly-identical solids from co-located but differently
2333/// shaped solids (e.g., a cone and a box that share an AABB).
2334fn all_vertices_inside_or_on(
2335    topo: &Topology,
2336    solid: SolidId,
2337    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2338    tol: brepkit_math::tolerance::Tolerance,
2339) -> bool {
2340    let Ok(s) = topo.solid(solid) else {
2341        return false;
2342    };
2343    let Ok(sh) = topo.shell(s.outer_shell()) else {
2344        return false;
2345    };
2346    for &fid in sh.faces() {
2347        let Ok(f) = topo.face(fid) else { return false };
2348        let Ok(w) = topo.wire(f.outer_wire()) else {
2349            return false;
2350        };
2351        for oe in w.edges() {
2352            let Ok(e) = topo.edge(oe.edge()) else {
2353                return false;
2354            };
2355            for vid in [e.start(), e.end()] {
2356                let Ok(v) = topo.vertex(vid) else {
2357                    return false;
2358                };
2359                // The analytic classifier returns `None` for points within
2360                // tol.linear of the boundary — treat as "on" for this check.
2361                if classifier.classify(v.point(), tol) == Some(brepkit_algo::FaceClass::Outside) {
2362                    return false;
2363                }
2364            }
2365        }
2366    }
2367    true
2368}
2369
2370/// True when every outer-shell vertex of `inner` classifies as *strictly*
2371/// `Inside` (not on the boundary) of `classifier`. A strictly-contained tool
2372/// has no surface contact with the blank, so `Cut(blank, tool)` is a clean
2373/// internal cavity rather than a notch through the boundary.
2374fn solid_strictly_inside(
2375    topo: &Topology,
2376    inner: SolidId,
2377    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2378    tol: brepkit_math::tolerance::Tolerance,
2379) -> bool {
2380    let Ok(s) = topo.solid(inner) else {
2381        return false;
2382    };
2383    let Ok(sh) = topo.shell(s.outer_shell()) else {
2384        return false;
2385    };
2386    let mut saw_vertex = false;
2387    for &fid in sh.faces() {
2388        let Ok(f) = topo.face(fid) else { return false };
2389        // Check the outer wire and any inner (hole) wires — a hole boundary on
2390        // a simple solid's face can also reach the blank's surface.
2391        let mut wires = vec![f.outer_wire()];
2392        wires.extend_from_slice(f.inner_wires());
2393        for wid in wires {
2394            let Ok(w) = topo.wire(wid) else {
2395                return false;
2396            };
2397            for oe in w.edges() {
2398                let Ok(e) = topo.edge(oe.edge()) else {
2399                    return false;
2400                };
2401                for vid in [e.start(), e.end()] {
2402                    let Ok(v) = topo.vertex(vid) else {
2403                        return false;
2404                    };
2405                    if classifier.classify(v.point(), tol) != Some(brepkit_algo::FaceClass::Inside)
2406                    {
2407                        return false;
2408                    }
2409                    saw_vertex = true;
2410                }
2411            }
2412        }
2413    }
2414    saw_vertex
2415}
2416
2417/// Build `Cut(blank, tool)` for a tool strictly contained in the blank: the
2418/// result is the blank with a tool-shaped internal cavity. Deep-copies the
2419/// blank and the tool, reverses every copied tool face in place so the cavity
2420/// boundary faces into the void, and attaches the reversed tool shell to the
2421/// copied blank as an inner shell. Bypasses GFA, whose no-intersection assembly
2422/// drops fully-contained cone/torus tools.
2423fn build_contained_cut_hollow(
2424    topo: &mut Topology,
2425    blank: SolidId,
2426    tool: SolidId,
2427) -> Result<SolidId, crate::OperationsError> {
2428    let result = crate::copy::copy_solid(topo, blank)?;
2429
2430    // Deep-copy the tool as a whole solid so the cavity shell shares edges and
2431    // vertices between adjacent faces (a per-face copy would duplicate shared
2432    // boundary edges and leave the cavity non-manifold — wrong Euler, though
2433    // per-face volume is unaffected). Reverse each copied face in place and
2434    // reuse the copied outer shell directly as the cavity inner shell, so no
2435    // duplicate faces or extra result solid are created.
2436    let tool_copy = crate::copy::copy_solid(topo, tool)?;
2437    let cavity_shell = topo.solid(tool_copy)?.outer_shell();
2438    let cavity_faces = topo.shell(cavity_shell)?.faces().to_vec();
2439    for fid in cavity_faces {
2440        let face = topo.face_mut(fid)?;
2441        let flipped = !face.is_reversed();
2442        face.set_reversed(flipped);
2443    }
2444    topo.solid_mut(result)?.add_inner_shell(cavity_shell);
2445    Ok(result)
2446}
2447
2448/// Best-effort mesh boolean fallback for high face-count solids.
2449///
2450/// Tessellates both solids, runs mesh co-refinement, assembles the result,
2451/// and applies the same post-processing as the other boolean paths.
2452/// Returns `Err` on any failure so the caller can fall through to the
2453/// chord-based path.
2454fn mesh_boolean_fallback(
2455    topo: &mut Topology,
2456    op: BooleanOp,
2457    a: SolidId,
2458    b: SolidId,
2459    deflection: f64,
2460    tol: brepkit_math::tolerance::Tolerance,
2461    opts: &BooleanOptions,
2462) -> Result<SolidId, crate::OperationsError> {
2463    // Mesh density here is a boolean-robustness concern, independent of the
2464    // rendering tolerance: use the linear-only criterion (angular_tol 0.0) so
2465    // the face count is unaffected by the display deflection cap, AND keep the
2466    // circle curvature floor so co-refinement gets the denser circular sampling
2467    // it needs (display tessellation drops that floor for triangle count).
2468    let mesh_a = crate::tessellate::tessellate_solid_for_boolean(topo, a, deflection, 0.0)?;
2469    let mesh_b = crate::tessellate::tessellate_solid_for_boolean(topo, b, deflection, 0.0)?;
2470    log::debug!(
2471        "mesh fallback {op:?}: tessellated operands to {} + {} triangles at deflection {deflection}",
2472        mesh_a.indices.len() / 3,
2473        mesh_b.indices.len() / 3,
2474    );
2475
2476    let mb_result = crate::mesh_boolean::mesh_boolean(&mesh_a, &mesh_b, op, tol.linear)?;
2477    if mb_result.boundary_edge_count > 0 || mb_result.non_manifold_edge_count > 0 {
2478        log::warn!(
2479            "boolean {op:?}: mesh boolean fallback output is NOT a closed 2-manifold \
2480             ({} boundary edge(s), {} non-manifold edge(s) after position welding) — \
2481             downstream healing may not recover; exported geometry may be broken",
2482            mb_result.boundary_edge_count,
2483            mb_result.non_manifold_edge_count,
2484        );
2485    }
2486    let face_specs = mesh_result_to_face_specs(&mb_result);
2487    if face_specs.is_empty() {
2488        return Err(crate::OperationsError::EmptyResult {
2489            reason: "mesh boolean produced no output faces".into(),
2490        });
2491    }
2492    log::debug!(
2493        "mesh fallback {op:?}: {} face specs -> assemble_solid_mixed",
2494        face_specs.len()
2495    );
2496    let result = assemble_solid_mixed(topo, &face_specs, tol)?;
2497    let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
2498    if opts.unify_faces {
2499        let _ = crate::heal::unify_faces(topo, result)?;
2500    }
2501    // Cross-face symmetrization: tessellation diagonals that one face
2502    // dropped while its neighbour kept (#696) leave structurally
2503    // orphan collinear interior wire vertices. Collapse those so both
2504    // sides reference the same EdgeId for the shared 3D segment,
2505    // eliminating the residual non-manifold edges that `unify_faces`
2506    // can't symmetrize from per-face surface matching alone.
2507    let collapsed =
2508        brepkit_heal::upgrade::collapse_collinear_vertices::collapse_collinear_wire_vertices(
2509            topo, result, tol,
2510        )
2511        .unwrap_or_else(|e| {
2512            log::warn!("boolean {op:?}: collapse_collinear_wire_vertices failed: {e}");
2513            0
2514        });
2515    if collapsed > 0 {
2516        log::info!(
2517            "boolean {op:?}: collapsed {collapsed} collinear interior wire vertex/vertices post-mesh-assembly",
2518        );
2519    }
2520    // Mesh-fallback can glue two physically-separate holes into a
2521    // single figure-8 inner wire via diagonal "bridge" edges across
2522    // gap material (#696 cumulative pattern: a slab top with multiple
2523    // pocket cuts ends up with one self-intersecting inner wire that
2524    // visits each pocket region). Split such wires at every pinch
2525    // vertex so each physical hole is its own simple inner wire —
2526    // the resulting topology is well-formed for downstream
2527    // tessellation, validation, and STEP export, even when the
2528    // bridge edges themselves remain as boundary edges (those are a
2529    // separate cleanup).
2530    let wires_split =
2531        brepkit_heal::upgrade::split_self_intersecting_wires::split_self_intersecting_inner_wires(
2532            topo, result,
2533        )
2534        .unwrap_or_else(|e| {
2535            log::warn!("boolean {op:?}: split_self_intersecting_inner_wires failed: {e}");
2536            0
2537        });
2538    if wires_split > 0 {
2539        log::info!(
2540            "boolean {op:?}: split {wires_split} self-intersecting inner wire(s) post-mesh-assembly",
2541        );
2542    }
2543    if opts.heal_after_boolean {
2544        let _ = crate::heal::heal_solid(topo, result, tol.linear)?;
2545    }
2546    assembly::validate_boolean_result_lenient(topo, result)?;
2547    log::info!(
2548        "boolean {op:?}: mesh boolean path → solid {} ({} faces, surface types lost)",
2549        result.index(),
2550        face_specs.len()
2551    );
2552    Ok(result)
2553}
2554
2555/// Convert a mesh boolean result into `FaceSpec` entries for solid assembly.
2556fn mesh_result_to_face_specs(result: &crate::mesh_boolean::MeshBooleanResult) -> Vec<FaceSpec> {
2557    let mut specs = Vec::new();
2558    for tri in result.mesh.indices.chunks_exact(3) {
2559        let v0 = result.mesh.positions[tri[0] as usize];
2560        let v1 = result.mesh.positions[tri[1] as usize];
2561        let v2 = result.mesh.positions[tri[2] as usize];
2562
2563        let edge1 = v1 - v0;
2564        let edge2 = v2 - v0;
2565        let Ok(normal) = edge1.cross(edge2).normalize() else {
2566            continue;
2567        };
2568        let d = crate::dot_normal_point(normal, v0);
2569        specs.push(FaceSpec::Planar {
2570            vertices: vec![v0, v1, v2],
2571            normal,
2572            d,
2573            inner_wires: vec![],
2574        });
2575    }
2576    specs
2577}
2578
2579/// True when the outer-shell face components represent disjoint solid
2580/// pieces (e.g., a previous cut split one solid into N parts), false
2581/// when one component is concentric inside another (a hollow solid:
2582/// outer surface + cavity surface both live in the outer shell).
2583///
2584/// The check is AABB-based: if any component's bounding box is
2585/// strictly contained in another's, treat the whole solid as hollow
2586/// and skip the multi-region split path.
2587/// Check that every component's AABB centre classifies as outside the
2588/// supplied classifier. Used to reject multi-region GFA Cut results that
2589/// erroneously include the tool's interior as one of the pieces.
2590fn all_component_centers_outside(
2591    topo: &Topology,
2592    components: &[Vec<FaceId>],
2593    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2594    tol: brepkit_math::tolerance::Tolerance,
2595) -> bool {
2596    use brepkit_algo::FaceClass;
2597    for comp in components {
2598        let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2599        let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2600        for &fid in comp {
2601            let Ok(face) = topo.face(fid) else { continue };
2602            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2603            {
2604                let Ok(wire) = topo.wire(wid) else { continue };
2605                for oe in wire.edges() {
2606                    let Ok(edge) = topo.edge(oe.edge()) else {
2607                        continue;
2608                    };
2609                    for vid in [edge.start(), edge.end()] {
2610                        if let Ok(v) = topo.vertex(vid) {
2611                            let p = v.point();
2612                            min = Point3::new(
2613                                min.x().min(p.x()),
2614                                min.y().min(p.y()),
2615                                min.z().min(p.z()),
2616                            );
2617                            max = Point3::new(
2618                                max.x().max(p.x()),
2619                                max.y().max(p.y()),
2620                                max.z().max(p.z()),
2621                            );
2622                        }
2623                    }
2624                }
2625            }
2626        }
2627        let centre = Point3::new(
2628            (min.x() + max.x()) * 0.5,
2629            (min.y() + max.y()) * 0.5,
2630            (min.z() + max.z()) * 0.5,
2631        );
2632        if matches!(classifier.classify(centre, tol), Some(FaceClass::Inside)) {
2633            return false;
2634        }
2635    }
2636    true
2637}
2638
2639/// Centre of a face component's vertex AABB, or `None` for an empty component.
2640fn component_aabb_centre(topo: &Topology, comp: &[FaceId]) -> Option<Point3> {
2641    let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2642    let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2643    for &fid in comp {
2644        let Ok(face) = topo.face(fid) else { continue };
2645        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
2646            let Ok(wire) = topo.wire(wid) else { continue };
2647            for oe in wire.edges() {
2648                let Ok(edge) = topo.edge(oe.edge()) else {
2649                    continue;
2650                };
2651                for vid in [edge.start(), edge.end()] {
2652                    if let Ok(v) = topo.vertex(vid) {
2653                        let p = v.point();
2654                        min =
2655                            Point3::new(min.x().min(p.x()), min.y().min(p.y()), min.z().min(p.z()));
2656                        max =
2657                            Point3::new(max.x().max(p.x()), max.y().max(p.y()), max.z().max(p.z()));
2658                    }
2659                }
2660            }
2661        }
2662    }
2663    if min.x() > max.x() {
2664        return None;
2665    }
2666    Some(Point3::new(
2667        (min.x() + max.x()) * 0.5,
2668        (min.y() + max.y()) * 0.5,
2669        (min.z() + max.z()) * 0.5,
2670    ))
2671}
2672
2673/// Does the closed surface made of `faces` enclose `p`?
2674///
2675/// Ray-parity against the component's own tessellation. Read-only by design:
2676/// building a temporary solid per component would add entities to an arena that
2677/// never reclaims, which is the growth cliff fixed in #1237.
2678///
2679/// `watertight_ray_triangle_intersect` reports exactly one hit on a shared edge,
2680/// so parity is meaningful across face boundaries. The direction is deliberately
2681/// irrational so the ray does not graze a face boundary or lie in a face plane —
2682/// the degeneracy that makes axis-aligned probes unreliable on the feature-plane
2683/// intersections these pieces are full of. Returns `None` when the component
2684/// cannot be tessellated, so callers can fall back rather than guess.
2685fn component_encloses_point(
2686    topo: &Topology,
2687    faces: &[FaceId],
2688    p: Point3,
2689    deflection: f64,
2690) -> Option<bool> {
2691    // A sqrt-prime direction: irrational in every component, so the ray cannot
2692    // lie in a face plane or run along an edge — the same generic-direction
2693    // escape the ray-cast classifier uses for degenerate probes.
2694    let dir = Vec3::new(2.0_f64.sqrt(), 3.0_f64.sqrt(), 5.0_f64.sqrt())
2695        .normalize()
2696        .ok()?;
2697    let mut crossings = 0usize;
2698    let mut any_triangle = false;
2699    for &fid in faces {
2700        let mesh = crate::tessellate::tessellate_with_uvs(topo, fid, deflection).ok()?;
2701        let pos = &mesh.mesh.positions;
2702        for tri in mesh.mesh.indices.chunks_exact(3) {
2703            let (a, b, c) = (
2704                pos[tri[0] as usize],
2705                pos[tri[1] as usize],
2706                pos[tri[2] as usize],
2707            );
2708            any_triangle = true;
2709            if let Some(hit) =
2710                brepkit_math::ray_triangle::watertight_ray_triangle_intersect(p, dir, a, b, c)
2711                && hit.t > 1e-9
2712            {
2713                crossings += 1;
2714            }
2715        }
2716    }
2717    any_triangle.then_some(crossings % 2 == 1)
2718}
2719
2720/// Any vertex position on `faces`, for use as a probe point.
2721fn any_vertex_of(topo: &Topology, faces: &[FaceId]) -> Option<Point3> {
2722    for &fid in faces {
2723        let face = topo.face(fid).ok()?;
2724        let wire = topo.wire(face.outer_wire()).ok()?;
2725        if let Some(oe) = wire.edges().first()
2726            && let Ok(edge) = topo.edge(oe.edge())
2727            && let Ok(v) = topo.vertex(edge.start())
2728        {
2729            return Some(v.point());
2730        }
2731    }
2732    None
2733}
2734
2735fn components_are_disjoint_pieces(topo: &Topology, components: &[Vec<FaceId>]) -> bool {
2736    let aabbs: Vec<(Point3, Point3)> = components
2737        .iter()
2738        .map(|comp| {
2739            let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2740            let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2741            for &fid in comp {
2742                let Ok(face) = topo.face(fid) else {
2743                    continue;
2744                };
2745                for wid in
2746                    std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2747                {
2748                    let Ok(wire) = topo.wire(wid) else {
2749                        continue;
2750                    };
2751                    for oe in wire.edges() {
2752                        let Ok(edge) = topo.edge(oe.edge()) else {
2753                            continue;
2754                        };
2755                        for vid in [edge.start(), edge.end()] {
2756                            if let Ok(v) = topo.vertex(vid) {
2757                                let p = v.point();
2758                                min = Point3::new(
2759                                    min.x().min(p.x()),
2760                                    min.y().min(p.y()),
2761                                    min.z().min(p.z()),
2762                                );
2763                                max = Point3::new(
2764                                    max.x().max(p.x()),
2765                                    max.y().max(p.y()),
2766                                    max.z().max(p.z()),
2767                                );
2768                            }
2769                        }
2770                    }
2771                }
2772            }
2773            (min, max)
2774        })
2775        .collect();
2776
2777    // Reject NESTING, not mere AABB overlap.
2778    //
2779    // Nesting is the hazard worth rejecting (a blob sitting inside another
2780    // piece's cavity is not a disjoint union); side-by-side pieces are exactly
2781    // what multi-region acceptance is for.
2782    //
2783    // Assume nothing about the components handed in. The acceptance gate calls
2784    // this on a GFA result that has cleared only `euler_balanced` — which is
2785    // genus-tolerant, and whose `closed_manifold` companion is a LATER conjunct
2786    // in the same `&&` chain, not a precondition. The input-splitting paths
2787    // call it on components of a raw operand no gate has examined at all. So
2788    // "every piece is a closed manifold, hence disjoint-or-nested" is not
2789    // available here; the ray-parity confirmation below earns the answer
2790    // instead of inferring it.
2791    //
2792    // Overlap is the wrong predicate for that, because an AABB is only tight on
2793    // axis-aligned geometry. Two ROTATED bars a clear distance apart each span
2794    // the whole diagonal envelope, so their boxes interpenetrate and an
2795    // overlap test calls them touching — which is why a kumiko lattice cut,
2796    // whose members are diagonal, could never be accepted and fell back to the
2797    // mesh path on every band (see
2798    // `tests::rotated_separate_pieces_are_recognised_as_disjoint`). Containment
2799    // is tight in the direction that matters: nesting implies it, and rotation
2800    // does not manufacture it.
2801    let eps = 1e-7;
2802    let contains = |(o_min, o_max): (Point3, Point3), (i_min, i_max): (Point3, Point3)| {
2803        o_min.x() - eps <= i_min.x()
2804            && o_min.y() - eps <= i_min.y()
2805            && o_min.z() - eps <= i_min.z()
2806            && o_max.x() + eps >= i_max.x()
2807            && o_max.y() + eps >= i_max.y()
2808            && o_max.z() + eps >= i_max.z()
2809    };
2810    // AABB containment is only the PRE-FILTER. It is necessary for nesting but
2811    // far from sufficient: a ring's box contains the box of a separate piece
2812    // sitting in its HOLE, and a lattice is full of rings. So a suspect pair
2813    // gets a real ray-parity test against the enclosing candidate's own surface,
2814    // and only genuine enclosure rejects. If the probe cannot be evaluated the
2815    // pair falls back to the conservative answer.
2816    for i in 0..aabbs.len() {
2817        for j in (i + 1)..aabbs.len() {
2818            let (outer, inner) = if contains(aabbs[i], aabbs[j]) {
2819                (i, j)
2820            } else if contains(aabbs[j], aabbs[i]) {
2821                (j, i)
2822            } else {
2823                continue;
2824            };
2825            let (o_min, o_max) = aabbs[outer];
2826            let diag = ((o_max.x() - o_min.x()).powi(2)
2827                + (o_max.y() - o_min.y()).powi(2)
2828                + (o_max.z() - o_min.z()).powi(2))
2829            .sqrt();
2830            let deflection = (diag / 200.0).max(1e-4);
2831            let Some(probe) = any_vertex_of(topo, &components[inner]) else {
2832                return false;
2833            };
2834            match component_encloses_point(topo, &components[outer], probe, deflection) {
2835                Some(true) => return false,
2836                Some(false) => {}
2837                None => return false,
2838            }
2839        }
2840    }
2841    true
2842}
2843
2844/// Fuse a multi-component TOOL by folding its disjoint pieces into the
2845/// target one at a time.
2846///
2847/// Each piece is copied into a fresh connected solid (the pavefiller
2848/// stumbles on shared vertex IDs across what it considers one "solid B")
2849/// and fused via the full `boolean` entry, so every per-piece fuse gets the
2850/// analytic path, gates, and fallbacks. Fuse distributes over a
2851/// disjoint-union tool, so the fold is exact. Recursion terminates: each
2852/// piece is single-component, so the recursive call never re-enters this
2853/// path.
2854fn fuse_multi_component_tool(
2855    topo: &mut Topology,
2856    a: SolidId,
2857    b_components: Vec<Vec<brepkit_topology::face::FaceId>>,
2858) -> Result<SolidId, crate::OperationsError> {
2859    let mut result = a;
2860    for comp_faces in b_components {
2861        let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2862        let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2863        result = boolean(topo, BooleanOp::Fuse, result, comp_solid)?;
2864    }
2865    Ok(result)
2866}
2867
2868/// Cut a multi-region input solid: split the components, cut each
2869/// against `b` independently, then combine the per-component results
2870/// back into a single multi-region solid.
2871///
2872/// This works around the GFA pavefiller's assumption of a single
2873/// connected input — feeding a 2-piece "solid" into GFA loses one piece
2874/// at a time as the cut proceeds (Category B `multiple cuts creating
2875/// three pieces` and gear bore are both downstream of this).
2876fn cut_multi_region_input(
2877    topo: &mut Topology,
2878    a: SolidId,
2879    b: SolidId,
2880    comp_count: usize,
2881) -> Result<SolidId, crate::OperationsError> {
2882    let components = crate::boolean::assembly::face_components(topo, a);
2883    debug_assert_eq!(components.len(), comp_count);
2884
2885    let mut per_component_results: Vec<SolidId> = Vec::with_capacity(components.len());
2886    for comp_faces in components {
2887        // Copy the component's faces into a fresh single-component solid
2888        // so the boolean engine sees a connected manifold.
2889        let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2890        // Deep-copy the component into a fresh solid so its faces/edges/
2891        // vertices have fresh IDs disjoint from the original multi-region
2892        // input — GFA's pavefiller can stumble on shared vertex IDs across
2893        // what it considers a single "solid A".
2894        let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2895        match boolean(topo, BooleanOp::Cut, comp_solid, b) {
2896            Ok(r) => per_component_results.push(r),
2897            Err(
2898                crate::OperationsError::EmptyResult { .. }
2899                | crate::OperationsError::InvalidInput { .. },
2900            ) => {
2901                per_component_results.push(comp_solid);
2902            }
2903            Err(e) => return Err(e),
2904        }
2905    }
2906
2907    // Combine all per-component results into a single multi-region solid.
2908    // Collect every face from every result into one outer shell. The
2909    // results are pairwise disjoint by construction (each came from a
2910    // disjoint input component cut by the same tool), so a single shell
2911    // containing all their faces is a valid manifold representation.
2912    let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
2913    for &r in &per_component_results {
2914        let r_data = topo.solid(r)?;
2915        for &fid in topo.shell(r_data.outer_shell())?.faces() {
2916            all_faces.push(fid);
2917        }
2918    }
2919    make_solid_from_face_subset(topo, &all_faces)
2920}
2921
2922/// Build a new solid whose outer shell consists exactly of the given
2923/// faces. Faces are referenced as-is (no copying) — the caller is
2924/// expected to pass faces that already form a closed manifold.
2925///
2926/// `reversed=true` faces are NORMALIZED on the way in: a fresh face is
2927/// created with the surface normal negated, the wires reversed, and
2928/// `reversed=false`. Boolean operations downstream are sensitive to the
2929/// `reversed` flag (cut1's output carries reversed faces that GFA can't
2930/// re-process cleanly even via deep-copy), so handing GFA an
2931/// orientation-normalized solid recovers the fresh-primitive code path.
2932fn make_solid_from_face_subset(
2933    topo: &mut Topology,
2934    faces: &[brepkit_topology::face::FaceId],
2935) -> Result<SolidId, crate::OperationsError> {
2936    use brepkit_topology::face::{Face, FaceSurface};
2937    use brepkit_topology::wire::{OrientedEdge, Wire};
2938
2939    let mut normalized: Vec<brepkit_topology::face::FaceId> = Vec::with_capacity(faces.len());
2940    for &fid in faces {
2941        let face = topo.face(fid)?;
2942        if !face.is_reversed() {
2943            normalized.push(fid);
2944            continue;
2945        }
2946        // Only Plane has a trivial negate-the-normal flip. Non-planar
2947        // reversed faces (cylinder/cone/sphere/torus/nurbs) cannot have
2948        // their surface negated cheaply — they hit surface-specific GFA
2949        // paths that don't suffer from the same reversed-flag sensitivity.
2950        // Exhaustive match so a new FaceSurface variant fails to compile
2951        // rather than silently passing through un-normalized.
2952        let flipped_surface = match face.surface() {
2953            FaceSurface::Plane { normal, d } => FaceSurface::Plane {
2954                normal: -*normal,
2955                d: -*d,
2956            },
2957            FaceSurface::Nurbs(_)
2958            | FaceSurface::Cylinder(_)
2959            | FaceSurface::Cone(_)
2960            | FaceSurface::Sphere(_)
2961            | FaceSurface::Torus(_) => {
2962                normalized.push(fid);
2963                continue;
2964            }
2965        };
2966        let outer_wid = face.outer_wire();
2967        let inner_wids: Vec<_> = face.inner_wires().to_vec();
2968        let outer_wire = topo.wire(outer_wid)?;
2969        let outer_reversed: Vec<OrientedEdge> = outer_wire
2970            .edges()
2971            .iter()
2972            .rev()
2973            .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
2974            .collect();
2975        let new_outer_wire =
2976            Wire::new(outer_reversed, true).map_err(crate::OperationsError::Topology)?;
2977        let new_outer_wid = topo.add_wire(new_outer_wire);
2978        let mut new_inner_wids = Vec::with_capacity(inner_wids.len());
2979        for iw in &inner_wids {
2980            let w = topo.wire(*iw)?;
2981            let rev: Vec<OrientedEdge> = w
2982                .edges()
2983                .iter()
2984                .rev()
2985                .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
2986                .collect();
2987            let new_w = Wire::new(rev, true).map_err(crate::OperationsError::Topology)?;
2988            new_inner_wids.push(topo.add_wire(new_w));
2989        }
2990        let new_face = Face::new(new_outer_wid, new_inner_wids, flipped_surface);
2991        normalized.push(topo.add_face(new_face));
2992    }
2993
2994    let shell = brepkit_topology::shell::Shell::new(normalized)
2995        .map_err(crate::OperationsError::Topology)?;
2996    let shell_id = topo.add_shell(shell);
2997    let solid = brepkit_topology::solid::Solid::new(shell_id, Vec::new());
2998    Ok(topo.add_solid(solid))
2999}
3000
3001/// Count inner wire loops across all faces of a solid (outer + inner shells).
3002fn solid_inner_wire_count(topo: &Topology, solid: SolidId) -> Result<i64, crate::OperationsError> {
3003    let mut count: i64 = 0;
3004    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3005        let face = topo.face(fid)?;
3006        #[allow(clippy::cast_possible_wrap)]
3007        {
3008            count += face.inner_wires().len() as i64;
3009        }
3010    }
3011    Ok(count)
3012}
3013
3014/// Genus-aware Euler balance for `components` closed orientable surfaces with
3015/// holed faces.
3016///
3017/// Euler-Poincare over `C` closed components of total genus `G`:
3018/// `V - E + F - L = 2C - 2G`, so the inner-wire surplus `euler - L` is valid
3019/// when it is even and no greater than `2C` — `2C` for all-genus-0 pieces, less
3020/// by two per unit of genus (a thin wall pierced by N through-holes has genus
3021/// N). Odd or `> 2C` surpluses indicate a miscounted shell.
3022///
3023/// The `2C` bound matters as much as the parity: a multi-region result is not
3024/// obliged to be a bag of spheres. A kumiko lattice cut yields RINGS, and a
3025/// closed loop of material is genus 1 (`chi = 0`), so demanding `euler == 2C`
3026/// exactly rejected every lattice result and forced it onto the mesh path.
3027///
3028/// Callers must pair this with a closed-manifold check — the relation only holds
3029/// for closed surfaces.
3030const fn euler_balanced(euler: i64, inner_wires: i64, components: i64) -> bool {
3031    let surplus = euler - inner_wires;
3032    surplus <= components.saturating_mul(2) && surplus % 2 == 0
3033}
3034
3035/// Count edge uses across ALL shells of a solid (outer + inner cavity
3036/// shells). Hollow solids keep cavity faces in inner shells — an
3037/// outer-shell-only walk silently misses their edges, letting open or
3038/// non-manifold cavity shells pass the acceptance gates.
3039fn solid_edge_use_counts(
3040    topo: &Topology,
3041    solid: SolidId,
3042) -> Result<std::collections::HashMap<usize, usize>, crate::OperationsError> {
3043    let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3044    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3045        let face = topo.face(fid)?;
3046        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3047            let wire = topo.wire(wid)?;
3048            for oe in wire.edges() {
3049                *counts.entry(oe.edge().index()).or_insert(0) += 1;
3050            }
3051        }
3052    }
3053    Ok(counts)
3054}
3055
3056/// Check whether every shell of a solid is a closed manifold: every edge
3057/// is shared by exactly 2 faces within its shell. Returns `false` for open
3058/// shells (boundary edges with count == 1) and non-manifold shells
3059/// (count > 2). Walks inner (cavity) shells as well as the outer shell —
3060/// each shell is an independent closed surface, so a single pooled count
3061/// per shell is correct.
3062///
3063/// Stricter than [`brepkit_topology::validation::validate_shell_manifold`],
3064/// which only rejects edges shared by *more* than two faces.
3065fn is_closed_manifold(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3066    let s = topo.solid(solid)?;
3067    let shell_ids: Vec<_> = std::iter::once(s.outer_shell())
3068        .chain(s.inner_shells().iter().copied())
3069        .collect();
3070    for shell_id in shell_ids {
3071        let shell = topo.shell(shell_id)?;
3072        if !shell_is_closed_manifold(topo, shell)? {
3073            return Ok(false);
3074        }
3075    }
3076    Ok(true)
3077}
3078
3079fn shell_is_closed_manifold(
3080    topo: &Topology,
3081    shell: &brepkit_topology::shell::Shell,
3082) -> Result<bool, crate::OperationsError> {
3083    use std::collections::HashMap;
3084
3085    let mut counts: HashMap<usize, usize> = HashMap::new();
3086    for &fid in shell.faces() {
3087        let face = topo.face(fid)?;
3088        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3089            let wire = topo.wire(wid)?;
3090            for oe in wire.edges() {
3091                *counts.entry(oe.edge().index()).or_insert(0) += 1;
3092            }
3093        }
3094    }
3095    if counts.is_empty() {
3096        return Ok(false);
3097    }
3098    Ok(counts.values().all(|&c| c == 2))
3099}
3100
3101/// Check whether a solid's boundary has free edges: edges used by only
3102/// one wire occurrence. A free edge means the shell is open (e.g. a phantom
3103/// membrane face left a circle edge unmatched), which is never a valid
3104/// boolean result even when Euler accidentally balances.
3105fn has_free_edges(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3106    let counts = solid_edge_use_counts(topo, solid)?;
3107    Ok(counts.values().any(|&c| c == 1))
3108}
3109
3110/// Cheap read-only test for whether [`flatten_planar_nurbs_faces`] would change
3111/// anything: does `solid` carry a planar NURBS face or a straight NURBS edge?
3112/// Used to gate the deep-copy-and-flatten pre-pass so analytic operands are
3113/// passed to the engine unchanged (a needless deep copy renumbers entity ids
3114/// and can perturb the engine's id-keyed ordering on volume-sensitive cuts).
3115///
3116/// `tol` must match the linear tolerance passed to [`flatten_planar_nurbs_faces`]
3117/// so the gate and the pass agree: a looser default here could report "nothing
3118/// to flatten" while the pass (run at the operation tolerance) would in fact
3119/// rewrite geometry, reintroducing the NURBS-vs-plane fragmentation.
3120fn solid_has_flattenable_nurbs(
3121    topo: &Topology,
3122    solid: SolidId,
3123    tol: f64,
3124) -> Result<bool, crate::OperationsError> {
3125    use brepkit_geometry::convert::{
3126        RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3127    };
3128    use brepkit_topology::edge::EdgeCurve;
3129    use brepkit_topology::explorer::solid_faces;
3130
3131    let mut seen = std::collections::HashSet::new();
3132    for fid in solid_faces(topo, solid)? {
3133        let face = topo.face(fid)?;
3134        if let FaceSurface::Nurbs(nurbs) = face.surface()
3135            && matches!(
3136                recognize_surface(nurbs, tol),
3137                RecognizedSurface::Plane { .. }
3138            )
3139        {
3140            return Ok(true);
3141        }
3142        for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3143            let wire = topo.wire(wid)?;
3144            for oe in wire.edges() {
3145                let eid = oe.edge();
3146                if !seen.insert(eid.index()) {
3147                    continue;
3148                }
3149                if let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve()
3150                    && matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. })
3151                {
3152                    return Ok(true);
3153                }
3154            }
3155        }
3156    }
3157    Ok(false)
3158}
3159
3160/// Replace planar NURBS faces of `solid` with analytic `Plane` surfaces, and
3161/// straight NURBS boundary edges with the `Line` variant.
3162///
3163/// A NURBS surface whose every control point lies within `tol` of a single
3164/// plane is geometrically a plane; the tool's rounded-rect extrude emits the
3165/// straight cavity walls as planar B-splines, and the boolean engine's
3166/// face-face intersections only take the exact (same-domain) plane×plane path
3167/// when both operands are `FaceSurface::Plane`. Recognising the flat walls as
3168/// planes before the boolean lets coincident/abutting wall regions merge
3169/// analytically instead of fragmenting through the NURBS surface-intersection
3170/// path.
3171///
3172/// The same extrude also leaves the straight cavity-floor/wall boundary edges
3173/// as NURBS curves. A planar-arrangement splitter treats every non-`Line` edge
3174/// as an arc and bails when one is split mid-edge by a coplanar section, so a
3175/// straight NURBS floor edge crossed by the scoop footprint forces the floor
3176/// face to a self-crossing trace. Recognising those straight NURBS edges as
3177/// `Line` lets the arrangement split them exactly.
3178///
3179/// Genuinely curved NURBS surfaces/edges (and all other analytic geometry) are
3180/// left untouched. Returns the number of faces flattened.
3181fn flatten_planar_nurbs_faces(
3182    topo: &mut Topology,
3183    solid: SolidId,
3184    tol: f64,
3185) -> Result<usize, crate::OperationsError> {
3186    use brepkit_geometry::convert::{
3187        RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3188    };
3189    use brepkit_topology::edge::{EdgeCurve, EdgeId};
3190    use brepkit_topology::explorer::solid_faces;
3191
3192    let face_ids = solid_faces(topo, solid)?;
3193    // Snapshot the surfaces first (immutable borrow), then mutate.
3194    let planar: Vec<(FaceId, Vec3, f64)> = face_ids
3195        .iter()
3196        .filter_map(|&fid| {
3197            let face = topo.face(fid).ok()?;
3198            let FaceSurface::Nurbs(nurbs) = face.surface() else {
3199                return None;
3200            };
3201            match recognize_surface(nurbs, tol) {
3202                RecognizedSurface::Plane { normal, d } => {
3203                    // `recognize_surface` derives the plane normal from a
3204                    // control-point cross product, whose sign can OPPOSE the
3205                    // NURBS surface's own du×dv normal. A `FaceSurface::Plane`
3206                    // is read with its normal flipped by `is_reversed`, so an
3207                    // opposed sign silently inverts the face's effective
3208                    // outward direction. Align to the surface du×dv normal at
3209                    // the domain midpoint.
3210                    let (u0, u1) = nurbs.domain_u();
3211                    let (v0, v1) = nurbs.domain_v();
3212                    let mid_n = nurbs.normal(0.5 * (u0 + u1), 0.5 * (v0 + v1)).ok();
3213                    let (normal, d) = match mid_n {
3214                        Some(n) if normal.dot(n) < 0.0 => (-normal, -d),
3215                        _ => (normal, d),
3216                    };
3217                    Some((fid, normal, d))
3218                }
3219                _ => None,
3220            }
3221        })
3222        .collect();
3223    let count = planar.len();
3224    for (fid, normal, d) in planar {
3225        topo.face_mut(fid)?
3226            .set_surface(FaceSurface::Plane { normal, d });
3227    }
3228
3229    // Straighten NURBS edges that are geometrically lines.
3230    let mut straight_edges: Vec<EdgeId> = Vec::new();
3231    let mut seen = std::collections::HashSet::new();
3232    for &fid in &face_ids {
3233        let face = topo.face(fid)?;
3234        for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3235            let wire = topo.wire(wid)?;
3236            for oe in wire.edges() {
3237                let eid = oe.edge();
3238                if !seen.insert(eid.index()) {
3239                    continue;
3240                }
3241                let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve() else {
3242                    continue;
3243                };
3244                if matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. }) {
3245                    straight_edges.push(eid);
3246                }
3247            }
3248        }
3249    }
3250    for eid in straight_edges {
3251        topo.edge_mut(eid)?.set_curve(EdgeCurve::Line);
3252    }
3253
3254    Ok(count)
3255}
3256
3257/// Test-only access to [`flatten_planar_nurbs_faces`] so integration tests can
3258/// reproduce the exact operand preprocessing the boolean applies before handing
3259/// the operands to the GFA engine.
3260#[doc(hidden)]
3261pub fn flatten_planar_nurbs_faces_for_tests(
3262    topo: &mut Topology,
3263    solid: SolidId,
3264    tol: f64,
3265) -> Result<usize, crate::OperationsError> {
3266    flatten_planar_nurbs_faces(topo, solid, tol)
3267}
3268
3269/// For each vertex position (quantized at tolerance), picks one canonical
3270/// vertex. Rebuilds all edges and wires to use canonical vertices.
3271/// Creates new edges (doesn't mutate existing ones) to avoid corrupting
3272/// input solids that may share edge topology.
3273#[allow(clippy::items_after_statements, clippy::type_complexity)]
3274fn merge_result_vertices(
3275    topo: &mut Topology,
3276    solid: SolidId,
3277    tol: brepkit_math::tolerance::Tolerance,
3278) -> Result<(), crate::OperationsError> {
3279    use std::collections::{BTreeMap, HashMap};
3280
3281    let shell_id = topo.solid(solid)?.outer_shell();
3282    let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3283
3284    let scale = 1.0 / tol.linear;
3285    let quantize = |p: brepkit_math::vec::Point3| -> (i64, i64, i64) {
3286        (
3287            (p.x() * scale).round() as i64,
3288            (p.y() * scale).round() as i64,
3289            (p.z() * scale).round() as i64,
3290        )
3291    };
3292
3293    // Build vertex canonical map: position → first VertexId seen
3294    let mut canonical: BTreeMap<(i64, i64, i64), brepkit_topology::vertex::VertexId> =
3295        BTreeMap::new();
3296    let mut replacements: HashMap<
3297        brepkit_topology::vertex::VertexId,
3298        brepkit_topology::vertex::VertexId,
3299    > = HashMap::new();
3300
3301    for &fid in &face_ids {
3302        let face = topo.face(fid)?;
3303        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3304            let wire = topo.wire(wid)?;
3305            for oe in wire.edges() {
3306                let edge = topo.edge(oe.edge())?;
3307                for vid in [edge.start(), edge.end()] {
3308                    let pos = topo.vertex(vid)?.point();
3309                    let key = quantize(pos);
3310                    let canon = *canonical.entry(key).or_insert(vid);
3311                    if canon != vid {
3312                        replacements.insert(vid, canon);
3313                    }
3314                }
3315            }
3316        }
3317    }
3318
3319    if replacements.is_empty() {
3320        return Ok(());
3321    }
3322
3323    // Rebuild faces with merged vertices
3324    // Cache: (old_edge, new_start, new_end) → new_edge to share edges
3325    let mut edge_cache: HashMap<
3326        (
3327            brepkit_topology::edge::EdgeId,
3328            brepkit_topology::vertex::VertexId,
3329            brepkit_topology::vertex::VertexId,
3330        ),
3331        brepkit_topology::edge::EdgeId,
3332    > = HashMap::new();
3333
3334    // Snapshot face data, then rebuild with merged vertices
3335    struct FaceSnap {
3336        surface: brepkit_topology::face::FaceSurface,
3337        reversed: bool,
3338        outer_oes: Vec<(
3339            brepkit_topology::edge::EdgeId,
3340            bool,
3341            brepkit_topology::edge::EdgeCurve,
3342            brepkit_topology::vertex::VertexId,
3343            brepkit_topology::vertex::VertexId,
3344            Option<f64>, // edge tolerance
3345        )>,
3346        outer_closed: bool,
3347        inner_wires: Vec<(
3348            Vec<(
3349                brepkit_topology::edge::EdgeId,
3350                bool,
3351                brepkit_topology::edge::EdgeCurve,
3352                brepkit_topology::vertex::VertexId,
3353                brepkit_topology::vertex::VertexId,
3354                Option<f64>,
3355            )>,
3356            bool, // wire closed flag
3357        )>,
3358    }
3359
3360    let mut snaps = Vec::with_capacity(face_ids.len());
3361    for &fid in &face_ids {
3362        let face = topo.face(fid)?;
3363        let surface = face.surface().clone();
3364        let reversed = face.is_reversed();
3365        let outer_wire = topo.wire(face.outer_wire())?;
3366        let outer_closed = outer_wire.is_closed();
3367        let outer_oes: Vec<_> = outer_wire
3368            .edges()
3369            .iter()
3370            .map(|oe| -> Result<_, crate::OperationsError> {
3371                let e = topo.edge(oe.edge())?;
3372                Ok((
3373                    oe.edge(),
3374                    oe.is_forward(),
3375                    e.curve().clone(),
3376                    e.start(),
3377                    e.end(),
3378                    e.tolerance(),
3379                ))
3380            })
3381            .collect::<Result<_, _>>()?;
3382        let inner_wids = face.inner_wires().to_vec();
3383        let mut inner_wires = Vec::new();
3384        for iw in inner_wids {
3385            let w = topo.wire(iw)?;
3386            let closed = w.is_closed();
3387            let oes: Vec<_> = w
3388                .edges()
3389                .iter()
3390                .map(|oe| -> Result<_, crate::OperationsError> {
3391                    let e = topo.edge(oe.edge())?;
3392                    Ok((
3393                        oe.edge(),
3394                        oe.is_forward(),
3395                        e.curve().clone(),
3396                        e.start(),
3397                        e.end(),
3398                        e.tolerance(),
3399                    ))
3400                })
3401                .collect::<Result<_, _>>()?;
3402            inner_wires.push((oes, closed));
3403        }
3404        snaps.push(FaceSnap {
3405            surface,
3406            reversed,
3407            outer_oes,
3408            outer_closed,
3409            inner_wires,
3410        });
3411    }
3412
3413    #[allow(clippy::type_complexity)]
3414    let remap_oes = |oes: &[(
3415        brepkit_topology::edge::EdgeId,
3416        bool,
3417        brepkit_topology::edge::EdgeCurve,
3418        brepkit_topology::vertex::VertexId,
3419        brepkit_topology::vertex::VertexId,
3420        Option<f64>,
3421    )],
3422                     replacements: &HashMap<
3423        brepkit_topology::vertex::VertexId,
3424        brepkit_topology::vertex::VertexId,
3425    >,
3426                     edge_cache: &mut HashMap<
3427        (
3428            brepkit_topology::edge::EdgeId,
3429            brepkit_topology::vertex::VertexId,
3430            brepkit_topology::vertex::VertexId,
3431        ),
3432        brepkit_topology::edge::EdgeId,
3433    >,
3434                     topo: &mut Topology|
3435     -> Vec<brepkit_topology::wire::OrientedEdge> {
3436        oes.iter()
3437            .map(|(eid, fwd, curve, start, end, edge_tol)| {
3438                let ns = replacements.get(start).copied().unwrap_or(*start);
3439                let ne = replacements.get(end).copied().unwrap_or(*end);
3440                if ns == *start && ne == *end {
3441                    return brepkit_topology::wire::OrientedEdge::new(*eid, *fwd);
3442                }
3443                let key = (*eid, ns, ne);
3444                let new_eid = *edge_cache.entry(key).or_insert_with(|| {
3445                    topo.add_edge(brepkit_topology::edge::Edge::with_tolerance(
3446                        ns,
3447                        ne,
3448                        curve.clone(),
3449                        *edge_tol,
3450                    ))
3451                });
3452                brepkit_topology::wire::OrientedEdge::new(new_eid, *fwd)
3453            })
3454            .collect()
3455    };
3456
3457    let mut new_face_ids = Vec::with_capacity(snaps.len());
3458    for snap in &snaps {
3459        let outer_oes = remap_oes(&snap.outer_oes, &replacements, &mut edge_cache, topo);
3460        let Ok(outer_wire) = brepkit_topology::wire::Wire::new(outer_oes, snap.outer_closed) else {
3461            // Wire rebuild failed — keep the original face unchanged
3462            // rather than silently dropping it
3463            continue;
3464        };
3465        let outer_id = topo.add_wire(outer_wire);
3466
3467        let mut inner_ids = Vec::new();
3468        for (inner_oes_snap, inner_closed) in &snap.inner_wires {
3469            let oes = remap_oes(inner_oes_snap, &replacements, &mut edge_cache, topo);
3470            if let Ok(w) = brepkit_topology::wire::Wire::new(oes, *inner_closed) {
3471                inner_ids.push(topo.add_wire(w));
3472            }
3473        }
3474
3475        let mut new_face =
3476            brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3477        if snap.reversed {
3478            new_face.set_reversed(true);
3479        }
3480        new_face_ids.push(topo.add_face(new_face));
3481    }
3482
3483    // Replace the shell's faces
3484    let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3485    let new_shell_id = topo.add_shell(new_shell);
3486    let solid_mut = topo.solid_mut(solid)?;
3487    solid_mut.set_outer_shell(new_shell_id);
3488
3489    Ok(())
3490}
3491
3492/// Merge geometrically-coincident duplicate boundary edges on the outer shell.
3493///
3494/// A coincident-junction fuse (e.g. a box stacked on a tapered loft that share
3495/// a cap face) annihilates the shared cap but leaves each argument's faces
3496/// carrying their OWN copy of the junction-wire edges. Because the two copies
3497/// come from independently-built solids their endpoints differ by sub-micron
3498/// numerical noise (loft re-parameterization), so the tight-tolerance vertex
3499/// merge above leaves them as distinct edges — each used once → free edges that
3500/// open the shell.
3501///
3502/// This snaps vertices at `tol_merge` (looser than the default linear
3503/// tolerance, to absorb that noise), then rebuilds every wire against a global
3504/// canonical-edge map keyed by *unordered canonical endpoints + curve type +
3505/// geometric midpoint* — so a straight line and a bulged arc between the same
3506/// endpoints stay distinct, while true duplicates collapse to one shared edge.
3507/// Edges whose endpoints merge to a single vertex (degenerate) are dropped.
3508///
3509/// Returns `true` if anything changed. Run only on already-broken results
3510/// (free edges / non-manifold) so clean booleans keep their exact topology.
3511#[allow(
3512    clippy::too_many_lines,
3513    clippy::type_complexity,
3514    clippy::items_after_statements
3515)]
3516fn unify_coincident_boundary_edges(
3517    topo: &mut Topology,
3518    solid: SolidId,
3519    tol_merge: f64,
3520) -> Result<bool, crate::OperationsError> {
3521    use brepkit_topology::edge::{Edge, EdgeCurve, EdgeId};
3522    use brepkit_topology::vertex::VertexId;
3523    use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
3524    use std::collections::HashMap;
3525
3526    let shell_id = topo.solid(solid)?.outer_shell();
3527    let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3528
3529    let scale = 1.0 / tol_merge;
3530    let q = |p: Point3| -> (i64, i64, i64) {
3531        (
3532            (p.x() * scale).round() as i64,
3533            (p.y() * scale).round() as i64,
3534            (p.z() * scale).round() as i64,
3535        )
3536    };
3537
3538    // 1. Canonical vertex per quantized position (first VertexId seen wins).
3539    let mut vcanon: HashMap<(i64, i64, i64), VertexId> = HashMap::new();
3540    for &fid in &face_ids {
3541        let face = topo.face(fid)?;
3542        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3543            let wire = topo.wire(wid)?;
3544            for oe in wire.edges() {
3545                let edge = topo.edge(oe.edge())?;
3546                for vid in [edge.start(), edge.end()] {
3547                    let key = q(topo.vertex(vid)?.point());
3548                    vcanon.entry(key).or_insert(vid);
3549                }
3550            }
3551        }
3552    }
3553
3554    // 2. Snapshot each face's wires (edge id, fwd, curve, endpoints, tol).
3555    type OeSnap = (EdgeId, bool, EdgeCurve, VertexId, VertexId, Option<f64>);
3556    struct FaceSnap {
3557        surface: FaceSurface,
3558        reversed: bool,
3559        outer: Vec<OeSnap>,
3560        outer_closed: bool,
3561        inners: Vec<(Vec<OeSnap>, bool)>,
3562    }
3563    let snap_wire =
3564        |topo: &Topology, wid: WireId| -> Result<(Vec<OeSnap>, bool), crate::OperationsError> {
3565            let w = topo.wire(wid)?;
3566            let closed = w.is_closed();
3567            let oes = w
3568                .edges()
3569                .iter()
3570                .map(|oe| -> Result<OeSnap, crate::OperationsError> {
3571                    let e = topo.edge(oe.edge())?;
3572                    Ok((
3573                        oe.edge(),
3574                        oe.is_forward(),
3575                        e.curve().clone(),
3576                        e.start(),
3577                        e.end(),
3578                        e.tolerance(),
3579                    ))
3580                })
3581                .collect::<Result<_, _>>()?;
3582            Ok((oes, closed))
3583        };
3584    let mut snaps = Vec::with_capacity(face_ids.len());
3585    for &fid in &face_ids {
3586        let face = topo.face(fid)?;
3587        let surface = face.surface().clone();
3588        let reversed = face.is_reversed();
3589        let (outer, outer_closed) = snap_wire(topo, face.outer_wire())?;
3590        let mut inners = Vec::new();
3591        for iw in face.inner_wires() {
3592            inners.push(snap_wire(topo, *iw)?);
3593        }
3594        snaps.push(FaceSnap {
3595            surface,
3596            reversed,
3597            outer,
3598            outer_closed,
3599            inners,
3600        });
3601    }
3602
3603    // 3. Rebuild wires against a global canonical-edge map.
3604    //    Key: (lo endpoint q, hi endpoint q, midpoint q, curve type tag).
3605    type EdgeKey = (
3606        (i64, i64, i64),
3607        (i64, i64, i64),
3608        (i64, i64, i64),
3609        &'static str,
3610    );
3611    let mut ecanon: HashMap<EdgeKey, (EdgeId, VertexId, VertexId)> = HashMap::new();
3612    let mut changed = false;
3613
3614    let canon_vid = |topo: &Topology, vid: VertexId| -> Result<VertexId, crate::OperationsError> {
3615        Ok(*vcanon.get(&q(topo.vertex(vid)?.point())).unwrap_or(&vid))
3616    };
3617
3618    let rebuild = |topo: &mut Topology,
3619                   oes: &[OeSnap],
3620                   ecanon: &mut HashMap<EdgeKey, (EdgeId, VertexId, VertexId)>,
3621                   changed: &mut bool|
3622     -> Result<Vec<OrientedEdge>, crate::OperationsError> {
3623        let mut out = Vec::with_capacity(oes.len());
3624        for (eid, fwd, curve, start, end, etol) in oes {
3625            let cs = canon_vid(topo, *start)?;
3626            let ce = canon_vid(topo, *end)?;
3627            if cs == ce {
3628                // Endpoints collapsed to a single vertex → degenerate, drop it.
3629                *changed = true;
3630                continue;
3631            }
3632            let sp = topo.vertex(*start)?.point();
3633            let ep = topo.vertex(*end)?.point();
3634            let (t0, t1) = curve.domain_with_endpoints(sp, ep);
3635            let mid = curve.evaluate_with_endpoints((t0 + t1) * 0.5, sp, ep);
3636            let (cs_q, ce_q) = (q(topo.vertex(cs)?.point()), q(topo.vertex(ce)?.point()));
3637            let (lo, hi) = if cs_q <= ce_q {
3638                (cs_q, ce_q)
3639            } else {
3640                (ce_q, cs_q)
3641            };
3642            let key = (lo, hi, q(mid), curve.type_tag());
3643
3644            // Physical traversal start vertex (after canonicalization).
3645            let trav_start = if *fwd { cs } else { ce };
3646            if let Some(&(c_eid, c_start, _c_end)) = ecanon.get(&key) {
3647                // A duplicate of an already-seen edge → merge onto the keeper.
3648                *changed = true;
3649                out.push(OrientedEdge::new(c_eid, c_start == trav_start));
3650            } else {
3651                // First edge with this key. Reuse the original edge when its
3652                // endpoints didn't move; only allocate (and flag a change) when
3653                // a vertex was snapped — so an already-clean shell is a no-op.
3654                let (eid_use, e_start) = if cs == *start && ce == *end {
3655                    (*eid, *start)
3656                } else {
3657                    *changed = true;
3658                    (
3659                        topo.add_edge(Edge::with_tolerance(cs, ce, curve.clone(), *etol)),
3660                        cs,
3661                    )
3662                };
3663                ecanon.insert(key, (eid_use, e_start, ce));
3664                out.push(OrientedEdge::new(eid_use, e_start == trav_start));
3665            }
3666        }
3667        Ok(out)
3668    };
3669
3670    let mut new_face_ids = Vec::with_capacity(snaps.len());
3671    for snap in &snaps {
3672        let outer_oes = rebuild(topo, &snap.outer, &mut ecanon, &mut changed)?;
3673        let Ok(outer_wire) = Wire::new(outer_oes, snap.outer_closed) else {
3674            // Keep original face if the rebuilt wire is invalid.
3675            return Ok(false);
3676        };
3677        let outer_id = topo.add_wire(outer_wire);
3678        let mut inner_ids = Vec::new();
3679        for (inner_oes, inner_closed) in &snap.inners {
3680            let oes = rebuild(topo, inner_oes, &mut ecanon, &mut changed)?;
3681            let Ok(w) = Wire::new(oes, *inner_closed) else {
3682                // A dropped hole silently changes topology (and removes free
3683                // edges, so the downstream gate can't catch it). Bail like the
3684                // outer-wire case, leaving the original solid untouched.
3685                return Ok(false);
3686            };
3687            inner_ids.push(topo.add_wire(w));
3688        }
3689        let mut new_face =
3690            brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3691        if snap.reversed {
3692            new_face.set_reversed(true);
3693        }
3694        new_face_ids.push(topo.add_face(new_face));
3695    }
3696
3697    if !changed {
3698        return Ok(false);
3699    }
3700
3701    let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3702    let new_shell_id = topo.add_shell(new_shell);
3703    topo.solid_mut(solid)?.set_outer_shell(new_shell_id);
3704    Ok(true)
3705}
3706
3707/// Post-process a solid to enforce manifold topology via greedy flood-fill.
3708///
3709/// Detects non-manifold edges (shared by 3+ faces) and uses greedy
3710/// shell building to split the non-manifold shell into manifold
3711/// sub-shells. The largest sub-shell becomes the outer shell; smaller ones
3712/// become inner shells (cavities).
3713///
3714/// If the solid is already manifold, returns it unchanged.
3715#[allow(clippy::too_many_lines)]
3716fn enforce_manifold_shell(
3717    topo: &mut Topology,
3718    solid: SolidId,
3719) -> Result<SolidId, crate::OperationsError> {
3720    use std::collections::{HashMap, HashSet, VecDeque};
3721
3722    let shell_id = topo.solid(solid)?.outer_shell();
3723    let face_ids = topo.shell(shell_id)?.faces().to_vec();
3724
3725    // Count edges per face.
3726    let mut edge_face_count: HashMap<usize, u32> = HashMap::new();
3727    for &fid in &face_ids {
3728        if let Ok(face) = topo.face(fid) {
3729            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3730            {
3731                if let Ok(wire) = topo.wire(wid) {
3732                    for oe in wire.edges() {
3733                        *edge_face_count.entry(oe.edge().index()).or_default() += 1;
3734                    }
3735                }
3736            }
3737        }
3738    }
3739
3740    // Only apply for significant non-manifold (>3 edges). Minor non-manifold
3741    // (1-3 edges) from sphere/cone intersections is tolerable and splitting
3742    // the shell at those edges breaks downstream operations (section, volume).
3743    let nm_count = edge_face_count.values().filter(|&&c| c > 2).count();
3744    if nm_count <= 3 {
3745        return Ok(solid);
3746    }
3747
3748    log::debug!(
3749        "enforce_manifold_shell: {} non-manifold edges in {} faces",
3750        nm_count,
3751        face_ids.len()
3752    );
3753
3754    // Build vertex-pair → face adjacency for neighbor discovery.
3755    let mut vpair_faces: HashMap<(usize, usize), Vec<brepkit_topology::face::FaceId>> =
3756        HashMap::new();
3757    for &fid in &face_ids {
3758        if let Ok(face) = topo.face(fid) {
3759            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3760            {
3761                if let Ok(wire) = topo.wire(wid) {
3762                    for oe in wire.edges() {
3763                        if let Ok(e) = topo.edge(oe.edge()) {
3764                            let si = e.start().index();
3765                            let ei = e.end().index();
3766                            let key = if si <= ei { (si, ei) } else { (ei, si) };
3767                            vpair_faces.entry(key).or_default().push(fid);
3768                        }
3769                    }
3770                }
3771            }
3772        }
3773    }
3774
3775    // Greedy flood-fill shell construction.
3776    let available: HashSet<brepkit_topology::face::FaceId> = face_ids.iter().copied().collect();
3777    let mut processed: HashSet<brepkit_topology::face::FaceId> = HashSet::new();
3778    let mut shells: Vec<Vec<brepkit_topology::face::FaceId>> = Vec::new();
3779
3780    for &start_face in &face_ids {
3781        if processed.contains(&start_face) {
3782            continue;
3783        }
3784
3785        let mut shell_faces = vec![start_face];
3786        processed.insert(start_face);
3787
3788        // Track edge-ID usage within this shell.
3789        let mut shell_edge_count: HashMap<usize, u32> = HashMap::new();
3790        if let Ok(face) = topo.face(start_face) {
3791            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3792            {
3793                if let Ok(wire) = topo.wire(wid) {
3794                    for oe in wire.edges() {
3795                        *shell_edge_count.entry(oe.edge().index()).or_default() += 1;
3796                    }
3797                }
3798            }
3799        }
3800
3801        let mut queue = VecDeque::new();
3802        queue.push_back(start_face);
3803
3804        while let Some(current) = queue.pop_front() {
3805            let Ok(face) = topo.face(current) else {
3806                continue;
3807            };
3808            // Collect (vpair, edge_id) from all wires.
3809            let mut all_edges = Vec::new();
3810            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3811            {
3812                if let Ok(wire) = topo.wire(wid) {
3813                    for oe in wire.edges() {
3814                        if let Ok(e) = topo.edge(oe.edge()) {
3815                            let si = e.start().index();
3816                            let ei = e.end().index();
3817                            let key = if si <= ei { (si, ei) } else { (ei, si) };
3818                            all_edges.push((key, oe.edge()));
3819                        }
3820                    }
3821                }
3822            }
3823
3824            for (vpair, edge_id) in all_edges {
3825                let eidx = edge_id.index();
3826
3827                // Skip edges already manifold in this shell.
3828                if shell_edge_count.get(&eidx).copied().unwrap_or(0) >= 2 {
3829                    continue;
3830                }
3831
3832                // Find candidate neighbor faces via vertex-pair.
3833                let candidates: Vec<brepkit_topology::face::FaceId> = vpair_faces
3834                    .get(&vpair)
3835                    .map(|fs| {
3836                        fs.iter()
3837                            .copied()
3838                            .filter(|&f| {
3839                                f != current && available.contains(&f) && !processed.contains(&f)
3840                            })
3841                            .collect()
3842                    })
3843                    .unwrap_or_default();
3844
3845                if candidates.is_empty() {
3846                    continue;
3847                }
3848
3849                // Pick first candidate (simple heuristic — dihedral selection
3850                // would be better but requires surface normal evaluation).
3851                let selected = candidates[0];
3852
3853                if processed.contains(&selected) {
3854                    continue;
3855                }
3856
3857                processed.insert(selected);
3858                shell_faces.push(selected);
3859                queue.push_back(selected);
3860
3861                // Update edge count.
3862                if let Ok(sel_face) = topo.face(selected) {
3863                    for wid in std::iter::once(sel_face.outer_wire())
3864                        .chain(sel_face.inner_wires().iter().copied())
3865                    {
3866                        if let Ok(wire) = topo.wire(wid) {
3867                            for sel_oe in wire.edges() {
3868                                *shell_edge_count.entry(sel_oe.edge().index()).or_default() += 1;
3869                            }
3870                        }
3871                    }
3872                }
3873            }
3874        }
3875
3876        shells.push(shell_faces);
3877    }
3878
3879    // Add any unprocessed faces to a final shell.
3880    let remaining: Vec<brepkit_topology::face::FaceId> = available
3881        .iter()
3882        .filter(|f| !processed.contains(f))
3883        .copied()
3884        .collect();
3885    if !remaining.is_empty() {
3886        shells.push(remaining);
3887    }
3888
3889    if shells.len() <= 1 {
3890        // Single shell — nothing to split.
3891        return Ok(solid);
3892    }
3893
3894    log::debug!(
3895        "enforce_manifold_shell: split into {} shells (sizes: {:?})",
3896        shells.len(),
3897        shells.iter().map(Vec::len).collect::<Vec<_>>(),
3898    );
3899
3900    // Build the solid: largest shell is outer, rest are inner.
3901    let mut best_idx = 0;
3902    let mut best_count = 0;
3903    for (i, faces) in shells.iter().enumerate() {
3904        if faces.len() > best_count {
3905            best_count = faces.len();
3906            best_idx = i;
3907        }
3908    }
3909
3910    let outer = brepkit_topology::shell::Shell::new(shells[best_idx].clone())
3911        .map_err(crate::OperationsError::Topology)?;
3912    let outer_id = topo.add_shell(outer);
3913    let mut inner_ids = Vec::new();
3914    for (i, faces) in shells.iter().enumerate() {
3915        if i != best_idx
3916            && !faces.is_empty()
3917            && let Ok(inner) = brepkit_topology::shell::Shell::new(faces.clone())
3918        {
3919            inner_ids.push(topo.add_shell(inner));
3920        }
3921    }
3922
3923    Ok(topo.add_solid(brepkit_topology::solid::Solid::new(outer_id, inner_ids)))
3924}
3925
3926/// Sample `n` evenly-spaced points along a closed edge curve.
3927///
3928/// For `Circle` and `Ellipse`, samples at `TAU * i / n`.
3929/// For closed `NurbsCurve`, samples across the domain avoiding endpoint
3930/// duplication. Returns an empty vec for `Line` (no sampling possible).
3931pub(crate) fn sample_edge_curve(curve: &EdgeCurve, n: usize) -> Vec<Point3> {
3932    match curve {
3933        EdgeCurve::Circle(c) => (0..n)
3934            .map(|i| {
3935                #[allow(clippy::cast_precision_loss)]
3936                let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3937                c.evaluate(t)
3938            })
3939            .collect(),
3940        EdgeCurve::Ellipse(e) => (0..n)
3941            .map(|i| {
3942                #[allow(clippy::cast_precision_loss)]
3943                let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3944                e.evaluate(t)
3945            })
3946            .collect(),
3947        EdgeCurve::NurbsCurve(nc) => {
3948            let (u0, u1) = nc.domain();
3949            // For closed curves (start ~ end), use n as divisor to avoid
3950            // duplicating the first point at t=u_max.
3951            let start_pt = nc.evaluate(u0);
3952            let end_pt = nc.evaluate(u1);
3953            // 1e-6 m: closure detection threshold — if start and end points are
3954            // within 1 micron, treat the NURBS curve as closed to avoid
3955            // duplicating the first point at t=u_max.
3956            let is_closed = (start_pt - end_pt).length() < 1e-6;
3957            let divisor = if is_closed { n } else { n - 1 };
3958            (0..n)
3959                .map(|i| {
3960                    #[allow(clippy::cast_precision_loss)]
3961                    let t = u0 + (u1 - u0) * (i as f64) / (divisor as f64);
3962                    nc.evaluate(t)
3963                })
3964                .collect()
3965        }
3966        EdgeCurve::Line => vec![],
3967    }
3968}
3969
3970/// Get a polygon approximation of a face by sampling curved edges.
3971///
3972/// Samples circle/ellipse edges into 32 points so faces with a
3973/// single closed-curve edge (e.g. cylinder caps) get a proper polygon.
3974///
3975/// # Errors
3976///
3977/// Returns an error if the face or its wire cannot be resolved.
3978pub fn face_polygon(
3979    topo: &Topology,
3980    face_id: FaceId,
3981) -> Result<Vec<Point3>, crate::OperationsError> {
3982    let face = topo.face(face_id)?;
3983    let wire = topo.wire(face.outer_wire())?;
3984    let mut pts = Vec::new();
3985
3986    for oe in wire.edges() {
3987        let edge = topo.edge(oe.edge())?;
3988        let curve = edge.curve();
3989        // Sample closed parametric edges (start == end vertex).
3990        // Partial arcs fall through to the vertex-based path.
3991        let start_vid = edge.start();
3992        let end_vid = edge.end();
3993        let is_closed_edge = start_vid == end_vid
3994            && matches!(
3995                curve,
3996                EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) | EdgeCurve::NurbsCurve(_)
3997            );
3998        if is_closed_edge {
3999            // Must use CLOSED_CURVE_SAMPLES (not a larger value) — vertex count
4000            // must match create_band_fragments and inner-wire dedup for sharing.
4001            let mut sampled = sample_edge_curve(curve, types::CLOSED_CURVE_SAMPLES);
4002            if !oe.is_forward() {
4003                sampled.reverse();
4004            }
4005            pts.extend(sampled);
4006        } else {
4007            let vid = oe.oriented_start(edge);
4008            pts.push(topo.vertex(vid)?.point());
4009        }
4010    }
4011
4012    Ok(pts)
4013}
4014
4015/// Collect face signatures (index, normal, centroid) for evolution tracking.
4016///
4017/// For each face of the solid, computes a representative normal and centroid
4018/// from the face polygon. Used by [`boolean_with_evolution`] to match output
4019/// faces back to input faces.
4020///
4021/// # Errors
4022///
4023/// Returns an error if any face or wire cannot be resolved.
4024/// Snapshot each outer-shell face as `(index, face normal, centroid)` — the
4025/// signature [`crate::evolution::build_evolution_by_geometry`] matches on. The
4026/// normal is the stored plane normal (or a polygon-derived normal for
4027/// non-planar faces), not re-oriented by the face's `reversed` flag; matching
4028/// stays consistent because input and output faces use the same convention.
4029pub fn collect_face_signatures(
4030    topo: &Topology,
4031    solid_id: SolidId,
4032) -> Result<Vec<(usize, Vec3, Point3)>, crate::OperationsError> {
4033    let solid = topo.solid(solid_id)?;
4034    let shell = topo.shell(solid.outer_shell())?;
4035    let mut result = Vec::with_capacity(shell.faces().len());
4036
4037    for &fid in shell.faces() {
4038        let face = topo.face(fid)?;
4039        let verts = face_polygon(topo, fid)?;
4040        let normal = if let FaceSurface::Plane { normal, .. } = face.surface() {
4041            *normal
4042        } else if verts.len() >= 3 {
4043            let e1 = verts[1] - verts[0];
4044            let e2 = verts[2] - verts[0];
4045            e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0))
4046        } else {
4047            Vec3::new(0.0, 0.0, 1.0)
4048        };
4049
4050        let centroid = classify::polygon_centroid(&verts);
4051        result.push((fid.index(), normal, centroid));
4052    }
4053
4054    Ok(result)
4055}
4056
4057#[cfg(test)]
4058mod tests;