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 let normal = (corners[1] - corners[0]).cross(corners[2] - corners[0]);
93 if normal.length_squared() == 0.0 {
94 return Err(GeomError::Degenerate(
95 "cannot retriangulate a degenerate face".into(),
96 ));
97 }
98
99 let mut points: Vec<Point3> = corners.to_vec();
100 let mut sources: Vec<Option<NodeKey>> = vec![None; 3];
101 let mut index_of: BTreeMap<NodeKey, u32> = BTreeMap::new();
102
103 // A node can lie on this face's EDGE without any segment crossing this
104 // face -- the curve runs through the neighbour instead. Splitting the
105 // edge here anyway is what keeps the two faces combinatorially matched.
106 //
107 // Skipping this leaves a T-junction: the neighbour splits the shared edge
108 // at the node while this face keeps it whole, so the edge is used once
109 // from each side under different names and the surface reads as open.
110 // The volume still comes out right, which is exactly why this needs an
111 // explicit closure check rather than a volume check to catch.
112 let mut edge_nodes: Vec<(NodeKey, Point3)> = Vec::new();
113 for (&node, &point) in positions {
114 if corners.contains(&point) {
115 continue;
116 }
117 if point_on_face_edge(point, corners) {
118 edge_nodes.push((node, point));
119 }
120 }
121 for (node, point) in edge_nodes {
122 if index_of.contains_key(&node) {
123 continue;
124 }
125 index_of.insert(node, points.len() as u32);
126 points.push(point);
127 sources.push(Some(node));
128 }
129
130 // With no cut and no edge node, the face is already its own triangulation.
131 if segments.is_empty() && points.len() == 3 {
132 return Ok(FacePatch {
133 points: corners.to_vec(),
134 sources: vec![None; 3],
135 triangles: vec![[0, 1, 2]],
136 });
137 }
138
139 // Curve nodes join the corner list. A node that coincides with a corner
140 // reuses that corner's index instead of adding a duplicate point, which
141 // would leave the triangulation with a zero-length edge.
142 for segment in segments {
143 for node in [segment.start, segment.end] {
144 if index_of.contains_key(&node) {
145 continue;
146 }
147 let point = *positions.get(&node).ok_or_else(|| {
148 GeomError::Degenerate("curve node has no recorded position".into())
149 })?;
150 if let Some(corner) = corners.iter().position(|&c| c == point) {
151 index_of.insert(node, corner as u32);
152 sources[corner] = Some(node);
153 continue;
154 }
155 index_of.insert(node, points.len() as u32);
156 points.push(point);
157 sources.push(Some(node));
158 }
159 }
160
161 let axis = dominant_axis(normal);
162 let flat: Vec<Point2> = points.iter().map(|&p| project(p, axis)).collect();
163
164 // Constraint edges, as index pairs into `points`.
165 let mut constraints: Vec<(u32, u32)> = Vec::new();
166 for segment in segments {
167 let start = index_of[&segment.start];
168 let end = index_of[&segment.end];
169 if start != end {
170 constraints.push((start.min(end), start.max(end)));
171 }
172 }
173 constraints.sort_unstable();
174 constraints.dedup();
175
176 let triangles = triangulate_with_constraints(&flat, &constraints)?;
177
178 // The 2D work happens in projected space, whose handedness depends on
179 // which axis was dropped and which way the face pointed. Rewinding
180 // against the ORIGINAL normal restores the source orientation, so the
181 // patch can be substituted for the face without flipping it.
182 let triangles = triangles
183 .into_iter()
184 .map(|tri| {
185 let [a, b, c] = tri.map(|i| points[i as usize]);
186 if (b - a).cross(c - a).dot(normal) < 0.0 {
187 [tri[0], tri[2], tri[1]]
188 } else {
189 tri
190 }
191 })
192 .collect();
193
194 Ok(FacePatch {
195 points,
196 sources,
197 triangles,
198 })
199}
200
201/// Triangulate a point set so every constraint edge appears in the output.
202///
203/// # Approach
204///
205/// A brute-force maximal triangulation: consider every candidate triangle,
206/// keep those that are non-degenerate, contain no other point, and cross no
207/// constraint. `O(n^4)`, which is the right trade for a reference -- it is
208/// short enough to audit line by line, and the input is one triangle's worth
209/// of points, not a mesh.
210///
211/// A production provider would use a proper CDT. This exists to be
212/// obviously correct, so a fast implementation has something to be checked
213/// against.
214fn triangulate_with_constraints(
215 points: &[Point2],
216 constraints: &[(u32, u32)],
217) -> GeomResult<Vec<[u32; 3]>> {
218 let count = points.len();
219 let mut triangles: Vec<[u32; 3]> = Vec::new();
220
221 for a in 0..count {
222 for b in (a + 1)..count {
223 for c in (b + 1)..count {
224 let tri = [a as u32, b as u32, c as u32];
225 let [pa, pb, pc] = [points[a], points[b], points[c]];
226
227 // A collinear triple has no area and would contribute a
228 // sliver that later orientation tests cannot classify.
229 if sign(orient2d(pa, pb, pc)) == Sign::Zero {
230 continue;
231 }
232 // A triangle covering another point is not part of any
233 // valid triangulation of the full point set.
234 //
235 // UNPROVEN: no fixture reaches this branch, and mutating it
236 // away leaves every test passing. It is kept because the
237 // smallest-area-first order makes a covering triangle lose
238 // anyway, not because a test demonstrates the need. Delete it
239 // only alongside a case that shows it is genuinely dead.
240 if (0..count).any(|other| {
241 other != a
242 && other != b
243 && other != c
244 && point_inside(points[other], [pa, pb, pc])
245 }) {
246 continue;
247 }
248 // A triangle edge cutting across a constraint would erase
249 // the cut the whole operation exists to make.
250 if crosses_a_constraint(tri, points, constraints) {
251 continue;
252 }
253 triangles.push(tri);
254 }
255 }
256 }
257
258 // Candidates may still overlap each other, so a maximal non-overlapping
259 // subset has to be chosen. Order matters: a greedy pass that took the
260 // whole face first would block every finer triangle, since the face
261 // overlaps all of them, and the cut would vanish. Smallest-area-first
262 // makes the fine pieces win and the coarse cover lose.
263 //
264 // Ties break on the index triple so the result is deterministic for a
265 // given input rather than dependent on sort stability.
266 triangles.sort_by(|left, right| {
267 let area = |t: &[u32; 3]| {
268 let [a, b, c] = t.map(|i| points[i as usize]);
269 ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs()
270 };
271 area(left)
272 .partial_cmp(&area(right))
273 .expect("finite coordinates give comparable areas")
274 .then_with(|| left.cmp(right))
275 });
276
277 let mut kept: Vec<[u32; 3]> = Vec::new();
278 for tri in triangles {
279 if kept.iter().any(|existing| overlaps(*existing, tri, points)) {
280 continue;
281 }
282 kept.push(tri);
283 }
284
285 if kept.is_empty() {
286 return Err(GeomError::Degenerate(
287 "no valid triangle survives the constraints".into(),
288 ));
289 }
290
291 // Every constraint must survive as an edge of some kept triangle.
292 // Reporting this rather than returning a plausible-looking mesh is the
293 // difference between a refusal and a silently wrong cut.
294 //
295 // UNPROVEN: no fixture triggers this refusal. Mutating it away leaves the
296 // suite green, so it is a belt-and-braces check, not a tested guarantee.
297 // A case that reaches it would be a valuable addition.
298 for &(start, end) in constraints {
299 let present = kept.iter().any(|tri| {
300 [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]
301 .iter()
302 .any(|&(u, v)| (u.min(v), u.max(v)) == (start, end))
303 });
304 if !present {
305 return Err(GeomError::Unsupported {
306 backend: axiolid_contracts::BackendId::new("scalar-retriangulate"),
307 operation: axiolid_contracts::Operation::MeshBoolean,
308 });
309 }
310 }
311
312 Ok(kept)
313}
314
315/// Strictly inside the triangle: on an edge does not count.
316///
317/// Boundary points are excluded deliberately. A point ON an edge is shared
318/// with the neighbouring triangle and does not invalidate either.
319fn point_inside(point: Point2, [a, b, c]: [Point2; 3]) -> bool {
320 let signs = [
321 sign(orient2d(a, b, point)),
322 sign(orient2d(b, c, point)),
323 sign(orient2d(c, a, point)),
324 ];
325 signs.iter().all(|&s| s == Sign::Positive) || signs.iter().all(|&s| s == Sign::Negative)
326}
327
328/// Whether any edge of `tri` properly crosses any constraint.
329///
330/// Sharing an endpoint is not a crossing: constraints meet each other and
331/// the face boundary at nodes, which is exactly what they are meant to do.
332fn crosses_a_constraint(tri: [u32; 3], points: &[Point2], constraints: &[(u32, u32)]) -> bool {
333 let edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])];
334 for &(u, v) in &edges {
335 for &(s, e) in constraints {
336 if u == s || u == e || v == s || v == e {
337 continue;
338 }
339 if segments_properly_cross(
340 [points[u as usize], points[v as usize]],
341 [points[s as usize], points[e as usize]],
342 ) {
343 return true;
344 }
345 }
346 }
347 false
348}
349
350/// Two segments crossing at an interior point of both.
351fn segments_properly_cross([a, b]: [Point2; 2], [c, d]: [Point2; 2]) -> bool {
352 let d1 = sign(orient2d(a, b, c));
353 let d2 = sign(orient2d(a, b, d));
354 let d3 = sign(orient2d(c, d, a));
355 let d4 = sign(orient2d(c, d, b));
356 d1 != Sign::Zero
357 && d2 != Sign::Zero
358 && d3 != Sign::Zero
359 && d4 != Sign::Zero
360 && d1 != d2
361 && d3 != d4
362}
363
364/// Whether two triangles share interior area.
365///
366/// Tested by centroid containment both ways plus proper edge crossings.
367/// Triangles that merely share a vertex or an edge do not overlap, which is
368/// the normal case in any triangulation.
369fn overlaps(first: [u32; 3], second: [u32; 3], points: &[Point2]) -> bool {
370 let fa = first.map(|i| points[i as usize]);
371 let sa = second.map(|i| points[i as usize]);
372
373 if point_inside(centroid(fa), sa) || point_inside(centroid(sa), fa) {
374 return true;
375 }
376 for i in 0..3 {
377 for j in 0..3 {
378 let first_edge = [fa[i], fa[(i + 1) % 3]];
379 let second_edge = [sa[j], sa[(j + 1) % 3]];
380 if segments_properly_cross(first_edge, second_edge) {
381 return true;
382 }
383 }
384 }
385 false
386}
387
388/// The average of three corners, which lies strictly inside the triangle.
389fn centroid([a, b, c]: [Point2; 3]) -> Point2 {
390 Point2::new((a.x + b.x + c.x) / 3.0, (a.y + b.y + c.y) / 3.0)
391}
392
393fn sign(value: axiolid_contracts::Certified) -> Sign {
394 value.sign().expect("certified predicates are total")
395}
396
397/// Whether `point` lies exactly on one of the face's three edges.
398///
399/// Exact: the point must be collinear with the edge by `orient3d`-grade
400/// reasoning and lie within its span. Used to find T-junction nodes that
401/// belong to this face's boundary even though no segment crosses the face.
402fn point_on_face_edge(point: Point3, corners: [Point3; 3]) -> bool {
403 for i in 0..3 {
404 let a = corners[i];
405 let b = corners[(i + 1) % 3];
406 let ab = b - a;
407 let ap = point - a;
408 // Collinear, and strictly between the endpoints.
409 if ab.cross(ap).length_squared() != 0.0 {
410 continue;
411 }
412 let t = ab.dot(ap);
413 if t > 0.0 && t < ab.dot(ab) {
414 return true;
415 }
416 }
417 false
418}