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(boxes) = tool_bounding_boxes(topo, tools)
944        && let clusters = cluster_tools_by_aabb(&boxes, tools)
945        && !clusters.is_empty()
946    {
947        // Cheapest rung first: when no tool pair shares volume (disjoint or
948        // merely TOUCHING — a baseplate's pocket grid meets rim-to-rim), the
949        // union's boundary is just the concatenation of the tool shells, so
950        // combine them verbatim and cut once. This is the same
951        // position-duplicate-edge tool the fuse ladder below builds one GFA
952        // run per tool (a 6x4 plate's 24-pocket stage spent ~24 wasted
953        // arrangements on it), minus all the fuses. Interpenetrating pairs
954        // (the coaxial magnet+screw drill) disqualify the whole set: a
955        // multi-component tool with overlapping components breaks parity
956        // classification, so those take the fuse ladder.
957        if tools_at_most_touch(&boxes) {
958            let shortcut = crate::compound_ops::merge_disjoint_solids(topo, tools)
959                .and_then(|tool| boolean_inner(topo, BooleanOp::Cut, target, tool));
960            match shortcut {
961                Ok(cut) if !LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) => {
962                    result = cut;
963                    batched = true;
964                }
965                _ => log::debug!("compound_cut: contact-thin shortcut declined"),
966            }
967        }
968        // Tools that merely TOUCH (a plate's edge-tangent preview pockets)
969        // cluster together by AABB, and their union is genuinely non-manifold,
970        // so every pairwise fuse below "succeeds" through the mesh fallback —
971        // the batch then proceeds with a degraded all-planar tool and the cut
972        // grinds against it (11 s and lost cones on a 4x4 baseplate, #1488).
973        // A fallback-tainted merge is a FAILED merge for batching purposes:
974        // the sequential per-tool cuts are exact and never see the tangency.
975        // Taint propagates as Err from `fuse_cluster` and from the cross-
976        // cluster merge fuses below, so a discarded merge never touches the
977        // public fallback counter.
978        if !batched {
979            let merged = clusters.iter().try_fold(None::<SolidId>, |acc, cluster| {
980                let fused = fuse_cluster(topo, cluster)?;
981                match acc {
982                    None => Ok(Some(fused)),
983                    Some(prev) => {
984                        let m = boolean_inner(topo, BooleanOp::Fuse, prev, fused)?;
985                        if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
986                            return Err(crate::OperationsError::InvalidInput {
987                                reason: "cluster merge degraded to mesh fallback".to_string(),
988                            });
989                        }
990                        Ok(Some(m))
991                    }
992                }
993            });
994            if let Ok(Some(tool)) = merged
995                && let Ok(cut) = boolean(topo, BooleanOp::Cut, target, tool)
996            {
997                result = cut;
998                batched = true;
999            } else {
1000                log::debug!("compound_cut: batched tool path failed, using sequential cuts");
1001            }
1002        }
1003    }
1004    if !batched {
1005        for &tool in tools {
1006            result = boolean(topo, BooleanOp::Cut, result, tool)?;
1007        }
1008    }
1009    if opts.unify_faces {
1010        let unify_opts = brepkit_heal::upgrade::unify_same_domain::UnifyOptions::default();
1011        if let Err(e) =
1012            brepkit_heal::upgrade::unify_same_domain::unify_same_domain(topo, result, &unify_opts)
1013        {
1014            log::debug!("compound_cut unify_faces failed: {e}");
1015        }
1016    }
1017    Ok(result)
1018}
1019
1020/// Fuse one AABB-overlap cluster into a single solid.
1021///
1022/// For a cluster of 3+ interpenetrating/touching tools, tries the single-pass
1023/// N-way GFA fuse (`brepkit_algo::gfa::fuse_n`) — one arrangement over all tools
1024/// instead of the sequential pairwise fuse's O(n²) re-processing of a growing
1025/// accumulator. Falls back to the sequential fuse when the N-way path errors
1026/// (e.g. a non-planar coincident contact it does not yet handle) or yields an
1027/// invalid result. Clusters of 1–2 tools go straight to the sequential path,
1028/// where the N-way arrangement has nothing to save. The cluster must be
1029/// non-empty.
1030pub(crate) fn fuse_cluster(
1031    topo: &mut Topology,
1032    cluster: &[SolidId],
1033) -> Result<SolidId, crate::OperationsError> {
1034    let Some((&first, rest)) = cluster.split_first() else {
1035        return Err(crate::OperationsError::InvalidInput {
1036            reason: "fuse_cluster requires a non-empty cluster".into(),
1037        });
1038    };
1039    if cluster.len() >= 3
1040        && let Ok(fused) = brepkit_algo::gfa::fuse_n(topo, cluster)
1041        && validate_boolean_result(topo, fused).is_ok()
1042    {
1043        return Ok(fused);
1044    }
1045    // A pairwise fuse that degrades to the mesh fallback poisons the whole
1046    // batch; bail at the first one instead of paying the fallback for every
1047    // remaining pair. `boolean_inner` + the taint flag keeps the discarded
1048    // probe out of the public fallback counter entirely.
1049    rest.iter().try_fold(first, |a, &t| {
1050        let fused = boolean_inner(topo, BooleanOp::Fuse, a, t)?;
1051        if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
1052            return Err(crate::OperationsError::InvalidInput {
1053                reason: "cluster fuse degraded to mesh fallback".to_string(),
1054            });
1055        }
1056        Ok(fused)
1057    })
1058}
1059
1060/// True when no pair of tools shares volume beyond a weld-scale sliver:
1061/// every pairwise AABB intersection is at most `100·tol` thick in some
1062/// axis, so tools can touch face-to-face or rim-to-rim but never nest or
1063/// interpenetrate. The discriminant for the contact-thin compound-cut
1064/// shortcut — a coaxial magnet+screw drill pair (screw box nested inside
1065/// the magnet box) fails it, a pitch-aligned pocket grid passes.
1066fn tools_at_most_touch(boxes: &[brepkit_math::aabb::Aabb3]) -> bool {
1067    let band = brepkit_math::tolerance::Tolerance::new().linear * 100.0;
1068    for i in 0..boxes.len() {
1069        for j in (i + 1)..boxes.len() {
1070            let ix =
1071                boxes[i].max.x().min(boxes[j].max.x()) - boxes[i].min.x().max(boxes[j].min.x());
1072            let iy =
1073                boxes[i].max.y().min(boxes[j].max.y()) - boxes[i].min.y().max(boxes[j].min.y());
1074            let iz =
1075                boxes[i].max.z().min(boxes[j].max.z()) - boxes[i].min.z().max(boxes[j].min.z());
1076            if ix > band && iy > band && iz > band {
1077                return false;
1078            }
1079        }
1080    }
1081    true
1082}
1083
1084/// Per-tool AABBs, computed once and shared by the clustering pass and the
1085/// contact-thin guard. `None` when any AABB is unavailable.
1086fn tool_bounding_boxes(
1087    topo: &Topology,
1088    tools: &[SolidId],
1089) -> Option<Vec<brepkit_math::aabb::Aabb3>> {
1090    tools
1091        .iter()
1092        .map(|&t| crate::measure::solid_bounding_box(topo, t).ok())
1093        .collect()
1094}
1095
1096/// Group tools into AABB-overlap clusters (union-find over tolerance-
1097/// expanded boxes). Tools within a cluster may interpenetrate; distinct
1098/// clusters are pairwise disjoint.
1099fn cluster_tools_by_aabb(
1100    boxes: &[brepkit_math::aabb::Aabb3],
1101    tools: &[SolidId],
1102) -> Vec<Vec<SolidId>> {
1103    fn find(parent: &mut Vec<usize>, i: usize) -> usize {
1104        if parent[i] != i {
1105            let root = find(parent, parent[i]);
1106            parent[i] = root;
1107        }
1108        parent[i]
1109    }
1110    let tol = brepkit_math::tolerance::Tolerance::new().linear;
1111    let mut parent: Vec<usize> = (0..tools.len()).collect();
1112    for i in 0..boxes.len() {
1113        for j in (i + 1)..boxes.len() {
1114            if boxes[i].expanded(tol).intersects(boxes[j]) {
1115                let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
1116                if ri != rj {
1117                    parent[ri] = rj;
1118                }
1119            }
1120        }
1121    }
1122    let mut clusters: std::collections::BTreeMap<usize, Vec<SolidId>> =
1123        std::collections::BTreeMap::new();
1124    for i in 0..tools.len() {
1125        let root = find(&mut parent, i);
1126        clusters.entry(root).or_default().push(tools[i]);
1127    }
1128    clusters.into_values().collect()
1129}
1130
1131/// Perform a boolean operation and return an [`crate::evolution::EvolutionMap`]
1132/// tracking face provenance.
1133///
1134/// Prefers **faithful** provenance from the GFA builder — each result face
1135/// records the input face it was split/derived from
1136/// (`brepkit_algo::gfa::boolean_with_face_origins`). Because that path runs the
1137/// GFA directly, it can take a different route than [`boolean`] (which
1138/// short-circuits some cases via AABB/containment fast paths), so its result is
1139/// validated; on a GFA error or an invalid result — and for identical or
1140/// fully-contained operand pairs (`detect_trivial_relation`) — it falls back to
1141/// [`boolean`] with the geometry heuristic (normal + centroid). Either way,
1142/// unmatched input faces are classified as "deleted"; synthesised result faces
1143/// with no input origin are left unattributed.
1144///
1145/// # Errors
1146///
1147/// Returns the same errors as [`boolean`].
1148pub fn boolean_with_evolution(
1149    topo: &mut Topology,
1150    op: BooleanOp,
1151    a: SolidId,
1152    b: SolidId,
1153) -> Result<(SolidId, crate::evolution::EvolutionMap), crate::OperationsError> {
1154    use brepkit_topology::explorer::solid_faces;
1155
1156    // Faithful path: the GFA reports each result face's true input source.
1157    // Identical/contained operand pairs must NOT take it: those are the
1158    // fully-coincident-boundary configurations `boolean` short-circuits
1159    // precisely because the raw GFA mis-splits them — coincident walls drop
1160    // into an open shell whose position-duplicate free edges pass the
1161    // by-edge-id validation gate (every edge id used ≤ 2×), so the broken
1162    // result would be returned as "valid". Route them through `boolean`'s
1163    // shortcuts below; the geometry heuristic attributes a copied result's
1164    // faces exactly (normal + centroid match 1:1). Detection only runs for
1165    // a != b (a == b skips the faithful path regardless) and its cost is
1166    // O(faces + vertices) per call; `boolean` re-runs it on the fallback,
1167    // which is accepted — deduplicating would mean threading the relation
1168    // through `boolean`'s public signature.
1169    let trivial = a != b && {
1170        use brepkit_algo::classifier::try_build_analytic_classifier;
1171        let tol = brepkit_math::tolerance::Tolerance::new();
1172        let ca = try_build_analytic_classifier(topo, a);
1173        let cb = try_build_analytic_classifier(topo, b);
1174        let rel = detect_trivial_relation(topo, a, b, ca.as_ref(), cb.as_ref(), tol);
1175        rel.identical || rel.a_in_b || rel.b_in_a
1176    };
1177    if a != b && !trivial {
1178        let input_indices: Vec<usize> = solid_faces(topo, a)?
1179            .into_iter()
1180            .chain(solid_faces(topo, b)?)
1181            .map(brepkit_topology::arena::Id::index)
1182            .collect();
1183        let algo_op = match op {
1184            BooleanOp::Fuse => brepkit_algo::bop::BooleanOp::Fuse,
1185            BooleanOp::Cut => brepkit_algo::bop::BooleanOp::Cut,
1186            BooleanOp::Intersect => brepkit_algo::bop::BooleanOp::Intersect,
1187        };
1188        if let Ok((result, origins)) =
1189            brepkit_algo::gfa::boolean_with_face_origins(topo, algo_op, a, b)
1190        {
1191            // Apply the face-id-preserving result heals so the evolution result
1192            // is as correct as the standard boolean (manifold, no #801 wire
1193            // spurs). These rewrite wires in place, so the provenance — keyed by
1194            // face ID — survives. `unify_faces` is intentionally NOT run here:
1195            // it merges coplanar faces into new entities, discarding the
1196            // per-face provenance this path exists to track.
1197            // A heal failure here is not fatal: fall through to boolean()'s
1198            // full pipeline rather than propagating (the result solid stays as
1199            // orphaned topology, which is harmless in the arena).
1200            let tol = brepkit_math::tolerance::Tolerance::default();
1201            let healed_ok = crate::heal::remove_degenerate_edges(topo, result, tol.linear).is_ok()
1202                && crate::heal::remove_wire_spurs(topo, result).is_ok();
1203
1204            // Trust the faithful path only if its result is valid; otherwise
1205            // fall through to boolean()'s full pipeline (fast paths + mesh
1206            // fallback + validation), matching boolean()'s contract.
1207            if healed_ok && validate_boolean_result(topo, result).is_ok() {
1208                let mut evo = crate::evolution::EvolutionMap::new();
1209                let mut sourced: std::collections::HashSet<usize> =
1210                    std::collections::HashSet::new();
1211                for (out_idx, src) in origins {
1212                    if let Some(in_idx) = src {
1213                        evo.add_modified(in_idx, out_idx);
1214                        sourced.insert(in_idx);
1215                    }
1216                }
1217                for in_idx in input_indices {
1218                    if !sourced.contains(&in_idx) {
1219                        evo.add_deleted(in_idx);
1220                    }
1221                }
1222                return Ok((result, evo));
1223            }
1224        }
1225    }
1226
1227    // Fallback: geometry heuristic over the standard boolean result. Reached
1228    // for identical operands, a GFA error, or a GFA result that failed
1229    // validation — the EvolutionMap is then approximate, not faithful.
1230    log::debug!("boolean_with_evolution: faithful GFA provenance unavailable, using heuristic");
1231    let input_faces_a = collect_face_signatures(topo, a)?;
1232    let input_faces_b = collect_face_signatures(topo, b)?;
1233
1234    let mut input_faces: Vec<(usize, Vec3, Point3)> =
1235        Vec::with_capacity(input_faces_a.len() + input_faces_b.len());
1236    input_faces.extend(input_faces_a);
1237    input_faces.extend(input_faces_b);
1238
1239    let result = boolean(topo, op, a, b)?;
1240
1241    let output_faces = collect_face_signatures(topo, result)?;
1242
1243    let evo = crate::evolution::build_evolution_by_geometry(&input_faces, &output_faces);
1244
1245    Ok((result, evo))
1246}
1247
1248/// Compute the boolean of two axis-aligned boxes via AABB algebra.
1249///
1250/// Returns `Ok(None)` when the result isn't a single box:
1251/// - Fuse: requires two of three dims to match exactly AND the boxes to
1252///   overlap or touch in the third dim. Otherwise the union is L-shaped.
1253/// - Intersect: any non-empty AABB intersection is a box.
1254/// - Cut: skipped — the general case is L-shaped, defer to GFA.
1255fn box_pair_shortcut(
1256    topo: &mut Topology,
1257    op: BooleanOp,
1258    a_min: Point3,
1259    a_max: Point3,
1260    b_min: Point3,
1261    b_max: Point3,
1262    tol: brepkit_math::tolerance::Tolerance,
1263) -> Result<Option<SolidId>, crate::OperationsError> {
1264    let eps = tol.linear;
1265    let (min, max) = match op {
1266        BooleanOp::Intersect => {
1267            let lo = Point3::new(
1268                a_min.x().max(b_min.x()),
1269                a_min.y().max(b_min.y()),
1270                a_min.z().max(b_min.z()),
1271            );
1272            let hi = Point3::new(
1273                a_max.x().min(b_max.x()),
1274                a_max.y().min(b_max.y()),
1275                a_max.z().min(b_max.z()),
1276            );
1277            // Empty intersection — let general path return an error.
1278            if hi.x() <= lo.x() + eps || hi.y() <= lo.y() + eps || hi.z() <= lo.z() + eps {
1279                return Ok(None);
1280            }
1281            (lo, hi)
1282        }
1283        BooleanOp::Fuse => {
1284            // The union of two axis-aligned boxes is itself a box only
1285            // when two of three dimensions match exactly AND the boxes
1286            // overlap or touch in the third dim.
1287            let x_match =
1288                (a_min.x() - b_min.x()).abs() < eps && (a_max.x() - b_max.x()).abs() < eps;
1289            let y_match =
1290                (a_min.y() - b_min.y()).abs() < eps && (a_max.y() - b_max.y()).abs() < eps;
1291            let z_match =
1292                (a_min.z() - b_min.z()).abs() < eps && (a_max.z() - b_max.z()).abs() < eps;
1293            let matched = u8::from(x_match) + u8::from(y_match) + u8::from(z_match);
1294            if matched < 2 {
1295                return Ok(None);
1296            }
1297            // Verify overlap/touch in all three dims (the unmatched dim
1298            // must overlap; matched dims trivially do).
1299            if a_max.x() < b_min.x() - eps
1300                || b_max.x() < a_min.x() - eps
1301                || a_max.y() < b_min.y() - eps
1302                || b_max.y() < a_min.y() - eps
1303                || a_max.z() < b_min.z() - eps
1304                || b_max.z() < a_min.z() - eps
1305            {
1306                return Ok(None);
1307            }
1308            (
1309                Point3::new(
1310                    a_min.x().min(b_min.x()),
1311                    a_min.y().min(b_min.y()),
1312                    a_min.z().min(b_min.z()),
1313                ),
1314                Point3::new(
1315                    a_max.x().max(b_max.x()),
1316                    a_max.y().max(b_max.y()),
1317                    a_max.z().max(b_max.z()),
1318                ),
1319            )
1320        }
1321        BooleanOp::Cut => {
1322            // Cut shortcut: when B spans A in 2 of 3 dims (≥ A's extent
1323            // on both sides) and overlaps in the third, the result is
1324            // up-to-2 axis-aligned boxes (the leftover slabs on either
1325            // side of B in the cutting dim). This avoids routing through
1326            // GFA's same-domain handling which currently mishandles the
1327            // 4-coincident-face case (target's lateral walls + tool's
1328            // matching walls).
1329            return box_pair_cut_shortcut(topo, a_min, a_max, b_min, b_max, eps);
1330        }
1331    };
1332    let dx = max.x() - min.x();
1333    let dy = max.y() - min.y();
1334    let dz = max.z() - min.z();
1335    if dx <= eps || dy <= eps || dz <= eps {
1336        return Ok(None);
1337    }
1338    let bx = crate::primitives::make_box(topo, dx, dy, dz)?;
1339    if min.x().abs() > eps || min.y().abs() > eps || min.z().abs() > eps {
1340        let xform = brepkit_math::mat::Mat4::translation(min.x(), min.y(), min.z());
1341        crate::transform::transform_solid(topo, bx, &xform)?;
1342    }
1343    Ok(Some(bx))
1344}
1345
1346/// Cut shortcut for two axis-aligned boxes: returns the leftover
1347/// portion(s) when B slices through A in one dimension while spanning
1348/// A in the other two dimensions. The result is 0, 1, or 2 axis-aligned
1349/// boxes packaged into a single multi-region Solid.
1350///
1351/// Returns `Ok(None)` when the shortcut doesn't fit — e.g., B doesn't
1352/// span A in any 2 dims, B touches only a corner, etc. The general path
1353/// (GFA) handles those cases.
1354fn box_pair_cut_shortcut(
1355    topo: &mut Topology,
1356    a_min: Point3,
1357    a_max: Point3,
1358    b_min: Point3,
1359    b_max: Point3,
1360    eps: f64,
1361) -> Result<Option<SolidId>, crate::OperationsError> {
1362    // B must span A in 2 of 3 dims (B_min ≤ A_min - eps AND B_max ≥ A_max + eps,
1363    // i.e., B's extent covers A's extent in that dim).
1364    let x_spans = b_min.x() <= a_min.x() + eps && b_max.x() >= a_max.x() - eps;
1365    let y_spans = b_min.y() <= a_min.y() + eps && b_max.y() >= a_max.y() - eps;
1366    let z_spans = b_min.z() <= a_min.z() + eps && b_max.z() >= a_max.z() - eps;
1367    let spans_count = u8::from(x_spans) + u8::from(y_spans) + u8::from(z_spans);
1368    if spans_count != 2 {
1369        return Ok(None);
1370    }
1371    // In the non-spanning dim, B must actually intersect A.
1372    let (a_lo, a_hi, b_lo, b_hi) = if !x_spans {
1373        (a_min.x(), a_max.x(), b_min.x(), b_max.x())
1374    } else if !y_spans {
1375        (a_min.y(), a_max.y(), b_min.y(), b_max.y())
1376    } else {
1377        (a_min.z(), a_max.z(), b_min.z(), b_max.z())
1378    };
1379    if b_hi <= a_lo + eps || b_lo >= a_hi - eps {
1380        return Ok(None);
1381    }
1382
1383    // Build the leftover slabs. There are 0, 1, or 2 pieces depending on
1384    // whether B extends past A on each side in the cutting dim.
1385    let cuts: Vec<(f64, f64)> = {
1386        let mut pieces = Vec::with_capacity(2);
1387        if b_lo > a_lo + eps {
1388            pieces.push((a_lo, b_lo)); // slab before B
1389        }
1390        if b_hi < a_hi - eps {
1391            pieces.push((b_hi, a_hi)); // slab after B
1392        }
1393        pieces
1394    };
1395    if cuts.is_empty() {
1396        // B fully covers A in the cutting dim → cut leaves nothing.
1397        // Let the general path handle this (it errors).
1398        return Ok(None);
1399    }
1400
1401    let piece_solids: Vec<SolidId> = cuts
1402        .iter()
1403        .map(|&(lo, hi)| -> Result<SolidId, crate::OperationsError> {
1404            let (dx, dy, dz, tx, ty, tz) = if !x_spans {
1405                (
1406                    hi - lo,
1407                    a_max.y() - a_min.y(),
1408                    a_max.z() - a_min.z(),
1409                    lo,
1410                    a_min.y(),
1411                    a_min.z(),
1412                )
1413            } else if !y_spans {
1414                (
1415                    a_max.x() - a_min.x(),
1416                    hi - lo,
1417                    a_max.z() - a_min.z(),
1418                    a_min.x(),
1419                    lo,
1420                    a_min.z(),
1421                )
1422            } else {
1423                (
1424                    a_max.x() - a_min.x(),
1425                    a_max.y() - a_min.y(),
1426                    hi - lo,
1427                    a_min.x(),
1428                    a_min.y(),
1429                    lo,
1430                )
1431            };
1432            let bx = crate::primitives::make_box(topo, dx, dy, dz)?;
1433            if tx.abs() > eps || ty.abs() > eps || tz.abs() > eps {
1434                let xform = brepkit_math::mat::Mat4::translation(tx, ty, tz);
1435                crate::transform::transform_solid(topo, bx, &xform)?;
1436            }
1437            Ok(bx)
1438        })
1439        .collect::<Result<_, _>>()?;
1440
1441    if piece_solids.len() == 1 {
1442        return Ok(Some(piece_solids[0]));
1443    }
1444
1445    // Combine pieces into a single multi-region solid.
1446    let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
1447    for &p in &piece_solids {
1448        let p_data = topo.solid(p)?;
1449        for &fid in topo.shell(p_data.outer_shell())?.faces() {
1450            all_faces.push(fid);
1451        }
1452    }
1453    Ok(Some(make_solid_from_face_subset(topo, &all_faces)?))
1454}
1455
1456/// Compute the coaxial-cylinder boolean for two cylinders sharing axis,
1457/// origin, and radius. Returns `Ok(None)` when the shortcut doesn't apply
1458/// (disjoint along axis for fuse/intersect; cut requires general handling).
1459#[allow(clippy::too_many_arguments)]
1460fn coaxial_cylinder_shortcut(
1461    topo: &mut Topology,
1462    op: BooleanOp,
1463    origin: Point3,
1464    axis: Vec3,
1465    radius: f64,
1466    a_range: (f64, f64),
1467    b_range: (f64, f64),
1468    tol: brepkit_math::tolerance::Tolerance,
1469) -> Result<Option<SolidId>, crate::OperationsError> {
1470    let (za_min, za_max) = a_range;
1471    let (zb_min, zb_max) = b_range;
1472    // For fuse: ranges must touch or overlap. Disjoint cylinders would
1473    // produce a compound, which the boolean API doesn't return.
1474    let touches_or_overlaps = zb_min <= za_max + tol.linear && za_min <= zb_max + tol.linear;
1475    let (z_min, z_max) = match op {
1476        BooleanOp::Fuse => {
1477            if !touches_or_overlaps {
1478                return Ok(None);
1479            }
1480            (za_min.min(zb_min), za_max.max(zb_max))
1481        }
1482        BooleanOp::Intersect => {
1483            // Strict overlap (not just touching) for non-degenerate result.
1484            let lo = za_min.max(zb_min);
1485            let hi = za_max.min(zb_max);
1486            if hi <= lo + tol.linear {
1487                return Ok(None);
1488            }
1489            (lo, hi)
1490        }
1491        BooleanOp::Cut => return Ok(None), // Defer to GFA / general path.
1492    };
1493    let height = z_max - z_min;
1494    if height <= tol.linear {
1495        return Ok(None);
1496    }
1497    // Build a fresh cylinder at axis-origin + axis*z_min, oriented along
1498    // axis. make_cylinder produces the canonical (0,0,0)→(0,0,h) cylinder;
1499    // then transform to the world axis frame.
1500    let cyl = crate::primitives::make_cylinder(topo, radius, height)?;
1501    let world_origin = Point3::new(
1502        origin.x() + axis.x() * z_min,
1503        origin.y() + axis.y() * z_min,
1504        origin.z() + axis.z() * z_min,
1505    );
1506    let xform = xform_from_canonical_z(world_origin, axis, tol);
1507    crate::transform::transform_solid(topo, cyl, &xform)?;
1508    Ok(Some(cyl))
1509}
1510
1511/// Compute the coaxial-cone boolean for two frustums on the same conical
1512/// surface (shared apex, axis, and half-angle). Returns `Ok(None)` when
1513/// the shortcut doesn't apply.
1514#[allow(clippy::too_many_arguments)]
1515fn coaxial_cone_shortcut(
1516    topo: &mut Topology,
1517    op: BooleanOp,
1518    apex: Point3,
1519    axis: Vec3,
1520    slope: f64,
1521    a_range: (f64, f64),
1522    b_range: (f64, f64),
1523    tol: brepkit_math::tolerance::Tolerance,
1524) -> Result<Option<SolidId>, crate::OperationsError> {
1525    let (za_min, za_max) = a_range;
1526    let (zb_min, zb_max) = b_range;
1527    let touches_or_overlaps = zb_min <= za_max + tol.linear && za_min <= zb_max + tol.linear;
1528    let (z_min, z_max) = match op {
1529        BooleanOp::Fuse => {
1530            if !touches_or_overlaps {
1531                return Ok(None);
1532            }
1533            (za_min.min(zb_min), za_max.max(zb_max))
1534        }
1535        BooleanOp::Intersect => {
1536            let lo = za_min.max(zb_min);
1537            let hi = za_max.min(zb_max);
1538            if hi <= lo + tol.linear {
1539                return Ok(None);
1540            }
1541            (lo, hi)
1542        }
1543        BooleanOp::Cut => return Ok(None),
1544    };
1545    let height = z_max - z_min;
1546    if height <= tol.linear {
1547        return Ok(None);
1548    }
1549    // r at axial position z (apex-relative) = slope * z. For frustums on
1550    // the +axis nappe, both z values are positive; if either becomes ≤ 0
1551    // (apex inclusion), bail out so we don't construct a degenerate cone.
1552    let r_at_z_min = slope * z_min;
1553    let r_at_z_max = slope * z_max;
1554    if r_at_z_min < -tol.linear || r_at_z_max < -tol.linear {
1555        return Ok(None);
1556    }
1557    let r_bot = r_at_z_min.abs();
1558    let r_top = r_at_z_max.abs();
1559    if r_bot <= tol.linear && r_top <= tol.linear {
1560        return Ok(None);
1561    }
1562    let cone = crate::primitives::make_cone(topo, r_bot, r_top, height)?;
1563    let world_origin = Point3::new(
1564        apex.x() + axis.x() * z_min,
1565        apex.y() + axis.y() * z_min,
1566        apex.z() + axis.z() * z_min,
1567    );
1568    // Cone shortcut keeps to axis-aligned cases for now (test corpus does
1569    // not yet cover off-axis cones). Detect parallel/antiparallel via the
1570    // dot product (the canonical-axis Z-component is the only term that
1571    // survives `canonical · axis` since canonical = ẑ).
1572    let dot = axis.z().clamp(-1.0, 1.0);
1573    if 1.0 - dot.abs() > tol.angular {
1574        return Ok(None);
1575    }
1576    let xform = xform_from_canonical_z(world_origin, axis, tol);
1577    crate::transform::transform_solid(topo, cone, &xform)?;
1578    Ok(Some(cone))
1579}
1580
1581/// Compute the concentric-sphere boolean for two spheres sharing a
1582/// Box-sphere `Intersect` shortcut. Handles two configurations exactly,
1583/// returning `Ok(None)` to fall through to GFA otherwise:
1584///
1585/// 1. **Sphere fully inside box** — every box face plane has the sphere
1586///    on the box-interior side with margin ≥ `R` (`s ≤ -R + eps`). The
1587///    result is a fresh sphere primitive at `sphere_center` with radius
1588///    `sphere_radius`.
1589/// 2. **Spherical "octant"** — exactly 3 of the 6 box face planes cut
1590///    the sphere (`|s| < R - eps`) and the other 3 leave the sphere on
1591///    the box-interior side. The 3 cutting planes are mutually orthogonal
1592///    (axis-aligned box invariant) and meet at a single box corner `O`.
1593///    The result is the sphere region in the box-interior octant of `O`,
1594///    bounded by 3 quarter-disc box sub-faces and 1 spherical patch.
1595///
1596/// `s` is the signed distance from `sphere_center` to a face plane along
1597/// the face's outward normal (positive = sphere on box-exterior side).
1598/// If any face has `s ≥ R - eps` the result is empty (sphere doesn't
1599/// reach into the box from that side) — we return `None` rather than an
1600/// empty solid so the caller can produce the canonical `EmptyResult`
1601/// error via the regular path.
1602#[allow(clippy::too_many_arguments)]
1603fn box_sphere_intersect_shortcut(
1604    topo: &mut Topology,
1605    box_min: Point3,
1606    box_max: Point3,
1607    sphere_center: Point3,
1608    sphere_radius: f64,
1609    sphere_segments: usize,
1610    tol: brepkit_math::tolerance::Tolerance,
1611) -> Result<Option<SolidId>, crate::OperationsError> {
1612    let r = sphere_radius;
1613    let eps = tol.linear;
1614    if r <= eps {
1615        return Ok(None);
1616    }
1617    // Sanity: degenerate or inverted box.
1618    if box_max.x() <= box_min.x() + eps
1619        || box_max.y() <= box_min.y() + eps
1620        || box_max.z() <= box_min.z() + eps
1621    {
1622        return Ok(None);
1623    }
1624
1625    // For each of 6 box face planes, compute `s` (signed distance from
1626    // sphere center along outward normal). Classify each plane.
1627    let faces: [(Vec3, f64); 6] = [
1628        (Vec3::new(-1.0, 0.0, 0.0), -box_min.x()),
1629        (Vec3::new(1.0, 0.0, 0.0), box_max.x()),
1630        (Vec3::new(0.0, -1.0, 0.0), -box_min.y()),
1631        (Vec3::new(0.0, 1.0, 0.0), box_max.y()),
1632        (Vec3::new(0.0, 0.0, -1.0), -box_min.z()),
1633        (Vec3::new(0.0, 0.0, 1.0), box_max.z()),
1634    ];
1635    let signed_dist = |n: Vec3, d: f64| -> f64 {
1636        n.x() * sphere_center.x() + n.y() * sphere_center.y() + n.z() * sphere_center.z() - d
1637    };
1638
1639    let mut cuts: Vec<usize> = Vec::new();
1640    for (i, &(n, d)) in faces.iter().enumerate() {
1641        let s = signed_dist(n, d);
1642        if s >= r - eps {
1643            // Sphere is fully on the exterior side of this plane → box ∩
1644            // sphere = empty. Defer to GFA which will surface an
1645            // EmptyResult error in its usual form.
1646            return Ok(None);
1647        }
1648        if s.abs() < r - eps {
1649            cuts.push(i);
1650        }
1651        // else: s ≤ -r + eps → sphere fully inside this plane, face
1652        // doesn't bound the result; nothing to do.
1653    }
1654
1655    // Case 1: sphere fully inside box (no cutting planes).
1656    if cuts.is_empty() {
1657        let sphere = crate::primitives::make_sphere(topo, r, sphere_segments)?;
1658        if sphere_center.x().abs() > eps
1659            || sphere_center.y().abs() > eps
1660            || sphere_center.z().abs() > eps
1661        {
1662            let xform = brepkit_math::mat::Mat4::translation(
1663                sphere_center.x(),
1664                sphere_center.y(),
1665                sphere_center.z(),
1666            );
1667            crate::transform::transform_solid(topo, sphere, &xform)?;
1668        }
1669        return Ok(Some(sphere));
1670    }
1671
1672    // Case 2: 3 cutting planes meeting at a box corner → spherical
1673    // octant. The 3 cut planes' outward normals are mutually orthogonal
1674    // (axis-aligned box invariant) so the in-box direction perpendicular
1675    // to each is the negated outward normal.
1676    if cuts.len() == 3 {
1677        return build_box_sphere_octant(topo, &faces, &cuts, sphere_center, r, tol);
1678    }
1679
1680    // 1, 2, 4, 5, 6 cutting planes — more complex geometries (caps,
1681    // lenses, etc.). Out of scope for this shortcut; fall through.
1682    Ok(None)
1683}
1684
1685/// Construct the result of `box ∩ sphere` when exactly 3 box face planes
1686/// cut the sphere and meet at a single corner `O`. The result topology
1687/// is 4 faces (3 quarter-discs + 1 spherical patch), 6 edges, 4 vertices.
1688fn build_box_sphere_octant(
1689    topo: &mut Topology,
1690    faces: &[(Vec3, f64); 6],
1691    cuts: &[usize],
1692    sphere_center: Point3,
1693    r: f64,
1694    tol: brepkit_math::tolerance::Tolerance,
1695) -> Result<Option<SolidId>, crate::OperationsError> {
1696    use brepkit_math::curves::Circle3D;
1697    use brepkit_math::surfaces::SphericalSurface;
1698    use brepkit_topology::edge::{Edge, EdgeCurve};
1699    use brepkit_topology::face::{Face, FaceSurface};
1700    use brepkit_topology::shell::Shell;
1701    use brepkit_topology::solid::Solid;
1702    use brepkit_topology::vertex::Vertex;
1703    use brepkit_topology::wire::{OrientedEdge, Wire};
1704
1705    // Cutting plane normals + their box-plane-d values.
1706    let cut_planes: Vec<(Vec3, f64)> = cuts.iter().map(|&i| faces[i]).collect();
1707    // The 3 outward normals must be mutually orthogonal (axis-aligned box).
1708    let n0 = cut_planes[0].0;
1709    let n1 = cut_planes[1].0;
1710    let n2 = cut_planes[2].0;
1711    if n0.dot(n1).abs() > tol.angular
1712        || n0.dot(n2).abs() > tol.angular
1713        || n1.dot(n2).abs() > tol.angular
1714    {
1715        // Not orthogonal — defer to GFA.
1716        return Ok(None);
1717    }
1718    // The corner O is at the intersection of the 3 cutting planes:
1719    //   n_i · O = d_i  for all 3 i.
1720    // Since the normals are axis-aligned (±x, ±y, ±z), we can pull each
1721    // coordinate of O directly off the matching plane's d.
1722    let coord_from_axis = |axis: Vec3, d: f64| -> f64 {
1723        if axis.x().abs() > 0.5 {
1724            d * axis.x().signum()
1725        } else if axis.y().abs() > 0.5 {
1726            d * axis.y().signum()
1727        } else {
1728            d * axis.z().signum()
1729        }
1730    };
1731    let mut o = [0.0_f64; 3];
1732    for &(n, d) in &cut_planes {
1733        if n.x().abs() > 0.5 {
1734            o[0] = coord_from_axis(n, d);
1735        } else if n.y().abs() > 0.5 {
1736            o[1] = coord_from_axis(n, d);
1737        } else {
1738            o[2] = coord_from_axis(n, d);
1739        }
1740    }
1741    let o = Point3::new(o[0], o[1], o[2]);
1742
1743    // In-box direction perpendicular to each cutting plane = -n_i.
1744    let in_dirs: Vec<Vec3> = cut_planes
1745        .iter()
1746        .map(|&(n, _)| Vec3::new(-n.x(), -n.y(), -n.z()))
1747        .collect();
1748
1749    // For each cutting plane i, the box edge from O in direction in_dirs[i]
1750    // is the intersection of the other two cutting planes. Find the sphere
1751    // intersection with this edge — the vertex on the sphere along the box
1752    // edge.
1753    //
1754    // Edge parameterised as O + t·d_i for t ≥ 0. Sphere: |P - C|² = R².
1755    //   (O + t·d_i - C) · (O + t·d_i - C) = R²
1756    //   Let v = O - C; expand:
1757    //     t² + 2 t (v · d_i) + |v|² - R² = 0
1758    //   So t = -v·d_i ± sqrt((v·d_i)² - |v|² + R²)
1759    let mut sphere_pts: [Point3; 3] = [Point3::new(0.0, 0.0, 0.0); 3];
1760    for (idx, &dir) in in_dirs.iter().enumerate() {
1761        let vx = o.x() - sphere_center.x();
1762        let vy = o.y() - sphere_center.y();
1763        let vz = o.z() - sphere_center.z();
1764        let v_dot_d = vx * dir.x() + vy * dir.y() + vz * dir.z();
1765        let v_sq = vx * vx + vy * vy + vz * vz;
1766        let disc = v_dot_d * v_dot_d - v_sq + r * r;
1767        if disc < -tol.linear * tol.linear {
1768            return Ok(None);
1769        }
1770        let t = -v_dot_d + disc.max(0.0).sqrt();
1771        if t <= tol.linear {
1772            return Ok(None);
1773        }
1774        sphere_pts[idx] = Point3::new(
1775            o.x() + t * dir.x(),
1776            o.y() + t * dir.y(),
1777            o.z() + t * dir.z(),
1778        );
1779    }
1780
1781    // Topology: 4 vertices, 6 edges, 4 faces.
1782    let v_o = topo.add_vertex(Vertex::new(o, tol.linear));
1783    let v_x = topo.add_vertex(Vertex::new(sphere_pts[0], tol.linear));
1784    let v_y = topo.add_vertex(Vertex::new(sphere_pts[1], tol.linear));
1785    let v_z = topo.add_vertex(Vertex::new(sphere_pts[2], tol.linear));
1786
1787    // 3 line edges from O along the box edges.
1788    let e_ox = topo.add_edge(Edge::new(v_o, v_x, EdgeCurve::Line));
1789    let e_oy = topo.add_edge(Edge::new(v_o, v_y, EdgeCurve::Line));
1790    let e_oz = topo.add_edge(Edge::new(v_o, v_z, EdgeCurve::Line));
1791
1792    // 3 arc edges on the sphere. Each arc lies on one of the cutting planes:
1793    // the arc opposite vertex `i` (i.e., between the other two vertices)
1794    // sits on cutting plane `i` (normal `n_i`), because those two vertices
1795    // lie on edges perpendicular to the remaining two normals — and both
1796    // of those edges lie within the plane perpendicular to `n_i`.
1797    let mut build_arc_edge =
1798        |n: Vec3,
1799         p_start: Point3,
1800         p_end: Point3,
1801         start_vid,
1802         end_vid|
1803         -> Result<brepkit_topology::edge::EdgeId, crate::OperationsError> {
1804            let dist = n.x() * (sphere_center.x() - p_start.x())
1805                + n.y() * (sphere_center.y() - p_start.y())
1806                + n.z() * (sphere_center.z() - p_start.z());
1807            let circle_center = Point3::new(
1808                sphere_center.x() - dist * n.x(),
1809                sphere_center.y() - dist * n.y(),
1810                sphere_center.z() - dist * n.z(),
1811            );
1812            let circle_r = (r * r - dist * dist).max(0.0).sqrt();
1813            if circle_r <= tol.linear {
1814                return Err(crate::OperationsError::InvalidInput {
1815                    reason: "box-sphere octant: degenerate arc radius".into(),
1816                });
1817            }
1818            let dx = p_start.x() - circle_center.x();
1819            let dy = p_start.y() - circle_center.y();
1820            let dz = p_start.z() - circle_center.z();
1821            let len = (dx * dx + dy * dy + dz * dz).sqrt();
1822            if len <= tol.linear {
1823                return Err(crate::OperationsError::InvalidInput {
1824                    reason: "box-sphere octant: degenerate arc reference".into(),
1825                });
1826            }
1827            let u_ref = Vec3::new(dx / len, dy / len, dz / len);
1828            // The circle's CCW direction must take start -> end the SHORT
1829            // way (the quarter arc bounding the octant). About the cutting
1830            // plane's OUTWARD normal that span is the 270-degree
1831            // complement (the wrong-region 1304.8 volume); the INWARD
1832            // normal makes it the intended quarter.
1833            let inward = Vec3::new(-n.x(), -n.y(), -n.z());
1834            let circle =
1835                Circle3D::new_with_ref(circle_center, inward, circle_r, u_ref).map_err(|e| {
1836                    crate::OperationsError::InvalidInput {
1837                        reason: format!("box-sphere octant: circle construction failed: {e}"),
1838                    }
1839                })?;
1840            let _ = p_end; // p_end is used only via end_vid (already pre-placed at the correct sphere point)
1841            Ok(topo.add_edge(Edge::new(start_vid, end_vid, EdgeCurve::Circle(circle))))
1842        };
1843
1844    // Arc on cut plane 0 (between v_y and v_z, i.e., the edge "opposite" v_x).
1845    let arc_yz = build_arc_edge(n0, sphere_pts[1], sphere_pts[2], v_y, v_z)?;
1846    // Arc on cut plane 1 (between v_z and v_x).
1847    let arc_zx = build_arc_edge(n1, sphere_pts[2], sphere_pts[0], v_z, v_x)?;
1848    // Arc on cut plane 2 (between v_x and v_y).
1849    let arc_xy = build_arc_edge(n2, sphere_pts[0], sphere_pts[1], v_x, v_y)?;
1850
1851    // Quarter-disc face on cut plane 0 (perpendicular to n0): bounded by
1852    // box edges O-Y and O-Z + arc Y→Z.
1853    let qd0_wire = Wire::new(
1854        vec![
1855            OrientedEdge::new(e_oy, true),   // O → Y
1856            OrientedEdge::new(arc_yz, true), // Y → Z (arc)
1857            OrientedEdge::new(e_oz, false),  // Z → O (reversed)
1858        ],
1859        true,
1860    )
1861    .map_err(crate::OperationsError::Topology)?;
1862    let qd0_id = topo.add_wire(qd0_wire);
1863    let qd0_face = topo.add_face(Face::new(
1864        qd0_id,
1865        Vec::new(),
1866        FaceSurface::Plane {
1867            normal: n0,
1868            d: cut_planes[0].1,
1869        },
1870    ));
1871
1872    let qd1_wire = Wire::new(
1873        vec![
1874            OrientedEdge::new(e_oz, true),   // O → Z
1875            OrientedEdge::new(arc_zx, true), // Z → X (arc)
1876            OrientedEdge::new(e_ox, false),  // X → O (reversed)
1877        ],
1878        true,
1879    )
1880    .map_err(crate::OperationsError::Topology)?;
1881    let qd1_id = topo.add_wire(qd1_wire);
1882    let qd1_face = topo.add_face(Face::new(
1883        qd1_id,
1884        Vec::new(),
1885        FaceSurface::Plane {
1886            normal: n1,
1887            d: cut_planes[1].1,
1888        },
1889    ));
1890
1891    let qd2_wire = Wire::new(
1892        vec![
1893            OrientedEdge::new(e_ox, true),   // O → X
1894            OrientedEdge::new(arc_xy, true), // X → Y (arc)
1895            OrientedEdge::new(e_oy, false),  // Y → O (reversed)
1896        ],
1897        true,
1898    )
1899    .map_err(crate::OperationsError::Topology)?;
1900    let qd2_id = topo.add_wire(qd2_wire);
1901    let qd2_face = topo.add_face(Face::new(
1902        qd2_id,
1903        Vec::new(),
1904        FaceSurface::Plane {
1905            normal: n2,
1906            d: cut_planes[2].1,
1907        },
1908    ));
1909
1910    // Spherical patch: bounded by the 3 arcs.
1911    // Wind so the sphere's outward normal matches the resulting volume
1912    // (outside the octant). With arcs going X→Y→Z→X around the patch,
1913    // the right-hand rule gives an outward normal pointing AWAY from O.
1914    // Each arc is traversed forward by its quarter-disc, so the patch must
1915    // traverse all three reversed for consistent edge senses: X → Z → Y → X.
1916    let sph_wire = Wire::new(
1917        vec![
1918            OrientedEdge::new(arc_zx, false), // X → Z
1919            OrientedEdge::new(arc_yz, false), // Z → Y
1920            OrientedEdge::new(arc_xy, false), // Y → X
1921        ],
1922        true,
1923    )
1924    .map_err(crate::OperationsError::Topology)?;
1925    let sph_wire_id = topo.add_wire(sph_wire);
1926    let sphere_surface = SphericalSurface::new(sphere_center, r).map_err(|e| {
1927        crate::OperationsError::InvalidInput {
1928            reason: format!("box-sphere octant: sphere surface construction failed: {e}"),
1929        }
1930    })?;
1931    let sphere_face = topo.add_face(Face::new(
1932        sph_wire_id,
1933        Vec::new(),
1934        FaceSurface::Sphere(sphere_surface),
1935    ));
1936
1937    let shell = Shell::new(vec![qd0_face, qd1_face, qd2_face, sphere_face])
1938        .map_err(crate::OperationsError::Topology)?;
1939    let shell_id = topo.add_shell(shell);
1940    let solid = topo.add_solid(Solid::new(shell_id, Vec::new()));
1941    Ok(Some(solid))
1942}
1943
1944/// center. Returns `Ok(None)` when the shortcut doesn't apply (Cut, or
1945/// degenerate radii).
1946///
1947/// Sphere-sphere is simpler than the cylinder/cone analogues because
1948/// there's no axial range — the result radius is just `max(r_a, r_b)`
1949/// for Fuse and `min(r_a, r_b)` for Intersect.
1950///
1951/// The new sphere's tessellation density (segment count) is inherited from
1952/// whichever input has a higher equatorial vertex count, so a
1953/// 64-segment input never silently downgrades to a coarse default. This
1954/// relies on `make_sphere` allocating exactly `segments` equatorial
1955/// vertices and no pole vertices — see `crates/operations/src/primitives.rs`.
1956#[allow(clippy::too_many_arguments)]
1957fn concentric_sphere_shortcut(
1958    topo: &mut Topology,
1959    op: BooleanOp,
1960    a: SolidId,
1961    b: SolidId,
1962    center: Point3,
1963    r_a: f64,
1964    r_b: f64,
1965    tol: brepkit_math::tolerance::Tolerance,
1966) -> Result<Option<SolidId>, crate::OperationsError> {
1967    if r_a <= tol.linear || r_b <= tol.linear {
1968        return Ok(None);
1969    }
1970    let r_result = match op {
1971        BooleanOp::Fuse => r_a.max(r_b),
1972        BooleanOp::Intersect => {
1973            // Both r_a and r_b are guaranteed > tol.linear by the guard above,
1974            // so `min(r_a, r_b)` is always positive here.
1975            r_a.min(r_b)
1976        }
1977        // Cut(A, B) on concentric spheres yields a hollow ball when r_a > r_b;
1978        // empty when r_a ≤ r_b. The hollow-ball case needs an outer + inner
1979        // shell, which `make_sphere` doesn't produce — defer to GFA.
1980        BooleanOp::Cut => return Ok(None),
1981    };
1982
1983    // Inherit segment count from whichever input was tessellated more finely.
1984    // `make_sphere(r, n)` allocates exactly `n` equatorial vertices; because
1985    // sphere primitives are fully describe by (center, radius), all vertices
1986    // belong to that ring. Floor at 4 to satisfy `make_sphere`'s lower bound.
1987    let segments_a = brepkit_topology::explorer::solid_vertices(topo, a)
1988        .map(|v| v.len())
1989        .unwrap_or(0);
1990    let segments_b = brepkit_topology::explorer::solid_vertices(topo, b)
1991        .map(|v| v.len())
1992        .unwrap_or(0);
1993    let segments = segments_a.max(segments_b).max(4);
1994
1995    let sphere = crate::primitives::make_sphere(topo, r_result, segments)?;
1996    if center.x().abs() > tol.linear
1997        || center.y().abs() > tol.linear
1998        || center.z().abs() > tol.linear
1999    {
2000        let xform = brepkit_math::mat::Mat4::translation(center.x(), center.y(), center.z());
2001        crate::transform::transform_solid(topo, sphere, &xform)?;
2002    }
2003    Ok(Some(sphere))
2004}
2005
2006/// Compute the coaxial-torus boolean for two tori sharing center, axis,
2007/// and major radius. Returns `Ok(None)` when the shortcut doesn't apply
2008/// (Cut, or degenerate radii / overlap).
2009///
2010/// Like the concentric-sphere shortcut, the result tessellation density
2011/// is inherited from the higher-quality input so a 64-segment input
2012/// torus never silently downgrades.
2013#[allow(clippy::too_many_arguments)]
2014fn coaxial_torus_shortcut(
2015    topo: &mut Topology,
2016    op: BooleanOp,
2017    a: SolidId,
2018    b: SolidId,
2019    center: Point3,
2020    axis: Vec3,
2021    major_radius: f64,
2022    minor_a: f64,
2023    minor_b: f64,
2024    tol: brepkit_math::tolerance::Tolerance,
2025) -> Result<Option<SolidId>, crate::OperationsError> {
2026    if minor_a <= tol.linear || minor_b <= tol.linear || major_radius <= tol.linear {
2027        return Ok(None);
2028    }
2029    let minor_result = match op {
2030        BooleanOp::Fuse => minor_a.max(minor_b),
2031        BooleanOp::Intersect => {
2032            // Both minors are guaranteed > tol by the guard above.
2033            minor_a.min(minor_b)
2034        }
2035        // Cut on coaxial tori with shared major produces a hollow torus
2036        // (outer + inner small-circle shells) when minor_a > minor_b.
2037        // `make_torus` doesn't build that topology — defer to GFA.
2038        BooleanOp::Cut => return Ok(None),
2039    };
2040    if minor_result >= major_radius {
2041        // make_torus rejects self-intersecting tori (minor >= major).
2042        return Ok(None);
2043    }
2044
2045    // Inherit segment count from the higher-quality input. `make_torus`
2046    // accepts a `segments` param controlling u-direction discretization.
2047    // We'd ideally extract this from each input solid's vertex count, but
2048    // unlike make_sphere torus topology has internal seam vertices that
2049    // make the relationship less clean. Approximate by the larger vertex
2050    // count.
2051    let segments_a = brepkit_topology::explorer::solid_vertices(topo, a)
2052        .map(|v| v.len())
2053        .unwrap_or(0);
2054    let segments_b = brepkit_topology::explorer::solid_vertices(topo, b)
2055        .map(|v| v.len())
2056        .unwrap_or(0);
2057    let segments = segments_a.max(segments_b).max(8);
2058
2059    // Build a fresh torus at the origin then transform to the shared
2060    // center / axis. `make_torus` builds with axis = +z by default.
2061    let torus = crate::primitives::make_torus(topo, major_radius, minor_result, segments)?;
2062    let xform = xform_from_canonical_z(center, axis, tol);
2063    crate::transform::transform_solid(topo, torus, &xform)?;
2064    Ok(Some(torus))
2065}
2066
2067/// Build the world-frame transform that maps a primitive built in the
2068/// canonical Z-up local frame (origin at world origin, axis = +Z) to a
2069/// world frame at `world_origin` with up-axis `axis` (assumed
2070/// unit-length). Uses Rodrigues' rotation formula for the general case.
2071///
2072/// Comparisons use `1.0 - axis.dot(canonical) < tol.angular` rather than
2073/// vector-length deltas, because for unit vectors `|u−v| ≈ √2·θ`, so a
2074/// length comparison against `tol.angular` would correspond to
2075/// `θ ≈ 7×10⁻¹³` rad — effectively bit-identity.
2076fn xform_from_canonical_z(
2077    world_origin: Point3,
2078    axis: Vec3,
2079    tol: brepkit_math::tolerance::Tolerance,
2080) -> brepkit_math::mat::Mat4 {
2081    let translate =
2082        brepkit_math::mat::Mat4::translation(world_origin.x(), world_origin.y(), world_origin.z());
2083    let canonical = Vec3::new(0.0, 0.0, 1.0);
2084    let dot = canonical.dot(axis).clamp(-1.0, 1.0);
2085    // Parallel to +Z: pure translation.
2086    if 1.0 - dot < tol.angular {
2087        return translate;
2088    }
2089    // Antiparallel: rotate canonical (+z) by π around X to flip to −z.
2090    if 1.0 + dot < tol.angular {
2091        return translate * brepkit_math::mat::Mat4::rotation_x(std::f64::consts::PI);
2092    }
2093    // Rotate canonical (0,0,1) → axis via Rodrigues' formula:
2094    //   R = I + sin(θ) K + (1 - cos(θ)) K²,  K = [k]× for k = ẑ × axis / sin(θ).
2095    // k.z = 0 by construction, so K's z-row/z-column have a known structure.
2096    let sin_t = (1.0 - dot * dot).sqrt();
2097    let kx = -axis.y() / sin_t;
2098    let ky = axis.x() / sin_t;
2099    let one_minus_cos = 1.0 - dot;
2100    let r00 = one_minus_cos.mul_add(kx * kx, dot);
2101    let r01 = one_minus_cos * kx * ky;
2102    let r02 = sin_t * ky;
2103    let r10 = one_minus_cos * kx * ky;
2104    let r11 = one_minus_cos.mul_add(ky * ky, dot);
2105    let r12 = -sin_t * kx;
2106    let r20 = -sin_t * ky;
2107    let r21 = sin_t * kx;
2108    let r22 = dot;
2109    let rot = brepkit_math::mat::Mat4([
2110        [r00, r01, r02, 0.0],
2111        [r10, r11, r12, 0.0],
2112        [r20, r21, r22, 0.0],
2113        [0.0, 0.0, 0.0, 1.0],
2114    ]);
2115    translate * rot
2116}
2117
2118/// Returns `true` when two axis-aligned boxes are separated on at least
2119/// one axis by more than `margin` — i.e. their (margin-expanded) extents
2120/// do not overlap and the solids they bound provably do not intersect.
2121///
2122/// The `margin` shrinks the overlap test so boxes that only touch (or
2123/// nearly touch) within `margin` are treated as separated: a shared
2124/// face/edge/corner has zero overlap volume.
2125fn aabbs_separated(
2126    a: &brepkit_math::aabb::Aabb3,
2127    b: &brepkit_math::aabb::Aabb3,
2128    margin: f64,
2129) -> bool {
2130    a.max.x() < b.min.x() + margin
2131        || b.max.x() < a.min.x() + margin
2132        || a.max.y() < b.min.y() + margin
2133        || b.max.y() < a.min.y() + margin
2134        || a.max.z() < b.min.z() + margin
2135        || b.max.z() < a.min.z() + margin
2136}
2137
2138/// Returns `true` when two axis-aligned boxes have a *clear gap* exceeding
2139/// `margin` on at least one axis — i.e. they are separated by a real positive
2140/// distance, not merely touching.
2141///
2142/// This is intentionally stricter than [`aabbs_separated`]: a shared
2143/// face/edge/corner (zero gap) returns `false` here. Touching solids must NOT
2144/// be treated as disjoint by the fuse fast path — their shared geometry has to
2145/// be welded by GFA.
2146fn aabbs_clear_gap(
2147    a: &brepkit_math::aabb::Aabb3,
2148    b: &brepkit_math::aabb::Aabb3,
2149    margin: f64,
2150) -> bool {
2151    b.min.x() - a.max.x() > margin
2152        || a.min.x() - b.max.x() > margin
2153        || b.min.y() - a.max.y() > margin
2154        || a.min.y() - b.max.y() > margin
2155        || b.min.z() - a.max.z() > margin
2156        || a.min.z() - b.max.z() > margin
2157}
2158
2159/// Returns `true` when solids `a` and `b` are provably spatially disjoint with
2160/// a clear gap: every connected face component of `a` is separated from every
2161/// connected face component of `b` by more than `margin` on some axis.
2162///
2163/// Soundness: component AABBs come from [`crate::measure::face_set_bounding_box`],
2164/// which is a conservative *outer* bound (vertices plus surface-curvature
2165/// expansion). If two components' true geometry overlapped or touched, their
2166/// boxes would touch or overlap and [`aabbs_clear_gap`] would (correctly)
2167/// return `false`. So a `true` result guarantees a real positive gap between
2168/// the two solids — never a false "disjoint" for touching/coincident inputs,
2169/// which must still go through GFA to weld shared geometry.
2170///
2171/// Component-level (rather than whole-solid) granularity is essential: a
2172/// multi-region solid (e.g. an accumulator of several already-merged disjoint
2173/// pieces) has a single outer shell whose overall box overlaps a nearby piece,
2174/// yet none of its pieces actually touch that piece. [`assembly::face_components`]
2175/// recovers the individual pieces from the merged shell.
2176///
2177/// Returns `false` on any topology error or empty operand (fall through to the
2178/// general path) rather than risking an unsound merge.
2179fn solids_provably_disjoint(topo: &Topology, a: SolidId, b: SolidId, margin: f64) -> bool {
2180    let comps_a = assembly::face_components(topo, a);
2181    let comps_b = assembly::face_components(topo, b);
2182    if comps_a.is_empty() || comps_b.is_empty() {
2183        return false;
2184    }
2185    let boxes = |comps: &[Vec<FaceId>]| -> Option<Vec<brepkit_math::aabb::Aabb3>> {
2186        comps
2187            .iter()
2188            .map(|faces| crate::measure::face_set_bounding_box(topo, faces).ok())
2189            .collect()
2190    };
2191    let (Some(boxes_a), Some(boxes_b)) = (boxes(&comps_a), boxes(&comps_b)) else {
2192        return false;
2193    };
2194    boxes_a
2195        .iter()
2196        .all(|ba| boxes_b.iter().all(|bb| aabbs_clear_gap(ba, bb, margin)))
2197}
2198
2199/// The trivial operand relationships that let [`boolean`] short-circuit
2200/// without running the GFA: identical solids and full containment.
2201struct TrivialRelation {
2202    /// Matching AABBs AND every boundary vertex of each solid classifies
2203    /// as inside-or-on the other's analytic classifier.
2204    identical: bool,
2205    /// A is fully contained in B.
2206    a_in_b: bool,
2207    /// B is fully contained in A.
2208    b_in_a: bool,
2209}
2210
2211/// Detect the trivial operand relationships (identical / contained).
2212///
2213/// [`boolean`] uses this to take copy/empty shortcuts. [`boolean_with_evolution`]
2214/// consults the same detection BEFORE its faithful raw-GFA provenance path:
2215/// these are exactly the fully-coincident-boundary configurations the raw GFA
2216/// mis-splits (coincident walls dropped into an open shell whose
2217/// position-duplicate free edges slip past the by-edge-id validation gate), so
2218/// the evolution path must route them through [`boolean`]'s shortcuts instead.
2219fn detect_trivial_relation(
2220    topo: &Topology,
2221    a: SolidId,
2222    b: SolidId,
2223    ca: Option<&brepkit_algo::classifier::AnalyticClassifier>,
2224    cb: Option<&brepkit_algo::classifier::AnalyticClassifier>,
2225    tol: brepkit_math::tolerance::Tolerance,
2226) -> TrivialRelation {
2227    // Use measure::solid_bounding_box — it expands for surface curvature
2228    // (cylinder vertex projection, sphere/torus analytic). The naive
2229    // edge-vertex sampler missed cylinder lateral extents because cylinders
2230    // only have seam vertices, leaving the AABB center on the lateral
2231    // surface where the analytic classifier returns None.
2232    let sample_aabb = |topo: &Topology, solid: SolidId| -> Option<(Point3, Point3)> {
2233        let bb = crate::measure::solid_bounding_box(topo, solid).ok()?;
2234        Some((bb.min, bb.max))
2235    };
2236    let aabb_a = sample_aabb(topo, a);
2237    let aabb_b = sample_aabb(topo, b);
2238    // AABB-encloses check (lenient): does `inner` fit inside `outer`?
2239    let aabb_encloses =
2240        |inner: &Option<(Point3, Point3)>, outer: &Option<(Point3, Point3)>| -> bool {
2241            let Some(((i_min, i_max), (o_min, o_max))) = inner.zip(*outer) else {
2242                return false;
2243            };
2244            let margin = tol.linear;
2245            i_min.x() >= o_min.x() - margin
2246                && i_min.y() >= o_min.y() - margin
2247                && i_min.z() >= o_min.z() - margin
2248                && i_max.x() <= o_max.x() + margin
2249                && i_max.y() <= o_max.y() + margin
2250                && i_max.z() <= o_max.z() + margin
2251        };
2252    // AABB-strictly-contains (strict): outer must also be ≥10% larger in
2253    // ALL 3 dims. Used as the no-classifier fallback to detect true
2254    // nested containment (e.g., a ring fully inside a shell's cavity)
2255    // without false-positives on sparse multi-shell solids (e.g., a
2256    // fuse of disjoint boxes whose AABB technically encloses another
2257    // solid's AABB while mostly being empty space).
2258    let aabb_strictly_contains =
2259        |inner: &Option<(Point3, Point3)>, outer: &Option<(Point3, Point3)>| -> bool {
2260            if !aabb_encloses(inner, outer) {
2261                return false;
2262            }
2263            let Some(((i_min, i_max), (o_min, o_max))) = inner.zip(*outer) else {
2264                return false;
2265            };
2266            let dims = [
2267                (o_max.x() - o_min.x(), i_max.x() - i_min.x()),
2268                (o_max.y() - o_min.y(), i_max.y() - i_min.y()),
2269                (o_max.z() - o_min.z(), i_max.z() - i_min.z()),
2270            ];
2271            dims.iter()
2272                .all(|(outer_d, inner_d)| *outer_d > *inner_d * 1.1)
2273        };
2274
2275    // AABB enclosure is necessary but NOT sufficient for solid
2276    // containment: a non-convex container (notched or hollow) can
2277    // AABB-enclose a solid that actually lies in its empty region.
2278    // Issue #801: `(a − b) ∪ (a ∩ b)` dropped the `a ∩ b` operand
2279    // because the unit cube's bbox fits inside the notched `a − b`'s
2280    // bbox, yet the cube lives in the carved-out notch. Confirm the
2281    // AABB-only fallback with a real point-in-solid test: reject when
2282    // the inner solid's center is provably inside `inner` yet outside
2283    // `outer`. By the containment lemma (inner ⊆ outer ⇒ every point
2284    // of inner is in outer), that witness can only occur for genuine
2285    // non-containment, so it never rejects a true containment.
2286    let center_outside =
2287        |topo: &Topology, inner: SolidId, outer: SolidId, bb: &Option<(Point3, Point3)>| -> bool {
2288            let Some((lo, hi)) = *bb else { return false };
2289            let c = Point3::new(
2290                0.5 * (lo.x() + hi.x()),
2291                0.5 * (lo.y() + hi.y()),
2292                0.5 * (lo.z() + hi.z()),
2293            );
2294            let (dx, dy, dz) = (hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z());
2295            let defl = (dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() * 0.01).max(1e-6);
2296            // Conservative by design: when the AABB center falls in `inner`'s own
2297            // concavity (a C/U-shaped solid), `inside_inner` is false and the
2298            // witness is disabled, so a false-positive containment could still
2299            // slip through. That only ever fails to *reject* — it never rejects a
2300            // true containment — so the shortcut stays sound, just not complete.
2301            let inside_inner = matches!(
2302                crate::classify::classify_point(topo, inner, c, defl, tol.linear),
2303                Ok(crate::classify::PointClassification::Inside)
2304            );
2305            let outside_outer = matches!(
2306                crate::classify::classify_point(topo, outer, c, defl, tol.linear),
2307                Ok(crate::classify::PointClassification::Outside)
2308            );
2309            inside_inner && outside_outer
2310        };
2311
2312    // Volume witness for the AABB-only fallback: `inner ⊆ outer` implies
2313    // `vol(inner) ≤ vol(outer)`, so a decisively larger inner volume proves
2314    // non-containment. This catches what `center_outside` cannot: the AABB
2315    // expansion for partial cylinder/cone faces is a conservative full-circle
2316    // bound, so a thin angular wedge's box balloons to the whole cylinder
2317    // footprint and can "strictly contain" a much bigger solid's box, while
2318    // the bigger solid's own AABB center sits in its annular hole and
2319    // disables the center witness (gh #1499, the kumiko corner cutter).
2320    // Volumes are immune to that inflation. The 1.05 factor absorbs
2321    // deflection under-counting on curved faces so a true containment is
2322    // never rejected; errors fall through to "no refutation" (shortcut
2323    // soundness is then up to the remaining witnesses, as before).
2324    let volume_refutes = |topo: &Topology, inner: SolidId, outer: SolidId| -> bool {
2325        let defl = |bb: &Option<(Point3, Point3)>| {
2326            let Some((lo, hi)) = *bb else { return 1e-3 };
2327            let (dx, dy, dz) = (hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z());
2328            (dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() * 0.01).max(1e-6)
2329        };
2330        let d = defl(&aabb_a).min(defl(&aabb_b));
2331        let (Ok(vi), Ok(vo)) = (
2332            crate::measure::solid_volume(topo, inner, d),
2333            crate::measure::solid_volume(topo, outer, d),
2334        ) else {
2335            return false;
2336        };
2337        vi > vo * 1.05
2338    };
2339
2340    // Bidirectional vertex check via the analytic classifier — the
2341    // primary signal for identical/containment classification. A vertex
2342    // classifying as inside-or-on (None within tolerance band counts
2343    // as on) means it sits within the solid's region.
2344    let all_b_verts_in_a = ca.is_some_and(|c| all_vertices_inside_or_on(topo, b, c, tol));
2345    let all_a_verts_in_b = cb.is_some_and(|c| all_vertices_inside_or_on(topo, a, c, tol));
2346    let aabbs_match = aabb_a
2347        .zip(aabb_b)
2348        .map(|((a_min, a_max), (b_min, b_max))| {
2349            let eps = tol.linear;
2350            (a_min.x() - b_min.x()).abs() < eps
2351                && (a_min.y() - b_min.y()).abs() < eps
2352                && (a_min.z() - b_min.z()).abs() < eps
2353                && (a_max.x() - b_max.x()).abs() < eps
2354                && (a_max.y() - b_max.y()).abs() < eps
2355                && (a_max.z() - b_max.z()).abs() < eps
2356        })
2357        .unwrap_or(false);
2358
2359    // Containment: A contains B when all B vertices are inside-or-on A AND
2360    // A's AABB encloses B's. Falls back to a strict AABB-only check when the
2361    // containing solid has no classifier — the strict check requires ≥10%
2362    // larger in ALL three dims so that sparse multi-shell solids (e.g., a
2363    // fuse of two disjoint boxes) don't false-positive as "contains another
2364    // solid".
2365    // Both the analytic-classifier term and the AABB-only fallback can
2366    // false-positive when the container is non-convex: the analytic
2367    // classifier may mis-report notch points as inside-or-on, and an
2368    // AABB encloses a notch's empty volume. Guard the whole determination
2369    // with the `center_outside` witness — sound for every path because it
2370    // only fires on proven non-containment (see the lemma above).
2371    let b_in_a = ((all_b_verts_in_a && aabb_encloses(&aabb_b, &aabb_a))
2372        || (ca.is_none()
2373            && aabb_strictly_contains(&aabb_b, &aabb_a)
2374            && !volume_refutes(topo, b, a)))
2375        && !center_outside(topo, b, a, &aabb_b);
2376    let a_in_b = ((all_a_verts_in_b && aabb_encloses(&aabb_a, &aabb_b))
2377        || (cb.is_none()
2378            && aabb_strictly_contains(&aabb_a, &aabb_b)
2379            && !volume_refutes(topo, a, b)))
2380        && !center_outside(topo, a, b, &aabb_a);
2381
2382    TrivialRelation {
2383        identical: aabbs_match && all_b_verts_in_a && all_a_verts_in_b,
2384        a_in_b,
2385        b_in_a,
2386    }
2387}
2388
2389/// Check whether every boundary vertex of `solid` is classified as
2390/// `Inside` or `On` by `classifier`. Used by the identical-solid shortcut
2391/// to distinguish truly-identical solids from co-located but differently
2392/// shaped solids (e.g., a cone and a box that share an AABB).
2393fn all_vertices_inside_or_on(
2394    topo: &Topology,
2395    solid: SolidId,
2396    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2397    tol: brepkit_math::tolerance::Tolerance,
2398) -> bool {
2399    let Ok(s) = topo.solid(solid) else {
2400        return false;
2401    };
2402    let Ok(sh) = topo.shell(s.outer_shell()) else {
2403        return false;
2404    };
2405    for &fid in sh.faces() {
2406        let Ok(f) = topo.face(fid) else { return false };
2407        let Ok(w) = topo.wire(f.outer_wire()) else {
2408            return false;
2409        };
2410        for oe in w.edges() {
2411            let Ok(e) = topo.edge(oe.edge()) else {
2412                return false;
2413            };
2414            for vid in [e.start(), e.end()] {
2415                let Ok(v) = topo.vertex(vid) else {
2416                    return false;
2417                };
2418                // The analytic classifier returns `None` for points within
2419                // tol.linear of the boundary — treat as "on" for this check.
2420                if classifier.classify(v.point(), tol) == Some(brepkit_algo::FaceClass::Outside) {
2421                    return false;
2422                }
2423            }
2424        }
2425    }
2426    true
2427}
2428
2429/// True when every outer-shell vertex of `inner` classifies as *strictly*
2430/// `Inside` (not on the boundary) of `classifier`. A strictly-contained tool
2431/// has no surface contact with the blank, so `Cut(blank, tool)` is a clean
2432/// internal cavity rather than a notch through the boundary.
2433fn solid_strictly_inside(
2434    topo: &Topology,
2435    inner: SolidId,
2436    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2437    tol: brepkit_math::tolerance::Tolerance,
2438) -> bool {
2439    let Ok(s) = topo.solid(inner) else {
2440        return false;
2441    };
2442    let Ok(sh) = topo.shell(s.outer_shell()) else {
2443        return false;
2444    };
2445    let mut saw_vertex = false;
2446    for &fid in sh.faces() {
2447        let Ok(f) = topo.face(fid) else { return false };
2448        // Check the outer wire and any inner (hole) wires — a hole boundary on
2449        // a simple solid's face can also reach the blank's surface.
2450        let mut wires = vec![f.outer_wire()];
2451        wires.extend_from_slice(f.inner_wires());
2452        for wid in wires {
2453            let Ok(w) = topo.wire(wid) else {
2454                return false;
2455            };
2456            for oe in w.edges() {
2457                let Ok(e) = topo.edge(oe.edge()) else {
2458                    return false;
2459                };
2460                for vid in [e.start(), e.end()] {
2461                    let Ok(v) = topo.vertex(vid) else {
2462                        return false;
2463                    };
2464                    if classifier.classify(v.point(), tol) != Some(brepkit_algo::FaceClass::Inside)
2465                    {
2466                        return false;
2467                    }
2468                    saw_vertex = true;
2469                }
2470            }
2471        }
2472    }
2473    saw_vertex
2474}
2475
2476/// Build `Cut(blank, tool)` for a tool strictly contained in the blank: the
2477/// result is the blank with a tool-shaped internal cavity. Deep-copies the
2478/// blank and the tool, reverses every copied tool face in place so the cavity
2479/// boundary faces into the void, and attaches the reversed tool shell to the
2480/// copied blank as an inner shell. Bypasses GFA, whose no-intersection assembly
2481/// drops fully-contained cone/torus tools.
2482fn build_contained_cut_hollow(
2483    topo: &mut Topology,
2484    blank: SolidId,
2485    tool: SolidId,
2486) -> Result<SolidId, crate::OperationsError> {
2487    let result = crate::copy::copy_solid(topo, blank)?;
2488
2489    // Deep-copy the tool as a whole solid so the cavity shell shares edges and
2490    // vertices between adjacent faces (a per-face copy would duplicate shared
2491    // boundary edges and leave the cavity non-manifold — wrong Euler, though
2492    // per-face volume is unaffected). Reverse each copied face in place and
2493    // reuse the copied outer shell directly as the cavity inner shell, so no
2494    // duplicate faces or extra result solid are created.
2495    let tool_copy = crate::copy::copy_solid(topo, tool)?;
2496    let cavity_shell = topo.solid(tool_copy)?.outer_shell();
2497    let cavity_faces = topo.shell(cavity_shell)?.faces().to_vec();
2498    for fid in cavity_faces {
2499        let face = topo.face_mut(fid)?;
2500        let flipped = !face.is_reversed();
2501        face.set_reversed(flipped);
2502    }
2503    topo.solid_mut(result)?.add_inner_shell(cavity_shell);
2504    Ok(result)
2505}
2506
2507/// Best-effort mesh boolean fallback for high face-count solids.
2508///
2509/// Tessellates both solids, runs mesh co-refinement, assembles the result,
2510/// and applies the same post-processing as the other boolean paths.
2511/// Returns `Err` on any failure so the caller can fall through to the
2512/// chord-based path.
2513fn mesh_boolean_fallback(
2514    topo: &mut Topology,
2515    op: BooleanOp,
2516    a: SolidId,
2517    b: SolidId,
2518    deflection: f64,
2519    tol: brepkit_math::tolerance::Tolerance,
2520    opts: &BooleanOptions,
2521) -> Result<SolidId, crate::OperationsError> {
2522    // Mesh density here is a boolean-robustness concern, independent of the
2523    // rendering tolerance: use the linear-only criterion (angular_tol 0.0) so
2524    // the face count is unaffected by the display deflection cap, AND keep the
2525    // circle curvature floor so co-refinement gets the denser circular sampling
2526    // it needs (display tessellation drops that floor for triangle count).
2527    let mesh_a = crate::tessellate::tessellate_solid_for_boolean(topo, a, deflection, 0.0)?;
2528    let mesh_b = crate::tessellate::tessellate_solid_for_boolean(topo, b, deflection, 0.0)?;
2529    log::debug!(
2530        "mesh fallback {op:?}: tessellated operands to {} + {} triangles at deflection {deflection}",
2531        mesh_a.indices.len() / 3,
2532        mesh_b.indices.len() / 3,
2533    );
2534
2535    let mb_result = crate::mesh_boolean::mesh_boolean(&mesh_a, &mesh_b, op, tol.linear)?;
2536    if mb_result.boundary_edge_count > 0 || mb_result.non_manifold_edge_count > 0 {
2537        log::warn!(
2538            "boolean {op:?}: mesh boolean fallback output is NOT a closed 2-manifold \
2539             ({} boundary edge(s), {} non-manifold edge(s) after position welding) — \
2540             downstream healing may not recover; exported geometry may be broken",
2541            mb_result.boundary_edge_count,
2542            mb_result.non_manifold_edge_count,
2543        );
2544    }
2545    let face_specs = mesh_result_to_face_specs(&mb_result);
2546    if face_specs.is_empty() {
2547        return Err(crate::OperationsError::EmptyResult {
2548            reason: "mesh boolean produced no output faces".into(),
2549        });
2550    }
2551    log::debug!(
2552        "mesh fallback {op:?}: {} face specs -> assemble_solid_mixed",
2553        face_specs.len()
2554    );
2555    let result = assemble_solid_mixed(topo, &face_specs, tol)?;
2556    let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
2557    if opts.unify_faces {
2558        let _ = crate::heal::unify_faces(topo, result)?;
2559    }
2560    // Cross-face symmetrization: tessellation diagonals that one face
2561    // dropped while its neighbour kept (#696) leave structurally
2562    // orphan collinear interior wire vertices. Collapse those so both
2563    // sides reference the same EdgeId for the shared 3D segment,
2564    // eliminating the residual non-manifold edges that `unify_faces`
2565    // can't symmetrize from per-face surface matching alone.
2566    let collapsed =
2567        brepkit_heal::upgrade::collapse_collinear_vertices::collapse_collinear_wire_vertices(
2568            topo, result, tol,
2569        )
2570        .unwrap_or_else(|e| {
2571            log::warn!("boolean {op:?}: collapse_collinear_wire_vertices failed: {e}");
2572            0
2573        });
2574    if collapsed > 0 {
2575        log::info!(
2576            "boolean {op:?}: collapsed {collapsed} collinear interior wire vertex/vertices post-mesh-assembly",
2577        );
2578    }
2579    // Mesh-fallback can glue two physically-separate holes into a
2580    // single figure-8 inner wire via diagonal "bridge" edges across
2581    // gap material (#696 cumulative pattern: a slab top with multiple
2582    // pocket cuts ends up with one self-intersecting inner wire that
2583    // visits each pocket region). Split such wires at every pinch
2584    // vertex so each physical hole is its own simple inner wire —
2585    // the resulting topology is well-formed for downstream
2586    // tessellation, validation, and STEP export, even when the
2587    // bridge edges themselves remain as boundary edges (those are a
2588    // separate cleanup).
2589    let wires_split =
2590        brepkit_heal::upgrade::split_self_intersecting_wires::split_self_intersecting_inner_wires(
2591            topo, result,
2592        )
2593        .unwrap_or_else(|e| {
2594            log::warn!("boolean {op:?}: split_self_intersecting_inner_wires failed: {e}");
2595            0
2596        });
2597    if wires_split > 0 {
2598        log::info!(
2599            "boolean {op:?}: split {wires_split} self-intersecting inner wire(s) post-mesh-assembly",
2600        );
2601    }
2602    if opts.heal_after_boolean {
2603        let _ = crate::heal::heal_solid(topo, result, tol.linear)?;
2604    }
2605    assembly::validate_boolean_result_lenient(topo, result)?;
2606    log::info!(
2607        "boolean {op:?}: mesh boolean path → solid {} ({} faces, surface types lost)",
2608        result.index(),
2609        face_specs.len()
2610    );
2611    Ok(result)
2612}
2613
2614/// Convert a mesh boolean result into `FaceSpec` entries for solid assembly.
2615fn mesh_result_to_face_specs(result: &crate::mesh_boolean::MeshBooleanResult) -> Vec<FaceSpec> {
2616    let mut specs = Vec::new();
2617    for tri in result.mesh.indices.chunks_exact(3) {
2618        let v0 = result.mesh.positions[tri[0] as usize];
2619        let v1 = result.mesh.positions[tri[1] as usize];
2620        let v2 = result.mesh.positions[tri[2] as usize];
2621
2622        let edge1 = v1 - v0;
2623        let edge2 = v2 - v0;
2624        let Ok(normal) = edge1.cross(edge2).normalize() else {
2625            continue;
2626        };
2627        let d = crate::dot_normal_point(normal, v0);
2628        specs.push(FaceSpec::Planar {
2629            vertices: vec![v0, v1, v2],
2630            normal,
2631            d,
2632            inner_wires: vec![],
2633        });
2634    }
2635    specs
2636}
2637
2638/// True when the outer-shell face components represent disjoint solid
2639/// pieces (e.g., a previous cut split one solid into N parts), false
2640/// when one component is concentric inside another (a hollow solid:
2641/// outer surface + cavity surface both live in the outer shell).
2642///
2643/// The check is AABB-based: if any component's bounding box is
2644/// strictly contained in another's, treat the whole solid as hollow
2645/// and skip the multi-region split path.
2646/// Check that every component's AABB centre classifies as outside the
2647/// supplied classifier. Used to reject multi-region GFA Cut results that
2648/// erroneously include the tool's interior as one of the pieces.
2649fn all_component_centers_outside(
2650    topo: &Topology,
2651    components: &[Vec<FaceId>],
2652    classifier: &brepkit_algo::classifier::AnalyticClassifier,
2653    tol: brepkit_math::tolerance::Tolerance,
2654) -> bool {
2655    use brepkit_algo::FaceClass;
2656    for comp in components {
2657        let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2658        let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2659        for &fid in comp {
2660            let Ok(face) = topo.face(fid) else { continue };
2661            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2662            {
2663                let Ok(wire) = topo.wire(wid) else { continue };
2664                for oe in wire.edges() {
2665                    let Ok(edge) = topo.edge(oe.edge()) else {
2666                        continue;
2667                    };
2668                    for vid in [edge.start(), edge.end()] {
2669                        if let Ok(v) = topo.vertex(vid) {
2670                            let p = v.point();
2671                            min = Point3::new(
2672                                min.x().min(p.x()),
2673                                min.y().min(p.y()),
2674                                min.z().min(p.z()),
2675                            );
2676                            max = Point3::new(
2677                                max.x().max(p.x()),
2678                                max.y().max(p.y()),
2679                                max.z().max(p.z()),
2680                            );
2681                        }
2682                    }
2683                }
2684            }
2685        }
2686        let centre = Point3::new(
2687            (min.x() + max.x()) * 0.5,
2688            (min.y() + max.y()) * 0.5,
2689            (min.z() + max.z()) * 0.5,
2690        );
2691        if matches!(classifier.classify(centre, tol), Some(FaceClass::Inside)) {
2692            return false;
2693        }
2694    }
2695    true
2696}
2697
2698/// Centre of a face component's vertex AABB, or `None` for an empty component.
2699fn component_aabb_centre(topo: &Topology, comp: &[FaceId]) -> Option<Point3> {
2700    let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2701    let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2702    for &fid in comp {
2703        let Ok(face) = topo.face(fid) else { continue };
2704        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
2705            let Ok(wire) = topo.wire(wid) else { continue };
2706            for oe in wire.edges() {
2707                let Ok(edge) = topo.edge(oe.edge()) else {
2708                    continue;
2709                };
2710                for vid in [edge.start(), edge.end()] {
2711                    if let Ok(v) = topo.vertex(vid) {
2712                        let p = v.point();
2713                        min =
2714                            Point3::new(min.x().min(p.x()), min.y().min(p.y()), min.z().min(p.z()));
2715                        max =
2716                            Point3::new(max.x().max(p.x()), max.y().max(p.y()), max.z().max(p.z()));
2717                    }
2718                }
2719            }
2720        }
2721    }
2722    if min.x() > max.x() {
2723        return None;
2724    }
2725    Some(Point3::new(
2726        (min.x() + max.x()) * 0.5,
2727        (min.y() + max.y()) * 0.5,
2728        (min.z() + max.z()) * 0.5,
2729    ))
2730}
2731
2732/// Does the closed surface made of `faces` enclose `p`?
2733///
2734/// Ray-parity against the component's own tessellation. Read-only by design:
2735/// building a temporary solid per component would add entities to an arena that
2736/// never reclaims, which is the growth cliff fixed in #1237.
2737///
2738/// `watertight_ray_triangle_intersect` reports exactly one hit on a shared edge,
2739/// so parity is meaningful across face boundaries. The direction is deliberately
2740/// irrational so the ray does not graze a face boundary or lie in a face plane —
2741/// the degeneracy that makes axis-aligned probes unreliable on the feature-plane
2742/// intersections these pieces are full of. Returns `None` when the component
2743/// cannot be tessellated, so callers can fall back rather than guess.
2744fn component_encloses_point(
2745    topo: &Topology,
2746    faces: &[FaceId],
2747    p: Point3,
2748    deflection: f64,
2749) -> Option<bool> {
2750    // A sqrt-prime direction: irrational in every component, so the ray cannot
2751    // lie in a face plane or run along an edge — the same generic-direction
2752    // escape the ray-cast classifier uses for degenerate probes.
2753    let dir = Vec3::new(2.0_f64.sqrt(), 3.0_f64.sqrt(), 5.0_f64.sqrt())
2754        .normalize()
2755        .ok()?;
2756    let mut crossings = 0usize;
2757    let mut any_triangle = false;
2758    for &fid in faces {
2759        let mesh = crate::tessellate::tessellate_with_uvs(topo, fid, deflection).ok()?;
2760        let pos = &mesh.mesh.positions;
2761        for tri in mesh.mesh.indices.chunks_exact(3) {
2762            let (a, b, c) = (
2763                pos[tri[0] as usize],
2764                pos[tri[1] as usize],
2765                pos[tri[2] as usize],
2766            );
2767            any_triangle = true;
2768            if let Some(hit) =
2769                brepkit_math::ray_triangle::watertight_ray_triangle_intersect(p, dir, a, b, c)
2770                && hit.t > 1e-9
2771            {
2772                crossings += 1;
2773            }
2774        }
2775    }
2776    any_triangle.then_some(crossings % 2 == 1)
2777}
2778
2779/// Any vertex position on `faces`, for use as a probe point.
2780fn any_vertex_of(topo: &Topology, faces: &[FaceId]) -> Option<Point3> {
2781    for &fid in faces {
2782        let face = topo.face(fid).ok()?;
2783        let wire = topo.wire(face.outer_wire()).ok()?;
2784        if let Some(oe) = wire.edges().first()
2785            && let Ok(edge) = topo.edge(oe.edge())
2786            && let Ok(v) = topo.vertex(edge.start())
2787        {
2788            return Some(v.point());
2789        }
2790    }
2791    None
2792}
2793
2794fn components_are_disjoint_pieces(topo: &Topology, components: &[Vec<FaceId>]) -> bool {
2795    let aabbs: Vec<(Point3, Point3)> = components
2796        .iter()
2797        .map(|comp| {
2798            let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2799            let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2800            for &fid in comp {
2801                let Ok(face) = topo.face(fid) else {
2802                    continue;
2803                };
2804                for wid in
2805                    std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2806                {
2807                    let Ok(wire) = topo.wire(wid) else {
2808                        continue;
2809                    };
2810                    for oe in wire.edges() {
2811                        let Ok(edge) = topo.edge(oe.edge()) else {
2812                            continue;
2813                        };
2814                        for vid in [edge.start(), edge.end()] {
2815                            if let Ok(v) = topo.vertex(vid) {
2816                                let p = v.point();
2817                                min = Point3::new(
2818                                    min.x().min(p.x()),
2819                                    min.y().min(p.y()),
2820                                    min.z().min(p.z()),
2821                                );
2822                                max = Point3::new(
2823                                    max.x().max(p.x()),
2824                                    max.y().max(p.y()),
2825                                    max.z().max(p.z()),
2826                                );
2827                            }
2828                        }
2829                    }
2830                }
2831            }
2832            (min, max)
2833        })
2834        .collect();
2835
2836    // Reject NESTING, not mere AABB overlap.
2837    //
2838    // Nesting is the hazard worth rejecting (a blob sitting inside another
2839    // piece's cavity is not a disjoint union); side-by-side pieces are exactly
2840    // what multi-region acceptance is for.
2841    //
2842    // Assume nothing about the components handed in. The acceptance gate calls
2843    // this on a GFA result that has cleared only `euler_balanced` — which is
2844    // genus-tolerant, and whose `closed_manifold` companion is a LATER conjunct
2845    // in the same `&&` chain, not a precondition. The input-splitting paths
2846    // call it on components of a raw operand no gate has examined at all. So
2847    // "every piece is a closed manifold, hence disjoint-or-nested" is not
2848    // available here; the ray-parity confirmation below earns the answer
2849    // instead of inferring it.
2850    //
2851    // Overlap is the wrong predicate for that, because an AABB is only tight on
2852    // axis-aligned geometry. Two ROTATED bars a clear distance apart each span
2853    // the whole diagonal envelope, so their boxes interpenetrate and an
2854    // overlap test calls them touching — which is why a kumiko lattice cut,
2855    // whose members are diagonal, could never be accepted and fell back to the
2856    // mesh path on every band (see
2857    // `tests::rotated_separate_pieces_are_recognised_as_disjoint`). Containment
2858    // is tight in the direction that matters: nesting implies it, and rotation
2859    // does not manufacture it.
2860    let eps = 1e-7;
2861    let contains = |(o_min, o_max): (Point3, Point3), (i_min, i_max): (Point3, Point3)| {
2862        o_min.x() - eps <= i_min.x()
2863            && o_min.y() - eps <= i_min.y()
2864            && o_min.z() - eps <= i_min.z()
2865            && o_max.x() + eps >= i_max.x()
2866            && o_max.y() + eps >= i_max.y()
2867            && o_max.z() + eps >= i_max.z()
2868    };
2869    // AABB containment is only the PRE-FILTER. It is necessary for nesting but
2870    // far from sufficient: a ring's box contains the box of a separate piece
2871    // sitting in its HOLE, and a lattice is full of rings. So a suspect pair
2872    // gets a real ray-parity test against the enclosing candidate's own surface,
2873    // and only genuine enclosure rejects. If the probe cannot be evaluated the
2874    // pair falls back to the conservative answer.
2875    for i in 0..aabbs.len() {
2876        for j in (i + 1)..aabbs.len() {
2877            let (outer, inner) = if contains(aabbs[i], aabbs[j]) {
2878                (i, j)
2879            } else if contains(aabbs[j], aabbs[i]) {
2880                (j, i)
2881            } else {
2882                continue;
2883            };
2884            let (o_min, o_max) = aabbs[outer];
2885            let diag = ((o_max.x() - o_min.x()).powi(2)
2886                + (o_max.y() - o_min.y()).powi(2)
2887                + (o_max.z() - o_min.z()).powi(2))
2888            .sqrt();
2889            let deflection = (diag / 200.0).max(1e-4);
2890            let Some(probe) = any_vertex_of(topo, &components[inner]) else {
2891                return false;
2892            };
2893            match component_encloses_point(topo, &components[outer], probe, deflection) {
2894                Some(true) => return false,
2895                Some(false) => {}
2896                None => return false,
2897            }
2898        }
2899    }
2900    true
2901}
2902
2903/// Fuse a multi-component TOOL by folding its disjoint pieces into the
2904/// target one at a time.
2905///
2906/// Each piece is copied into a fresh connected solid (the pavefiller
2907/// stumbles on shared vertex IDs across what it considers one "solid B")
2908/// and fused via the full `boolean` entry, so every per-piece fuse gets the
2909/// analytic path, gates, and fallbacks. Fuse distributes over a
2910/// disjoint-union tool, so the fold is exact. Recursion terminates: each
2911/// piece is single-component, so the recursive call never re-enters this
2912/// path.
2913fn fuse_multi_component_tool(
2914    topo: &mut Topology,
2915    a: SolidId,
2916    b_components: Vec<Vec<brepkit_topology::face::FaceId>>,
2917) -> Result<SolidId, crate::OperationsError> {
2918    let mut result = a;
2919    for comp_faces in b_components {
2920        let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2921        let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2922        result = boolean(topo, BooleanOp::Fuse, result, comp_solid)?;
2923    }
2924    Ok(result)
2925}
2926
2927/// Cut a multi-region input solid: split the components, cut each
2928/// against `b` independently, then combine the per-component results
2929/// back into a single multi-region solid.
2930///
2931/// This works around the GFA pavefiller's assumption of a single
2932/// connected input — feeding a 2-piece "solid" into GFA loses one piece
2933/// at a time as the cut proceeds (Category B `multiple cuts creating
2934/// three pieces` and gear bore are both downstream of this).
2935fn cut_multi_region_input(
2936    topo: &mut Topology,
2937    a: SolidId,
2938    b: SolidId,
2939    comp_count: usize,
2940) -> Result<SolidId, crate::OperationsError> {
2941    let components = crate::boolean::assembly::face_components(topo, a);
2942    debug_assert_eq!(components.len(), comp_count);
2943
2944    let mut per_component_results: Vec<SolidId> = Vec::with_capacity(components.len());
2945    for comp_faces in components {
2946        // Copy the component's faces into a fresh single-component solid
2947        // so the boolean engine sees a connected manifold.
2948        let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2949        // Deep-copy the component into a fresh solid so its faces/edges/
2950        // vertices have fresh IDs disjoint from the original multi-region
2951        // input — GFA's pavefiller can stumble on shared vertex IDs across
2952        // what it considers a single "solid A".
2953        let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2954        match boolean(topo, BooleanOp::Cut, comp_solid, b) {
2955            Ok(r) => per_component_results.push(r),
2956            Err(
2957                crate::OperationsError::EmptyResult { .. }
2958                | crate::OperationsError::InvalidInput { .. },
2959            ) => {
2960                per_component_results.push(comp_solid);
2961            }
2962            Err(e) => return Err(e),
2963        }
2964    }
2965
2966    // Combine all per-component results into a single multi-region solid.
2967    // Collect every face from every result into one outer shell. The
2968    // results are pairwise disjoint by construction (each came from a
2969    // disjoint input component cut by the same tool), so a single shell
2970    // containing all their faces is a valid manifold representation.
2971    let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
2972    for &r in &per_component_results {
2973        let r_data = topo.solid(r)?;
2974        for &fid in topo.shell(r_data.outer_shell())?.faces() {
2975            all_faces.push(fid);
2976        }
2977    }
2978    make_solid_from_face_subset(topo, &all_faces)
2979}
2980
2981/// Build a new solid whose outer shell consists exactly of the given
2982/// faces. Faces are referenced as-is (no copying) — the caller is
2983/// expected to pass faces that already form a closed manifold.
2984///
2985/// `reversed=true` faces are NORMALIZED on the way in: a fresh face is
2986/// created with the surface normal negated, the wires reversed, and
2987/// `reversed=false`. Boolean operations downstream are sensitive to the
2988/// `reversed` flag (cut1's output carries reversed faces that GFA can't
2989/// re-process cleanly even via deep-copy), so handing GFA an
2990/// orientation-normalized solid recovers the fresh-primitive code path.
2991fn make_solid_from_face_subset(
2992    topo: &mut Topology,
2993    faces: &[brepkit_topology::face::FaceId],
2994) -> Result<SolidId, crate::OperationsError> {
2995    use brepkit_topology::face::{Face, FaceSurface};
2996    use brepkit_topology::wire::{OrientedEdge, Wire};
2997
2998    let mut normalized: Vec<brepkit_topology::face::FaceId> = Vec::with_capacity(faces.len());
2999    for &fid in faces {
3000        let face = topo.face(fid)?;
3001        if !face.is_reversed() {
3002            normalized.push(fid);
3003            continue;
3004        }
3005        // Only Plane has a trivial negate-the-normal flip. Non-planar
3006        // reversed faces (cylinder/cone/sphere/torus/nurbs) cannot have
3007        // their surface negated cheaply — they hit surface-specific GFA
3008        // paths that don't suffer from the same reversed-flag sensitivity.
3009        // Exhaustive match so a new FaceSurface variant fails to compile
3010        // rather than silently passing through un-normalized.
3011        let flipped_surface = match face.surface() {
3012            FaceSurface::Plane { normal, d } => FaceSurface::Plane {
3013                normal: -*normal,
3014                d: -*d,
3015            },
3016            FaceSurface::Nurbs(_)
3017            | FaceSurface::Cylinder(_)
3018            | FaceSurface::Cone(_)
3019            | FaceSurface::Sphere(_)
3020            | FaceSurface::Torus(_) => {
3021                normalized.push(fid);
3022                continue;
3023            }
3024        };
3025        let outer_wid = face.outer_wire();
3026        let inner_wids: Vec<_> = face.inner_wires().to_vec();
3027        let outer_wire = topo.wire(outer_wid)?;
3028        let outer_reversed: Vec<OrientedEdge> = outer_wire
3029            .edges()
3030            .iter()
3031            .rev()
3032            .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
3033            .collect();
3034        let new_outer_wire =
3035            Wire::new(outer_reversed, true).map_err(crate::OperationsError::Topology)?;
3036        let new_outer_wid = topo.add_wire(new_outer_wire);
3037        let mut new_inner_wids = Vec::with_capacity(inner_wids.len());
3038        for iw in &inner_wids {
3039            let w = topo.wire(*iw)?;
3040            let rev: Vec<OrientedEdge> = w
3041                .edges()
3042                .iter()
3043                .rev()
3044                .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
3045                .collect();
3046            let new_w = Wire::new(rev, true).map_err(crate::OperationsError::Topology)?;
3047            new_inner_wids.push(topo.add_wire(new_w));
3048        }
3049        let new_face = Face::new(new_outer_wid, new_inner_wids, flipped_surface);
3050        normalized.push(topo.add_face(new_face));
3051    }
3052
3053    let shell = brepkit_topology::shell::Shell::new(normalized)
3054        .map_err(crate::OperationsError::Topology)?;
3055    let shell_id = topo.add_shell(shell);
3056    let solid = brepkit_topology::solid::Solid::new(shell_id, Vec::new());
3057    Ok(topo.add_solid(solid))
3058}
3059
3060/// Count inner wire loops across all faces of a solid (outer + inner shells).
3061fn solid_inner_wire_count(topo: &Topology, solid: SolidId) -> Result<i64, crate::OperationsError> {
3062    let mut count: i64 = 0;
3063    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3064        let face = topo.face(fid)?;
3065        #[allow(clippy::cast_possible_wrap)]
3066        {
3067            count += face.inner_wires().len() as i64;
3068        }
3069    }
3070    Ok(count)
3071}
3072
3073/// Genus-aware Euler balance for `components` closed orientable surfaces with
3074/// holed faces.
3075///
3076/// Euler-Poincare over `C` closed components of total genus `G`:
3077/// `V - E + F - L = 2C - 2G`, so the inner-wire surplus `euler - L` is valid
3078/// when it is even and no greater than `2C` — `2C` for all-genus-0 pieces, less
3079/// by two per unit of genus (a thin wall pierced by N through-holes has genus
3080/// N). Odd or `> 2C` surpluses indicate a miscounted shell.
3081///
3082/// The `2C` bound matters as much as the parity: a multi-region result is not
3083/// obliged to be a bag of spheres. A kumiko lattice cut yields RINGS, and a
3084/// closed loop of material is genus 1 (`chi = 0`), so demanding `euler == 2C`
3085/// exactly rejected every lattice result and forced it onto the mesh path.
3086///
3087/// Callers must pair this with a closed-manifold check — the relation only holds
3088/// for closed surfaces.
3089const fn euler_balanced(euler: i64, inner_wires: i64, components: i64) -> bool {
3090    let surplus = euler - inner_wires;
3091    surplus <= components.saturating_mul(2) && surplus % 2 == 0
3092}
3093
3094/// Count edge uses across ALL shells of a solid (outer + inner cavity
3095/// shells). Hollow solids keep cavity faces in inner shells — an
3096/// outer-shell-only walk silently misses their edges, letting open or
3097/// non-manifold cavity shells pass the acceptance gates.
3098fn solid_edge_use_counts(
3099    topo: &Topology,
3100    solid: SolidId,
3101) -> Result<std::collections::HashMap<usize, usize>, crate::OperationsError> {
3102    let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3103    for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3104        let face = topo.face(fid)?;
3105        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3106            let wire = topo.wire(wid)?;
3107            for oe in wire.edges() {
3108                *counts.entry(oe.edge().index()).or_insert(0) += 1;
3109            }
3110        }
3111    }
3112    Ok(counts)
3113}
3114
3115/// Check whether every shell of a solid is a closed manifold: every edge
3116/// is shared by exactly 2 faces within its shell. Returns `false` for open
3117/// shells (boundary edges with count == 1) and non-manifold shells
3118/// (count > 2). Walks inner (cavity) shells as well as the outer shell —
3119/// each shell is an independent closed surface, so a single pooled count
3120/// per shell is correct.
3121///
3122/// Stricter than [`brepkit_topology::validation::validate_shell_manifold`],
3123/// which only rejects edges shared by *more* than two faces.
3124fn is_closed_manifold(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3125    let s = topo.solid(solid)?;
3126    let shell_ids: Vec<_> = std::iter::once(s.outer_shell())
3127        .chain(s.inner_shells().iter().copied())
3128        .collect();
3129    for shell_id in shell_ids {
3130        let shell = topo.shell(shell_id)?;
3131        if !shell_is_closed_manifold(topo, shell)? {
3132            return Ok(false);
3133        }
3134    }
3135    Ok(true)
3136}
3137
3138fn shell_is_closed_manifold(
3139    topo: &Topology,
3140    shell: &brepkit_topology::shell::Shell,
3141) -> Result<bool, crate::OperationsError> {
3142    use std::collections::HashMap;
3143
3144    let mut counts: HashMap<usize, usize> = HashMap::new();
3145    for &fid in shell.faces() {
3146        let face = topo.face(fid)?;
3147        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3148            let wire = topo.wire(wid)?;
3149            for oe in wire.edges() {
3150                *counts.entry(oe.edge().index()).or_insert(0) += 1;
3151            }
3152        }
3153    }
3154    if counts.is_empty() {
3155        return Ok(false);
3156    }
3157    Ok(counts.values().all(|&c| c == 2))
3158}
3159
3160/// Check whether a solid's boundary has free edges: edges used by only
3161/// one wire occurrence. A free edge means the shell is open (e.g. a phantom
3162/// membrane face left a circle edge unmatched), which is never a valid
3163/// boolean result even when Euler accidentally balances.
3164fn has_free_edges(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3165    let counts = solid_edge_use_counts(topo, solid)?;
3166    Ok(counts.values().any(|&c| c == 1))
3167}
3168
3169/// Cheap read-only test for whether [`flatten_planar_nurbs_faces`] would change
3170/// anything: does `solid` carry a planar NURBS face or a straight NURBS edge?
3171/// Used to gate the deep-copy-and-flatten pre-pass so analytic operands are
3172/// passed to the engine unchanged (a needless deep copy renumbers entity ids
3173/// and can perturb the engine's id-keyed ordering on volume-sensitive cuts).
3174///
3175/// `tol` must match the linear tolerance passed to [`flatten_planar_nurbs_faces`]
3176/// so the gate and the pass agree: a looser default here could report "nothing
3177/// to flatten" while the pass (run at the operation tolerance) would in fact
3178/// rewrite geometry, reintroducing the NURBS-vs-plane fragmentation.
3179fn solid_has_flattenable_nurbs(
3180    topo: &Topology,
3181    solid: SolidId,
3182    tol: f64,
3183) -> Result<bool, crate::OperationsError> {
3184    use brepkit_geometry::convert::{
3185        RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3186    };
3187    use brepkit_topology::edge::EdgeCurve;
3188    use brepkit_topology::explorer::solid_faces;
3189
3190    let mut seen = std::collections::HashSet::new();
3191    for fid in solid_faces(topo, solid)? {
3192        let face = topo.face(fid)?;
3193        if let FaceSurface::Nurbs(nurbs) = face.surface()
3194            && matches!(
3195                recognize_surface(nurbs, tol),
3196                RecognizedSurface::Plane { .. }
3197            )
3198        {
3199            return Ok(true);
3200        }
3201        for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3202            let wire = topo.wire(wid)?;
3203            for oe in wire.edges() {
3204                let eid = oe.edge();
3205                if !seen.insert(eid.index()) {
3206                    continue;
3207                }
3208                if let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve()
3209                    && matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. })
3210                {
3211                    return Ok(true);
3212                }
3213            }
3214        }
3215    }
3216    Ok(false)
3217}
3218
3219/// Replace planar NURBS faces of `solid` with analytic `Plane` surfaces, and
3220/// straight NURBS boundary edges with the `Line` variant.
3221///
3222/// A NURBS surface whose every control point lies within `tol` of a single
3223/// plane is geometrically a plane; the tool's rounded-rect extrude emits the
3224/// straight cavity walls as planar B-splines, and the boolean engine's
3225/// face-face intersections only take the exact (same-domain) plane×plane path
3226/// when both operands are `FaceSurface::Plane`. Recognising the flat walls as
3227/// planes before the boolean lets coincident/abutting wall regions merge
3228/// analytically instead of fragmenting through the NURBS surface-intersection
3229/// path.
3230///
3231/// The same extrude also leaves the straight cavity-floor/wall boundary edges
3232/// as NURBS curves. A planar-arrangement splitter treats every non-`Line` edge
3233/// as an arc and bails when one is split mid-edge by a coplanar section, so a
3234/// straight NURBS floor edge crossed by the scoop footprint forces the floor
3235/// face to a self-crossing trace. Recognising those straight NURBS edges as
3236/// `Line` lets the arrangement split them exactly.
3237///
3238/// Genuinely curved NURBS surfaces/edges (and all other analytic geometry) are
3239/// left untouched. Returns the number of faces flattened.
3240fn flatten_planar_nurbs_faces(
3241    topo: &mut Topology,
3242    solid: SolidId,
3243    tol: f64,
3244) -> Result<usize, crate::OperationsError> {
3245    use brepkit_geometry::convert::{
3246        RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3247    };
3248    use brepkit_topology::edge::{EdgeCurve, EdgeId};
3249    use brepkit_topology::explorer::solid_faces;
3250
3251    let face_ids = solid_faces(topo, solid)?;
3252    // Snapshot the surfaces first (immutable borrow), then mutate.
3253    let planar: Vec<(FaceId, Vec3, f64)> = face_ids
3254        .iter()
3255        .filter_map(|&fid| {
3256            let face = topo.face(fid).ok()?;
3257            let FaceSurface::Nurbs(nurbs) = face.surface() else {
3258                return None;
3259            };
3260            match recognize_surface(nurbs, tol) {
3261                RecognizedSurface::Plane { normal, d } => {
3262                    // `recognize_surface` derives the plane normal from a
3263                    // control-point cross product, whose sign can OPPOSE the
3264                    // NURBS surface's own du×dv normal. A `FaceSurface::Plane`
3265                    // is read with its normal flipped by `is_reversed`, so an
3266                    // opposed sign silently inverts the face's effective
3267                    // outward direction. Align to the surface du×dv normal at
3268                    // the domain midpoint.
3269                    let (u0, u1) = nurbs.domain_u();
3270                    let (v0, v1) = nurbs.domain_v();
3271                    let mid_n = nurbs.normal(0.5 * (u0 + u1), 0.5 * (v0 + v1)).ok();
3272                    let (normal, d) = match mid_n {
3273                        Some(n) if normal.dot(n) < 0.0 => (-normal, -d),
3274                        _ => (normal, d),
3275                    };
3276                    Some((fid, normal, d))
3277                }
3278                _ => None,
3279            }
3280        })
3281        .collect();
3282    let count = planar.len();
3283    for (fid, normal, d) in planar {
3284        topo.face_mut(fid)?
3285            .set_surface(FaceSurface::Plane { normal, d });
3286    }
3287
3288    // Straighten NURBS edges that are geometrically lines.
3289    let mut straight_edges: Vec<EdgeId> = Vec::new();
3290    let mut seen = std::collections::HashSet::new();
3291    for &fid in &face_ids {
3292        let face = topo.face(fid)?;
3293        for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3294            let wire = topo.wire(wid)?;
3295            for oe in wire.edges() {
3296                let eid = oe.edge();
3297                if !seen.insert(eid.index()) {
3298                    continue;
3299                }
3300                let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve() else {
3301                    continue;
3302                };
3303                if matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. }) {
3304                    straight_edges.push(eid);
3305                }
3306            }
3307        }
3308    }
3309    for eid in straight_edges {
3310        topo.edge_mut(eid)?.set_curve(EdgeCurve::Line);
3311    }
3312
3313    Ok(count)
3314}
3315
3316/// Test-only access to [`flatten_planar_nurbs_faces`] so integration tests can
3317/// reproduce the exact operand preprocessing the boolean applies before handing
3318/// the operands to the GFA engine.
3319#[doc(hidden)]
3320pub fn flatten_planar_nurbs_faces_for_tests(
3321    topo: &mut Topology,
3322    solid: SolidId,
3323    tol: f64,
3324) -> Result<usize, crate::OperationsError> {
3325    flatten_planar_nurbs_faces(topo, solid, tol)
3326}
3327
3328/// For each vertex position (quantized at tolerance), picks one canonical
3329/// vertex. Rebuilds all edges and wires to use canonical vertices.
3330/// Creates new edges (doesn't mutate existing ones) to avoid corrupting
3331/// input solids that may share edge topology.
3332#[allow(clippy::items_after_statements, clippy::type_complexity)]
3333fn merge_result_vertices(
3334    topo: &mut Topology,
3335    solid: SolidId,
3336    tol: brepkit_math::tolerance::Tolerance,
3337) -> Result<(), crate::OperationsError> {
3338    use std::collections::{BTreeMap, HashMap};
3339
3340    let shell_id = topo.solid(solid)?.outer_shell();
3341    let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3342
3343    let scale = 1.0 / tol.linear;
3344    let quantize = |p: brepkit_math::vec::Point3| -> (i64, i64, i64) {
3345        (
3346            (p.x() * scale).round() as i64,
3347            (p.y() * scale).round() as i64,
3348            (p.z() * scale).round() as i64,
3349        )
3350    };
3351
3352    // Build vertex canonical map: position → first VertexId seen
3353    let mut canonical: BTreeMap<(i64, i64, i64), brepkit_topology::vertex::VertexId> =
3354        BTreeMap::new();
3355    let mut replacements: HashMap<
3356        brepkit_topology::vertex::VertexId,
3357        brepkit_topology::vertex::VertexId,
3358    > = HashMap::new();
3359
3360    for &fid in &face_ids {
3361        let face = topo.face(fid)?;
3362        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3363            let wire = topo.wire(wid)?;
3364            for oe in wire.edges() {
3365                let edge = topo.edge(oe.edge())?;
3366                for vid in [edge.start(), edge.end()] {
3367                    let pos = topo.vertex(vid)?.point();
3368                    let key = quantize(pos);
3369                    let canon = *canonical.entry(key).or_insert(vid);
3370                    if canon != vid {
3371                        replacements.insert(vid, canon);
3372                    }
3373                }
3374            }
3375        }
3376    }
3377
3378    if replacements.is_empty() {
3379        return Ok(());
3380    }
3381
3382    // Rebuild faces with merged vertices
3383    // Cache: (old_edge, new_start, new_end) → new_edge to share edges
3384    let mut edge_cache: HashMap<
3385        (
3386            brepkit_topology::edge::EdgeId,
3387            brepkit_topology::vertex::VertexId,
3388            brepkit_topology::vertex::VertexId,
3389        ),
3390        brepkit_topology::edge::EdgeId,
3391    > = HashMap::new();
3392
3393    // Snapshot face data, then rebuild with merged vertices
3394    struct FaceSnap {
3395        surface: brepkit_topology::face::FaceSurface,
3396        reversed: bool,
3397        outer_oes: Vec<(
3398            brepkit_topology::edge::EdgeId,
3399            bool,
3400            brepkit_topology::edge::EdgeCurve,
3401            brepkit_topology::vertex::VertexId,
3402            brepkit_topology::vertex::VertexId,
3403            Option<f64>, // edge tolerance
3404        )>,
3405        outer_closed: bool,
3406        inner_wires: Vec<(
3407            Vec<(
3408                brepkit_topology::edge::EdgeId,
3409                bool,
3410                brepkit_topology::edge::EdgeCurve,
3411                brepkit_topology::vertex::VertexId,
3412                brepkit_topology::vertex::VertexId,
3413                Option<f64>,
3414            )>,
3415            bool, // wire closed flag
3416        )>,
3417    }
3418
3419    let mut snaps = Vec::with_capacity(face_ids.len());
3420    for &fid in &face_ids {
3421        let face = topo.face(fid)?;
3422        let surface = face.surface().clone();
3423        let reversed = face.is_reversed();
3424        let outer_wire = topo.wire(face.outer_wire())?;
3425        let outer_closed = outer_wire.is_closed();
3426        let outer_oes: Vec<_> = outer_wire
3427            .edges()
3428            .iter()
3429            .map(|oe| -> Result<_, crate::OperationsError> {
3430                let e = topo.edge(oe.edge())?;
3431                Ok((
3432                    oe.edge(),
3433                    oe.is_forward(),
3434                    e.curve().clone(),
3435                    e.start(),
3436                    e.end(),
3437                    e.tolerance(),
3438                ))
3439            })
3440            .collect::<Result<_, _>>()?;
3441        let inner_wids = face.inner_wires().to_vec();
3442        let mut inner_wires = Vec::new();
3443        for iw in inner_wids {
3444            let w = topo.wire(iw)?;
3445            let closed = w.is_closed();
3446            let oes: Vec<_> = w
3447                .edges()
3448                .iter()
3449                .map(|oe| -> Result<_, crate::OperationsError> {
3450                    let e = topo.edge(oe.edge())?;
3451                    Ok((
3452                        oe.edge(),
3453                        oe.is_forward(),
3454                        e.curve().clone(),
3455                        e.start(),
3456                        e.end(),
3457                        e.tolerance(),
3458                    ))
3459                })
3460                .collect::<Result<_, _>>()?;
3461            inner_wires.push((oes, closed));
3462        }
3463        snaps.push(FaceSnap {
3464            surface,
3465            reversed,
3466            outer_oes,
3467            outer_closed,
3468            inner_wires,
3469        });
3470    }
3471
3472    #[allow(clippy::type_complexity)]
3473    let remap_oes = |oes: &[(
3474        brepkit_topology::edge::EdgeId,
3475        bool,
3476        brepkit_topology::edge::EdgeCurve,
3477        brepkit_topology::vertex::VertexId,
3478        brepkit_topology::vertex::VertexId,
3479        Option<f64>,
3480    )],
3481                     replacements: &HashMap<
3482        brepkit_topology::vertex::VertexId,
3483        brepkit_topology::vertex::VertexId,
3484    >,
3485                     edge_cache: &mut HashMap<
3486        (
3487            brepkit_topology::edge::EdgeId,
3488            brepkit_topology::vertex::VertexId,
3489            brepkit_topology::vertex::VertexId,
3490        ),
3491        brepkit_topology::edge::EdgeId,
3492    >,
3493                     topo: &mut Topology|
3494     -> Vec<brepkit_topology::wire::OrientedEdge> {
3495        oes.iter()
3496            .map(|(eid, fwd, curve, start, end, edge_tol)| {
3497                let ns = replacements.get(start).copied().unwrap_or(*start);
3498                let ne = replacements.get(end).copied().unwrap_or(*end);
3499                if ns == *start && ne == *end {
3500                    return brepkit_topology::wire::OrientedEdge::new(*eid, *fwd);
3501                }
3502                let key = (*eid, ns, ne);
3503                let new_eid = *edge_cache.entry(key).or_insert_with(|| {
3504                    topo.add_edge(brepkit_topology::edge::Edge::with_tolerance(
3505                        ns,
3506                        ne,
3507                        curve.clone(),
3508                        *edge_tol,
3509                    ))
3510                });
3511                brepkit_topology::wire::OrientedEdge::new(new_eid, *fwd)
3512            })
3513            .collect()
3514    };
3515
3516    let mut new_face_ids = Vec::with_capacity(snaps.len());
3517    for snap in &snaps {
3518        let outer_oes = remap_oes(&snap.outer_oes, &replacements, &mut edge_cache, topo);
3519        let Ok(outer_wire) = brepkit_topology::wire::Wire::new(outer_oes, snap.outer_closed) else {
3520            // Wire rebuild failed — keep the original face unchanged
3521            // rather than silently dropping it
3522            continue;
3523        };
3524        let outer_id = topo.add_wire(outer_wire);
3525
3526        let mut inner_ids = Vec::new();
3527        for (inner_oes_snap, inner_closed) in &snap.inner_wires {
3528            let oes = remap_oes(inner_oes_snap, &replacements, &mut edge_cache, topo);
3529            if let Ok(w) = brepkit_topology::wire::Wire::new(oes, *inner_closed) {
3530                inner_ids.push(topo.add_wire(w));
3531            }
3532        }
3533
3534        let mut new_face =
3535            brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3536        if snap.reversed {
3537            new_face.set_reversed(true);
3538        }
3539        new_face_ids.push(topo.add_face(new_face));
3540    }
3541
3542    // Replace the shell's faces
3543    let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3544    let new_shell_id = topo.add_shell(new_shell);
3545    let solid_mut = topo.solid_mut(solid)?;
3546    solid_mut.set_outer_shell(new_shell_id);
3547
3548    Ok(())
3549}
3550
3551/// Merge geometrically-coincident duplicate boundary edges on the outer shell.
3552///
3553/// A coincident-junction fuse (e.g. a box stacked on a tapered loft that share
3554/// a cap face) annihilates the shared cap but leaves each argument's faces
3555/// carrying their OWN copy of the junction-wire edges. Because the two copies
3556/// come from independently-built solids their endpoints differ by sub-micron
3557/// numerical noise (loft re-parameterization), so the tight-tolerance vertex
3558/// merge above leaves them as distinct edges — each used once → free edges that
3559/// open the shell.
3560///
3561/// This snaps vertices at `tol_merge` (looser than the default linear
3562/// tolerance, to absorb that noise), then rebuilds every wire against a global
3563/// canonical-edge map keyed by *unordered canonical endpoints + curve type +
3564/// geometric midpoint* — so a straight line and a bulged arc between the same
3565/// endpoints stay distinct, while true duplicates collapse to one shared edge.
3566/// Edges whose endpoints merge to a single vertex (degenerate) are dropped.
3567///
3568/// Returns `true` if anything changed. Run only on already-broken results
3569/// (free edges / non-manifold) so clean booleans keep their exact topology.
3570#[allow(
3571    clippy::too_many_lines,
3572    clippy::type_complexity,
3573    clippy::items_after_statements
3574)]
3575fn unify_coincident_boundary_edges(
3576    topo: &mut Topology,
3577    solid: SolidId,
3578    tol_merge: f64,
3579) -> Result<bool, crate::OperationsError> {
3580    use brepkit_topology::edge::{Edge, EdgeCurve, EdgeId};
3581    use brepkit_topology::vertex::VertexId;
3582    use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
3583    use std::collections::HashMap;
3584
3585    let shell_id = topo.solid(solid)?.outer_shell();
3586    let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3587
3588    let scale = 1.0 / tol_merge;
3589    let q = |p: Point3| -> (i64, i64, i64) {
3590        (
3591            (p.x() * scale).round() as i64,
3592            (p.y() * scale).round() as i64,
3593            (p.z() * scale).round() as i64,
3594        )
3595    };
3596
3597    // 1. Canonical vertex per quantized position (first VertexId seen wins).
3598    let mut vcanon: HashMap<(i64, i64, i64), VertexId> = HashMap::new();
3599    for &fid in &face_ids {
3600        let face = topo.face(fid)?;
3601        for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3602            let wire = topo.wire(wid)?;
3603            for oe in wire.edges() {
3604                let edge = topo.edge(oe.edge())?;
3605                for vid in [edge.start(), edge.end()] {
3606                    let key = q(topo.vertex(vid)?.point());
3607                    vcanon.entry(key).or_insert(vid);
3608                }
3609            }
3610        }
3611    }
3612
3613    // 2. Snapshot each face's wires (edge id, fwd, curve, endpoints, tol).
3614    type OeSnap = (EdgeId, bool, EdgeCurve, VertexId, VertexId, Option<f64>);
3615    struct FaceSnap {
3616        surface: FaceSurface,
3617        reversed: bool,
3618        outer: Vec<OeSnap>,
3619        outer_closed: bool,
3620        inners: Vec<(Vec<OeSnap>, bool)>,
3621    }
3622    let snap_wire =
3623        |topo: &Topology, wid: WireId| -> Result<(Vec<OeSnap>, bool), crate::OperationsError> {
3624            let w = topo.wire(wid)?;
3625            let closed = w.is_closed();
3626            let oes = w
3627                .edges()
3628                .iter()
3629                .map(|oe| -> Result<OeSnap, crate::OperationsError> {
3630                    let e = topo.edge(oe.edge())?;
3631                    Ok((
3632                        oe.edge(),
3633                        oe.is_forward(),
3634                        e.curve().clone(),
3635                        e.start(),
3636                        e.end(),
3637                        e.tolerance(),
3638                    ))
3639                })
3640                .collect::<Result<_, _>>()?;
3641            Ok((oes, closed))
3642        };
3643    let mut snaps = Vec::with_capacity(face_ids.len());
3644    for &fid in &face_ids {
3645        let face = topo.face(fid)?;
3646        let surface = face.surface().clone();
3647        let reversed = face.is_reversed();
3648        let (outer, outer_closed) = snap_wire(topo, face.outer_wire())?;
3649        let mut inners = Vec::new();
3650        for iw in face.inner_wires() {
3651            inners.push(snap_wire(topo, *iw)?);
3652        }
3653        snaps.push(FaceSnap {
3654            surface,
3655            reversed,
3656            outer,
3657            outer_closed,
3658            inners,
3659        });
3660    }
3661
3662    // 3. Rebuild wires against a global canonical-edge map.
3663    //    Key: (lo endpoint q, hi endpoint q, midpoint q, curve type tag).
3664    type EdgeKey = (
3665        (i64, i64, i64),
3666        (i64, i64, i64),
3667        (i64, i64, i64),
3668        &'static str,
3669    );
3670    let mut ecanon: HashMap<EdgeKey, (EdgeId, VertexId, VertexId)> = HashMap::new();
3671    let mut changed = false;
3672
3673    let canon_vid = |topo: &Topology, vid: VertexId| -> Result<VertexId, crate::OperationsError> {
3674        Ok(*vcanon.get(&q(topo.vertex(vid)?.point())).unwrap_or(&vid))
3675    };
3676
3677    let rebuild = |topo: &mut Topology,
3678                   oes: &[OeSnap],
3679                   ecanon: &mut HashMap<EdgeKey, (EdgeId, VertexId, VertexId)>,
3680                   changed: &mut bool|
3681     -> Result<Vec<OrientedEdge>, crate::OperationsError> {
3682        let mut out = Vec::with_capacity(oes.len());
3683        for (eid, fwd, curve, start, end, etol) in oes {
3684            let cs = canon_vid(topo, *start)?;
3685            let ce = canon_vid(topo, *end)?;
3686            if cs == ce {
3687                // Endpoints collapsed to a single vertex → degenerate, drop it.
3688                *changed = true;
3689                continue;
3690            }
3691            let sp = topo.vertex(*start)?.point();
3692            let ep = topo.vertex(*end)?.point();
3693            let (t0, t1) = curve.domain_with_endpoints(sp, ep);
3694            let mid = curve.evaluate_with_endpoints((t0 + t1) * 0.5, sp, ep);
3695            let (cs_q, ce_q) = (q(topo.vertex(cs)?.point()), q(topo.vertex(ce)?.point()));
3696            let (lo, hi) = if cs_q <= ce_q {
3697                (cs_q, ce_q)
3698            } else {
3699                (ce_q, cs_q)
3700            };
3701            let key = (lo, hi, q(mid), curve.type_tag());
3702
3703            // Physical traversal start vertex (after canonicalization).
3704            let trav_start = if *fwd { cs } else { ce };
3705            if let Some(&(c_eid, c_start, _c_end)) = ecanon.get(&key) {
3706                // A duplicate of an already-seen edge → merge onto the keeper.
3707                *changed = true;
3708                out.push(OrientedEdge::new(c_eid, c_start == trav_start));
3709            } else {
3710                // First edge with this key. Reuse the original edge when its
3711                // endpoints didn't move; only allocate (and flag a change) when
3712                // a vertex was snapped — so an already-clean shell is a no-op.
3713                let (eid_use, e_start) = if cs == *start && ce == *end {
3714                    (*eid, *start)
3715                } else {
3716                    *changed = true;
3717                    (
3718                        topo.add_edge(Edge::with_tolerance(cs, ce, curve.clone(), *etol)),
3719                        cs,
3720                    )
3721                };
3722                ecanon.insert(key, (eid_use, e_start, ce));
3723                out.push(OrientedEdge::new(eid_use, e_start == trav_start));
3724            }
3725        }
3726        Ok(out)
3727    };
3728
3729    let mut new_face_ids = Vec::with_capacity(snaps.len());
3730    for snap in &snaps {
3731        let outer_oes = rebuild(topo, &snap.outer, &mut ecanon, &mut changed)?;
3732        let Ok(outer_wire) = Wire::new(outer_oes, snap.outer_closed) else {
3733            // Keep original face if the rebuilt wire is invalid.
3734            return Ok(false);
3735        };
3736        let outer_id = topo.add_wire(outer_wire);
3737        let mut inner_ids = Vec::new();
3738        for (inner_oes, inner_closed) in &snap.inners {
3739            let oes = rebuild(topo, inner_oes, &mut ecanon, &mut changed)?;
3740            let Ok(w) = Wire::new(oes, *inner_closed) else {
3741                // A dropped hole silently changes topology (and removes free
3742                // edges, so the downstream gate can't catch it). Bail like the
3743                // outer-wire case, leaving the original solid untouched.
3744                return Ok(false);
3745            };
3746            inner_ids.push(topo.add_wire(w));
3747        }
3748        let mut new_face =
3749            brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3750        if snap.reversed {
3751            new_face.set_reversed(true);
3752        }
3753        new_face_ids.push(topo.add_face(new_face));
3754    }
3755
3756    if !changed {
3757        return Ok(false);
3758    }
3759
3760    let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3761    let new_shell_id = topo.add_shell(new_shell);
3762    topo.solid_mut(solid)?.set_outer_shell(new_shell_id);
3763    Ok(true)
3764}
3765
3766/// Post-process a solid to enforce manifold topology via greedy flood-fill.
3767///
3768/// Detects non-manifold edges (shared by 3+ faces) and uses greedy
3769/// shell building to split the non-manifold shell into manifold
3770/// sub-shells. The largest sub-shell becomes the outer shell; smaller ones
3771/// become inner shells (cavities).
3772///
3773/// If the solid is already manifold, returns it unchanged.
3774#[allow(clippy::too_many_lines)]
3775fn enforce_manifold_shell(
3776    topo: &mut Topology,
3777    solid: SolidId,
3778) -> Result<SolidId, crate::OperationsError> {
3779    use std::collections::{HashMap, HashSet, VecDeque};
3780
3781    let shell_id = topo.solid(solid)?.outer_shell();
3782    let face_ids = topo.shell(shell_id)?.faces().to_vec();
3783
3784    // Count edges per face.
3785    let mut edge_face_count: HashMap<usize, u32> = HashMap::new();
3786    for &fid in &face_ids {
3787        if let Ok(face) = topo.face(fid) {
3788            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3789            {
3790                if let Ok(wire) = topo.wire(wid) {
3791                    for oe in wire.edges() {
3792                        *edge_face_count.entry(oe.edge().index()).or_default() += 1;
3793                    }
3794                }
3795            }
3796        }
3797    }
3798
3799    // Only apply for significant non-manifold (>3 edges). Minor non-manifold
3800    // (1-3 edges) from sphere/cone intersections is tolerable and splitting
3801    // the shell at those edges breaks downstream operations (section, volume).
3802    let nm_count = edge_face_count.values().filter(|&&c| c > 2).count();
3803    if nm_count <= 3 {
3804        return Ok(solid);
3805    }
3806
3807    log::debug!(
3808        "enforce_manifold_shell: {} non-manifold edges in {} faces",
3809        nm_count,
3810        face_ids.len()
3811    );
3812
3813    // Build vertex-pair → face adjacency for neighbor discovery.
3814    let mut vpair_faces: HashMap<(usize, usize), Vec<brepkit_topology::face::FaceId>> =
3815        HashMap::new();
3816    for &fid in &face_ids {
3817        if let Ok(face) = topo.face(fid) {
3818            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3819            {
3820                if let Ok(wire) = topo.wire(wid) {
3821                    for oe in wire.edges() {
3822                        if let Ok(e) = topo.edge(oe.edge()) {
3823                            let si = e.start().index();
3824                            let ei = e.end().index();
3825                            let key = if si <= ei { (si, ei) } else { (ei, si) };
3826                            vpair_faces.entry(key).or_default().push(fid);
3827                        }
3828                    }
3829                }
3830            }
3831        }
3832    }
3833
3834    // Greedy flood-fill shell construction.
3835    let available: HashSet<brepkit_topology::face::FaceId> = face_ids.iter().copied().collect();
3836    let mut processed: HashSet<brepkit_topology::face::FaceId> = HashSet::new();
3837    let mut shells: Vec<Vec<brepkit_topology::face::FaceId>> = Vec::new();
3838
3839    for &start_face in &face_ids {
3840        if processed.contains(&start_face) {
3841            continue;
3842        }
3843
3844        let mut shell_faces = vec![start_face];
3845        processed.insert(start_face);
3846
3847        // Track edge-ID usage within this shell.
3848        let mut shell_edge_count: HashMap<usize, u32> = HashMap::new();
3849        if let Ok(face) = topo.face(start_face) {
3850            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3851            {
3852                if let Ok(wire) = topo.wire(wid) {
3853                    for oe in wire.edges() {
3854                        *shell_edge_count.entry(oe.edge().index()).or_default() += 1;
3855                    }
3856                }
3857            }
3858        }
3859
3860        let mut queue = VecDeque::new();
3861        queue.push_back(start_face);
3862
3863        while let Some(current) = queue.pop_front() {
3864            let Ok(face) = topo.face(current) else {
3865                continue;
3866            };
3867            // Collect (vpair, edge_id) from all wires.
3868            let mut all_edges = Vec::new();
3869            for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3870            {
3871                if let Ok(wire) = topo.wire(wid) {
3872                    for oe in wire.edges() {
3873                        if let Ok(e) = topo.edge(oe.edge()) {
3874                            let si = e.start().index();
3875                            let ei = e.end().index();
3876                            let key = if si <= ei { (si, ei) } else { (ei, si) };
3877                            all_edges.push((key, oe.edge()));
3878                        }
3879                    }
3880                }
3881            }
3882
3883            for (vpair, edge_id) in all_edges {
3884                let eidx = edge_id.index();
3885
3886                // Skip edges already manifold in this shell.
3887                if shell_edge_count.get(&eidx).copied().unwrap_or(0) >= 2 {
3888                    continue;
3889                }
3890
3891                // Find candidate neighbor faces via vertex-pair.
3892                let candidates: Vec<brepkit_topology::face::FaceId> = vpair_faces
3893                    .get(&vpair)
3894                    .map(|fs| {
3895                        fs.iter()
3896                            .copied()
3897                            .filter(|&f| {
3898                                f != current && available.contains(&f) && !processed.contains(&f)
3899                            })
3900                            .collect()
3901                    })
3902                    .unwrap_or_default();
3903
3904                if candidates.is_empty() {
3905                    continue;
3906                }
3907
3908                // Pick first candidate (simple heuristic — dihedral selection
3909                // would be better but requires surface normal evaluation).
3910                let selected = candidates[0];
3911
3912                if processed.contains(&selected) {
3913                    continue;
3914                }
3915
3916                processed.insert(selected);
3917                shell_faces.push(selected);
3918                queue.push_back(selected);
3919
3920                // Update edge count.
3921                if let Ok(sel_face) = topo.face(selected) {
3922                    for wid in std::iter::once(sel_face.outer_wire())
3923                        .chain(sel_face.inner_wires().iter().copied())
3924                    {
3925                        if let Ok(wire) = topo.wire(wid) {
3926                            for sel_oe in wire.edges() {
3927                                *shell_edge_count.entry(sel_oe.edge().index()).or_default() += 1;
3928                            }
3929                        }
3930                    }
3931                }
3932            }
3933        }
3934
3935        shells.push(shell_faces);
3936    }
3937
3938    // Add any unprocessed faces to a final shell.
3939    let remaining: Vec<brepkit_topology::face::FaceId> = available
3940        .iter()
3941        .filter(|f| !processed.contains(f))
3942        .copied()
3943        .collect();
3944    if !remaining.is_empty() {
3945        shells.push(remaining);
3946    }
3947
3948    if shells.len() <= 1 {
3949        // Single shell — nothing to split.
3950        return Ok(solid);
3951    }
3952
3953    log::debug!(
3954        "enforce_manifold_shell: split into {} shells (sizes: {:?})",
3955        shells.len(),
3956        shells.iter().map(Vec::len).collect::<Vec<_>>(),
3957    );
3958
3959    // Build the solid: largest shell is outer, rest are inner.
3960    let mut best_idx = 0;
3961    let mut best_count = 0;
3962    for (i, faces) in shells.iter().enumerate() {
3963        if faces.len() > best_count {
3964            best_count = faces.len();
3965            best_idx = i;
3966        }
3967    }
3968
3969    let outer = brepkit_topology::shell::Shell::new(shells[best_idx].clone())
3970        .map_err(crate::OperationsError::Topology)?;
3971    let outer_id = topo.add_shell(outer);
3972    let mut inner_ids = Vec::new();
3973    for (i, faces) in shells.iter().enumerate() {
3974        if i != best_idx
3975            && !faces.is_empty()
3976            && let Ok(inner) = brepkit_topology::shell::Shell::new(faces.clone())
3977        {
3978            inner_ids.push(topo.add_shell(inner));
3979        }
3980    }
3981
3982    Ok(topo.add_solid(brepkit_topology::solid::Solid::new(outer_id, inner_ids)))
3983}
3984
3985/// Sample `n` evenly-spaced points along a closed edge curve.
3986///
3987/// For `Circle` and `Ellipse`, samples at `TAU * i / n`.
3988/// For closed `NurbsCurve`, samples across the domain avoiding endpoint
3989/// duplication. Returns an empty vec for `Line` (no sampling possible).
3990pub(crate) fn sample_edge_curve(curve: &EdgeCurve, n: usize) -> Vec<Point3> {
3991    match curve {
3992        EdgeCurve::Circle(c) => (0..n)
3993            .map(|i| {
3994                #[allow(clippy::cast_precision_loss)]
3995                let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3996                c.evaluate(t)
3997            })
3998            .collect(),
3999        EdgeCurve::Ellipse(e) => (0..n)
4000            .map(|i| {
4001                #[allow(clippy::cast_precision_loss)]
4002                let t = std::f64::consts::TAU * (i as f64) / (n as f64);
4003                e.evaluate(t)
4004            })
4005            .collect(),
4006        EdgeCurve::NurbsCurve(nc) => {
4007            let (u0, u1) = nc.domain();
4008            // For closed curves (start ~ end), use n as divisor to avoid
4009            // duplicating the first point at t=u_max.
4010            let start_pt = nc.evaluate(u0);
4011            let end_pt = nc.evaluate(u1);
4012            // 1e-6 m: closure detection threshold — if start and end points are
4013            // within 1 micron, treat the NURBS curve as closed to avoid
4014            // duplicating the first point at t=u_max.
4015            let is_closed = (start_pt - end_pt).length() < 1e-6;
4016            let divisor = if is_closed { n } else { n - 1 };
4017            (0..n)
4018                .map(|i| {
4019                    #[allow(clippy::cast_precision_loss)]
4020                    let t = u0 + (u1 - u0) * (i as f64) / (divisor as f64);
4021                    nc.evaluate(t)
4022                })
4023                .collect()
4024        }
4025        EdgeCurve::Line => vec![],
4026    }
4027}
4028
4029/// Get a polygon approximation of a face by sampling curved edges.
4030///
4031/// Samples circle/ellipse edges into 32 points so faces with a
4032/// single closed-curve edge (e.g. cylinder caps) get a proper polygon.
4033///
4034/// # Errors
4035///
4036/// Returns an error if the face or its wire cannot be resolved.
4037pub fn face_polygon(
4038    topo: &Topology,
4039    face_id: FaceId,
4040) -> Result<Vec<Point3>, crate::OperationsError> {
4041    let face = topo.face(face_id)?;
4042    let wire = topo.wire(face.outer_wire())?;
4043    let mut pts = Vec::new();
4044
4045    for oe in wire.edges() {
4046        let edge = topo.edge(oe.edge())?;
4047        let curve = edge.curve();
4048        // Sample closed parametric edges (start == end vertex).
4049        // Partial arcs fall through to the vertex-based path.
4050        let start_vid = edge.start();
4051        let end_vid = edge.end();
4052        let is_closed_edge = start_vid == end_vid
4053            && matches!(
4054                curve,
4055                EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) | EdgeCurve::NurbsCurve(_)
4056            );
4057        if is_closed_edge {
4058            // Must use CLOSED_CURVE_SAMPLES (not a larger value) — vertex count
4059            // must match create_band_fragments and inner-wire dedup for sharing.
4060            let mut sampled = sample_edge_curve(curve, types::CLOSED_CURVE_SAMPLES);
4061            if !oe.is_forward() {
4062                sampled.reverse();
4063            }
4064            pts.extend(sampled);
4065        } else {
4066            let vid = oe.oriented_start(edge);
4067            pts.push(topo.vertex(vid)?.point());
4068        }
4069    }
4070
4071    Ok(pts)
4072}
4073
4074/// Collect face signatures (index, normal, centroid) for evolution tracking.
4075///
4076/// For each face of the solid, computes a representative normal and centroid
4077/// from the face polygon. Used by [`boolean_with_evolution`] to match output
4078/// faces back to input faces.
4079///
4080/// # Errors
4081///
4082/// Returns an error if any face or wire cannot be resolved.
4083/// Snapshot each outer-shell face as `(index, face normal, centroid)` — the
4084/// signature [`crate::evolution::build_evolution_by_geometry`] matches on. The
4085/// normal is the stored plane normal (or a polygon-derived normal for
4086/// non-planar faces), not re-oriented by the face's `reversed` flag; matching
4087/// stays consistent because input and output faces use the same convention.
4088pub fn collect_face_signatures(
4089    topo: &Topology,
4090    solid_id: SolidId,
4091) -> Result<Vec<(usize, Vec3, Point3)>, crate::OperationsError> {
4092    let solid = topo.solid(solid_id)?;
4093    let shell = topo.shell(solid.outer_shell())?;
4094    let mut result = Vec::with_capacity(shell.faces().len());
4095
4096    for &fid in shell.faces() {
4097        let face = topo.face(fid)?;
4098        let verts = face_polygon(topo, fid)?;
4099        let normal = if let FaceSurface::Plane { normal, .. } = face.surface() {
4100            *normal
4101        } else if verts.len() >= 3 {
4102            let e1 = verts[1] - verts[0];
4103            let e2 = verts[2] - verts[0];
4104            e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0))
4105        } else {
4106            Vec3::new(0.0, 0.0, 1.0)
4107        };
4108
4109        let centroid = classify::polygon_centroid(&verts);
4110        result.push((fid.index(), normal, centroid));
4111    }
4112
4113    Ok(result)
4114}
4115
4116#[cfg(test)]
4117mod tests;