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}