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        self.a.distance(x, y, z).min(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/// Central-difference step for an f32 SDF normal at `(x, y, z)`.
381///
382/// `base_eps` is used as is near the origin. An absolute step stops resolving
383/// far from it: once `base_eps` falls below the f32 spacing of the
384/// coordinates, `x + eps` rounds back to `x` and the difference is zero or
385/// noise (AUD-A-S5W3-008, AUD-A-S5W3-013). The step therefore grows in
386/// proportion to the largest coordinate magnitude, `1e-4 * max(|x|, |y|, |z|)`
387/// (at least about 840 f32 ulps of that coordinate), whenever that exceeds
388/// `base_eps`.
389/// The same step is used on all three axes so the gradient direction is not
390/// skewed.
391#[inline]
392#[must_use]
393pub(crate) fn fd_normal_step(base_eps: f32, x: f32, y: f32, z: f32) -> f32 {
394    let scale = x.abs().max(y.abs()).max(z.abs());
395    base_eps.max(1.0e-4 * scale)
396}
397
398/// Base step of the central-difference SDF normal, used near the origin
399/// (see [`DistanceSdfQuery`]; farther out the step grows with the
400/// coordinate magnitude).
401pub const FD_NORMAL_BASE_EPS: f32 = 1.0e-3;
402
403/// Central-difference normal of the distance `f` at `(x, y, z)`: step
404/// [`fd_normal_step`]`(base_eps, ..)`, the three axis differences
405/// normalised, `(0, 1, 0)` when their length is below `1e-10`, NaN when a
406/// sample is not finite. The one formula behind [`DistanceSdfQuery`] and
407/// the normals of `ModifiedSdf`, `SingleModifiedSdf` and `DestructibleSdf`.
408#[cfg(feature = "std")]
409#[inline]
410#[must_use]
411pub(crate) fn fd_normal<F: Fn(f32, f32, f32) -> f32>(
412    f: F,
413    base_eps: f32,
414    x: f32,
415    y: f32,
416    z: f32,
417) -> (f32, f32, f32) {
418    let e = fd_normal_step(base_eps, x, y, z);
419    let dx = f(x + e, y, z) - f(x - e, y, z);
420    let dy = f(x, y + e, z) - f(x, y - e, z);
421    let dz = f(x, y, z + e) - f(x, y, z - e);
422
423    let len = dz.mul_add(dz, dx.mul_add(dx, dy * dy)).sqrt();
424    if len < 1e-10 {
425        (0.0, 1.0, 0.0)
426    } else {
427        (dx / len, dy / len, dz / len)
428    }
429}
430
431// ============================================================================
432// SDF Collider (world-space placement)
433// ============================================================================
434
435/// An SDF field placed in the physics world with a transform.
436///
437/// The SDF is evaluated in local space; the collider transforms
438/// world-space query points into local space before evaluation.
439///
440/// Cached fields (`inv_rotation`, `scale_f32`, `inv_scale_f32`) avoid
441/// recomputing invariants on every collision query.
442pub struct SdfCollider {
443    /// The underlying distance field
444    pub field: Box<dyn SdfField>,
445    /// World-space position of the SDF origin
446    pub position: Vec3Fix,
447    /// World-space orientation of the SDF, a unit quaternion
448    ///
449    /// [`Self::new_static`], [`Self::set_pose`], [`Self::sync_to_body`],
450    /// [`Self::update_cache`], [`crate::solver::PhysicsWorld::add_sdf_collider`]
451    /// and the start of every world step store it at unit length (a quaternion
452    /// whose squared length is within `2^-32` of one is kept bit for bit). A
453    /// value written to this field directly takes effect through
454    /// [`Self::update_cache`], which normalizes it and recomputes the cached
455    /// inverse rotation the field is evaluated through;
456    /// [`crate::solver::PhysicsWorld::add_sdf_collider`] and the start of
457    /// every world step call it. Between a direct write and the next of those,
458    /// the field is evaluated in the old orientation while the contact normal
459    /// is rotated by the value as written.
460    pub rotation: QuatFix,
461    /// Uniform scale factor
462    ///
463    /// A value written directly takes effect through [`Self::update_cache`]
464    /// (called by [`crate::solver::PhysicsWorld::add_sdf_collider`] and at the
465    /// start of every world step), as for `rotation`.
466    pub scale: Fix128,
467    /// Body index this SDF is attached to (`usize::MAX` = static world geometry)
468    pub body_index: usize,
469    // -- Cached invariants (derived from rotation/scale) --
470    /// Inverse rotation (cached)
471    pub(crate) inv_rotation: QuatFix,
472    /// Scale as f32 (cached)
473    pub(crate) scale_f32: f32,
474    /// Inverse scale as f32 (cached, guards against zero)
475    pub(crate) inv_scale_f32: f32,
476}
477
478/// The placement of a distance field in the world: position, orientation
479/// and uniform scale, with the inverse rotation and the scale as `f32`
480/// precomputed.
481///
482/// A field is evaluated in its local space: a world point `p` is queried at
483/// `R⁻¹ (p - position) / scale`, the returned distance is multiplied by
484/// `scale`, and a local normal is rotated by `R` back to world space. This is
485/// the transform an [`SdfCollider`] applies; [`SdfCollider::frame`] returns
486/// the collider's own frame (from its cached values, so a collider and its
487/// frame agree bit for bit even when the cache was not refreshed with
488/// [`SdfCollider::update_cache`]).
489///
490/// A scale whose magnitude is below `1e-10` is inverted as `1`, as in
491/// [`SdfCollider::with_scale`].
492#[derive(Clone, Copy, Debug, PartialEq)]
493pub struct SdfFrame {
494    position: Vec3Fix,
495    rotation: QuatFix,
496    inv_rotation: QuatFix,
497    scale_f32: f32,
498    inv_scale_f32: f32,
499}
500
501impl SdfFrame {
502    /// The world frame: no translation, no rotation, scale 1.
503    pub const IDENTITY: Self = Self {
504        position: Vec3Fix::ZERO,
505        rotation: QuatFix::IDENTITY,
506        inv_rotation: QuatFix::IDENTITY,
507        scale_f32: 1.0,
508        inv_scale_f32: 1.0,
509    };
510
511    /// A field placed at `position`, rotated by `rotation`, scaled by `scale`.
512    ///
513    /// `rotation` is used as a unit quaternion (normalized unless its squared
514    /// length is within `2^-32` of one, which is kept bit for bit): applying
515    /// a quaternion `q` scales what it rotates by `|q|^2`.
516    #[must_use]
517    pub fn new(position: Vec3Fix, rotation: QuatFix, scale: Fix128) -> Self {
518        let rotation = rotation.unit_rotation();
519        let s = scale.to_f32();
520        Self {
521            position,
522            rotation,
523            inv_rotation: rotation.conjugate(),
524            scale_f32: s,
525            inv_scale_f32: if s.abs() < 1e-10 { 1.0 } else { 1.0 / s },
526        }
527    }
528
529    /// The uniform scale as `f32` (local distances are multiplied by it).
530    #[must_use]
531    pub fn scale_f32(&self) -> f32 {
532        self.scale_f32
533    }
534
535    /// Transform a world-space point to the field's local space (f32).
536    #[cfg(feature = "std")]
537    #[inline]
538    pub(crate) fn world_to_local(&self, world_point: Vec3Fix) -> (f32, f32, f32) {
539        let relative = world_point - self.position;
540        let local = self.inv_rotation.rotate_vec(relative);
541        let (lx, ly, lz) = local.to_f32();
542        let inv_s = self.inv_scale_f32;
543        (lx * inv_s, ly * inv_s, lz * inv_s)
544    }
545
546    /// Transform a local-space normal to world space (Fix128).
547    #[cfg(feature = "std")]
548    #[inline]
549    pub(crate) fn local_normal_to_world(&self, nx: f32, ny: f32, nz: f32) -> Vec3Fix {
550        let local_n = Vec3Fix::from_f32(nx, ny, nz);
551        self.rotation.rotate_vec(local_n).normalize()
552    }
553}
554
555/// Sentinel value for static (world-fixed) SDF colliders
556pub const SDF_STATIC: usize = usize::MAX;
557
558impl SdfCollider {
559    /// Create a new SDF collider attached to the world (static).
560    ///
561    /// `rotation` is stored as a unit quaternion (normalized unless its
562    /// squared length is within `2^-32` of one): applying a quaternion `q`
563    /// scales what it rotates by `|q|^2`.
564    #[must_use]
565    pub fn new_static(field: Box<dyn SdfField>, position: Vec3Fix, rotation: QuatFix) -> Self {
566        let rotation = rotation.unit_rotation();
567        Self {
568            field,
569            position,
570            inv_rotation: rotation.conjugate(),
571            rotation,
572            scale: Fix128::ONE,
573            body_index: SDF_STATIC,
574            scale_f32: 1.0,
575            inv_scale_f32: 1.0,
576        }
577    }
578
579    /// Create a new SDF collider attached to a rigid body.
580    ///
581    /// The field's local origin is the body's position and its local axes are
582    /// the body's axes. The collider starts at the world origin with no
583    /// rotation and does not follow the body by itself: the body's pose is
584    /// copied in by [`Self::sync_to_body`] (or [`sync_dynamic_sdf_colliders`]
585    /// for a whole set), which has to run after the body moves and before the
586    /// collider is queried.
587    #[must_use]
588    pub fn new_dynamic(field: Box<dyn SdfField>, body_index: usize) -> Self {
589        Self {
590            field,
591            position: Vec3Fix::ZERO,
592            rotation: QuatFix::IDENTITY,
593            inv_rotation: QuatFix::IDENTITY,
594            scale: Fix128::ONE,
595            body_index,
596            scale_f32: 1.0,
597            inv_scale_f32: 1.0,
598        }
599    }
600
601    /// Set uniform scale
602    #[must_use]
603    pub fn with_scale(mut self, scale: Fix128) -> Self {
604        self.scale = scale;
605        let s = scale.to_f32();
606        self.scale_f32 = s;
607        self.inv_scale_f32 = if s.abs() < 1e-10 { 1.0 } else { 1.0 / s };
608        self
609    }
610
611    /// Recompute cached fields after changing position/rotation externally.
612    ///
613    /// The queries read the cached inverse rotation and scale, so a collider
614    /// whose `rotation` or `scale` field was written directly keeps answering
615    /// for the old orientation and scale until this is called.
616    /// [`Self::set_pose`], [`Self::sync_to_body`],
617    /// [`crate::solver::PhysicsWorld::add_sdf_collider`] and the start of
618    /// every world step (for every collider in
619    /// [`crate::solver::PhysicsWorld::sdf_colliders`]) call it themselves.
620    /// `rotation` is brought to unit length first (kept bit for bit when its
621    /// squared length is within `2^-32` of one).
622    pub fn update_cache(&mut self) {
623        self.rotation = self.rotation.unit_rotation();
624        self.inv_rotation = self.rotation.conjugate();
625        let s = self.scale.to_f32();
626        self.scale_f32 = s;
627        self.inv_scale_f32 = if s.abs() < 1e-10 { 1.0 } else { 1.0 / s };
628    }
629
630    /// Place the collider at `position` with orientation `rotation` and
631    /// refresh the cached inverse rotation, so every query that follows
632    /// evaluates the field in the new placement. The scale is kept. The
633    /// rotation is stored as a unit quaternion (see [`Self::update_cache`]).
634    pub fn set_pose(&mut self, position: Vec3Fix, rotation: QuatFix) {
635        self.position = position;
636        self.rotation = rotation;
637        self.update_cache();
638    }
639
640    /// Copy the pose of the body this collider is attached to:
641    /// `set_pose(bodies[body_index].position, bodies[body_index].rotation)`.
642    ///
643    /// Returns `true` when the pose was copied. A static collider
644    /// ([`SDF_STATIC`]) or a `body_index` outside `bodies` is left untouched
645    /// and gives `false`.
646    pub fn sync_to_body(&mut self, bodies: &[crate::solver::RigidBody]) -> bool {
647        if self.body_index == SDF_STATIC {
648            return false;
649        }
650        match bodies.get(self.body_index) {
651            Some(body) => {
652                self.set_pose(body.position, body.rotation);
653                true
654            }
655            None => false,
656        }
657    }
658
659    /// Transform world-space point to SDF local space (f32).
660    ///
661    /// Uses cached `inv_rotation` and `inv_scale_f32` to avoid recomputation.
662    #[cfg(feature = "std")]
663    #[inline]
664    pub(crate) fn world_to_local(&self, world_point: Vec3Fix) -> (f32, f32, f32) {
665        self.frame().world_to_local(world_point)
666    }
667
668    /// Transform local-space normal to world space (Fix128).
669    #[cfg(feature = "std")]
670    #[inline]
671    pub(crate) fn local_normal_to_world(&self, nx: f32, ny: f32, nz: f32) -> Vec3Fix {
672        self.frame().local_normal_to_world(nx, ny, nz)
673    }
674
675    /// The collider's placement as an [`SdfFrame`], taken from its cached
676    /// inverse rotation and scale (the values every query uses), so a
677    /// collider and its frame transform points identically.
678    #[must_use]
679    pub fn frame(&self) -> SdfFrame {
680        SdfFrame {
681            position: self.position,
682            rotation: self.rotation,
683            inv_rotation: self.inv_rotation,
684            scale_f32: self.scale_f32,
685            inv_scale_f32: self.inv_scale_f32,
686        }
687    }
688}
689
690/// Move every dynamic collider of `colliders` to the current pose of the
691/// body it is attached to ([`SdfCollider::sync_to_body`]); static colliders
692/// are not touched. Returns how many colliders were moved.
693///
694/// The queries below take the collider as it is stored, so a set that holds
695/// colliders made by [`SdfCollider::new_dynamic`] has to be synced after the
696/// bodies move and before it is queried.
697pub fn sync_dynamic_sdf_colliders(
698    colliders: &mut [SdfCollider],
699    bodies: &[crate::solver::RigidBody],
700) -> usize {
701    let mut moved = 0;
702    for collider in colliders {
703        if collider.sync_to_body(bodies) {
704            moved += 1;
705        }
706    }
707    moved
708}
709
710// ============================================================================
711// Collision Detection Functions
712// ============================================================================
713
714/// Collide a single point against an SDF.
715///
716/// Returns a contact if the point is inside the SDF (distance < 0).
717/// - `depth` = penetration depth (positive)
718/// - `normal` = direction to push the point out
719/// - `point_a` = the query point itself
720/// - `point_b` = nearest surface point
721///
722/// Optimization: evaluates distance first (1 eval), then normal only on hit (4 evals).
723#[cfg(feature = "std")]
724#[must_use]
725pub fn collide_point_sdf(point: Vec3Fix, sdf: &SdfCollider) -> Option<Contact> {
726    collide_point_sdf_field(point, &*sdf.field, &sdf.frame())
727}
728
729/// [`collide_point_sdf`] against a borrowed field placed by `frame`.
730///
731/// `field` need not be `'static`, `Send` or `Sync` (see [`SdfQuery`]); the
732/// result is bit for bit that of [`collide_point_sdf`] for a collider with
733/// the same field and [`SdfCollider::frame`]. A point on the surface or
734/// outside (world distance `>= 0`) gives `None`.
735#[cfg(feature = "std")]
736#[must_use]
737pub fn collide_point_sdf_field<F: SdfQuery + ?Sized>(
738    point: Vec3Fix,
739    field: &F,
740    frame: &SdfFrame,
741) -> Option<Contact> {
742    let (lx, ly, lz) = frame.world_to_local(point);
743
744    // Early-out: distance only (1 eval instead of 5)
745    let dist = field.query_distance(lx, ly, lz);
746    let world_dist = dist * frame.scale_f32();
747
748    if world_dist >= 0.0 {
749        return None; // Outside or on surface
750    }
751
752    // Only compute normal for penetrating points (4 additional evals)
753    let (nx, ny, nz) = field.query_normal(lx, ly, lz);
754
755    let depth = Fix128::from_f32(-world_dist);
756    let normal = frame.local_normal_to_world(nx, ny, nz);
757    let surface_point = point + normal * depth;
758
759    Some(Contact {
760        depth,
761        normal,
762        point_a: point,
763        point_b: surface_point,
764    })
765}
766
767/// Collide a sphere against an SDF.
768///
769/// Evaluates distance at sphere center, then adjusts by radius.
770///
771/// Optimization: evaluates distance first (1 eval), then normal only on hit (4 evals).
772/// Most bodies are NOT colliding, so this saves 4 evals per non-colliding body.
773#[cfg(feature = "std")]
774#[must_use]
775pub fn collide_sphere_sdf(center: Vec3Fix, radius: Fix128, sdf: &SdfCollider) -> Option<Contact> {
776    collide_sphere_sdf_field(center, radius, &*sdf.field, &sdf.frame())
777}
778
779/// [`collide_sphere_sdf`] against a borrowed field placed by `frame`.
780///
781/// `field` need not be `'static`, `Send` or `Sync` (see [`SdfQuery`]); the
782/// result is bit for bit that of [`collide_sphere_sdf`] for a collider with
783/// the same field and [`SdfCollider::frame`]. A sphere that does not reach
784/// the surface (`radius - world distance <= 0`) gives `None`; with zero
785/// `radius` this is the point test with the touching case excluded.
786#[cfg(feature = "std")]
787#[must_use]
788pub fn collide_sphere_sdf_field<F: SdfQuery + ?Sized>(
789    center: Vec3Fix,
790    radius: Fix128,
791    field: &F,
792    frame: &SdfFrame,
793) -> Option<Contact> {
794    let (lx, ly, lz) = frame.world_to_local(center);
795
796    // Early-out: distance only (1 eval)
797    let dist = field.query_distance(lx, ly, lz);
798    let world_dist = dist * frame.scale_f32();
799    let radius_f32 = radius.to_f32();
800
801    // Penetration = radius - distance_to_surface
802    let penetration = radius_f32 - world_dist;
803
804    if penetration <= 0.0 {
805        return None; // Sphere doesn't reach the surface
806    }
807
808    // Only compute normal for penetrating spheres (4 additional evals)
809    let (nx, ny, nz) = field.query_normal(lx, ly, lz);
810
811    let depth = Fix128::from_f32(penetration);
812    let normal = frame.local_normal_to_world(nx, ny, nz);
813
814    // Contact point on sphere surface (toward SDF)
815    let point_a = center - normal * radius;
816    // Contact point on SDF surface
817    let point_b = center - normal * Fix128::from_f32(world_dist);
818
819    Some(Contact {
820        depth,
821        normal,
822        point_a,
823        point_b,
824    })
825}
826
827/// Collide a capsule against an SDF.
828///
829/// Samples 3 points along the capsule axis (endpoints + midpoint),
830/// returns the deepest penetrating contact.
831#[cfg(feature = "std")]
832#[must_use]
833pub fn collide_capsule_sdf(
834    a: Vec3Fix,
835    b: Vec3Fix,
836    radius: Fix128,
837    sdf: &SdfCollider,
838) -> Option<Contact> {
839    let mid = Vec3Fix::new((a.x + b.x).half(), (a.y + b.y).half(), (a.z + b.z).half());
840
841    let samples = [a, mid, b];
842    let mut best: Option<Contact> = None;
843
844    for &point in &samples {
845        if let Some(contact) = collide_sphere_sdf(point, radius, sdf) {
846            match &best {
847                Some(prev) if prev.depth >= contact.depth => {}
848                _ => best = Some(contact),
849            }
850        }
851    }
852
853    best
854}
855
856/// The 27 points of a box that decide its contact with a field: its 8 corners, 12
857/// edge midpoints, 6 face centres and its centre (`centre + R · (i·hx, j·hy, k·hz)`
858/// for `i, j, k ∈ {−1, 0, 1}`). A flat field is deepest at a corner; a curved one
859/// (a ball, a hill) can be deepest at a face centre or an edge midpoint.
860#[cfg(feature = "std")]
861#[must_use]
862pub(crate) fn box_sample_points(
863    center: Vec3Fix,
864    half_extents: Vec3Fix,
865    rotation: QuatFix,
866) -> [Vec3Fix; 27] {
867    let mut points = [center; 27];
868    let steps = [-Fix128::ONE, Fix128::ZERO, Fix128::ONE];
869    let mut n = 0;
870    for &i in &steps {
871        for &j in &steps {
872            for &k in &steps {
873                let local =
874                    Vec3Fix::new(half_extents.x * i, half_extents.y * j, half_extents.z * k);
875                points[n] = center + rotation.unit_rotation().rotate_vec(local);
876                n += 1;
877            }
878        }
879    }
880    points
881}
882
883/// The deepest contact of a set of points with an SDF.
884#[cfg(feature = "std")]
885#[must_use]
886pub(crate) fn collide_points_sdf(points: &[Vec3Fix], sdf: &SdfCollider) -> Option<Contact> {
887    let mut best: Option<Contact> = None;
888    for &point in points {
889        if let Some(contact) = collide_point_sdf(point, sdf) {
890            match &best {
891                Some(prev) if prev.depth >= contact.depth => {}
892                _ => best = Some(contact),
893            }
894        }
895    }
896    best
897}
898
899/// Collide an AABB against an SDF.
900///
901/// Samples 27 points of the box (corners, edge midpoints, face centres, centre;
902/// `box_sample_points`) and returns the deepest penetrating contact. Exact for a
903/// flat field; for a curved one the deepest point of the box can lie between the
904/// samples.
905#[cfg(feature = "std")]
906#[must_use]
907pub fn collide_aabb_sdf(min: Vec3Fix, max: Vec3Fix, sdf: &SdfCollider) -> Option<Contact> {
908    let center = Vec3Fix::new(
909        (min.x + max.x).half(),
910        (min.y + max.y).half(),
911        (min.z + max.z).half(),
912    );
913    let half = Vec3Fix::new(
914        (max.x - min.x).half(),
915        (max.y - min.y).half(),
916        (max.z - min.z).half(),
917    );
918    collide_points_sdf(&box_sample_points(center, half, QuatFix::IDENTITY), sdf)
919}
920
921/// Detect all SDF collisions for a set of bodies.
922///
923/// For each body, tests against all SDF colliders and returns contacts.
924/// Bodies with `inv_mass == 0` (static) are skipped, and so is the collider
925/// attached to the body itself. Each collider is queried at its stored pose;
926/// call [`sync_dynamic_sdf_colliders`] first when the set holds dynamic
927/// colliders.
928#[cfg(feature = "std")]
929#[must_use]
930pub fn detect_sdf_contacts(
931    bodies: &[crate::solver::RigidBody],
932    sdf_colliders: &[SdfCollider],
933    collision_radius: Fix128,
934) -> Vec<(usize, Contact)> {
935    let mut contacts = Vec::new();
936
937    for (body_idx, body) in bodies.iter().enumerate() {
938        if body.is_static() {
939            continue;
940        }
941
942        for sdf in sdf_colliders {
943            // Skip self-collision
944            if sdf.body_index == body_idx {
945                continue;
946            }
947
948            // Treat each body as a sphere with the given collision radius
949            if let Some(contact) = collide_sphere_sdf(body.position, collision_radius, sdf) {
950                contacts.push((body_idx, contact));
951            }
952        }
953    }
954
955    contacts
956}
957
958// ============================================================================
959// Tests
960// ============================================================================
961
962#[cfg(all(test, feature = "std"))]
963mod tests {
964    use super::*;
965
966    /// Simple unit sphere SDF for testing
967    fn unit_sphere() -> ClosureSdf {
968        ClosureSdf::new(
969            |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
970            |x, y, z| {
971                let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
972                if len < 1e-10 {
973                    (0.0, 1.0, 0.0) // Degenerate: point normal upward
974                } else {
975                    (x / len, y / len, z / len)
976                }
977            },
978        )
979    }
980
981    /// Infinite ground plane at y=0 (negative below)
982    fn ground_plane() -> ClosureSdf {
983        ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0))
984    }
985
986    #[test]
987    fn test_point_outside_sphere() {
988        let sdf =
989            SdfCollider::new_static(Box::new(unit_sphere()), Vec3Fix::ZERO, QuatFix::IDENTITY);
990
991        // Point at (2, 0, 0) — outside sphere of radius 1
992        let point = Vec3Fix::from_f32(2.0, 0.0, 0.0);
993        let result = collide_point_sdf(point, &sdf);
994        assert!(result.is_none(), "Point outside sphere should not collide");
995    }
996
997    #[test]
998    fn test_point_inside_sphere() {
999        let sdf =
1000            SdfCollider::new_static(Box::new(unit_sphere()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1001
1002        // Point at (0.5, 0, 0) — inside sphere of radius 1
1003        let point = Vec3Fix::from_f32(0.5, 0.0, 0.0);
1004        let result = collide_point_sdf(point, &sdf);
1005        assert!(result.is_some(), "Point inside sphere should collide");
1006
1007        let contact = result.unwrap();
1008        // Penetration depth should be ~0.5
1009        let depth = contact.depth.to_f32();
1010        assert!(
1011            (depth - 0.5).abs() < 0.05,
1012            "Depth should be ~0.5, got {depth}"
1013        );
1014
1015        // Normal should point in +X
1016        let (nx, _, _) = contact.normal.to_f32();
1017        assert!(nx > 0.9, "Normal should point in +X, got {nx}");
1018    }
1019
1020    #[test]
1021    fn test_sphere_vs_ground() {
1022        let sdf =
1023            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1024
1025        // Sphere at y=0.5 with radius 1.0 — penetrates ground by 0.5
1026        let center = Vec3Fix::from_f32(0.0, 0.5, 0.0);
1027        let radius = Fix128::ONE;
1028        let result = collide_sphere_sdf(center, radius, &sdf);
1029        assert!(result.is_some(), "Sphere should penetrate ground");
1030
1031        let contact = result.unwrap();
1032        let depth = contact.depth.to_f32();
1033        assert!(
1034            (depth - 0.5).abs() < 0.05,
1035            "Penetration should be ~0.5, got {depth}"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_sphere_above_ground() {
1041        let sdf =
1042            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1043
1044        // Sphere at y=2.0 with radius 1.0 — floating above ground
1045        let center = Vec3Fix::from_f32(0.0, 2.0, 0.0);
1046        let radius = Fix128::ONE;
1047        let result = collide_sphere_sdf(center, radius, &sdf);
1048        assert!(result.is_none(), "Sphere above ground should not collide");
1049    }
1050
1051    #[test]
1052    fn test_capsule_vs_ground() {
1053        let sdf =
1054            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1055
1056        // Horizontal capsule at y=0.3, radius=0.5 — penetrates
1057        let a = Vec3Fix::from_f32(-1.0, 0.3, 0.0);
1058        let b = Vec3Fix::from_f32(1.0, 0.3, 0.0);
1059        let radius = Fix128::from_f32(0.5);
1060        let result = collide_capsule_sdf(a, b, radius, &sdf);
1061        assert!(result.is_some(), "Capsule should penetrate ground");
1062
1063        let contact = result.unwrap();
1064        let depth = contact.depth.to_f32();
1065        assert!(
1066            (depth - 0.2).abs() < 0.05,
1067            "Penetration should be ~0.2, got {depth}"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_translated_sdf() {
1073        // Sphere SDF centered at (5, 0, 0)
1074        let sdf = SdfCollider::new_static(
1075            Box::new(unit_sphere()),
1076            Vec3Fix::from_f32(5.0, 0.0, 0.0),
1077            QuatFix::IDENTITY,
1078        );
1079
1080        // Point at (5.5, 0, 0) — inside the translated sphere
1081        let point = Vec3Fix::from_f32(5.5, 0.0, 0.0);
1082        let result = collide_point_sdf(point, &sdf);
1083        assert!(
1084            result.is_some(),
1085            "Point inside translated sphere should collide"
1086        );
1087
1088        // Point at (0, 0, 0) — far outside
1089        let origin = Vec3Fix::ZERO;
1090        let result2 = collide_point_sdf(origin, &sdf);
1091        assert!(
1092            result2.is_none(),
1093            "Origin should be outside translated sphere"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_scaled_sdf() {
1099        // Sphere scaled by 3x — effective radius 3
1100        let sdf =
1101            SdfCollider::new_static(Box::new(unit_sphere()), Vec3Fix::ZERO, QuatFix::IDENTITY)
1102                .with_scale(Fix128::from_int(3));
1103
1104        // Point at (2, 0, 0) — inside scaled sphere (radius 3)
1105        let point = Vec3Fix::from_f32(2.0, 0.0, 0.0);
1106        let result = collide_point_sdf(point, &sdf);
1107        assert!(
1108            result.is_some(),
1109            "Point inside scaled sphere should collide"
1110        );
1111
1112        // Point at (4, 0, 0) — outside scaled sphere
1113        let point_outside = Vec3Fix::from_f32(4.0, 0.0, 0.0);
1114        let result2 = collide_point_sdf(point_outside, &sdf);
1115        assert!(
1116            result2.is_none(),
1117            "Point outside scaled sphere should not collide"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_aabb_vs_ground() {
1123        let sdf =
1124            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1125
1126        // AABB with bottom at y=-0.5 — penetrates ground
1127        let min = Vec3Fix::from_f32(-1.0, -0.5, -1.0);
1128        let max = Vec3Fix::from_f32(1.0, 1.0, 1.0);
1129        let result = collide_aabb_sdf(min, max, &sdf);
1130        assert!(result.is_some(), "AABB below ground should collide");
1131
1132        let contact = result.unwrap();
1133        let depth = contact.depth.to_f32();
1134        assert!(
1135            (depth - 0.5).abs() < 0.05,
1136            "Penetration should be ~0.5, got {depth}"
1137        );
1138    }
1139
1140    #[test]
1141    fn detect_sdf_contacts_reports_penetrating_dynamic_bodies_with_closed_form_depth() {
1142        use crate::solver::RigidBody;
1143        let ground =
1144            SdfCollider::new_static(Box::new(ground_plane()), Vec3Fix::ZERO, QuatFix::IDENTITY);
1145        // 単位球 SDF (原点) を body 5 に attach (self-collision skip の確認用)
1146        let attached = SdfCollider::new_dynamic(Box::new(unit_sphere()), 5);
1147        let colliders = [ground, attached];
1148        let radius = Fix128::from_ratio(1, 2);
1149
1150        let mut bodies = vec![
1151            // 0: dynamic、(5, 0.25, 0) → 地面貫入 0.5 - 0.25 = 0.25、球からは遠い
1152            RigidBody::new_dynamic(Vec3Fix::from_f32(5.0, 0.25, 0.0), Fix128::ONE),
1153            // 1: dynamic、(0, 2, 0) → 地面 非接触、球 距離 1 ≥ 0.5 → 非接触
1154            RigidBody::new_dynamic(Vec3Fix::from_f32(0.0, 2.0, 0.0), Fix128::ONE),
1155            // 2: static、y = -1 (完全貫入) でも skip
1156            RigidBody::new_static(Vec3Fix::from_f32(0.0, -1.0, 0.0)),
1157            // 3: dynamic、(0, 0, 5) 地面上 → 貫入 0.5、球からは距離 4 → 非接触
1158            RigidBody::new_dynamic(Vec3Fix::from_f32(0.0, 0.0, 5.0), Fix128::ONE),
1159            // 4: dynamic、(1.2, 3, 0) → 地面 非接触、球 距離 sqrt(10.44)-1 ≈ 2.23 → 非接触
1160            RigidBody::new_dynamic(Vec3Fix::from_f32(1.2, 3.0, 0.0), Fix128::ONE),
1161            // 5: dynamic、(1.2, 0, 0) → 地面貫入 0.5、自身に attach された球 SDF は skip
1162            RigidBody::new_dynamic(Vec3Fix::from_f32(1.2, 0.0, 0.0), Fix128::ONE),
1163        ];
1164
1165        let contacts = detect_sdf_contacts(&bodies, &colliders, radius);
1166        let idxs: Vec<usize> = contacts.iter().map(|(i, _)| *i).collect();
1167        // body 順 → collider 順で列挙される
1168        assert_eq!(idxs, vec![0, 3, 5]);
1169        let expected_depth = [0.25_f32, 0.5, 0.5];
1170        for (k, want) in expected_depth.iter().enumerate() {
1171            let d = contacts[k].1.depth.to_f32();
1172            assert!(
1173                (d - want).abs() < 1e-5,
1174                "contact {k} depth {d}, want {want}"
1175            );
1176            let (nx, ny, nz) = contacts[k].1.normal.to_f32();
1177            assert!(nx.abs() < 1e-5 && (ny - 1.0).abs() < 1e-5 && nz.abs() < 1e-5);
1178            // point_b は SDF 表面 (y = 0)、point_a は球の最下点 (y = pos - 0.5)
1179            let (_, pby, _) = contacts[k].1.point_b.to_f32();
1180            let (_, pay, _) = contacts[k].1.point_a.to_f32();
1181            let (_, y, _) = bodies[idxs[k]].position.to_f32();
1182            assert!(pby.abs() < 1e-5, "contact {k} point_b.y {pby}");
1183            assert!(
1184                (pay - (y - 0.5)).abs() < 1e-5,
1185                "contact {k} point_a.y {pay}"
1186            );
1187        }
1188
1189        // 同じ位置 (1.2, 0, 0) の別 body (index 6) は球 SDF との接触も報告される:
1190        // 距離 0.2 < 0.5 → 貫入 0.3、法線 +X (self-collision skip は index で判定)
1191        bodies.push(RigidBody::new_dynamic(
1192            Vec3Fix::from_f32(1.2, 0.0, 0.0),
1193            Fix128::ONE,
1194        ));
1195        let c2 = detect_sdf_contacts(&bodies, &colliders, radius);
1196        let idxs2: Vec<usize> = c2.iter().map(|(i, _)| *i).collect();
1197        assert_eq!(idxs2, vec![0, 3, 5, 6, 6]);
1198        let sphere_contact = &c2[4].1;
1199        let d = sphere_contact.depth.to_f32();
1200        assert!((d - 0.3).abs() < 1e-5, "sphere depth {d}");
1201        let (nx, ny, nz) = sphere_contact.normal.to_f32();
1202        assert!((nx - 1.0).abs() < 1e-5 && ny.abs() < 1e-5 && nz.abs() < 1e-5);
1203
1204        // 半径 0 なら地面上 (y=0) の body は接触しない (penetration <= 0 → None)
1205        let none = detect_sdf_contacts(&bodies[3..4], &colliders[..1], Fix128::ZERO);
1206        assert!(none.is_empty());
1207        // collider なし → 空
1208        assert!(detect_sdf_contacts(&bodies, &[], radius).is_empty());
1209    }
1210}