Skip to main content

alice_physics/
rope.rs

1//! Rope / Cable Simulation (XPBD Distance Chain)
2//!
3//! Position-based rope using chained distance constraints.
4//! Supports SDF collision for wrapping around geometry.
5//!
6//! # Features
7//!
8//! - XPBD distance constraints between particles
9//! - SDF surface collision and friction
10//! - Configurable stiffness, damping, gravity
11//! - Pin constraints (attach endpoints to bodies or world)
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// Rope Configuration
26// ============================================================================
27
28/// Rope simulation configuration
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct RopeConfig {
31    /// Number of solver iterations per step
32    pub iterations: usize,
33    /// Number of substeps per step
34    pub substeps: usize,
35    /// Gravity vector
36    pub gravity: Vec3Fix,
37    /// Velocity retention per frame (`step()` call), applied once per frame since 1.2.0
38    pub damping: Fix128,
39    /// Distance constraint compliance (0 = rigid)
40    pub compliance: Fix128,
41    /// Coulomb friction coefficient against SDF surfaces: a contact takes at
42    /// most `sdf_friction` times its normal velocity change off the tangential
43    /// speed (independent of the substep count)
44    pub sdf_friction: Fix128,
45}
46
47impl Default for RopeConfig {
48    fn default() -> Self {
49        Self {
50            iterations: 8,
51            substeps: 4,
52            gravity: Vec3Fix::new(Fix128::ZERO, Fix128::from_int(-10), Fix128::ZERO),
53            damping: Fix128::from_ratio(99, 100),
54            compliance: Fix128::ZERO,
55            sdf_friction: Fix128::from_ratio(3, 10),
56        }
57    }
58}
59
60/// Pin constraint: attach a particle to a fixed point or body
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct PinConstraint {
63    /// Particle index
64    pub particle_index: usize,
65    /// Target position (world space)
66    pub target: Vec3Fix,
67    /// If Some, follow this body's position + offset
68    pub body_index: Option<usize>,
69    /// Offset from body position (local space)
70    pub local_offset: Vec3Fix,
71}
72
73// ============================================================================
74// Rope
75// ============================================================================
76
77/// Rope / cable simulation
78pub struct Rope {
79    /// Particle positions
80    pub positions: Vec<Vec3Fix>,
81    /// Previous positions (for Verlet integration)
82    pub prev_positions: Vec<Vec3Fix>,
83    /// Particle velocities (derived)
84    pub velocities: Vec<Vec3Fix>,
85    /// Inverse mass per particle (0 = pinned)
86    pub inv_masses: Vec<Fix128>,
87    /// Rest distance between consecutive particles
88    pub rest_lengths: Vec<Fix128>,
89    /// Pin constraints
90    pub pins: Vec<PinConstraint>,
91    /// Configuration
92    pub config: RopeConfig,
93    /// Total rope length
94    pub total_length: Fix128,
95}
96
97impl Rope {
98    /// Create a straight rope between two endpoints.
99    ///
100    /// # Panics
101    ///
102    /// Panics if `num_segments` is zero.
103    #[must_use]
104    pub fn new(start: Vec3Fix, end: Vec3Fix, num_segments: usize, mass_per_unit: Fix128) -> Self {
105        assert!(num_segments > 0, "Rope requires at least one segment");
106        let n = num_segments + 1; // number of particles
107        let total_length = (end - start).length();
108        let segment_length = total_length / Fix128::from_int(num_segments as i64);
109        // lumped masses: each segment's mass `mass_per_unit * segment_length` is
110        // split between its two particles, so interior particles carry one
111        // segment mass, the two ends half of one, and the total is
112        // `mass_per_unit * total_length`
113        let particle_mass = mass_per_unit * segment_length;
114        let inv_mass = if particle_mass.is_zero() {
115            Fix128::ZERO
116        } else {
117            Fix128::ONE / particle_mass
118        };
119        let end_inv_mass = inv_mass.double();
120
121        let mut positions = Vec::with_capacity(n);
122        for i in 0..n {
123            let t = Fix128::from_ratio(i as i64, num_segments as i64);
124            let p = Vec3Fix::new(
125                start.x + (end.x - start.x) * t,
126                start.y + (end.y - start.y) * t,
127                start.z + (end.z - start.z) * t,
128            );
129            positions.push(p);
130        }
131
132        let rest_lengths = vec![segment_length; num_segments];
133        let mut inv_masses = vec![inv_mass; n];
134        inv_masses[0] = end_inv_mass;
135        inv_masses[n - 1] = end_inv_mass;
136
137        Self {
138            prev_positions: positions.clone(),
139            velocities: vec![Vec3Fix::ZERO; n],
140            positions,
141            inv_masses,
142            rest_lengths,
143            pins: Vec::new(),
144            config: RopeConfig::default(),
145            total_length,
146        }
147    }
148
149    /// Number of particles
150    #[inline]
151    #[must_use]
152    pub fn particle_count(&self) -> usize {
153        self.positions.len()
154    }
155
156    /// Number of segments
157    #[inline]
158    #[must_use]
159    pub fn segment_count(&self) -> usize {
160        self.rest_lengths.len()
161    }
162
163    /// Add a pin constraint
164    pub fn add_pin(&mut self, pin: PinConstraint) {
165        self.inv_masses[pin.particle_index] = Fix128::ZERO;
166        self.pins.push(pin);
167    }
168
169    /// Pin the first particle to its current position
170    pub fn pin_start(&mut self) {
171        let pos = self.positions[0];
172        self.add_pin(PinConstraint {
173            particle_index: 0,
174            target: pos,
175            body_index: None,
176            local_offset: Vec3Fix::ZERO,
177        });
178    }
179
180    /// Pin the last particle to its current position
181    pub fn pin_end(&mut self) {
182        let last = self.particle_count() - 1;
183        let pos = self.positions[last];
184        self.add_pin(PinConstraint {
185            particle_index: last,
186            target: pos,
187            body_index: None,
188            local_offset: Vec3Fix::ZERO,
189        });
190    }
191
192    /// Update pin targets that track dynamic bodies.
193    ///
194    /// Call this before `step()` when pins are attached to moving bodies.
195    pub fn update_pin_targets(
196        &mut self,
197        body_positions: &[Vec3Fix],
198        body_rotations: &[crate::math::QuatFix],
199    ) {
200        for pin in &mut self.pins {
201            if let Some(idx) = pin.body_index {
202                if idx < body_positions.len() && idx < body_rotations.len() {
203                    pin.target =
204                        body_positions[idx] + body_rotations[idx].rotate_vec(pin.local_offset);
205                }
206            }
207        }
208    }
209
210    /// Step rope simulation
211    pub fn step(&mut self, dt: Fix128) {
212        let substep_dt = dt / Fix128::from_int(self.config.substeps as i64);
213
214        for _ in 0..self.config.substeps {
215            self.substep(substep_dt);
216        }
217        self.apply_frame_damping();
218    }
219
220    /// Step rope with SDF collision
221    #[cfg(feature = "std")]
222    pub fn step_with_sdf(&mut self, dt: Fix128, sdf_colliders: &[SdfCollider]) {
223        let substep_dt = dt / Fix128::from_int(self.config.substeps as i64);
224
225        for _ in 0..self.config.substeps {
226            self.substep(substep_dt);
227            self.resolve_sdf_collisions(sdf_colliders);
228        }
229        self.apply_frame_damping();
230    }
231
232    /// `config.damping` once per frame (velocity retention per `step()` call).
233    ///
234    /// 1.2.0: applied per substep before, which made the terminal velocity
235    /// depend on `substeps` (`g·h·d/(1−d)`, `h = dt/substeps`) — the same
236    /// defect as the rigid-body solver's frame damping fix.
237    fn apply_frame_damping(&mut self) {
238        let d = self.config.damping;
239        for v in &mut self.velocities {
240            *v = *v * d;
241        }
242    }
243
244    /// Single substep
245    fn substep(&mut self, dt: Fix128) {
246        let n = self.particle_count();
247
248        // 1. Predict positions (Verlet integration)
249        for i in 0..n {
250            if self.inv_masses[i].is_zero() {
251                continue;
252            }
253
254            self.prev_positions[i] = self.positions[i];
255            self.velocities[i] = self.velocities[i] + self.config.gravity * dt;
256            self.positions[i] = self.positions[i] + self.velocities[i] * dt;
257        }
258
259        // 2. Apply pin constraints (static targets; body-following is handled by update_pin_targets)
260        for pin in &self.pins {
261            self.positions[pin.particle_index] = pin.target;
262        }
263
264        // 3. Solve distance constraints
265        for _ in 0..self.config.iterations {
266            self.solve_distance_constraints(dt);
267        }
268
269        // 4. Update velocities
270        let inv_dt = Fix128::ONE / dt;
271        for i in 0..n {
272            if self.inv_masses[i].is_zero() {
273                continue;
274            }
275            self.velocities[i] = (self.positions[i] - self.prev_positions[i]) * inv_dt;
276        }
277    }
278
279    /// Solve distance constraints between consecutive particles
280    fn solve_distance_constraints(&mut self, dt: Fix128) {
281        for i in 0..self.segment_count() {
282            let p0 = self.positions[i];
283            let p1 = self.positions[i + 1];
284            let w0 = self.inv_masses[i];
285            let w1 = self.inv_masses[i + 1];
286
287            let w_sum = w0 + w1;
288            if w_sum.is_zero() {
289                continue;
290            }
291
292            let delta = p1 - p0;
293            let dist = delta.length();
294            if dist.is_zero() {
295                continue;
296            }
297
298            let rest = self.rest_lengths[i];
299            let error = dist - rest;
300
301            let compliance_term = self.config.compliance / (dt * dt);
302            let lambda = error / (w_sum + compliance_term);
303            let correction = delta / dist * lambda;
304
305            if !w0.is_zero() {
306                self.positions[i] = self.positions[i] + correction * w0;
307            }
308            if !w1.is_zero() {
309                self.positions[i + 1] = self.positions[i + 1] - correction * w1;
310            }
311        }
312    }
313
314    /// Resolve SDF collisions for all particles
315    #[cfg(feature = "std")]
316    fn resolve_sdf_collisions(&mut self, sdf_colliders: &[SdfCollider]) {
317        for i in 0..self.particle_count() {
318            if self.inv_masses[i].is_zero() {
319                continue;
320            }
321
322            for sdf in sdf_colliders {
323                let (lx, ly, lz) = sdf.world_to_local(self.positions[i]);
324                let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
325
326                if dist < 0.0 {
327                    // Penetrating: push out along gradient
328                    let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
329                    let normal = sdf.local_normal_to_world(nx, ny, nz);
330                    let depth = Fix128::from_f32(-dist);
331
332                    self.positions[i] = self.positions[i] + normal * depth;
333
334                    // Restitution 0.1 on an approaching normal velocity only; a
335                    // particle already moving out keeps it. Coulomb friction: the
336                    // tangential speed drops by at most `sdf_friction` times the
337                    // normal velocity change of the contact (stops at zero), so
338                    // the loss over a frame is `mu * (1 + e) * g * dt` for a
339                    // resting particle whatever the substep count.
340                    let vel = self.velocities[i];
341                    let vn_mag = vel.dot(normal);
342                    let vt = vel - normal * vn_mag;
343                    if vn_mag < Fix128::ZERO {
344                        let restitution = Fix128::from_ratio(1, 10);
345                        let dvn = -(Fix128::ONE + restitution) * vn_mag;
346                        let vt_mag = vt.length();
347                        let drop = self.config.sdf_friction * dvn;
348                        let vt_new = if vt_mag <= drop || vt_mag.is_zero() {
349                            Vec3Fix::ZERO
350                        } else {
351                            vt * ((vt_mag - drop) / vt_mag)
352                        };
353                        self.velocities[i] = normal * (-restitution * vn_mag) + vt_new;
354                    }
355                }
356            }
357        }
358    }
359
360    /// Get current rope length (sum of segment distances)
361    #[must_use]
362    pub fn current_length(&self) -> Fix128 {
363        let mut length = Fix128::ZERO;
364        for i in 0..self.segment_count() {
365            length = length + (self.positions[i + 1] - self.positions[i]).length();
366        }
367        length
368    }
369}
370
371impl core::fmt::Debug for Rope {
372    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
373        f.debug_struct("Rope")
374            .field(
375                "positions",
376                &format_args!("[{} items]", self.positions.len()),
377            )
378            .field(
379                "prev_positions",
380                &format_args!("[{} items]", self.prev_positions.len()),
381            )
382            .field(
383                "velocities",
384                &format_args!("[{} items]", self.velocities.len()),
385            )
386            .field(
387                "inv_masses",
388                &format_args!("[{} items]", self.inv_masses.len()),
389            )
390            .field(
391                "rest_lengths",
392                &format_args!("[{} items]", self.rest_lengths.len()),
393            )
394            .field("pins", &format_args!("[{} items]", self.pins.len()))
395            .field("config", &self.config)
396            .field("total_length", &self.total_length)
397            .finish()
398    }
399}
400
401// ============================================================================
402// Tests
403// ============================================================================
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_rope_creation() {
411        let rope = Rope::new(
412            Vec3Fix::ZERO,
413            Vec3Fix::from_int(10, 0, 0),
414            20,
415            Fix128::from_ratio(1, 10),
416        );
417
418        assert_eq!(rope.particle_count(), 21);
419        assert_eq!(rope.segment_count(), 20);
420    }
421
422    #[test]
423    fn test_rope_pins() {
424        let mut rope = Rope::new(
425            Vec3Fix::ZERO,
426            Vec3Fix::from_int(5, 0, 0),
427            10,
428            Fix128::from_ratio(1, 10),
429        );
430
431        rope.pin_start();
432        rope.pin_end();
433
434        assert_eq!(rope.pins.len(), 2);
435        assert!(rope.inv_masses[0].is_zero(), "Start should be pinned");
436        assert!(rope.inv_masses[10].is_zero(), "End should be pinned");
437    }
438
439    #[test]
440    fn test_rope_gravity() {
441        let mut rope = Rope::new(
442            Vec3Fix::ZERO,
443            Vec3Fix::from_int(5, 0, 0),
444            10,
445            Fix128::from_ratio(1, 10),
446        );
447        rope.pin_start();
448        rope.pin_end();
449
450        // Simulate for 1 second
451        let dt = Fix128::from_ratio(1, 60);
452        for _ in 0..60 {
453            rope.step(dt);
454        }
455
456        // Middle particles should sag below the endpoints
457        let mid = rope.positions[5];
458        assert!(mid.y < Fix128::ZERO, "Rope should sag under gravity");
459    }
460
461    #[test]
462    fn test_rope_length_preservation() {
463        let mut rope = Rope::new(
464            Vec3Fix::ZERO,
465            Vec3Fix::from_int(5, 0, 0),
466            10,
467            Fix128::from_ratio(1, 10),
468        );
469        rope.pin_start();
470        rope.pin_end();
471        rope.config.iterations = 16;
472
473        let initial_length = rope.total_length;
474
475        let dt = Fix128::from_ratio(1, 60);
476        for _ in 0..120 {
477            rope.step(dt);
478        }
479
480        let current_length = rope.current_length();
481        let error = (current_length - initial_length).abs();
482        let tolerance = initial_length * Fix128::from_ratio(1, 10); // 10% tolerance
483        assert!(
484            error < tolerance,
485            "Rope length should be approximately preserved: initial={:?}, current={:?}",
486            initial_length.to_f32(),
487            current_length.to_f32()
488        );
489    }
490
491    /// 原点中心の単位球 SDF (f32 sqrt のみ、det_math gate 対象外)
492    #[cfg(feature = "std")]
493    fn unit_sphere_collider() -> crate::sdf_collider::SdfCollider {
494        use crate::sdf_collider::{ClosureSdf, SdfCollider};
495        let field = ClosureSdf::new(
496            |x, y, z| (x * x + y * y + z * z).sqrt() - 1.0,
497            |x, y, z| {
498                let len = (x * x + y * y + z * z).sqrt();
499                if len > 1e-6 {
500                    (x / len, y / len, z / len)
501                } else {
502                    (0.0, 1.0, 0.0)
503                }
504            },
505        );
506        SdfCollider::new_static(
507            Box::new(field),
508            Vec3Fix::ZERO,
509            crate::math::QuatFix::IDENTITY,
510        )
511    }
512
513    /// 単位球からの符号付き距離 (f32 oracle)
514    #[cfg(feature = "std")]
515    fn sphere_dist(p: Vec3Fix) -> f32 {
516        let (x, y, z) = p.to_f32();
517        (x * x + y * y + z * z).sqrt() - 1.0
518    }
519
520    #[cfg(feature = "std")]
521    #[test]
522    fn step_with_sdf_keeps_free_rope_particles_outside_unit_sphere() {
523        // 長さ 2 の水平 rope (8 segment、粒子間 0.25) を y = 2 から球の上に落とす
524        let make = || {
525            Rope::new(
526                Vec3Fix::from_int(-1, 2, 0),
527                Vec3Fix::from_int(1, 2, 0),
528                8,
529                Fix128::ONE,
530            )
531        };
532        let sphere = [unit_sphere_collider()];
533        let mut rope = make();
534        let dt = Fix128::from_ratio(1, 60);
535        let mut min_dist = f32::MAX;
536        for frame in 0..60 {
537            rope.step_with_sdf(dt, &sphere);
538            for (i, p) in rope.positions.iter().enumerate() {
539                let d = sphere_dist(*p);
540                min_dist = min_dist.min(d);
541                assert!(
542                    d >= -1e-3,
543                    "frame {frame} particle {i} inside sphere: dist {d}"
544                );
545            }
546        }
547        assert!(min_dist < 0.05, "never touched: min dist {min_dist}");
548        // rope 長は保たれる (rest 2、compliance 0)
549        let len = rope.current_length().to_f32();
550        assert!((len - 2.0).abs() < 0.2, "length {len}");
551
552        // SDF なしでは球を貫通する frame が存在する
553        let mut free = make();
554        let mut free_min = f32::MAX;
555        for _ in 0..60 {
556            free.step(dt);
557            for p in &free.positions {
558                free_min = free_min.min(sphere_dist(*p));
559            }
560        }
561        assert!(
562            free_min < -0.1,
563            "without SDF the rope should pass through: {free_min}"
564        );
565
566        // pinned particle (inv_mass 0) は SDF に押されない: 球内に pin した start は動かない
567        let mut pinned = Rope::new(
568            Vec3Fix::from_f32(0.0, 0.5, 0.0),
569            Vec3Fix::from_int(0, 3, 0),
570            5,
571            Fix128::ONE,
572        );
573        pinned.pin_start();
574        for _ in 0..10 {
575            pinned.step_with_sdf(dt, &sphere);
576        }
577        assert_eq!(pinned.positions[0], Vec3Fix::from_f32(0.0, 0.5, 0.0));
578
579        // collider が空なら step と bit 一致
580        let mut a = make();
581        let mut b = make();
582        for _ in 0..10 {
583            a.step_with_sdf(dt, &[]);
584            b.step(dt);
585        }
586        assert_eq!(a.positions, b.positions);
587        assert_eq!(a.velocities, b.velocities);
588    }
589}