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}