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 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.as_chunks_mut::<3>().0 {
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.as_chunks::<3>().0 {
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        .as_chunks::<3>()
322        .0
323        .iter()
324        .map(|t| {
325            let mut v = [t[0], t[1], t[2]];
326            v.sort_unstable();
327            v
328        })
329        .collect();
330    let mut right: Vec<[u32; 3]> = b
331        .indices
332        .as_chunks::<3>()
333        .0
334        .iter()
335        .map(|t| {
336            let mut v = [t[0], t[1], t[2]];
337            v.sort_unstable();
338            v
339        })
340        .collect();
341    left.sort_unstable();
342    right.sort_unstable();
343    left == right
344}
345
346/// Whether any triangle of `a` properly crosses any triangle of `b`.
347///
348/// Uses exact [`orient3d`] signs: `b`'s triangle is crossed when `a`'s vertices
349/// straddle its plane *and* the crossing lies inside the triangle. Shared
350/// vertices and edge contact are not proper crossings.
351fn surfaces_intersect(a: &TriMesh, b: &TriMesh, options: &ExecutionOptions) -> GeomResult<bool> {
352    for left in a.indices.as_chunks::<3>().0 {
353        options.check_cancelled()?;
354        let triangle_a = [
355            a.positions[left[0] as usize],
356            a.positions[left[1] as usize],
357            a.positions[left[2] as usize],
358        ];
359        for right in b.indices.as_chunks::<3>().0 {
360            let triangle_b = [
361                b.positions[right[0] as usize],
362                b.positions[right[1] as usize],
363                b.positions[right[2] as usize],
364            ];
365            if edges_cross_triangle(&triangle_a, &triangle_b)
366                || edges_cross_triangle(&triangle_b, &triangle_a)
367            {
368                return Ok(true);
369            }
370        }
371    }
372    Ok(false)
373}
374
375/// Whether any edge of `edges` passes through the interior of `face`.
376fn edges_cross_triangle(edges: &[Point3; 3], face: &[Point3; 3]) -> bool {
377    let [p, q, r] = *face;
378    for (start, end) in [
379        (edges[0], edges[1]),
380        (edges[1], edges[2]),
381        (edges[2], edges[0]),
382    ] {
383        let side_start = exact_sign(orient3d(p, q, r, start));
384        let side_end = exact_sign(orient3d(p, q, r, end));
385        // Both on one side, or either exactly on the plane: not a proper
386        // crossing. Touching is contact, and contact is not interpenetration.
387        if side_start == Sign::Zero || side_end == Sign::Zero || side_start == side_end {
388            continue;
389        }
390        // The segment pierces the plane; is the hit inside the CLOSED
391        // triangle? Requiring three identical non-zero signs tests the open
392        // interior only, and misses a hit landing exactly on an edge -- which
393        // is precisely where two triangles of a quad meet. Both triangles then
394        // report "no crossing" and interpenetration goes undetected.
395        //
396        // Closed test: the point is inside or on the boundary unless the signs
397        // disagree strictly. Zeros mean "on an edge", which still counts.
398        let signs = [
399            exact_sign(orient3d(start, end, p, q)),
400            exact_sign(orient3d(start, end, q, r)),
401            exact_sign(orient3d(start, end, r, p)),
402        ];
403        let positive = signs.contains(&Sign::Positive);
404        let negative = signs.contains(&Sign::Negative);
405        if !(positive && negative) {
406            return true;
407        }
408    }
409    false
410}
411
412/// Whether `point` lies strictly inside the closed surface `mesh`.
413///
414/// Ray parity along `+x`. Rays that hit a vertex or edge are ambiguous, so the
415/// direction is perturbed and retried rather than resolved by tolerance: an
416/// oracle decides exactly or not at all.
417/// Whether `point` lies strictly inside the closed surface `mesh`.
418///
419/// Exact ray parity: `orient3d` signs decide every crossing, so a point is
420/// never misclassified by a near-miss. Shared with the exact boolean assembly,
421/// which asks the same question per retriangulated piece.
422/// Containment, or `None` when the point cannot be classified exactly.
423///
424/// `contains_point` folds three different situations into `false`: strictly
425/// outside, exactly ON the surface, and every probe direction degenerate.
426/// That is fine for the whole-operand arrangement test, which only ever
427/// samples interior points. It is NOT fine for classifying a retriangulated
428/// piece by its centroid: a centroid can land exactly on the other operand's
429/// edge, and silently calling that "outside" keeps a piece that should be
430/// dropped, leaving a hole in the result.
431pub(crate) fn contains_point_exact(mesh: &TriMesh, point: Point3) -> Option<bool> {
432    const DIRECTIONS: [[f64; 3]; 4] = [
433        [1.0, 0.0, 0.0],
434        [1.0, 0.125, 0.0625],
435        [0.5, 1.0, 0.25],
436        [0.25, 0.5, 1.0],
437    ];
438
439    // A point lying ON the surface is neither inside nor outside; report the
440    // ambiguity rather than picking a side.
441    if on_surface(mesh, point) {
442        return None;
443    }
444    for direction in DIRECTIONS {
445        if let Some(inside) = parity_along(mesh, point, direction) {
446            return Some(inside);
447        }
448    }
449    None
450}
451
452/// Whether `point` lies exactly on one of the mesh's triangles.
453fn on_surface(mesh: &TriMesh, point: Point3) -> bool {
454    mesh.indices.as_chunks::<3>().0.iter().any(|triangle| {
455        let p = mesh.positions[triangle[0] as usize];
456        let q = mesh.positions[triangle[1] as usize];
457        let r = mesh.positions[triangle[2] as usize];
458        exact_sign(orient3d(p, q, r, point)) == Sign::Zero
459            && crate::segment_triangle_relation(point, point, [p, q, r])
460                != crate::SegmentTriangleRelation::Disjoint
461    })
462}
463
464pub(crate) fn contains_point(mesh: &TriMesh, point: Point3) -> bool {
465    // Directions tried in order; each is used only if the previous produced a
466    // degenerate hit. Fixed, so the result stays deterministic.
467    const DIRECTIONS: [[f64; 3]; 4] = [
468        [1.0, 0.0, 0.0],
469        [1.0, 0.125, 0.0625],
470        [0.5, 1.0, 0.25],
471        [0.25, 0.5, 1.0],
472    ];
473
474    for direction in DIRECTIONS {
475        if let Some(inside) = parity_along(mesh, point, direction) {
476            return inside;
477        }
478    }
479    // Every direction was degenerate. Outside is the conservative answer, and
480    // callers only reach here for pathological inputs the oracle refuses.
481    false
482}
483
484/// Count crossings along one ray, or `None` if any hit was degenerate.
485fn parity_along(mesh: &TriMesh, origin: Point3, direction: [f64; 3]) -> Option<bool> {
486    // A point far enough along the ray to be outside any operand: the ray
487    // becomes a segment, which orient3d can answer exactly.
488    let bounds = mesh.bounds();
489    let span = (bounds.max.x - bounds.min.x)
490        .max(bounds.max.y - bounds.min.y)
491        .max(bounds.max.z - bounds.min.z)
492        .max(1.0)
493        * 8.0;
494    let far = Point3::new(
495        origin.x + direction[0] * span,
496        origin.y + direction[1] * span,
497        origin.z + direction[2] * span,
498    );
499
500    let mut crossings = 0usize;
501    for triangle in mesh.indices.as_chunks::<3>().0 {
502        let p = mesh.positions[triangle[0] as usize];
503        let q = mesh.positions[triangle[1] as usize];
504        let r = mesh.positions[triangle[2] as usize];
505
506        let side_origin = exact_sign(orient3d(p, q, r, origin));
507        let side_far = exact_sign(orient3d(p, q, r, far));
508        if side_origin == Sign::Zero {
509            // The point is ON the surface: neither inside nor outside.
510            return Some(false);
511        }
512        if side_far == Sign::Zero || side_origin == side_far {
513            continue;
514        }
515
516        let a = exact_sign(orient3d(origin, far, p, q));
517        let b = exact_sign(orient3d(origin, far, q, r));
518        let c = exact_sign(orient3d(origin, far, r, p));
519        // A zero means the ray grazes an edge or vertex: ambiguous parity, so
520        // this direction cannot be trusted at all.
521        if a == Sign::Zero || b == Sign::Zero || c == Sign::Zero {
522            return None;
523        }
524        if a == b && b == c {
525            crossings += 1;
526        }
527    }
528    Some(crossings % 2 == 1)
529}