axiolid_heal/intersect.rs
1//! Triangle-triangle self-intersection over a mesh (#73).
2//!
3//! # Why adjacency is the hard part
4//!
5//! In a closed mesh, every triangle shares an edge with three neighbours and
6//! a vertex with many more. Those touch by construction. A naive predicate
7//! that answers "do these two triangles share a point" reports every closed
8//! mesh as broken, so adjacency is excluded structurally: pairs sharing any
9//! vertex INDEX are skipped before any arithmetic runs.
10//!
11//! Index-based exclusion, not coordinate comparison. Two distinct vertices
12//! holding equal coordinates are a `DuplicateVertex` defect, reported
13//! separately; treating them as adjacent here would hide it.
14//!
15//! # Narrow phase
16//!
17//! Exact end to end: every decision is the sign of a certified `orient3d` or
18//! `orient2d`, so near-parallel planes (a rotation rounds vertices slightly
19//! off their common plane) are decided as the inputs stand, not by a float
20//! direction (#399), at any finite coordinate scale (#404).
21//! `triangles_intersect` gives the method and the contact rule.
22//!
23//! # Broad phase
24//!
25//! Candidate pairs come from the shared `Bvh`. The index is an accelerator
26//! only: `self_intersections_brute_force` computes the same answer by
27//! checking every pair, and the two must agree on every input. If they ever
28//! disagree, the index is wrong -- that is a bug, not a tuning parameter.
29
30use axiolid_core::{Aabb, Point2, Point3};
31use axiolid_guarantees::Sign;
32use axiolid_mesh::TriangleMeshView;
33use axiolid_predicates::{orient2d, orient3d};
34use axiolid_spatial::{Bvh, SpatialIndex, SpatialItem};
35use std::ops::ControlFlow;
36
37/// One intersecting triangle pair, lower index first.
38///
39/// Ordered and deduplicated so a caller can compare two runs directly.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub struct IntersectingPair {
42 /// Lower triangle index.
43 pub first: u32,
44 /// Higher triangle index.
45 pub second: u32,
46}
47
48impl IntersectingPair {
49 /// Normalise so `first < second`, giving one canonical form per pair.
50 fn new(a: usize, b: usize) -> Self {
51 let (lo, hi) = if a < b { (a, b) } else { (b, a) };
52 Self {
53 first: lo as u32,
54 second: hi as u32,
55 }
56 }
57}
58
59/// Whether two triangles share at least one vertex index.
60///
61/// Adjacency is decided on indices alone, so it costs no arithmetic and
62/// cannot be perturbed by coordinates.
63fn share_a_vertex(a: [u64; 3], b: [u64; 3]) -> bool {
64 a.iter().any(|i| b.contains(i))
65}
66
67/// Sign of `orient3d`, zero when the four points are coplanar.
68///
69/// The certified predicate decides the sign exactly, so a point lying in a
70/// plane is reported as coplanar rather than as an arbitrary side chosen by
71/// rounding.
72fn side(a: Point3, b: Point3, c: Point3, d: Point3) -> i32 {
73 match orient3d(a, b, c, d).sign() {
74 Some(Sign::Positive) => 1,
75 Some(Sign::Negative) => -1,
76 _ => 0,
77 }
78}
79
80/// Whether two non-adjacent triangles intersect, decided exactly.
81///
82/// Plane-side rejection alone is not sufficient. Two triangles can each
83/// straddle the other's plane and still miss entirely -- the cube's opposite
84/// faces do exactly that -- so passing the rejection test means only "not
85/// separated by these two planes", which is not the same as intersecting.
86///
87/// # Method
88///
89/// Every decision is the sign of a certified `orient3d` or `orient2d`; no
90/// direction, distance or intersection point is ever computed in floating
91/// point. A float direction `n_p x n_q` is cancellation noise for nearly
92/// parallel planes, so none is used (#399).
93///
94/// 1. The six vertex-against-plane signs reject triangles wholly on one side
95/// of the other's plane.
96/// 2. Otherwise the planes are distinct and meet in a line `L`. Each
97/// triangle meets `L` in an interval, so the triangles meet exactly when
98/// an end of one interval lies in the other triangle, and that end is on
99/// an edge. So the pair intersects if and only if some edge of one
100/// triangle meets the closed other triangle (`edge_meets_triangle`).
101/// An edge lying in the other's plane is skipped: it lies on `L`, and the
102/// interval end it carries is a vertex, reached through the next edge.
103/// 3. Exactly coplanar pairs (all six signs zero) are projected onto a
104/// coordinate plane that keeps the triangle non-degenerate, which is
105/// exact, and decided with `orient2d` (`coplanar_triangles_overlap`).
106///
107/// Contact: outside the coplanar case the intersection is closed, so a vertex
108/// or edge of one triangle on the other counts. Coplanar triangles are
109/// reported only when they share interior area; sharing an edge or a vertex
110/// is touching, not overlapping. A triangle of zero area has no interior and
111/// is never reported.
112fn triangles_intersect(p: [Point3; 3], q: [Point3; 3]) -> bool {
113 let Some(p_sides) = plane_sides(q, p) else {
114 return coplanar_triangles_overlap(p, q);
115 };
116 if separated(p_sides) {
117 return false;
118 }
119 let Some(q_sides) = plane_sides(p, q) else {
120 return coplanar_triangles_overlap(p, q);
121 };
122 if separated(q_sides) {
123 return false;
124 }
125 (0..3).any(|i| edge_meets_triangle(p[i], p[(i + 1) % 3], [p_sides[i], p_sides[(i + 1) % 3]], q))
126 || (0..3).any(|i| {
127 edge_meets_triangle(q[i], q[(i + 1) % 3], [q_sides[i], q_sides[(i + 1) % 3]], p)
128 })
129}
130
131/// Signed side of each vertex of `t` against the plane of `plane`.
132fn plane_sides(plane: [Point3; 3], t: [Point3; 3]) -> Option<[i32; 3]> {
133 let sides = t.map(|v| side(plane[0], plane[1], plane[2], v));
134 // All three in the plane: the pair is coplanar, which the caller decides
135 // in two dimensions.
136 sides.iter().any(|&s| s != 0).then_some(sides)
137}
138
139/// Whether every vertex lies strictly on one side.
140fn separated(sides: [i32; 3]) -> bool {
141 sides.iter().all(|&s| s > 0) || sides.iter().all(|&s| s < 0)
142}
143
144/// Whether the closed segment `a`-`b` meets the closed triangle `t`.
145///
146/// `ends` are the signs of `a` and `b` against the plane of `t`. The segment
147/// reaches the plane unless both ends are strictly on one side. A segment in
148/// the plane is skipped (see [`triangles_intersect`]). Otherwise it crosses
149/// the plane in one point `X`, and `X` lies in the closed triangle when the
150/// three `orient3d(a, b, t_i, t_j)` signs, which are the orientations of `X`
151/// against the three edges up to one common factor, are not both positive
152/// and negative (the Guigue-Devillers sign test).
153fn edge_meets_triangle(a: Point3, b: Point3, ends: [i32; 2], t: [Point3; 3]) -> bool {
154 if ends[0] * ends[1] > 0 || (ends[0] == 0 && ends[1] == 0) {
155 return false;
156 }
157 let around = [
158 side(a, b, t[0], t[1]),
159 side(a, b, t[1], t[2]),
160 side(a, b, t[2], t[0]),
161 ];
162 !(around.iter().any(|&s| s > 0) && around.iter().any(|&s| s < 0))
163}
164
165/// Triangle corner positions and vertex indices, if the triangle is usable.
166fn triangle_of<M: TriangleMeshView + ?Sized>(
167 mesh: &M,
168 index: usize,
169) -> Option<([Point3; 3], [u64; 3])> {
170 let corners = mesh.triangle(index);
171 let positions = corners.map(|j| mesh.position(j as usize));
172 if positions.iter().any(|p| !p.is_finite()) {
173 return None;
174 }
175 Some((positions, corners))
176}
177
178/// Self-intersecting triangle pairs, found through the spatial index.
179///
180/// Returns pairs in sorted order. An empty result means no pair intersects.
181///
182/// There is no tolerance parameter: the narrow phase decides with certified
183/// `orient3d`, and the broad phase uses exact triangle bounds. A tolerance
184/// here would only be able to make the answer *wrong*, by admitting or
185/// rejecting pairs the exact test already decides.
186#[must_use]
187pub fn self_intersections<M: TriangleMeshView + ?Sized>(mesh: &M) -> Vec<IntersectingPair> {
188 let count = mesh.triangle_count();
189 let mut items = Vec::with_capacity(count);
190 for index in 0..count {
191 if let Some((positions, _)) = triangle_of(mesh, index) {
192 // Pad by the caller's tolerance so the broad phase never rejects
193 // a pair the exact narrow phase would have accepted.
194 let mut bounds = Aabb::from_point(positions[0]);
195 bounds.extend(positions[1]);
196 bounds.extend(positions[2]);
197 items.push(SpatialItem::new(index as u32, bounds));
198 }
199 }
200 let bvh = Bvh::build(items);
201
202 let mut found = Vec::new();
203 for index in 0..count {
204 let Some((positions, corners)) = triangle_of(mesh, index) else {
205 continue;
206 };
207 let mut query = Aabb::from_point(positions[0]);
208 query.extend(positions[1]);
209 query.extend(positions[2]);
210 bvh.visit_aabb(&query, &mut |other: &u32| {
211 let other = *other as usize;
212 // Each unordered pair is decided once, by its lower index.
213 if other <= index {
214 return ControlFlow::Continue(());
215 }
216 if let Some((other_positions, other_corners)) = triangle_of(mesh, other) {
217 if !share_a_vertex(corners, other_corners)
218 && triangles_intersect(positions, other_positions)
219 {
220 found.push(IntersectingPair::new(index, other));
221 }
222 }
223 ControlFlow::Continue(())
224 });
225 }
226 found.sort_unstable();
227 found.dedup();
228 found
229}
230
231/// The same answer without the spatial index, by checking every pair.
232///
233/// This exists to be compared against [`self_intersections`]. The index is an
234/// optimisation, and an optimisation that changes the answer is a bug, so the
235/// reference is kept in production code rather than in a test where it could
236/// drift out of sync with the accelerated path.
237#[must_use]
238pub fn self_intersections_brute_force<M: TriangleMeshView + ?Sized>(
239 mesh: &M,
240) -> Vec<IntersectingPair> {
241 let count = mesh.triangle_count();
242 let mut found = Vec::new();
243 for index in 0..count {
244 let Some((positions, corners)) = triangle_of(mesh, index) else {
245 continue;
246 };
247 for other in (index + 1)..count {
248 let Some((other_positions, other_corners)) = triangle_of(mesh, other) else {
249 continue;
250 };
251 if !share_a_vertex(corners, other_corners)
252 && triangles_intersect(positions, other_positions)
253 {
254 found.push(IntersectingPair::new(index, other));
255 }
256 }
257 }
258 found.sort_unstable();
259 found.dedup();
260 found
261}
262
263/// Whether two coplanar triangles share interior area, decided exactly.
264///
265/// Both are projected onto a coordinate plane in which the first triangle has
266/// non-zero area, so neither projection degenerates (both lie in one plane).
267/// Dropping a coordinate is exact, and every test below is `orient2d`: no
268/// tolerance and no constructed point.
269///
270/// Two convex regions have disjoint interiors if and only if an edge line of
271/// one has the other wholly on its closed outer side (separating axis). That
272/// is decided for the edges of both triangles, so the shapes need not cross
273/// properly: equal triangles, a vertex on an edge and containment all overlap,
274/// while triangles sharing only an edge or a vertex are separated by that
275/// edge and are NOT reported.
276fn coplanar_triangles_overlap(p: [Point3; 3], q: [Point3; 3]) -> bool {
277 for axis in 0..3 {
278 let pp = project_triangle(p, axis);
279 let p_turn = orient_sign(pp[0], pp[1], pp[2]);
280 if p_turn == 0 {
281 continue;
282 }
283 let qq = project_triangle(q, axis);
284 let q_turn = orient_sign(qq[0], qq[1], qq[2]);
285 if q_turn == 0 {
286 return false;
287 }
288 return !has_separating_edge(pp, p_turn, qq) && !has_separating_edge(qq, q_turn, pp);
289 }
290 false
291}
292
293/// Drop `axis`, keeping the other two coordinates in cyclic order.
294fn project_triangle(t: [Point3; 3], axis: usize) -> [Point2; 3] {
295 t.map(|p| match axis {
296 0 => Point2::new(p.y, p.z),
297 1 => Point2::new(p.z, p.x),
298 _ => Point2::new(p.x, p.y),
299 })
300}
301
302/// Sign of `orient2d` (zero when the three points are collinear).
303fn orient_sign(a: Point2, b: Point2, c: Point2) -> i32 {
304 match orient2d(a, b, c).sign() {
305 Some(Sign::Positive) => 1,
306 Some(Sign::Negative) => -1,
307 _ => 0,
308 }
309}
310
311/// Whether some edge line of `t` (turning `turn`, +1 or -1) has every vertex
312/// of `other` on its outer side or on it.
313fn has_separating_edge(t: [Point2; 3], turn: i32, other: [Point2; 3]) -> bool {
314 (0..3).any(|i| {
315 other
316 .iter()
317 .all(|&v| orient_sign(t[i], t[(i + 1) % 3], v) * turn <= 0)
318 })
319}