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//! # Broad phase
16//!
17//! Candidate pairs come from the shared `Bvh`. The index is an accelerator
18//! only: `self_intersections_brute_force` computes the same answer by
19//! checking every pair, and the two must agree on every input. If they ever
20//! disagree, the index is wrong -- that is a bug, not a tuning parameter.
21
22use axiolid_core::{Aabb, Point2, Point3, Vec3};
23use axiolid_guarantees::Sign;
24use axiolid_mesh::TriangleMeshView;
25use axiolid_predicates::{orient2d, orient3d};
26use axiolid_spatial::{Bvh, SpatialIndex, SpatialItem};
27use std::ops::ControlFlow;
28
29/// One intersecting triangle pair, lower index first.
30///
31/// Ordered and deduplicated so a caller can compare two runs directly.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub struct IntersectingPair {
34 /// Lower triangle index.
35 pub first: u32,
36 /// Higher triangle index.
37 pub second: u32,
38}
39
40impl IntersectingPair {
41 /// Normalise so `first < second`, giving one canonical form per pair.
42 fn new(a: usize, b: usize) -> Self {
43 let (lo, hi) = if a < b { (a, b) } else { (b, a) };
44 Self {
45 first: lo as u32,
46 second: hi as u32,
47 }
48 }
49}
50
51/// Whether two triangles share at least one vertex index.
52///
53/// Adjacency is decided on indices alone, so it costs no arithmetic and
54/// cannot be perturbed by coordinates.
55fn share_a_vertex(a: [u64; 3], b: [u64; 3]) -> bool {
56 a.iter().any(|i| b.contains(i))
57}
58
59/// Sign of `orient3d`, or `None` when the four points are coplanar.
60///
61/// The certified predicate decides the sign exactly, so a point lying in a
62/// plane is reported as coplanar rather than as an arbitrary side chosen by
63/// rounding.
64fn side(a: Point3, b: Point3, c: Point3, d: Point3) -> Option<i32> {
65 match orient3d(a, b, c, d).sign() {
66 Some(Sign::Positive) => Some(1),
67 Some(Sign::Negative) => Some(-1),
68 _ => None,
69 }
70}
71
72/// Whether two non-adjacent triangles properly intersect.
73///
74/// Plane-side rejection alone is not sufficient. Two triangles can each
75/// straddle the other's plane and still miss entirely -- the cube's opposite
76/// faces do exactly that -- so passing the rejection test means only "not
77/// separated by these two planes", which is not the same as intersecting.
78///
79/// The decision is therefore made on the intersection LINE of the two planes.
80/// Each triangle meets that line in an interval; the triangles intersect if
81/// and only if the intervals overlap. Intervals are compared through the
82/// signed plane distances rather than by constructing the line, so no
83/// intersection point is ever computed in floating point.
84///
85/// Coplanar configurations are decided by exact 2D region logic rather than
86/// assumed to intersect: the pair is projected onto its dominant plane and
87/// tested for shared interior area with `orient2d`. Triangles that merely
88/// touch along a shared edge or vertex have no common area and are not
89/// reported.
90fn triangles_intersect(p: [Point3; 3], q: [Point3; 3]) -> bool {
91 let Some(p_sides) = plane_sides(q, p) else {
92 return coplanar_triangles_overlap(p, q);
93 };
94 if separated(p_sides) {
95 return false;
96 }
97 let Some(q_sides) = plane_sides(p, q) else {
98 return coplanar_triangles_overlap(p, q);
99 };
100 if separated(q_sides) {
101 return false;
102 }
103 intervals_overlap(p, p_sides, q, q_sides)
104}
105
106/// Signed side of each vertex of `t` against the plane of `plane`.
107fn plane_sides(plane: [Point3; 3], t: [Point3; 3]) -> Option<[i32; 3]> {
108 let a = side(plane[0], plane[1], plane[2], t[0]);
109 let b = side(plane[0], plane[1], plane[2], t[1]);
110 let c = side(plane[0], plane[1], plane[2], t[2]);
111 match (a, b, c) {
112 // All three coplanar with the other triangle: the interval test
113 // degenerates, so the caller falls back to conservative reporting.
114 (None, None, None) => None,
115 _ => Some([a.unwrap_or(0), b.unwrap_or(0), c.unwrap_or(0)]),
116 }
117}
118
119/// Whether every vertex lies strictly on one side.
120fn separated(sides: [i32; 3]) -> bool {
121 sides.iter().all(|&s| s > 0) || sides.iter().all(|&s| s < 0)
122}
123
124/// Whether the two triangles' intervals on the planes' common line overlap.
125///
126/// Each triangle has one vertex alone on one side of the other's plane (or a
127/// vertex exactly on it). The two edges from that vertex cross the plane, and
128/// their crossing points bound the triangle's interval on the common line.
129/// Positions along the line are measured by projecting onto the line's
130/// direction, and the crossing points are interpolated by the signed
131/// distances, which is exactly where the edge meets the plane.
132fn intervals_overlap(p: [Point3; 3], p_sides: [i32; 3], q: [Point3; 3], q_sides: [i32; 3]) -> bool {
133 let direction = (p[1] - p[0])
134 .cross(p[2] - p[0])
135 .cross((q[1] - q[0]).cross(q[2] - q[0]));
136 if !direction.is_finite() || direction.length_squared() == 0.0 {
137 return true; // parallel planes that are not separated: conservative
138 }
139 let Some(p_span) = interval_on(p, p_sides, q, direction) else {
140 return true;
141 };
142 let Some(q_span) = interval_on(q, q_sides, p, direction) else {
143 return true;
144 };
145 p_span.0 <= q_span.1 && q_span.0 <= p_span.1
146}
147
148/// The triangle's interval along `direction`, bounded by where its edges
149/// cross the other triangle's plane.
150///
151/// Returns `None` when the configuration is degenerate enough that the
152/// crossing points cannot be located, so the caller can stay conservative.
153fn interval_on(
154 t: [Point3; 3],
155 sides: [i32; 3],
156 plane: [Point3; 3],
157 direction: Vec3,
158) -> Option<(f64, f64)> {
159 let distance = |point: Point3| {
160 let normal = (plane[1] - plane[0]).cross(plane[2] - plane[0]);
161 (point - plane[0]).dot(normal)
162 };
163 let mut hits: Vec<f64> = Vec::new();
164 for (i, j) in [(0usize, 1usize), (1, 2), (2, 0)] {
165 let (si, sj) = (sides[i], sides[j]);
166 if si == 0 {
167 hits.push(t[i].dot(direction));
168 }
169 if si != 0 && sj != 0 && si != sj {
170 let (di, dj) = (distance(t[i]), distance(t[j]));
171 let denominator = di - dj;
172 if denominator == 0.0 {
173 return None;
174 }
175 let ratio = di / denominator;
176 let crossing = t[i] + (t[j] - t[i]) * ratio;
177 hits.push(crossing.dot(direction));
178 }
179 }
180 if hits.is_empty() {
181 return None;
182 }
183 let mut low = f64::INFINITY;
184 let mut high = f64::NEG_INFINITY;
185 for hit in hits {
186 low = low.min(hit);
187 high = high.max(hit);
188 }
189 Some((low, high))
190}
191
192/// Triangle corner positions and vertex indices, if the triangle is usable.
193fn triangle_of<M: TriangleMeshView + ?Sized>(
194 mesh: &M,
195 index: usize,
196) -> Option<([Point3; 3], [u64; 3])> {
197 let corners = mesh.triangle(index);
198 let positions = corners.map(|j| mesh.position(j as usize));
199 if positions.iter().any(|p| !p.is_finite()) {
200 return None;
201 }
202 Some((positions, corners))
203}
204
205/// Self-intersecting triangle pairs, found through the spatial index.
206///
207/// Returns pairs in sorted order. An empty result means no pair intersects.
208///
209/// There is no tolerance parameter: the narrow phase decides with certified
210/// `orient3d`, and the broad phase uses exact triangle bounds. A tolerance
211/// here would only be able to make the answer *wrong*, by admitting or
212/// rejecting pairs the exact test already decides.
213#[must_use]
214pub fn self_intersections<M: TriangleMeshView + ?Sized>(mesh: &M) -> Vec<IntersectingPair> {
215 let count = mesh.triangle_count();
216 let mut items = Vec::with_capacity(count);
217 for index in 0..count {
218 if let Some((positions, _)) = triangle_of(mesh, index) {
219 // Pad by the caller's tolerance so the broad phase never rejects
220 // a pair the exact narrow phase would have accepted.
221 let mut bounds = Aabb::from_point(positions[0]);
222 bounds.extend(positions[1]);
223 bounds.extend(positions[2]);
224 items.push(SpatialItem::new(index as u32, bounds));
225 }
226 }
227 let bvh = Bvh::build(items);
228
229 let mut found = Vec::new();
230 for index in 0..count {
231 let Some((positions, corners)) = triangle_of(mesh, index) else {
232 continue;
233 };
234 let mut query = Aabb::from_point(positions[0]);
235 query.extend(positions[1]);
236 query.extend(positions[2]);
237 bvh.visit_aabb(&query, &mut |other: &u32| {
238 let other = *other as usize;
239 // Each unordered pair is decided once, by its lower index.
240 if other <= index {
241 return ControlFlow::Continue(());
242 }
243 if let Some((other_positions, other_corners)) = triangle_of(mesh, other) {
244 if !share_a_vertex(corners, other_corners)
245 && triangles_intersect(positions, other_positions)
246 {
247 found.push(IntersectingPair::new(index, other));
248 }
249 }
250 ControlFlow::Continue(())
251 });
252 }
253 found.sort_unstable();
254 found.dedup();
255 found
256}
257
258/// The same answer without the spatial index, by checking every pair.
259///
260/// This exists to be compared against [`self_intersections`]. The index is an
261/// optimisation, and an optimisation that changes the answer is a bug, so the
262/// reference is kept in production code rather than in a test where it could
263/// drift out of sync with the accelerated path.
264#[must_use]
265pub fn self_intersections_brute_force<M: TriangleMeshView + ?Sized>(
266 mesh: &M,
267) -> Vec<IntersectingPair> {
268 let count = mesh.triangle_count();
269 let mut found = Vec::new();
270 for index in 0..count {
271 let Some((positions, corners)) = triangle_of(mesh, index) else {
272 continue;
273 };
274 for other in (index + 1)..count {
275 let Some((other_positions, other_corners)) = triangle_of(mesh, other) else {
276 continue;
277 };
278 if !share_a_vertex(corners, other_corners)
279 && triangles_intersect(positions, other_positions)
280 {
281 found.push(IntersectingPair::new(index, other));
282 }
283 }
284 }
285 found.sort_unstable();
286 found.dedup();
287 found
288}
289
290/// Whether two coplanar triangles share interior area, decided exactly.
291///
292/// Both are projected onto the coordinate plane where their shared normal is
293/// largest, so no projected triangle degenerates to a segment. Every test
294/// below is `orient2d`, so this stays as exact as the non-coplanar path it
295/// replaces -- no tolerance, no constructed intersection point.
296///
297/// Touching is not overlapping: triangles sharing only an edge or a vertex
298/// have no interior area in common and are NOT reported.
299fn coplanar_triangles_overlap(p: [Point3; 3], q: [Point3; 3]) -> bool {
300 let normal = (p[1] - p[0]).cross(p[2] - p[0]);
301 let axis = dominant_axis(normal);
302 let pp = project_triangle(p, axis);
303 let qq = project_triangle(q, axis);
304
305 // Edge-crossing: any proper crossing of a p edge with a q edge means the
306 // boundaries pass through each other, which requires shared area.
307 for i in 0..3 {
308 for j in 0..3 {
309 if segments_properly_cross(pp[i], pp[(i + 1) % 3], qq[j], qq[(j + 1) % 3]) {
310 return true;
311 }
312 }
313 }
314
315 // Containment: no crossing but one triangle strictly inside the other.
316 pp.iter().any(|&v| strictly_inside(v, qq)) || qq.iter().any(|&v| strictly_inside(v, pp))
317}
318
319/// Index of the largest-magnitude normal component.
320fn dominant_axis(normal: Vec3) -> usize {
321 let (x, y, z) = (normal.x.abs(), normal.y.abs(), normal.z.abs());
322 if x >= y && x >= z {
323 0
324 } else if y >= z {
325 1
326 } else {
327 2
328 }
329}
330
331/// Drop the dominant axis, keeping the projection non-degenerate.
332fn project_triangle(t: [Point3; 3], axis: usize) -> [Point2; 3] {
333 [
334 project_point(t[0], axis),
335 project_point(t[1], axis),
336 project_point(t[2], axis),
337 ]
338}
339
340fn project_point(p: Point3, axis: usize) -> Point2 {
341 match axis {
342 0 => Point2::new(p.y, p.z),
343 1 => Point2::new(p.z, p.x),
344 _ => Point2::new(p.x, p.y),
345 }
346}
347
348/// Whether segments `a`-`b` and `c`-`d` cross at an interior point of both.
349///
350/// Requires all four orientations to be strictly non-zero and opposite in
351/// pairs. Collinear or touching-at-an-endpoint configurations are rejected:
352/// they share boundary, not area.
353fn segments_properly_cross(a: Point2, b: Point2, c: Point2, d: Point2) -> bool {
354 let (Some(o1), Some(o2), Some(o3), Some(o4)) = (
355 sign2(a, b, c),
356 sign2(a, b, d),
357 sign2(c, d, a),
358 sign2(c, d, b),
359 ) else {
360 return false;
361 };
362 o1 != o2 && o3 != o4
363}
364
365/// Strict `orient2d` sign, or `None` when the three points are collinear.
366fn sign2(a: Point2, b: Point2, c: Point2) -> Option<i32> {
367 match orient2d(a, b, c).sign() {
368 Some(Sign::Positive) => Some(1),
369 Some(Sign::Negative) => Some(-1),
370 _ => None,
371 }
372}
373
374/// Whether `point` lies strictly inside triangle `t`.
375///
376/// A point on an edge is NOT inside: two triangles meeting along a shared
377/// edge touch without overlapping, and reporting that as a self-intersection
378/// is the false positive this whole function exists to remove.
379fn strictly_inside(point: Point2, t: [Point2; 3]) -> bool {
380 let mut seen: Option<i32> = None;
381 for i in 0..3 {
382 let Some(s) = sign2(t[i], t[(i + 1) % 3], point) else {
383 return false; // on an edge line: boundary, not interior
384 };
385 match seen {
386 None => seen = Some(s),
387 Some(previous) if previous == s => {}
388 Some(_) => return false,
389 }
390 }
391 true
392}