axiolid_reference/retriangulate.rs
1//! Retriangulating a face against the intersection curve crossing it.
2//!
3//! # What this produces
4//!
5//! A face cut by the intersection curve is replaced by triangles whose
6//! edges follow that curve. Every output triangle then lies wholly inside or
7//! wholly outside the other solid, so classification becomes a per-triangle
8//! question with no further geometry -- which is what makes an exact boolean
9//! possible.
10//!
11//! # Why the work happens in 2D
12//!
13//! All points involved lie in the face's plane by construction: the face's
14//! own corners, and curve nodes that were computed as crossings OF that
15//! plane. Projecting along the plane's dominant axis is therefore exact in
16//! the sense that matters -- it drops a coordinate that carries no
17//! information, rather than approximating one that does.
18//!
19//! The dominant axis is chosen from the largest normal component so the
20//! projection never collapses: picking a near-perpendicular axis would
21//! squash the triangle to a sliver and lose the orientation the
22//! triangulation depends on.
23//!
24//! # Honest limits
25//!
26//! This handles the case the intersection curve actually produces for the
27//! operands `ScalarBoolean` supports: a face crossed by a chain of segments
28//! that enters and leaves through its boundary. A curve forming a closed
29//! loop strictly INSIDE one face is refused -- it needs a hole-aware
30//! triangulation, and inventing a bridge edge to fake it would produce a
31//! mesh whose topology no longer matches the geometry.
32
33use std::collections::BTreeMap;
34
35use axiolid_contracts::{GeomError, GeomResult, Sign};
36use axiolid_core::{Point2, Point3};
37
38use crate::intersection::{IntersectionSegment, NodeKey};
39use crate::orient2d;
40
41/// A face's corners plus the curve nodes lying on it, ready to triangulate.
42#[derive(Debug, Clone)]
43pub struct FacePatch {
44 /// Every point, in the order the triangulation indexes them.
45 ///
46 /// Corners come first so the original winding stays recoverable.
47 pub points: Vec<Point3>,
48 /// Which curve node each point came from, where it came from one.
49 ///
50 /// `None` marks an original face corner. Retaining this lets a caller
51 /// weld patches from adjacent faces by NODE IDENTITY rather than by
52 /// comparing coordinates -- the same discipline the curve itself uses.
53 pub sources: Vec<Option<NodeKey>>,
54 /// Triangles as indices into `points`, wound like the source face.
55 pub triangles: Vec<[u32; 3]>,
56}
57
58/// Which axis to drop when flattening, chosen from the largest normal term.
59fn dominant_axis(normal: Point3) -> usize {
60 let absolute = normal.abs();
61 if absolute.x >= absolute.y && absolute.x >= absolute.z {
62 0
63 } else if absolute.y >= absolute.z {
64 1
65 } else {
66 2
67 }
68}
69
70/// Drop `axis`, keeping the other two coordinates in a fixed order.
71fn project(point: Point3, axis: usize) -> Point2 {
72 match axis {
73 0 => Point2::new(point.y, point.z),
74 1 => Point2::new(point.x, point.z),
75 _ => Point2::new(point.x, point.y),
76 }
77}
78
79/// Retriangulate one face against the curve segments lying on it.
80///
81/// `corners` are the face's three vertices, wound as the source mesh winds
82/// them. `segments` are the curve segments on this face, and `positions`
83/// resolves their nodes to coordinates.
84///
85/// The result reproduces the face exactly when `segments` is empty, so a
86/// caller can run every face through this without special-casing.
87pub fn retriangulate_face(
88 corners: [Point3; 3],
89 segments: &[IntersectionSegment],
90 positions: &BTreeMap<NodeKey, Point3>,
91) -> GeomResult<FacePatch> {
92 // No orientation predicate can decide a NaN or infinite coordinate, and
93 // `sign` below relies on every predicate being certain.
94 if corners
95 .iter()
96 .chain(positions.values())
97 .any(|point| !point.is_finite())
98 {
99 return Err(GeomError::InvalidInput(
100 "face corners and node positions must be finite".into(),
101 ));
102 }
103 let normal = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
104 if normal.length_squared() == 0.0 {
105 return Err(GeomError::Degenerate(
106 "cannot retriangulate a degenerate face".into(),
107 ));
108 }
109
110 let mut points: Vec<Point3> = corners.to_vec();
111 let mut sources: Vec<Option<NodeKey>> = vec![None; 3];
112 let mut index_of: BTreeMap<NodeKey, u32> = BTreeMap::new();
113
114 // A node can lie on this face's EDGE without any segment crossing this
115 // face -- the curve runs through the neighbour instead. Splitting the
116 // edge here anyway is what keeps the two faces combinatorially matched.
117 //
118 // Skipping this leaves a T-junction: the neighbour splits the shared edge
119 // at the node while this face keeps it whole, so the edge is used once
120 // from each side under different names and the surface reads as open.
121 // The volume still comes out right, which is exactly why this needs an
122 // explicit closure check rather than a volume check to catch.
123 let mut edge_nodes: Vec<(NodeKey, Point3)> = Vec::new();
124 for (&node, &point) in positions {
125 if corners.contains(&point) {
126 continue;
127 }
128 if point_on_face_edge(point, corners) {
129 edge_nodes.push((node, point));
130 }
131 }
132 for (node, point) in edge_nodes {
133 if index_of.contains_key(&node) {
134 continue;
135 }
136 index_of.insert(node, points.len() as u32);
137 points.push(point);
138 sources.push(Some(node));
139 }
140
141 // With no cut and no edge node, the face is already its own triangulation.
142 if segments.is_empty() && points.len() == 3 {
143 return Ok(FacePatch {
144 points: corners.to_vec(),
145 sources: vec![None; 3],
146 triangles: vec![[0, 1, 2]],
147 });
148 }
149
150 // Curve nodes join the corner list. A node that coincides with a corner
151 // reuses that corner's index instead of adding a duplicate point, which
152 // would leave the triangulation with a zero-length edge.
153 for segment in segments {
154 for node in [segment.start, segment.end] {
155 if index_of.contains_key(&node) {
156 continue;
157 }
158 let point = *positions.get(&node).ok_or_else(|| {
159 GeomError::Degenerate("curve node has no recorded position".into())
160 })?;
161 if let Some(corner) = corners.iter().position(|&c| c == point) {
162 index_of.insert(node, corner as u32);
163 sources[corner] = Some(node);
164 continue;
165 }
166 index_of.insert(node, points.len() as u32);
167 points.push(point);
168 sources.push(Some(node));
169 }
170 }
171
172 let axis = dominant_axis(normal);
173 let flat: Vec<Point2> = points.iter().map(|&p| project(p, axis)).collect();
174
175 // Constraint edges, as index pairs into `points`.
176 let mut constraints: Vec<(u32, u32)> = Vec::new();
177 for segment in segments {
178 let start = index_of[&segment.start];
179 let end = index_of[&segment.end];
180 if start != end {
181 constraints.push((start.min(end), start.max(end)));
182 }
183 }
184 constraints.sort_unstable();
185 constraints.dedup();
186
187 let triangles = triangulate_with_constraints(&flat, &constraints)?;
188
189 // The 2D work happens in projected space, whose handedness depends on
190 // which axis was dropped and which way the face pointed. Rewinding
191 // against the ORIGINAL normal restores the source orientation, so the
192 // patch can be substituted for the face without flipping it.
193 let triangles = triangles
194 .into_iter()
195 .map(|tri| {
196 let [a, b, c] = tri.map(|i| points[i as usize]);
197 if (b - a).cross(c - a).dot(normal) < 0.0 {
198 [tri[0], tri[2], tri[1]]
199 } else {
200 tri
201 }
202 })
203 .collect();
204
205 Ok(FacePatch {
206 points,
207 sources,
208 triangles,
209 })
210}
211
212/// Triangulate a point set so every constraint edge appears in the output.
213///
214/// # Approach
215///
216/// A brute-force maximal triangulation: consider every candidate triangle,
217/// keep those that are non-degenerate, contain no other point, and cross no
218/// constraint. `O(n^4)`, which is the right trade for a reference -- it is
219/// short enough to audit line by line, and the input is one triangle's worth
220/// of points, not a mesh.
221///
222/// A production provider would use a proper CDT. This exists to be
223/// obviously correct, so a fast implementation has something to be checked
224/// against.
225fn triangulate_with_constraints(
226 points: &[Point2],
227 constraints: &[(u32, u32)],
228) -> GeomResult<Vec<[u32; 3]>> {
229 let count = points.len();
230 let mut triangles: Vec<[u32; 3]> = Vec::new();
231
232 for a in 0..count {
233 for b in (a + 1)..count {
234 for c in (b + 1)..count {
235 let tri = [a as u32, b as u32, c as u32];
236 let [pa, pb, pc] = [points[a], points[b], points[c]];
237
238 // A collinear triple has no area and would contribute a
239 // sliver that later orientation tests cannot classify.
240 if sign(orient2d(pa, pb, pc)) == Sign::Zero {
241 continue;
242 }
243 // A triangle covering another point is not part of any
244 // valid triangulation of the full point set.
245 //
246 // UNPROVEN: no fixture reaches this branch, and mutating it
247 // away leaves every test passing. It is kept because the
248 // smallest-area-first order makes a covering triangle lose
249 // anyway, not because a test demonstrates the need. Delete it
250 // only alongside a case that shows it is genuinely dead.
251 if (0..count).any(|other| {
252 other != a
253 && other != b
254 && other != c
255 && point_inside(points[other], [pa, pb, pc])
256 }) {
257 continue;
258 }
259 // A triangle edge cutting across a constraint would erase
260 // the cut the whole operation exists to make.
261 if crosses_a_constraint(tri, points, constraints) {
262 continue;
263 }
264 triangles.push(tri);
265 }
266 }
267 }
268
269 // Candidates may still overlap each other, so a maximal non-overlapping
270 // subset has to be chosen. Order matters: a greedy pass that took the
271 // whole face first would block every finer triangle, since the face
272 // overlaps all of them, and the cut would vanish. Smallest-area-first
273 // makes the fine pieces win and the coarse cover lose.
274 //
275 // Ties break on the index triple so the result is deterministic for a
276 // given input rather than dependent on sort stability.
277 triangles.sort_by(|left, right| {
278 let area = |t: &[u32; 3]| {
279 let [a, b, c] = t.map(|i| points[i as usize]);
280 ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs()
281 };
282 area(left)
283 .partial_cmp(&area(right))
284 .expect("finite coordinates give comparable areas")
285 .then_with(|| left.cmp(right))
286 });
287
288 let mut kept: Vec<[u32; 3]> = Vec::new();
289 for tri in triangles {
290 if kept.iter().any(|existing| overlaps(*existing, tri, points)) {
291 continue;
292 }
293 kept.push(tri);
294 }
295
296 if kept.is_empty() {
297 return Err(GeomError::Degenerate(
298 "no valid triangle survives the constraints".into(),
299 ));
300 }
301
302 // Every constraint must survive as an edge of some kept triangle.
303 // Reporting this rather than returning a plausible-looking mesh is the
304 // difference between a refusal and a silently wrong cut.
305 //
306 // UNPROVEN: no fixture triggers this refusal. Mutating it away leaves the
307 // suite green, so it is a belt-and-braces check, not a tested guarantee.
308 // A case that reaches it would be a valuable addition.
309 for &(start, end) in constraints {
310 let present = kept.iter().any(|tri| {
311 [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]
312 .iter()
313 .any(|&(u, v)| (u.min(v), u.max(v)) == (start, end))
314 });
315 if !present {
316 return Err(GeomError::Unsupported {
317 backend: axiolid_contracts::BackendId::new("scalar-retriangulate"),
318 operation: axiolid_contracts::Operation::MeshBoolean,
319 });
320 }
321 }
322
323 Ok(kept)
324}
325
326/// Strictly inside the triangle: on an edge does not count.
327///
328/// Boundary points are excluded deliberately. A point ON an edge is shared
329/// with the neighbouring triangle and does not invalidate either.
330fn point_inside(point: Point2, [a, b, c]: [Point2; 3]) -> bool {
331 let signs = [
332 sign(orient2d(a, b, point)),
333 sign(orient2d(b, c, point)),
334 sign(orient2d(c, a, point)),
335 ];
336 signs.iter().all(|&s| s == Sign::Positive) || signs.iter().all(|&s| s == Sign::Negative)
337}
338
339/// Whether any edge of `tri` properly crosses any constraint.
340///
341/// Sharing an endpoint is not a crossing: constraints meet each other and
342/// the face boundary at nodes, which is exactly what they are meant to do.
343fn crosses_a_constraint(tri: [u32; 3], points: &[Point2], constraints: &[(u32, u32)]) -> bool {
344 let edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])];
345 for &(u, v) in &edges {
346 for &(s, e) in constraints {
347 if u == s || u == e || v == s || v == e {
348 continue;
349 }
350 if segments_properly_cross(
351 [points[u as usize], points[v as usize]],
352 [points[s as usize], points[e as usize]],
353 ) {
354 return true;
355 }
356 }
357 }
358 false
359}
360
361/// Two segments crossing at an interior point of both.
362fn segments_properly_cross([a, b]: [Point2; 2], [c, d]: [Point2; 2]) -> bool {
363 let d1 = sign(orient2d(a, b, c));
364 let d2 = sign(orient2d(a, b, d));
365 let d3 = sign(orient2d(c, d, a));
366 let d4 = sign(orient2d(c, d, b));
367 d1 != Sign::Zero
368 && d2 != Sign::Zero
369 && d3 != Sign::Zero
370 && d4 != Sign::Zero
371 && d1 != d2
372 && d3 != d4
373}
374
375/// Whether two triangles share interior area.
376///
377/// Tested by centroid containment both ways plus proper edge crossings.
378/// Triangles that merely share a vertex or an edge do not overlap, which is
379/// the normal case in any triangulation.
380fn overlaps(first: [u32; 3], second: [u32; 3], points: &[Point2]) -> bool {
381 let fa = first.map(|i| points[i as usize]);
382 let sa = second.map(|i| points[i as usize]);
383
384 if point_inside(centroid(fa), sa) || point_inside(centroid(sa), fa) {
385 return true;
386 }
387 for i in 0..3 {
388 for j in 0..3 {
389 let first_edge = [fa[i], fa[(i + 1) % 3]];
390 let second_edge = [sa[j], sa[(j + 1) % 3]];
391 if segments_properly_cross(first_edge, second_edge) {
392 return true;
393 }
394 }
395 }
396 false
397}
398
399/// The average of three corners, which lies strictly inside the triangle.
400fn centroid([a, b, c]: [Point2; 3]) -> Point2 {
401 Point2::new((a.x + b.x + c.x) / 3.0, (a.y + b.y + c.y) / 3.0)
402}
403
404fn sign(value: axiolid_contracts::Certified) -> Sign {
405 value
406 .sign()
407 .expect("predicates are certain for the finite coordinates checked on entry")
408}
409
410/// Whether `point` lies exactly on one of the face's three edges.
411///
412/// Exact: the point must be collinear with the edge by `orient3d`-grade
413/// reasoning and lie within its span. Used to find T-junction nodes that
414/// belong to this face's boundary even though no segment crosses the face.
415fn point_on_face_edge(point: Point3, corners: [Point3; 3]) -> bool {
416 for i in 0..3 {
417 let a = corners[i];
418 let b = corners[(i + 1) % 3];
419 let ab = b - a;
420 let ap = point - a;
421 // Collinear, and strictly between the endpoints.
422 if ab.cross(ap).length_squared() != 0.0 {
423 continue;
424 }
425 let t = ab.dot(ap);
426 if t > 0.0 && t < ab.dot(ab) {
427 return true;
428 }
429 }
430 false
431}