Skip to main content

axiolid_mesh_boolean_boolmesh/
provider.rs

1//! The `MeshBoolean` implementation.
2//!
3//! # Whose fault an error is
4//!
5//! Input faults are the caller's: an operand that fails the orientation
6//! gate (`InvalidInput`, `Degenerate`, naming which argument) or that the
7//! kernel cannot build as a manifold (`NotManifold`). A result that fails
8//! [`check_result`] is `BackendContractViolation`: the inputs were already
9//! validated, so a bad result is this provider's defect, never blamed on
10//! the caller. A failure inside the kernel's own solve (for example an odd
11//! edge-point count in `pair_up`, #101) is `BackendContractViolation` for
12//! the same reason: the operands passed every input gate, so the solve
13//! failing on them is this provider's defect.
14
15use std::borrow::Cow;
16
17use crate::csg::{compute_boolean, OpType, SolveError};
18use axiolid_contracts::{
19    Backend, BackendDescriptor, BackendId, CancellationGranularity, Determinism, ExecutionOptions,
20    ExecutionTarget, GeomError, GeomResult, ScratchRequirement,
21};
22use axiolid_core::BooleanOperator;
23use axiolid_mesh::{AttributeChannel, AttributeFate, Blend, DropReason, TriMesh};
24use axiolid_mesh_boolean_contract::{merge_fates, BooleanEvidence, BooleanOutcome, MeshBoolean};
25
26use crate::attributes::{carry, sources_by_plane, FaceSource};
27use crate::convert::{from_boolean_mesh, six_signed_volume, to_manifold};
28
29/// Mesh boolean built on the algorithm absorbed from `boolmesh` (pure Rust,
30/// `glam`-only, MPL-2.0).
31///
32/// Adopted in `docs/adr/0014` and absorbed into this crate's private `csg`
33/// module by `docs/adr/0047`. This type owns conversion and contract
34/// enforcement; no `csg` type is part of the public API, so the kernel can
35/// be replaced without a consumer noticing.
36#[derive(Debug, Clone, Copy, Default)]
37pub struct BoolmeshBoolean;
38
39/// Fixed scratch of one boolean; see [`MeshBoolean::scratch_requirement`].
40pub(crate) const SCRATCH_BASE_BYTES: usize = 64 * 1024;
41/// Scratch per input triangle, all operands together.
42pub(crate) const SCRATCH_BYTES_PER_TRIANGLE: usize = 1536;
43/// A rayon worker's bookkeeping and the work stolen onto it.
44#[cfg(any(feature = "parallel", feature = "parallel-batch"))]
45const WORKER_BYTES: usize = 16 * 1024;
46#[cfg(not(any(feature = "parallel", feature = "parallel-batch")))]
47const WORKER_BYTES: usize = 0;
48/// Per worker thread. The booleans a batch runs concurrently are charged by
49/// the batch, per boolean (`budget.rs`), not here: stolen work can put more
50/// of them in flight than there are workers (#259).
51pub(crate) const SCRATCH_BYTES_PER_WORKER: usize = WORKER_BYTES;
52
53impl BoolmeshBoolean {
54    /// Stable identifier used in errors and explicit provider selection.
55    pub const ID: BackendId = BackendId::new("boolmesh");
56
57    /// Construct the provider.
58    pub const fn new() -> Self {
59        Self
60    }
61
62    /// Subtract axis-aligned box cutters from an axis-aligned box subject using
63    /// an exact closed-form construction, bypassing the general solver.
64    ///
65    /// Returns `Ok(None)` when the operands are outside this path's competence,
66    /// so the caller falls back to [`MeshBoolean::subtract_many`] rather than
67    /// receiving an approximate answer. Declining cases:
68    ///
69    /// * subject or any tool is not an axis-aligned box (recognised
70    ///   structurally -- triangle count, corner lattice, and face planes -- not
71    ///   by bounding box, since every mesh has one of those)
72    /// * no tools, or no tool overlapping the subject
73    /// * the induced grid would exceed `max_cells`
74    ///
75    /// # Why opt-in rather than automatic
76    ///
77    /// Dispatching on shape automatically would make output topology depend on
78    /// input in a way the caller cannot predict: the same wall would produce
79    /// different triangle counts depending on whether its openings happened to
80    /// be axis-aligned. Callers that want the speed ask for it and handle the
81    /// `None`; callers that want one predictable code path never see this.
82    ///
83    /// # Guarantees
84    ///
85    /// The result is watertight by construction (adjacent cells address shared
86    /// grid vertices by integer index) and deterministic across processes
87    /// (vertex identity is an ordered map, not a randomly-seeded hash map).
88    ///
89    /// The returned evidence has [`BooleanEvidence::analytic_path`] set, so a
90    /// caller can tell this result apart from a general-solver result.
91    pub fn subtract_boxes_analytic(
92        &self,
93        subject: &TriMesh,
94        tools: &[TriMesh],
95        options: &ExecutionOptions,
96        max_cells: usize,
97    ) -> GeomResult<Option<BooleanOutcome>> {
98        options.check_cancelled()?;
99
100        // Tolerance is scale-relative: an absolute epsilon would reject a
101        // building-scale box and accept a millimetre-scale non-box.
102        let d = subject.bounds().diagonal();
103        let eps = d.x.max(d.y).max(d.z) * 1e-9;
104
105        let Some(host) = crate::box_detect::recognise(subject, eps) else {
106            return Ok(None);
107        };
108        let mut cutters = Vec::with_capacity(tools.len());
109        for tool in tools {
110            let Some(b) = crate::box_detect::recognise(tool, eps) else {
111                return Ok(None);
112            };
113            cutters.push(b);
114        }
115
116        let Some(mut result) = crate::cellular::subtract_boxes(&host, &cutters, max_cells) else {
117            return Ok(None);
118        };
119
120        // The analytic path is exact, but it is still a provider result: hold it
121        // to the same orientation contract as the general path rather than
122        // trusting the construction.
123        check_result(&result, BooleanOperator::Difference)?;
124
125        let tool_refs: Vec<&TriMesh> = tools.iter().collect();
126        let fates = carry_analytic(subject, tools, &mut result);
127        let evidence = evidence_for(subject, &tool_refs, &result, 1)
128            .with_analytic_path(true)
129            .with_attribute_fates(fates);
130        Ok(Some(BooleanOutcome::new(result, evidence)))
131    }
132}
133
134impl Backend for BoolmeshBoolean {
135    fn descriptor(&self) -> BackendDescriptor {
136        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
137    }
138}
139
140/// Verify the result against the contract before handing it back.
141///
142/// `boolmesh` reports its own manifoldness; we additionally check orientation,
143/// because a returned inside-out solid would poison every subsequent operation
144/// in a subtract chain. Blamed on the backend, not the caller: the inputs were
145/// already validated on the way in.
146fn check_result(result: &TriMesh, operation: BooleanOperator) -> GeomResult<()> {
147    // An empty result is legitimate: subtracting a tool that fully contains the
148    // subject leaves nothing. Orientation is undefined for it, so stop here.
149    //
150    // Removing this early return is behaviour-preserving (an empty mesh sums to
151    // zero, not a negative), so no test can distinguish it. It is kept because
152    // it states the intent at the point of the decision.
153    if result.indices.is_empty() {
154        return Ok(());
155    }
156    let six_volume = six_signed_volume(&result.positions, &result.indices);
157    if !six_volume.is_finite() {
158        return Err(GeomError::BackendContractViolation {
159            backend: BoolmeshBoolean::ID,
160            detail: format!("{operation:?} returned non-finite signed volume"),
161        });
162    }
163    if six_volume < 0.0 {
164        return Err(GeomError::BackendContractViolation {
165            backend: BoolmeshBoolean::ID,
166            detail: format!(
167                "{operation:?} returned an inside-out solid (signed volume {:.6})",
168                six_volume / 6.0
169            ),
170        });
171    }
172    Ok(())
173}
174
175impl MeshBoolean for BoolmeshBoolean {
176    /// Honest about the general path, which is not byte-reproducible.
177    ///
178    /// Upstream `boolmesh` dedups vertices through a randomly-seeded
179    /// `HashMap`, so its output ordering varies between processes even
180    /// single-threaded. That is the same root cause documented in
181    /// [`Self::subtract_boxes_analytic`], which avoids it with a `BTreeMap`. The general
182    /// path therefore cannot claim [`Determinism::Bitwise`] at any thread
183    /// count, and enabling `parallel` does not make it weaker than it
184    /// already is.
185    ///
186    /// [`Determinism::Topological`] is the honest ceiling: the result's
187    /// connectivity is stable, its vertex ordering is not. Callers needing
188    /// byte reproducibility use [`Self::subtract_boxes_analytic`],
189    /// whose ordered vertex identity makes it reproducible across processes.
190    fn determinism(&self) -> Determinism {
191        Determinism::Topological
192    }
193
194    /// Measured, not guessed: [`ScratchRequirement::Affine`] over the input
195    /// triangles of all operands and the worker threads the call runs on.
196    ///
197    /// `boolmesh` builds a Morton collider and intersection tables sized by the
198    /// combined input. It exposes no bound, so this was measured directly with
199    /// a counting global allocator (`src/bin/scratch_probe/`) across all four
200    /// operations at 24 to 1,536 input triangles, after one discarded warmup
201    /// call so the first operation measured is not charged for process
202    /// startup (#110). With rayon each boolean runs in a pool started inside
203    /// the measured window, with the join halves forced onto other workers
204    /// (#226; the module docs of `scratch.rs` say how). Worst peak in bytes
205    /// over the four operations and ten runs with `parallel`, six on an idle
206    /// 20-core machine and four at load average 20 to 43:
207    ///
208    /// ```text
209    ///  triangles   1 worker   4 workers   16 workers   64 workers
210    ///         24      59752       65656       160136       471736
211    ///         96     108232      112152       193504       460672
212    ///        384     348792      357000       422472       680384
213    ///       1536    1354692     1362900      1411548      1702180
214    /// ```
215    ///
216    /// Three terms, each with its own evidence:
217    ///
218    /// - Per triangle, 1,536 bytes. The slope is linear and flat at about
219    ///   880 bytes per triangle from 384 up to 24,576 triangles (one worker,
220    ///   checked once at the two larger sizes), so this is 1.7x it.
221    ///   `SymmetricDifference` is the worst operation because it is composed
222    ///   from three passes and holds intermediates alive.
223    /// - Base, 64 KiB: fixed setup that dominates small inputs. 24 triangles
224    ///   peak at 59,752 bytes on one worker, 22 KiB above the per-triangle
225    ///   term, so the base is 2.9x that excess.
226    /// - Per worker, 16 KiB with `parallel`. A rayon worker's own
227    ///   bookkeeping (deque, registry slot, thread-locals) is 4.8 KB at most
228    ///   from 1 to 512 threads, and it is charged to whichever boolean runs
229    ///   while the worker starts, which on a loaded machine can be any of
230    ///   them. Work stolen onto it adds its scratch alongside the rest. The
231    ///   measured excess over one worker is at most 6.7 KB per extra
232    ///   worker, idle or loaded, so this is 2.4x it. With `parallel-batch`
233    ///   alone the batch's pool workers pay the same bookkeeping. Without
234    ///   either feature nothing runs off the calling thread and the term is
235    ///   zero.
236    ///
237    /// # The result is charged in the solve (#308)
238    ///
239    /// This counts input triangles only, so it is the admission bound on
240    /// the operands, not a bound on the call. Operands that cross many
241    /// times have a far larger result: two perpendicular combs of 32 teeth,
242    /// 1,048 triangles, cross 17,716 times, and their union peaked at 14.7
243    /// MB, 8.8x this declaration (52.9 MB, 31x, for the symmetric
244    /// difference). With a memory budget the solve therefore charges the
245    /// result once its size is known: after the intersection pass,
246    /// `boolean45` counts the result's half-edges exactly and refuses with
247    /// `BudgetExceeded` before allocating the result when this declaration
248    /// plus 448 bytes per half-edge beyond three per input triangle does not
249    /// fit. Ordinary results stay within the three and are charged nothing
250    /// more; the intersection pass stops as soon as its crossings alone
251    /// could not fit. A budget that admits this declaration can still be
252    /// refused, so, as for batches, the check before dispatch is a
253    /// necessary condition, not a sufficient one. A composed
254    /// `SymmetricDifference` charges each step the union and intersection
255    /// it holds. `src/budget.rs` gives the measurements.
256    ///
257    /// # Batches
258    ///
259    /// The bound covers each boolean of [`MeshBoolean::subtract_many`] and
260    /// [`MeshBoolean::union_many`] on that step's own operands, not the batch
261    /// in its input triangles: intermediates can outgrow the inputs, and
262    /// with `parallel-batch` stolen work can put every pair of a level in
263    /// flight at once (#259). With a memory budget the batch charges each
264    /// step when its operands exist (its own copies and intermediates, plus
265    /// this bound for every boolean that can be live) and refuses with
266    /// `BudgetExceeded` before the first step that does not fit, so the
267    /// check before dispatch is a necessary condition for a batch, not a
268    /// sufficient one. Each boolean then gets the budget the batch does not
269    /// hold (split evenly between the pairs of a `parallel-batch` level)
270    /// and charges its result against it (#308). `tests/scratch_bound.rs`
271    /// holds the peak of both batches under forced schedules to the
272    /// smallest budget they admit.
273    ///
274    /// The old `PerElement { bytes_per_element: 4096 }` was 2x the large-input
275    /// slope but no bound at all for small inputs on a pool: 24 triangles
276    /// on 16 workers peak at 125,688 to 160,136 bytes against its 98,304
277    /// (#226).
278    /// `tests/scratch_bound.rs` measures the same workloads on 1 to 64
279    /// workers and fails if any peak exceeds this. A declared bound that is
280    /// occasionally too low is worse than `Unbounded`, because it makes a
281    /// budget look enforced when it is not.
282    ///
283    /// The worker count is only known where the call runs, so with a memory
284    /// budget every boolean re-checks this bound against the pool it is in
285    /// and refuses with `BudgetExceeded` when that pool is wider than the
286    /// budget check before dispatch assumed. The re-check runs in every
287    /// build, so a budget given to the provider directly, without dispatch,
288    /// is enforced too.
289    fn scratch_requirement(&self) -> ScratchRequirement {
290        ScratchRequirement::Affine {
291            base_bytes: SCRATCH_BASE_BYTES,
292            bytes_per_element: SCRATCH_BYTES_PER_TRIANGLE,
293            bytes_per_worker: SCRATCH_BYTES_PER_WORKER,
294        }
295    }
296
297    /// `boolmesh` takes no cancellation handle, so nothing can interrupt a
298    /// single boolean once it starts. Declared honestly: the batch override
299    /// polls between groups, which is the only real poll point available.
300    fn cancellation_granularity(&self) -> CancellationGranularity {
301        CancellationGranularity::BetweenOperations
302    }
303
304    /// Group mutually disjoint cutters and remove each group with one
305    /// boolean, instead of one boolean per cutter.
306    ///
307    /// Rests on `(S \ A) \ B == S \ (A union B)` and on a concatenation of
308    /// disjoint solids being their union. Bounding-box grouping over-separates
309    /// but never wrongly fuses, so the result is identical to the sequential
310    /// default -- gated by volume equality in `tests/batch.rs`.
311    fn subtract_many(
312        &self,
313        subject: &TriMesh,
314        tools: &[TriMesh],
315        options: &ExecutionOptions,
316    ) -> GeomResult<BooleanOutcome> {
317        self.subtract_grouped(subject, tools, options)
318    }
319
320    fn union_many(
321        &self,
322        solids: &[TriMesh],
323        options: &ExecutionOptions,
324    ) -> GeomResult<BooleanOutcome> {
325        self.union_tree(solids, options)
326    }
327
328    fn boolean(
329        &self,
330        subject: &TriMesh,
331        tool: &TriMesh,
332        operation: BooleanOperator,
333        options: &ExecutionOptions,
334    ) -> GeomResult<BooleanOutcome> {
335        self.boolean_within(
336            subject,
337            tool,
338            operation,
339            options,
340            false,
341            options.memory_budget_bytes(),
342        )
343    }
344}
345
346impl BoolmeshBoolean {
347    /// Alternative to [`MeshBoolean::boolean`] using a cheaper winding-number
348    /// classification for the same result.
349    ///
350    /// See `csg::boolean03::kernel03::winding03_fast`'s doc comment for the
351    /// guarantee: not an approximation, but a bug in edge-break detection
352    /// would mislabel a whole connected component instead of one vertex.
353    /// Opt-in rather than the default until this has run against a wider
354    /// correctness corpus than the differential tests already covering it.
355    ///
356    /// `SymmetricDifference` is refused rather than silently composed from
357    /// three slow-path calls: a caller asking for the fast path should get
358    /// it or a clear refusal, not a surprise fallback.
359    pub fn boolean_fast(
360        &self,
361        subject: &TriMesh,
362        tool: &TriMesh,
363        operation: BooleanOperator,
364        options: &ExecutionOptions,
365    ) -> GeomResult<BooleanOutcome> {
366        if operation == BooleanOperator::SymmetricDifference {
367            return Err(GeomError::Unsupported {
368                backend: BoolmeshBoolean::ID,
369                operation: axiolid_contracts::Operation::MeshBoolean,
370            });
371        }
372        self.boolean_within(
373            subject,
374            tool,
375            operation,
376            options,
377            true,
378            options.memory_budget_bytes(),
379        )
380    }
381
382    /// One boolean with `budget` bytes for it alone (the caller's budget
383    /// less whatever the caller holds), or none.
384    ///
385    /// Admitted on the declaration for the pool this call runs on (#226),
386    /// then charged for its result inside the solve (#308, `budget.rs`).
387    fn boolean_within(
388        &self,
389        subject: &TriMesh,
390        tool: &TriMesh,
391        operation: BooleanOperator,
392        options: &ExecutionOptions,
393        fast_winding: bool,
394        budget: Option<usize>,
395    ) -> GeomResult<BooleanOutcome> {
396        let elements = subject.triangle_count() + tool.triangle_count();
397        let result_budget = crate::budget::ResultBudget::admit(budget, elements)?;
398        // `SymmetricDifference` has no `boolmesh` counterpart. Compose it from
399        // the three primitives rather than pretending the backend's operation
400        // set is the contract's; `sub_operations` in the evidence records that
401        // the caller got a composed result.
402        if operation == BooleanOperator::SymmetricDifference {
403            return self.symmetric_difference(subject, tool, options, budget);
404        }
405
406        let op = match operation {
407            BooleanOperator::Union => OpType::Add,
408            BooleanOperator::Intersection => OpType::Intersect,
409            BooleanOperator::Difference => OpType::Subtract,
410            BooleanOperator::SymmetricDifference => unreachable!("composed above"),
411            // A future operand added to the contract. Refusing is honest and
412            // lets the registry fall back to a provider that supports it.
413            _ => {
414                return Err(GeomError::Unsupported {
415                    backend: BoolmeshBoolean::ID,
416                    operation: axiolid_contracts::Operation::MeshBoolean,
417                })
418            }
419        };
420        options.check_cancelled()?;
421        let subject_manifold = to_manifold(subject, "subject")?;
422        let tool_manifold = to_manifold(tool, "tool")?;
423
424        let output = match compute_boolean(
425            &subject_manifold,
426            &tool_manifold,
427            op,
428            fast_winding,
429            result_budget.as_ref(),
430        ) {
431            Ok(output) => output,
432            Err(SolveError::OverBudget) => {
433                return Err(GeomError::BudgetExceeded { resource: "memory" })
434            }
435            // An empty result is a legitimate value under the contract: the
436            // intersection of disjoint solids is nothing. `boolmesh` signals
437            // that by failing to build a mesh from an empty vertex matrix, so
438            // translate it back into the empty solid the contract specifies
439            // rather than surfacing a backend quirk as a geometry error.
440            Err(SolveError::Failed(reason)) if is_empty_result(&reason) => {
441                let mut empty = TriMesh::new(Vec::new(), Vec::new());
442                // Zero faces carry every channel vacuously: nothing lost,
443                // nothing derived. Keeps a union/fold over it consistent.
444                let fates = carry(subject, tool, &mut empty, &[]);
445                let evidence =
446                    evidence_for(subject, &[tool], &empty, 1).with_attribute_fates(fates);
447                return Ok(BooleanOutcome::new(empty, evidence));
448            }
449            Err(SolveError::Failed(reason)) => return Err(solve_refusal(operation, &reason)),
450        };
451
452        let mut result = from_boolean_mesh(&output);
453        check_result(&result, operation)?;
454        let sources: Vec<FaceSource> = output
455            .src
456            .iter()
457            .map(|&(operand, triangle)| FaceSource { operand, triangle })
458            .collect();
459        let fates = carry(subject, tool, &mut result, &sources);
460        let evidence = evidence_for(subject, &[tool], &result, 1).with_attribute_fates(fates);
461        Ok(BooleanOutcome::new(result, evidence))
462    }
463
464    /// `A △ B = (A ∪ B) \ (A ∩ B)`, charging each step what the earlier
465    /// steps leave alive (#308).
466    ///
467    /// The same composition as the contract's
468    /// `symmetric_difference_via_composition`, with the same evidence, but
469    /// that helper passes every step the whole budget while the union is
470    /// held through the intersection and both through the difference. A
471    /// union of crossing operands can be larger than either, so here each
472    /// step gets the budget less the results it holds.
473    fn symmetric_difference(
474        &self,
475        subject: &TriMesh,
476        tool: &TriMesh,
477        options: &ExecutionOptions,
478        budget: Option<usize>,
479    ) -> GeomResult<BooleanOutcome> {
480        let held = |outcome: &BooleanOutcome| {
481            crate::budget::heap_bytes(&outcome.mesh)
482                + crate::budget::fates_bytes(&outcome.evidence.attribute_fates)
483        };
484        let less = |budget: Option<usize>, bytes: usize| budget.map(|b| b.saturating_sub(bytes));
485        options.check_cancelled()?;
486        let union = self.boolean_within(
487            subject,
488            tool,
489            BooleanOperator::Union,
490            options,
491            false,
492            budget,
493        )?;
494        options.check_cancelled()?;
495        let budget = less(budget, held(&union));
496        let intersection = self.boolean_within(
497            subject,
498            tool,
499            BooleanOperator::Intersection,
500            options,
501            false,
502            budget,
503        )?;
504
505        // A ∩ B empty means the operands are disjoint, so A △ B == A ∪ B,
506        // and subtracting an empty solid is a degenerate operand.
507        if intersection.mesh.indices.is_empty() {
508            let mut evidence = union.evidence;
509            evidence.sub_operations = 2;
510            evidence.coincident_faces_encountered |=
511                intersection.evidence.coincident_faces_encountered;
512            return Ok(BooleanOutcome::new(union.mesh, evidence));
513        }
514
515        options.check_cancelled()?;
516        let budget = less(budget, held(&intersection));
517        let difference = self.boolean_within(
518            &union.mesh,
519            &intersection.mesh,
520            BooleanOperator::Difference,
521            options,
522            false,
523            budget,
524        )?;
525
526        let mut evidence = difference.evidence;
527        // The result's channels came through union then difference: compose
528        // those fates.
529        evidence.attribute_fates =
530            merge_fates(&union.evidence.attribute_fates, evidence.attribute_fates);
531        evidence.subject_triangles = subject.triangle_count();
532        evidence.tool_triangles = tool.triangle_count();
533        evidence.sub_operations = 3;
534        evidence.coincident_faces_encountered |= union.evidence.coincident_faces_encountered
535            || intersection.evidence.coincident_faces_encountered;
536        Ok(BooleanOutcome::new(difference.mesh, evidence))
537    }
538}
539
540/// The error for a refusal inside the solve.
541///
542/// Every operand reaching the solve has passed the input gates, so a
543/// refusal there (an unpairable edge-point count in `pair_up`, #101) is this
544/// provider's defect: `BackendContractViolation`, never `Degenerate`, which
545/// would blame operands that were admissible.
546fn solve_refusal(operation: BooleanOperator, reason: &str) -> GeomError {
547    GeomError::BackendContractViolation {
548        backend: BoolmeshBoolean::ID,
549        detail: format!("{operation:?} failed inside the solve: {reason}"),
550    }
551}
552
553/// Whether a `boolmesh` failure actually means "the result is empty".
554///
555/// Matched on the message because the backend does not expose a typed empty
556/// case. Narrow on purpose: any other failure stays a real error rather than
557/// being silently converted into an empty solid, which would turn a genuine
558/// fault into a plausible-looking zero-volume answer.
559fn is_empty_result(reason: &impl std::fmt::Display) -> bool {
560    let text = reason.to_string().to_lowercase();
561    text.contains("empty pos matrix") || text.contains("empty mesh")
562}
563
564/// Build evidence for one completed operation.
565///
566/// `output_components` counts connected components by union-find over shared
567/// vertex indices. A difference that severs a wall reports two components, so
568/// the caller learns about the split here rather than in a later quantity.
569fn evidence_for(
570    subject: &TriMesh,
571    tools: &[&TriMesh],
572    result: &TriMesh,
573    sub_operations: usize,
574) -> BooleanEvidence {
575    let disjoint = tools
576        .iter()
577        .filter(|tool| !subject.bounds().intersects(&tool.bounds()))
578        .count();
579    let evidence = BooleanEvidence::record(
580        subject.triangle_count(),
581        tools.iter().map(|t| t.triangle_count()).sum(),
582        result.triangle_count(),
583        axiolid_mesh::component_count(result),
584    )
585    .with_disjoint_tools(disjoint)
586    .with_sub_operations(sub_operations)
587    // `boolmesh` does not report coincident-face encounters, so claiming
588    // detection would be a lie. Left false until a provider can answer.
589    .with_coincident_faces(false)
590    // Fallback fate, overwritten by every path that carries channels (all
591    // of them since #116). What remains here is the answer when no
592    // provenance exists: the `carry` refusal path, and nothing else.
593    .with_attribute_fates(
594        subject
595            .attributes
596            .iter()
597            .map(|channel| {
598                let reason = match channel.blend {
599                    axiolid_mesh::Blend::None => axiolid_mesh::DropReason::NotBlendable,
600                    _ => axiolid_mesh::DropReason::ProviderLimitation,
601                };
602                (channel.name.clone(), AttributeFate::Dropped(reason))
603            })
604            .collect(),
605    );
606
607    match relative_overlap(subject, tools) {
608        Some(ratio) => evidence.with_relative_overlap(ratio),
609        None => evidence,
610    }
611}
612
613/// Carry channels through the analytic box path.
614///
615/// The cellular result is rebuilt, not cut, so it has no `Tref`s. Every face
616/// lies in a host face (as is) or a cutter face (reversed); plane lookup
617/// recovers the source, and the pairwise sampler does the rest. The cutters
618/// are fused so the sampler sees one tool, as in the general path.
619fn carry_analytic(
620    subject: &TriMesh,
621    tools: &[TriMesh],
622    result: &mut TriMesh,
623) -> Vec<(String, AttributeFate)> {
624    let members: Vec<&TriMesh> = tools.iter().collect();
625    let tool = crate::grouping::fuse_with_channels(&members, &subject.attributes);
626    match sources_by_plane(result, &[(subject, 1.0), (&tool, -1.0)]) {
627        Some(sources) => carry(subject, &tool, result, &sources),
628        // A face with no source means the construction is not what the
629        // lookup assumes: attach nothing rather than a wrong value.
630        None => dropped_fates(subject, DropReason::ProviderLimitation),
631    }
632}
633
634/// Every subject channel as dropped, with `reason` (`NotBlendable` wins for
635/// a `Blend::None` channel regardless: that is the data's own answer).
636fn dropped_fates(subject: &TriMesh, reason: DropReason) -> Vec<(String, AttributeFate)> {
637    subject
638        .attributes
639        .iter()
640        .map(|c| {
641            let r = match c.blend {
642                Blend::None => DropReason::NotBlendable,
643                _ => reason,
644            };
645            (c.name.clone(), AttributeFate::Dropped(r))
646        })
647        .collect()
648}
649
650/// Per-channel fates, in the subject's channel order.
651type Fates = Vec<(String, AttributeFate)>;
652
653/// Every subject channel `Preserved`: the starting point a composed path
654/// merges each step's fates onto.
655fn seed_fates(subject: &TriMesh) -> Fates {
656    subject
657        .attributes
658        .iter()
659        .map(|c| (c.name.clone(), AttributeFate::Preserved))
660        .collect()
661}
662
663/// `mesh` carrying exactly `template`'s channels: its own where one matches
664/// (name, width, blend), an all-`UNMAPPED` channel where none does.
665///
666/// Union is symmetric but channel reporting is keyed on the first operand,
667/// so a tree node's left child must carry every channel the batch reports
668/// on -- whichever solid it came from. Unmapped keeps the absence honest.
669///
670/// Built by fusing the one mesh, which copies positions and indices
671/// unchanged, so no second copy is made only to take its channels.
672fn conform(mesh: &TriMesh, template: &[AttributeChannel]) -> TriMesh {
673    let mut out = crate::grouping::fuse_with_channels(&[mesh], template);
674    out.normals.clone_from(&mesh.normals);
675    out
676}
677
678/// Whether [`conform`] would return `mesh` unchanged: neither it nor the
679/// template carries a channel.
680fn conforms(mesh: &TriMesh, template: &[AttributeChannel]) -> bool {
681    template.is_empty() && mesh.attributes.is_empty()
682}
683
684/// A `union_many` tree node: a solid, borrowed from the input until a union
685/// produces it, and the fates of the path that built it.
686type Node<'a> = (Cow<'a, TriMesh>, Fates);
687
688/// `union_many`'s bookkeeping per input solid, by the most it can reach:
689/// its node in the level and in the next, and its share of a level's list of
690/// pairs.
691const UNION_BOOKKEEPING_PER_SOLID: usize =
692    2 * std::mem::size_of::<Node<'static>>() + std::mem::size_of::<&[Node<'static>]>();
693
694/// `subtract_many`'s bookkeeping per tool, by the most it can reach: its
695/// bounds, twice (the list and its group's), its index in a group and in the
696/// step's member list, and the headers of a group of its own, doubled for
697/// lists that grow by pushing.
698const SUBTRACT_BOOKKEEPING_PER_TOOL: usize = 2
699    * (2 * std::mem::size_of::<axiolid_core::Aabb>()
700        + std::mem::size_of::<usize>()
701        + std::mem::size_of::<&TriMesh>()
702        + 2 * std::mem::size_of::<Vec<usize>>());
703
704/// Heap bytes a tree node holds beyond the input.
705fn node_bytes(node: &Node<'_>) -> usize {
706    let mesh = match &node.0 {
707        Cow::Borrowed(_) => 0,
708        Cow::Owned(mesh) => crate::budget::heap_bytes(mesh),
709    };
710    mesh + crate::budget::fates_bytes(&node.1)
711}
712
713/// Remove every channel whose composed fate is `Dropped`.
714fn strip_dropped(mesh: &mut TriMesh, fates: &[(String, AttributeFate)]) {
715    mesh.attributes.retain(|c| {
716        !fates
717            .iter()
718            .any(|(n, f)| n == &c.name && matches!(f, AttributeFate::Dropped(_)))
719    });
720}
721/// Smallest relative overlap between the subject and any tool.
722///
723/// Measured from operand bounds, not from the result: the result cannot show
724/// how thin the sliver that produced it was. For each intersecting tool, the
725/// overlap box's shortest side is divided by the operand size, giving a
726/// scale-free number (ADR 0045).
727///
728/// Disjoint tools are skipped — they contribute no intersection to condition.
729/// `None` when nothing intersects or the operands are degenerate, which is
730/// honest: no intersection was constructed, so none was ill conditioned.
731fn relative_overlap(subject: &TriMesh, tools: &[&TriMesh]) -> Option<f64> {
732    let subject_bounds = subject.bounds();
733    let mut worst: Option<f64> = None;
734
735    for tool in tools {
736        let tool_bounds = tool.bounds();
737        if !subject_bounds.intersects(&tool_bounds) {
738            continue;
739        }
740
741        // The overlap box: componentwise max of mins, min of maxes.
742        let lo = subject_bounds.min.max(tool_bounds.min);
743        let hi = subject_bounds.max.min(tool_bounds.max);
744        let overlap = hi - lo;
745
746        // Scale: the larger operand's diagonal, so the ratio is relative to
747        // the model rather than to whichever operand happens to be smaller.
748        let scale = subject_bounds
749            .diagonal()
750            .length()
751            .max(tool_bounds.diagonal().length());
752        if !scale.is_finite() || scale <= 0.0 {
753            continue;
754        }
755
756        // The shortest overlap side governs: a wide, thin sliver is exactly
757        // as ill conditioned as its thin dimension makes it.
758        let thinnest = overlap.x.min(overlap.y).min(overlap.z);
759        if !thinnest.is_finite() {
760            continue;
761        }
762        let ratio = (thinnest / scale).max(0.0);
763        worst = Some(worst.map_or(ratio, |w: f64| w.min(ratio)));
764    }
765
766    worst
767}
768
769/// Batch difference: fuse mutually disjoint cutters, then subtract per group.
770///
771/// The sequential default runs N booleans, each against a subject that has
772/// already been cut N-1 times. This override runs one boolean per GROUP of
773/// mutually disjoint cutters, which for the dominant layout (a wall with
774/// non-overlapping openings) collapses to a single boolean.
775///
776/// Correctness rests on `(S \ A) \ B == S \ (A union B)`, plus the fact that a
777/// concatenation of disjoint solids IS their union. Grouping uses bounding
778/// boxes, which over-separate but never wrongly fuse, so the result is
779/// identical to the sequential path -- asserted by the volume gates.
780impl BoolmeshBoolean {
781    /// Subtract every tool, grouping disjoint ones into single operations.
782    pub(crate) fn subtract_grouped(
783        &self,
784        subject: &TriMesh,
785        tools: &[TriMesh],
786        options: &ExecutionOptions,
787    ) -> GeomResult<BooleanOutcome> {
788        // The tool bounds and the grouping are held through the batch;
789        // refused before they are built when even they do not fit.
790        let bookkeeping = tools.len() * SUBTRACT_BOOKKEEPING_PER_TOOL;
791        crate::budget::charge_step(options, bookkeeping, 0, 0)?;
792        let bounds: Vec<_> = tools.iter().map(TriMesh::bounds).collect();
793        let groups = crate::grouping::disjoint_groups(&bounds);
794
795        // Borrowed until the first group cuts it: a copy of the subject
796        // would be scratch held through the whole batch.
797        let mut current = Cow::Borrowed(subject);
798        let mut sub_operations = 0;
799        // Seeded Preserved: the subject enters untouched, and each step's
800        // fates compose onto this (`merge_fates`).
801        let mut fates = seed_fates(subject);
802        for group in &groups {
803            // The only real poll point: between groups. Cancelling here returns
804            // no mesh at all rather than a partially cut one.
805            options.check_cancelled()?;
806            sub_operations += 1;
807            let members: Vec<&TriMesh> = group.iter().map(|&i| &tools[i]).collect();
808            // Charged now, not up front: the subject grows with every group,
809            // so this step's operands did not exist before the last one
810            // (#259). A single-member group gains nothing from fusing and
811            // holds no fused copy.
812            let fused_bytes = match members.as_slice() {
813                [_] => 0,
814                _ => crate::budget::fused_bound(&members, &subject.attributes),
815            };
816            let held = match &current {
817                Cow::Borrowed(_) => 0,
818                Cow::Owned(mesh) => crate::budget::heap_bytes(mesh),
819            } + crate::budget::fates_bytes(&fates)
820                + fused_bytes
821                + bookkeeping;
822            let elements = current.triangle_count()
823                + members.iter().map(|m| m.triangle_count()).sum::<usize>();
824            crate::budget::charge_step(options, held, 1, elements)?;
825            // The boolean gets what the batch does not hold, and charges its
826            // own result against that (#308).
827            let budget = crate::budget::remaining(options, held);
828            let step = if let [only] = members.as_slice() {
829                self.difference(&current, only, options, budget)?
830            } else {
831                let fused = crate::grouping::fuse_with_channels(&members, &subject.attributes);
832                self.difference(&current, &fused, options, budget)?
833            };
834            fates = merge_fates(&fates, step.evidence.attribute_fates);
835            current = Cow::Owned(step.mesh);
836        }
837        let current = current.into_owned();
838        let borrowed: Vec<&TriMesh> = tools.iter().collect();
839        let evidence =
840            evidence_for(subject, &borrowed, &current, sub_operations).with_attribute_fates(fates);
841        Ok(BooleanOutcome::new(current, evidence))
842    }
843
844    /// Union every solid by balanced pairwise reduction.
845    ///
846    /// The sequential fold the trait default performs makes step `i` union
847    /// an accumulator already holding `i` solids against one more, so the
848    /// subject is re-walked on every step and total work is quadratic in the
849    /// operand count. Reducing in pairs -- union neighbours, then pairs of
850    /// those, until one remains -- issues the SAME number of booleans, but
851    /// the operands stay small until the final levels.
852    ///
853    /// Measured on a k^3 grid of icospheres (see the `benchmarks` sibling
854    /// repo, `sphere_grid`): 1.4x at 8 solids, 7.8x at 125, 28.9x at 512
855    /// (44.4s to 1.5s). The gap widens with n because the difference is
856    /// complexity, not a constant factor.
857    ///
858    /// # Known cost: intermediates are rebuilt at every level
859    ///
860    /// Each level hands the next one a [`TriMesh`], so every intermediate is
861    /// converted back from the internal half-edge form and rebuilt by
862    /// `to_manifold` on the level above. Instrumenting `to_manifold` against
863    /// `compute_boolean` on a disjoint sphere grid:
864    ///
865    /// ```text
866    ///   n   wall_ms  build_ms  build%  triangles rebuilt vs input
867    ///   8       8.9       3.9   43.8%   3.00x
868    ///  27      51.2      22.2   43.4%   4.85x
869    ///  64     145.7      65.2   44.7%   6.00x
870    /// 125     344.1     147.5   42.9%   6.98x
871    /// ```
872    ///
873    /// Construction is ~43% of wall time and the redundancy grows with `n`:
874    /// at 125 solids the same triangles are rebuilt seven times. Threading
875    /// the built structure through the levels instead would have a ceiling
876    /// of about **1.6x** for this path (perfect reuse, zero conversion
877    /// cost), so the real figure would be lower.
878    ///
879    /// It has NOT been done, deliberately. It requires a new internal type
880    /// boundary carrying a collider and planar grid between levels, which
881    /// makes intermediates heavier in memory, and it touches the code path
882    /// with the recorded ordering nondeterminism -- while this path is
883    /// currently STABLE at 1000 solids. That is a real property to risk for
884    /// a bounded win.
885    ///
886    /// Note the ceiling applies to BATCH paths only. A single
887    /// [`MeshBoolean::boolean`] call has no intermediates -- both operands
888    /// are leaves -- so construction there is irreducible and reuse saves
889    /// exactly nothing. It is not a lead on the single-boolean gap against
890    /// upstream Manifold, which profiling showed to be flat rather than
891    /// hotspot-shaped.
892    ///
893    /// # Memory budget (#259)
894    ///
895    /// Each level is charged before its pairs start, on what then exists:
896    /// the nodes it holds, and the declared bound of every pair that can be
897    /// live. With `parallel-batch` that is all of them at once, since a
898    /// worker waiting in one boolean's join can steal another pair; without
899    /// it, one pair at a time on top of the results so far. Each union gets
900    /// the budget the level does not hold, shared evenly between pairs that
901    /// run at once, and charges its result against it (#308). See
902    /// `budget.rs`.
903    ///
904    /// Union is associative and commutative, so any reduction order yields
905    /// the same solid; unlike `subtract_grouped` this needs no disjointness
906    /// precondition and no fusing, and therefore has no correctness cliff.
907    /// Floating-point differences remain -- a differently ordered triangle
908    /// list sums to a marginally different volume -- so the gates compare
909    /// volumes to a relative tolerance, not bitwise.
910    pub(crate) fn union_tree(
911        &self,
912        solids: &[TriMesh],
913        options: &ExecutionOptions,
914    ) -> GeomResult<BooleanOutcome> {
915        // The union of nothing is nothing. Returning an empty solid keeps
916        // this total rather than making every caller special-case it.
917        if solids.is_empty() {
918            let borrowed: Vec<&TriMesh> = Vec::new();
919            let empty = TriMesh::default();
920            let evidence = evidence_for(&empty, &borrowed, &empty, 0);
921            return Ok(BooleanOutcome::new(empty, evidence));
922        }
923
924        // Every solid carries solids[0]'s channel set, so a channel the
925        // result keeps is defined (or explicitly unmapped) on every piece.
926        // Each node carries the fates of the path that built it. A solid that
927        // already has that set (none, the common case) is borrowed: a copy
928        // would be scratch held through the first level.
929        let template = &solids[0].attributes;
930        let seed = seed_fates(&solids[0]);
931        let copies: usize = solids
932            .iter()
933            .filter(|s| !conforms(s, template))
934            .map(|s| crate::budget::fused_bound(&[s], template))
935            .sum();
936        // The node lists of a level and the next, held through the batch.
937        let bookkeeping = solids.len() * UNION_BOOKKEEPING_PER_SOLID;
938        crate::budget::charge_step(
939            options,
940            copies + solids.len() * crate::budget::fates_bytes(&seed) + bookkeeping,
941            0,
942            0,
943        )?;
944        let mut level: Vec<Node<'_>> = solids
945            .iter()
946            .map(|s| {
947                let mesh = if conforms(s, template) {
948                    Cow::Borrowed(s)
949                } else {
950                    Cow::Owned(conform(s, template))
951                };
952                (mesh, seed.clone())
953            })
954            .collect();
955        let mut sub_operations = 0;
956        while level.len() > 1 {
957            // An odd trailing solid rides to the next level uncombined
958            // rather than being unioned into an already-merged neighbour,
959            // which would unbalance the tree this exists to keep balanced.
960            // Moved out, not copied, so it is held once.
961            let carried = if level.len() % 2 == 1 {
962                level.pop()
963            } else {
964                None
965            };
966            // What the level holds while its pairs run: its own copies and
967            // the previous level's results, with every node's history.
968            let held: usize = level
969                .iter()
970                .chain(carried.iter())
971                .map(node_bytes)
972                .sum::<usize>()
973                + bookkeeping;
974            // Every pair at one level is independent: no pair reads another
975            // pair's output, so the level is a pure map. That is the whole
976            // reason this axis can be threaded while intra-solve threading
977            // cannot pay for itself -- there is no coordination inside the
978            // level, only a join at the end of it.
979            let pairs = level.as_chunks::<2>().0;
980            let step = |pair: &[Node<'_>], budget: Option<usize>| -> GeomResult<(TriMesh, Fates)> {
981                let out = self.union(&pair[0].0, &pair[1].0, options, budget)?;
982                // Both halves' histories, then this union.
983                let history = merge_fates(&pair[0].1, pair[1].1.clone());
984                Ok((
985                    out.mesh,
986                    merge_fates(&history, out.evidence.attribute_fates),
987                ))
988            };
989            let triangles =
990                |pair: &[Node<'_>]| pair[0].0.triangle_count() + pair[1].0.triangle_count();
991
992            // Cancellation is polled ONCE per level rather than per pair.
993            // Under rayon the pairs are not ordered, so a per-pair poll
994            // would abort at a schedule-dependent point; per level, the
995            // cut point is deterministic and the partial work discarded is
996            // the same either way.
997            options.check_cancelled()?;
998
999            #[cfg(feature = "parallel-batch")]
1000            let mut next: Vec<Node<'_>> = {
1001                use rayon::prelude::*;
1002                // Charged for every pair at once (#259): the pairs run
1003                // concurrently, and a worker waiting inside one boolean's
1004                // join can steal another pair and run it on top, so up to
1005                // every pair of the level is in flight however few workers
1006                // there are. A finished pair's result is covered by its own
1007                // term.
1008                let chunks: Vec<&[Node<'_>]> =
1009                    pairs.iter().map(<[Node<'_>; 2]>::as_slice).collect();
1010                let elements = chunks.iter().map(|pair| triangles(pair)).sum();
1011                crate::budget::charge_step(options, held, chunks.len(), elements)?;
1012                // Each pair charges its result against its own share of the
1013                // budget (#308), so pairs running at once cannot together
1014                // overrun it.
1015                let share = |pair: &[Node<'_>]| {
1016                    crate::budget::concurrent_share(
1017                        options,
1018                        held,
1019                        chunks.len(),
1020                        elements,
1021                        triangles(pair),
1022                    )
1023                };
1024                // `par_iter` over an indexed slice, NOT `par_bridge`: the
1025                // bridge does not preserve order, which would permute the
1026                // level and silently change the tree's shape from run to
1027                // run. An indexed parallel iterator collects positionally,
1028                // so the output is identical to the sequential path.
1029                let merged: Result<Vec<Node<'_>>, GeomError> = chunks
1030                    .par_iter()
1031                    .map(|pair| {
1032                        step(pair, share(pair)).map(|(mesh, fates)| (Cow::Owned(mesh), fates))
1033                    })
1034                    .collect();
1035                merged?
1036            };
1037
1038            #[cfg(not(feature = "parallel-batch"))]
1039            let mut next: Vec<Node<'_>> = {
1040                let mut acc: Vec<Node<'_>> = Vec::with_capacity(level.len() / 2 + 1);
1041                for pair in pairs.iter().map(<[Node<'_>; 2]>::as_slice) {
1042                    // One boolean at a time, on top of the level and the
1043                    // results so far.
1044                    let done: usize = acc.iter().map(node_bytes).sum();
1045                    crate::budget::charge_step(options, held + done, 1, triangles(pair))?;
1046                    // The union gets what the batch does not hold, and
1047                    // charges its own result against that (#308).
1048                    let budget = crate::budget::remaining(options, held + done);
1049                    let (mesh, fates) = step(pair, budget)?;
1050                    acc.push((Cow::Owned(mesh), fates));
1051                }
1052                acc
1053            };
1054
1055            sub_operations += next.len();
1056            next.extend(carried);
1057            level = next;
1058        }
1059
1060        let (result, fates) = level.into_iter().next().expect("non-empty input");
1061        let mut result = result.into_owned();
1062        // A channel some step dropped is not on the result, or only partly:
1063        // strip it so the mesh never carries a channel its fate calls lost.
1064        strip_dropped(&mut result, &fates);
1065        let borrowed: Vec<&TriMesh> = solids.iter().collect();
1066        // Evidence names the first operand as the subject and the rest as
1067        // tools, matching how a caller reads a batch: one solid grown by the
1068        // others. The reduction order is an implementation detail.
1069        let (subject, tools) = borrowed.split_first().expect("non-empty input");
1070        let evidence =
1071            evidence_for(subject, tools, &result, sub_operations).with_attribute_fates(fates);
1072        Ok(BooleanOutcome::new(result, evidence))
1073    }
1074
1075    /// One union, routed through the same validation as `boolean`, with
1076    /// `budget` bytes for it alone.
1077    fn union(
1078        &self,
1079        subject: &TriMesh,
1080        tool: &TriMesh,
1081        options: &ExecutionOptions,
1082        budget: Option<usize>,
1083    ) -> GeomResult<BooleanOutcome> {
1084        self.boolean_within(
1085            subject,
1086            tool,
1087            BooleanOperator::Union,
1088            options,
1089            false,
1090            budget,
1091        )
1092    }
1093
1094    /// One difference, routed through the same validation as `boolean`,
1095    /// with `budget` bytes for it alone.
1096    fn difference(
1097        &self,
1098        subject: &TriMesh,
1099        tool: &TriMesh,
1100        options: &ExecutionOptions,
1101        budget: Option<usize>,
1102    ) -> GeomResult<BooleanOutcome> {
1103        self.boolean_within(
1104            subject,
1105            tool,
1106            BooleanOperator::Difference,
1107            options,
1108            false,
1109            budget,
1110        )
1111    }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117    use axiolid_core::Point3;
1118
1119    /// A tetrahedron with reversed winding: the shape a faulty backend would
1120    /// return if it inverted its output.
1121    fn inside_out_tetrahedron() -> TriMesh {
1122        let positions = vec![
1123            Point3::new(0.0, 0.0, 0.0),
1124            Point3::new(1.0, 0.0, 0.0),
1125            Point3::new(0.0, 1.0, 0.0),
1126            Point3::new(0.0, 0.0, 1.0),
1127        ];
1128        // Verified: signed volume -1/6, i.e. inward-facing normals.
1129        let indices = vec![0, 1, 2, 0, 3, 1, 0, 2, 3, 1, 3, 2];
1130        TriMesh::new(positions, indices)
1131    }
1132
1133    fn positive_infinite_volume() -> TriMesh {
1134        let large = 1.0e308;
1135        let small = 1.0e-308;
1136        TriMesh::new(
1137            vec![
1138                Point3::new(small, small, 1.0),
1139                Point3::new(large, 1.0, small),
1140                Point3::new(small, large, 1.0),
1141            ],
1142            vec![0, 1, 2],
1143        )
1144    }
1145
1146    /// `check_result` guards against an upstream regression that inverts its
1147    /// output. With validated inputs the current `boolmesh` release never does
1148    /// this -- verified by instrumenting the branch across the whole suite and
1149    /// observing zero hits -- so the guard is exercised directly here rather
1150    /// than left as untested defensive code.
1151    #[test]
1152    fn an_inside_out_result_is_blamed_on_the_backend() {
1153        let error = check_result(&inside_out_tetrahedron(), BooleanOperator::Difference)
1154            .expect_err("an inside-out result must be rejected");
1155        match error {
1156            GeomError::BackendContractViolation { backend, detail } => {
1157                assert_eq!(backend, BoolmeshBoolean::ID);
1158                assert!(detail.contains("inside-out"), "{detail}");
1159            }
1160            other => panic!("must blame the backend, not the caller: {other:?}"),
1161        }
1162    }
1163
1164    #[test]
1165    fn a_non_finite_result_is_blamed_on_the_backend() {
1166        let error = check_result(&positive_infinite_volume(), BooleanOperator::Union)
1167            .expect_err("a non-finite result volume must be rejected");
1168        assert!(
1169            matches!(error, GeomError::BackendContractViolation { backend, ref detail }
1170                if backend == BoolmeshBoolean::ID && detail.contains("non-finite signed volume")),
1171            "must blame the backend, got {error:?}"
1172        );
1173    }
1174
1175    /// A refusal inside the solve blames this provider, not the operands.
1176    ///
1177    /// Replaces the grid-union reproducer that reached the refusal until
1178    /// #203 was fixed; no admissible input is known to reach it now, so the
1179    /// mapping is pinned at the seam where `compute_boolean`'s error lands.
1180    #[test]
1181    fn a_refusal_inside_the_solve_is_a_backend_contract_violation() {
1182        let error = solve_refusal(
1183            BooleanOperator::Union,
1184            "boolean produced 3 edge points, which cannot be paired into halfedges",
1185        );
1186        assert!(
1187            matches!(
1188                &error,
1189                GeomError::BackendContractViolation { backend, detail }
1190                    if *backend == BoolmeshBoolean::ID
1191                        && detail.contains("Union failed inside the solve")
1192                        && detail.contains("cannot be paired")
1193            ),
1194            "{error:?}"
1195        );
1196    }
1197
1198    /// An empty result is legitimate (tool fully contains subject) and must not
1199    /// be mistaken for an orientation fault.
1200    #[test]
1201    fn an_empty_result_is_accepted() {
1202        assert!(check_result(&TriMesh::default(), BooleanOperator::Difference).is_ok());
1203    }
1204
1205    /// A correctly oriented result passes.
1206    #[test]
1207    fn an_outward_result_is_accepted() {
1208        let mut mesh = inside_out_tetrahedron();
1209        for corner in mesh.indices.as_chunks_mut::<3>().0 {
1210            corner.swap(1, 2);
1211        }
1212        assert!(check_result(&mesh, BooleanOperator::Difference).is_ok());
1213    }
1214}