axiolid_reference/assemble.rs
1//! Assembling an exact boolean result from retriangulated operands.
2//!
3//! # The three steps
4//!
5//! 1. [`intersection_segments`] finds WHERE the
6//! two surfaces cross.
7//! 2. [`retriangulate_face`] rebuilds each cut face
8//! so the curve exists as mesh edges.
9//! 3. This module decides which of the resulting pieces to keep.
10//!
11//! # Why step 2 makes step 3 easy
12//!
13//! After retriangulation no triangle straddles the other solid's surface:
14//! each one lies wholly inside or wholly outside. So a single containment
15//! test per triangle settles it, and the test can be taken at the centroid --
16//! a point guaranteed to be in the triangle's interior, away from the
17//! boundary where classification is ambiguous.
18//!
19//! Without step 2 this would be false: a triangle crossing the surface has no
20//! single answer, and sampling it anywhere would be a guess.
21//!
22//! # Winding
23//!
24//! `Difference` keeps the subject's outside and the tool's inside, but the
25//! tool's kept faces must be REVERSED: they become the cavity wall, and a
26//! cavity's outward normal points into the removed volume. Getting this
27//! wrong produces a mesh that looks right and has the wrong sign everywhere.
28
29use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation};
30use axiolid_core::{BooleanOperator, Point3};
31use axiolid_mesh::TriMesh;
32
33use crate::boolean::contains_point_exact;
34use crate::intersection::{intersection_segments, IntersectionSegment, NodeKey};
35use crate::retriangulate::retriangulate_face;
36use std::collections::BTreeMap;
37
38/// Which side of the other solid a piece lies on.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum Side {
41 /// Strictly inside the other operand.
42 Inside,
43 /// Strictly outside it.
44 Outside,
45}
46
47/// Rebuild one operand's faces against the curve, and classify each piece.
48///
49/// Returns the retriangulated triangles paired with the side they fall on,
50/// so the caller can keep whichever the operation asks for.
51fn split_and_classify(
52 mesh: &TriMesh,
53 other: &TriMesh,
54 face_segments: &BTreeMap<u32, Vec<IntersectionSegment>>,
55 positions: &BTreeMap<NodeKey, Point3>,
56) -> GeomResult<Vec<([Point3; 3], Side)>> {
57 let mut pieces = Vec::new();
58
59 for face_index in 0..mesh.triangle_count() {
60 let base = face_index * 3;
61 let corners: [Point3; 3] = [
62 mesh.positions[mesh.indices[base] as usize],
63 mesh.positions[mesh.indices[base + 1] as usize],
64 mesh.positions[mesh.indices[base + 2] as usize],
65 ];
66
67 let key = u32::try_from(face_index).unwrap_or(u32::MAX);
68 let empty: Vec<IntersectionSegment> = Vec::new();
69 let segments = face_segments.get(&key).unwrap_or(&empty);
70 let patch = retriangulate_face(corners, segments, positions)?;
71
72 for triangle in &patch.triangles {
73 let [a, b, c] = triangle.map(|i| patch.points[i as usize]);
74 // The centroid is interior to its OWN piece, but that says
75 // nothing about where it falls relative to the other operand: it
76 // can land exactly on the other's face, edge, or corner. Measured,
77 // not assumed -- a corner-overlap subtraction puts a centroid on
78 // exactly (2,4,2), the tool's corner edge.
79 let centroid = Point3::new(
80 (a.x + b.x + c.x) / 3.0,
81 (a.y + b.y + c.y) / 3.0,
82 (a.z + b.z + c.z) / 3.0,
83 );
84 // A centroid on the other operand's surface is unclassifiable, so
85 // retry from points nudged toward each corner. These stay strictly
86 // inside the piece -- so they classify the SAME piece -- while
87 // moving off whatever feature the centroid landed on. Refusing
88 // beats guessing: treating an on-surface centroid as "outside"
89 // keeps a piece whose neighbours were dropped, leaving a hole that
90 // still reports a plausible volume.
91 let probes = [
92 centroid,
93 lerp(centroid, a, 0.25),
94 lerp(centroid, b, 0.25),
95 lerp(centroid, c, 0.25),
96 ];
97 let inside = probes
98 .into_iter()
99 .find_map(|probe| contains_point_exact(other, probe));
100 let side = match inside {
101 Some(true) => Side::Inside,
102 Some(false) => Side::Outside,
103 None => {
104 return Err(GeomError::Unsupported {
105 backend: BackendId::new("scalar-assemble"),
106 operation: Operation::MeshBoolean,
107 })
108 }
109 };
110 pieces.push(([a, b, c], side));
111 }
112 }
113 Ok(pieces)
114}
115
116/// Compute a boolean of two interpenetrating solids, exactly.
117///
118/// This is the case [`ScalarBoolean`](crate::ScalarBoolean) refuses: surfaces
119/// that properly cross. Every decision is an exact predicate -- the curve from
120/// `orient3d` signs, the retriangulation from `orient2d` signs, the
121/// classification from ray parity -- so the result is not a tolerance
122/// approximation of the answer, it is the answer.
123///
124/// # Errors
125///
126/// Propagates the curve's refusals: coplanar face overlap, degenerate faces,
127/// and a curve that cannot be stitched. Refusing is deliberate; a boolean
128/// that guesses in those cases is worse than one that declines.
129pub fn exact_boolean(
130 subject: &TriMesh,
131 tool: &TriMesh,
132 operation: BooleanOperator,
133) -> GeomResult<TriMesh> {
134 let curve = intersection_segments(subject, tool)?;
135
136 let subject_pieces = split_and_classify(
137 subject,
138 tool,
139 &curve.subject_face_segments,
140 &curve.positions,
141 )?;
142 let tool_pieces =
143 split_and_classify(tool, subject, &curve.tool_face_segments, &curve.positions)?;
144
145 // Which side of each operand the operation keeps, and whether the tool's
146 // kept faces have to be flipped.
147 //
148 // Difference keeps the subject's outside and the tool's inside, and the
149 // tool's faces become the cavity wall: their outward normal must point
150 // INTO the removed volume, so they are reversed. Union and Intersection
151 // keep consistently-oriented faces from both, so they are not.
152 let (keep_subject, keep_tool, flip_tool) = match operation {
153 BooleanOperator::Union => (Side::Outside, Side::Outside, false),
154 BooleanOperator::Intersection => (Side::Inside, Side::Inside, false),
155 BooleanOperator::Difference => (Side::Outside, Side::Inside, true),
156 _ => {
157 return Err(axiolid_contracts::GeomError::Unsupported {
158 backend: axiolid_contracts::BackendId::new("scalar-exact-boolean"),
159 operation: Operation::MeshBoolean,
160 })
161 }
162 };
163
164 let mut positions: Vec<Point3> = Vec::new();
165 let mut indices: Vec<u32> = Vec::new();
166
167 // Vertices are welded on exact coordinate bits, the same discipline the
168 // curve uses for node identity. Two pieces that meet along the cut were
169 // computed from the same arithmetic, so their shared corners are
170 // bit-identical and join into one vertex -- leaving the result closed
171 // rather than a shell of unconnected triangles.
172 let mut welded: BTreeMap<[u64; 3], u32> = BTreeMap::new();
173 let mut push = |point: Point3, positions: &mut Vec<Point3>| -> u32 {
174 let bits = [point.x + 0.0, point.y + 0.0, point.z + 0.0].map(f64::to_bits);
175 *welded.entry(bits).or_insert_with(|| {
176 positions.push(point);
177 (positions.len() - 1) as u32
178 })
179 };
180
181 for (triangle, side) in &subject_pieces {
182 if *side != keep_subject {
183 continue;
184 }
185 for corner in triangle {
186 let index = push(*corner, &mut positions);
187 indices.push(index);
188 }
189 }
190 for (triangle, side) in &tool_pieces {
191 if *side != keep_tool {
192 continue;
193 }
194 // Reversing swaps two corners, which flips the winding and so the
195 // outward normal.
196 let ordered = if flip_tool {
197 [triangle[0], triangle[2], triangle[1]]
198 } else {
199 *triangle
200 };
201 for corner in &ordered {
202 let index = push(*corner, &mut positions);
203 indices.push(index);
204 }
205 }
206
207 Ok(TriMesh::new(positions, indices))
208}
209
210/// A point a fraction of the way from `from` toward `to`.
211///
212/// Used to move a probe off a degenerate feature while keeping it strictly
213/// inside the same piece, so it still classifies that piece.
214fn lerp(from: Point3, to: Point3, t: f64) -> Point3 {
215 Point3::new(
216 from.x + (to.x - from.x) * t,
217 from.y + (to.y - from.y) * t,
218 from.z + (to.z - from.z) * t,
219 )
220}