Skip to main content

alice_physics/
deformable.rs

1//! Deformable Body Simulation (FEM-XPBD)
2//!
3//! Tetrahedral mesh deformable bodies using XPBD volume + shape constraints.
4//! Supports SDF collision for deformable-rigid interaction.
5//!
6//! # Algorithm
7//!
8//! - Volume constraints preserve tetrahedral volume
9//! - Shape matching constraints resist deformation
10//! - Edge constraints prevent over-stretching
11//! - SDF collision for surface contacts
12//!
13//! Author: Moroya Sakamoto
14
15use crate::math::{Fix128, Vec3Fix};
16#[cfg(feature = "std")]
17use crate::sdf_collider::SdfCollider;
18
19#[cfg(not(feature = "std"))]
20use alloc::vec;
21#[cfg(not(feature = "std"))]
22use alloc::vec::Vec;
23
24// ============================================================================
25// Deformable Configuration
26// ============================================================================
27
28/// Deformable body configuration
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct DeformableConfig {
31    /// Solver iterations
32    pub iterations: usize,
33    /// Number of substeps
34    pub substeps: usize,
35    /// Gravity
36    pub gravity: Vec3Fix,
37    /// Velocity retention per frame (`step()` call), applied once per frame since 1.2.0
38    pub damping: Fix128,
39    /// Volume preservation compliance (0 = rigid volume)
40    pub volume_compliance: Fix128,
41    /// Edge stretch compliance
42    pub edge_compliance: Fix128,
43    /// Shape matching stiffness (0..1, 1 = rigid)
44    pub shape_stiffness: Fix128,
45    /// SDF collision friction
46    pub sdf_friction: Fix128,
47}
48
49impl Default for DeformableConfig {
50    fn default() -> Self {
51        Self {
52            iterations: 8,
53            substeps: 4,
54            gravity: Vec3Fix::new(Fix128::ZERO, Fix128::from_int(-10), Fix128::ZERO),
55            damping: Fix128::from_ratio(99, 100),
56            volume_compliance: Fix128::from_ratio(1, 1000),
57            edge_compliance: Fix128::ZERO,
58            shape_stiffness: Fix128::from_ratio(5, 10),
59            sdf_friction: Fix128::from_ratio(3, 10),
60        }
61    }
62}
63
64// ============================================================================
65// Constraints
66// ============================================================================
67
68/// Tetrahedral volume constraint
69#[derive(Clone, Copy, Debug, PartialEq)]
70struct TetConstraint {
71    /// Four vertex indices
72    indices: [usize; 4],
73    /// Rest volume (signed, preserves orientation)
74    rest_volume: Fix128,
75}
76
77/// Edge length constraint
78#[derive(Clone, Copy, Debug, PartialEq)]
79struct EdgeConstraint {
80    i0: usize,
81    i1: usize,
82    rest_length: Fix128,
83}
84
85// ============================================================================
86// Deformable Body
87// ============================================================================
88
89/// Deformable body simulation
90pub struct DeformableBody {
91    /// Particle positions
92    pub positions: Vec<Vec3Fix>,
93    /// Previous positions
94    prev_positions: Vec<Vec3Fix>,
95    /// Velocities
96    pub velocities: Vec<Vec3Fix>,
97    /// Inverse mass per particle
98    pub inv_masses: Vec<Fix128>,
99    /// Tetrahedra (4 vertex indices each)
100    pub tetrahedra: Vec<[usize; 4]>,
101    /// Surface triangles (for rendering/collision)
102    pub surface_triangles: Vec<[usize; 3]>,
103    /// Tetrahedral volume constraints
104    tet_constraints: Vec<TetConstraint>,
105    /// Edge constraints
106    edge_constraints: Vec<EdgeConstraint>,
107    /// Rest positions relative to center
108    rest_relative: Vec<Vec3Fix>,
109    /// Configuration
110    pub config: DeformableConfig,
111}
112
113impl DeformableBody {
114    /// Create from particle positions and tetrahedra
115    #[must_use]
116    pub fn new(
117        positions: Vec<Vec3Fix>,
118        tetrahedra: &[[usize; 4]],
119        surface_triangles: Vec<[usize; 3]>,
120        mass_per_particle: Fix128,
121    ) -> Self {
122        let n = positions.len();
123        let inv_mass = if mass_per_particle.is_zero() {
124            Fix128::ZERO
125        } else {
126            Fix128::ONE / mass_per_particle
127        };
128
129        // Compute rest shape
130        let rest_center = compute_center_of_mass(&positions);
131        let rest_relative: Vec<Vec3Fix> = positions.iter().map(|p| *p - rest_center).collect();
132
133        let mut body = Self {
134            prev_positions: positions.clone(),
135            velocities: vec![Vec3Fix::ZERO; n],
136            inv_masses: vec![inv_mass; n],
137            tetrahedra: tetrahedra.to_vec(),
138            surface_triangles,
139            tet_constraints: Vec::new(),
140            edge_constraints: Vec::new(),
141            rest_relative,
142            positions,
143            config: DeformableConfig::default(),
144        };
145
146        body.build_constraints();
147        body
148    }
149
150    /// Create a simple cube deformable (for testing)
151    #[must_use]
152    pub fn new_cube(center: Vec3Fix, half_extent: Fix128, mass: Fix128) -> Self {
153        let h = half_extent;
154        let positions = vec![
155            center + Vec3Fix::new(-h, -h, -h),
156            center + Vec3Fix::new(h, -h, -h),
157            center + Vec3Fix::new(h, h, -h),
158            center + Vec3Fix::new(-h, h, -h),
159            center + Vec3Fix::new(-h, -h, h),
160            center + Vec3Fix::new(h, -h, h),
161            center + Vec3Fix::new(h, h, h),
162            center + Vec3Fix::new(-h, h, h),
163        ];
164
165        // 5 tetrahedra to fill a cube
166        let tetrahedra = vec![
167            [0, 1, 3, 4],
168            [1, 2, 3, 6],
169            [1, 4, 5, 6],
170            [3, 4, 6, 7],
171            [1, 3, 4, 6],
172        ];
173
174        let surface_triangles = vec![
175            [0, 1, 2],
176            [0, 2, 3], // front
177            [4, 6, 5],
178            [4, 7, 6], // back
179            [0, 4, 5],
180            [0, 5, 1], // bottom
181            [2, 6, 7],
182            [2, 7, 3], // top
183            [0, 3, 7],
184            [0, 7, 4], // left
185            [1, 5, 6],
186            [1, 6, 2], // right
187        ];
188
189        let mass_per_particle = mass / Fix128::from_int(8);
190        Self::new(positions, &tetrahedra, surface_triangles, mass_per_particle)
191    }
192
193    /// Build constraints from geometry
194    fn build_constraints(&mut self) {
195        // Tetrahedral volume constraints (skip degenerate tetrahedra)
196        let min_volume = Fix128::from_ratio(1, 1000000); // 1e-6
197        for tet in &self.tetrahedra {
198            let rest_vol = compute_tet_volume(
199                self.positions[tet[0]],
200                self.positions[tet[1]],
201                self.positions[tet[2]],
202                self.positions[tet[3]],
203            );
204            if rest_vol.abs() < min_volume {
205                continue; // Skip degenerate (near-zero volume) tetrahedra
206            }
207            self.tet_constraints.push(TetConstraint {
208                indices: *tet,
209                rest_volume: rest_vol,
210            });
211        }
212
213        // Edge constraints: unique edges from tetrahedra
214        let mut edge_set: Vec<(usize, usize)> = Vec::new();
215        for tet in &self.tetrahedra {
216            let edges = [
217                (tet[0], tet[1]),
218                (tet[0], tet[2]),
219                (tet[0], tet[3]),
220                (tet[1], tet[2]),
221                (tet[1], tet[3]),
222                (tet[2], tet[3]),
223            ];
224            for (a, b) in edges {
225                let (lo, hi) = if a < b { (a, b) } else { (b, a) };
226                if !edge_set.contains(&(lo, hi)) {
227                    edge_set.push((lo, hi));
228                    let rest = (self.positions[a] - self.positions[b]).length();
229                    self.edge_constraints.push(EdgeConstraint {
230                        i0: a,
231                        i1: b,
232                        rest_length: rest,
233                    });
234                }
235            }
236        }
237    }
238
239    /// Number of particles
240    #[inline]
241    #[must_use]
242    pub fn particle_count(&self) -> usize {
243        self.positions.len()
244    }
245
246    /// Step simulation
247    pub fn step(&mut self, dt: Fix128) {
248        let substep_dt = dt / Fix128::from_int(self.config.substeps as i64);
249        for _ in 0..self.config.substeps {
250            self.substep(substep_dt);
251        }
252        self.apply_frame_damping();
253    }
254
255    /// Step with SDF collision
256    #[cfg(feature = "std")]
257    pub fn step_with_sdf(&mut self, dt: Fix128, sdf_colliders: &[SdfCollider]) {
258        let substep_dt = dt / Fix128::from_int(self.config.substeps as i64);
259        for _ in 0..self.config.substeps {
260            self.substep(substep_dt);
261            self.resolve_sdf_collisions(sdf_colliders);
262        }
263        self.apply_frame_damping();
264    }
265
266    /// `config.damping` once per frame (velocity retention per `step()` call).
267    ///
268    /// 1.2.0: applied per substep before, which made the terminal velocity
269    /// depend on `substeps` (`g·h·d/(1−d)`, `h = dt/substeps`) — the same
270    /// defect as the rigid-body solver's frame damping fix.
271    fn apply_frame_damping(&mut self) {
272        let d = self.config.damping;
273        for v in &mut self.velocities {
274            *v = *v * d;
275        }
276    }
277
278    /// Single substep
279    fn substep(&mut self, dt: Fix128) {
280        let n = self.particle_count();
281
282        // 1. Predict
283        for i in 0..n {
284            if self.inv_masses[i].is_zero() {
285                continue;
286            }
287            self.prev_positions[i] = self.positions[i];
288            self.velocities[i] = self.velocities[i] + self.config.gravity * dt;
289            self.positions[i] = self.positions[i] + self.velocities[i] * dt;
290        }
291
292        // 2. Solve constraints
293        for _ in 0..self.config.iterations {
294            self.solve_edge_constraints(dt);
295            self.solve_volume_constraints(dt);
296            self.apply_shape_matching();
297        }
298
299        // 3. Update velocities
300        let inv_dt = Fix128::ONE / dt;
301        for i in 0..n {
302            if self.inv_masses[i].is_zero() {
303                continue;
304            }
305            self.velocities[i] = (self.positions[i] - self.prev_positions[i]) * inv_dt;
306        }
307    }
308
309    /// Solve edge constraints
310    fn solve_edge_constraints(&mut self, dt: Fix128) {
311        let compliance = self.config.edge_compliance / (dt * dt);
312
313        for c_idx in 0..self.edge_constraints.len() {
314            let c = self.edge_constraints[c_idx];
315            let p0 = self.positions[c.i0];
316            let p1 = self.positions[c.i1];
317            let w0 = self.inv_masses[c.i0];
318            let w1 = self.inv_masses[c.i1];
319
320            let w_sum = w0 + w1 + compliance;
321            if w_sum.is_zero() {
322                continue;
323            }
324
325            let delta = p1 - p0;
326            let dist = delta.length();
327            if dist.is_zero() {
328                continue;
329            }
330
331            let error = dist - c.rest_length;
332            let lambda = error / w_sum;
333            let correction = delta / dist * lambda;
334
335            if !w0.is_zero() {
336                self.positions[c.i0] = self.positions[c.i0] + correction * w0;
337            }
338            if !w1.is_zero() {
339                self.positions[c.i1] = self.positions[c.i1] - correction * w1;
340            }
341        }
342    }
343
344    /// Solve tetrahedral volume constraints
345    fn solve_volume_constraints(&mut self, dt: Fix128) {
346        let compliance = self.config.volume_compliance / (dt * dt);
347
348        for c_idx in 0..self.tet_constraints.len() {
349            let c = self.tet_constraints[c_idx];
350            let [i0, i1, i2, i3] = c.indices;
351
352            let p0 = self.positions[i0];
353            let p1 = self.positions[i1];
354            let p2 = self.positions[i2];
355            let p3 = self.positions[i3];
356
357            let current_vol = compute_tet_volume(p0, p1, p2, p3);
358            let error = current_vol - c.rest_volume;
359
360            if error.abs() < Fix128::from_ratio(1, 10000) {
361                continue;
362            }
363
364            // Gradients of volume w.r.t. each vertex
365            let g0 = (p1 - p3).cross(p2 - p3);
366            let g1 = (p2 - p3).cross(p0 - p3);
367            let g2 = (p0 - p3).cross(p1 - p3);
368            let g3 = -(g0 + g1 + g2);
369
370            let w0 = self.inv_masses[i0];
371            let w1 = self.inv_masses[i1];
372            let w2 = self.inv_masses[i2];
373            let w3 = self.inv_masses[i3];
374
375            let denom = w0 * g0.length_squared()
376                + w1 * g1.length_squared()
377                + w2 * g2.length_squared()
378                + w3 * g3.length_squared()
379                + compliance;
380
381            if denom.is_zero() {
382                continue;
383            }
384
385            let lambda = error / denom;
386            let six = Fix128::from_int(6);
387
388            if !w0.is_zero() {
389                self.positions[i0] = self.positions[i0] - g0 * (lambda * w0 / six);
390            }
391            if !w1.is_zero() {
392                self.positions[i1] = self.positions[i1] - g1 * (lambda * w1 / six);
393            }
394            if !w2.is_zero() {
395                self.positions[i2] = self.positions[i2] - g2 * (lambda * w2 / six);
396            }
397            if !w3.is_zero() {
398                self.positions[i3] = self.positions[i3] - g3 * (lambda * w3 / six);
399            }
400        }
401    }
402
403    /// Shape matching: move particles toward their rest pose
404    fn apply_shape_matching(&mut self) {
405        let alpha = self.config.shape_stiffness;
406        if alpha.is_zero() {
407            return;
408        }
409
410        // Current center of mass
411        let current_center = compute_center_of_mass(&self.positions);
412
413        // LIMITATION(COV-SOFT-017): Simplified shape matching: blend toward rest shape
414        // Simplified shape matching: blend toward rest shape
415        for i in 0..self.particle_count() {
416            if self.inv_masses[i].is_zero() {
417                continue;
418            }
419
420            let target = current_center + self.rest_relative[i];
421            let delta = target - self.positions[i];
422            self.positions[i] = self.positions[i] + delta * alpha;
423        }
424    }
425
426    /// Resolve SDF collisions
427    #[cfg(feature = "std")]
428    fn resolve_sdf_collisions(&mut self, sdf_colliders: &[SdfCollider]) {
429        for i in 0..self.particle_count() {
430            if self.inv_masses[i].is_zero() {
431                continue;
432            }
433
434            for sdf in sdf_colliders {
435                let (lx, ly, lz) = sdf.world_to_local(self.positions[i]);
436                let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
437
438                if dist < 0.0 {
439                    let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
440                    let normal = sdf.local_normal_to_world(nx, ny, nz);
441                    let depth = Fix128::from_f32(-dist);
442                    self.positions[i] = self.positions[i] + normal * depth;
443
444                    let vel = self.velocities[i];
445                    let vn = normal * vel.dot(normal);
446                    let vt = vel - vn;
447                    self.velocities[i] = vt * (Fix128::ONE - self.config.sdf_friction);
448                }
449            }
450        }
451    }
452
453    /// Centroid of the particle positions. Every constructor gives all particles the same
454    /// mass, for which this is the centre of mass; it is **not** mass weighted if
455    /// `inv_masses` are edited to differ. Zero for a body without particles.
456    #[must_use]
457    pub fn center_of_mass(&self) -> Vec3Fix {
458        compute_center_of_mass(&self.positions)
459    }
460
461    /// Resolve collisions between deformable body particles and rigid bodies
462    ///
463    // LIMITATION(COV-SOFT-059): Treats each rigid body as a sphere with given radius for simplicity.
464    /// Treats each rigid body as a sphere with given radius for simplicity.
465    /// Applies two-way coupling: deformable particles are pushed out,
466    /// and rigid bodies receive reaction impulses.
467    ///
468    /// The correction and the (perfectly inelastic) normal impulse are shared by inverse
469    /// mass, `w_p / (w_p + w_b)` to the particle and `w_b / (w_p + w_b)` to the body; a body
470    /// with zero inverse mass (static, kinematic) takes none, so a particle pressed into it
471    /// ends exactly on its surface with zero approach speed. A particle exactly at the
472    /// centre of a body has no push direction and is left untouched; pinned particles
473    /// (zero inverse mass) are skipped. `dt` only gates the call (zero is a no-op).
474    pub fn resolve_rigid_body_collisions(
475        &mut self,
476        rigid_bodies: &mut [crate::solver::RigidBody],
477        body_radii: &[Fix128],
478        dt: Fix128,
479    ) {
480        if dt.is_zero() {
481            return;
482        }
483
484        for i in 0..self.particle_count() {
485            if self.inv_masses[i].is_zero() {
486                continue;
487            }
488
489            for (rb_idx, rb) in rigid_bodies.iter_mut().enumerate() {
490                if rb.is_static() && rb_idx >= body_radii.len() {
491                    continue;
492                }
493                let radius = if rb_idx < body_radii.len() {
494                    body_radii[rb_idx]
495                } else {
496                    Fix128::ONE
497                };
498
499                let delta = self.positions[i] - rb.position;
500                let dist_sq = delta.length_squared();
501                let r_sq = radius * radius;
502
503                if dist_sq < r_sq && !dist_sq.is_zero() {
504                    let dist = dist_sq.sqrt();
505                    let normal = delta / dist;
506                    let penetration = radius - dist;
507
508                    // Inverse-mass weights: a body with zero inverse mass (static or
509                    // kinematic) is infinitely heavy and takes no share, so the particle
510                    // is moved by the whole penetration and loses the whole approach
511                    // speed (no finite stand-in mass, which left `m_p / (m_p + 10^6)`
512                    // of the penetration and of the speed behind).
513                    let w_particle = self.inv_masses[i];
514                    let w_body = rb.inv_mass;
515                    let w_sum = w_particle + w_body;
516                    if w_sum.is_zero() {
517                        continue;
518                    }
519
520                    let particle_ratio = w_particle / w_sum;
521                    let rb_ratio = w_body / w_sum;
522
523                    // Position correction
524                    self.positions[i] = self.positions[i] + normal * (penetration * particle_ratio);
525
526                    if !rb.inv_mass.is_zero() {
527                        rb.position = rb.position - normal * (penetration * rb_ratio);
528                    }
529
530                    // Velocity response (inelastic along the normal)
531                    let rel_vel = self.velocities[i] - rb.velocity;
532                    let vel_normal = rel_vel.dot(normal);
533
534                    if vel_normal < Fix128::ZERO {
535                        let impulse_mag = -vel_normal / w_sum;
536                        let impulse = normal * impulse_mag;
537
538                        self.velocities[i] = self.velocities[i] + impulse * w_particle;
539                        if !rb.inv_mass.is_zero() {
540                            rb.velocity = rb.velocity - impulse * rb.inv_mass;
541                        }
542                    }
543                }
544            }
545        }
546    }
547}
548
549// ============================================================================
550// Helpers
551// ============================================================================
552
553/// Compute center of mass
554fn compute_center_of_mass(positions: &[Vec3Fix]) -> Vec3Fix {
555    if positions.is_empty() {
556        return Vec3Fix::ZERO;
557    }
558    let mut sum = Vec3Fix::ZERO;
559    for p in positions {
560        sum = sum + *p;
561    }
562    sum / Fix128::from_int(positions.len() as i64)
563}
564
565/// Compute signed volume of a tetrahedron
566fn compute_tet_volume(p0: Vec3Fix, p1: Vec3Fix, p2: Vec3Fix, p3: Vec3Fix) -> Fix128 {
567    let a = p1 - p0;
568    let b = p2 - p0;
569    let c = p3 - p0;
570    a.dot(b.cross(c)) / Fix128::from_int(6)
571}
572
573impl core::fmt::Debug for DeformableBody {
574    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
575        f.debug_struct("DeformableBody")
576            .field(
577                "positions",
578                &format_args!("[{} items]", self.positions.len()),
579            )
580            .field(
581                "prev_positions",
582                &format_args!("[{} items]", self.prev_positions.len()),
583            )
584            .field(
585                "velocities",
586                &format_args!("[{} items]", self.velocities.len()),
587            )
588            .field(
589                "inv_masses",
590                &format_args!("[{} items]", self.inv_masses.len()),
591            )
592            .field(
593                "tetrahedra",
594                &format_args!("[{} items]", self.tetrahedra.len()),
595            )
596            .field(
597                "surface_triangles",
598                &format_args!("[{} items]", self.surface_triangles.len()),
599            )
600            .field(
601                "tet_constraints",
602                &format_args!("[{} items]", self.tet_constraints.len()),
603            )
604            .field(
605                "edge_constraints",
606                &format_args!("[{} items]", self.edge_constraints.len()),
607            )
608            .field(
609                "rest_relative",
610                &format_args!("[{} items]", self.rest_relative.len()),
611            )
612            .field("config", &self.config)
613            .finish()
614    }
615}
616
617// ============================================================================
618// Tests
619// ============================================================================
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_deformable_cube() {
627        let body = DeformableBody::new_cube(Vec3Fix::ZERO, Fix128::ONE, Fix128::from_int(10));
628
629        assert_eq!(body.particle_count(), 8);
630        assert_eq!(body.tetrahedra.len(), 5);
631        assert!(!body.edge_constraints.is_empty());
632        assert!(!body.tet_constraints.is_empty());
633    }
634
635    #[test]
636    fn test_deformable_gravity() {
637        let mut body = DeformableBody::new_cube(
638            Vec3Fix::from_int(0, 5, 0),
639            Fix128::ONE,
640            Fix128::from_int(10),
641        );
642
643        let dt = Fix128::from_ratio(1, 60);
644        for _ in 0..60 {
645            body.step(dt);
646        }
647
648        let com = body.center_of_mass();
649        assert!(
650            com.y < Fix128::from_int(5),
651            "Deformable should fall under gravity"
652        );
653    }
654
655    #[test]
656    fn test_tet_volume() {
657        let vol = compute_tet_volume(
658            Vec3Fix::ZERO,
659            Vec3Fix::from_int(1, 0, 0),
660            Vec3Fix::from_int(0, 1, 0),
661            Vec3Fix::from_int(0, 0, 1),
662        );
663        // Volume of standard simplex = 1/6
664        let expected = Fix128::from_ratio(1, 6);
665        let error = (vol - expected).abs();
666        assert!(
667            error < Fix128::from_ratio(1, 100),
668            "Tet volume should be 1/6"
669        );
670    }
671
672    #[test]
673    fn test_center_of_mass() {
674        let positions = vec![
675            Vec3Fix::from_int(0, 0, 0),
676            Vec3Fix::from_int(2, 0, 0),
677            Vec3Fix::from_int(0, 2, 0),
678            Vec3Fix::from_int(0, 0, 2),
679        ];
680
681        let com = compute_center_of_mass(&positions);
682        let (cx, cy, cz) = com.to_f32();
683        assert!((cx - 0.5).abs() < 0.01);
684        assert!((cy - 0.5).abs() < 0.01);
685        assert!((cz - 0.5).abs() < 0.01);
686    }
687
688    #[test]
689    fn test_soft_rigid_coupling() {
690        use crate::solver::RigidBody;
691
692        let mut body = DeformableBody::new_cube(
693            Vec3Fix::from_int(0, 0, 0),
694            Fix128::ONE,
695            Fix128::from_int(10),
696        );
697
698        let mut rigid_bodies = vec![RigidBody::new(
699            Vec3Fix::from_int(0, 0, 0),
700            Fix128::from_int(5),
701        )];
702        let radii = vec![Fix128::from_int(3)]; // Large sphere overlapping the cube
703
704        let dt = Fix128::from_ratio(1, 60);
705        body.resolve_rigid_body_collisions(&mut rigid_bodies, &radii, dt);
706
707        // After collision resolution, some particles should have moved
708        // and the rigid body should have received a reaction
709        // This is a basic sanity test
710        let com = body.center_of_mass();
711        // The COM might have shifted due to collision response
712        assert!(
713            com.x.hi >= -10 && com.x.hi <= 10,
714            "COM should be reasonable"
715        );
716    }
717
718    /// 原点中心の単位球 SDF (f32 sqrt のみ、det_math gate 対象外)
719    #[cfg(feature = "std")]
720    fn unit_sphere_collider() -> crate::sdf_collider::SdfCollider {
721        use crate::sdf_collider::{ClosureSdf, SdfCollider};
722        let field = ClosureSdf::new(
723            |x, y, z| (x * x + y * y + z * z).sqrt() - 1.0,
724            |x, y, z| {
725                let len = (x * x + y * y + z * z).sqrt();
726                if len > 1e-6 {
727                    (x / len, y / len, z / len)
728                } else {
729                    (0.0, 1.0, 0.0)
730                }
731            },
732        );
733        SdfCollider::new_static(
734            Box::new(field),
735            Vec3Fix::ZERO,
736            crate::math::QuatFix::IDENTITY,
737        )
738    }
739
740    /// 単位球からの符号付き距離 (f32 oracle)
741    #[cfg(feature = "std")]
742    fn sphere_dist(p: Vec3Fix) -> f32 {
743        let (x, y, z) = p.to_f32();
744        (x * x + y * y + z * z).sqrt() - 1.0
745    }
746
747    #[cfg(feature = "std")]
748    #[test]
749    fn step_with_sdf_keeps_cube_particles_outside_unit_sphere() {
750        // 一辺 1 の cube (8 corner) を y = 2.5 から球の上に落とす
751        let make = || {
752            DeformableBody::new_cube(
753                Vec3Fix::new(Fix128::ZERO, Fix128::from_ratio(5, 2), Fix128::ZERO),
754                Fix128::from_ratio(1, 2),
755                Fix128::from_int(8),
756            )
757        };
758        let sphere = [unit_sphere_collider()];
759        let mut body = make();
760        let dt = Fix128::from_ratio(1, 60);
761        let mut min_dist = f32::MAX;
762        for frame in 0..60 {
763            body.step_with_sdf(dt, &sphere);
764            for (i, p) in body.positions.iter().enumerate() {
765                let d = sphere_dist(*p);
766                min_dist = min_dist.min(d);
767                assert!(
768                    d >= -1e-3,
769                    "frame {frame} particle {i} inside sphere: dist {d}"
770                );
771            }
772        }
773        assert!(min_dist < 0.05, "never touched: min dist {min_dist}");
774        // 重心は球の上 (y > 0) に留まる (貫通落下なら y < 0)
775        let (_, cy, _) = body.center_of_mass().to_f32();
776        assert!(cy > 0.5, "center of mass y {cy}");
777
778        // SDF なしでは球を貫通する frame が存在する
779        let mut free = make();
780        let mut free_min = f32::MAX;
781        for _ in 0..60 {
782            free.step(dt);
783            for p in &free.positions {
784                free_min = free_min.min(sphere_dist(*p));
785            }
786        }
787        assert!(
788            free_min < -0.1,
789            "without SDF the cube should pass through: {free_min}"
790        );
791
792        // collider が空なら step と bit 一致
793        let mut a = make();
794        let mut b = make();
795        for _ in 0..10 {
796            a.step_with_sdf(dt, &[]);
797            b.step(dt);
798        }
799        assert_eq!(a.positions, b.positions);
800        assert_eq!(a.velocities, b.velocities);
801    }
802}