Skip to main content

axiolid_mesh_boolean_contract/
contract.rs

1//! Portable mesh-boolean provider contract.
2
3use axiolid_contracts::{
4    Backend, CancellationGranularity, Determinism, ExecutionOptions, GeomResult, ScratchRequirement,
5};
6use axiolid_core::BooleanOperator;
7use axiolid_mesh::TriMesh;
8use axiolid_mesh_contracts::SolidRequirements;
9
10use crate::{merge_fates, BooleanEvidence, BooleanOutcome};
11
12/// Mesh boolean provider.
13///
14/// Implementing this trait is the capability declaration. Providers that do not
15/// implement mesh booleans must not implement this trait.
16pub trait MeshBoolean: Backend {
17    /// Scratch this provider needs beyond its inputs and result.
18    ///
19    /// Callers budget against this before dispatch. Defaults to
20    /// [`ScratchRequirement::Unbounded`] so an unaudited provider is treated as
21    /// unbudgetable rather than silently assumed cheap.
22    ///
23    /// The elements are the input triangles of every operand together, which
24    /// is what `axiolid-dispatch` charges. A provider that runs on a thread
25    /// pool declares what each worker costs as well
26    /// ([`ScratchRequirement::Affine`]), since that cost does not shrink with
27    /// the input (#226).
28    ///
29    /// For [`Self::subtract_many`] and [`Self::union_many`] it bounds each
30    /// boolean of the batch on that step's own operands. The batch as a
31    /// whole has no bound in its input triangles: a union's result can have
32    /// more triangles than its operands, so later steps run on larger
33    /// intermediates, and a batch that runs booleans concurrently has
34    /// several live at once. A provider whose batch holds intermediates or
35    /// runs concurrently charges each step when its operands exist, and
36    /// refuses with [`GeomError::BudgetExceeded`] before the first that does
37    /// not fit (#259). The registry's check before a batch, in the input
38    /// triangles, is then a necessary condition and not a sufficient one.
39    ///
40    /// [`GeomError::BudgetExceeded`]: axiolid_contracts::GeomError::BudgetExceeded
41    fn scratch_requirement(&self) -> ScratchRequirement {
42        ScratchRequirement::Unbounded
43    }
44
45    /// Reproducibility this provider guarantees for its results.
46    ///
47    /// Defaults to [`Determinism::BestEffort`]: the weakest level, so a
48    /// provider that has not audited its own reproducibility cannot silently
49    /// satisfy a stronger request. Overstating this is the dangerous
50    /// direction — `Plan::admit` refuses a step whose guarantee is weaker
51    /// than the caller asked for, and that refusal is only sound if the
52    /// declared level is honest.
53    ///
54    /// A provider whose output depends on thread scheduling, hash seeding, or
55    /// any other run-to-run variation must not claim [`Determinism::Bitwise`].
56    fn determinism(&self) -> Determinism {
57        Determinism::BestEffort
58    }
59
60    /// How finely this provider polls a cancellation token.
61    ///
62    /// Defaults to [`CancellationGranularity::None`]: a provider that has not
63    /// declared otherwise is assumed not to poll. Claiming responsiveness a
64    /// provider does not have is worse than admitting none.
65    fn cancellation_granularity(&self) -> CancellationGranularity {
66        CancellationGranularity::None
67    }
68
69    /// Admissibility this provider requires of its operands.
70    ///
71    /// Advisory only: the registry validates at the contract level before
72    /// dispatch. A provider declaring a *lower* level does not thereby get to
73    /// accept looser input, and one declaring a higher level is rejected by the
74    /// conformance suite for narrowing the contract.
75    fn solid_requirements(&self) -> SolidRequirements {
76        SolidRequirements::Oriented
77    }
78
79    /// Apply one regularized set operation.
80    ///
81    /// Operands are pre-validated by the registry. Returns a
82    /// [`BooleanOutcome`]: the mesh plus what was done to produce it. An empty
83    /// result mesh is a legitimate value, not an error.
84    fn boolean(
85        &self,
86        subject: &TriMesh,
87        tool: &TriMesh,
88        operation: BooleanOperator,
89        options: &ExecutionOptions,
90    ) -> GeomResult<BooleanOutcome>;
91
92    /// Subtract many tools in one batch so implementations can union or schedule
93    /// cutters efficiently. The default is correct but deliberately simple.
94    ///
95    /// The default polls cancellation between tools, which is why the default
96    /// granularity for an overriding provider must be declared honestly.
97    fn subtract_many(
98        &self,
99        subject: &TriMesh,
100        tools: &[TriMesh],
101        options: &ExecutionOptions,
102    ) -> GeomResult<BooleanOutcome> {
103        let mut evidence = BooleanEvidence {
104            subject_triangles: subject.triangle_count(),
105            tool_triangles: tools.iter().map(TriMesh::triangle_count).sum(),
106            output_triangles: subject.triangle_count(),
107            output_components: 1,
108            ..BooleanEvidence::default()
109        };
110        let mut result = subject.clone();
111        for tool in tools {
112            options.check_cancelled()?;
113            let outcome = self.boolean(&result, tool, BooleanOperator::Difference, options)?;
114            evidence.absorb(outcome.evidence);
115            result = outcome.mesh;
116        }
117        Ok(BooleanOutcome::new(result, evidence))
118    }
119
120    /// Union many solids in one batch so implementations can choose a
121    /// reduction order.
122    ///
123    /// The default folds left, which is correct but makes step `i` pay for
124    /// an accumulator holding `i` operands -- quadratic total work. A
125    /// provider that can do better should override this; see
126    /// `BoolmeshBoolean::union_tree` for a balanced reduction that measures
127    /// 28.9x faster on a 512-sphere grid.
128    ///
129    /// An empty slice yields an empty solid: the union of nothing is
130    /// nothing, which is a legitimate answer rather than an error.
131    ///
132    /// The default polls cancellation between operands, which is why the
133    /// declared granularity for an overriding provider must stay honest.
134    fn union_many(
135        &self,
136        solids: &[TriMesh],
137        options: &ExecutionOptions,
138    ) -> GeomResult<BooleanOutcome> {
139        let Some((first, rest)) = solids.split_first() else {
140            return Ok(BooleanOutcome::new(
141                TriMesh::default(),
142                BooleanEvidence::default(),
143            ));
144        };
145
146        let mut evidence = BooleanEvidence {
147            subject_triangles: first.triangle_count(),
148            tool_triangles: rest.iter().map(TriMesh::triangle_count).sum(),
149            output_triangles: first.triangle_count(),
150            output_components: 1,
151            ..BooleanEvidence::default()
152        };
153        let mut result = first.clone();
154        for solid in rest {
155            options.check_cancelled()?;
156            let outcome = self.boolean(&result, solid, BooleanOperator::Union, options)?;
157            evidence.absorb(outcome.evidence);
158            result = outcome.mesh;
159        }
160        Ok(BooleanOutcome::new(result, evidence))
161    }
162}
163
164/// Compose `A △ B` as `(A ∪ B) \ (A ∩ B)`.
165///
166/// Free-standing rather than a trait default so a provider cannot accidentally
167/// inherit a composed implementation while reporting `sub_operations: 1`. A
168/// native implementor overrides [`MeshBoolean::boolean`] and never calls this.
169///
170/// Composition is the reason `BooleanEvidence::sub_operations` exists: without
171/// it a caller cannot tell a three-pass emulation from a single-pass primitive,
172/// and the two have materially different numerical behaviour.
173pub fn symmetric_difference_via_composition<P>(
174    provider: &P,
175    subject: &TriMesh,
176    tool: &TriMesh,
177    options: &ExecutionOptions,
178) -> GeomResult<BooleanOutcome>
179where
180    P: MeshBoolean + ?Sized,
181{
182    options.check_cancelled()?;
183    let union = provider.boolean(subject, tool, BooleanOperator::Union, options)?;
184    options.check_cancelled()?;
185    let intersection = provider.boolean(subject, tool, BooleanOperator::Intersection, options)?;
186
187    // A ∩ B empty means the operands are disjoint, so A △ B == A ∪ B. Skipping
188    // the final difference is not just an optimisation: subtracting an empty
189    // solid is a degenerate operand many backends reject.
190    if intersection.mesh.indices.is_empty() {
191        let mut evidence = union.evidence;
192        evidence.sub_operations = 2;
193        evidence.coincident_faces_encountered |= intersection.evidence.coincident_faces_encountered;
194        return Ok(BooleanOutcome::new(union.mesh, evidence));
195    }
196
197    options.check_cancelled()?;
198    let difference = provider.boolean(
199        &union.mesh,
200        &intersection.mesh,
201        BooleanOperator::Difference,
202        options,
203    )?;
204
205    let mut evidence = difference.evidence;
206    // The result's channels came through union then difference: compose
207    // those fates. The difference alone would report `Preserved` for values
208    // the union already derived.
209    evidence.attribute_fates =
210        merge_fates(&union.evidence.attribute_fates, evidence.attribute_fates);
211    evidence.subject_triangles = subject.triangle_count();
212    evidence.tool_triangles = tool.triangle_count();
213    evidence.sub_operations = 3;
214    evidence.coincident_faces_encountered |= union.evidence.coincident_faces_encountered
215        || intersection.evidence.coincident_faces_encountered;
216    Ok(BooleanOutcome::new(difference.mesh, evidence))
217}