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_mesh::TriMesh;
15use axiolid_spatial::{Bvh, SpatialItem};
16
17/// Smallest distance between the surfaces of `a` and `b`.
18///
19/// Returns `None` when nothing is found within `search_length`, and
20/// `Some(0.0)` when the surfaces touch or interpenetrate. A caller doing
21/// clash detection reads those as "clear beyond the search radius" and
22/// "clash" respectively, and any positive value as the remaining clearance.
23///
24/// # Panics
25///
26/// Never. A malformed index buffer yields `None` rather than a panic.
27pub fn min_gap(a: &TriMesh, b: &TriMesh, search_length: f64) -> Option<f64> {
28    if !search_length.is_finite() || search_length <= 0.0 {
29        return None;
30    }
31    // Interpenetration is a clash even when no triangle pair crosses: a
32    // corner of one solid can sit strictly inside the other while every
33    // surface-to-surface distance stays positive. Surface distance alone
34    // would report that as clearance, which is exactly backwards. Reuse the
35    // exact containment query rather than inventing a second answer.
36    if any_vertex_inside(a, b) || any_vertex_inside(b, a) {
37        return Some(0.0);
38    }
39
40    let b_index = build_index(b, search_length);
41    let mut best: Option<f64> = None;
42    let mut candidates = Vec::new();
43
44    for a_triangle in triangles(a) {
45        let probe = grow(bounds_of(&a_triangle), search_length);
46        candidates.clear();
47        b_index.query_aabb(&probe, &mut candidates);
48        for &candidate in &candidates {
49            let Some(item) = b_index.item(candidate) else {
50                continue;
51            };
52            let Some(b_triangle) = triangle_at(b, item.key as usize) else {
53                continue;
54            };
55            // Interpenetration without a crossing edge is possible: a
56            // corner of one solid can sit inside the other while every
57            // triangle pair stays a positive distance apart. Surface
58            // distance alone would report that gap as clearance, which is
59            // exactly backwards for clash detection.
60            let distance = triangle_distance(&a_triangle, &b_triangle);
61            if best.is_none_or(|current| distance < current) {
62                best = Some(distance);
63            }
64            if distance == 0.0 {
65                return Some(0.0);
66            }
67        }
68    }
69    best.filter(|distance| *distance <= search_length)
70}
71
72/// Distance between two triangles, zero when they touch or cross.
73///
74/// Every closest pair of convex sets is realised on a boundary feature, so
75/// checking all edge pairs and all vertex-to-face distances is exhaustive
76/// for triangles.
77fn triangle_distance(a: &[Point3; 3], b: &[Point3; 3]) -> f64 {
78    let mut best = f64::INFINITY;
79    for i in 0..3 {
80        for j in 0..3 {
81            let d = segment_distance(a[i], a[(i + 1) % 3], b[j], b[(j + 1) % 3]);
82            best = best.min(d);
83        }
84    }
85    for vertex in a {
86        best = best.min(point_triangle_distance(*vertex, b));
87    }
88    for vertex in b {
89        best = best.min(point_triangle_distance(*vertex, a));
90    }
91    best
92}
93
94/// Distance between two segments, handling the parallel case.
95fn segment_distance(p0: Point3, p1: Point3, q0: Point3, q1: Point3) -> f64 {
96    let u = p1 - p0;
97    let v = q1 - q0;
98    let w = p0 - q0;
99    let a = u.dot(u);
100    let b = u.dot(v);
101    let c = v.dot(v);
102    let d = u.dot(w);
103    let e = v.dot(w);
104    let denominator = a * c - b * b;
105
106    // Parallel segments leave the parameters underdetermined; clamping to the
107    // endpoints is what makes this total rather than a special case.
108    let (mut s, mut t) = if denominator.abs() <= f64::EPSILON * a.max(c).max(1.0) {
109        (0.0, if c > 0.0 { e / c } else { 0.0 })
110    } else {
111        ((b * e - c * d) / denominator, (a * e - b * d) / denominator)
112    };
113    s = s.clamp(0.0, 1.0);
114    t = t.clamp(0.0, 1.0);
115
116    // Re-solve each parameter against the other's clamped value, so a
117    // clamped endpoint still finds the true nearest point on its partner.
118    if c > 0.0 {
119        t = ((s * b - e) / c).clamp(0.0, 1.0);
120    }
121    if a > 0.0 {
122        s = (-((t * b + d) / a)).clamp(0.0, 1.0);
123    }
124    ((p0 + u * s) - (q0 + v * t)).length()
125}
126
127/// Distance from a point to a triangle, including its interior.
128fn point_triangle_distance(point: Point3, triangle: &[Point3; 3]) -> f64 {
129    let [a, b, c] = *triangle;
130    let normal = (b - a).cross(c - a);
131    let area_squared = normal.length_squared();
132    if area_squared > 0.0 {
133        // Project onto the plane and test the barycentric signs. If the
134        // projection lands inside, the perpendicular IS the distance.
135        let distance = normal.dot(point - a) / area_squared.sqrt();
136        let projected = point - normal * (normal.dot(point - a) / area_squared);
137        let inside = [(a, b), (b, c), (c, a)]
138            .iter()
139            .all(|(from, to)| normal.dot((*to - *from).cross(projected - *from)) >= 0.0);
140        if inside {
141            return distance.abs();
142        }
143    }
144    // Outside the projection, or a degenerate triangle: the nearest point is
145    // on an edge.
146    let mut best = f64::INFINITY;
147    for i in 0..3 {
148        best = best.min(point_segment_distance(
149            point,
150            triangle[i],
151            triangle[(i + 1) % 3],
152        ));
153    }
154    best
155}
156
157/// Distance from a point to a segment.
158fn point_segment_distance(point: Point3, from: Point3, to: Point3) -> f64 {
159    let direction = to - from;
160    let length_squared = direction.length_squared();
161    if length_squared == 0.0 {
162        return (point - from).length();
163    }
164    let t = ((point - from).dot(direction) / length_squared).clamp(0.0, 1.0);
165    (point - (from + direction * t)).length()
166}
167
168/// Triangles of a mesh as coordinate triples.
169fn triangles(mesh: &TriMesh) -> impl Iterator<Item = [Point3; 3]> + '_ {
170    (0..mesh.indices.len() / 3).filter_map(move |index| triangle_at(mesh, index))
171}
172
173/// One triangle by index, or `None` if the index buffer is malformed.
174pub(crate) fn triangle_at(mesh: &TriMesh, index: usize) -> Option<[Point3; 3]> {
175    let corners = mesh.indices.get(index * 3..index * 3 + 3)?;
176    Some([
177        *mesh.positions.get(corners[0] as usize)?,
178        *mesh.positions.get(corners[1] as usize)?,
179        *mesh.positions.get(corners[2] as usize)?,
180    ])
181}
182
183/// Bounding box of a triangle.
184fn bounds_of(triangle: &[Point3; 3]) -> Aabb {
185    let mut bounds = Aabb::from_point(triangle[0]);
186    bounds.extend(triangle[1]);
187    bounds.extend(triangle[2]);
188    bounds
189}
190
191/// Grow a box by a uniform margin, so a query catches near misses.
192fn grow(bounds: Aabb, margin: f64) -> Aabb {
193    let padding = Vec3::splat(margin);
194    let mut grown = Aabb::from_point(bounds.min - padding);
195    grown.extend(bounds.max + padding);
196    grown
197}
198
199/// BVH over the triangles of a mesh, padded by the search length.
200fn build_index(mesh: &TriMesh, search_length: f64) -> Bvh<u32> {
201    let items = (0..mesh.indices.len() / 3).filter_map(|index| {
202        let triangle = triangle_at(mesh, index)?;
203        Some(SpatialItem::new(
204            index as u32,
205            grow(bounds_of(&triangle), search_length),
206        ))
207    });
208    Bvh::build(items)
209}
210
211/// Whether any vertex of `probe` lies strictly inside `solid`.
212fn any_vertex_inside(probe: &TriMesh, solid: &TriMesh) -> bool {
213    probe
214        .positions
215        .iter()
216        .any(|point| crate::containment::contains(solid, *point).unwrap_or(false))
217}