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 ¤t {
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(¤t, only, options, budget)?
830 } else {
831 let fused = crate::grouping::fuse_with_channels(&members, &subject.attributes);
832 self.difference(¤t, &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, ¤t, 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}