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 crate::csg::{compute_boolean, OpType};
16use axiolid_contracts::{
17    Backend, BackendDescriptor, BackendId, CancellationGranularity, Determinism, ExecutionOptions,
18    ExecutionTarget, GeomError, GeomResult, ScratchRequirement,
19};
20use axiolid_core::BooleanOperator;
21use axiolid_mesh::{AttributeChannel, AttributeFate, Blend, DropReason, TriMesh};
22use axiolid_mesh_boolean_contract::{
23    merge_fates, symmetric_difference_via_composition, BooleanEvidence, BooleanOutcome, MeshBoolean,
24};
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
39impl BoolmeshBoolean {
40    /// Stable identifier used in errors and explicit provider selection.
41    pub const ID: BackendId = BackendId::new("boolmesh");
42
43    /// Construct the provider.
44    pub const fn new() -> Self {
45        Self
46    }
47
48    /// Subtract axis-aligned box cutters from an axis-aligned box subject using
49    /// an exact closed-form construction, bypassing the general solver.
50    ///
51    /// Returns `Ok(None)` when the operands are outside this path's competence,
52    /// so the caller falls back to [`MeshBoolean::subtract_many`] rather than
53    /// receiving an approximate answer. Declining cases:
54    ///
55    /// * subject or any tool is not an axis-aligned box (recognised
56    ///   structurally -- triangle count, corner lattice, and face planes -- not
57    ///   by bounding box, since every mesh has one of those)
58    /// * no tools, or no tool overlapping the subject
59    /// * the induced grid would exceed `max_cells`
60    ///
61    /// # Why opt-in rather than automatic
62    ///
63    /// Dispatching on shape automatically would make output topology depend on
64    /// input in a way the caller cannot predict: the same wall would produce
65    /// different triangle counts depending on whether its openings happened to
66    /// be axis-aligned. Callers that want the speed ask for it and handle the
67    /// `None`; callers that want one predictable code path never see this.
68    ///
69    /// # Guarantees
70    ///
71    /// The result is watertight by construction (adjacent cells address shared
72    /// grid vertices by integer index) and deterministic across processes
73    /// (vertex identity is an ordered map, not a randomly-seeded hash map).
74    ///
75    /// The returned evidence has [`BooleanEvidence::analytic_path`] set, so a
76    /// caller can tell this result apart from a general-solver result.
77    pub fn subtract_boxes_analytic(
78        &self,
79        subject: &TriMesh,
80        tools: &[TriMesh],
81        options: &ExecutionOptions,
82        max_cells: usize,
83    ) -> GeomResult<Option<BooleanOutcome>> {
84        options.check_cancelled()?;
85
86        // Tolerance is scale-relative: an absolute epsilon would reject a
87        // building-scale box and accept a millimetre-scale non-box.
88        let d = subject.bounds().diagonal();
89        let eps = d.x.max(d.y).max(d.z) * 1e-9;
90
91        let Some(host) = crate::box_detect::recognise(subject, eps) else {
92            return Ok(None);
93        };
94        let mut cutters = Vec::with_capacity(tools.len());
95        for tool in tools {
96            let Some(b) = crate::box_detect::recognise(tool, eps) else {
97                return Ok(None);
98            };
99            cutters.push(b);
100        }
101
102        let Some(mut result) = crate::cellular::subtract_boxes(&host, &cutters, max_cells) else {
103            return Ok(None);
104        };
105
106        // The analytic path is exact, but it is still a provider result: hold it
107        // to the same orientation contract as the general path rather than
108        // trusting the construction.
109        check_result(&result, BooleanOperator::Difference)?;
110
111        let tool_refs: Vec<&TriMesh> = tools.iter().collect();
112        let fates = carry_analytic(subject, tools, &mut result);
113        let evidence = evidence_for(subject, &tool_refs, &result, 1)
114            .with_analytic_path(true)
115            .with_attribute_fates(fates);
116        Ok(Some(BooleanOutcome::new(result, evidence)))
117    }
118}
119
120impl Backend for BoolmeshBoolean {
121    fn descriptor(&self) -> BackendDescriptor {
122        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
123    }
124}
125
126/// Verify the result against the contract before handing it back.
127///
128/// `boolmesh` reports its own manifoldness; we additionally check orientation,
129/// because a returned inside-out solid would poison every subsequent operation
130/// in a subtract chain. Blamed on the backend, not the caller: the inputs were
131/// already validated on the way in.
132fn check_result(result: &TriMesh, operation: BooleanOperator) -> GeomResult<()> {
133    // An empty result is legitimate: subtracting a tool that fully contains the
134    // subject leaves nothing. Orientation is undefined for it, so stop here.
135    //
136    // Removing this early return is behaviour-preserving (an empty mesh sums to
137    // zero, not a negative), so no test can distinguish it. It is kept because
138    // it states the intent at the point of the decision.
139    if result.indices.is_empty() {
140        return Ok(());
141    }
142    let six_volume = six_signed_volume(&result.positions, &result.indices);
143    if !six_volume.is_finite() {
144        return Err(GeomError::BackendContractViolation {
145            backend: BoolmeshBoolean::ID,
146            detail: format!("{operation:?} returned non-finite signed volume"),
147        });
148    }
149    if six_volume < 0.0 {
150        return Err(GeomError::BackendContractViolation {
151            backend: BoolmeshBoolean::ID,
152            detail: format!(
153                "{operation:?} returned an inside-out solid (signed volume {:.6})",
154                six_volume / 6.0
155            ),
156        });
157    }
158    Ok(())
159}
160
161impl MeshBoolean for BoolmeshBoolean {
162    /// Honest about the general path, which is not byte-reproducible.
163    ///
164    /// Upstream `boolmesh` dedups vertices through a randomly-seeded
165    /// `HashMap`, so its output ordering varies between processes even
166    /// single-threaded. That is the same root cause documented in
167    /// [`Self::subtract_boxes_analytic`], which avoids it with a `BTreeMap`. The general
168    /// path therefore cannot claim [`Determinism::Bitwise`] at any thread
169    /// count, and enabling `parallel` does not make it weaker than it
170    /// already is.
171    ///
172    /// [`Determinism::Topological`] is the honest ceiling: the result's
173    /// connectivity is stable, its vertex ordering is not. Callers needing
174    /// byte reproducibility use [`Self::subtract_boxes_analytic`],
175    /// whose ordered vertex identity makes it reproducible across processes.
176    fn determinism(&self) -> Determinism {
177        Determinism::Topological
178    }
179
180    /// Measured, not guessed.
181    ///
182    /// `boolmesh` builds a Morton collider and intersection tables sized by the
183    /// combined input. It exposes no bound, so this was measured directly with
184    /// a counting global allocator (`src/bin/scratch_probe/`) across all four
185    /// operations at 24 to 1,536 input triangles, after one discarded warmup
186    /// call so the first operation measured is not charged for process
187    /// startup (#110):
188    ///
189    /// ```text
190    ///  triangles   peak bytes   bytes/triangle   worst operation
191    ///         24        52424             2184   SymmetricDifference
192    ///         96        98920             1030   SymmetricDifference
193    ///        384       343768              895   SymmetricDifference
194    ///       1536      1349668              878   SymmetricDifference
195    /// ```
196    ///
197    /// Consumption is linear in input size, converging to under 1 KiB per
198    /// triangle; the higher ratio at small inputs is fixed setup cost being
199    /// divided by few triangles. `SymmetricDifference` is worst because it is
200    /// composed from three passes and holds intermediates alive.
201    ///
202    /// The declared bound is 4 KiB per triangle: about 1.9x the worst
203    /// observed ratio, as headroom for allocator variance and future operand
204    /// shapes. `tests/scratch_bound.rs` measures the same workloads and fails
205    /// if any peak exceeds it. A declared bound that is occasionally too low
206    /// is worse than `Unbounded`, because it makes a budget look enforced
207    /// when it is not.
208    fn scratch_requirement(&self) -> ScratchRequirement {
209        ScratchRequirement::PerElement {
210            bytes_per_element: 4096,
211        }
212    }
213
214    /// `boolmesh` takes no cancellation handle, so nothing can interrupt a
215    /// single boolean once it starts. Declared honestly: the batch override
216    /// polls between groups, which is the only real poll point available.
217    fn cancellation_granularity(&self) -> CancellationGranularity {
218        CancellationGranularity::BetweenOperations
219    }
220
221    /// Group mutually disjoint cutters and remove each group with one
222    /// boolean, instead of one boolean per cutter.
223    ///
224    /// Rests on `(S \ A) \ B == S \ (A union B)` and on a concatenation of
225    /// disjoint solids being their union. Bounding-box grouping over-separates
226    /// but never wrongly fuses, so the result is identical to the sequential
227    /// default -- gated by volume equality in `tests/batch.rs`.
228    fn subtract_many(
229        &self,
230        subject: &TriMesh,
231        tools: &[TriMesh],
232        options: &ExecutionOptions,
233    ) -> GeomResult<BooleanOutcome> {
234        self.subtract_grouped(subject, tools, options)
235    }
236
237    fn union_many(
238        &self,
239        solids: &[TriMesh],
240        options: &ExecutionOptions,
241    ) -> GeomResult<BooleanOutcome> {
242        self.union_tree(solids, options)
243    }
244
245    fn boolean(
246        &self,
247        subject: &TriMesh,
248        tool: &TriMesh,
249        operation: BooleanOperator,
250        options: &ExecutionOptions,
251    ) -> GeomResult<BooleanOutcome> {
252        self.boolean_impl(subject, tool, operation, options, false)
253    }
254}
255
256impl BoolmeshBoolean {
257    /// Alternative to [`MeshBoolean::boolean`] using a cheaper winding-number
258    /// classification for the same result.
259    ///
260    /// See `csg::boolean03::kernel03::winding03_fast`'s doc comment for the
261    /// guarantee: not an approximation, but a bug in edge-break detection
262    /// would mislabel a whole connected component instead of one vertex.
263    /// Opt-in rather than the default until this has run against a wider
264    /// correctness corpus than the differential tests already covering it.
265    ///
266    /// `SymmetricDifference` is refused rather than silently composed from
267    /// three slow-path calls: a caller asking for the fast path should get
268    /// it or a clear refusal, not a surprise fallback.
269    pub fn boolean_fast(
270        &self,
271        subject: &TriMesh,
272        tool: &TriMesh,
273        operation: BooleanOperator,
274        options: &ExecutionOptions,
275    ) -> GeomResult<BooleanOutcome> {
276        if operation == BooleanOperator::SymmetricDifference {
277            return Err(GeomError::Unsupported {
278                backend: BoolmeshBoolean::ID,
279                operation: axiolid_contracts::Operation::MeshBoolean,
280            });
281        }
282        self.boolean_impl(subject, tool, operation, options, true)
283    }
284
285    fn boolean_impl(
286        &self,
287        subject: &TriMesh,
288        tool: &TriMesh,
289        operation: BooleanOperator,
290        options: &ExecutionOptions,
291        fast_winding: bool,
292    ) -> GeomResult<BooleanOutcome> {
293        // `SymmetricDifference` has no `boolmesh` counterpart. Compose it from
294        // the three primitives rather than pretending the backend's operation
295        // set is the contract's; `sub_operations` in the evidence records that
296        // the caller got a composed result.
297        if operation == BooleanOperator::SymmetricDifference {
298            return symmetric_difference_via_composition(self, subject, tool, options);
299        }
300
301        let op = match operation {
302            BooleanOperator::Union => OpType::Add,
303            BooleanOperator::Intersection => OpType::Intersect,
304            BooleanOperator::Difference => OpType::Subtract,
305            BooleanOperator::SymmetricDifference => unreachable!("composed above"),
306            // A future operand added to the contract. Refusing is honest and
307            // lets the registry fall back to a provider that supports it.
308            _ => {
309                return Err(GeomError::Unsupported {
310                    backend: BoolmeshBoolean::ID,
311                    operation: axiolid_contracts::Operation::MeshBoolean,
312                })
313            }
314        };
315        options.check_cancelled()?;
316        let subject_manifold = to_manifold(subject, "subject")?;
317        let tool_manifold = to_manifold(tool, "tool")?;
318
319        let output = match compute_boolean(&subject_manifold, &tool_manifold, op, fast_winding) {
320            Ok(output) => output,
321            // An empty result is a legitimate value under the contract: the
322            // intersection of disjoint solids is nothing. `boolmesh` signals
323            // that by failing to build a mesh from an empty vertex matrix, so
324            // translate it back into the empty solid the contract specifies
325            // rather than surfacing a backend quirk as a geometry error.
326            Err(reason) if is_empty_result(&reason) => {
327                let mut empty = TriMesh::new(Vec::new(), Vec::new());
328                // Zero faces carry every channel vacuously: nothing lost,
329                // nothing derived. Keeps a union/fold over it consistent.
330                let fates = carry(subject, tool, &mut empty, &[]);
331                let evidence =
332                    evidence_for(subject, &[tool], &empty, 1).with_attribute_fates(fates);
333                return Ok(BooleanOutcome::new(empty, evidence));
334            }
335            Err(reason) => {
336                return Err(GeomError::BackendContractViolation {
337                    backend: BoolmeshBoolean::ID,
338                    detail: format!("{operation:?} failed inside the solve: {reason}"),
339                })
340            }
341        };
342
343        let mut result = from_boolean_mesh(&output);
344        check_result(&result, operation)?;
345        let sources: Vec<FaceSource> = output
346            .src
347            .iter()
348            .map(|&(operand, triangle)| FaceSource { operand, triangle })
349            .collect();
350        let fates = carry(subject, tool, &mut result, &sources);
351        let evidence = evidence_for(subject, &[tool], &result, 1).with_attribute_fates(fates);
352        Ok(BooleanOutcome::new(result, evidence))
353    }
354}
355
356/// Whether a `boolmesh` failure actually means "the result is empty".
357///
358/// Matched on the message because the backend does not expose a typed empty
359/// case. Narrow on purpose: any other failure stays a real error rather than
360/// being silently converted into an empty solid, which would turn a genuine
361/// fault into a plausible-looking zero-volume answer.
362fn is_empty_result(reason: &impl std::fmt::Display) -> bool {
363    let text = reason.to_string().to_lowercase();
364    text.contains("empty pos matrix") || text.contains("empty mesh")
365}
366
367/// Build evidence for one completed operation.
368///
369/// `output_components` counts connected components by union-find over shared
370/// vertex indices. A difference that severs a wall reports two components, so
371/// the caller learns about the split here rather than in a later quantity.
372fn evidence_for(
373    subject: &TriMesh,
374    tools: &[&TriMesh],
375    result: &TriMesh,
376    sub_operations: usize,
377) -> BooleanEvidence {
378    let disjoint = tools
379        .iter()
380        .filter(|tool| !subject.bounds().intersects(&tool.bounds()))
381        .count();
382    let evidence = BooleanEvidence::record(
383        subject.triangle_count(),
384        tools.iter().map(|t| t.triangle_count()).sum(),
385        result.triangle_count(),
386        axiolid_mesh::component_count(result),
387    )
388    .with_disjoint_tools(disjoint)
389    .with_sub_operations(sub_operations)
390    // `boolmesh` does not report coincident-face encounters, so claiming
391    // detection would be a lie. Left false until a provider can answer.
392    .with_coincident_faces(false)
393    // Fallback fate, overwritten by every path that carries channels (all
394    // of them since #116). What remains here is the answer when no
395    // provenance exists: the `carry` refusal path, and nothing else.
396    .with_attribute_fates(
397        subject
398            .attributes
399            .iter()
400            .map(|channel| {
401                let reason = match channel.blend {
402                    axiolid_mesh::Blend::None => axiolid_mesh::DropReason::NotBlendable,
403                    _ => axiolid_mesh::DropReason::ProviderLimitation,
404                };
405                (channel.name.clone(), AttributeFate::Dropped(reason))
406            })
407            .collect(),
408    );
409
410    match relative_overlap(subject, tools) {
411        Some(ratio) => evidence.with_relative_overlap(ratio),
412        None => evidence,
413    }
414}
415
416/// Carry channels through the analytic box path.
417///
418/// The cellular result is rebuilt, not cut, so it has no `Tref`s. Every face
419/// lies in a host face (as is) or a cutter face (reversed); plane lookup
420/// recovers the source, and the pairwise sampler does the rest. The cutters
421/// are fused so the sampler sees one tool, as in the general path.
422fn carry_analytic(
423    subject: &TriMesh,
424    tools: &[TriMesh],
425    result: &mut TriMesh,
426) -> Vec<(String, AttributeFate)> {
427    let members: Vec<&TriMesh> = tools.iter().collect();
428    let tool = crate::grouping::fuse_with_channels(&members, &subject.attributes);
429    match sources_by_plane(result, &[(subject, 1.0), (&tool, -1.0)]) {
430        Some(sources) => carry(subject, &tool, result, &sources),
431        // A face with no source means the construction is not what the
432        // lookup assumes: attach nothing rather than a wrong value.
433        None => dropped_fates(subject, DropReason::ProviderLimitation),
434    }
435}
436
437/// Every subject channel as dropped, with `reason` (`NotBlendable` wins for
438/// a `Blend::None` channel regardless: that is the data's own answer).
439fn dropped_fates(subject: &TriMesh, reason: DropReason) -> Vec<(String, AttributeFate)> {
440    subject
441        .attributes
442        .iter()
443        .map(|c| {
444            let r = match c.blend {
445                Blend::None => DropReason::NotBlendable,
446                _ => reason,
447            };
448            (c.name.clone(), AttributeFate::Dropped(r))
449        })
450        .collect()
451}
452
453/// Per-channel fates, in the subject's channel order.
454type Fates = Vec<(String, AttributeFate)>;
455
456/// Every subject channel `Preserved`: the starting point a composed path
457/// merges each step's fates onto.
458fn seed_fates(subject: &TriMesh) -> Fates {
459    subject
460        .attributes
461        .iter()
462        .map(|c| (c.name.clone(), AttributeFate::Preserved))
463        .collect()
464}
465
466/// `mesh` carrying exactly `template`'s channels: its own where one matches
467/// (name, width, blend), an all-`UNMAPPED` channel where none does.
468///
469/// Union is symmetric but channel reporting is keyed on the first operand,
470/// so a tree node's left child must carry every channel the batch reports
471/// on -- whichever solid it came from. Unmapped keeps the absence honest.
472fn conform(mesh: &TriMesh, template: &[AttributeChannel]) -> TriMesh {
473    let mut out = mesh.clone();
474    out.attributes = crate::grouping::fuse_with_channels(&[mesh], template).attributes;
475    out
476}
477
478/// Remove every channel whose composed fate is `Dropped`.
479fn strip_dropped(mesh: &mut TriMesh, fates: &[(String, AttributeFate)]) {
480    mesh.attributes.retain(|c| {
481        !fates
482            .iter()
483            .any(|(n, f)| n == &c.name && matches!(f, AttributeFate::Dropped(_)))
484    });
485}
486/// Smallest relative overlap between the subject and any tool.
487///
488/// Measured from operand bounds, not from the result: the result cannot show
489/// how thin the sliver that produced it was. For each intersecting tool, the
490/// overlap box's shortest side is divided by the operand size, giving a
491/// scale-free number (ADR 0045).
492///
493/// Disjoint tools are skipped — they contribute no intersection to condition.
494/// `None` when nothing intersects or the operands are degenerate, which is
495/// honest: no intersection was constructed, so none was ill conditioned.
496fn relative_overlap(subject: &TriMesh, tools: &[&TriMesh]) -> Option<f64> {
497    let subject_bounds = subject.bounds();
498    let mut worst: Option<f64> = None;
499
500    for tool in tools {
501        let tool_bounds = tool.bounds();
502        if !subject_bounds.intersects(&tool_bounds) {
503            continue;
504        }
505
506        // The overlap box: componentwise max of mins, min of maxes.
507        let lo = subject_bounds.min.max(tool_bounds.min);
508        let hi = subject_bounds.max.min(tool_bounds.max);
509        let overlap = hi - lo;
510
511        // Scale: the larger operand's diagonal, so the ratio is relative to
512        // the model rather than to whichever operand happens to be smaller.
513        let scale = subject_bounds
514            .diagonal()
515            .length()
516            .max(tool_bounds.diagonal().length());
517        if !scale.is_finite() || scale <= 0.0 {
518            continue;
519        }
520
521        // The shortest overlap side governs: a wide, thin sliver is exactly
522        // as ill conditioned as its thin dimension makes it.
523        let thinnest = overlap.x.min(overlap.y).min(overlap.z);
524        if !thinnest.is_finite() {
525            continue;
526        }
527        let ratio = (thinnest / scale).max(0.0);
528        worst = Some(worst.map_or(ratio, |w: f64| w.min(ratio)));
529    }
530
531    worst
532}
533
534/// Batch difference: fuse mutually disjoint cutters, then subtract per group.
535///
536/// The sequential default runs N booleans, each against a subject that has
537/// already been cut N-1 times. This override runs one boolean per GROUP of
538/// mutually disjoint cutters, which for the dominant layout (a wall with
539/// non-overlapping openings) collapses to a single boolean.
540///
541/// Correctness rests on `(S \ A) \ B == S \ (A union B)`, plus the fact that a
542/// concatenation of disjoint solids IS their union. Grouping uses bounding
543/// boxes, which over-separate but never wrongly fuse, so the result is
544/// identical to the sequential path -- asserted by the volume gates.
545impl BoolmeshBoolean {
546    /// Subtract every tool, grouping disjoint ones into single operations.
547    pub(crate) fn subtract_grouped(
548        &self,
549        subject: &TriMesh,
550        tools: &[TriMesh],
551        options: &ExecutionOptions,
552    ) -> GeomResult<BooleanOutcome> {
553        let bounds: Vec<_> = tools.iter().map(TriMesh::bounds).collect();
554        let groups = crate::grouping::disjoint_groups(&bounds);
555
556        let mut current = subject.clone();
557        let mut sub_operations = 0;
558        // Seeded Preserved: the subject enters untouched, and each step's
559        // fates compose onto this (`merge_fates`).
560        let mut fates = seed_fates(subject);
561        for group in &groups {
562            // The only real poll point: between groups. Cancelling here returns
563            // no mesh at all rather than a partially cut one.
564            options.check_cancelled()?;
565            sub_operations += 1;
566            // A single-member group gains nothing from fusing, so skip the
567            // copy and subtract the tool directly.
568            let step = if let [only] = group.as_slice() {
569                self.difference(&current, &tools[*only], options)?
570            } else {
571                let members: Vec<&TriMesh> = group.iter().map(|&i| &tools[i]).collect();
572                let fused = crate::grouping::fuse_with_channels(&members, &subject.attributes);
573                self.difference(&current, &fused, options)?
574            };
575            fates = merge_fates(&fates, step.evidence.attribute_fates);
576            current = step.mesh;
577        }
578        let borrowed: Vec<&TriMesh> = tools.iter().collect();
579        let evidence =
580            evidence_for(subject, &borrowed, &current, sub_operations).with_attribute_fates(fates);
581        Ok(BooleanOutcome::new(current, evidence))
582    }
583
584    /// Union every solid by balanced pairwise reduction.
585    ///
586    /// The sequential fold the trait default performs makes step `i` union
587    /// an accumulator already holding `i` solids against one more, so the
588    /// subject is re-walked on every step and total work is quadratic in the
589    /// operand count. Reducing in pairs -- union neighbours, then pairs of
590    /// those, until one remains -- issues the SAME number of booleans, but
591    /// the operands stay small until the final levels.
592    ///
593    /// Measured on a k^3 grid of icospheres (see the `benchmarks` sibling
594    /// repo, `sphere_grid`): 1.4x at 8 solids, 7.8x at 125, 28.9x at 512
595    /// (44.4s to 1.5s). The gap widens with n because the difference is
596    /// complexity, not a constant factor.
597    ///
598    /// # Known cost: intermediates are rebuilt at every level
599    ///
600    /// Each level hands the next one a [`TriMesh`], so every intermediate is
601    /// converted back from the internal half-edge form and rebuilt by
602    /// `to_manifold` on the level above. Instrumenting `to_manifold` against
603    /// `compute_boolean` on a disjoint sphere grid:
604    ///
605    /// ```text
606    ///   n   wall_ms  build_ms  build%  triangles rebuilt vs input
607    ///   8       8.9       3.9   43.8%   3.00x
608    ///  27      51.2      22.2   43.4%   4.85x
609    ///  64     145.7      65.2   44.7%   6.00x
610    /// 125     344.1     147.5   42.9%   6.98x
611    /// ```
612    ///
613    /// Construction is ~43% of wall time and the redundancy grows with `n`:
614    /// at 125 solids the same triangles are rebuilt seven times. Threading
615    /// the built structure through the levels instead would have a ceiling
616    /// of about **1.6x** for this path (perfect reuse, zero conversion
617    /// cost), so the real figure would be lower.
618    ///
619    /// It has NOT been done, deliberately. It requires a new internal type
620    /// boundary carrying a collider and planar grid between levels, which
621    /// makes intermediates heavier in memory, and it touches the code path
622    /// with the recorded ordering nondeterminism -- while this path is
623    /// currently STABLE at 1000 solids. That is a real property to risk for
624    /// a bounded win.
625    ///
626    /// Note the ceiling applies to BATCH paths only. A single
627    /// [`MeshBoolean::boolean`] call has no intermediates -- both operands
628    /// are leaves -- so construction there is irreducible and reuse saves
629    /// exactly nothing. It is not a lead on the single-boolean gap against
630    /// upstream Manifold, which profiling showed to be flat rather than
631    /// hotspot-shaped.
632    ///
633    /// Union is associative and commutative, so any reduction order yields
634    /// the same solid; unlike `subtract_grouped` this needs no disjointness
635    /// precondition and no fusing, and therefore has no correctness cliff.
636    /// Floating-point differences remain -- a differently ordered triangle
637    /// list sums to a marginally different volume -- so the gates compare
638    /// volumes to a relative tolerance, not bitwise.
639    pub(crate) fn union_tree(
640        &self,
641        solids: &[TriMesh],
642        options: &ExecutionOptions,
643    ) -> GeomResult<BooleanOutcome> {
644        // The union of nothing is nothing. Returning an empty solid keeps
645        // this total rather than making every caller special-case it.
646        if solids.is_empty() {
647            let borrowed: Vec<&TriMesh> = Vec::new();
648            let empty = TriMesh::default();
649            let evidence = evidence_for(&empty, &borrowed, &empty, 0);
650            return Ok(BooleanOutcome::new(empty, evidence));
651        }
652
653        // Every solid carries solids[0]'s channel set, so a channel the
654        // result keeps is defined (or explicitly unmapped) on every piece.
655        // Each node carries the fates of the path that built it.
656        let template = &solids[0].attributes;
657        let mut level: Vec<(TriMesh, Fates)> = solids
658            .iter()
659            .map(|s| (conform(s, template), seed_fates(&solids[0])))
660            .collect();
661        let mut sub_operations = 0;
662        while level.len() > 1 {
663            // Every pair at one level is independent: no pair reads another
664            // pair's output, so the level is a pure map. That is the whole
665            // reason this axis can be threaded while intra-solve threading
666            // cannot pay for itself -- there is no coordination inside the
667            // level, only a join at the end of it.
668            let mut pairs = level.chunks_exact(2);
669            let step = |pair: &[(TriMesh, Fates)]| -> GeomResult<(TriMesh, Fates)> {
670                let out = self.union(&pair[0].0, &pair[1].0, options)?;
671                // Both halves' histories, then this union.
672                let history = merge_fates(&pair[0].1, pair[1].1.clone());
673                Ok((
674                    out.mesh,
675                    merge_fates(&history, out.evidence.attribute_fates),
676                ))
677            };
678
679            // Cancellation is polled ONCE per level rather than per pair.
680            // Under rayon the pairs are not ordered, so a per-pair poll
681            // would abort at a schedule-dependent point; per level, the
682            // cut point is deterministic and the partial work discarded is
683            // the same either way.
684            options.check_cancelled()?;
685
686            #[cfg(feature = "parallel-batch")]
687            let mut next: Vec<(TriMesh, Fates)> = {
688                use rayon::prelude::*;
689                // `par_iter` over an indexed slice, NOT `par_bridge`: the
690                // bridge does not preserve order, which would permute the
691                // level and silently change the tree's shape from run to
692                // run. An indexed parallel iterator collects positionally,
693                // so the output is identical to the sequential path.
694                let chunks: Vec<&[(TriMesh, Fates)]> = pairs.clone().collect();
695                let merged: Result<Vec<(TriMesh, Fates)>, GeomError> =
696                    chunks.par_iter().map(|pair| step(pair)).collect();
697                merged?
698            };
699
700            #[cfg(not(feature = "parallel-batch"))]
701            let mut next: Vec<(TriMesh, Fates)> = {
702                let mut acc = Vec::with_capacity(level.len().div_ceil(2));
703                for pair in pairs.clone() {
704                    acc.push(step(pair)?);
705                }
706                acc
707            };
708
709            sub_operations += next.len();
710            // `chunks_exact` was cloned for the merge above, so advance the
711            // original to reach its remainder.
712            for _ in pairs.by_ref() {}
713            // An odd trailing solid rides to the next level uncombined
714            // rather than being unioned into an already-merged neighbour,
715            // which would unbalance the tree this exists to keep balanced.
716            if let Some(last) = pairs.remainder().first() {
717                next.push(last.clone());
718            }
719            level = next;
720        }
721
722        let (mut result, fates) = level.into_iter().next().expect("non-empty input");
723        // A channel some step dropped is not on the result, or only partly:
724        // strip it so the mesh never carries a channel its fate calls lost.
725        strip_dropped(&mut result, &fates);
726        let borrowed: Vec<&TriMesh> = solids.iter().collect();
727        // Evidence names the first operand as the subject and the rest as
728        // tools, matching how a caller reads a batch: one solid grown by the
729        // others. The reduction order is an implementation detail.
730        let (subject, tools) = borrowed.split_first().expect("non-empty input");
731        let evidence =
732            evidence_for(subject, tools, &result, sub_operations).with_attribute_fates(fates);
733        Ok(BooleanOutcome::new(result, evidence))
734    }
735
736    /// One union, routed through the same validation as `boolean`.
737    fn union(
738        &self,
739        subject: &TriMesh,
740        tool: &TriMesh,
741        options: &ExecutionOptions,
742    ) -> GeomResult<BooleanOutcome> {
743        self.boolean(subject, tool, BooleanOperator::Union, options)
744    }
745
746    /// One difference, routed through the same validation as `boolean`.
747    fn difference(
748        &self,
749        subject: &TriMesh,
750        tool: &TriMesh,
751        options: &ExecutionOptions,
752    ) -> GeomResult<BooleanOutcome> {
753        self.boolean(subject, tool, BooleanOperator::Difference, options)
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use axiolid_core::Point3;
761
762    /// A tetrahedron with reversed winding: the shape a faulty backend would
763    /// return if it inverted its output.
764    fn inside_out_tetrahedron() -> TriMesh {
765        let positions = vec![
766            Point3::new(0.0, 0.0, 0.0),
767            Point3::new(1.0, 0.0, 0.0),
768            Point3::new(0.0, 1.0, 0.0),
769            Point3::new(0.0, 0.0, 1.0),
770        ];
771        // Verified: signed volume -1/6, i.e. inward-facing normals.
772        let indices = vec![0, 1, 2, 0, 3, 1, 0, 2, 3, 1, 3, 2];
773        TriMesh::new(positions, indices)
774    }
775
776    fn positive_infinite_volume() -> TriMesh {
777        let large = 1.0e308;
778        let small = 1.0e-308;
779        TriMesh::new(
780            vec![
781                Point3::new(small, small, 1.0),
782                Point3::new(large, 1.0, small),
783                Point3::new(small, large, 1.0),
784            ],
785            vec![0, 1, 2],
786        )
787    }
788
789    /// `check_result` guards against an upstream regression that inverts its
790    /// output. With validated inputs the current `boolmesh` release never does
791    /// this -- verified by instrumenting the branch across the whole suite and
792    /// observing zero hits -- so the guard is exercised directly here rather
793    /// than left as untested defensive code.
794    #[test]
795    fn an_inside_out_result_is_blamed_on_the_backend() {
796        let error = check_result(&inside_out_tetrahedron(), BooleanOperator::Difference)
797            .expect_err("an inside-out result must be rejected");
798        match error {
799            GeomError::BackendContractViolation { backend, detail } => {
800                assert_eq!(backend, BoolmeshBoolean::ID);
801                assert!(detail.contains("inside-out"), "{detail}");
802            }
803            other => panic!("must blame the backend, not the caller: {other:?}"),
804        }
805    }
806
807    #[test]
808    fn a_non_finite_result_is_blamed_on_the_backend() {
809        let error = check_result(&positive_infinite_volume(), BooleanOperator::Union)
810            .expect_err("a non-finite result volume must be rejected");
811        assert!(
812            matches!(error, GeomError::BackendContractViolation { backend, ref detail }
813                if backend == BoolmeshBoolean::ID && detail.contains("non-finite signed volume")),
814            "must blame the backend, got {error:?}"
815        );
816    }
817
818    /// An empty result is legitimate (tool fully contains subject) and must not
819    /// be mistaken for an orientation fault.
820    #[test]
821    fn an_empty_result_is_accepted() {
822        assert!(check_result(&TriMesh::default(), BooleanOperator::Difference).is_ok());
823    }
824
825    /// A correctly oriented result passes.
826    #[test]
827    fn an_outward_result_is_accepted() {
828        let mut mesh = inside_out_tetrahedron();
829        for corner in mesh.indices.chunks_exact_mut(3) {
830            corner.swap(1, 2);
831        }
832        assert!(check_result(&mesh, BooleanOperator::Difference).is_ok());
833    }
834}