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 is exact for:
34//!
35//! - disjoint operands (all four operations),
36//! - nested operands (one strictly inside the other),
37//! - identical operands,
38//! - **properly intersecting surfaces**, via [`crate::exact_boolean`], which
39//!   computes the intersection curve, retriangulates both operands along it,
40//!   and keeps the pieces the operation asks for.
41//!
42//! It still reports [`GeomError::Unsupported`] for coplanar faces that share
43//! an AREA -- two solids flush over a whole face. The shared region's boundary
44//! is currently derived per triangle pair, which yields edges interior to that
45//! region and a curve that branches. Continuing would produce a plausible
46//! wrong answer (measured: a Difference volume of 2.67 where the geometry says
47//! 8), so it refuses instead. Resolving it needs the overlap of the two face
48//! SETS per plane rather than of individual triangle pairs.
49//!
50//! A refusal is typed, so a registry treats it as retryable and another
51//! provider answers.
52//!
53//! Those cases already pin the algebra: identity, annihilation, idempotence,
54//! and containment. See `tests/oracle.rs`.
55
56use axiolid_contracts::{
57    Backend, BackendDescriptor, BackendId, CancellationGranularity, ExecutionOptions,
58    ExecutionTarget, GeomError, GeomResult, Operation, ScratchRequirement, Sign,
59};
60use axiolid_core::BooleanOperator;
61use axiolid_core::Point3;
62use axiolid_mesh::TriMesh;
63use axiolid_mesh_boolean_contract::{BooleanEvidence, BooleanOutcome, MeshBoolean};
64
65use crate::orient3d;
66
67/// Portable scalar boolean reference.
68///
69/// Not a production provider: `O(n·m)`, and it refuses interpenetrating
70/// surfaces. Registered at low priority so a real provider always wins
71/// dispatch; it exists to be the thing conformance is judged against.
72#[derive(Debug, Default, Clone, Copy)]
73pub struct ScalarBoolean;
74
75impl ScalarBoolean {
76    /// Stable identity for this reference implementation.
77    pub const ID: BackendId = BackendId::new("scalar-reference");
78
79    /// Construct the reference provider.
80    #[must_use]
81    pub fn new() -> Self {
82        Self
83    }
84}
85
86impl Backend for ScalarBoolean {
87    fn descriptor(&self) -> BackendDescriptor {
88        BackendDescriptor::new(Self::ID, ExecutionTarget::PortableCpu)
89    }
90}
91
92/// How one operand sits relative to the other.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94enum Arrangement {
95    /// The operand surfaces properly cross.
96    ///
97    /// Answered by [`crate::exact_boolean`], which retriangulates along the
98    /// intersection curve. Kept as its own arrangement rather than an error so
99    /// the decision to route stays visible next to the other cases.
100    Interpenetrating,
101    /// No shared volume and no surface contact.
102    Disjoint,
103    /// `subject` lies entirely within `tool`.
104    SubjectInsideTool,
105    /// `tool` lies entirely within `subject`.
106    ToolInsideSubject,
107    /// Same vertex set and same triangles, up to ordering.
108    Identical,
109}
110
111impl MeshBoolean for ScalarBoolean {
112    /// Exact, so no filter escalation and no scratch beyond the output.
113    fn scratch_requirement(&self) -> ScratchRequirement {
114        ScratchRequirement::None
115    }
116
117    /// Checked per triangle pair, which is the inner loop of the `O(n·m)` scan.
118    fn cancellation_granularity(&self) -> CancellationGranularity {
119        CancellationGranularity::Incremental
120    }
121
122    fn boolean(
123        &self,
124        subject: &TriMesh,
125        tool: &TriMesh,
126        operation: BooleanOperator,
127        options: &ExecutionOptions,
128    ) -> GeomResult<BooleanOutcome> {
129        let arrangement = classify(subject, tool, options)?;
130        let mesh = match (operation, arrangement) {
131            // --- identical operands: idempotence and annihilation ---
132            (BooleanOperator::Union | BooleanOperator::Intersection, Arrangement::Identical) => {
133                subject.clone()
134            }
135            (
136                BooleanOperator::Difference | BooleanOperator::SymmetricDifference,
137                Arrangement::Identical,
138            ) => empty(),
139
140            // --- disjoint operands ---
141            (
142                BooleanOperator::Union | BooleanOperator::SymmetricDifference,
143                Arrangement::Disjoint,
144            ) => concatenate(subject, tool),
145            (BooleanOperator::Intersection, Arrangement::Disjoint) => empty(),
146            (BooleanOperator::Difference, Arrangement::Disjoint) => subject.clone(),
147
148            // --- subject inside tool ---
149            (BooleanOperator::Union, Arrangement::SubjectInsideTool) => tool.clone(),
150            (BooleanOperator::Intersection, Arrangement::SubjectInsideTool) => subject.clone(),
151            (BooleanOperator::Difference, Arrangement::SubjectInsideTool) => empty(),
152            // A shell: outer boundary plus the inner boundary reversed, so the
153            // cavity's normals point into the removed volume.
154            (BooleanOperator::SymmetricDifference, Arrangement::SubjectInsideTool) => {
155                concatenate(tool, &reversed(subject))
156            }
157
158            // --- tool inside subject ---
159            (BooleanOperator::Union, Arrangement::ToolInsideSubject) => subject.clone(),
160            (BooleanOperator::Intersection, Arrangement::ToolInsideSubject) => tool.clone(),
161            (
162                BooleanOperator::Difference | BooleanOperator::SymmetricDifference,
163                Arrangement::ToolInsideSubject,
164            ) => concatenate(subject, &reversed(tool)),
165
166            // Properly crossing surfaces: hand over to the exact path, which
167            // cuts both operands along the curve and keeps the pieces this
168            // operation asks for. It refuses in turn on the shapes it cannot
169            // resolve yet, and that refusal reaches the registry as
170            // `Unsupported` so another provider can answer.
171            (_, Arrangement::Interpenetrating) => crate::exact_boolean(subject, tool, operation)?,
172
173            // The contract is `#[non_exhaustive]`; refuse rather than guess.
174            _ => {
175                return Err(GeomError::Unsupported {
176                    backend: Self::ID,
177                    operation: Operation::MeshBoolean,
178                })
179            }
180        };
181
182        let evidence = BooleanEvidence::record(
183            subject.triangle_count(),
184            tool.triangle_count(),
185            mesh.triangle_count(),
186            components(&mesh),
187        )
188        .with_disjoint_tools(usize::from(arrangement == Arrangement::Disjoint));
189        Ok(BooleanOutcome::new(mesh, evidence))
190    }
191}
192
193/// Exact orientation sign.
194///
195/// [`orient3d`] escalates to exact arithmetic internally and is documented to
196/// always return `Certain`, so `Uncertain` is unreachable. Treating it as
197/// [`Sign::Zero`] keeps that assumption from becoming a panic: a degenerate
198/// answer makes callers refuse or retry, which is the safe direction.
199fn exact_sign(certified: axiolid_contracts::Certified) -> Sign {
200    certified.sign().unwrap_or(Sign::Zero)
201}
202
203/// Empty solid: a legitimate boolean result, not an error.
204fn empty() -> TriMesh {
205    TriMesh::new(Vec::new(), Vec::new())
206}
207
208/// Append `b`'s geometry to `a`'s, rebasing `b`'s indices.
209fn concatenate(a: &TriMesh, b: &TriMesh) -> TriMesh {
210    let offset = a.positions.len() as u32;
211    let mut positions = a.positions.clone();
212    positions.extend_from_slice(&b.positions);
213    let mut indices = a.indices.clone();
214    indices.extend(b.indices.iter().map(|i| i + offset));
215    TriMesh::new(positions, indices)
216}
217
218/// Flip winding so the surface bounds the complement of what it bounded.
219fn reversed(mesh: &TriMesh) -> TriMesh {
220    let mut indices = mesh.indices.clone();
221    for triangle in indices.chunks_exact_mut(3) {
222        triangle.swap(0, 1);
223    }
224    TriMesh::new(mesh.positions.clone(), indices)
225}
226
227/// Connected components over triangle-shared vertices, by union-find.
228fn components(mesh: &TriMesh) -> usize {
229    if mesh.positions.is_empty() {
230        return 0;
231    }
232    let mut parent: Vec<usize> = (0..mesh.positions.len()).collect();
233
234    fn find(parent: &mut [usize], mut node: usize) -> usize {
235        while parent[node] != node {
236            parent[node] = parent[parent[node]];
237            node = parent[node];
238        }
239        node
240    }
241
242    for triangle in mesh.indices.chunks_exact(3) {
243        let root = find(&mut parent, triangle[0] as usize);
244        for corner in &triangle[1..] {
245            let other = find(&mut parent, *corner as usize);
246            if root != other {
247                parent[other] = root;
248            }
249        }
250    }
251
252    let mut roots = std::collections::BTreeSet::new();
253    for index in &mesh.indices {
254        let root = find(&mut parent, *index as usize);
255        roots.insert(root);
256    }
257    roots.len()
258}
259
260/// Decide how the operands sit, or refuse if the answer needs real cutting.
261fn classify(
262    subject: &TriMesh,
263    tool: &TriMesh,
264    options: &ExecutionOptions,
265) -> GeomResult<Arrangement> {
266    // An operand with no geometry is not a solid. Refusing here rather than
267    // indexing `positions[0]` keeps a malformed input from becoming a panic
268    // inside a reference implementation, where a crash is the worst outcome:
269    // it takes down the harness that was supposed to be judging correctness.
270    for (mesh, role) in [(subject, "subject"), (tool, "tool")] {
271        if mesh.positions.is_empty() || mesh.indices.is_empty() {
272            return Err(GeomError::InvalidInput(format!(
273                "{role}: an empty mesh has no interior and cannot be a boolean operand"
274            )));
275        }
276    }
277
278    if same_geometry(subject, tool) {
279        return Ok(Arrangement::Identical);
280    }
281
282    // Surfaces that properly cross need retriangulating along the
283    // intersection curve. That is no longer a refusal: it is a distinct
284    // arrangement, answered by the exact path.
285    if surfaces_intersect(subject, tool, options)? {
286        return Ok(Arrangement::Interpenetrating);
287    }
288
289    // Non-crossing surfaces: containment is decided by a single vertex, since
290    // the whole operand is on one side.
291    let subject_in_tool = contains_point(tool, subject.positions[0]);
292    let tool_in_subject = contains_point(subject, tool.positions[0]);
293
294    Ok(match (subject_in_tool, tool_in_subject) {
295        (true, false) => Arrangement::SubjectInsideTool,
296        (false, true) => Arrangement::ToolInsideSubject,
297        (false, false) => Arrangement::Disjoint,
298        // Mutual containment is impossible for non-crossing closed surfaces.
299        (true, true) => {
300            return Err(GeomError::Degenerate(
301                "operands report mutual containment, which is geometrically impossible".into(),
302            ))
303        }
304    })
305}
306
307/// Same positions and same triangles, ignoring triangle order.
308fn same_geometry(a: &TriMesh, b: &TriMesh) -> bool {
309    if a.positions.len() != b.positions.len() || a.indices.len() != b.indices.len() {
310        return false;
311    }
312    if a.positions
313        .iter()
314        .zip(&b.positions)
315        .any(|(p, q)| p.x != q.x || p.y != q.y || p.z != q.z)
316    {
317        return false;
318    }
319    let mut left: Vec<[u32; 3]> = a
320        .indices
321        .chunks_exact(3)
322        .map(|t| {
323            let mut v = [t[0], t[1], t[2]];
324            v.sort_unstable();
325            v
326        })
327        .collect();
328    let mut right: Vec<[u32; 3]> = b
329        .indices
330        .chunks_exact(3)
331        .map(|t| {
332            let mut v = [t[0], t[1], t[2]];
333            v.sort_unstable();
334            v
335        })
336        .collect();
337    left.sort_unstable();
338    right.sort_unstable();
339    left == right
340}
341
342/// Whether any triangle of `a` properly crosses any triangle of `b`.
343///
344/// Uses exact [`orient3d`] signs: `b`'s triangle is crossed when `a`'s vertices
345/// straddle its plane *and* the crossing lies inside the triangle. Shared
346/// vertices and edge contact are not proper crossings.
347fn surfaces_intersect(a: &TriMesh, b: &TriMesh, options: &ExecutionOptions) -> GeomResult<bool> {
348    for left in a.indices.chunks_exact(3) {
349        options.check_cancelled()?;
350        let triangle_a = [
351            a.positions[left[0] as usize],
352            a.positions[left[1] as usize],
353            a.positions[left[2] as usize],
354        ];
355        for right in b.indices.chunks_exact(3) {
356            let triangle_b = [
357                b.positions[right[0] as usize],
358                b.positions[right[1] as usize],
359                b.positions[right[2] as usize],
360            ];
361            if edges_cross_triangle(&triangle_a, &triangle_b)
362                || edges_cross_triangle(&triangle_b, &triangle_a)
363            {
364                return Ok(true);
365            }
366        }
367    }
368    Ok(false)
369}
370
371/// Whether any edge of `edges` passes through the interior of `face`.
372fn edges_cross_triangle(edges: &[Point3; 3], face: &[Point3; 3]) -> bool {
373    let [p, q, r] = *face;
374    for (start, end) in [
375        (edges[0], edges[1]),
376        (edges[1], edges[2]),
377        (edges[2], edges[0]),
378    ] {
379        let side_start = exact_sign(orient3d(p, q, r, start));
380        let side_end = exact_sign(orient3d(p, q, r, end));
381        // Both on one side, or either exactly on the plane: not a proper
382        // crossing. Touching is contact, and contact is not interpenetration.
383        if side_start == Sign::Zero || side_end == Sign::Zero || side_start == side_end {
384            continue;
385        }
386        // The segment pierces the plane; is the hit inside the CLOSED
387        // triangle? Requiring three identical non-zero signs tests the open
388        // interior only, and misses a hit landing exactly on an edge -- which
389        // is precisely where two triangles of a quad meet. Both triangles then
390        // report "no crossing" and interpenetration goes undetected.
391        //
392        // Closed test: the point is inside or on the boundary unless the signs
393        // disagree strictly. Zeros mean "on an edge", which still counts.
394        let signs = [
395            exact_sign(orient3d(start, end, p, q)),
396            exact_sign(orient3d(start, end, q, r)),
397            exact_sign(orient3d(start, end, r, p)),
398        ];
399        let positive = signs.contains(&Sign::Positive);
400        let negative = signs.contains(&Sign::Negative);
401        if !(positive && negative) {
402            return true;
403        }
404    }
405    false
406}
407
408/// Whether `point` lies strictly inside the closed surface `mesh`.
409///
410/// Ray parity along `+x`. Rays that hit a vertex or edge are ambiguous, so the
411/// direction is perturbed and retried rather than resolved by tolerance: an
412/// oracle decides exactly or not at all.
413/// Whether `point` lies strictly inside the closed surface `mesh`.
414///
415/// Exact ray parity: `orient3d` signs decide every crossing, so a point is
416/// never misclassified by a near-miss. Shared with the exact boolean assembly,
417/// which asks the same question per retriangulated piece.
418/// Containment, or `None` when the point cannot be classified exactly.
419///
420/// `contains_point` folds three different situations into `false`: strictly
421/// outside, exactly ON the surface, and every probe direction degenerate.
422/// That is fine for the whole-operand arrangement test, which only ever
423/// samples interior points. It is NOT fine for classifying a retriangulated
424/// piece by its centroid: a centroid can land exactly on the other operand's
425/// edge, and silently calling that "outside" keeps a piece that should be
426/// dropped, leaving a hole in the result.
427pub(crate) fn contains_point_exact(mesh: &TriMesh, point: Point3) -> Option<bool> {
428    const DIRECTIONS: [[f64; 3]; 4] = [
429        [1.0, 0.0, 0.0],
430        [1.0, 0.125, 0.0625],
431        [0.5, 1.0, 0.25],
432        [0.25, 0.5, 1.0],
433    ];
434
435    // A point lying ON the surface is neither inside nor outside; report the
436    // ambiguity rather than picking a side.
437    if on_surface(mesh, point) {
438        return None;
439    }
440    for direction in DIRECTIONS {
441        if let Some(inside) = parity_along(mesh, point, direction) {
442            return Some(inside);
443        }
444    }
445    None
446}
447
448/// Whether `point` lies exactly on one of the mesh's triangles.
449fn on_surface(mesh: &TriMesh, point: Point3) -> bool {
450    mesh.indices.chunks_exact(3).any(|triangle| {
451        let p = mesh.positions[triangle[0] as usize];
452        let q = mesh.positions[triangle[1] as usize];
453        let r = mesh.positions[triangle[2] as usize];
454        exact_sign(orient3d(p, q, r, point)) == Sign::Zero
455            && crate::segment_triangle_relation(point, point, [p, q, r])
456                != crate::SegmentTriangleRelation::Disjoint
457    })
458}
459
460pub(crate) fn contains_point(mesh: &TriMesh, point: Point3) -> bool {
461    // Directions tried in order; each is used only if the previous produced a
462    // degenerate hit. Fixed, so the result stays deterministic.
463    const DIRECTIONS: [[f64; 3]; 4] = [
464        [1.0, 0.0, 0.0],
465        [1.0, 0.125, 0.0625],
466        [0.5, 1.0, 0.25],
467        [0.25, 0.5, 1.0],
468    ];
469
470    for direction in DIRECTIONS {
471        if let Some(inside) = parity_along(mesh, point, direction) {
472            return inside;
473        }
474    }
475    // Every direction was degenerate. Outside is the conservative answer, and
476    // callers only reach here for pathological inputs the oracle refuses.
477    false
478}
479
480/// Count crossings along one ray, or `None` if any hit was degenerate.
481fn parity_along(mesh: &TriMesh, origin: Point3, direction: [f64; 3]) -> Option<bool> {
482    // A point far enough along the ray to be outside any operand: the ray
483    // becomes a segment, which orient3d can answer exactly.
484    let bounds = mesh.bounds();
485    let span = (bounds.max.x - bounds.min.x)
486        .max(bounds.max.y - bounds.min.y)
487        .max(bounds.max.z - bounds.min.z)
488        .max(1.0)
489        * 8.0;
490    let far = Point3::new(
491        origin.x + direction[0] * span,
492        origin.y + direction[1] * span,
493        origin.z + direction[2] * span,
494    );
495
496    let mut crossings = 0usize;
497    for triangle in mesh.indices.chunks_exact(3) {
498        let p = mesh.positions[triangle[0] as usize];
499        let q = mesh.positions[triangle[1] as usize];
500        let r = mesh.positions[triangle[2] as usize];
501
502        let side_origin = exact_sign(orient3d(p, q, r, origin));
503        let side_far = exact_sign(orient3d(p, q, r, far));
504        if side_origin == Sign::Zero {
505            // The point is ON the surface: neither inside nor outside.
506            return Some(false);
507        }
508        if side_far == Sign::Zero || side_origin == side_far {
509            continue;
510        }
511
512        let a = exact_sign(orient3d(origin, far, p, q));
513        let b = exact_sign(orient3d(origin, far, q, r));
514        let c = exact_sign(orient3d(origin, far, r, p));
515        // A zero means the ray grazes an edge or vertex: ambiguous parity, so
516        // this direction cannot be trusted at all.
517        if a == Sign::Zero || b == Sign::Zero || c == Sign::Zero {
518            return None;
519        }
520        if a == b && b == c {
521            crossings += 1;
522        }
523    }
524    Some(crossings % 2 == 1)
525}