Skip to main content

axiolid_inspect/
clearance.rs

1//! Closest approach between two meshes.
2//!
3//! Clearance is the clash-detection question: not "do these collide" but "if
4//! not, by how much do they miss". A boolean answers the first and discards
5//! the second.
6//!
7//! The BVH supplies candidate triangle pairs within the search length; the
8//! exact distance for each surviving pair is computed directly. Distance is
9//! a measurement, not a predicate, so f64 is the right currency here and
10//! ADR 0045's reasoning applies: the value is reported, not used to decide
11//! topology.
12
13use axiolid_core::{Aabb, Point3, Vec3};
14use axiolid_guarantees::Sign;
15use axiolid_mesh::TriMesh;
16use axiolid_predicates::orient3d;
17use axiolid_spatial::{Bvh, SpatialItem};
18
19/// Smallest distance between the surfaces of `a` and `b`.
20///
21/// Returns `None` when nothing is found within `search_length`, and
22/// `Some(0.0)` when the surfaces touch or interpenetrate. A caller doing
23/// clash detection reads those as "clear beyond the search radius" and
24/// "clash" respectively, and any positive value as the remaining clearance.
25///
26/// # Panics
27///
28/// Never. A malformed index buffer yields `None` rather than a panic.
29pub fn min_gap(a: &TriMesh, b: &TriMesh, search_length: f64) -> Option<f64> {
30    if !search_length.is_finite() || search_length <= 0.0 {
31        return None;
32    }
33    // Interpenetration is a clash even when no triangle pair crosses: a
34    // corner of one solid can sit strictly inside the other while every
35    // surface-to-surface distance stays positive. Surface distance alone
36    // would report that as clearance, which is exactly backwards. Reuse the
37    // exact containment query rather than inventing a second answer.
38    if any_vertex_inside(a, b) || any_vertex_inside(b, a) {
39        return Some(0.0);
40    }
41    surface_gap(a, b, search_length)
42}
43
44/// Smallest distance between the surfaces of `a` and `b`, when it is at most
45/// `search_length`.
46///
47/// Unlike [`min_gap`], containment is not tested: a mesh in another's cavity,
48/// or wholly inside it, is as far from it as the surfaces are.
49/// `Some(0.0)` when the surfaces touch or cross. `None` when nothing is within
50/// `search_length`, or `search_length` is negative or not finite (zero is
51/// allowed: only touching or crossing surfaces are found).
52///
53/// # Panics
54///
55/// Never. A malformed index buffer yields `None` rather than a panic.
56pub fn surface_gap(a: &TriMesh, b: &TriMesh, search_length: f64) -> Option<f64> {
57    if !search_length.is_finite() || search_length < 0.0 {
58        return None;
59    }
60    let b_index = build_index(b, search_length);
61    let mut best: Option<f64> = None;
62    let mut candidates = Vec::new();
63
64    for a_triangle in triangles(a) {
65        let probe = grow(bounds_of(&a_triangle), search_length);
66        candidates.clear();
67        b_index.query_aabb(&probe, &mut candidates);
68        for &candidate in &candidates {
69            let Some(item) = b_index.item(candidate) else {
70                continue;
71            };
72            let Some(b_triangle) = triangle_at(b, item.key as usize) else {
73                continue;
74            };
75            let distance = triangle_distance(&a_triangle, &b_triangle);
76            if best.is_none_or(|current| distance < current) {
77                best = Some(distance);
78            }
79            if distance == 0.0 {
80                return Some(0.0);
81            }
82        }
83    }
84    best.filter(|distance| *distance <= search_length)
85}
86
87/// Distance between two triangles, zero when they touch or cross.
88///
89/// Every closest pair of convex sets is realised on a boundary feature, so
90/// checking all edge pairs and all vertex-to-face distances is exhaustive
91/// for triangles.
92pub(crate) fn triangle_distance(a: &[Point3; 3], b: &[Point3; 3]) -> f64 {
93    // An edge through the other's interior has no closest pair on the
94    // boundary features below: both sides of the crossing are positive
95    // distances away (#390).
96    if edge_crosses(a, b) || edge_crosses(b, a) {
97        return 0.0;
98    }
99    let mut best = f64::INFINITY;
100    for i in 0..3 {
101        for j in 0..3 {
102            let d = segment_distance(a[i], a[(i + 1) % 3], b[j], b[(j + 1) % 3]);
103            best = best.min(d);
104        }
105    }
106    for vertex in a {
107        best = best.min(point_triangle_distance(*vertex, b));
108    }
109    for vertex in b {
110        best = best.min(point_triangle_distance(*vertex, a));
111    }
112    best
113}
114
115/// Whether an edge of `edges` crosses or touches the triangle `face`.
116///
117/// Certified `orient3d` signs, as in `containment.rs`: the edge's ends lie on
118/// opposite sides of, or on, the face's plane, and the three tetrahedra the
119/// edge spans with the face's edges agree in sign, zeros allowed. An edge in
120/// the face's plane is left to the edge and vertex distances; a sign that
121/// cannot be certified is read as no crossing, for the same reason.
122fn edge_crosses(edges: &[Point3; 3], face: &[Point3; 3]) -> bool {
123    (0..3).any(|i| segment_crosses(edges[i], edges[(i + 1) % 3], face))
124}
125
126fn segment_crosses(p: Point3, q: Point3, face: &[Point3; 3]) -> bool {
127    let [a, b, c] = *face;
128    let sign = |value: Option<Sign>| match value {
129        Some(Sign::Positive) => Some(1),
130        Some(Sign::Negative) => Some(-1),
131        Some(Sign::Zero) => Some(0),
132        _ => None,
133    };
134    let (Some(sp), Some(sq)) = (
135        sign(orient3d(a, b, c, p).sign()),
136        sign(orient3d(a, b, c, q).sign()),
137    ) else {
138        return false;
139    };
140    if sp * sq > 0 || (sp == 0 && sq == 0) {
141        return false;
142    }
143    let mut seen = 0;
144    for (from, to) in [(a, b), (b, c), (c, a)] {
145        let Some(side) = sign(orient3d(p, q, from, to).sign()) else {
146            return false;
147        };
148        if side != 0 {
149            if seen != 0 && seen != side {
150                return false;
151            }
152            seen = side;
153        }
154    }
155    true
156}
157
158/// Distance between two segments, handling the parallel case.
159fn segment_distance(p0: Point3, p1: Point3, q0: Point3, q1: Point3) -> f64 {
160    let u = p1 - p0;
161    let v = q1 - q0;
162    let w = p0 - q0;
163    let a = u.dot(u);
164    let b = u.dot(v);
165    let c = v.dot(v);
166    let d = u.dot(w);
167    let e = v.dot(w);
168    let denominator = a * c - b * b;
169
170    // Parallel segments leave the parameters underdetermined; clamping to the
171    // endpoints is what makes this total rather than a special case.
172    let (mut s, mut t) = if denominator.abs() <= f64::EPSILON * a.max(c).max(1.0) {
173        (0.0, if c > 0.0 { e / c } else { 0.0 })
174    } else {
175        ((b * e - c * d) / denominator, (a * e - b * d) / denominator)
176    };
177    s = s.clamp(0.0, 1.0);
178    t = t.clamp(0.0, 1.0);
179
180    // Re-solve each parameter against the other's clamped value, so a
181    // clamped endpoint still finds the true nearest point on its partner.
182    if c > 0.0 {
183        t = ((s * b - e) / c).clamp(0.0, 1.0);
184    }
185    if a > 0.0 {
186        s = (-((t * b + d) / a)).clamp(0.0, 1.0);
187    }
188    ((p0 + u * s) - (q0 + v * t)).length()
189}
190
191/// Distance from a point to a triangle, including its interior.
192fn point_triangle_distance(point: Point3, triangle: &[Point3; 3]) -> f64 {
193    let [a, b, c] = *triangle;
194    let normal = (b - a).cross(c - a);
195    let area_squared = normal.length_squared();
196    if area_squared > 0.0 {
197        // Project onto the plane and test the barycentric signs. If the
198        // projection lands inside, the perpendicular IS the distance.
199        let distance = normal.dot(point - a) / area_squared.sqrt();
200        let projected = point - normal * (normal.dot(point - a) / area_squared);
201        let inside = [(a, b), (b, c), (c, a)]
202            .iter()
203            .all(|(from, to)| normal.dot((*to - *from).cross(projected - *from)) >= 0.0);
204        if inside {
205            return distance.abs();
206        }
207    }
208    // Outside the projection, or a degenerate triangle: the nearest point is
209    // on an edge.
210    let mut best = f64::INFINITY;
211    for i in 0..3 {
212        best = best.min(point_segment_distance(
213            point,
214            triangle[i],
215            triangle[(i + 1) % 3],
216        ));
217    }
218    best
219}
220
221/// Distance from a point to a segment.
222fn point_segment_distance(point: Point3, from: Point3, to: Point3) -> f64 {
223    let direction = to - from;
224    let length_squared = direction.length_squared();
225    if length_squared == 0.0 {
226        return (point - from).length();
227    }
228    let t = ((point - from).dot(direction) / length_squared).clamp(0.0, 1.0);
229    (point - (from + direction * t)).length()
230}
231
232/// Triangles of a mesh as coordinate triples.
233fn triangles(mesh: &TriMesh) -> impl Iterator<Item = [Point3; 3]> + '_ {
234    (0..mesh.indices.len() / 3).filter_map(move |index| triangle_at(mesh, index))
235}
236
237/// One triangle by index, or `None` if the index buffer is malformed.
238pub(crate) fn triangle_at(mesh: &TriMesh, index: usize) -> Option<[Point3; 3]> {
239    let corners = mesh.indices.get(index * 3..index * 3 + 3)?;
240    Some([
241        *mesh.positions.get(corners[0] as usize)?,
242        *mesh.positions.get(corners[1] as usize)?,
243        *mesh.positions.get(corners[2] as usize)?,
244    ])
245}
246
247/// Bounding box of a triangle.
248pub(crate) fn bounds_of(triangle: &[Point3; 3]) -> Aabb {
249    let mut bounds = Aabb::from_point(triangle[0]);
250    bounds.extend(triangle[1]);
251    bounds.extend(triangle[2]);
252    bounds
253}
254
255/// Grow a box by a uniform margin, so a query catches near misses.
256pub(crate) fn grow(bounds: Aabb, margin: f64) -> Aabb {
257    let padding = Vec3::splat(margin);
258    let mut grown = Aabb::from_point(bounds.min - padding);
259    grown.extend(bounds.max + padding);
260    grown
261}
262
263/// BVH over the triangles of a mesh, padded by the search length.
264pub(crate) fn build_index(mesh: &TriMesh, search_length: f64) -> Bvh<u32> {
265    let items = (0..mesh.indices.len() / 3).filter_map(|index| {
266        let triangle = triangle_at(mesh, index)?;
267        Some(SpatialItem::new(
268            index as u32,
269            grow(bounds_of(&triangle), search_length),
270        ))
271    });
272    Bvh::build(items)
273}
274
275/// Whether any vertex of `probe` lies strictly inside `solid`.
276fn any_vertex_inside(probe: &TriMesh, solid: &TriMesh) -> bool {
277    probe
278        .positions
279        .iter()
280        .any(|point| crate::containment::contains(solid, *point).unwrap_or(false))
281}