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    // Bidirectional vertex check via the analytic classifier — the
2254    // primary signal for identical/containment classification. A vertex
2255    // classifying as inside-or-on (None within tolerance band counts
2256    // as on) means it sits within the solid's region.
2257    let all_b_verts_in_a = ca.is_some_and(|c| all_vertices_inside_or_on(topo, b, c, tol));
2258    let all_a_verts_in_b = cb.is_some_and(|c| all_vertices_inside_or_on(topo, a, c, tol));
2259    let aabbs_match = aabb_a
2260        .zip(aabb_b)
2261        .map(|((a_min, a_max), (b_min, b_max))| {
2262            let eps = tol.linear;
2263            (a_min.x() - b_min.x()).abs() < eps
2264                && (a_min.y() - b_min.y()).abs() < eps
2265                && (a_min.z() - b_min.z()).abs() < eps
2266                && (a_max.x() - b_max.x()).abs() < eps
2267                && (a_max.y() - b_max.y()).abs() < eps
2268                && (a_max.z() - b_max.z()).abs() < eps
2269        })
2270        .unwrap_or(false);
2271
2272    // Containment: A contains B when all B vertices are inside-or-on A AND
2273    // A's AABB encloses B's. Falls back to a strict AABB-only check when the
2274    // containing solid has no classifier — the strict check requires ≥10%
2275    // larger in ALL three dims so that sparse multi-shell solids (e.g., a
2276    // fuse of two disjoint boxes) don't false-positive as "contains another
2277    // solid".
2278    // Both the analytic-classifier term and the AABB-only fallback can
2279    // false-positive when the container is non-convex: the analytic
2280    // classifier may mis-report notch points as inside-or-on, and an
2281    // AABB encloses a notch's empty volume. Guard the whole determination
2282    // with the `center_outside` witness — sound for every path because it
2283    // only fires on proven non-containment (see the lemma above).
2284    let b_in_a = ((all_b_verts_in_a && aabb_encloses(&aabb_b, &aabb_a))
2285        || (ca.is_none() && aabb_strictly_contains(&aabb_b, &aabb_a)))
2286        && !center_outside(topo, b, a, &aabb_b);
2287    let a_in_b = ((all_a_verts_in_b && aabb_encloses(&aabb_a, &aabb_b))
2288        || (cb.is_none() && aabb_strictly_contains(&aabb_a, &aabb_b)))
2289        && !center_outside(topo, a, b, &aabb_a);
2290
2291    TrivialRelation {
2292        identical: aabbs_match && all_b_verts_in_a && all_a_verts_in_b,
2293        a_in_b,
2294        b_in_a,
2295    }
2296}
2297
2298/// Check whether every boundary vertex of `solid` is classified as
2299/// `Inside` or `On` by `classifier`. Used by the identical-solid shortcut
2300/// to distinguish truly-identical solids from co-located but differently
2301/// shaped solids (e.g., a cone and a box that share an AABB).
2302fn all_vertices_inside_or_on(
2303    topo: &Topology,
2304    solid: SolidId,
2305    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2306    tol: brepkit_math::tolerance::Tolerance,
2307) -> bool {
2308    let Ok(s) = topo.solid(solid) else {
2309        return false;
2310    };
2311    let Ok(sh) = topo.shell(s.outer_shell()) else {
2312        return false;
2313    };
2314    for &fid in sh.faces() {
2315        let Ok(f) = topo.face(fid) else { return false };
2316        let Ok(w) = topo.wire(f.outer_wire()) else {
2317            return false;
2318        };
2319        for oe in w.edges() {
2320            let Ok(e) = topo.edge(oe.edge()) else {
2321                return false;
2322            };
2323            for vid in [e.start(), e.end()] {
2324                let Ok(v) = topo.vertex(vid) else {
2325                    return false;
2326                };
2327                // The analytic classifier returns `None` for points within
2328                // tol.linear of the boundary — treat as "on" for this check.
2329                if classifier.classify(v.point(), tol) == Some(brepkit_algo::FaceClass::Outside) {
2330                    return false;
2331                }
2332            }
2333        }
2334    }
2335    true
2336}
2337
2338/// True when every outer-shell vertex of `inner` classifies as *strictly*
2339/// `Inside` (not on the boundary) of `classifier`. A strictly-contained tool
2340/// has no surface contact with the blank, so `Cut(blank, tool)` is a clean
2341/// internal cavity rather than a notch through the boundary.
2342fn solid_strictly_inside(
2343    topo: &Topology,
2344    inner: SolidId,
2345    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2346    tol: brepkit_math::tolerance::Tolerance,
2347) -> bool {
2348    let Ok(s) = topo.solid(inner) else {
2349        return false;
2350    };
2351    let Ok(sh) = topo.shell(s.outer_shell()) else {
2352        return false;
2353    };
2354    let mut saw_vertex = false;
2355    for &fid in sh.faces() {
2356        let Ok(f) = topo.face(fid) else { return false };
2357        // Check the outer wire and any inner (hole) wires — a hole boundary on
2358        // a simple solid's face can also reach the blank's surface.
2359        let mut wires = vec![f.outer_wire()];
2360        wires.extend_from_slice(f.inner_wires());
2361        for wid in wires {
2362            let Ok(w) = topo.wire(wid) else {
2363                return false;
2364            };
2365            for oe in w.edges() {
2366                let Ok(e) = topo.edge(oe.edge()) else {
2367                    return false;
2368                };
2369                for vid in [e.start(), e.end()] {
2370                    let Ok(v) = topo.vertex(vid) else {
2371                        return false;
2372                    };
2373                    if classifier.classify(v.point(), tol) != Some(brepkit_algo::FaceClass::Inside)
2374                    {
2375                        return false;
2376                    }
2377                    saw_vertex = true;
2378                }
2379            }
2380        }
2381    }
2382    saw_vertex
2383}
2384
2385/// Build `Cut(blank, tool)` for a tool strictly contained in the blank: the
2386/// result is the blank with a tool-shaped internal cavity. Deep-copies the
2387/// blank and the tool, reverses every copied tool face in place so the cavity
2388/// boundary faces into the void, and attaches the reversed tool shell to the
2389/// copied blank as an inner shell. Bypasses GFA, whose no-intersection assembly
2390/// drops fully-contained cone/torus tools.
2391fn build_contained_cut_hollow(
2392    topo: &mut Topology,
2393    blank: SolidId,
2394    tool: SolidId,
2395) -> Result<SolidId, crate::OperationsError> {
2396    let result = crate::copy::copy_solid(topo, blank)?;
2397
2398    // Deep-copy the tool as a whole solid so the cavity shell shares edges and
2399    // vertices between adjacent faces (a per-face copy would duplicate shared
2400    // boundary edges and leave the cavity non-manifold — wrong Euler, though
2401    // per-face volume is unaffected). Reverse each copied face in place and
2402    // reuse the copied outer shell directly as the cavity inner shell, so no
2403    // duplicate faces or extra result solid are created.
2404    let tool_copy = crate::copy::copy_solid(topo, tool)?;
2405    let cavity_shell = topo.solid(tool_copy)?.outer_shell();
2406    let cavity_faces = topo.shell(cavity_shell)?.faces().to_vec();
2407    for fid in cavity_faces {
2408        let face = topo.face_mut(fid)?;
2409        let flipped = !face.is_reversed();
2410        face.set_reversed(flipped);
2411    }
2412    topo.solid_mut(result)?.add_inner_shell(cavity_shell);
2413    Ok(result)
2414}
2415
2416/// Best-effort mesh boolean fallback for high face-count solids.
2417///
2418/// Tessellates both solids, runs mesh co-refinement, assembles the result,
2419/// and applies the same post-processing as the other boolean paths.
2420/// Returns `Err` on any failure so the caller can fall through to the
2421/// chord-based path.
2422fn mesh_boolean_fallback(
2423    topo: &mut Topology,
2424    op: BooleanOp,
2425    a: SolidId,
2426    b: SolidId,
2427    deflection: f64,
2428    tol: brepkit_math::tolerance::Tolerance,
2429    opts: &BooleanOptions,
2430) -> Result<SolidId, crate::OperationsError> {
2431    // Mesh density here is a boolean-robustness concern, independent of the
2432    // rendering tolerance: use the linear-only criterion (angular_tol 0.0) so
2433    // the face count is unaffected by the display deflection cap, AND keep the
2434    // circle curvature floor so co-refinement gets the denser circular sampling
2435    // it needs (display tessellation drops that floor for triangle count).
2436    let mesh_a = crate::tessellate::tessellate_solid_for_boolean(topo, a, deflection, 0.0)?;
2437    let mesh_b = crate::tessellate::tessellate_solid_for_boolean(topo, b, deflection, 0.0)?;
2438    log::debug!(
2439        "mesh fallback {op:?}: tessellated operands to {} + {} triangles at deflection {deflection}",
2440        mesh_a.indices.len() / 3,
2441        mesh_b.indices.len() / 3,
2442    );
2443
2444    let mb_result = crate::mesh_boolean::mesh_boolean(&mesh_a, &mesh_b, op, tol.linear)?;
2445    if mb_result.boundary_edge_count > 0 || mb_result.non_manifold_edge_count > 0 {
2446        log::warn!(
2447            "boolean {op:?}: mesh boolean fallback output is NOT a closed 2-manifold \
2448             ({} boundary edge(s), {} non-manifold edge(s) after position welding) — \
2449             downstream healing may not recover; exported geometry may be broken",
2450            mb_result.boundary_edge_count,
2451            mb_result.non_manifold_edge_count,
2452        );
2453    }
2454    let face_specs = mesh_result_to_face_specs(&mb_result);
2455    if face_specs.is_empty() {
2456        return Err(crate::OperationsError::EmptyResult {
2457            reason: "mesh boolean produced no output faces".into(),
2458        });
2459    }
2460    log::debug!(
2461        "mesh fallback {op:?}: {} face specs -> assemble_solid_mixed",
2462        face_specs.len()
2463    );
2464    let result = assemble_solid_mixed(topo, &face_specs, tol)?;
2465    let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
2466    if opts.unify_faces {
2467        let _ = crate::heal::unify_faces(topo, result)?;
2468    }
2469    // Cross-face symmetrization: tessellation diagonals that one face
2470    // dropped while its neighbour kept (#696) leave structurally
2471    // orphan collinear interior wire vertices. Collapse those so both
2472    // sides reference the same EdgeId for the shared 3D segment,
2473    // eliminating the residual non-manifold edges that `unify_faces`
2474    // can't symmetrize from per-face surface matching alone.
2475    let collapsed =
2476        brepkit_heal::upgrade::collapse_collinear_vertices::collapse_collinear_wire_vertices(
2477            topo, result, tol,
2478        )
2479        .unwrap_or_else(|e| {
2480            log::warn!("boolean {op:?}: collapse_collinear_wire_vertices failed: {e}");
2481            0
2482        });
2483    if collapsed > 0 {
2484        log::info!(
2485            "boolean {op:?}: collapsed {collapsed} collinear interior wire vertex/vertices post-mesh-assembly",
2486        );
2487    }
2488    // Mesh-fallback can glue two physically-separate holes into a
2489    // single figure-8 inner wire via diagonal "bridge" edges across
2490    // gap material (#696 cumulative pattern: a slab top with multiple
2491    // pocket cuts ends up with one self-intersecting inner wire that
2492    // visits each pocket region). Split such wires at every pinch
2493    // vertex so each physical hole is its own simple inner wire —
2494    // the resulting topology is well-formed for downstream
2495    // tessellation, validation, and STEP export, even when the
2496    // bridge edges themselves remain as boundary edges (those are a
2497    // separate cleanup).
2498    let wires_split =
2499        brepkit_heal::upgrade::split_self_intersecting_wires::split_self_intersecting_inner_wires(
2500            topo, result,
2501        )
2502        .unwrap_or_else(|e| {
2503            log::warn!("boolean {op:?}: split_self_intersecting_inner_wires failed: {e}");
2504            0
2505        });
2506    if wires_split > 0 {
2507        log::info!(
2508            "boolean {op:?}: split {wires_split} self-intersecting inner wire(s) post-mesh-assembly",
2509        );
2510    }
2511    if opts.heal_after_boolean {
2512        let _ = crate::heal::heal_solid(topo, result, tol.linear)?;
2513    }
2514    assembly::validate_boolean_result_lenient(topo, result)?;
2515    log::info!(
2516        "boolean {op:?}: mesh boolean path → solid {} ({} faces, surface types lost)",
2517        result.index(),
2518        face_specs.len()
2519    );
2520    Ok(result)
2521}
2522
2523/// Convert a mesh boolean result into `FaceSpec` entries for solid assembly.
2524fn mesh_result_to_face_specs(result: &crate::mesh_boolean::MeshBooleanResult) -> Vec<FaceSpec> {
2525    let mut specs = Vec::new();
2526    for tri in result.mesh.indices.chunks_exact(3) {
2527        let v0 = result.mesh.positions[tri[0] as usize];
2528        let v1 = result.mesh.positions[tri[1] as usize];
2529        let v2 = result.mesh.positions[tri[2] as usize];
2530
2531        let edge1 = v1 - v0;
2532        let edge2 = v2 - v0;
2533        let Ok(normal) = edge1.cross(edge2).normalize() else {
2534            continue;
2535        };
2536        let d = crate::dot_normal_point(normal, v0);
2537        specs.push(FaceSpec::Planar {
2538            vertices: vec![v0, v1, v2],
2539            normal,
2540            d,
2541            inner_wires: vec![],
2542        });
2543    }
2544    specs
2545}
2546
2547/// True when the outer-shell face components represent disjoint solid
2548/// pieces (e.g., a previous cut split one solid into N parts), false
2549/// when one component is concentric inside another (a hollow solid:
2550/// outer surface + cavity surface both live in the outer shell).
2551///
2552/// The check is AABB-based: if any component's bounding box is
2553/// strictly contained in another's, treat the whole solid as hollow
2554/// and skip the multi-region split path.
2555/// Check that every component's AABB centre classifies as outside the
2556/// supplied classifier. Used to reject multi-region GFA Cut results that
2557/// erroneously include the tool's interior as one of the pieces.
2558fn all_component_centers_outside(
2559    topo: &Topology,
2560    components: &[Vec<FaceId>],
2561    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2562    tol: brepkit_math::tolerance::Tolerance,
2563) -> bool {
2564    use brepkit_algo::FaceClass;
2565    for comp in components {
2566        let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2567        let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2568        for &fid in comp {
2569            let Ok(face) = topo.face(fid) else { continue };
2570            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2571            {
2572                let Ok(wire) = topo.wire(wid) else { continue };
2573                for oe in wire.edges() {
2574                    let Ok(edge) = topo.edge(oe.edge()) else {
2575                        continue;
2576                    };
2577                    for vid in [edge.start(), edge.end()] {
2578                        if let Ok(v) = topo.vertex(vid) {
2579                            let p = v.point();
2580                            min = Point3::new(
2581                                min.x().min(p.x()),
2582                                min.y().min(p.y()),
2583                                min.z().min(p.z()),
2584                            );
2585                            max = Point3::new(
2586                                max.x().max(p.x()),
2587                                max.y().max(p.y()),
2588                                max.z().max(p.z()),
2589                            );
2590                        }
2591                    }
2592                }
2593            }
2594        }
2595        let centre = Point3::new(
2596            (min.x() + max.x()) * 0.5,
2597            (min.y() + max.y()) * 0.5,
2598            (min.z() + max.z()) * 0.5,
2599        );
2600        if matches!(classifier.classify(centre, tol), Some(FaceClass::Inside)) {
2601            return false;
2602        }
2603    }
2604    true
2605}
2606
2607/// Centre of a face component's vertex AABB, or `None` for an empty component.
2608fn component_aabb_centre(topo: &Topology, comp: &[FaceId]) -> Option<Point3> {
2609    let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2610    let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2611    for &fid in comp {
2612        let Ok(face) = topo.face(fid) else { continue };
2613        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
2614            let Ok(wire) = topo.wire(wid) else { continue };
2615            for oe in wire.edges() {
2616                let Ok(edge) = topo.edge(oe.edge()) else {
2617                    continue;
2618                };
2619                for vid in [edge.start(), edge.end()] {
2620                    if let Ok(v) = topo.vertex(vid) {
2621                        let p = v.point();
2622                        min =
2623                            Point3::new(min.x().min(p.x()), min.y().min(p.y()), min.z().min(p.z()));
2624                        max =
2625                            Point3::new(max.x().max(p.x()), max.y().max(p.y()), max.z().max(p.z()));
2626                    }
2627                }
2628            }
2629        }
2630    }
2631    if min.x() > max.x() {
2632        return None;
2633    }
2634    Some(Point3::new(
2635        (min.x() + max.x()) * 0.5,
2636        (min.y() + max.y()) * 0.5,
2637        (min.z() + max.z()) * 0.5,
2638    ))
2639}
2640
2641/// Does the closed surface made of `faces` enclose `p`?
2642///
2643/// Ray-parity against the component's own tessellation. Read-only by design:
2644/// building a temporary solid per component would add entities to an arena that
2645/// never reclaims, which is the growth cliff fixed in #1237.
2646///
2647/// `watertight_ray_triangle_intersect` reports exactly one hit on a shared edge,
2648/// so parity is meaningful across face boundaries. The direction is deliberately
2649/// irrational so the ray does not graze a face boundary or lie in a face plane —
2650/// the degeneracy that makes axis-aligned probes unreliable on the feature-plane
2651/// intersections these pieces are full of. Returns `None` when the component
2652/// cannot be tessellated, so callers can fall back rather than guess.
2653fn component_encloses_point(
2654    topo: &Topology,
2655    faces: &[FaceId],
2656    p: Point3,
2657    deflection: f64,
2658) -> Option<bool> {
2659    // A sqrt-prime direction: irrational in every component, so the ray cannot
2660    // lie in a face plane or run along an edge — the same generic-direction
2661    // escape the ray-cast classifier uses for degenerate probes.
2662    let dir = Vec3::new(2.0_f64.sqrt(), 3.0_f64.sqrt(), 5.0_f64.sqrt())
2663        .normalize()
2664        .ok()?;
2665    let mut crossings = 0usize;
2666    let mut any_triangle = false;
2667    for &fid in faces {
2668        let mesh = crate::tessellate::tessellate_with_uvs(topo, fid, deflection).ok()?;
2669        let pos = &mesh.mesh.positions;
2670        for tri in mesh.mesh.indices.chunks_exact(3) {
2671            let (a, b, c) = (
2672                pos[tri[0] as usize],
2673                pos[tri[1] as usize],
2674                pos[tri[2] as usize],
2675            );
2676            any_triangle = true;
2677            if let Some(hit) =
2678                brepkit_math::ray_triangle::watertight_ray_triangle_intersect(p, dir, a, b, c)
2679                && hit.t > 1e-9
2680            {
2681                crossings += 1;
2682            }
2683        }
2684    }
2685    any_triangle.then_some(crossings % 2 == 1)
2686}
2687
2688/// Any vertex position on `faces`, for use as a probe point.
2689fn any_vertex_of(topo: &Topology, faces: &[FaceId]) -> Option<Point3> {
2690    for &fid in faces {
2691        let face = topo.face(fid).ok()?;
2692        let wire = topo.wire(face.outer_wire()).ok()?;
2693        if let Some(oe) = wire.edges().first()
2694            && let Ok(edge) = topo.edge(oe.edge())
2695            && let Ok(v) = topo.vertex(edge.start())
2696        {
2697            return Some(v.point());
2698        }
2699    }
2700    None
2701}
2702
2703fn components_are_disjoint_pieces(topo: &Topology, components: &[Vec<FaceId>]) -> bool {
2704    let aabbs: Vec<(Point3, Point3)> = components
2705        .iter()
2706        .map(|comp| {
2707            let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2708            let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2709            for &fid in comp {
2710                let Ok(face) = topo.face(fid) else {
2711                    continue;
2712                };
2713                for wid in
2714                    std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2715                {
2716                    let Ok(wire) = topo.wire(wid) else {
2717                        continue;
2718                    };
2719                    for oe in wire.edges() {
2720                        let Ok(edge) = topo.edge(oe.edge()) else {
2721                            continue;
2722                        };
2723                        for vid in [edge.start(), edge.end()] {
2724                            if let Ok(v) = topo.vertex(vid) {
2725                                let p = v.point();
2726                                min = Point3::new(
2727                                    min.x().min(p.x()),
2728                                    min.y().min(p.y()),
2729                                    min.z().min(p.z()),
2730                                );
2731                                max = Point3::new(
2732                                    max.x().max(p.x()),
2733                                    max.y().max(p.y()),
2734                                    max.z().max(p.z()),
2735                                );
2736                            }
2737                        }
2738                    }
2739                }
2740            }
2741            (min, max)
2742        })
2743        .collect();
2744
2745    // Reject NESTING, not mere AABB overlap.
2746    //
2747    // Nesting is the hazard worth rejecting (a blob sitting inside another
2748    // piece's cavity is not a disjoint union); side-by-side pieces are exactly
2749    // what multi-region acceptance is for.
2750    //
2751    // Assume nothing about the components handed in. The acceptance gate calls
2752    // this on a GFA result that has cleared only `euler_balanced` — which is
2753    // genus-tolerant, and whose `closed_manifold` companion is a LATER conjunct
2754    // in the same `&&` chain, not a precondition. The input-splitting paths
2755    // call it on components of a raw operand no gate has examined at all. So
2756    // "every piece is a closed manifold, hence disjoint-or-nested" is not
2757    // available here; the ray-parity confirmation below earns the answer
2758    // instead of inferring it.
2759    //
2760    // Overlap is the wrong predicate for that, because an AABB is only tight on
2761    // axis-aligned geometry. Two ROTATED bars a clear distance apart each span
2762    // the whole diagonal envelope, so their boxes interpenetrate and an
2763    // overlap test calls them touching — which is why a kumiko lattice cut,
2764    // whose members are diagonal, could never be accepted and fell back to the
2765    // mesh path on every band (see
2766    // `tests::rotated_separate_pieces_are_recognised_as_disjoint`). Containment
2767    // is tight in the direction that matters: nesting implies it, and rotation
2768    // does not manufacture it.
2769    let eps = 1e-7;
2770    let contains = |(o_min, o_max): (Point3, Point3), (i_min, i_max): (Point3, Point3)| {
2771        o_min.x() - eps <= i_min.x()
2772            && o_min.y() - eps <= i_min.y()
2773            && o_min.z() - eps <= i_min.z()
2774            && o_max.x() + eps >= i_max.x()
2775            && o_max.y() + eps >= i_max.y()
2776            && o_max.z() + eps >= i_max.z()
2777    };
2778    // AABB containment is only the PRE-FILTER. It is necessary for nesting but
2779    // far from sufficient: a ring's box contains the box of a separate piece
2780    // sitting in its HOLE, and a lattice is full of rings. So a suspect pair
2781    // gets a real ray-parity test against the enclosing candidate's own surface,
2782    // and only genuine enclosure rejects. If the probe cannot be evaluated the
2783    // pair falls back to the conservative answer.
2784    for i in 0..aabbs.len() {
2785        for j in (i + 1)..aabbs.len() {
2786            let (outer, inner) = if contains(aabbs[i], aabbs[j]) {
2787                (i, j)
2788            } else if contains(aabbs[j], aabbs[i]) {
2789                (j, i)
2790            } else {
2791                continue;
2792            };
2793            let (o_min, o_max) = aabbs[outer];
2794            let diag = ((o_max.x() - o_min.x()).powi(2)
2795                + (o_max.y() - o_min.y()).powi(2)
2796                + (o_max.z() - o_min.z()).powi(2))
2797            .sqrt();
2798            let deflection = (diag / 200.0).max(1e-4);
2799            let Some(probe) = any_vertex_of(topo, &components[inner]) else {
2800                return false;
2801            };
2802            match component_encloses_point(topo, &components[outer], probe, deflection) {
2803                Some(true) => return false,
2804                Some(false) => {}
2805                None => return false,
2806            }
2807        }
2808    }
2809    true
2810}
2811
2812/// Fuse a multi-component TOOL by folding its disjoint pieces into the
2813/// target one at a time.
2814///
2815/// Each piece is copied into a fresh connected solid (the pavefiller
2816/// stumbles on shared vertex IDs across what it considers one "solid B")
2817/// and fused via the full `boolean` entry, so every per-piece fuse gets the
2818/// analytic path, gates, and fallbacks. Fuse distributes over a
2819/// disjoint-union tool, so the fold is exact. Recursion terminates: each
2820/// piece is single-component, so the recursive call never re-enters this
2821/// path.
2822fn fuse_multi_component_tool(
2823    topo: &mut Topology,
2824    a: SolidId,
2825    b_components: Vec<Vec<brepkit_topology::face::FaceId>>,
2826) -> Result<SolidId, crate::OperationsError> {
2827    let mut result = a;
2828    for comp_faces in b_components {
2829        let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2830        let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2831        result = boolean(topo, BooleanOp::Fuse, result, comp_solid)?;
2832    }
2833    Ok(result)
2834}
2835
2836/// Cut a multi-region input solid: split the components, cut each
2837/// against `b` independently, then combine the per-component results
2838/// back into a single multi-region solid.
2839///
2840/// This works around the GFA pavefiller's assumption of a single
2841/// connected input — feeding a 2-piece "solid" into GFA loses one piece
2842/// at a time as the cut proceeds (Category B `multiple cuts creating
2843/// three pieces` and gear bore are both downstream of this).
2844fn cut_multi_region_input(
2845    topo: &mut Topology,
2846    a: SolidId,
2847    b: SolidId,
2848    comp_count: usize,
2849) -> Result<SolidId, crate::OperationsError> {
2850    let components = crate::boolean::assembly::face_components(topo, a);
2851    debug_assert_eq!(components.len(), comp_count);
2852
2853    let mut per_component_results: Vec<SolidId> = Vec::with_capacity(components.len());
2854    for comp_faces in components {
2855        // Copy the component's faces into a fresh single-component solid
2856        // so the boolean engine sees a connected manifold.
2857        let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2858        // Deep-copy the component into a fresh solid so its faces/edges/
2859        // vertices have fresh IDs disjoint from the original multi-region
2860        // input — GFA's pavefiller can stumble on shared vertex IDs across
2861        // what it considers a single "solid A".
2862        let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2863        match boolean(topo, BooleanOp::Cut, comp_solid, b) {
2864            Ok(r) => per_component_results.push(r),
2865            Err(
2866                crate::OperationsError::EmptyResult { .. }
2867                | crate::OperationsError::InvalidInput { .. },
2868            ) => {
2869                per_component_results.push(comp_solid);
2870            }
2871            Err(e) => return Err(e),
2872        }
2873    }
2874
2875    // Combine all per-component results into a single multi-region solid.
2876    // Collect every face from every result into one outer shell. The
2877    // results are pairwise disjoint by construction (each came from a
2878    // disjoint input component cut by the same tool), so a single shell
2879    // containing all their faces is a valid manifold representation.
2880    let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
2881    for &r in &per_component_results {
2882        let r_data = topo.solid(r)?;
2883        for &fid in topo.shell(r_data.outer_shell())?.faces() {
2884            all_faces.push(fid);
2885        }
2886    }
2887    make_solid_from_face_subset(topo, &all_faces)
2888}
2889
2890/// Build a new solid whose outer shell consists exactly of the given
2891/// faces. Faces are referenced as-is (no copying) — the caller is
2892/// expected to pass faces that already form a closed manifold.
2893///
2894/// `reversed=true` faces are NORMALIZED on the way in: a fresh face is
2895/// created with the surface normal negated, the wires reversed, and
2896/// `reversed=false`. Boolean operations downstream are sensitive to the
2897/// `reversed` flag (cut1's output carries reversed faces that GFA can't
2898/// re-process cleanly even via deep-copy), so handing GFA an
2899/// orientation-normalized solid recovers the fresh-primitive code path.
2900fn make_solid_from_face_subset(
2901    topo: &mut Topology,
2902    faces: &[brepkit_topology::face::FaceId],
2903) -> Result<SolidId, crate::OperationsError> {
2904    use brepkit_topology::face::{Face, FaceSurface};
2905    use brepkit_topology::wire::{OrientedEdge, Wire};
2906
2907    let mut normalized: Vec<brepkit_topology::face::FaceId> = Vec::with_capacity(faces.len());
2908    for &fid in faces {
2909        let face = topo.face(fid)?;
2910        if !face.is_reversed() {
2911            normalized.push(fid);
2912            continue;
2913        }
2914        // Only Plane has a trivial negate-the-normal flip. Non-planar
2915        // reversed faces (cylinder/cone/sphere/torus/nurbs) cannot have
2916        // their surface negated cheaply — they hit surface-specific GFA
2917        // paths that don't suffer from the same reversed-flag sensitivity.
2918        // Exhaustive match so a new FaceSurface variant fails to compile
2919        // rather than silently passing through un-normalized.
2920        let flipped_surface = match face.surface() {
2921            FaceSurface::Plane { normal, d } => FaceSurface::Plane {
2922                normal: -*normal,
2923                d: -*d,
2924            },
2925            FaceSurface::Nurbs(_)
2926            | FaceSurface::Cylinder(_)
2927            | FaceSurface::Cone(_)
2928            | FaceSurface::Sphere(_)
2929            | FaceSurface::Torus(_) => {
2930                normalized.push(fid);
2931                continue;
2932            }
2933        };
2934        let outer_wid = face.outer_wire();
2935        let inner_wids: Vec<_> = face.inner_wires().to_vec();
2936        let outer_wire = topo.wire(outer_wid)?;
2937        let outer_reversed: Vec<OrientedEdge> = outer_wire
2938            .edges()
2939            .iter()
2940            .rev()
2941            .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
2942            .collect();
2943        let new_outer_wire =
2944            Wire::new(outer_reversed, true).map_err(crate::OperationsError::Topology)?;
2945        let new_outer_wid = topo.add_wire(new_outer_wire);
2946        let mut new_inner_wids = Vec::with_capacity(inner_wids.len());
2947        for iw in &inner_wids {
2948            let w = topo.wire(*iw)?;
2949            let rev: Vec<OrientedEdge> = w
2950                .edges()
2951                .iter()
2952                .rev()
2953                .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
2954                .collect();
2955            let new_w = Wire::new(rev, true).map_err(crate::OperationsError::Topology)?;
2956            new_inner_wids.push(topo.add_wire(new_w));
2957        }
2958        let new_face = Face::new(new_outer_wid, new_inner_wids, flipped_surface);
2959        normalized.push(topo.add_face(new_face));
2960    }
2961
2962    let shell = brepkit_topology::shell::Shell::new(normalized)
2963        .map_err(crate::OperationsError::Topology)?;
2964    let shell_id = topo.add_shell(shell);
2965    let solid = brepkit_topology::solid::Solid::new(shell_id, Vec::new());
2966    Ok(topo.add_solid(solid))
2967}
2968
2969/// Count inner wire loops across all faces of a solid (outer + inner shells).
2970fn solid_inner_wire_count(topo: &Topology, solid: SolidId) -> Result<i64, crate::OperationsError> {
2971    let mut count: i64 = 0;
2972    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
2973        let face = topo.face(fid)?;
2974        #[allow(clippy::cast_possible_wrap)]
2975        {
2976            count += face.inner_wires().len() as i64;
2977        }
2978    }
2979    Ok(count)
2980}
2981
2982/// Genus-aware Euler balance for `components` closed orientable surfaces with
2983/// holed faces.
2984///
2985/// Euler-Poincare over `C` closed components of total genus `G`:
2986/// `V - E + F - L = 2C - 2G`, so the inner-wire surplus `euler - L` is valid
2987/// when it is even and no greater than `2C` — `2C` for all-genus-0 pieces, less
2988/// by two per unit of genus (a thin wall pierced by N through-holes has genus
2989/// N). Odd or `> 2C` surpluses indicate a miscounted shell.
2990///
2991/// The `2C` bound matters as much as the parity: a multi-region result is not
2992/// obliged to be a bag of spheres. A kumiko lattice cut yields RINGS, and a
2993/// closed loop of material is genus 1 (`chi = 0`), so demanding `euler == 2C`
2994/// exactly rejected every lattice result and forced it onto the mesh path.
2995///
2996/// Callers must pair this with a closed-manifold check — the relation only holds
2997/// for closed surfaces.
2998const fn euler_balanced(euler: i64, inner_wires: i64, components: i64) -> bool {
2999    let surplus = euler - inner_wires;
3000    surplus <= components.saturating_mul(2) && surplus % 2 == 0
3001}
3002
3003/// Count edge uses across ALL shells of a solid (outer + inner cavity
3004/// shells). Hollow solids keep cavity faces in inner shells — an
3005/// outer-shell-only walk silently misses their edges, letting open or
3006/// non-manifold cavity shells pass the acceptance gates.
3007fn solid_edge_use_counts(
3008    topo: &Topology,
3009    solid: SolidId,
3010) -> Result<std::collections::HashMap<usize, usize>, crate::OperationsError> {
3011    let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3012    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3013        let face = topo.face(fid)?;
3014        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3015            let wire = topo.wire(wid)?;
3016            for oe in wire.edges() {
3017                *counts.entry(oe.edge().index()).or_insert(0) += 1;
3018            }
3019        }
3020    }
3021    Ok(counts)
3022}
3023
3024/// Check whether every shell of a solid is a closed manifold: every edge
3025/// is shared by exactly 2 faces within its shell. Returns `false` for open
3026/// shells (boundary edges with count == 1) and non-manifold shells
3027/// (count > 2). Walks inner (cavity) shells as well as the outer shell —
3028/// each shell is an independent closed surface, so a single pooled count
3029/// per shell is correct.
3030///
3031/// Stricter than [`brepkit_topology::validation::validate_shell_manifold`],
3032/// which only rejects edges shared by *more* than two faces.
3033fn is_closed_manifold(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3034    let s = topo.solid(solid)?;
3035    let shell_ids: Vec<_> = std::iter::once(s.outer_shell())
3036        .chain(s.inner_shells().iter().copied())
3037        .collect();
3038    for shell_id in shell_ids {
3039        let shell = topo.shell(shell_id)?;
3040        if !shell_is_closed_manifold(topo, shell)? {
3041            return Ok(false);
3042        }
3043    }
3044    Ok(true)
3045}
3046
3047fn shell_is_closed_manifold(
3048    topo: &Topology,
3049    shell: &brepkit_topology::shell::Shell,
3050) -> Result<bool, crate::OperationsError> {
3051    use std::collections::HashMap;
3052
3053    let mut counts: HashMap<usize, usize> = HashMap::new();
3054    for &fid in shell.faces() {
3055        let face = topo.face(fid)?;
3056        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3057            let wire = topo.wire(wid)?;
3058            for oe in wire.edges() {
3059                *counts.entry(oe.edge().index()).or_insert(0) += 1;
3060            }
3061        }
3062    }
3063    if counts.is_empty() {
3064        return Ok(false);
3065    }
3066    Ok(counts.values().all(|&c| c == 2))
3067}
3068
3069/// Check whether a solid's boundary has free edges: edges used by only
3070/// one wire occurrence. A free edge means the shell is open (e.g. a phantom
3071/// membrane face left a circle edge unmatched), which is never a valid
3072/// boolean result even when Euler accidentally balances.
3073fn has_free_edges(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3074    let counts = solid_edge_use_counts(topo, solid)?;
3075    Ok(counts.values().any(|&c| c == 1))
3076}
3077
3078/// Cheap read-only test for whether [`flatten_planar_nurbs_faces`] would change
3079/// anything: does `solid` carry a planar NURBS face or a straight NURBS edge?
3080/// Used to gate the deep-copy-and-flatten pre-pass so analytic operands are
3081/// passed to the engine unchanged (a needless deep copy renumbers entity ids
3082/// and can perturb the engine's id-keyed ordering on volume-sensitive cuts).
3083///
3084/// `tol` must match the linear tolerance passed to [`flatten_planar_nurbs_faces`]
3085/// so the gate and the pass agree: a looser default here could report "nothing
3086/// to flatten" while the pass (run at the operation tolerance) would in fact
3087/// rewrite geometry, reintroducing the NURBS-vs-plane fragmentation.
3088fn solid_has_flattenable_nurbs(
3089    topo: &Topology,
3090    solid: SolidId,
3091    tol: f64,
3092) -> Result<bool, crate::OperationsError> {
3093    use brepkit_geometry::convert::{
3094        RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3095    };
3096    use brepkit_topology::edge::EdgeCurve;
3097    use brepkit_topology::explorer::solid_faces;
3098
3099    let mut seen = std::collections::HashSet::new();
3100    for fid in solid_faces(topo, solid)? {
3101        let face = topo.face(fid)?;
3102        if let FaceSurface::Nurbs(nurbs) = face.surface()
3103            && matches!(
3104                recognize_surface(nurbs, tol),
3105                RecognizedSurface::Plane { .. }
3106            )
3107        {
3108            return Ok(true);
3109        }
3110        for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3111            let wire = topo.wire(wid)?;
3112            for oe in wire.edges() {
3113                let eid = oe.edge();
3114                if !seen.insert(eid.index()) {
3115                    continue;
3116                }
3117                if let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve()
3118                    && matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. })
3119                {
3120                    return Ok(true);
3121                }
3122            }
3123        }
3124    }
3125    Ok(false)
3126}
3127
3128/// Replace planar NURBS faces of `solid` with analytic `Plane` surfaces, and
3129/// straight NURBS boundary edges with the `Line` variant.
3130///
3131/// A NURBS surface whose every control point lies within `tol` of a single
3132/// plane is geometrically a plane; the tool's rounded-rect extrude emits the
3133/// straight cavity walls as planar B-splines, and the boolean engine's
3134/// face-face intersections only take the exact (same-domain) plane×plane path
3135/// when both operands are `FaceSurface::Plane`. Recognising the flat walls as
3136/// planes before the boolean lets coincident/abutting wall regions merge
3137/// analytically instead of fragmenting through the NURBS surface-intersection
3138/// path.
3139///
3140/// The same extrude also leaves the straight cavity-floor/wall boundary edges
3141/// as NURBS curves. A planar-arrangement splitter treats every non-`Line` edge
3142/// as an arc and bails when one is split mid-edge by a coplanar section, so a
3143/// straight NURBS floor edge crossed by the scoop footprint forces the floor
3144/// face to a self-crossing trace. Recognising those straight NURBS edges as
3145/// `Line` lets the arrangement split them exactly.
3146///
3147/// Genuinely curved NURBS surfaces/edges (and all other analytic geometry) are
3148/// left untouched. Returns the number of faces flattened.
3149fn flatten_planar_nurbs_faces(
3150    topo: &mut Topology,
3151    solid: SolidId,
3152    tol: f64,
3153) -> Result<usize, crate::OperationsError> {
3154    use brepkit_geometry::convert::{
3155        RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3156    };
3157    use brepkit_topology::edge::{EdgeCurve, EdgeId};
3158    use brepkit_topology::explorer::solid_faces;
3159
3160    let face_ids = solid_faces(topo, solid)?;
3161    // Snapshot the surfaces first (immutable borrow), then mutate.
3162    let planar: Vec<(FaceId, Vec3, f64)> = face_ids
3163        .iter()
3164        .filter_map(|&fid| {
3165            let face = topo.face(fid).ok()?;
3166            let FaceSurface::Nurbs(nurbs) = face.surface() else {
3167                return None;
3168            };
3169            match recognize_surface(nurbs, tol) {
3170                RecognizedSurface::Plane { normal, d } => {
3171                    // `recognize_surface` derives the plane normal from a
3172                    // control-point cross product, whose sign can OPPOSE the
3173                    // NURBS surface's own du×dv normal. A `FaceSurface::Plane`
3174                    // is read with its normal flipped by `is_reversed`, so an
3175                    // opposed sign silently inverts the face's effective
3176                    // outward direction. Align to the surface du×dv normal at
3177                    // the domain midpoint.
3178                    let (u0, u1) = nurbs.domain_u();
3179                    let (v0, v1) = nurbs.domain_v();
3180                    let mid_n = nurbs.normal(0.5 * (u0 + u1), 0.5 * (v0 + v1)).ok();
3181                    let (normal, d) = match mid_n {
3182                        Some(n) if normal.dot(n) < 0.0 => (-normal, -d),
3183                        _ => (normal, d),
3184                    };
3185                    Some((fid, normal, d))
3186                }
3187                _ => None,
3188            }
3189        })
3190        .collect();
3191    let count = planar.len();
3192    for (fid, normal, d) in planar {
3193        topo.face_mut(fid)?
3194            .set_surface(FaceSurface::Plane { normal, d });
3195    }
3196
3197    // Straighten NURBS edges that are geometrically lines.
3198    let mut straight_edges: Vec<EdgeId> = Vec::new();
3199    let mut seen = std::collections::HashSet::new();
3200    for &fid in &face_ids {
3201        let face = topo.face(fid)?;
3202        for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3203            let wire = topo.wire(wid)?;
3204            for oe in wire.edges() {
3205                let eid = oe.edge();
3206                if !seen.insert(eid.index()) {
3207                    continue;
3208                }
3209                let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve() else {
3210                    continue;
3211                };
3212                if matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. }) {
3213                    straight_edges.push(eid);
3214                }
3215            }
3216        }
3217    }
3218    for eid in straight_edges {
3219        topo.edge_mut(eid)?.set_curve(EdgeCurve::Line);
3220    }
3221
3222    Ok(count)
3223}
3224
3225/// Test-only access to [`flatten_planar_nurbs_faces`] so integration tests can
3226/// reproduce the exact operand preprocessing the boolean applies before handing
3227/// the operands to the GFA engine.
3228#[doc(hidden)]
3229pub fn flatten_planar_nurbs_faces_for_tests(
3230    topo: &mut Topology,
3231    solid: SolidId,
3232    tol: f64,
3233) -> Result<usize, crate::OperationsError> {
3234    flatten_planar_nurbs_faces(topo, solid, tol)
3235}
3236
3237/// For each vertex position (quantized at tolerance), picks one canonical
3238/// vertex. Rebuilds all edges and wires to use canonical vertices.
3239/// Creates new edges (doesn't mutate existing ones) to avoid corrupting
3240/// input solids that may share edge topology.
3241#[allow(clippy::items_after_statements, clippy::type_complexity)]
3242fn merge_result_vertices(
3243    topo: &mut Topology,
3244    solid: SolidId,
3245    tol: brepkit_math::tolerance::Tolerance,
3246) -> Result<(), crate::OperationsError> {
3247    use std::collections::{BTreeMap, HashMap};
3248
3249    let shell_id = topo.solid(solid)?.outer_shell();
3250    let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3251
3252    let scale = 1.0 / tol.linear;
3253    let quantize = |p: brepkit_math::vec::Point3| -> (i64, i64, i64) {
3254        (
3255            (p.x() * scale).round() as i64,
3256            (p.y() * scale).round() as i64,
3257            (p.z() * scale).round() as i64,
3258        )
3259    };
3260
3261    // Build vertex canonical map: position → first VertexId seen
3262    let mut canonical: BTreeMap<(i64, i64, i64), brepkit_topology::vertex::VertexId> =
3263        BTreeMap::new();
3264    let mut replacements: HashMap<
3265        brepkit_topology::vertex::VertexId,
3266        brepkit_topology::vertex::VertexId,
3267    > = HashMap::new();
3268
3269    for &fid in &face_ids {
3270        let face = topo.face(fid)?;
3271        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3272            let wire = topo.wire(wid)?;
3273            for oe in wire.edges() {
3274                let edge = topo.edge(oe.edge())?;
3275                for vid in [edge.start(), edge.end()] {
3276                    let pos = topo.vertex(vid)?.point();
3277                    let key = quantize(pos);
3278                    let canon = *canonical.entry(key).or_insert(vid);
3279                    if canon != vid {
3280                        replacements.insert(vid, canon);
3281                    }
3282                }
3283            }
3284        }
3285    }
3286
3287    if replacements.is_empty() {
3288        return Ok(());
3289    }
3290
3291    // Rebuild faces with merged vertices
3292    // Cache: (old_edge, new_start, new_end) → new_edge to share edges
3293    let mut edge_cache: HashMap<
3294        (
3295            brepkit_topology::edge::EdgeId,
3296            brepkit_topology::vertex::VertexId,
3297            brepkit_topology::vertex::VertexId,
3298        ),
3299        brepkit_topology::edge::EdgeId,
3300    > = HashMap::new();
3301
3302    // Snapshot face data, then rebuild with merged vertices
3303    struct FaceSnap {
3304        surface: brepkit_topology::face::FaceSurface,
3305        reversed: bool,
3306        outer_oes: Vec<(
3307            brepkit_topology::edge::EdgeId,
3308            bool,
3309            brepkit_topology::edge::EdgeCurve,
3310            brepkit_topology::vertex::VertexId,
3311            brepkit_topology::vertex::VertexId,
3312            Option<f64>, // edge tolerance
3313        )>,
3314        outer_closed: bool,
3315        inner_wires: Vec<(
3316            Vec<(
3317                brepkit_topology::edge::EdgeId,
3318                bool,
3319                brepkit_topology::edge::EdgeCurve,
3320                brepkit_topology::vertex::VertexId,
3321                brepkit_topology::vertex::VertexId,
3322                Option<f64>,
3323            )>,
3324            bool, // wire closed flag
3325        )>,
3326    }
3327
3328    let mut snaps = Vec::with_capacity(face_ids.len());
3329    for &fid in &face_ids {
3330        let face = topo.face(fid)?;
3331        let surface = face.surface().clone();
3332        let reversed = face.is_reversed();
3333        let outer_wire = topo.wire(face.outer_wire())?;
3334        let outer_closed = outer_wire.is_closed();
3335        let outer_oes: Vec<_> = outer_wire
3336            .edges()
3337            .iter()
3338            .map(|oe| -> Result<_, crate::OperationsError> {
3339                let e = topo.edge(oe.edge())?;
3340                Ok((
3341                    oe.edge(),
3342                    oe.is_forward(),
3343                    e.curve().clone(),
3344                    e.start(),
3345                    e.end(),
3346                    e.tolerance(),
3347                ))
3348            })
3349            .collect::<Result<_, _>>()?;
3350        let inner_wids = face.inner_wires().to_vec();
3351        let mut inner_wires = Vec::new();
3352        for iw in inner_wids {
3353            let w = topo.wire(iw)?;
3354            let closed = w.is_closed();
3355            let oes: Vec<_> = w
3356                .edges()
3357                .iter()
3358                .map(|oe| -> Result<_, crate::OperationsError> {
3359                    let e = topo.edge(oe.edge())?;
3360                    Ok((
3361                        oe.edge(),
3362                        oe.is_forward(),
3363                        e.curve().clone(),
3364                        e.start(),
3365                        e.end(),
3366                        e.tolerance(),
3367                    ))
3368                })
3369                .collect::<Result<_, _>>()?;
3370            inner_wires.push((oes, closed));
3371        }
3372        snaps.push(FaceSnap {
3373            surface,
3374            reversed,
3375            outer_oes,
3376            outer_closed,
3377            inner_wires,
3378        });
3379    }
3380
3381    #[allow(clippy::type_complexity)]
3382    let remap_oes = |oes: &[(
3383        brepkit_topology::edge::EdgeId,
3384        bool,
3385        brepkit_topology::edge::EdgeCurve,
3386        brepkit_topology::vertex::VertexId,
3387        brepkit_topology::vertex::VertexId,
3388        Option<f64>,
3389    )],
3390                     replacements: &HashMap<
3391        brepkit_topology::vertex::VertexId,
3392        brepkit_topology::vertex::VertexId,
3393    >,
3394                     edge_cache: &mut HashMap<
3395        (
3396            brepkit_topology::edge::EdgeId,
3397            brepkit_topology::vertex::VertexId,
3398            brepkit_topology::vertex::VertexId,
3399        ),
3400        brepkit_topology::edge::EdgeId,
3401    >,
3402                     topo: &mut Topology|
3403     -> Vec<brepkit_topology::wire::OrientedEdge> {
3404        oes.iter()
3405            .map(|(eid, fwd, curve, start, end, edge_tol)| {
3406                let ns = replacements.get(start).copied().unwrap_or(*start);
3407                let ne = replacements.get(end).copied().unwrap_or(*end);
3408                if ns == *start && ne == *end {
3409                    return brepkit_topology::wire::OrientedEdge::new(*eid, *fwd);
3410                }
3411                let key = (*eid, ns, ne);
3412                let new_eid = *edge_cache.entry(key).or_insert_with(|| {
3413                    topo.add_edge(brepkit_topology::edge::Edge::with_tolerance(
3414                        ns,
3415                        ne,
3416                        curve.clone(),
3417                        *edge_tol,
3418                    ))
3419                });
3420                brepkit_topology::wire::OrientedEdge::new(new_eid, *fwd)
3421            })
3422            .collect()
3423    };
3424
3425    let mut new_face_ids = Vec::with_capacity(snaps.len());
3426    for snap in &snaps {
3427        let outer_oes = remap_oes(&snap.outer_oes, &replacements, &mut edge_cache, topo);
3428        let Ok(outer_wire) = brepkit_topology::wire::Wire::new(outer_oes, snap.outer_closed) else {
3429            // Wire rebuild failed — keep the original face unchanged
3430            // rather than silently dropping it
3431            continue;
3432        };
3433        let outer_id = topo.add_wire(outer_wire);
3434
3435        let mut inner_ids = Vec::new();
3436        for (inner_oes_snap, inner_closed) in &snap.inner_wires {
3437            let oes = remap_oes(inner_oes_snap, &replacements, &mut edge_cache, topo);
3438            if let Ok(w) = brepkit_topology::wire::Wire::new(oes, *inner_closed) {
3439                inner_ids.push(topo.add_wire(w));
3440            }
3441        }
3442
3443        let mut new_face =
3444            brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3445        if snap.reversed {
3446            new_face.set_reversed(true);
3447        }
3448        new_face_ids.push(topo.add_face(new_face));
3449    }
3450
3451    // Replace the shell's faces
3452    let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3453    let new_shell_id = topo.add_shell(new_shell);
3454    let solid_mut = topo.solid_mut(solid)?;
3455    solid_mut.set_outer_shell(new_shell_id);
3456
3457    Ok(())
3458}
3459
3460/// Merge geometrically-coincident duplicate boundary edges on the outer shell.
3461///
3462/// A coincident-junction fuse (e.g. a box stacked on a tapered loft that share
3463/// a cap face) annihilates the shared cap but leaves each argument's faces
3464/// carrying their OWN copy of the junction-wire edges. Because the two copies
3465/// come from independently-built solids their endpoints differ by sub-micron
3466/// numerical noise (loft re-parameterization), so the tight-tolerance vertex
3467/// merge above leaves them as distinct edges — each used once → free edges that
3468/// open the shell.
3469///
3470/// This snaps vertices at `tol_merge` (looser than the default linear
3471/// tolerance, to absorb that noise), then rebuilds every wire against a global
3472/// canonical-edge map keyed by *unordered canonical endpoints + curve type +
3473/// geometric midpoint* — so a straight line and a bulged arc between the same
3474/// endpoints stay distinct, while true duplicates collapse to one shared edge.
3475/// Edges whose endpoints merge to a single vertex (degenerate) are dropped.
3476///
3477/// Returns `true` if anything changed. Run only on already-broken results
3478/// (free edges / non-manifold) so clean booleans keep their exact topology.
3479#[allow(
3480    clippy::too_many_lines,
3481    clippy::type_complexity,
3482    clippy::items_after_statements
3483)]
3484fn unify_coincident_boundary_edges(
3485    topo: &mut Topology,
3486    solid: SolidId,
3487    tol_merge: f64,
3488) -> Result<bool, crate::OperationsError> {
3489    use brepkit_topology::edge::{Edge, EdgeCurve, EdgeId};
3490    use brepkit_topology::vertex::VertexId;
3491    use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
3492    use std::collections::HashMap;
3493
3494    let shell_id = topo.solid(solid)?.outer_shell();
3495    let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3496
3497    let scale = 1.0 / tol_merge;
3498    let q = |p: Point3| -> (i64, i64, i64) {
3499        (
3500            (p.x() * scale).round() as i64,
3501            (p.y() * scale).round() as i64,
3502            (p.z() * scale).round() as i64,
3503        )
3504    };
3505
3506    // 1. Canonical vertex per quantized position (first VertexId seen wins).
3507    let mut vcanon: HashMap<(i64, i64, i64), VertexId> = HashMap::new();
3508    for &fid in &face_ids {
3509        let face = topo.face(fid)?;
3510        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3511            let wire = topo.wire(wid)?;
3512            for oe in wire.edges() {
3513                let edge = topo.edge(oe.edge())?;
3514                for vid in [edge.start(), edge.end()] {
3515                    let key = q(topo.vertex(vid)?.point());
3516                    vcanon.entry(key).or_insert(vid);
3517                }
3518            }
3519        }
3520    }
3521
3522    // 2. Snapshot each face's wires (edge id, fwd, curve, endpoints, tol).
3523    type OeSnap = (EdgeId, bool, EdgeCurve, VertexId, VertexId, Option<f64>);
3524    struct FaceSnap {
3525        surface: FaceSurface,
3526        reversed: bool,
3527        outer: Vec<OeSnap>,
3528        outer_closed: bool,
3529        inners: Vec<(Vec<OeSnap>, bool)>,
3530    }
3531    let snap_wire =
3532        |topo: &Topology, wid: WireId| -> Result<(Vec<OeSnap>, bool), crate::OperationsError> {
3533            let w = topo.wire(wid)?;
3534            let closed = w.is_closed();
3535            let oes = w
3536                .edges()
3537                .iter()
3538                .map(|oe| -> Result<OeSnap, crate::OperationsError> {
3539                    let e = topo.edge(oe.edge())?;
3540                    Ok((
3541                        oe.edge(),
3542                        oe.is_forward(),
3543                        e.curve().clone(),
3544                        e.start(),
3545                        e.end(),
3546                        e.tolerance(),
3547                    ))
3548                })
3549                .collect::<Result<_, _>>()?;
3550            Ok((oes, closed))
3551        };
3552    let mut snaps = Vec::with_capacity(face_ids.len());
3553    for &fid in &face_ids {
3554        let face = topo.face(fid)?;
3555        let surface = face.surface().clone();
3556        let reversed = face.is_reversed();
3557        let (outer, outer_closed) = snap_wire(topo, face.outer_wire())?;
3558        let mut inners = Vec::new();
3559        for iw in face.inner_wires() {
3560            inners.push(snap_wire(topo, *iw)?);
3561        }
3562        snaps.push(FaceSnap {
3563            surface,
3564            reversed,
3565            outer,
3566            outer_closed,
3567            inners,
3568        });
3569    }
3570
3571    // 3. Rebuild wires against a global canonical-edge map.
3572    //    Key: (lo endpoint q, hi endpoint q, midpoint q, curve type tag).
3573    type EdgeKey = (
3574        (i64, i64, i64),
3575        (i64, i64, i64),
3576        (i64, i64, i64),
3577        &'static str,
3578    );
3579    let mut ecanon: HashMap<EdgeKey, (EdgeId, VertexId, VertexId)> = HashMap::new();
3580    let mut changed = false;
3581
3582    let canon_vid = |topo: &Topology, vid: VertexId| -> Result<VertexId, crate::OperationsError> {
3583        Ok(*vcanon.get(&q(topo.vertex(vid)?.point())).unwrap_or(&vid))
3584    };
3585
3586    let rebuild = |topo: &mut Topology,
3587                   oes: &[OeSnap],
3588                   ecanon: &mut HashMap<EdgeKey, (EdgeId, VertexId, VertexId)>,
3589                   changed: &mut bool|
3590     -> Result<Vec<OrientedEdge>, crate::OperationsError> {
3591        let mut out = Vec::with_capacity(oes.len());
3592        for (eid, fwd, curve, start, end, etol) in oes {
3593            let cs = canon_vid(topo, *start)?;
3594            let ce = canon_vid(topo, *end)?;
3595            if cs == ce {
3596                // Endpoints collapsed to a single vertex → degenerate, drop it.
3597                *changed = true;
3598                continue;
3599            }
3600            let sp = topo.vertex(*start)?.point();
3601            let ep = topo.vertex(*end)?.point();
3602            let (t0, t1) = curve.domain_with_endpoints(sp, ep);
3603            let mid = curve.evaluate_with_endpoints((t0 + t1) * 0.5, sp, ep);
3604            let (cs_q, ce_q) = (q(topo.vertex(cs)?.point()), q(topo.vertex(ce)?.point()));
3605            let (lo, hi) = if cs_q <= ce_q {
3606                (cs_q, ce_q)
3607            } else {
3608                (ce_q, cs_q)
3609            };
3610            let key = (lo, hi, q(mid), curve.type_tag());
3611
3612            // Physical traversal start vertex (after canonicalization).
3613            let trav_start = if *fwd { cs } else { ce };
3614            if let Some(&(c_eid, c_start, _c_end)) = ecanon.get(&key) {
3615                // A duplicate of an already-seen edge → merge onto the keeper.
3616                *changed = true;
3617                out.push(OrientedEdge::new(c_eid, c_start == trav_start));
3618            } else {
3619                // First edge with this key. Reuse the original edge when its
3620                // endpoints didn't move; only allocate (and flag a change) when
3621                // a vertex was snapped — so an already-clean shell is a no-op.
3622                let (eid_use, e_start) = if cs == *start && ce == *end {
3623                    (*eid, *start)
3624                } else {
3625                    *changed = true;
3626                    (
3627                        topo.add_edge(Edge::with_tolerance(cs, ce, curve.clone(), *etol)),
3628                        cs,
3629                    )
3630                };
3631                ecanon.insert(key, (eid_use, e_start, ce));
3632                out.push(OrientedEdge::new(eid_use, e_start == trav_start));
3633            }
3634        }
3635        Ok(out)
3636    };
3637
3638    let mut new_face_ids = Vec::with_capacity(snaps.len());
3639    for snap in &snaps {
3640        let outer_oes = rebuild(topo, &snap.outer, &mut ecanon, &mut changed)?;
3641        let Ok(outer_wire) = Wire::new(outer_oes, snap.outer_closed) else {
3642            // Keep original face if the rebuilt wire is invalid.
3643            return Ok(false);
3644        };
3645        let outer_id = topo.add_wire(outer_wire);
3646        let mut inner_ids = Vec::new();
3647        for (inner_oes, inner_closed) in &snap.inners {
3648            let oes = rebuild(topo, inner_oes, &mut ecanon, &mut changed)?;
3649            let Ok(w) = Wire::new(oes, *inner_closed) else {
3650                // A dropped hole silently changes topology (and removes free
3651                // edges, so the downstream gate can't catch it). Bail like the
3652                // outer-wire case, leaving the original solid untouched.
3653                return Ok(false);
3654            };
3655            inner_ids.push(topo.add_wire(w));
3656        }
3657        let mut new_face =
3658            brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3659        if snap.reversed {
3660            new_face.set_reversed(true);
3661        }
3662        new_face_ids.push(topo.add_face(new_face));
3663    }
3664
3665    if !changed {
3666        return Ok(false);
3667    }
3668
3669    let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3670    let new_shell_id = topo.add_shell(new_shell);
3671    topo.solid_mut(solid)?.set_outer_shell(new_shell_id);
3672    Ok(true)
3673}
3674
3675/// Post-process a solid to enforce manifold topology via greedy flood-fill.
3676///
3677/// Detects non-manifold edges (shared by 3+ faces) and uses greedy
3678/// shell building to split the non-manifold shell into manifold
3679/// sub-shells. The largest sub-shell becomes the outer shell; smaller ones
3680/// become inner shells (cavities).
3681///
3682/// If the solid is already manifold, returns it unchanged.
3683#[allow(clippy::too_many_lines)]
3684fn enforce_manifold_shell(
3685    topo: &mut Topology,
3686    solid: SolidId,
3687) -> Result<SolidId, crate::OperationsError> {
3688    use std::collections::{HashMap, HashSet, VecDeque};
3689
3690    let shell_id = topo.solid(solid)?.outer_shell();
3691    let face_ids = topo.shell(shell_id)?.faces().to_vec();
3692
3693    // Count edges per face.
3694    let mut edge_face_count: HashMap<usize, u32> = HashMap::new();
3695    for &fid in &face_ids {
3696        if let Ok(face) = topo.face(fid) {
3697            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3698            {
3699                if let Ok(wire) = topo.wire(wid) {
3700                    for oe in wire.edges() {
3701                        *edge_face_count.entry(oe.edge().index()).or_default() += 1;
3702                    }
3703                }
3704            }
3705        }
3706    }
3707
3708    // Only apply for significant non-manifold (>3 edges). Minor non-manifold
3709    // (1-3 edges) from sphere/cone intersections is tolerable and splitting
3710    // the shell at those edges breaks downstream operations (section, volume).
3711    let nm_count = edge_face_count.values().filter(|&&c| c > 2).count();
3712    if nm_count <= 3 {
3713        return Ok(solid);
3714    }
3715
3716    log::debug!(
3717        "enforce_manifold_shell: {} non-manifold edges in {} faces",
3718        nm_count,
3719        face_ids.len()
3720    );
3721
3722    // Build vertex-pair → face adjacency for neighbor discovery.
3723    let mut vpair_faces: HashMap<(usize, usize), Vec<brepkit_topology::face::FaceId>> =
3724        HashMap::new();
3725    for &fid in &face_ids {
3726        if let Ok(face) = topo.face(fid) {
3727            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3728            {
3729                if let Ok(wire) = topo.wire(wid) {
3730                    for oe in wire.edges() {
3731                        if let Ok(e) = topo.edge(oe.edge()) {
3732                            let si = e.start().index();
3733                            let ei = e.end().index();
3734                            let key = if si <= ei { (si, ei) } else { (ei, si) };
3735                            vpair_faces.entry(key).or_default().push(fid);
3736                        }
3737                    }
3738                }
3739            }
3740        }
3741    }
3742
3743    // Greedy flood-fill shell construction.
3744    let available: HashSet<brepkit_topology::face::FaceId> = face_ids.iter().copied().collect();
3745    let mut processed: HashSet<brepkit_topology::face::FaceId> = HashSet::new();
3746    let mut shells: Vec<Vec<brepkit_topology::face::FaceId>> = Vec::new();
3747
3748    for &start_face in &face_ids {
3749        if processed.contains(&start_face) {
3750            continue;
3751        }
3752
3753        let mut shell_faces = vec![start_face];
3754        processed.insert(start_face);
3755
3756        // Track edge-ID usage within this shell.
3757        let mut shell_edge_count: HashMap<usize, u32> = HashMap::new();
3758        if let Ok(face) = topo.face(start_face) {
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                        *shell_edge_count.entry(oe.edge().index()).or_default() += 1;
3764                    }
3765                }
3766            }
3767        }
3768
3769        let mut queue = VecDeque::new();
3770        queue.push_back(start_face);
3771
3772        while let Some(current) = queue.pop_front() {
3773            let Ok(face) = topo.face(current) else {
3774                continue;
3775            };
3776            // Collect (vpair, edge_id) from all wires.
3777            let mut all_edges = Vec::new();
3778            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3779            {
3780                if let Ok(wire) = topo.wire(wid) {
3781                    for oe in wire.edges() {
3782                        if let Ok(e) = topo.edge(oe.edge()) {
3783                            let si = e.start().index();
3784                            let ei = e.end().index();
3785                            let key = if si <= ei { (si, ei) } else { (ei, si) };
3786                            all_edges.push((key, oe.edge()));
3787                        }
3788                    }
3789                }
3790            }
3791
3792            for (vpair, edge_id) in all_edges {
3793                let eidx = edge_id.index();
3794
3795                // Skip edges already manifold in this shell.
3796                if shell_edge_count.get(&eidx).copied().unwrap_or(0) >= 2 {
3797                    continue;
3798                }
3799
3800                // Find candidate neighbor faces via vertex-pair.
3801                let candidates: Vec<brepkit_topology::face::FaceId> = vpair_faces
3802                    .get(&vpair)
3803                    .map(|fs| {
3804                        fs.iter()
3805                            .copied()
3806                            .filter(|&f| {
3807                                f != current && available.contains(&f) && !processed.contains(&f)
3808                            })
3809                            .collect()
3810                    })
3811                    .unwrap_or_default();
3812
3813                if candidates.is_empty() {
3814                    continue;
3815                }
3816
3817                // Pick first candidate (simple heuristic — dihedral selection
3818                // would be better but requires surface normal evaluation).
3819                let selected = candidates[0];
3820
3821                if processed.contains(&selected) {
3822                    continue;
3823                }
3824
3825                processed.insert(selected);
3826                shell_faces.push(selected);
3827                queue.push_back(selected);
3828
3829                // Update edge count.
3830                if let Ok(sel_face) = topo.face(selected) {
3831                    for wid in std::iter::once(sel_face.outer_wire())
3832                        .chain(sel_face.inner_wires().iter().copied())
3833                    {
3834                        if let Ok(wire) = topo.wire(wid) {
3835                            for sel_oe in wire.edges() {
3836                                *shell_edge_count.entry(sel_oe.edge().index()).or_default() += 1;
3837                            }
3838                        }
3839                    }
3840                }
3841            }
3842        }
3843
3844        shells.push(shell_faces);
3845    }
3846
3847    // Add any unprocessed faces to a final shell.
3848    let remaining: Vec<brepkit_topology::face::FaceId> = available
3849        .iter()
3850        .filter(|f| !processed.contains(f))
3851        .copied()
3852        .collect();
3853    if !remaining.is_empty() {
3854        shells.push(remaining);
3855    }
3856
3857    if shells.len() <= 1 {
3858        // Single shell — nothing to split.
3859        return Ok(solid);
3860    }
3861
3862    log::debug!(
3863        "enforce_manifold_shell: split into {} shells (sizes: {:?})",
3864        shells.len(),
3865        shells.iter().map(Vec::len).collect::<Vec<_>>(),
3866    );
3867
3868    // Build the solid: largest shell is outer, rest are inner.
3869    let mut best_idx = 0;
3870    let mut best_count = 0;
3871    for (i, faces) in shells.iter().enumerate() {
3872        if faces.len() > best_count {
3873            best_count = faces.len();
3874            best_idx = i;
3875        }
3876    }
3877
3878    let outer = brepkit_topology::shell::Shell::new(shells[best_idx].clone())
3879        .map_err(crate::OperationsError::Topology)?;
3880    let outer_id = topo.add_shell(outer);
3881    let mut inner_ids = Vec::new();
3882    for (i, faces) in shells.iter().enumerate() {
3883        if i != best_idx
3884            && !faces.is_empty()
3885            && let Ok(inner) = brepkit_topology::shell::Shell::new(faces.clone())
3886        {
3887            inner_ids.push(topo.add_shell(inner));
3888        }
3889    }
3890
3891    Ok(topo.add_solid(brepkit_topology::solid::Solid::new(outer_id, inner_ids)))
3892}
3893
3894/// Sample `n` evenly-spaced points along a closed edge curve.
3895///
3896/// For `Circle` and `Ellipse`, samples at `TAU * i / n`.
3897/// For closed `NurbsCurve`, samples across the domain avoiding endpoint
3898/// duplication. Returns an empty vec for `Line` (no sampling possible).
3899pub(crate) fn sample_edge_curve(curve: &EdgeCurve, n: usize) -> Vec<Point3> {
3900    match curve {
3901        EdgeCurve::Circle(c) => (0..n)
3902            .map(|i| {
3903                #[allow(clippy::cast_precision_loss)]
3904                let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3905                c.evaluate(t)
3906            })
3907            .collect(),
3908        EdgeCurve::Ellipse(e) => (0..n)
3909            .map(|i| {
3910                #[allow(clippy::cast_precision_loss)]
3911                let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3912                e.evaluate(t)
3913            })
3914            .collect(),
3915        EdgeCurve::NurbsCurve(nc) => {
3916            let (u0, u1) = nc.domain();
3917            // For closed curves (start ~ end), use n as divisor to avoid
3918            // duplicating the first point at t=u_max.
3919            let start_pt = nc.evaluate(u0);
3920            let end_pt = nc.evaluate(u1);
3921            // 1e-6 m: closure detection threshold — if start and end points are
3922            // within 1 micron, treat the NURBS curve as closed to avoid
3923            // duplicating the first point at t=u_max.
3924            let is_closed = (start_pt - end_pt).length() < 1e-6;
3925            let divisor = if is_closed { n } else { n - 1 };
3926            (0..n)
3927                .map(|i| {
3928                    #[allow(clippy::cast_precision_loss)]
3929                    let t = u0 + (u1 - u0) * (i as f64) / (divisor as f64);
3930                    nc.evaluate(t)
3931                })
3932                .collect()
3933        }
3934        EdgeCurve::Line => vec![],
3935    }
3936}
3937
3938/// Get a polygon approximation of a face by sampling curved edges.
3939///
3940/// Samples circle/ellipse edges into 32 points so faces with a
3941/// single closed-curve edge (e.g. cylinder caps) get a proper polygon.
3942///
3943/// # Errors
3944///
3945/// Returns an error if the face or its wire cannot be resolved.
3946pub fn face_polygon(
3947    topo: &Topology,
3948    face_id: FaceId,
3949) -> Result<Vec<Point3>, crate::OperationsError> {
3950    let face = topo.face(face_id)?;
3951    let wire = topo.wire(face.outer_wire())?;
3952    let mut pts = Vec::new();
3953
3954    for oe in wire.edges() {
3955        let edge = topo.edge(oe.edge())?;
3956        let curve = edge.curve();
3957        // Sample closed parametric edges (start == end vertex).
3958        // Partial arcs fall through to the vertex-based path.
3959        let start_vid = edge.start();
3960        let end_vid = edge.end();
3961        let is_closed_edge = start_vid == end_vid
3962            && matches!(
3963                curve,
3964                EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) | EdgeCurve::NurbsCurve(_)
3965            );
3966        if is_closed_edge {
3967            // Must use CLOSED_CURVE_SAMPLES (not a larger value) — vertex count
3968            // must match create_band_fragments and inner-wire dedup for sharing.
3969            let mut sampled = sample_edge_curve(curve, types::CLOSED_CURVE_SAMPLES);
3970            if !oe.is_forward() {
3971                sampled.reverse();
3972            }
3973            pts.extend(sampled);
3974        } else {
3975            let vid = oe.oriented_start(edge);
3976            pts.push(topo.vertex(vid)?.point());
3977        }
3978    }
3979
3980    Ok(pts)
3981}
3982
3983/// Collect face signatures (index, normal, centroid) for evolution tracking.
3984///
3985/// For each face of the solid, computes a representative normal and centroid
3986/// from the face polygon. Used by [`boolean_with_evolution`] to match output
3987/// faces back to input faces.
3988///
3989/// # Errors
3990///
3991/// Returns an error if any face or wire cannot be resolved.
3992/// Snapshot each outer-shell face as `(index, face normal, centroid)` — the
3993/// signature [`crate::evolution::build_evolution_by_geometry`] matches on. The
3994/// normal is the stored plane normal (or a polygon-derived normal for
3995/// non-planar faces), not re-oriented by the face's `reversed` flag; matching
3996/// stays consistent because input and output faces use the same convention.
3997pub fn collect_face_signatures(
3998    topo: &Topology,
3999    solid_id: SolidId,
4000) -> Result<Vec<(usize, Vec3, Point3)>, crate::OperationsError> {
4001    let solid = topo.solid(solid_id)?;
4002    let shell = topo.shell(solid.outer_shell())?;
4003    let mut result = Vec::with_capacity(shell.faces().len());
4004
4005    for &fid in shell.faces() {
4006        let face = topo.face(fid)?;
4007        let verts = face_polygon(topo, fid)?;
4008        let normal = if let FaceSurface::Plane { normal, .. } = face.surface() {
4009            *normal
4010        } else if verts.len() >= 3 {
4011            let e1 = verts[1] - verts[0];
4012            let e2 = verts[2] - verts[0];
4013            e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0))
4014        } else {
4015            Vec3::new(0.0, 0.0, 1.0)
4016        };
4017
4018        let centroid = classify::polygon_centroid(&verts);
4019        result.push((fid.index(), normal, centroid));
4020    }
4021
4022    Ok(result)
4023}
4024
4025#[cfg(test)]
4026mod tests;