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}