axiolid_mesh_boolean_contract/evidence.rs
1//! What a boolean actually did, alongside the mesh it produced.
2//!
3//! `axiolid-overlay` returns `OverlayResult`; `axiolid-field` returns
4//! `FieldEvidence`. The 3D boolean previously returned a bare `TriMesh`, which
5//! made the operation most in need of diagnostics the only one without any.
6//! This module closes that gap with the same mental model: *what did the kernel
7//! actually do to my geometry?*
8
9use axiolid_mesh::{AttributeFate, DropReason, TriMesh};
10
11/// Counters describing one boolean evaluation.
12///
13/// Every field is a fact about the computation, never a quality verdict. A
14/// caller decides whether a given count is acceptable for its domain.
15#[derive(Debug, Clone, PartialEq, Default)]
16#[non_exhaustive]
17pub struct BooleanEvidence {
18 /// Triangles in the subject operand as supplied.
19 pub subject_triangles: usize,
20 /// Triangles across all tool operands as supplied.
21 pub tool_triangles: usize,
22 /// Triangles in the result.
23 pub output_triangles: usize,
24 /// Connected components in the result.
25 ///
26 /// A difference that splits a wall into two pieces reports `2`. Callers
27 /// that expect a single solid can detect the split instead of discovering
28 /// it downstream in a quantity takeoff.
29 pub output_components: usize,
30 /// Tool operands that did not intersect the subject at all.
31 ///
32 /// A no-op cut is usually a placement bug upstream, but it is not an error
33 /// here, so it is reported rather than rejected.
34 pub disjoint_tools: usize,
35 /// Sub-operations executed, for composed operands.
36 ///
37 /// `SymmetricDifference` composed from union, intersection, and difference
38 /// reports `3`; a native implementation reports `1`. This is how a caller
39 /// tells a composed path from a primitive one.
40 pub sub_operations: usize,
41 /// What happened to each named attribute channel on the subject.
42 ///
43 /// A boolean creates vertices along the cut that have no preimage in
44 /// either operand, so a channel cannot always survive. Reporting the
45 /// fate per channel is what turns a silent loss into an answer: the
46 /// caller learns the data is gone AND why, instead of comparing the
47 /// mesh before and after to find out.
48 pub attribute_fates: Vec<(String, AttributeFate)>,
49 /// Whether the provider detected coincident faces between operands.
50 ///
51 /// Coincident faces are the dominant source of cross-kernel disagreement.
52 /// Reporting the encounter lets a caller treat those results with more
53 /// care without Axiolid choosing a policy on its behalf.
54 pub coincident_faces_encountered: bool,
55 /// Whether an analytic closed-form path produced this result instead of the
56 /// general boolean solver.
57 ///
58 /// An analytic path is exact only for the operand shapes it recognises, and
59 /// it produces a different (though equally valid) triangulation from the
60 /// general solver. A caller comparing results across runs, or reproducing a
61 /// result elsewhere, needs to know which machinery ran -- the same reason
62 /// [`Self::sub_operations`] distinguishes a composed result from a
63 /// primitive one.
64 pub analytic_path: bool,
65 /// Relative overlap between operands, when the provider measured it.
66 ///
67 /// The smallest operand-overlap extent divided by the operand size, so it
68 /// is scale-free: two cubes overlapping by 1mm at metre scale and by 1um
69 /// at millimetre scale report the same number. `None` means the provider
70 /// did not measure conditioning, never that the input was well
71 /// conditioned.
72 ///
73 /// Construction is f64 ([ADR 0045](../adr/0045-boolean-construction-arithmetic.md)),
74 /// so accuracy degrades as this approaches zero: invisible above 1e-6,
75 /// smooth between 1e-6 and 1e-12, severe below 1e-12. This is a fact
76 /// about the computation, not a verdict -- a caller decides what its
77 /// domain tolerates.
78 pub relative_overlap: Option<f64>,
79}
80
81impl BooleanEvidence {
82 /// Record one completed operation.
83 ///
84 /// A constructor rather than struct-literal syntax because the type is
85 /// `#[non_exhaustive]`: out-of-tree providers must be able to build
86 /// evidence without breaking when a counter is added.
87 pub fn record(
88 subject_triangles: usize,
89 tool_triangles: usize,
90 output_triangles: usize,
91 output_components: usize,
92 ) -> Self {
93 Self {
94 subject_triangles,
95 tool_triangles,
96 output_triangles,
97 output_components,
98 disjoint_tools: 0,
99 sub_operations: 1,
100 coincident_faces_encountered: false,
101 analytic_path: false,
102 relative_overlap: None,
103 attribute_fates: Vec::new(),
104 }
105 }
106
107 /// Record what happened to the subject's attribute channels.
108 ///
109 /// A provider that carries no attributes still calls this, reporting
110 /// every channel as dropped: an empty list would be indistinguishable
111 /// from a subject that had no channels to begin with.
112 #[must_use]
113 pub fn with_attribute_fates(mut self, fates: Vec<(String, AttributeFate)>) -> Self {
114 self.attribute_fates = fates;
115 self
116 }
117
118 /// Record the measured relative overlap between operands.
119 ///
120 /// Only a provider that actually measured conditioning may call this;
121 /// leaving the field `None` is the honest default for one that did not.
122 pub const fn with_relative_overlap(mut self, overlap: f64) -> Self {
123 self.relative_overlap = Some(overlap);
124 self
125 }
126
127 /// Record that an analytic closed-form path produced this result.
128 pub const fn with_analytic_path(mut self, analytic: bool) -> Self {
129 self.analytic_path = analytic;
130 self
131 }
132
133 /// Set the count of tools that did not meet the subject.
134 pub const fn with_disjoint_tools(mut self, count: usize) -> Self {
135 self.disjoint_tools = count;
136 self
137 }
138
139 /// Set how many sub-operations produced this result.
140 pub const fn with_sub_operations(mut self, count: usize) -> Self {
141 self.sub_operations = count;
142 self
143 }
144
145 /// Record that coincident faces were encountered between operands.
146 pub const fn with_coincident_faces(mut self, encountered: bool) -> Self {
147 self.coincident_faces_encountered = encountered;
148 self
149 }
150
151 /// Merge evidence from a sub-operation into a running total.
152 ///
153 /// Input counts come from the outermost call, so they are kept rather than
154 /// summed; output counts and flags come from the final sub-operation.
155 pub fn absorb(&mut self, other: Self) {
156 self.output_triangles = other.output_triangles;
157 self.output_components = other.output_components;
158 self.disjoint_tools += other.disjoint_tools;
159 self.sub_operations += other.sub_operations;
160 self.coincident_faces_encountered |= other.coincident_faces_encountered;
161 // Sticky: if any sub-operation took the analytic path, the composed
162 // result is not purely a general-solver product and must not claim to
163 // be.
164 self.analytic_path |= other.analytic_path;
165 self.attribute_fates = merge_fates(&self.attribute_fates, other.attribute_fates);
166 // Worst case wins: a composed result is only as well conditioned as
167 // its least well conditioned sub-operation. Taking the last or the
168 // best would let a clean final step mask a degenerate earlier one.
169 self.relative_overlap = match (self.relative_overlap, other.relative_overlap) {
170 (Some(a), Some(b)) => Some(a.min(b)),
171 (Some(a), None) => Some(a),
172 (None, b) => b,
173 };
174 }
175}
176
177/// Compose per-channel fates across two sequential steps.
178///
179/// Keyed by name, in `earlier`'s order. A channel `earlier` tracked that
180/// `later` does not mention was not on `later`'s input, so it was lost
181/// there: dropped, keeping any earlier reason. When `earlier` is empty (the
182/// first step of a fold over an evidence seeded without fates) `later` is
183/// taken as is.
184///
185/// Public so providers composing their own batch paths merge identically.
186pub fn merge_fates(
187 earlier: &[(String, AttributeFate)],
188 later: Vec<(String, AttributeFate)>,
189) -> Vec<(String, AttributeFate)> {
190 if earlier.is_empty() {
191 return later;
192 }
193 earlier
194 .iter()
195 .map(|(name, fate)| {
196 let next = later
197 .iter()
198 .find(|(n, _)| n == name)
199 .map(|(_, f)| f.clone())
200 .unwrap_or(AttributeFate::Dropped(DropReason::ProviderLimitation));
201 (name.clone(), fate.clone().then(next))
202 })
203 .collect()
204}
205
206/// A boolean result: the mesh plus what was done to produce it.
207#[derive(Debug, Clone, PartialEq)]
208#[non_exhaustive]
209pub struct BooleanOutcome {
210 /// Resulting solid. An empty mesh is a valid answer, not a failure:
211 /// `A ∩ B` for disjoint operands is legitimately empty.
212 pub mesh: TriMesh,
213 /// What the provider did.
214 pub evidence: BooleanEvidence,
215}
216
217impl BooleanOutcome {
218 /// Pair a mesh with its evidence.
219 pub const fn new(mesh: TriMesh, evidence: BooleanEvidence) -> Self {
220 Self { mesh, evidence }
221 }
222
223 /// Whether the operation produced no geometry.
224 pub fn is_empty(&self) -> bool {
225 self.mesh.indices.is_empty()
226 }
227}