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}