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}