Skip to main content

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}