Skip to main content

alice_physics/
sdf_collider.rs

1//! SDF (Signed Distance Field) Collision Detection
2//!
3//! Bypass GJK/EPA entirely — sample the distance field directly.
4//!
5//! # How It Works
6//!
7//! Traditional collision: GJK → EPA → penetration depth + normal
8//! SDF collision: distance(point) → penetration depth, gradient(point) → normal
9//!
10//! One distance query replaces the entire GJK+EPA pipeline.
11//! Works with concave shapes, fractals, CSG — anything expressible as an SDF.
12//!
13//! # Integration with ALICE-SDF
14//!
15//! ```ignore
16//! // ignored: requires the external `alice_sdf` crate and the `physics_bridge`
17//! // feature, which are not available in this crate's doc-test environment.
18//! use alice_sdf::CompiledSdf;
19//! use alice_physics::sdf_collider::{SdfCollider, SdfField};
20//!
21//! // CompiledSdf implements SdfField via the physics_bridge feature
22//! let sdf = CompiledSdf::compile(&node);
23//! let collider = SdfCollider::new(Box::new(bridge), position, rotation);
24//! world.add_sdf_collider(collider);
25//! ```
26//!
27//! Author: Moroya Sakamoto
28
29#[cfg(feature = "std")]
30use crate::collider::Contact;
31use crate::math::{Fix128, QuatFix, Vec3Fix};
32
33#[cfg(not(feature = "std"))]
34use alloc::boxed::Box;
35
36// ============================================================================
37// Recorded SDF Contact
38// ============================================================================
39
40/// One contact the step resolved between a body and an SDF collider: the body
41/// was pushed out of the field by `normal * depth`.
42///
43/// The step records one entry for every push-out it applies
44/// ([`crate::PhysicsWorld::last_step_sdf_contacts`]), in the order the step
45/// applies them: by substep, then by body index, then by collider index.
46/// Recording only reads what the push-out computes; a world whose record is
47/// never read steps to the same bits.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49#[non_exhaustive]
50pub struct SdfContact {
51    /// Index of the body in [`crate::PhysicsWorld::bodies`].
52    pub body_index: usize,
53    /// Index of the collider in [`crate::PhysicsWorld::sdf_colliders`].
54    pub collider_index: usize,
55    /// Contact point on the SDF surface, in world space, before the push-out.
56    pub point: Vec3Fix,
57    /// Surface normal in world space, pointing out of the collider (the
58    /// direction the body was pushed).
59    pub normal: Vec3Fix,
60    /// How far the body reached into the field (the push-out distance).
61    pub depth: Fix128,
62    /// Speed of the body towards the surface before the push-out:
63    /// `-(velocity · normal)`, positive while approaching. The velocity is
64    /// the body's velocity at that point of the substep (after this
65    /// substep's gravity and forces), which the push-out does not change.
66    pub approach_speed: Fix128,
67    /// Substep of the step in which the contact was resolved (`0..substeps`).
68    /// The TGS backend resolves SDF overlap once per step, so its contacts
69    /// all carry substep 0.
70    pub substep: usize,
71    /// Position of the collider when the contact was resolved. A collider
72    /// attached to a body ([`SdfCollider::new_dynamic`]) is moved to the
73    /// body's pose of each substep before the push-out, so its pose after the
74    /// step is not the pose `point` and `normal` were measured in; this is.
75    pub collider_position: Vec3Fix,
76    /// Rotation of the collider when the contact was resolved (unit length,
77    /// as [`SdfCollider::update_cache`] stores it). `point` in the field's
78    /// frame is `collider_rotation⁻¹ · (point − collider_position) / collider_scale`.
79    pub collider_rotation: QuatFix,
80    /// Scale of the collider when the contact was resolved.
81    pub collider_scale: Fix128,
82}
83
84// ============================================================================
85// SDF Field Trait
86// ============================================================================
87
88/// Trait for evaluating a signed distance field.
89///
90/// All methods use f32 because SDF evaluation is inherently floating-point.
91/// The Fix128 ↔ f32 conversion happens at the collider boundary.
92pub trait SdfField: Send + Sync {
93    /// Returns the signed distance from point to the nearest surface.
94    ///
95    /// - Positive: outside the shape
96    /// - Zero: on the surface
97    /// - Negative: inside the shape
98    fn distance(&self, x: f32, y: f32, z: f32) -> f32;
99
100    /// Returns the surface normal (normalized gradient) at the given point.
101    ///
102    /// Points away from the surface (outward direction).
103    fn normal(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32);
104
105    /// Combined distance + normal query (default: two separate calls).
106    ///
107    /// Override for implementations that can compute both efficiently
108    /// (e.g., ALICE-SDF's `eval_distance_and_gradient_simd`).
109    fn distance_and_normal(&self, x: f32, y: f32, z: f32) -> (f32, (f32, f32, f32)) {
110        (self.distance(x, y, z), self.normal(x, y, z))
111    }
112
113    /// A number that changes whenever the field changes shape (default: `0`,
114    /// a field that never changes).
115    ///
116    /// A field whose shape can change while it is held by a world (carved,
117    /// grown, edited) returns a different value after each change, for
118    /// instance a counter it bumps. At the head of every step the world
119    /// compares each collider's value with the one it saw at the previous step
120    /// (or when the collider was added) and, when it differs, wakes every
121    /// sleeping body of the world, not only those near the change (a field
122    /// does not report where it changed): a body asleep on a surface that has moved away is
123    /// otherwise never integrated again and stays where it was. Only a change
124    /// matters, not the value; a field that keeps returning the same number
125    /// never wakes anything.
126    fn generation(&self) -> u64 {
127        0
128    }
129}
130
131/// Type alias for a normal-returning closure used in [`ClosureSdf`]
132type NormalFn = Box<dyn Fn(f32, f32, f32) -> (f32, f32, f32) + Send + Sync>;
133
134/// Closure-based SDF field implementation.
135///
136/// Allows connecting any SDF evaluation function without trait implementation.
137///
138/// ```
139/// use alice_physics::sdf_collider::ClosureSdf;
140///
141/// let sdf = ClosureSdf::new(
142///     |x, y, z| ((x*x + y*y + z*z).sqrt() - 1.0), // sphere r=1
143///     |x, y, z| {
144///         let len = (x*x + y*y + z*z).sqrt();
145///         (x / len, y / len, z / len)
146///     },
147/// );
148/// ```
149pub struct ClosureSdf {
150    eval_fn: Box<dyn Fn(f32, f32, f32) -> f32 + Send + Sync>,
151    normal_fn: NormalFn,
152}
153
154impl ClosureSdf {
155    /// Create from evaluation and normal closures
156    pub fn new(
157        eval_fn: impl Fn(f32, f32, f32) -> f32 + Send + Sync + 'static,
158        normal_fn: impl Fn(f32, f32, f32) -> (f32, f32, f32) + Send + Sync + 'static,
159    ) -> Self {
160        Self {
161            eval_fn: Box::new(eval_fn),
162            normal_fn: Box::new(normal_fn),
163        }
164    }
165}
166
167impl SdfField for ClosureSdf {
168    #[inline]
169    fn distance(&self, x: f32, y: f32, z: f32) -> f32 {
170        (self.eval_fn)(x, y, z)
171    }
172
173    #[inline]
174    fn normal(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
175        (self.normal_fn)(x, y, z)
176    }
177}
178
179/// A signed distance field queried by reference, with no thread-safety or
180/// lifetime requirement.
181///
182/// [`SdfField`] requires `Send + Sync` (a collider owns its field as
183/// `Box<dyn SdfField>` and the world may be shared across threads). Entry
184/// points that only borrow a field for the length of one call, such as
185/// [`sphere_trace_sdf_field`](crate::sdf_ccd::sphere_trace_sdf_field), take
186/// this trait instead, so a field that borrows local data or holds an `Rc`
187/// can be queried too. Every [`SdfField`] implements it (the methods forward
188/// to [`SdfField::distance`] and [`SdfField::normal`]); a pair of borrowed
189/// closures can be wrapped in [`ClosureSdfQuery`].
190///
191/// The methods are named apart from [`SdfField`]'s so that a type with both
192/// traits in scope still resolves `field.distance(..)` unambiguously.
193pub trait SdfQuery {
194    /// Signed distance from the point to the nearest surface (positive
195    /// outside), as [`SdfField::distance`].
196    fn query_distance(&self, x: f32, y: f32, z: f32) -> f32;
197
198    /// Outward surface normal at the point, as [`SdfField::normal`].
199    fn query_normal(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32);
200}
201
202impl<T: SdfField + ?Sized> SdfQuery for T {
203    #[inline]
204    fn query_distance(&self, x: f32, y: f32, z: f32) -> f32 {
205        self.distance(x, y, z)
206    }
207
208    #[inline]
209    fn query_normal(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
210        self.normal(x, y, z)
211    }
212}
213
214/// A distance closure and a normal closure as an [`SdfQuery`], unboxed.
215///
216/// Unlike [`ClosureSdf`] the closures need not be `'static`, `Send` or
217/// `Sync`: they may borrow local data for the length of a query.
218///
219/// ```
220/// use alice_physics::sdf_collider::{ClosureSdfQuery, SdfQuery};
221///
222/// let radius = 2.0_f32; // a local, borrowed by the closure
223/// let sdf = ClosureSdfQuery::new(
224///     |x: f32, y: f32, z: f32| (x * x + y * y + z * z).sqrt() - radius,
225///     |x: f32, y: f32, z: f32| {
226///         let l = (x * x + y * y + z * z).sqrt();
227///         (x / l, y / l, z / l)
228///     },
229/// );
230/// assert_eq!(sdf.query_distance(3.0, 0.0, 0.0), 1.0);
231/// ```
232#[derive(Clone, Copy, Debug)]
233pub struct ClosureSdfQuery<D, N> {
234    distance_fn: D,
235    normal_fn: N,
236}
237
238impl<D, N> ClosureSdfQuery<D, N>
239where
240    D: Fn(f32, f32, f32) -> f32,
241    N: Fn(f32, f32, f32) -> (f32, f32, f32),
242{
243    /// Wrap a distance closure and a normal closure.
244    #[must_use]
245    pub fn new(distance_fn: D, normal_fn: N) -> Self {
246        Self {
247            distance_fn,
248            normal_fn,
249        }
250    }
251}
252
253impl<D, N> SdfQuery for ClosureSdfQuery<D, N>
254where
255    D: Fn(f32, f32, f32) -> f32,
256    N: Fn(f32, f32, f32) -> (f32, f32, f32),
257{
258    #[inline]
259    fn query_distance(&self, x: f32, y: f32, z: f32) -> f32 {
260        (self.distance_fn)(x, y, z)
261    }
262
263    #[inline]
264    fn query_normal(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
265        (self.normal_fn)(x, y, z)
266    }
267}
268
269/// A distance closure alone as an [`SdfQuery`]; the normal is the crate's
270/// central-difference gradient of that closure.
271///
272/// For a field with no analytic gradient (a height map, a blend of
273/// primitives, anything sampled): the normal is the one
274/// [`ModifiedSdf`](crate::sim_modifier::ModifiedSdf) and
275/// [`DestructibleSdf`](crate::sdf_destruction::DestructibleSdf) compute for
276/// their own fields, bit for bit. At `(x, y, z)` with step
277/// `e = max(FD_NORMAL_BASE_EPS, 1e-4 * max(|x|, |y|, |z|))` it is
278/// `(f(x+e)-f(x-e), f(y+e)-f(y-e), f(z+e)-f(z-e))` normalised (six
279/// evaluations; the step scales with the coordinates so it keeps resolving
280/// far from the origin). As with [`ClosureSdfQuery`] the closure need not be
281/// `'static`, `Send` or `Sync`.
282///
283/// Degenerate input: where the six samples give a gradient of length below
284/// `1e-10` (the centre of a sphere, a constant field) the normal is
285/// `(0, 1, 0)`. A NaN or infinite distance among the samples (or a
286/// non-finite query point) gives a NaN normal; the distance is returned as
287/// the closure gives it.
288///
289/// Requires the `std` feature (the normal needs `f32::sqrt`).
290///
291/// ```
292/// use alice_physics::sdf_collider::{DistanceSdfQuery, SdfQuery};
293///
294/// let radius = 2.0_f32; // a local, borrowed by the closure
295/// let sdf = DistanceSdfQuery::new(|x: f32, y: f32, z: f32| {
296///     (x * x + y * y + z * z).sqrt() - radius
297/// });
298/// assert_eq!(sdf.query_distance(3.0, 0.0, 0.0), 1.0);
299/// let (nx, ny, nz) = sdf.query_normal(3.0, 0.0, 0.0);
300/// assert!((nx - 1.0).abs() < 1e-3 && ny.abs() < 1e-3 && nz.abs() < 1e-3);
301/// ```
302#[cfg(feature = "std")]
303#[derive(Clone, Copy, Debug)]
304pub struct DistanceSdfQuery<D> {
305    distance_fn: D,
306}
307
308#[cfg(feature = "std")]
309impl<D> DistanceSdfQuery<D>
310where
311    D: Fn(f32, f32, f32) -> f32,
312{
313    /// Wrap a distance closure.
314    #[must_use]
315    pub fn new(distance_fn: D) -> Self {
316        Self { distance_fn }
317    }
318}
319
320#[cfg(feature = "std")]
321impl<D> SdfQuery for DistanceSdfQuery<D>
322where
323    D: Fn(f32, f32, f32) -> f32,
324{
325    #[inline]
326    fn query_distance(&self, x: f32, y: f32, z: f32) -> f32 {
327        (self.distance_fn)(x, y, z)
328    }
329
330    #[inline]
331    fn query_normal(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
332        fd_normal(&self.distance_fn, FD_NORMAL_BASE_EPS, x, y, z)
333    }
334}
335
336/// Union of two fields: `min(a, b)`, with the normal of whichever is nearer.
337///
338/// On a tie the first operand wins. The union of two exact distance fields
339/// is exact outside both shapes and a lower bound inside; the union of two
340/// `L`-Lipschitz fields is `L`-Lipschitz.
341///
342/// The common use is a surface made of several layers, such as ground and
343/// a water level that can stand above it: a character colliding with the
344/// union stands on whichever is higher.
345#[derive(Debug, Clone)]
346pub struct SdfUnion<A, B> {
347    a: A,
348    b: B,
349}
350
351impl<A: SdfField, B: SdfField> SdfUnion<A, B> {
352    /// The union of `a` and `b`.
353    #[must_use]
354    pub fn new(a: A, b: B) -> Self {
355        Self { a, b }
356    }
357}
358
359impl<A: SdfField, B: SdfField> SdfField for SdfUnion<A, B> {
360    #[inline]
361    fn distance(&self, x: f32, y: f32, z: f32) -> f32 {
362        crate::det_math::min(self.a.distance(x, y, z), self.b.distance(x, y, z))
363    }
364
365    fn normal(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) {
366        if self.a.distance(x, y, z) <= self.b.distance(x, y, z) {
367            self.a.normal(x, y, z)
368        } else {
369            self.b.normal(x, y, z)
370        }
371    }
372
373    /// Changes when either part changes: the wrapping sum of the parts'
374    /// generations (`0` while neither changes).
375    fn generation(&self) -> u64 {
376        self.a.generation().wrapping_add(self.b.generation())
377    }
378}
379
380#[cfg(feature = "std")]
381/// Central-difference step for an f32 SDF normal at `(x, y, z)`.
382///
383/// `base_eps` is used as is near the origin. An absolute step stops resolving
384/// far from it: once `base_eps` falls below the f32 spacing of the
385/// coordinates, `x + eps` rounds back to `x` and the difference is zero or
386/// noise (AUD-A-S5W3-008, AUD-A-S5W3-013). The step therefore grows in
387/// proportion to the largest coordinate magnitude, `1e-4 * max(|x|, |y|, |z|)`
388/// (at least about 840 f32 ulps of that coordinate), whenever that exceeds
389/// `base_eps`.
390/// The same step is used on all three axes so the gradient direction is not
391/// skewed.
392#[inline]
393#[must_use]
394pub(crate) fn fd_normal_step(base_eps: f32, x: f32, y: f32, z: f32) -> f32 {
395    let scale = crate::det_math::max(crate::det_math::max(x.abs(), y.abs()), z.abs());
396    crate::det_math::max(base_eps, 1.0e-4 * scale)
397}
398
399/// Base step of the central-difference SDF normal, used near the origin
400/// (see [`DistanceSdfQuery`]; farther out the step grows with the
401/// coordinate magnitude).
402pub const FD_NORMAL_BASE_EPS: f32 = 1.0e-3;
403
404/// Central-difference normal of the distance `f` at `(x, y, z)`: step
405/// [`fd_normal_step`]`(base_eps, ..)`, the three axis differences
406/// normalised, `(0, 1, 0)` when their length is below `1e-10`, NaN when a
407/// sample is not finite. The one formula behind [`DistanceSdfQuery`] and
408/// the normals of `ModifiedSdf`, `SingleModifiedSdf` and `DestructibleSdf`.
409#[cfg(feature = "std")]
410#[inline]
411#[must_use]
412pub(crate) fn fd_normal<F: Fn(f32, f32, f32) -> f32>(
413    f: F,
414    base_eps: f32,
415    x: f32,
416    y: f32,
417    z: f32,
418) -> (f32, f32, f32) {
419    let e = fd_normal_step(base_eps, x, y, z);
420    let dx = f(x + e, y, z) - f(x - e, y, z);
421    let dy = f(x, y + e, z) - f(x, y - e, z);
422    let dz = f(x, y, z + e) - f(x, y, z - e);
423
424    let len = dz.mul_add(dz, dx.mul_add(dx, dy * dy)).sqrt();
425    if len < 1e-10 {
426        (0.0, 1.0, 0.0)
427    } else {
428        (dx / len, dy / len, dz / len)
429    }
430}
431
432// ============================================================================
433// SDF Collider (world-space placement)
434// ============================================================================
435
436/// An SDF field placed in the physics world with a transform.
437///
438/// The SDF is evaluated in local space; the collider transforms
439/// world-space query points into local space before evaluation.
440///
441/// Cached fields (`inv_rotation`, `scale_f32`, `inv_scale_f32`) avoid
442/// recomputing invariants on every collision query.
443pub struct SdfCollider {
444    /// The underlying distance field
445    pub field: Box<dyn SdfField>,
446    /// World-space position of the SDF origin
447    pub position: Vec3Fix,
448    /// World-space orientation of the SDF, a unit quaternion
449    ///
450    /// [`Self::new_static`], [`Self::set_pose`], [`Self::sync_to_body`],
451    /// [`Self::update_cache`], [`crate::solver::PhysicsWorld::add_sdf_collider`]
452    /// and the start of every world step store it at unit length (a quaternion
453    /// whose squared length is within `2^-32` of one is kept bit for bit). A
454    /// value written to this field directly takes effect through
455    /// [`Self::update_cache`], which normalizes it and recomputes the cached
456    /// inverse rotation the field is evaluated through;
457    /// [`crate::solver::PhysicsWorld::add_sdf_collider`] and the start of
458    /// every world step call it. Between a direct write and the next of those,
459    /// the field is evaluated in the old orientation while the contact normal
460    /// is rotated by the value as written.
461    pub rotation: QuatFix,
462    /// Uniform scale factor
463    ///
464    /// A value written directly takes effect through [`Self::update_cache`]
465    /// (called by [`crate::solver::PhysicsWorld::add_sdf_collider`] and at the
466    /// start of every world step), as for `rotation`.
467    pub scale: Fix128,
468    /// Body index this SDF is attached to (`usize::MAX` = static world geometry)
469    pub body_index: usize,
470    // -- Cached invariants (derived from rotation/scale) --
471    /// Inverse rotation (cached)
472    pub(crate) inv_rotation: QuatFix,
473    /// Scale as f32 (cached)
474    pub(crate) scale_f32: f32,
475    /// Inverse scale as f32 (cached, guards against zero)
476    pub(crate) inv_scale_f32: f32,
477}
478
479/// The placement of a distance field in the world: position, orientation
480/// and uniform scale, with the inverse rotation and the scale as `f32`
481/// precomputed.
482///
483/// A field is evaluated in its local space: a world point `p` is queried at
484/// `R⁻¹ (p - position) / scale`, the returned distance is multiplied by
485/// `scale`, and a local normal is rotated by `R` back to world space. This is
486/// the transform an [`SdfCollider`] applies; [`SdfCollider::frame`] returns
487/// the collider's own frame (from its cached values, so a collider and its
488/// frame agree bit for bit even when the cache was not refreshed with
489/// [`SdfCollider::update_cache`]).
490///
491/// A scale whose magnitude is below `1e-10` is inverted as `1`, as in
492/// [`SdfCollider::with_scale`].
493#[derive(Clone, Copy, Debug, PartialEq)]
494pub struct SdfFrame {
495    position: Vec3Fix,
496    rotation: QuatFix,
497    inv_rotation: QuatFix,
498    scale_f32: f32,
499    inv_scale_f32: f32,
500}
501
502impl SdfFrame {
503    /// The world frame: no translation, no rotation, scale 1.
504    pub const IDENTITY: Self = Self {
505        position: Vec3Fix::ZERO,
506        rotation: QuatFix::IDENTITY,
507        inv_rotation: QuatFix::IDENTITY,
508        scale_f32: 1.0,
509        inv_scale_f32: 1.0,
510    };
511
512    /// A field placed at `position`, rotated by `rotation`, scaled by `scale`.
513    ///
514    /// `rotation` is used as a unit quaternion (normalized unless its squared
515    /// length is within `2^-32` of one, which is kept bit for bit): applying
516    /// a quaternion `q` scales what it rotates by `|q|^2`.
517    #[must_use]
518    pub fn new(position: Vec3Fix, rotation: QuatFix, scale: Fix128) -> Self {
519        let rotation = rotation.unit_rotation();
520        let s = scale.to_f32();
521        Self {
522            position,
523            rotation,
524            inv_rotation: rotation.conjugate(),
525            scale_f32: s,
526            inv_scale_f32: if s.abs() < 1e-10 { 1.0 } else { 1.0 / s },
527        }
528    }
529
530    /// The uniform scale as `f32` (local distances are multiplied by it).
531    #[must_use]
532    pub fn scale_f32(&self) -> f32 {
533        self.scale_f32
534    }
535
536    /// Transform a world-space point to the field's local space (f32).
537    #[cfg(feature = "std")]
538    #[inline]
539    pub(crate) fn world_to_local(&self, world_point: Vec3Fix) -> (f32, f32, f32) {
540        let relative = world_point - self.position;
541        let local = self.inv_rotation.rotate_vec(relative);
542        let (lx, ly, lz) = local.to_f32();
543        let inv_s = self.inv_scale_f32;
544        (lx * inv_s, ly * inv_s, lz * inv_s)
545    }
546
547    /// Transform a local-space normal to world space (Fix128).
548    #[cfg(feature = "std")]
549    #[inline]
550    pub(crate) fn local_normal_to_world(&self, nx: f32, ny: f32, nz: f32) -> Vec3Fix {
551        let local_n = Vec3Fix::from_f32(nx, ny, nz);
552        self.rotation.rotate_vec(local_n).normalize()
553    }
554}
555
556/// Sentinel value for static (world-fixed) SDF colliders
557pub const SDF_STATIC: usize = usize::MAX;
558
559impl SdfCollider {
560    /// Create a new SDF collider attached to the world (static).
561    ///
562    /// `rotation` is stored as a unit quaternion (normalized unless its
563    /// squared length is within `2^-32` of one): applying a quaternion `q`
564    /// scales what it rotates by `|q|^2`.
565    #[must_use]
566    pub fn new_static(field: Box<dyn SdfField>, position: Vec3Fix, rotation: QuatFix) -> Self {
567        let rotation = rotation.unit_rotation();
568        Self {
569            field,
570            position,
571            inv_rotation: rotation.conjugate(),
572            rotation,
573            scale: Fix128::ONE,
574            body_index: SDF_STATIC,
575            scale_f32: 1.0,
576            inv_scale_f32: 1.0,
577        }
578    }
579
580    /// Create a new SDF collider attached to a rigid body.
581    ///
582    /// The field's local origin is the body's position and its local axes are
583    /// the body's axes. The collider starts at the world origin with no
584    /// rotation and does not follow the body by itself: the body's pose is
585    /// copied in by [`Self::sync_to_body`] (or [`sync_dynamic_sdf_colliders`]
586    /// for a whole set), which has to run after the body moves and before the
587    /// collider is queried.
588    #[must_use]
589    pub fn new_dynamic(field: Box<dyn SdfField>, body_index: usize) -> Self {
590        Self {
591            field,
592            position: Vec3Fix::ZERO,
593            rotation: QuatFix::IDENTITY,
594            inv_rotation: QuatFix::IDENTITY,
595            scale: Fix128::ONE,
596            body_index,
597            scale_f32: 1.0,
598            inv_scale_f32: 1.0,
599        }
600    }
601
602    /// Set uniform scale
603    #[must_use]
604    pub fn with_scale(mut self, scale: Fix128) -> Self {
605        self.scale = scale;
606        let s = scale.to_f32();
607        self.scale_f32 = s;
608        self.inv_scale_f32 = if s.abs() < 1e-10 { 1.0 } else { 1.0 / s };
609        self
610    }
611
612    /// Recompute cached fields after changing position/rotation externally.
613    ///
614    /// The queries read the cached inverse rotation and scale, so a collider
615    /// whose `rotation` or `scale` field was written directly keeps answering
616    /// for the old orientation and scale until this is called.
617    /// [`Self::set_pose`], [`Self::sync_to_body`],
618    /// [`crate::solver::PhysicsWorld::add_sdf_collider`] and the start of
619    /// every world step (for every collider in
620    /// [`crate::solver::PhysicsWorld::sdf_colliders`]) call it themselves.
621    /// `rotation` is brought to unit length first (kept bit for bit when its
622    /// squared length is within `2^-32` of one).
623    pub fn update_cache(&mut self) {
624        self.rotation = self.rotation.unit_rotation();
625        self.inv_rotation = self.rotation.conjugate();
626        let s = self.scale.to_f32();
627        self.scale_f32 = s;
628        self.inv_scale_f32 = if s.abs() < 1e-10 { 1.0 } else { 1.0 / s };
629    }
630
631    /// Place the collider at `position` with orientation `rotation` and
632    /// refresh the cached inverse rotation, so every query that follows
633    /// evaluates the field in the new placement. The scale is kept. The
634    /// rotation is stored as a unit quaternion (see [`Self::update_cache`]).
635    pub fn set_pose(&mut self, position: Vec3Fix, rotation: QuatFix) {
636        self.position = position;
637        self.rotation = rotation;
638        self.update_cache();
639    }
640
641    /// Copy the pose of the body this collider is attached to:
642    /// `set_pose(bodies[body_index].position, bodies[body_index].rotation)`.
643    ///
644    /// Returns `true` when the pose was copied. A static collider
645    /// ([`SDF_STATIC`]) or a `body_index` outside `bodies` is left untouched
646    /// and gives `false`.
647    pub fn sync_to_body(&mut self, bodies: &[crate::solver::RigidBody]) -> bool {
648        if self.body_index == SDF_STATIC {
649            return false;
650        }
651        match bodies.get(self.body_index) {
652            Some(body) => {
653                self.set_pose(body.position, body.rotation);
654                true
655            }
656            None => false,
657        }
658    }
659
660    /// Transform world-space point to SDF local space (f32).
661    ///
662    /// Uses cached `inv_rotation` and `inv_scale_f32` to avoid recomputation.
663    #[cfg(feature = "std")]
664    #[inline]
665    pub(crate) fn world_to_local(&self, world_point: Vec3Fix) -> (f32, f32, f32) {
666        self.frame().world_to_local(world_point)
667    }
668
669    /// Transform local-space normal to world space (Fix128).
670    #[cfg(feature = "std")]
671    #[inline]
672    pub(crate) fn local_normal_to_world(&self, nx: f32, ny: f32, nz: f32) -> Vec3Fix {
673        self.frame().local_normal_to_world(nx, ny, nz)
674    }
675
676    /// The collider's placement as an [`SdfFrame`], taken from its cached
677    /// inverse rotation and scale (the values every query uses), so a
678    /// collider and its frame transform points identically.
679    #[must_use]
680    pub fn frame(&self) -> SdfFrame {
681        SdfFrame {
682            position: self.position,
683            rotation: self.rotation,
684            inv_rotation: self.inv_rotation,
685            scale_f32: self.scale_f32,
686            inv_scale_f32: self.inv_scale_f32,
687        }
688    }
689}
690
691/// Move every dynamic collider of `colliders` to the current pose of the
692/// body it is attached to ([`SdfCollider::sync_to_body`]); static colliders
693/// are not touched. Returns how many colliders were moved.
694///
695/// The queries below take the collider as it is stored, so a set that holds
696/// colliders made by [`SdfCollider::new_dynamic`] has to be synced after the
697/// bodies move and before it is queried.
698pub fn sync_dynamic_sdf_colliders(
699    colliders: &mut [SdfCollider],
700    bodies: &[crate::solver::RigidBody],
701) -> usize {
702    let mut moved = 0;
703    for collider in colliders {
704        if collider.sync_to_body(bodies) {
705            moved += 1;
706        }
707    }
708    moved
709}
710
711// ============================================================================
712// Collision Detection Functions
713// ============================================================================
714
715/// Collide a single point against an SDF.
716///
717/// Returns a contact if the point is inside the SDF (distance < 0).
718/// - `depth` = penetration depth (positive)
719/// - `normal` = direction to push the point out
720/// - `point_a` = the query point itself
721/// - `point_b` = nearest surface point
722///
723/// Optimization: evaluates distance first (1 eval), then normal only on hit (4 evals).
724#[cfg(feature = "std")]
725#[must_use]
726pub fn collide_point_sdf(point: Vec3Fix, sdf: &SdfCollider) -> Option<Contact> {
727    collide_point_sdf_field(point, &*sdf.field, &sdf.frame())
728}
729
730/// [`collide_point_sdf`] against a borrowed field placed by `frame`.
731///
732/// `field` need not be `'static`, `Send` or `Sync` (see [`SdfQuery`]); the
733/// result is bit for bit that of [`collide_point_sdf`] for a collider with
734/// the same field and [`SdfCollider::frame`]. A point on the surface or
735/// outside (world distance `>= 0`) gives `None`.
736#[cfg(feature = "std")]
737#[must_use]
738pub fn collide_point_sdf_field<F: SdfQuery + ?Sized>(
739    point: Vec3Fix,
740    field: &F,
741    frame: &SdfFrame,
742) -> Option<Contact> {
743    let (lx, ly, lz) = frame.world_to_local(point);
744
745    // Early-out: distance only (1 eval instead of 5)
746    let dist = field.query_distance(lx, ly, lz);
747    let world_dist = dist * frame.scale_f32();
748
749    if world_dist >= 0.0 {
750        return None; // Outside or on surface
751    }
752
753    // Only compute normal for penetrating points (4 additional evals)
754    let (nx, ny, nz) = field.query_normal(lx, ly, lz);
755
756    let depth = Fix128::from_f32(-world_dist);
757    let normal = frame.local_normal_to_world(nx, ny, nz);
758    let surface_point = point + normal * depth;
759
760    Some(Contact {
761        depth,
762        normal,
763        point_a: point,
764        point_b: surface_point,
765    })
766}
767
768/// Collide a sphere against an SDF.
769///
770/// Evaluates distance at sphere center, then adjusts by radius.
771///
772/// Optimization: evaluates distance first (1 eval), then normal only on hit (4 evals).
773/// Most bodies are NOT colliding, so this saves 4 evals per non-colliding body.
774#[cfg(feature = "std")]
775#[must_use]
776pub fn collide_sphere_sdf(center: Vec3Fix, radius: Fix128, sdf: &SdfCollider) -> Option<Contact> {
777    collide_sphere_sdf_field(center, radius, &*sdf.field, &sdf.frame())
778}
779
780/// [`collide_sphere_sdf`] against a borrowed field placed by `frame`.
781///
782/// `field` need not be `'static`, `Send` or `Sync` (see [`SdfQuery`]); the
783/// result is bit for bit that of [`collide_sphere_sdf`] for a collider with
784/// the same field and [`SdfCollider::frame`]. A sphere that does not reach
785/// the surface (`radius - world distance <= 0`) gives `None`; with zero
786/// `radius` this is the point test with the touching case excluded.
787#[cfg(feature = "std")]
788#[must_use]
789pub fn collide_sphere_sdf_field<F: SdfQuery + ?Sized>(
790    center: Vec3Fix,
791    radius: Fix128,
792    field: &F,
793    frame: &SdfFrame,
794) -> Option<Contact> {
795    let (lx, ly, lz) = frame.world_to_local(center);
796
797    // Early-out: distance only (1 eval)
798    let dist = field.query_distance(lx, ly, lz);
799    let world_dist = dist * frame.scale_f32();
800    let radius_f32 = radius.to_f32();
801
802    // Penetration = radius - distance_to_surface
803    let penetration = radius_f32 - world_dist;
804
805    if penetration <= 0.0 {
806        return None; // Sphere doesn't reach the surface
807    }
808
809    // Only compute normal for penetrating spheres (4 additional evals)
810    let (nx, ny, nz) = field.query_normal(lx, ly, lz);
811
812    let depth = Fix128::from_f32(penetration);
813    let normal = frame.local_normal_to_world(nx, ny, nz);
814
815    // Contact point on sphere surface (toward SDF)
816    let point_a = center - normal * radius;
817    // Contact point on SDF surface
818    let point_b = center - normal * Fix128::from_f32(world_dist);
819
820    Some(Contact {
821        depth,
822        normal,
823        point_a,
824        point_b,
825    })
826}
827
828/// Collide a capsule against an SDF.
829///
830/// Samples 3 points along the capsule axis (endpoints + midpoint),
831/// returns the deepest penetrating contact.
832#[cfg(feature = "std")]
833#[must_use]
834pub fn collide_capsule_sdf(
835    a: Vec3Fix,
836    b: Vec3Fix,
837    radius: Fix128,
838    sdf: &SdfCollider,
839) -> Option<Contact> {
840    let mid = Vec3Fix::new((a.x + b.x).half(), (a.y + b.y).half(), (a.z + b.z).half());
841
842    let samples = [a, mid, b];
843    let mut best: Option<Contact> = None;
844
845    for &point in &samples {
846        if let Some(contact) = collide_sphere_sdf(point, radius, sdf) {
847            match &best {
848                Some(prev) if prev.depth >= contact.depth => {}
849                _ => best = Some(contact),
850            }
851        }
852    }
853
854    best
855}
856
857/// The 27 points of a box that decide its contact with a field: its 8 corners, 12
858/// edge midpoints, 6 face centres and its centre (`centre + R · (i·hx, j·hy, k·hz)`
859/// for `i, j, k ∈ {−1, 0, 1}`). A flat field is deepest at a corner; a curved one
860/// (a ball, a hill) can be deepest at a face centre or an edge midpoint.
861#[cfg(feature = "std")]
862#[must_use]
863pub(crate) fn box_sample_points(
864    center: Vec3Fix,
865    half_extents: Vec3Fix,
866    rotation: QuatFix,
867) -> [Vec3Fix; 27] {
868    let mut points = [center; 27];
869    let steps = [-Fix128::ONE, Fix128::ZERO, Fix128::ONE];
870    let mut n = 0;
871    for &i in &steps {
872        for &j in &steps {
873            for &k in &steps {
874                let local =
875                    Vec3Fix::new(half_extents.x * i, half_extents.y * j, half_extents.z * k);
876                points[n] = center + rotation.unit_rotation().rotate_vec(local);
877                n += 1;
878            }
879        }
880    }
881    points
882}
883
884/// The deepest contact of a set of points with an SDF.
885#[cfg(feature = "std")]
886#[must_use]
887pub(crate) fn collide_points_sdf(points: &[Vec3Fix], sdf: &SdfCollider) -> Option<Contact> {
888    let mut best: Option<Contact> = None;
889    for &point in points {
890        if let Some(contact) = collide_point_sdf(point, sdf) {
891            match &best {
892                Some(prev) if prev.depth >= contact.depth => {}
893                _ => best = Some(contact),
894            }
895        }
896    }
897    best
898}
899
900/// Collide an AABB against an SDF.
901///
902/// Samples 27 points of the box (corners, edge midpoints, face centres, centre;
903/// `box_sample_points`) and returns the deepest penetrating contact. Exact for a
904/// flat field; for a curved one the deepest point of the box can lie between the
905/// samples.
906#[cfg(feature = "std")]
907#[must_use]
908pub fn collide_aabb_sdf(min: Vec3Fix, max: Vec3Fix, sdf: &SdfCollider) -> Option<Contact> {
909    let center = Vec3Fix::new(
910        (min.x + max.x).half(),
911        (min.y + max.y).half(),
912        (min.z + max.z).half(),
913    );
914    let half = Vec3Fix::new(
915        (max.x - min.x).half(),
916        (max.y - min.y).half(),
917        (max.z - min.z).half(),
918    );
919    collide_points_sdf(&box_sample_points(center, half, QuatFix::IDENTITY), sdf)
920}
921
922/// Detect all SDF collisions for a set of bodies.
923///
924/// For each body, tests against all SDF colliders and returns contacts.
925/// Bodies with `inv_mass == 0` (static) are skipped, and so is the collider
926/// attached to the body itself. Each collider is queried at its stored pose;
927/// call [`sync_dynamic_sdf_colliders`] first when the set holds dynamic
928/// colliders.
929#[cfg(feature = "std")]
930#[must_use]
931pub fn detect_sdf_contacts(
932    bodies: &[crate::solver::RigidBody],
933    sdf_colliders: &[SdfCollider],
934    collision_radius: Fix128,
935) -> Vec<(usize, Contact)> {
936    let mut contacts = Vec::new();
937
938    for (body_idx, body) in bodies.iter().enumerate() {
939        if body.is_static() {
940            continue;
941        }
942
943        for sdf in sdf_colliders {
944            // Skip self-collision
945            if sdf.body_index == body_idx {
946                continue;
947            }
948
949            // Treat each body as a sphere with the given collision radius
950            if let Some(contact) = collide_sphere_sdf(body.position, collision_radius, sdf) {
951                contacts.push((body_idx, contact));
952            }
953        }
954    }
955
956    contacts
957}
958
959// ============================================================================
960// Tests
961// ============================================================================
962
963#[cfg(all(test, feature = "std"))]
964mod tests {
965    use super::*;
966
967    /// Simple unit sphere SDF for testing
968    fn unit_sphere() -> ClosureSdf {
969        ClosureSdf::new(
970            |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
971            |x, y, z| {
972                let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
973                if len < 1e-10 {
974                    (0.0, 1.0, 0.0) // Degenerate: point normal upward
975                } else {
976                    (x / len, y / len, z / len)
977                }
978            },
979        )
980    }
981
982    /// Infinite ground plane at y=0 (negative below)
983    fn ground_plane() -> ClosureSdf {
984        ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0))
985    }
986
987    #[test]
988    fn test_point_outside_sphere() {
989        let sdf =
990            SdfCollider::new_static(Box::new(unit_sphere()), Vec3Fix::ZERO, QuatFix::IDENTITY);
991
992        // Point at (2, 0, 0) — outside sphere of radius 1
993        let point = Vec3Fix::from_f32(2.0, 0.0, 0.0);
994        let result = collide_point_sdf(point, &sdf);
995        assert!(result.is_none(), "Point outside sphere should not collide");
996    }
997
998    #[test]
999    fn test_point_inside_sphere() {
1000        let sdf =
1001            SdfCollider::new_static(Box::new(unit_sphere()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1002
1003        // Point at (0.5, 0, 0) — inside sphere of radius 1
1004        let point = Vec3Fix::from_f32(0.5, 0.0, 0.0);
1005        let result = collide_point_sdf(point, &sdf);
1006        assert!(result.is_some(), "Point inside sphere should collide");
1007
1008        let contact = result.unwrap();
1009        // Penetration depth should be ~0.5
1010        let depth = contact.depth.to_f32();
1011        assert!(
1012            (depth - 0.5).abs() < 0.05,
1013            "Depth should be ~0.5, got {depth}"
1014        );
1015
1016        // Normal should point in +X
1017        let (nx, _, _) = contact.normal.to_f32();
1018        assert!(nx > 0.9, "Normal should point in +X, got {nx}");
1019    }
1020
1021    #[test]
1022    fn test_sphere_vs_ground() {
1023        let sdf =
1024            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1025
1026        // Sphere at y=0.5 with radius 1.0 — penetrates ground by 0.5
1027        let center = Vec3Fix::from_f32(0.0, 0.5, 0.0);
1028        let radius = Fix128::ONE;
1029        let result = collide_sphere_sdf(center, radius, &sdf);
1030        assert!(result.is_some(), "Sphere should penetrate ground");
1031
1032        let contact = result.unwrap();
1033        let depth = contact.depth.to_f32();
1034        assert!(
1035            (depth - 0.5).abs() < 0.05,
1036            "Penetration should be ~0.5, got {depth}"
1037        );
1038    }
1039
1040    #[test]
1041    fn test_sphere_above_ground() {
1042        let sdf =
1043            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1044
1045        // Sphere at y=2.0 with radius 1.0 — floating above ground
1046        let center = Vec3Fix::from_f32(0.0, 2.0, 0.0);
1047        let radius = Fix128::ONE;
1048        let result = collide_sphere_sdf(center, radius, &sdf);
1049        assert!(result.is_none(), "Sphere above ground should not collide");
1050    }
1051
1052    #[test]
1053    fn test_capsule_vs_ground() {
1054        let sdf =
1055            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1056
1057        // Horizontal capsule at y=0.3, radius=0.5 — penetrates
1058        let a = Vec3Fix::from_f32(-1.0, 0.3, 0.0);
1059        let b = Vec3Fix::from_f32(1.0, 0.3, 0.0);
1060        let radius = Fix128::from_f32(0.5);
1061        let result = collide_capsule_sdf(a, b, radius, &sdf);
1062        assert!(result.is_some(), "Capsule should penetrate ground");
1063
1064        let contact = result.unwrap();
1065        let depth = contact.depth.to_f32();
1066        assert!(
1067            (depth - 0.2).abs() < 0.05,
1068            "Penetration should be ~0.2, got {depth}"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_translated_sdf() {
1074        // Sphere SDF centered at (5, 0, 0)
1075        let sdf = SdfCollider::new_static(
1076            Box::new(unit_sphere()),
1077            Vec3Fix::from_f32(5.0, 0.0, 0.0),
1078            QuatFix::IDENTITY,
1079        );
1080
1081        // Point at (5.5, 0, 0) — inside the translated sphere
1082        let point = Vec3Fix::from_f32(5.5, 0.0, 0.0);
1083        let result = collide_point_sdf(point, &sdf);
1084        assert!(
1085            result.is_some(),
1086            "Point inside translated sphere should collide"
1087        );
1088
1089        // Point at (0, 0, 0) — far outside
1090        let origin = Vec3Fix::ZERO;
1091        let result2 = collide_point_sdf(origin, &sdf);
1092        assert!(
1093            result2.is_none(),
1094            "Origin should be outside translated sphere"
1095        );
1096    }
1097
1098    #[test]
1099    fn test_scaled_sdf() {
1100        // Sphere scaled by 3x — effective radius 3
1101        let sdf =
1102            SdfCollider::new_static(Box::new(unit_sphere()), Vec3Fix::ZERO, QuatFix::IDENTITY)
1103                .with_scale(Fix128::from_int(3));
1104
1105        // Point at (2, 0, 0) — inside scaled sphere (radius 3)
1106        let point = Vec3Fix::from_f32(2.0, 0.0, 0.0);
1107        let result = collide_point_sdf(point, &sdf);
1108        assert!(
1109            result.is_some(),
1110            "Point inside scaled sphere should collide"
1111        );
1112
1113        // Point at (4, 0, 0) — outside scaled sphere
1114        let point_outside = Vec3Fix::from_f32(4.0, 0.0, 0.0);
1115        let result2 = collide_point_sdf(point_outside, &sdf);
1116        assert!(
1117            result2.is_none(),
1118            "Point outside scaled sphere should not collide"
1119        );
1120    }
1121
1122    #[test]
1123    fn test_aabb_vs_ground() {
1124        let sdf =
1125            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1126
1127        // AABB with bottom at y=-0.5 — penetrates ground
1128        let min = Vec3Fix::from_f32(-1.0, -0.5, -1.0);
1129        let max = Vec3Fix::from_f32(1.0, 1.0, 1.0);
1130        let result = collide_aabb_sdf(min, max, &sdf);
1131        assert!(result.is_some(), "AABB below ground should collide");
1132
1133        let contact = result.unwrap();
1134        let depth = contact.depth.to_f32();
1135        assert!(
1136            (depth - 0.5).abs() < 0.05,
1137            "Penetration should be ~0.5, got {depth}"
1138        );
1139    }
1140
1141    #[test]
1142    fn detect_sdf_contacts_reports_penetrating_dynamic_bodies_with_closed_form_depth() {
1143        use crate::solver::RigidBody;
1144        let ground =
1145            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1146        // 単位球 SDF (原点) を body 5 に attach (self-collision skip の確認用)
1147        let attached = SdfCollider::new_dynamic(Box::new(unit_sphere()), 5);
1148        let colliders = [ground, attached];
1149        let radius = Fix128::from_ratio(1, 2);
1150
1151        let mut bodies = vec![
1152            // 0: dynamic、(5, 0.25, 0) → 地面貫入 0.5 - 0.25 = 0.25、球からは遠い
1153            RigidBody::new_dynamic(Vec3Fix::from_f32(5.0, 0.25, 0.0), Fix128::ONE),
1154            // 1: dynamic、(0, 2, 0) → 地面 非接触、球 距離 1 ≥ 0.5 → 非接触
1155            RigidBody::new_dynamic(Vec3Fix::from_f32(0.0, 2.0, 0.0), Fix128::ONE),
1156            // 2: static、y = -1 (完全貫入) でも skip
1157            RigidBody::new_static(Vec3Fix::from_f32(0.0, -1.0, 0.0)),
1158            // 3: dynamic、(0, 0, 5) 地面上 → 貫入 0.5、球からは距離 4 → 非接触
1159            RigidBody::new_dynamic(Vec3Fix::from_f32(0.0, 0.0, 5.0), Fix128::ONE),
1160            // 4: dynamic、(1.2, 3, 0) → 地面 非接触、球 距離 sqrt(10.44)-1 ≈ 2.23 → 非接触
1161            RigidBody::new_dynamic(Vec3Fix::from_f32(1.2, 3.0, 0.0), Fix128::ONE),
1162            // 5: dynamic、(1.2, 0, 0) → 地面貫入 0.5、自身に attach された球 SDF は skip
1163            RigidBody::new_dynamic(Vec3Fix::from_f32(1.2, 0.0, 0.0), Fix128::ONE),
1164        ];
1165
1166        let contacts = detect_sdf_contacts(&bodies, &colliders, radius);
1167        let idxs: Vec<usize> = contacts.iter().map(|(i, _)| *i).collect();
1168        // body 順 → collider 順で列挙される
1169        assert_eq!(idxs, vec![0, 3, 5]);
1170        let expected_depth = [0.25_f32, 0.5, 0.5];
1171        for (k, want) in expected_depth.iter().enumerate() {
1172            let d = contacts[k].1.depth.to_f32();
1173            assert!(
1174                (d - want).abs() < 1e-5,
1175                "contact {k} depth {d}, want {want}"
1176            );
1177            let (nx, ny, nz) = contacts[k].1.normal.to_f32();
1178            assert!(nx.abs() < 1e-5 && (ny - 1.0).abs() < 1e-5 && nz.abs() < 1e-5);
1179            // point_b は SDF 表面 (y = 0)、point_a は球の最下点 (y = pos - 0.5)
1180            let (_, pby, _) = contacts[k].1.point_b.to_f32();
1181            let (_, pay, _) = contacts[k].1.point_a.to_f32();
1182            let (_, y, _) = bodies[idxs[k]].position.to_f32();
1183            assert!(pby.abs() < 1e-5, "contact {k} point_b.y {pby}");
1184            assert!(
1185                (pay - (y - 0.5)).abs() < 1e-5,
1186                "contact {k} point_a.y {pay}"
1187            );
1188        }
1189
1190        // 同じ位置 (1.2, 0, 0) の別 body (index 6) は球 SDF との接触も報告される:
1191        // 距離 0.2 < 0.5 → 貫入 0.3、法線 +X (self-collision skip は index で判定)
1192        bodies.push(RigidBody::new_dynamic(
1193            Vec3Fix::from_f32(1.2, 0.0, 0.0),
1194            Fix128::ONE,
1195        ));
1196        let c2 = detect_sdf_contacts(&bodies, &colliders, radius);
1197        let idxs2: Vec<usize> = c2.iter().map(|(i, _)| *i).collect();
1198        assert_eq!(idxs2, vec![0, 3, 5, 6, 6]);
1199        let sphere_contact = &c2[4].1;
1200        let d = sphere_contact.depth.to_f32();
1201        assert!((d - 0.3).abs() < 1e-5, "sphere depth {d}");
1202        let (nx, ny, nz) = sphere_contact.normal.to_f32();
1203        assert!((nx - 1.0).abs() < 1e-5 && ny.abs() < 1e-5 && nz.abs() < 1e-5);
1204
1205        // 半径 0 なら地面上 (y=0) の body は接触しない (penetration <= 0 → None)
1206        let none = detect_sdf_contacts(&bodies[3..4], &colliders[..1], Fix128::ZERO);
1207        assert!(none.is_empty());
1208        // collider なし → 空
1209        assert!(detect_sdf_contacts(&bodies, &[], radius).is_empty());
1210    }
1211}