Skip to main content

oxiphysics_collision/
proximity_query.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Advanced proximity queries for collision detection.
5//!
6//! This module provides closest-point and signed-distance-field computations
7//! for common geometric primitives, along with CSG SDF operations and a
8//! warm-start proximity cache.
9//!
10//! All positions are represented as `[f64; 3]` (x, y, z) to avoid external
11//! algebra dependencies.
12
13// ─────────────────────────────────────────────────────────────────────────────
14// Low-level vector helpers (no external dependencies)
15// ─────────────────────────────────────────────────────────────────────────────
16
17#[inline]
18fn vadd(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
19    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
20}
21
22#[inline]
23fn vsub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
24    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
25}
26
27#[inline]
28fn vscale(a: [f64; 3], s: f64) -> [f64; 3] {
29    [a[0] * s, a[1] * s, a[2] * s]
30}
31
32#[inline]
33fn vdot(a: [f64; 3], b: [f64; 3]) -> f64 {
34    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
35}
36
37#[inline]
38fn vcross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
39    [
40        a[1] * b[2] - a[2] * b[1],
41        a[2] * b[0] - a[0] * b[2],
42        a[0] * b[1] - a[1] * b[0],
43    ]
44}
45
46#[inline]
47fn vlen_sq(a: [f64; 3]) -> f64 {
48    vdot(a, a)
49}
50
51#[inline]
52fn vlen(a: [f64; 3]) -> f64 {
53    vlen_sq(a).sqrt()
54}
55
56#[inline]
57fn vnormalize(a: [f64; 3]) -> [f64; 3] {
58    let l = vlen(a);
59    if l < 1e-300 {
60        [0.0, 0.0, 0.0]
61    } else {
62        vscale(a, 1.0 / l)
63    }
64}
65
66#[inline]
67fn vlerp(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
68    vadd(vscale(a, 1.0 - t), vscale(b, t))
69}
70
71#[inline]
72fn vdist(a: [f64; 3], b: [f64; 3]) -> f64 {
73    vlen(vsub(a, b))
74}
75
76#[inline]
77fn vclamp(x: f64, lo: f64, hi: f64) -> f64 {
78    x.max(lo).min(hi)
79}
80
81// ─────────────────────────────────────────────────────────────────────────────
82// Public data types
83// ─────────────────────────────────────────────────────────────────────────────
84
85/// Identifies which geometric feature of a primitive is closest.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ClosestFeature {
88    /// A vertex / corner of the primitive.
89    Vertex,
90    /// An edge of the primitive.
91    Edge,
92    /// A face (interior) of the primitive.
93    Face,
94}
95
96/// Result returned by closest-point queries.
97///
98/// Contains the closest position, the separation distance, which feature was
99/// nearest, and optional barycentric parameters for that feature.
100#[derive(Debug, Clone, Copy)]
101pub struct ClosestPointResult {
102    /// The closest point on the primitive expressed in world space.
103    pub position: [f64; 3],
104    /// Non-negative Euclidean distance from the query point to `position`.
105    pub distance: f64,
106    /// Which geometric feature of the primitive is closest.
107    pub feature: ClosestFeature,
108    /// First barycentric / interpolation parameter (e.g. `t` along an edge).
109    pub u_param: f64,
110    /// Second barycentric parameter (e.g. `v` in a triangle).
111    pub v_param: f64,
112}
113
114impl ClosestPointResult {
115    fn new(
116        position: [f64; 3],
117        query_pt: [f64; 3],
118        feature: ClosestFeature,
119        u_param: f64,
120        v_param: f64,
121    ) -> Self {
122        let distance = vdist(position, query_pt);
123        Self {
124            position,
125            distance,
126            feature,
127            u_param,
128            v_param,
129        }
130    }
131}
132
133// ─────────────────────────────────────────────────────────────────────────────
134// Segment – segment closest points helper result
135// ─────────────────────────────────────────────────────────────────────────────
136
137/// Result from a segment–segment closest-point query.
138#[derive(Debug, Clone, Copy)]
139pub struct SegSegResult {
140    /// Closest point on segment AB.
141    pub point_a: [f64; 3],
142    /// Closest point on segment CD.
143    pub point_b: [f64; 3],
144    /// Parameter along AB (0 → A, 1 → B).
145    pub s: f64,
146    /// Parameter along CD (0 → C, 1 → D).
147    pub t: f64,
148    /// Euclidean distance between the two closest points.
149    pub distance: f64,
150}
151
152// ─────────────────────────────────────────────────────────────────────────────
153// Point – segment
154// ─────────────────────────────────────────────────────────────────────────────
155
156/// Computes the closest point on segment `[a, b]` to `p`.
157///
158/// Returns a [`ClosestPointResult`] whose `u_param` is the clamped parameter
159/// `t ∈ [0, 1]` such that `closest = a + t * (b - a)`.
160///
161/// # Examples
162/// ```no_run
163/// use oxiphysics_collision::proximity_query::point_segment_closest;
164/// let result = point_segment_closest([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]);
165/// assert!((result.u_param - 0.5).abs() < 1e-9);
166/// ```
167pub fn point_segment_closest(a: [f64; 3], b: [f64; 3], p: [f64; 3]) -> ClosestPointResult {
168    let ab = vsub(b, a);
169    let ap = vsub(p, a);
170    let len_sq = vlen_sq(ab);
171
172    let (t, feature) = if len_sq < 1e-300 {
173        // Degenerate segment – a == b
174        (0.0, ClosestFeature::Vertex)
175    } else {
176        let raw_t = vdot(ap, ab) / len_sq;
177        if raw_t <= 0.0 {
178            (0.0, ClosestFeature::Vertex)
179        } else if raw_t >= 1.0 {
180            (1.0, ClosestFeature::Vertex)
181        } else {
182            (raw_t, ClosestFeature::Edge)
183        }
184    };
185
186    let pos = vadd(a, vscale(ab, t));
187    ClosestPointResult::new(pos, p, feature, t, 0.0)
188}
189
190// ─────────────────────────────────────────────────────────────────────────────
191// Point – triangle  (Ericson / Voronoi region method)
192// ─────────────────────────────────────────────────────────────────────────────
193
194/// Computes the closest point on triangle `(v0, v1, v2)` to point `p` using
195/// Voronoi-region decomposition (Real-Time Collision Detection, Ericson §5.1.5).
196///
197/// The returned `u_param` and `v_param` are the barycentric coordinates
198/// corresponding to `v1` and `v2` respectively (so `w = 1 - u - v` for `v0`).
199///
200/// # Examples
201/// ```no_run
202/// use oxiphysics_collision::proximity_query::point_triangle_closest;
203/// // Point directly above centroid
204/// let r = point_triangle_closest(
205///     [0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [0.0, 3.0, 0.0],
206///     [1.0, 1.0, 2.0]);
207/// assert!(r.distance < 2.1);
208/// ```
209pub fn point_triangle_closest(
210    v0: [f64; 3],
211    v1: [f64; 3],
212    v2: [f64; 3],
213    p: [f64; 3],
214) -> ClosestPointResult {
215    let ab = vsub(v1, v0);
216    let ac = vsub(v2, v0);
217    let ap = vsub(p, v0);
218
219    let d1 = vdot(ab, ap);
220    let d2 = vdot(ac, ap);
221    // Vertex region v0
222    if d1 <= 0.0 && d2 <= 0.0 {
223        return ClosestPointResult::new(v0, p, ClosestFeature::Vertex, 0.0, 0.0);
224    }
225
226    let bp = vsub(p, v1);
227    let d3 = vdot(ab, bp);
228    let d4 = vdot(ac, bp);
229    // Vertex region v1
230    if d3 >= 0.0 && d4 <= d3 {
231        return ClosestPointResult::new(v1, p, ClosestFeature::Vertex, 1.0, 0.0);
232    }
233
234    // Edge region v0-v1
235    let vc = d1 * d4 - d3 * d2;
236    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
237        let v = d1 / (d1 - d3);
238        let pos = vadd(v0, vscale(ab, v));
239        return ClosestPointResult::new(pos, p, ClosestFeature::Edge, v, 0.0);
240    }
241
242    let cp = vsub(p, v2);
243    let d5 = vdot(ab, cp);
244    let d6 = vdot(ac, cp);
245    // Vertex region v2
246    if d6 >= 0.0 && d5 <= d6 {
247        return ClosestPointResult::new(v2, p, ClosestFeature::Vertex, 0.0, 1.0);
248    }
249
250    // Edge region v0-v2
251    let vb = d5 * d2 - d1 * d6;
252    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
253        let w = d2 / (d2 - d6);
254        let pos = vadd(v0, vscale(ac, w));
255        return ClosestPointResult::new(pos, p, ClosestFeature::Edge, 0.0, w);
256    }
257
258    // Edge region v1-v2
259    let va = d3 * d6 - d5 * d4;
260    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
261        let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
262        let pos = vadd(v1, vscale(vsub(v2, v1), w));
263        return ClosestPointResult::new(pos, p, ClosestFeature::Edge, 1.0 - w, w);
264    }
265
266    // Interior of triangle
267    let denom = 1.0 / (va + vb + vc);
268    let v = vb * denom;
269    let w = vc * denom;
270    let pos = vadd(v0, vadd(vscale(ab, v), vscale(ac, w)));
271    ClosestPointResult::new(pos, p, ClosestFeature::Face, v, w)
272}
273
274// ─────────────────────────────────────────────────────────────────────────────
275// Segment – segment
276// ─────────────────────────────────────────────────────────────────────────────
277
278/// Computes the closest points between segments `AB` and `CD`.
279///
280/// Handles the degenerate (parallel / point) cases robustly.
281///
282/// # Examples
283/// ```no_run
284/// use oxiphysics_collision::proximity_query::segment_segment_closest;
285/// let r = segment_segment_closest(
286///     [0.0,0.0,0.0],[1.0,0.0,0.0],
287///     [0.5,1.0,0.0],[0.5,-1.0,0.0]);
288/// assert!(r.distance < 1e-9);
289/// ```
290pub fn segment_segment_closest(a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) -> SegSegResult {
291    let d1 = vsub(b, a);
292    let d2 = vsub(d, c);
293    let r = vsub(a, c);
294
295    let e = vdot(d1, d1);
296    let f = vdot(d2, d2);
297
298    let (mut s, t);
299
300    const EPS: f64 = 1e-10;
301
302    // Both degenerate
303    if e < EPS && f < EPS {
304        s = 0.0;
305        t = 0.0;
306    } else if e < EPS {
307        // Segment AB is a point
308        s = 0.0;
309        t = vclamp(vdot(r, d2) / f, 0.0, 1.0);
310    } else {
311        let c1 = vdot(d1, r);
312        if f < EPS {
313            // Segment CD is a point
314            t = 0.0;
315            s = vclamp(-c1 / e, 0.0, 1.0);
316        } else {
317            let b_coeff = vdot(d1, d2);
318            let denom = e * f - b_coeff * b_coeff;
319            // Not parallel
320            if denom.abs() > EPS {
321                let c2 = vdot(d2, r);
322                s = vclamp((b_coeff * c2 - c1 * f) / denom, 0.0, 1.0);
323            } else {
324                // Parallel – pick s=0
325                s = 0.0;
326            }
327            let c2 = vdot(d2, r);
328            let tnom = b_coeff * s + c2;
329            if tnom < 0.0 {
330                t = 0.0;
331                s = vclamp(-c1 / e, 0.0, 1.0);
332            } else if tnom > f {
333                t = 1.0;
334                s = vclamp((b_coeff - c1) / e, 0.0, 1.0);
335            } else {
336                t = tnom / f;
337            }
338        }
339    }
340
341    let point_a = vadd(a, vscale(d1, s));
342    let point_b = vadd(c, vscale(d2, t));
343    let distance = vdist(point_a, point_b);
344    SegSegResult {
345        point_a,
346        point_b,
347        s,
348        t,
349        distance,
350    }
351}
352
353// ─────────────────────────────────────────────────────────────────────────────
354// Point – AABB
355// ─────────────────────────────────────────────────────────────────────────────
356
357/// Signed distance from point `p` to axis-aligned bounding box defined by
358/// `min` and `max` corners.
359///
360/// Positive when the point is outside the box, negative when inside.
361///
362/// # Examples
363/// ```no_run
364/// use oxiphysics_collision::proximity_query::point_box_distance;
365/// // Inside
366/// assert!(point_box_distance([-1.0,-1.0,-1.0],[1.0,1.0,1.0],[0.0,0.0,0.0]) < 0.0);
367/// // Outside
368/// assert!(point_box_distance([-1.0,-1.0,-1.0],[1.0,1.0,1.0],[2.0,0.0,0.0]) > 0.0);
369/// ```
370pub fn point_box_distance(min: [f64; 3], max: [f64; 3], p: [f64; 3]) -> f64 {
371    // Exterior distance squared
372    let mut dist_sq_outside = 0.0_f64;
373    for i in 0..3 {
374        if p[i] < min[i] {
375            let d = min[i] - p[i];
376            dist_sq_outside += d * d;
377        } else if p[i] > max[i] {
378            let d = p[i] - max[i];
379            dist_sq_outside += d * d;
380        }
381    }
382    if dist_sq_outside > 0.0 {
383        return dist_sq_outside.sqrt();
384    }
385
386    // Interior: return negative of deepest penetration
387    let mut min_pen = f64::MAX;
388    for i in 0..3 {
389        let d_lo = p[i] - min[i];
390        let d_hi = max[i] - p[i];
391        let pen = d_lo.min(d_hi);
392        if pen < min_pen {
393            min_pen = pen;
394        }
395    }
396    -min_pen
397}
398
399// ─────────────────────────────────────────────────────────────────────────────
400// Point – sphere
401// ─────────────────────────────────────────────────────────────────────────────
402
403/// Signed distance from `p` to a sphere with `center` and radius `r`.
404///
405/// Negative when the point is inside the sphere.
406///
407/// # Examples
408/// ```no_run
409/// use oxiphysics_collision::proximity_query::point_sphere_distance;
410/// assert!((point_sphere_distance([0.0,0.0,0.0], 1.0, [0.0,0.0,0.0]) - (-1.0)).abs() < 1e-9);
411/// assert!((point_sphere_distance([0.0,0.0,0.0], 1.0, [2.0,0.0,0.0]) - 1.0).abs() < 1e-9);
412/// ```
413pub fn point_sphere_distance(center: [f64; 3], r: f64, p: [f64; 3]) -> f64 {
414    vdist(p, center) - r
415}
416
417// ─────────────────────────────────────────────────────────────────────────────
418// Point – capsule
419// ─────────────────────────────────────────────────────────────────────────────
420
421/// Signed distance from `p` to a capsule defined by segment endpoints `a`, `b`
422/// and radius `r`.
423///
424/// Negative when the point is inside the capsule volume.
425///
426/// # Examples
427/// ```no_run
428/// use oxiphysics_collision::proximity_query::point_capsule_distance;
429/// // Point on the capsule axis → inside if within radius
430/// let d = point_capsule_distance([0.0,0.0,0.0],[0.0,1.0,0.0], 0.5, [0.0,0.5,0.0]);
431/// assert!(d < 0.0);
432/// ```
433pub fn point_capsule_distance(a: [f64; 3], b: [f64; 3], r: f64, p: [f64; 3]) -> f64 {
434    let res = point_segment_closest(a, b, p);
435    res.distance - r
436}
437
438// ─────────────────────────────────────────────────────────────────────────────
439// Signed Distance Field
440// ─────────────────────────────────────────────────────────────────────────────
441
442/// A variant-based signed distance field (SDF) for common primitive shapes.
443///
444/// Use [`SignedDistanceField::eval`] to obtain the signed distance at any
445/// world-space point.
446#[derive(Debug, Clone)]
447pub enum SignedDistanceField {
448    /// A sphere defined by its `center` and radius `r`.
449    Sphere {
450        /// World-space center of the sphere.
451        center: [f64; 3],
452        /// Radius of the sphere.
453        r: f64,
454    },
455    /// An axis-aligned box defined by `half`-extents centred at the origin.
456    Box {
457        /// Half-widths along each axis.
458        half: [f64; 3],
459    },
460    /// A capsule (swept sphere) from point `a` to `b` with radius `r`.
461    Capsule {
462        /// Start of the capsule's medial axis.
463        a: [f64; 3],
464        /// End of the capsule's medial axis.
465        b: [f64; 3],
466        /// Radius of the capsule.
467        r: f64,
468    },
469    /// An upright cylinder centred at the origin, aligned along `axis` (unit
470    /// vector), with radius `r` and total height `h`.
471    Cylinder {
472        /// Unit direction along the cylinder's length.
473        axis: [f64; 3],
474        /// Radius of the cylinder.
475        r: f64,
476        /// Total height of the cylinder.
477        h: f64,
478    },
479}
480
481impl SignedDistanceField {
482    /// Evaluates the signed distance at world-space point `p`.
483    ///
484    /// Returns a negative value when `p` is inside the shape.
485    ///
486    /// # Examples
487    /// ```no_run
488    /// use oxiphysics_collision::proximity_query::SignedDistanceField;
489    /// let sdf = SignedDistanceField::Sphere { center: [0.0;3], r: 1.0 };
490    /// assert!((sdf.eval([1.5,0.0,0.0]) - 0.5).abs() < 1e-9);
491    /// ```
492    pub fn eval(&self, p: [f64; 3]) -> f64 {
493        match self {
494            SignedDistanceField::Sphere { center, r } => point_sphere_distance(*center, *r, p),
495            SignedDistanceField::Box { half } => {
496                // Symmetric about origin
497                let q = [
498                    p[0].abs() - half[0],
499                    p[1].abs() - half[1],
500                    p[2].abs() - half[2],
501                ];
502                let q_pos = [q[0].max(0.0), q[1].max(0.0), q[2].max(0.0)];
503                let outside = vlen(q_pos);
504                let inside = q[0].max(q[1]).max(q[2]).min(0.0);
505                outside + inside
506            }
507            SignedDistanceField::Capsule { a, b, r } => point_capsule_distance(*a, *b, *r, p),
508            SignedDistanceField::Cylinder { axis, r, h } => {
509                // Project onto axis (assumed to pass through origin)
510                let ax = vnormalize(*axis);
511                let proj = vdot(p, ax);
512                let half_h = h / 2.0;
513                let radial_pt = vsub(p, vscale(ax, proj));
514                let rd = vlen(radial_pt) - r;
515                let yd = proj.abs() - half_h;
516                // Distance to cylinder
517                let outside_r = rd.max(0.0);
518                let outside_y = yd.max(0.0);
519                let outside = (outside_r * outside_r + outside_y * outside_y).sqrt();
520                let inside = rd.max(yd).min(0.0);
521                outside + inside
522            }
523        }
524    }
525}
526
527// ─────────────────────────────────────────────────────────────────────────────
528// CSG SDF operations
529// ─────────────────────────────────────────────────────────────────────────────
530
531/// Boolean union of two SDF values: the region that is inside *either* shape.
532///
533/// Returns `min(a, b)`.
534///
535/// # Examples
536/// ```no_run
537/// use oxiphysics_collision::proximity_query::sdf_union;
538/// assert_eq!(sdf_union(2.0, -1.0), -1.0);
539/// ```
540#[inline]
541pub fn sdf_union(a: f64, b: f64) -> f64 {
542    a.min(b)
543}
544
545/// Boolean intersection of two SDF values: the region that is inside *both* shapes.
546///
547/// Returns `max(a, b)`.
548///
549/// # Examples
550/// ```no_run
551/// use oxiphysics_collision::proximity_query::sdf_intersect;
552/// assert_eq!(sdf_intersect(2.0, -1.0), 2.0);
553/// ```
554#[inline]
555pub fn sdf_intersect(a: f64, b: f64) -> f64 {
556    a.max(b)
557}
558
559/// Boolean subtraction of SDF `b` from `a`: the region inside `a` but outside `b`.
560///
561/// Returns `max(a, -b)`.
562///
563/// # Examples
564/// ```no_run
565/// use oxiphysics_collision::proximity_query::sdf_subtract;
566/// assert!((sdf_subtract(-0.5, -0.3) - 0.3).abs() < 1e-9);
567/// ```
568#[inline]
569pub fn sdf_subtract(a: f64, b: f64) -> f64 {
570    a.max(-b)
571}
572
573// ─────────────────────────────────────────────────────────────────────────────
574// Proximity cache
575// ─────────────────────────────────────────────────────────────────────────────
576
577/// Maximum number of entries in a [`ProximityCache`].
578pub const PROXIMITY_CACHE_CAPACITY: usize = 64;
579
580/// A cached closest-feature pair between two shapes identified by integer IDs.
581#[derive(Debug, Clone, Copy)]
582pub struct ProximityCacheEntry {
583    /// ID of the first shape.
584    pub id_a: u32,
585    /// ID of the second shape.
586    pub id_b: u32,
587    /// Closest point on shape A.
588    pub point_a: [f64; 3],
589    /// Closest point on shape B.
590    pub point_b: [f64; 3],
591    /// Distance at the time of caching.
592    pub distance: f64,
593    /// Which feature of shape A produced the closest point.
594    pub feature_a: ClosestFeature,
595    /// Which feature of shape B produced the closest point.
596    pub feature_b: ClosestFeature,
597    /// Generation / frame stamp when this entry was last updated.
598    pub stamp: u64,
599}
600
601/// LRU-eviction proximity cache holding up to [`PROXIMITY_CACHE_CAPACITY`]
602/// closest-feature pairs.
603///
604/// Useful for warm-starting iterative contact solvers: re-using the previous
605/// frame's closest features drastically reduces the number of GJK / distance
606/// iterations required.
607///
608/// # Examples
609/// ```no_run
610/// use oxiphysics_collision::proximity_query::{ProximityCache, ClosestFeature};
611/// let mut cache = ProximityCache::new();
612/// cache.insert(1, 2, [0.0;3], [1.0,0.0,0.0], 1.0,
613///              ClosestFeature::Face, ClosestFeature::Face, 0);
614/// assert!(cache.get(1, 2).is_some());
615/// ```
616#[derive(Debug, Clone)]
617pub struct ProximityCache {
618    entries: Vec<ProximityCacheEntry>,
619}
620
621impl Default for ProximityCache {
622    fn default() -> Self {
623        Self::new()
624    }
625}
626
627impl ProximityCache {
628    /// Creates a new, empty proximity cache.
629    pub fn new() -> Self {
630        Self {
631            entries: Vec::with_capacity(PROXIMITY_CACHE_CAPACITY),
632        }
633    }
634
635    /// Returns the number of entries currently held.
636    pub fn len(&self) -> usize {
637        self.entries.len()
638    }
639
640    /// Returns `true` if the cache contains no entries.
641    pub fn is_empty(&self) -> bool {
642        self.entries.is_empty()
643    }
644
645    /// Looks up a cached entry for the (unordered) pair `(id_a, id_b)`.
646    ///
647    /// Returns `None` when the pair has not been cached.
648    pub fn get(&self, id_a: u32, id_b: u32) -> Option<&ProximityCacheEntry> {
649        let (lo, hi) = if id_a <= id_b {
650            (id_a, id_b)
651        } else {
652            (id_b, id_a)
653        };
654        self.entries.iter().find(|e| e.id_a == lo && e.id_b == hi)
655    }
656
657    /// Inserts or updates a closest-feature pair in the cache.
658    ///
659    /// When the cache is full the oldest entry (smallest `stamp`) is evicted.
660    pub fn insert(
661        &mut self,
662        id_a: u32,
663        id_b: u32,
664        point_a: [f64; 3],
665        point_b: [f64; 3],
666        distance: f64,
667        feature_a: ClosestFeature,
668        feature_b: ClosestFeature,
669        stamp: u64,
670    ) {
671        let (lo, hi, pa, pb, fa, fb) = if id_a <= id_b {
672            (id_a, id_b, point_a, point_b, feature_a, feature_b)
673        } else {
674            (id_b, id_a, point_b, point_a, feature_b, feature_a)
675        };
676
677        // Update in place if already present
678        if let Some(e) = self
679            .entries
680            .iter_mut()
681            .find(|e| e.id_a == lo && e.id_b == hi)
682        {
683            e.point_a = pa;
684            e.point_b = pb;
685            e.distance = distance;
686            e.feature_a = fa;
687            e.feature_b = fb;
688            e.stamp = stamp;
689            return;
690        }
691
692        let entry = ProximityCacheEntry {
693            id_a: lo,
694            id_b: hi,
695            point_a: pa,
696            point_b: pb,
697            distance,
698            feature_a: fa,
699            feature_b: fb,
700            stamp,
701        };
702
703        if self.entries.len() < PROXIMITY_CACHE_CAPACITY {
704            self.entries.push(entry);
705        } else {
706            // Evict oldest
707            let oldest_idx = self
708                .entries
709                .iter()
710                .enumerate()
711                .min_by_key(|(_, e)| e.stamp)
712                .map(|(i, _)| i)
713                .unwrap_or(0);
714            self.entries[oldest_idx] = entry;
715        }
716    }
717
718    /// Removes the cached entry for pair `(id_a, id_b)` if present.
719    pub fn remove(&mut self, id_a: u32, id_b: u32) {
720        let (lo, hi) = if id_a <= id_b {
721            (id_a, id_b)
722        } else {
723            (id_b, id_a)
724        };
725        self.entries.retain(|e| !(e.id_a == lo && e.id_b == hi));
726    }
727
728    /// Removes all entries whose stamp is strictly less than `min_stamp`.
729    ///
730    /// Call once per simulation step with the current frame index to evict
731    /// stale entries from objects that have been removed.
732    pub fn evict_stale(&mut self, min_stamp: u64) {
733        self.entries.retain(|e| e.stamp >= min_stamp);
734    }
735}
736
737// ─────────────────────────────────────────────────────────────────────────────
738// Triangle – triangle distance
739// ─────────────────────────────────────────────────────────────────────────────
740
741/// Result from a triangle–triangle minimum-distance query.
742#[derive(Debug, Clone, Copy)]
743pub struct TriTriResult {
744    /// Closest point on the first triangle.
745    pub point_a: [f64; 3],
746    /// Closest point on the second triangle.
747    pub point_b: [f64; 3],
748    /// Minimum Euclidean distance between the two triangles.
749    pub distance: f64,
750}
751
752/// Computes the minimum distance between two triangles in 3-D.
753///
754/// Uses an exhaustive search over vertex–triangle and edge–edge pairs following
755/// the approach described in Real-Time Collision Detection §5.2.
756///
757/// The triangles are defined by their three vertices `a0/a1/a2` and
758/// `b0/b1/b2`.
759///
760/// # Examples
761/// ```no_run
762/// use oxiphysics_collision::proximity_query::triangle_triangle_distance;
763/// let r = triangle_triangle_distance(
764///     [0.0,0.0,0.0],[1.0,0.0,0.0],[0.0,1.0,0.0],
765///     [0.0,0.0,2.0],[1.0,0.0,2.0],[0.0,1.0,2.0]);
766/// assert!((r.distance - 2.0).abs() < 1e-9);
767/// ```
768pub fn triangle_triangle_distance(
769    a0: [f64; 3],
770    a1: [f64; 3],
771    a2: [f64; 3],
772    b0: [f64; 3],
773    b1: [f64; 3],
774    b2: [f64; 3],
775) -> TriTriResult {
776    let mut best_dist = f64::MAX;
777    let mut best_pa = a0;
778    let mut best_pb = b0;
779
780    // Helper: update best from a closest-point result against a fixed "other" point
781    macro_rules! try_pt_tri {
782        ($pt:expr, $q0:expr, $q1:expr, $q2:expr, $is_a:expr) => {{
783            let r = point_triangle_closest($q0, $q1, $q2, $pt);
784            if r.distance < best_dist {
785                best_dist = r.distance;
786                if $is_a {
787                    best_pa = $pt;
788                    best_pb = r.position;
789                } else {
790                    best_pa = r.position;
791                    best_pb = $pt;
792                }
793            }
794        }};
795    }
796
797    // Vertices of A against triangle B
798    try_pt_tri!(a0, b0, b1, b2, true);
799    try_pt_tri!(a1, b0, b1, b2, true);
800    try_pt_tri!(a2, b0, b1, b2, true);
801
802    // Vertices of B against triangle A
803    try_pt_tri!(b0, a0, a1, a2, false);
804    try_pt_tri!(b1, a0, a1, a2, false);
805    try_pt_tri!(b2, a0, a1, a2, false);
806
807    // Edge–edge pairs (3×3 = 9)
808    let edges_a = [(a0, a1), (a1, a2), (a2, a0)];
809    let edges_b = [(b0, b1), (b1, b2), (b2, b0)];
810    for &(ea, eb) in &edges_a {
811        for &(ec, ed) in &edges_b {
812            let r = segment_segment_closest(ea, eb, ec, ed);
813            if r.distance < best_dist {
814                best_dist = r.distance;
815                best_pa = r.point_a;
816                best_pb = r.point_b;
817            }
818        }
819    }
820
821    TriTriResult {
822        point_a: best_pa,
823        point_b: best_pb,
824        distance: best_dist,
825    }
826}
827
828// ─────────────────────────────────────────────────────────────────────────────
829// Smooth-min (k-factor) CSG helper (bonus, not in spec but useful)
830// ─────────────────────────────────────────────────────────────────────────────
831
832/// Smooth union of two SDF values using an exponential smooth-min kernel.
833///
834/// `k` controls the blending radius; larger `k` → more aggressive smoothing.
835///
836/// # Examples
837/// ```no_run
838/// use oxiphysics_collision::proximity_query::sdf_smooth_union;
839/// let v = sdf_smooth_union(0.0, 0.0, 32.0);
840/// assert!(v <= 0.0);
841/// ```
842pub fn sdf_smooth_union(a: f64, b: f64, k: f64) -> f64 {
843    let h = (0.5 + 0.5 * (b - a) / k).clamp(0.0, 1.0);
844    vlerp([b, 0.0, 0.0], [a, 0.0, 0.0], h)[0] - k * h * (1.0 - h)
845}
846
847// ─────────────────────────────────────────────────────────────────────────────
848// Closest point on AABB
849// ─────────────────────────────────────────────────────────────────────────────
850
851/// Returns the closest point on an AABB (defined by `min` and `max`) to `p`.
852///
853/// When `p` is inside the box the closest point is `p` itself and the distance
854/// is negative (use [`point_box_distance`] for the signed scalar).
855///
856/// # Examples
857/// ```no_run
858/// use oxiphysics_collision::proximity_query::point_box_closest;
859/// let cp = point_box_closest([-1.0,-1.0,-1.0],[1.0,1.0,1.0],[3.0,0.0,0.0]);
860/// assert!((cp[0] - 1.0).abs() < 1e-9);
861/// ```
862pub fn point_box_closest(min: [f64; 3], max: [f64; 3], p: [f64; 3]) -> [f64; 3] {
863    [
864        p[0].clamp(min[0], max[0]),
865        p[1].clamp(min[1], max[1]),
866        p[2].clamp(min[2], max[2]),
867    ]
868}
869
870// ─────────────────────────────────────────────────────────────────────────────
871// Point-in-triangle test (barycentric method)
872// ─────────────────────────────────────────────────────────────────────────────
873
874/// Tests whether point `p` (assumed co-planar with the triangle) lies inside
875/// triangle `(v0, v1, v2)` using the barycentric method.
876///
877/// Returns the `(u, v)` barycentric coordinates if inside, otherwise `None`.
878///
879/// # Examples
880/// ```no_run
881/// use oxiphysics_collision::proximity_query::point_in_triangle_barycentric;
882/// let r = point_in_triangle_barycentric(
883///     [0.0,0.0,0.0],[1.0,0.0,0.0],[0.0,1.0,0.0],[0.25,0.25,0.0]);
884/// assert!(r.is_some());
885/// ```
886pub fn point_in_triangle_barycentric(
887    v0: [f64; 3],
888    v1: [f64; 3],
889    v2: [f64; 3],
890    p: [f64; 3],
891) -> Option<(f64, f64)> {
892    let ab = vsub(v1, v0);
893    let ac = vsub(v2, v0);
894    let ap = vsub(p, v0);
895    let n = vcross(ab, ac);
896    let area2 = vlen(n);
897    if area2 < 1e-300 {
898        return None;
899    }
900    let inv_area2 = 1.0 / area2;
901    let u = vdot(vcross(ap, ac), n) * inv_area2;
902    let v = vdot(vcross(ab, ap), n) * inv_area2;
903    if u >= 0.0 && v >= 0.0 && u + v <= 1.0 {
904        Some((u, v))
905    } else {
906        None
907    }
908}
909
910// ─────────────────────────────────────────────────────────────────────────────
911// Ray – sphere / AABB distance helpers (complementary)
912// ─────────────────────────────────────────────────────────────────────────────
913
914/// Signed distance from point `p` to an infinite plane defined by `normal`
915/// (unit vector) and scalar offset `d` (plane equation: dot(n, x) = d).
916///
917/// Positive on the side the normal points toward, negative on the other.
918///
919/// # Examples
920/// ```no_run
921/// use oxiphysics_collision::proximity_query::point_plane_distance;
922/// let d = point_plane_distance([0.0,1.0,0.0], 0.0, [0.0,2.0,0.0]);
923/// assert!((d - 2.0).abs() < 1e-9);
924/// ```
925pub fn point_plane_distance(normal: [f64; 3], offset_d: f64, p: [f64; 3]) -> f64 {
926    vdot(normal, p) - offset_d
927}
928
929// ─────────────────────────────────────────────────────────────────────────────
930// Utility: squared distance point to segment
931// ─────────────────────────────────────────────────────────────────────────────
932
933/// Returns the squared distance from `p` to segment `[a, b]`.
934///
935/// This avoids a square-root and is suitable for comparisons.
936///
937/// # Examples
938/// ```no_run
939/// use oxiphysics_collision::proximity_query::point_segment_dist_sq;
940/// assert!((point_segment_dist_sq([0.0,0.0,0.0],[2.0,0.0,0.0],[1.0,1.0,0.0]) - 1.0).abs() < 1e-9);
941/// ```
942pub fn point_segment_dist_sq(a: [f64; 3], b: [f64; 3], p: [f64; 3]) -> f64 {
943    let res = point_segment_closest(a, b, p);
944    res.distance * res.distance
945}
946
947// ─────────────────────────────────────────────────────────────────────────────
948// Tests
949// ─────────────────────────────────────────────────────────────────────────────
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    const EPS: f64 = 1e-9;
956
957    // ── point_segment_closest ────────────────────────────────────────────────
958
959    #[test]
960    fn test_point_segment_at_start() {
961        let r = point_segment_closest([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]);
962        assert!((r.u_param - 0.0).abs() < EPS, "t should be 0 at start");
963        assert!((r.distance - 1.0).abs() < EPS);
964        assert_eq!(r.feature, ClosestFeature::Vertex);
965    }
966
967    #[test]
968    fn test_point_segment_at_end() {
969        let r = point_segment_closest([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]);
970        assert!((r.u_param - 1.0).abs() < EPS, "t should be 1 at end");
971        assert!((r.distance - 1.0).abs() < EPS);
972        assert_eq!(r.feature, ClosestFeature::Vertex);
973    }
974
975    #[test]
976    fn test_point_segment_midpoint() {
977        let r = point_segment_closest([0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 1.0, 0.0]);
978        assert!((r.u_param - 0.5).abs() < EPS);
979        assert!((r.distance - 1.0).abs() < EPS);
980        assert_eq!(r.feature, ClosestFeature::Edge);
981    }
982
983    #[test]
984    fn test_point_segment_degenerate() {
985        // Both endpoints coincide
986        let r = point_segment_closest([3.0, 0.0, 0.0], [3.0, 0.0, 0.0], [0.0, 0.0, 0.0]);
987        assert!((r.distance - 3.0).abs() < EPS);
988    }
989
990    // ── point_triangle_closest ──────────────────────────────────────────────
991
992    #[test]
993    fn test_point_triangle_vertex_v0() {
994        let r = point_triangle_closest(
995            [0.0, 0.0, 0.0],
996            [1.0, 0.0, 0.0],
997            [0.0, 1.0, 0.0],
998            [-1.0, -1.0, 0.0],
999        );
1000        assert_eq!(r.feature, ClosestFeature::Vertex);
1001        assert!((r.position[0] - 0.0).abs() < EPS && (r.position[1] - 0.0).abs() < EPS);
1002    }
1003
1004    #[test]
1005    fn test_point_triangle_vertex_v1() {
1006        let r = point_triangle_closest(
1007            [0.0, 0.0, 0.0],
1008            [2.0, 0.0, 0.0],
1009            [0.0, 2.0, 0.0],
1010            [4.0, 0.0, 0.0],
1011        );
1012        assert_eq!(r.feature, ClosestFeature::Vertex);
1013        assert!((r.position[0] - 2.0).abs() < EPS);
1014    }
1015
1016    #[test]
1017    fn test_point_triangle_vertex_v2() {
1018        let r = point_triangle_closest(
1019            [0.0, 0.0, 0.0],
1020            [2.0, 0.0, 0.0],
1021            [0.0, 2.0, 0.0],
1022            [0.0, 4.0, 0.0],
1023        );
1024        assert_eq!(r.feature, ClosestFeature::Vertex);
1025        assert!((r.position[1] - 2.0).abs() < EPS);
1026    }
1027
1028    #[test]
1029    fn test_point_triangle_edge_v0v1() {
1030        let r = point_triangle_closest(
1031            [0.0, 0.0, 0.0],
1032            [2.0, 0.0, 0.0],
1033            [0.0, 2.0, 0.0],
1034            [1.0, -1.0, 0.0],
1035        );
1036        assert_eq!(r.feature, ClosestFeature::Edge);
1037        assert!((r.position[1] - 0.0).abs() < EPS);
1038    }
1039
1040    #[test]
1041    fn test_point_triangle_face_interior() {
1042        let r = point_triangle_closest(
1043            [0.0, 0.0, 0.0],
1044            [3.0, 0.0, 0.0],
1045            [0.0, 3.0, 0.0],
1046            [1.0, 1.0, 0.0],
1047        );
1048        assert_eq!(r.feature, ClosestFeature::Face);
1049        assert!((r.distance).abs() < EPS);
1050    }
1051
1052    #[test]
1053    fn test_point_triangle_above_face() {
1054        // Point directly above centroid
1055        let r = point_triangle_closest(
1056            [0.0, 0.0, 0.0],
1057            [3.0, 0.0, 0.0],
1058            [0.0, 3.0, 0.0],
1059            [1.0, 1.0, 5.0],
1060        );
1061        assert!((r.distance - 5.0).abs() < EPS);
1062    }
1063
1064    // ── segment_segment_closest ─────────────────────────────────────────────
1065
1066    #[test]
1067    fn test_segseg_crossing() {
1068        // Two crossing segments in XY plane
1069        let r = segment_segment_closest(
1070            [0.0, 0.0, 0.0],
1071            [2.0, 0.0, 0.0],
1072            [1.0, -1.0, 0.0],
1073            [1.0, 1.0, 0.0],
1074        );
1075        assert!(r.distance < EPS, "crossing segments: dist={}", r.distance);
1076    }
1077
1078    #[test]
1079    fn test_segseg_parallel() {
1080        // Parallel segments, no crossing
1081        let r = segment_segment_closest(
1082            [0.0, 0.0, 0.0],
1083            [2.0, 0.0, 0.0],
1084            [0.0, 1.0, 0.0],
1085            [2.0, 1.0, 0.0],
1086        );
1087        assert!(
1088            (r.distance - 1.0).abs() < EPS,
1089            "parallel dist should be 1.0"
1090        );
1091    }
1092
1093    #[test]
1094    fn test_segseg_skew() {
1095        // Skew segments
1096        let r = segment_segment_closest(
1097            [0.0, 0.0, 0.0],
1098            [1.0, 0.0, 0.0],
1099            [0.5, 1.0, 1.0],
1100            [0.5, 1.0, -1.0],
1101        );
1102        // Closest should be about 1.0 (vertical offset)
1103        assert!((r.distance - 1.0).abs() < EPS);
1104    }
1105
1106    #[test]
1107    fn test_segseg_endpoint_to_endpoint() {
1108        let r = segment_segment_closest(
1109            [0.0, 0.0, 0.0],
1110            [1.0, 0.0, 0.0],
1111            [2.0, 0.0, 0.0],
1112            [3.0, 0.0, 0.0],
1113        );
1114        assert!((r.distance - 1.0).abs() < EPS);
1115        assert!((r.s - 1.0).abs() < EPS);
1116        assert!((r.t - 0.0).abs() < EPS);
1117    }
1118
1119    // ── point_box_distance ──────────────────────────────────────────────────
1120
1121    #[test]
1122    fn test_point_box_inside() {
1123        let d = point_box_distance([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0]);
1124        assert!(d < 0.0, "origin should be inside unit box");
1125        assert!((d - (-1.0)).abs() < EPS);
1126    }
1127
1128    #[test]
1129    fn test_point_box_outside_face() {
1130        let d = point_box_distance([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0], [2.0, 0.0, 0.0]);
1131        assert!((d - 1.0).abs() < EPS);
1132    }
1133
1134    #[test]
1135    fn test_point_box_outside_corner() {
1136        let d = point_box_distance([0.0, 0.0, 0.0], [1.0, 1.0, 1.0], [2.0, 2.0, 2.0]);
1137        let expected = (3.0_f64).sqrt();
1138        assert!((d - expected).abs() < EPS);
1139    }
1140
1141    #[test]
1142    fn test_point_box_on_surface() {
1143        let d = point_box_distance([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0], [1.0, 0.0, 0.0]);
1144        assert!(
1145            d.abs() < EPS,
1146            "point on surface should have zero signed dist"
1147        );
1148    }
1149
1150    // ── point_sphere_distance ───────────────────────────────────────────────
1151
1152    #[test]
1153    fn test_point_sphere_inside() {
1154        let d = point_sphere_distance([0.0, 0.0, 0.0], 2.0, [1.0, 0.0, 0.0]);
1155        assert!((d - (-1.0)).abs() < EPS);
1156    }
1157
1158    #[test]
1159    fn test_point_sphere_outside() {
1160        let d = point_sphere_distance([0.0, 0.0, 0.0], 1.0, [3.0, 0.0, 0.0]);
1161        assert!((d - 2.0).abs() < EPS);
1162    }
1163
1164    #[test]
1165    fn test_point_sphere_on_surface() {
1166        let d = point_sphere_distance([1.0, 2.0, 3.0], 1.5, [2.5, 2.0, 3.0]);
1167        assert!(d.abs() < EPS);
1168    }
1169
1170    // ── point_capsule_distance ──────────────────────────────────────────────
1171
1172    #[test]
1173    fn test_point_capsule_inside_cylinder_portion() {
1174        let d = point_capsule_distance([0.0, 0.0, 0.0], [0.0, 4.0, 0.0], 1.0, [0.0, 2.0, 0.0]);
1175        assert!((d - (-1.0)).abs() < EPS);
1176    }
1177
1178    #[test]
1179    fn test_point_capsule_outside_end_cap() {
1180        let d = point_capsule_distance([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.5, [0.0, 3.0, 0.0]);
1181        // Nearest segment point is (0,1,0), dist=2.0, minus radius 0.5 → 1.5
1182        assert!((d - 1.5).abs() < EPS);
1183    }
1184
1185    // ── SignedDistanceField ─────────────────────────────────────────────────
1186
1187    #[test]
1188    fn test_sdf_sphere_inside() {
1189        let sdf = SignedDistanceField::Sphere {
1190            center: [0.0; 3],
1191            r: 2.0,
1192        };
1193        assert!(sdf.eval([0.0, 0.0, 0.0]) < 0.0);
1194    }
1195
1196    #[test]
1197    fn test_sdf_sphere_outside() {
1198        let sdf = SignedDistanceField::Sphere {
1199            center: [0.0; 3],
1200            r: 1.0,
1201        };
1202        let d = sdf.eval([2.0, 0.0, 0.0]);
1203        assert!((d - 1.0).abs() < EPS);
1204    }
1205
1206    #[test]
1207    fn test_sdf_box_center() {
1208        let sdf = SignedDistanceField::Box {
1209            half: [1.0, 1.0, 1.0],
1210        };
1211        let d = sdf.eval([0.0, 0.0, 0.0]);
1212        assert!(d < 0.0);
1213        assert!((d - (-1.0)).abs() < EPS);
1214    }
1215
1216    #[test]
1217    fn test_sdf_box_corner() {
1218        let sdf = SignedDistanceField::Box {
1219            half: [1.0, 1.0, 1.0],
1220        };
1221        let d = sdf.eval([2.0, 2.0, 2.0]);
1222        let expected = (3.0_f64).sqrt();
1223        assert!((d - expected).abs() < EPS);
1224    }
1225
1226    #[test]
1227    fn test_sdf_box_face() {
1228        let sdf = SignedDistanceField::Box {
1229            half: [1.0, 1.0, 1.0],
1230        };
1231        let d = sdf.eval([3.0, 0.0, 0.0]);
1232        assert!((d - 2.0).abs() < EPS);
1233    }
1234
1235    #[test]
1236    fn test_sdf_capsule_inside() {
1237        let sdf = SignedDistanceField::Capsule {
1238            a: [0.0, 0.0, 0.0],
1239            b: [0.0, 2.0, 0.0],
1240            r: 1.0,
1241        };
1242        assert!(sdf.eval([0.0, 1.0, 0.0]) < 0.0);
1243    }
1244
1245    #[test]
1246    fn test_sdf_capsule_outside() {
1247        let sdf = SignedDistanceField::Capsule {
1248            a: [0.0, 0.0, 0.0],
1249            b: [0.0, 2.0, 0.0],
1250            r: 0.5,
1251        };
1252        let d = sdf.eval([0.0, 5.0, 0.0]);
1253        assert!((d - 2.5).abs() < EPS);
1254    }
1255
1256    #[test]
1257    fn test_sdf_cylinder_inside() {
1258        let sdf = SignedDistanceField::Cylinder {
1259            axis: [0.0, 1.0, 0.0],
1260            r: 1.0,
1261            h: 4.0,
1262        };
1263        assert!(sdf.eval([0.0, 0.0, 0.0]) < 0.0);
1264    }
1265
1266    #[test]
1267    fn test_sdf_cylinder_outside_radially() {
1268        let sdf = SignedDistanceField::Cylinder {
1269            axis: [0.0, 1.0, 0.0],
1270            r: 1.0,
1271            h: 4.0,
1272        };
1273        let d = sdf.eval([3.0, 0.0, 0.0]);
1274        assert!((d - 2.0).abs() < EPS);
1275    }
1276
1277    // ── CSG operations ──────────────────────────────────────────────────────
1278
1279    #[test]
1280    fn test_csg_union_picks_smaller() {
1281        assert!((sdf_union(1.0, -0.5) - (-0.5)).abs() < EPS);
1282        assert!((sdf_union(-0.3, 0.7) - (-0.3)).abs() < EPS);
1283    }
1284
1285    #[test]
1286    fn test_csg_intersect_picks_larger() {
1287        assert!((sdf_intersect(-1.0, 0.5) - 0.5).abs() < EPS);
1288        assert!((sdf_intersect(-0.5, -0.3) - (-0.3)).abs() < EPS);
1289    }
1290
1291    #[test]
1292    fn test_csg_subtract() {
1293        // subtract positive region: should shrink inside
1294        assert!((sdf_subtract(-0.5, -0.3) - 0.3).abs() < EPS);
1295        assert!((sdf_subtract(-1.0, 2.0) - (-1.0)).abs() < EPS);
1296    }
1297
1298    #[test]
1299    fn test_csg_two_spheres_union() {
1300        let s1 = SignedDistanceField::Sphere {
1301            center: [-2.0, 0.0, 0.0],
1302            r: 1.5,
1303        };
1304        let s2 = SignedDistanceField::Sphere {
1305            center: [2.0, 0.0, 0.0],
1306            r: 1.5,
1307        };
1308        // Point between: inside union if inside either
1309        let p = [0.0, 0.0, 0.0];
1310        let combined = sdf_union(s1.eval(p), s2.eval(p));
1311        // both spheres are 2 units away, radius 1.5 → outside, distance 0.5
1312        assert!((combined - 0.5).abs() < EPS);
1313    }
1314
1315    // ── ProximityCache ──────────────────────────────────────────────────────
1316
1317    #[test]
1318    fn test_proximity_cache_insert_and_get() {
1319        let mut cache = ProximityCache::new();
1320        cache.insert(
1321            1,
1322            2,
1323            [0.0; 3],
1324            [1.0, 0.0, 0.0],
1325            1.0,
1326            ClosestFeature::Face,
1327            ClosestFeature::Face,
1328            0,
1329        );
1330        let e = cache.get(1, 2).expect("entry should exist");
1331        assert!((e.distance - 1.0).abs() < EPS);
1332    }
1333
1334    #[test]
1335    fn test_proximity_cache_order_independent() {
1336        let mut cache = ProximityCache::new();
1337        cache.insert(
1338            5,
1339            3,
1340            [0.0; 3],
1341            [1.0, 0.0, 0.0],
1342            2.0,
1343            ClosestFeature::Edge,
1344            ClosestFeature::Vertex,
1345            0,
1346        );
1347        assert!(cache.get(3, 5).is_some());
1348        assert!(cache.get(5, 3).is_some());
1349    }
1350
1351    #[test]
1352    fn test_proximity_cache_remove() {
1353        let mut cache = ProximityCache::new();
1354        cache.insert(
1355            1,
1356            2,
1357            [0.0; 3],
1358            [1.0, 0.0, 0.0],
1359            1.0,
1360            ClosestFeature::Face,
1361            ClosestFeature::Face,
1362            0,
1363        );
1364        cache.remove(1, 2);
1365        assert!(cache.get(1, 2).is_none());
1366    }
1367
1368    #[test]
1369    fn test_proximity_cache_evict_stale() {
1370        let mut cache = ProximityCache::new();
1371        cache.insert(
1372            1,
1373            2,
1374            [0.0; 3],
1375            [1.0, 0.0, 0.0],
1376            1.0,
1377            ClosestFeature::Face,
1378            ClosestFeature::Face,
1379            0,
1380        );
1381        cache.insert(
1382            3,
1383            4,
1384            [0.0; 3],
1385            [1.0, 0.0, 0.0],
1386            1.0,
1387            ClosestFeature::Face,
1388            ClosestFeature::Face,
1389            10,
1390        );
1391        cache.evict_stale(5);
1392        assert!(cache.get(1, 2).is_none());
1393        assert!(cache.get(3, 4).is_some());
1394    }
1395
1396    #[test]
1397    fn test_proximity_cache_capacity_evicts_oldest() {
1398        let mut cache = ProximityCache::new();
1399        for i in 0..PROXIMITY_CACHE_CAPACITY as u32 {
1400            cache.insert(
1401                i * 2,
1402                i * 2 + 1,
1403                [0.0; 3],
1404                [1.0, 0.0, 0.0],
1405                1.0,
1406                ClosestFeature::Face,
1407                ClosestFeature::Face,
1408                i as u64,
1409            );
1410        }
1411        assert_eq!(cache.len(), PROXIMITY_CACHE_CAPACITY);
1412        // Insert one more: oldest (stamp=0) should be evicted
1413        cache.insert(
1414            200,
1415            201,
1416            [0.0; 3],
1417            [1.0, 0.0, 0.0],
1418            1.0,
1419            ClosestFeature::Face,
1420            ClosestFeature::Face,
1421            1000,
1422        );
1423        assert_eq!(cache.len(), PROXIMITY_CACHE_CAPACITY);
1424        assert!(cache.get(0, 1).is_none());
1425    }
1426
1427    // ── triangle_triangle_distance ──────────────────────────────────────────
1428
1429    #[test]
1430    fn test_tri_tri_parallel_offset() {
1431        let r = triangle_triangle_distance(
1432            [0.0, 0.0, 0.0],
1433            [1.0, 0.0, 0.0],
1434            [0.0, 1.0, 0.0],
1435            [0.0, 0.0, 3.0],
1436            [1.0, 0.0, 3.0],
1437            [0.0, 1.0, 3.0],
1438        );
1439        assert!((r.distance - 3.0).abs() < EPS);
1440    }
1441
1442    #[test]
1443    fn test_tri_tri_touching() {
1444        let r = triangle_triangle_distance(
1445            [0.0, 0.0, 0.0],
1446            [1.0, 0.0, 0.0],
1447            [0.0, 1.0, 0.0],
1448            [0.0, 0.0, 0.0],
1449            [-1.0, 0.0, 0.0],
1450            [0.0, -1.0, 0.0],
1451        );
1452        assert!(r.distance < EPS, "touching at origin, dist={}", r.distance);
1453    }
1454
1455    // ── point_box_closest ───────────────────────────────────────────────────
1456
1457    #[test]
1458    fn test_point_box_closest_outside() {
1459        let cp = point_box_closest([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0], [3.0, 0.0, 0.0]);
1460        assert!((cp[0] - 1.0).abs() < EPS);
1461        assert!((cp[1] - 0.0).abs() < EPS);
1462    }
1463
1464    // ── sdf_smooth_union ────────────────────────────────────────────────────
1465
1466    #[test]
1467    fn test_sdf_smooth_union_equals_min_when_far_apart() {
1468        // When values are far apart, smooth union ≈ regular union
1469        let a = -10.0_f64;
1470        let b = 10.0_f64;
1471        let su = sdf_smooth_union(a, b, 0.1);
1472        let u = sdf_union(a, b);
1473        assert!((su - u).abs() < 0.01, "smooth_union far apart should ≈ min");
1474    }
1475}