Skip to main content

axiolid_reference/
boolean.rs

1//! Scalar reference implementation of solid booleans (ADR 0012, ADR 0017 §5).
2//!
3//! # Why this exists
4//!
5//! ADR 0012 requires a scalar reference to land *before* an optimized provider,
6//! so conformance has something to be judged against. Booleans skipped that
7//! step: `axiolid-mesh-boolean-boolmesh` arrived first and was, for a while, the only
8//! definition of a correct result. A suite that only ever runs one
9//! implementation cannot tell "correct" from "self-consistent".
10//!
11//! # What "reference" means here
12//!
13//! Correctness first, speed never. This deliberately uses the most direct
14//! algorithm that can be reasoned about line by line, because its job is to be
15//! *obviously right*, not fast:
16//!
17//! - Classification is by **exact** [`orient3d`] signs and ray parity, not by
18//!   floating-point distance comparisons.
19//! - Work is `O(n·m)` with no acceleration structure. A BVH would be a second
20//!   thing to get wrong, and an oracle with its own bugs is worse than none.
21//!
22//! # Independence
23//!
24//! This shares no code path with `boolmesh`. It does not subdivide against the
25//! other operand's triangles; it classifies whole triangles by containment and
26//! keeps or drops them. That makes it a genuinely independent implementation
27//! for differential testing, at the cost of only being exact for operands whose
28//! surfaces do not interpenetrate.
29//!
30//! # Honest limits
31//!
32//! [`ScalarBoolean`] refuses inputs it cannot answer exactly rather than
33//! guessing. It reports [`GeomError::Unsupported`] when operand surfaces
34//! properly intersect, because resolving that requires retriangulating along
35//! the intersection curve -- the hard part of a real boolean, and the part an
36//! oracle must not fake. It is exact and total for:
37//!
38//! - disjoint operands (all four operations),
39//! - nested operands (one strictly inside the other),
40//! - identical operands.
41//!
42//! Those cases already pin the algebra: identity, annihilation, idempotence,
43//! and containment. See `tests/oracle.rs`.
44
45use axiolid_contracts::{
46    Backend, BackendDescriptor, BackendId, CancellationGranularity, ExecutionOptions,
47    ExecutionTarget, GeomError, GeomResult, Operation, ScratchRequirement, Sign,
48};
49use axiolid_core::BooleanOperator;
50use axiolid_core::Point3;
51use axiolid_mesh::TriMesh;
52use axiolid_mesh_boolean_contract::{BooleanEvidence, BooleanOutcome, MeshBoolean};
53
54use crate::orient3d;
55
56/// Portable scalar boolean reference.
57///
58/// Not a production provider: `O(n·m)`, and it refuses interpenetrating
59/// surfaces. Registered at low priority so a real provider always wins
60/// dispatch; it exists to be the thing conformance is judged against.
61#[derive(Debug, Default, Clone, Copy)]
62pub struct ScalarBoolean;
63
64impl ScalarBoolean {
65    /// Stable identity for this reference implementation.
66    pub const ID: BackendId = BackendId::new("scalar-reference");
67
68    /// Construct the reference provider.
69    #[must_use]
70    pub fn new() -> Self {
71        Self
72    }
73}
74
75impl Backend for ScalarBoolean {
76    fn descriptor(&self) -> BackendDescriptor {
77        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
78    }
79}
80
81/// How one operand sits relative to the other.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum Arrangement {
84    /// No shared volume and no surface contact.
85    Disjoint,
86    /// `subject` lies entirely within `tool`.
87    SubjectInsideTool,
88    /// `tool` lies entirely within `subject`.
89    ToolInsideSubject,
90    /// Same vertex set and same triangles, up to ordering.
91    Identical,
92}
93
94impl MeshBoolean for ScalarBoolean {
95    /// Exact, so no filter escalation and no scratch beyond the output.
96    fn scratch_requirement(&self) -> ScratchRequirement {
97        ScratchRequirement::None
98    }
99
100    /// Checked per triangle pair, which is the inner loop of the `O(n·m)` scan.
101    fn cancellation_granularity(&self) -> CancellationGranularity {
102        CancellationGranularity::Incremental
103    }
104
105    fn boolean(
106        &self,
107        subject: &TriMesh,
108        tool: &TriMesh,
109        operation: BooleanOperator,
110        options: &ExecutionOptions,
111    ) -> GeomResult<BooleanOutcome> {
112        let arrangement = classify(subject, tool, options)?;
113        let mesh = match (operation, arrangement) {
114            // --- identical operands: idempotence and annihilation ---
115            (BooleanOperator::Union | BooleanOperator::Intersection, Arrangement::Identical) => {
116                subject.clone()
117            }
118            (
119                BooleanOperator::Difference | BooleanOperator::SymmetricDifference,
120                Arrangement::Identical,
121            ) => empty(),
122
123            // --- disjoint operands ---
124            (
125                BooleanOperator::Union | BooleanOperator::SymmetricDifference,
126                Arrangement::Disjoint,
127            ) => concatenate(subject, tool),
128            (BooleanOperator::Intersection, Arrangement::Disjoint) => empty(),
129            (BooleanOperator::Difference, Arrangement::Disjoint) => subject.clone(),
130
131            // --- subject inside tool ---
132            (BooleanOperator::Union, Arrangement::SubjectInsideTool) => tool.clone(),
133            (BooleanOperator::Intersection, Arrangement::SubjectInsideTool) => subject.clone(),
134            (BooleanOperator::Difference, Arrangement::SubjectInsideTool) => empty(),
135            // A shell: outer boundary plus the inner boundary reversed, so the
136            // cavity's normals point into the removed volume.
137            (BooleanOperator::SymmetricDifference, Arrangement::SubjectInsideTool) => {
138                concatenate(tool, &reversed(subject))
139            }
140
141            // --- tool inside subject ---
142            (BooleanOperator::Union, Arrangement::ToolInsideSubject) => subject.clone(),
143            (BooleanOperator::Intersection, Arrangement::ToolInsideSubject) => tool.clone(),
144            (
145                BooleanOperator::Difference | BooleanOperator::SymmetricDifference,
146                Arrangement::ToolInsideSubject,
147            ) => concatenate(subject, &reversed(tool)),
148
149            // The contract is `#[non_exhaustive]`; refuse rather than guess.
150            _ => {
151                return Err(GeomError::Unsupported {
152                    backend: Self::ID,
153                    operation: Operation::MeshBoolean,
154                })
155            }
156        };
157
158        let evidence = BooleanEvidence::record(
159            subject.triangle_count(),
160            tool.triangle_count(),
161            mesh.triangle_count(),
162            components(&mesh),
163        )
164        .with_disjoint_tools(usize::from(arrangement == Arrangement::Disjoint));
165        Ok(BooleanOutcome::new(mesh, evidence))
166    }
167}
168
169/// Exact orientation sign.
170///
171/// [`orient3d`] escalates to exact arithmetic internally and is documented to
172/// always return `Certain`, so `Uncertain` is unreachable. Treating it as
173/// [`Sign::Zero`] keeps that assumption from becoming a panic: a degenerate
174/// answer makes callers refuse or retry, which is the safe direction.
175fn exact_sign(certified: axiolid_contracts::Certified) -> Sign {
176    certified.sign().unwrap_or(Sign::Zero)
177}
178
179/// Empty solid: a legitimate boolean result, not an error.
180fn empty() -> TriMesh {
181    TriMesh::new(Vec::new(), Vec::new())
182}
183
184/// Append `b`'s geometry to `a`'s, rebasing `b`'s indices.
185fn concatenate(a: &TriMesh, b: &TriMesh) -> TriMesh {
186    let offset = a.positions.len() as u32;
187    let mut positions = a.positions.clone();
188    positions.extend_from_slice(&b.positions);
189    let mut indices = a.indices.clone();
190    indices.extend(b.indices.iter().map(|i| i + offset));
191    TriMesh::new(positions, indices)
192}
193
194/// Flip winding so the surface bounds the complement of what it bounded.
195fn reversed(mesh: &TriMesh) -> TriMesh {
196    let mut indices = mesh.indices.clone();
197    for triangle in indices.chunks_exact_mut(3) {
198        triangle.swap(0, 1);
199    }
200    TriMesh::new(mesh.positions.clone(), indices)
201}
202
203/// Connected components over triangle-shared vertices, by union-find.
204fn components(mesh: &TriMesh) -> usize {
205    if mesh.positions.is_empty() {
206        return 0;
207    }
208    let mut parent: Vec<usize> = (0..mesh.positions.len()).collect();
209
210    fn find(parent: &mut [usize], mut node: usize) -> usize {
211        while parent[node] != node {
212            parent[node] = parent[parent[node]];
213            node = parent[node];
214        }
215        node
216    }
217
218    for triangle in mesh.indices.chunks_exact(3) {
219        let root = find(&mut parent, triangle[0] as usize);
220        for corner in &triangle[1..] {
221            let other = find(&mut parent, *corner as usize);
222            if root != other {
223                parent[other] = root;
224            }
225        }
226    }
227
228    let mut roots = std::collections::BTreeSet::new();
229    for index in &mesh.indices {
230        let root = find(&mut parent, *index as usize);
231        roots.insert(root);
232    }
233    roots.len()
234}
235
236/// Decide how the operands sit, or refuse if the answer needs real cutting.
237fn classify(
238    subject: &TriMesh,
239    tool: &TriMesh,
240    options: &ExecutionOptions,
241) -> GeomResult<Arrangement> {
242    // An operand with no geometry is not a solid. Refusing here rather than
243    // indexing `positions[0]` keeps a malformed input from becoming a panic
244    // inside a reference implementation, where a crash is the worst outcome:
245    // it takes down the harness that was supposed to be judging correctness.
246    for (mesh, role) in [(subject, "subject"), (tool, "tool")] {
247        if mesh.positions.is_empty() || mesh.indices.is_empty() {
248            return Err(GeomError::InvalidInput(format!(
249                "{role}: an empty mesh has no interior and cannot be a boolean operand"
250            )));
251        }
252    }
253
254    if same_geometry(subject, tool) {
255        return Ok(Arrangement::Identical);
256    }
257
258    // Surfaces that properly cross require retriangulating along the
259    // intersection curve. An oracle must refuse that rather than approximate
260    // it, so the refusal is explicit and typed.
261    if surfaces_intersect(subject, tool, options)? {
262        return Err(GeomError::Unsupported {
263            backend: ScalarBoolean::ID,
264            operation: Operation::MeshBoolean,
265        });
266    }
267
268    // Non-crossing surfaces: containment is decided by a single vertex, since
269    // the whole operand is on one side.
270    let subject_in_tool = contains_point(tool, subject.positions[0]);
271    let tool_in_subject = contains_point(subject, tool.positions[0]);
272
273    Ok(match (subject_in_tool, tool_in_subject) {
274        (true, false) => Arrangement::SubjectInsideTool,
275        (false, true) => Arrangement::ToolInsideSubject,
276        (false, false) => Arrangement::Disjoint,
277        // Mutual containment is impossible for non-crossing closed surfaces.
278        (true, true) => {
279            return Err(GeomError::Degenerate(
280                "operands report mutual containment, which is geometrically impossible".into(),
281            ))
282        }
283    })
284}
285
286/// Same positions and same triangles, ignoring triangle order.
287fn same_geometry(a: &TriMesh, b: &TriMesh) -> bool {
288    if a.positions.len() != b.positions.len() || a.indices.len() != b.indices.len() {
289        return false;
290    }
291    if a.positions
292        .iter()
293        .zip(&b.positions)
294        .any(|(p, q)| p.x != q.x || p.y != q.y || p.z != q.z)
295    {
296        return false;
297    }
298    let mut left: Vec<[u32; 3]> = a
299        .indices
300        .chunks_exact(3)
301        .map(|t| {
302            let mut v = [t[0], t[1], t[2]];
303            v.sort_unstable();
304            v
305        })
306        .collect();
307    let mut right: Vec<[u32; 3]> = b
308        .indices
309        .chunks_exact(3)
310        .map(|t| {
311            let mut v = [t[0], t[1], t[2]];
312            v.sort_unstable();
313            v
314        })
315        .collect();
316    left.sort_unstable();
317    right.sort_unstable();
318    left == right
319}
320
321/// Whether any triangle of `a` properly crosses any triangle of `b`.
322///
323/// Uses exact [`orient3d`] signs: `b`'s triangle is crossed when `a`'s vertices
324/// straddle its plane *and* the crossing lies inside the triangle. Shared
325/// vertices and edge contact are not proper crossings.
326fn surfaces_intersect(a: &TriMesh, b: &TriMesh, options: &ExecutionOptions) -> GeomResult<bool> {
327    for left in a.indices.chunks_exact(3) {
328        options.check_cancelled()?;
329        let triangle_a = [
330            a.positions[left[0] as usize],
331            a.positions[left[1] as usize],
332            a.positions[left[2] as usize],
333        ];
334        for right in b.indices.chunks_exact(3) {
335            let triangle_b = [
336                b.positions[right[0] as usize],
337                b.positions[right[1] as usize],
338                b.positions[right[2] as usize],
339            ];
340            if edges_cross_triangle(&triangle_a, &triangle_b)
341                || edges_cross_triangle(&triangle_b, &triangle_a)
342            {
343                return Ok(true);
344            }
345        }
346    }
347    Ok(false)
348}
349
350/// Whether any edge of `edges` passes through the interior of `face`.
351fn edges_cross_triangle(edges: &[Point3; 3], face: &[Point3; 3]) -> bool {
352    let [p, q, r] = *face;
353    for (start, end) in [
354        (edges[0], edges[1]),
355        (edges[1], edges[2]),
356        (edges[2], edges[0]),
357    ] {
358        let side_start = exact_sign(orient3d(p, q, r, start));
359        let side_end = exact_sign(orient3d(p, q, r, end));
360        // Both on one side, or either exactly on the plane: not a proper
361        // crossing. Touching is contact, and contact is not interpenetration.
362        if side_start == Sign::Zero || side_end == Sign::Zero || side_start == side_end {
363            continue;
364        }
365        // The segment pierces the plane; is the hit inside the CLOSED
366        // triangle? Requiring three identical non-zero signs tests the open
367        // interior only, and misses a hit landing exactly on an edge -- which
368        // is precisely where two triangles of a quad meet. Both triangles then
369        // report "no crossing" and interpenetration goes undetected.
370        //
371        // Closed test: the point is inside or on the boundary unless the signs
372        // disagree strictly. Zeros mean "on an edge", which still counts.
373        let signs = [
374            exact_sign(orient3d(start, end, p, q)),
375            exact_sign(orient3d(start, end, q, r)),
376            exact_sign(orient3d(start, end, r, p)),
377        ];
378        let positive = signs.contains(&Sign::Positive);
379        let negative = signs.contains(&Sign::Negative);
380        if !(positive && negative) {
381            return true;
382        }
383    }
384    false
385}
386
387/// Whether `point` lies strictly inside the closed surface `mesh`.
388///
389/// Ray parity along `+x`. Rays that hit a vertex or edge are ambiguous, so the
390/// direction is perturbed and retried rather than resolved by tolerance: an
391/// oracle decides exactly or not at all.
392fn contains_point(mesh: &TriMesh, point: Point3) -> bool {
393    // Directions tried in order; each is used only if the previous produced a
394    // degenerate hit. Fixed, so the result stays deterministic.
395    const DIRECTIONS: [[f64; 3]; 4] = [
396        [1.0, 0.0, 0.0],
397        [1.0, 0.125, 0.0625],
398        [0.5, 1.0, 0.25],
399        [0.25, 0.5, 1.0],
400    ];
401
402    for direction in DIRECTIONS {
403        if let Some(inside) = parity_along(mesh, point, direction) {
404            return inside;
405        }
406    }
407    // Every direction was degenerate. Outside is the conservative answer, and
408    // callers only reach here for pathological inputs the oracle refuses.
409    false
410}
411
412/// Count crossings along one ray, or `None` if any hit was degenerate.
413fn parity_along(mesh: &TriMesh, origin: Point3, direction: [f64; 3]) -> Option<bool> {
414    // A point far enough along the ray to be outside any operand: the ray
415    // becomes a segment, which orient3d can answer exactly.
416    let bounds = mesh.bounds();
417    let span = (bounds.max.x - bounds.min.x)
418        .max(bounds.max.y - bounds.min.y)
419        .max(bounds.max.z - bounds.min.z)
420        .max(1.0)
421        * 8.0;
422    let far = Point3::new(
423        origin.x + direction[0] * span,
424        origin.y + direction[1] * span,
425        origin.z + direction[2] * span,
426    );
427
428    let mut crossings = 0usize;
429    for triangle in mesh.indices.chunks_exact(3) {
430        let p = mesh.positions[triangle[0] as usize];
431        let q = mesh.positions[triangle[1] as usize];
432        let r = mesh.positions[triangle[2] as usize];
433
434        let side_origin = exact_sign(orient3d(p, q, r, origin));
435        let side_far = exact_sign(orient3d(p, q, r, far));
436        if side_origin == Sign::Zero {
437            // The point is ON the surface: neither inside nor outside.
438            return Some(false);
439        }
440        if side_far == Sign::Zero || side_origin == side_far {
441            continue;
442        }
443
444        let a = exact_sign(orient3d(origin, far, p, q));
445        let b = exact_sign(orient3d(origin, far, q, r));
446        let c = exact_sign(orient3d(origin, far, r, p));
447        // A zero means the ray grazes an edge or vertex: ambiguous parity, so
448        // this direction cannot be trusted at all.
449        if a == Sign::Zero || b == Sign::Zero || c == Sign::Zero {
450            return None;
451        }
452        if a == b && b == c {
453            crossings += 1;
454        }
455    }
456    Some(crossings % 2 == 1)
457}