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(¤t, &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(¤t, &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, ¤t, 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}