Skip to main content

oxihuman_morph/
secondary_motion.rs

1//! Secondary motion — lag/follow-through for hair, clothing, and soft attachments.
2
3#[allow(dead_code)]
4pub struct SecondaryBone {
5    pub id: u32,
6    pub name: String,
7    pub position: [f32; 3],
8    pub velocity: [f32; 3],
9    pub target_position: [f32; 3],
10    pub stiffness: f32,
11    pub damping: f32,
12    pub mass: f32,
13}
14
15#[allow(dead_code)]
16pub struct SecondaryChain {
17    pub name: String,
18    pub bones: Vec<SecondaryBone>,
19    pub gravity: [f32; 3],
20    pub wind: [f32; 3],
21}
22
23#[allow(dead_code)]
24pub struct SecondaryMotionConfig {
25    pub gravity: [f32; 3],
26    pub default_stiffness: f32,
27    pub default_damping: f32,
28    pub default_mass: f32,
29}
30
31#[allow(dead_code)]
32pub fn default_secondary_config() -> SecondaryMotionConfig {
33    SecondaryMotionConfig {
34        gravity: [0.0, -9.81, 0.0],
35        default_stiffness: 50.0,
36        default_damping: 5.0,
37        default_mass: 1.0,
38    }
39}
40
41#[allow(dead_code)]
42pub fn new_secondary_bone(
43    id: u32,
44    name: &str,
45    pos: [f32; 3],
46    stiffness: f32,
47    damping: f32,
48) -> SecondaryBone {
49    SecondaryBone {
50        id,
51        name: name.to_string(),
52        position: pos,
53        velocity: [0.0, 0.0, 0.0],
54        target_position: pos,
55        stiffness,
56        damping,
57        mass: 1.0,
58    }
59}
60
61#[allow(dead_code)]
62pub fn new_secondary_chain(name: &str, gravity: [f32; 3]) -> SecondaryChain {
63    SecondaryChain {
64        name: name.to_string(),
65        bones: Vec::new(),
66        gravity,
67        wind: [0.0, 0.0, 0.0],
68    }
69}
70
71#[allow(dead_code)]
72pub fn add_secondary_bone(chain: &mut SecondaryChain, bone: SecondaryBone) {
73    chain.bones.push(bone);
74}
75
76#[allow(dead_code)]
77pub fn update_secondary_bone(bone: &mut SecondaryBone, dt: f32, external_force: [f32; 3]) {
78    // Spring-damper: F = k*(target-pos) - d*vel + ext
79    let spring = [
80        bone.stiffness * (bone.target_position[0] - bone.position[0]),
81        bone.stiffness * (bone.target_position[1] - bone.position[1]),
82        bone.stiffness * (bone.target_position[2] - bone.position[2]),
83    ];
84    let damp = [
85        bone.damping * bone.velocity[0],
86        bone.damping * bone.velocity[1],
87        bone.damping * bone.velocity[2],
88    ];
89    let force = [
90        spring[0] - damp[0] + external_force[0],
91        spring[1] - damp[1] + external_force[1],
92        spring[2] - damp[2] + external_force[2],
93    ];
94    let inv_mass = if bone.mass > 0.0 {
95        1.0 / bone.mass
96    } else {
97        1.0
98    };
99    bone.velocity[0] += force[0] * inv_mass * dt;
100    bone.velocity[1] += force[1] * inv_mass * dt;
101    bone.velocity[2] += force[2] * inv_mass * dt;
102    bone.position[0] += bone.velocity[0] * dt;
103    bone.position[1] += bone.velocity[1] * dt;
104    bone.position[2] += bone.velocity[2] * dt;
105}
106
107#[allow(dead_code)]
108pub fn update_secondary_chain(chain: &mut SecondaryChain, dt: f32, target_positions: &[[f32; 3]]) {
109    let gravity = chain.gravity;
110    let wind = chain.wind;
111    let external = [
112        gravity[0] + wind[0],
113        gravity[1] + wind[1],
114        gravity[2] + wind[2],
115    ];
116    for (i, bone) in chain.bones.iter_mut().enumerate() {
117        if i < target_positions.len() {
118            bone.target_position = target_positions[i];
119        }
120        update_secondary_bone(bone, dt, external);
121    }
122}
123
124#[allow(dead_code)]
125pub fn set_chain_wind(chain: &mut SecondaryChain, wind: [f32; 3]) {
126    chain.wind = wind;
127}
128
129#[allow(dead_code)]
130pub fn secondary_bone_count(chain: &SecondaryChain) -> usize {
131    chain.bones.len()
132}
133
134#[allow(dead_code)]
135pub fn chain_kinetic_energy(chain: &SecondaryChain) -> f32 {
136    chain.bones.iter().fold(0.0_f32, |acc, bone| {
137        let v2 = bone.velocity[0] * bone.velocity[0]
138            + bone.velocity[1] * bone.velocity[1]
139            + bone.velocity[2] * bone.velocity[2];
140        acc + 0.5 * bone.mass * v2
141    })
142}
143
144#[allow(dead_code)]
145pub fn reset_secondary_chain(chain: &mut SecondaryChain) {
146    for bone in chain.bones.iter_mut() {
147        bone.velocity = [0.0, 0.0, 0.0];
148    }
149}
150
151#[allow(dead_code)]
152pub fn secondary_chain_positions(chain: &SecondaryChain) -> Vec<[f32; 3]> {
153    chain.bones.iter().map(|b| b.position).collect()
154}
155
156#[allow(dead_code)]
157pub fn secondary_bone_lag(bone: &SecondaryBone) -> f32 {
158    let dx = bone.position[0] - bone.target_position[0];
159    let dy = bone.position[1] - bone.target_position[1];
160    let dz = bone.position[2] - bone.target_position[2];
161    (dx * dx + dy * dy + dz * dz).sqrt()
162}
163
164#[allow(dead_code)]
165pub fn blend_secondary_to_target(chain: &mut SecondaryChain, alpha: f32) {
166    let a = alpha.clamp(0.0, 1.0);
167    for bone in chain.bones.iter_mut() {
168        bone.position[0] = bone.position[0] + a * (bone.target_position[0] - bone.position[0]);
169        bone.position[1] = bone.position[1] + a * (bone.target_position[1] - bone.position[1]);
170        bone.position[2] = bone.position[2] + a * (bone.target_position[2] - bone.position[2]);
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_new_secondary_bone_fields() {
180        let bone = new_secondary_bone(1, "hair_tip", [1.0, 2.0, 3.0], 40.0, 4.0);
181        assert_eq!(bone.id, 1);
182        assert_eq!(bone.name, "hair_tip");
183        assert_eq!(bone.position, [1.0, 2.0, 3.0]);
184        assert_eq!(bone.velocity, [0.0, 0.0, 0.0]);
185        assert_eq!(bone.stiffness, 40.0);
186        assert_eq!(bone.damping, 4.0);
187    }
188
189    #[test]
190    fn test_new_secondary_chain_empty() {
191        let chain = new_secondary_chain("hair_chain", [0.0, -9.81, 0.0]);
192        assert_eq!(chain.name, "hair_chain");
193        assert!(chain.bones.is_empty());
194    }
195
196    #[test]
197    fn test_add_secondary_bone() {
198        let mut chain = new_secondary_chain("chain", [0.0, -9.81, 0.0]);
199        let bone = new_secondary_bone(0, "b0", [0.0, 0.0, 0.0], 50.0, 5.0);
200        add_secondary_bone(&mut chain, bone);
201        assert_eq!(chain.bones.len(), 1);
202    }
203
204    #[test]
205    fn test_secondary_bone_count() {
206        let mut chain = new_secondary_chain("c", [0.0, 0.0, 0.0]);
207        add_secondary_bone(&mut chain, new_secondary_bone(0, "b0", [0.0; 3], 1.0, 1.0));
208        add_secondary_bone(&mut chain, new_secondary_bone(1, "b1", [1.0; 3], 1.0, 1.0));
209        assert_eq!(secondary_bone_count(&chain), 2);
210    }
211
212    #[test]
213    fn test_update_secondary_bone_spring_force() {
214        let mut bone = new_secondary_bone(0, "b", [0.0, 0.0, 0.0], 100.0, 0.0);
215        bone.target_position = [1.0, 0.0, 0.0];
216        // F = k*(target-pos) - d*vel + ext = 100*(1-0) - 0 + 0 = 100
217        // a = F/m = 100
218        // vel += a*dt => vel = 100 * 0.01 = 1.0
219        // pos += vel*dt => pos = 1.0 * 0.01 = 0.01
220        update_secondary_bone(&mut bone, 0.01, [0.0, 0.0, 0.0]);
221        assert!((bone.velocity[0] - 1.0).abs() < 1e-4);
222        assert!((bone.position[0] - 0.01).abs() < 1e-4);
223    }
224
225    #[test]
226    fn test_update_secondary_bone_external_force() {
227        let mut bone = new_secondary_bone(0, "b", [0.0; 3], 0.0, 0.0);
228        // external force of 10 in Y, no spring or damping
229        update_secondary_bone(&mut bone, 0.1, [0.0, 10.0, 0.0]);
230        assert!((bone.velocity[1] - 1.0).abs() < 1e-4);
231    }
232
233    #[test]
234    fn test_chain_kinetic_energy_zero_velocity() {
235        let mut chain = new_secondary_chain("c", [0.0; 3]);
236        add_secondary_bone(&mut chain, new_secondary_bone(0, "b0", [0.0; 3], 1.0, 1.0));
237        assert_eq!(chain_kinetic_energy(&chain), 0.0);
238    }
239
240    #[test]
241    fn test_chain_kinetic_energy_nonzero() {
242        let mut chain = new_secondary_chain("c", [0.0; 3]);
243        let mut bone = new_secondary_bone(0, "b0", [0.0; 3], 1.0, 1.0);
244        bone.velocity = [2.0, 0.0, 0.0];
245        bone.mass = 1.0;
246        add_secondary_bone(&mut chain, bone);
247        // KE = 0.5 * 1.0 * 4.0 = 2.0
248        assert!((chain_kinetic_energy(&chain) - 2.0).abs() < 1e-5);
249    }
250
251    #[test]
252    fn test_reset_secondary_chain_zeros_velocity() {
253        let mut chain = new_secondary_chain("c", [0.0; 3]);
254        let mut bone = new_secondary_bone(0, "b0", [0.0; 3], 1.0, 1.0);
255        bone.velocity = [5.0, 3.0, 1.0];
256        add_secondary_bone(&mut chain, bone);
257        reset_secondary_chain(&mut chain);
258        assert_eq!(chain.bones[0].velocity, [0.0, 0.0, 0.0]);
259    }
260
261    #[test]
262    fn test_secondary_bone_lag() {
263        let mut bone = new_secondary_bone(0, "b", [0.0; 3], 1.0, 1.0);
264        bone.target_position = [3.0, 4.0, 0.0];
265        let lag = secondary_bone_lag(&bone);
266        assert!((lag - 5.0).abs() < 1e-5);
267    }
268
269    #[test]
270    fn test_secondary_bone_lag_zero() {
271        let bone = new_secondary_bone(0, "b", [1.0, 2.0, 3.0], 1.0, 1.0);
272        assert_eq!(secondary_bone_lag(&bone), 0.0);
273    }
274
275    #[test]
276    fn test_blend_secondary_to_target_full() {
277        let mut chain = new_secondary_chain("c", [0.0; 3]);
278        let mut bone = new_secondary_bone(0, "b0", [0.0; 3], 1.0, 1.0);
279        bone.target_position = [10.0, 0.0, 0.0];
280        add_secondary_bone(&mut chain, bone);
281        blend_secondary_to_target(&mut chain, 1.0);
282        assert!((chain.bones[0].position[0] - 10.0).abs() < 1e-5);
283    }
284
285    #[test]
286    fn test_blend_secondary_to_target_half() {
287        let mut chain = new_secondary_chain("c", [0.0; 3]);
288        let mut bone = new_secondary_bone(0, "b0", [0.0; 3], 1.0, 1.0);
289        bone.target_position = [10.0, 0.0, 0.0];
290        add_secondary_bone(&mut chain, bone);
291        blend_secondary_to_target(&mut chain, 0.5);
292        assert!((chain.bones[0].position[0] - 5.0).abs() < 1e-5);
293    }
294
295    #[test]
296    fn test_secondary_chain_positions() {
297        let mut chain = new_secondary_chain("c", [0.0; 3]);
298        add_secondary_bone(
299            &mut chain,
300            new_secondary_bone(0, "b0", [1.0, 2.0, 3.0], 1.0, 1.0),
301        );
302        add_secondary_bone(
303            &mut chain,
304            new_secondary_bone(1, "b1", [4.0, 5.0, 6.0], 1.0, 1.0),
305        );
306        let positions = secondary_chain_positions(&chain);
307        assert_eq!(positions.len(), 2);
308        assert_eq!(positions[0], [1.0, 2.0, 3.0]);
309        assert_eq!(positions[1], [4.0, 5.0, 6.0]);
310    }
311
312    #[test]
313    fn test_set_chain_wind() {
314        let mut chain = new_secondary_chain("c", [0.0; 3]);
315        set_chain_wind(&mut chain, [1.0, 0.0, 2.0]);
316        assert_eq!(chain.wind, [1.0, 0.0, 2.0]);
317    }
318
319    #[test]
320    fn test_update_secondary_chain_targets() {
321        let mut chain = new_secondary_chain("c", [0.0; 3]);
322        add_secondary_bone(&mut chain, new_secondary_bone(0, "b0", [0.0; 3], 10.0, 0.0));
323        let targets = [[1.0, 0.0, 0.0]];
324        update_secondary_chain(&mut chain, 0.016, &targets);
325        assert_eq!(chain.bones[0].target_position, [1.0, 0.0, 0.0]);
326    }
327
328    #[test]
329    fn test_default_secondary_config() {
330        let cfg = default_secondary_config();
331        assert!(cfg.default_stiffness > 0.0);
332        assert!(cfg.default_damping > 0.0);
333        assert!(cfg.default_mass > 0.0);
334        assert!(cfg.gravity[1] < 0.0);
335    }
336}
337
338// ---------------------------------------------------------------------------
339// XPBD Secondary-Motion Constraints
340// ---------------------------------------------------------------------------
341
342/// A constraint applied during the XPBD projection pass.
343pub enum SecondaryConstraint {
344    /// Fix a single particle to a world-space target position.
345    Pin { vertex_idx: usize, target: [f32; 3] },
346    /// Maintain a rest distance between two particles.
347    Length { a: usize, b: usize, rest_len: f32 },
348    /// Maintain an approximate volume for a group of particles via radial scaling.
349    Volume { vertices: Vec<usize>, rest_vol: f32 },
350}
351
352/// A single simulated point mass in the XPBD system.
353pub struct XpbdParticle {
354    pub pos: [f32; 3],
355    pub prev_pos: [f32; 3],
356    /// Inverse mass.  `0.0` means the particle is static / pinned by mass.
357    pub inv_mass: f32,
358}
359
360/// Full XPBD secondary-motion system containing particles and constraints.
361pub struct SecondaryMotionSystem {
362    pub particles: Vec<XpbdParticle>,
363    pub constraints: Vec<SecondaryConstraint>,
364    pub gravity: [f32; 3],
365    pub xpbd_iterations: u32,
366}
367
368impl SecondaryMotionSystem {
369    /// Create a new empty system with the given gravity vector.
370    pub fn new(gravity: [f32; 3]) -> Self {
371        SecondaryMotionSystem {
372            particles: Vec::new(),
373            constraints: Vec::new(),
374            gravity,
375            xpbd_iterations: 4,
376        }
377    }
378
379    /// Add a particle at `pos` with `inv_mass` (use `0.0` for a static particle).
380    pub fn add_particle(&mut self, pos: [f32; 3], inv_mass: f32) {
381        self.particles.push(XpbdParticle {
382            pos,
383            prev_pos: pos,
384            inv_mass,
385        });
386    }
387
388    /// Append a constraint to the system.
389    pub fn add_constraint(&mut self, c: SecondaryConstraint) {
390        self.constraints.push(c);
391    }
392
393    /// Advance the simulation by one time step `dt`.
394    ///
395    /// 1. Semi-implicit Euler (gravity + Verlet velocity integration).
396    /// 2. XPBD projection (`xpbd_iterations` passes): Pin, Length, Volume.
397    /// 3. `prev_pos` is already updated in step 1.
398    pub fn update(&mut self, dt: f32) {
399        // --- Step 1: Semi-implicit Euler ---------------------------------
400        for p in self.particles.iter_mut() {
401            if p.inv_mass <= 0.0 {
402                continue;
403            }
404            let velocity = vec3_sub(p.pos, p.prev_pos);
405            p.prev_pos = p.pos;
406            let grav_dt2 = [
407                self.gravity[0] * dt * dt,
408                self.gravity[1] * dt * dt,
409                self.gravity[2] * dt * dt,
410            ];
411            p.pos = vec3_add(vec3_add(p.pos, velocity), grav_dt2);
412        }
413
414        // --- Step 2: XPBD projection -------------------------------------
415        for _ in 0..self.xpbd_iterations {
416            // We need index-based access; collect indices to avoid borrow issues
417            let n_constraints = self.constraints.len();
418            for ci in 0..n_constraints {
419                // Safety: we use raw pointer arithmetic only inside the unsafe
420                // block to satisfy the borrow checker.  The indices are
421                // validated before use.
422                match &self.constraints[ci] {
423                    SecondaryConstraint::Pin { vertex_idx, target } => {
424                        let idx = *vertex_idx;
425                        let tgt = *target;
426                        if idx < self.particles.len() {
427                            let p = &mut self.particles[idx];
428                            if p.inv_mass > 0.0 {
429                                p.pos = tgt;
430                            }
431                        }
432                    }
433                    SecondaryConstraint::Length { a, b, rest_len } => {
434                        let (ia, ib, rl) = (*a, *b, *rest_len);
435                        if ia >= self.particles.len() || ib >= self.particles.len() {
436                            continue;
437                        }
438                        // Read current positions
439                        let pos_a = self.particles[ia].pos;
440                        let pos_b = self.particles[ib].pos;
441                        let inv_a = self.particles[ia].inv_mass;
442                        let inv_b = self.particles[ib].inv_mass;
443
444                        let delta = vec3_sub(pos_b, pos_a);
445                        let dist = vec3_len(delta);
446                        if dist < 1e-10 {
447                            continue;
448                        }
449                        let w_sum = inv_a + inv_b;
450                        if w_sum == 0.0 {
451                            continue;
452                        }
453                        let scale = (dist - rl) / dist;
454                        let correction = [delta[0] * scale, delta[1] * scale, delta[2] * scale];
455                        self.particles[ia].pos = vec3_add(
456                            self.particles[ia].pos,
457                            vec3_scale(correction, inv_a / w_sum),
458                        );
459                        self.particles[ib].pos = vec3_sub(
460                            self.particles[ib].pos,
461                            vec3_scale(correction, inv_b / w_sum),
462                        );
463                    }
464                    SecondaryConstraint::Volume { vertices, rest_vol } => {
465                        let indices: Vec<usize> = vertices.clone();
466                        let rv = *rest_vol;
467                        let n = indices.len();
468                        if n == 0 {
469                            continue;
470                        }
471
472                        // Compute centroid
473                        let mut centroid = [0.0_f32; 3];
474                        let mut valid_count = 0usize;
475                        for &vi in &indices {
476                            if vi < self.particles.len() {
477                                centroid = vec3_add(centroid, self.particles[vi].pos);
478                                valid_count += 1;
479                            }
480                        }
481                        if valid_count == 0 {
482                            continue;
483                        }
484                        let inv_n = 1.0 / valid_count as f32;
485                        centroid = vec3_scale(centroid, inv_n);
486
487                        // Approximate current "volume" as mean squared distance from centroid
488                        let mut mean_r2 = 0.0_f32;
489                        for &vi in &indices {
490                            if vi >= self.particles.len() {
491                                continue;
492                            }
493                            let d = vec3_sub(self.particles[vi].pos, centroid);
494                            mean_r2 += vec3_dot(d, d);
495                        }
496                        mean_r2 *= inv_n;
497
498                        if mean_r2 < 1e-12 {
499                            continue;
500                        }
501
502                        // rest_vol is treated as the rest mean-squared radius
503                        let ratio = (rv / mean_r2).sqrt();
504
505                        for &vi in &indices {
506                            if vi >= self.particles.len() {
507                                continue;
508                            }
509                            if self.particles[vi].inv_mass <= 0.0 {
510                                continue;
511                            }
512                            let offset = vec3_sub(self.particles[vi].pos, centroid);
513                            let new_offset = vec3_scale(offset, ratio);
514                            self.particles[vi].pos = vec3_add(centroid, new_offset);
515                        }
516                    }
517                }
518            }
519        }
520    }
521
522    /// Return a snapshot of all current particle positions.
523    pub fn particle_positions(&self) -> Vec<[f32; 3]> {
524        self.particles.iter().map(|p| p.pos).collect()
525    }
526
527    /// Detect particle pairs that are closer than `2 * collision_radius`.
528    ///
529    /// Uses O(n²) brute-force for n < 32, and a spatial hash grid for n ≥ 32.
530    pub fn detect_self_collisions(&self, collision_radius: f32) -> Vec<(usize, usize)> {
531        let n = self.particles.len();
532        let threshold = 2.0 * collision_radius;
533
534        if n < 32 {
535            // O(n²) brute-force
536            let mut pairs = Vec::new();
537            for i in 0..n {
538                for j in (i + 1)..n {
539                    let d = vec3_sub(self.particles[i].pos, self.particles[j].pos);
540                    if vec3_len(d) < threshold {
541                        pairs.push((i, j));
542                    }
543                }
544            }
545            pairs
546        } else {
547            // Spatial hash grid
548            spatial_hash_collisions(&self.particles, threshold)
549        }
550    }
551}
552
553// ---------------------------------------------------------------------------
554// Private spatial hash collision detection (used when n >= 32)
555// ---------------------------------------------------------------------------
556
557fn spatial_hash_collisions(particles: &[XpbdParticle], threshold: f32) -> Vec<(usize, usize)> {
558    use std::collections::HashMap;
559
560    let cell_size = threshold.max(1e-6);
561    let inv_cell = 1.0 / cell_size;
562
563    let cell_key = |pos: [f32; 3]| -> (i64, i64, i64) {
564        (
565            (pos[0] * inv_cell).floor() as i64,
566            (pos[1] * inv_cell).floor() as i64,
567            (pos[2] * inv_cell).floor() as i64,
568        )
569    };
570
571    // Build grid: cell -> list of particle indices
572    let mut grid: HashMap<(i64, i64, i64), Vec<usize>> = HashMap::new();
573    for (i, p) in particles.iter().enumerate() {
574        let key = cell_key(p.pos);
575        grid.entry(key).or_default().push(i);
576    }
577
578    let mut pairs: Vec<(usize, usize)> = Vec::new();
579    // For each particle, check its own cell and the 26 neighbouring cells
580    let offsets: &[(i64, i64, i64)] = &[
581        (-1, -1, -1),
582        (-1, -1, 0),
583        (-1, -1, 1),
584        (-1, 0, -1),
585        (-1, 0, 0),
586        (-1, 0, 1),
587        (-1, 1, -1),
588        (-1, 1, 0),
589        (-1, 1, 1),
590        (0, -1, -1),
591        (0, -1, 0),
592        (0, -1, 1),
593        (0, 0, -1),
594        (0, 0, 0),
595        (0, 0, 1),
596        (0, 1, -1),
597        (0, 1, 0),
598        (0, 1, 1),
599        (1, -1, -1),
600        (1, -1, 0),
601        (1, -1, 1),
602        (1, 0, -1),
603        (1, 0, 0),
604        (1, 0, 1),
605        (1, 1, -1),
606        (1, 1, 0),
607        (1, 1, 1),
608    ];
609
610    // Use a HashSet to deduplicate pairs
611    let mut seen: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
612
613    for (i, p) in particles.iter().enumerate() {
614        let (cx, cy, cz) = cell_key(p.pos);
615        for &(ox, oy, oz) in offsets {
616            let neighbour_key = (cx + ox, cy + oy, cz + oz);
617            if let Some(bucket) = grid.get(&neighbour_key) {
618                for &j in bucket {
619                    if i == j {
620                        continue;
621                    }
622                    let (lo, hi) = if i < j { (i, j) } else { (j, i) };
623                    if seen.contains(&(lo, hi)) {
624                        continue;
625                    }
626                    let d = vec3_sub(particles[i].pos, particles[j].pos);
627                    if vec3_len(d) < threshold {
628                        seen.insert((lo, hi));
629                        pairs.push((lo, hi));
630                    }
631                }
632            }
633        }
634    }
635
636    pairs.sort_unstable();
637    pairs
638}
639
640// ---------------------------------------------------------------------------
641// Inline vec3 helpers (private)
642// ---------------------------------------------------------------------------
643
644#[inline]
645fn vec3_add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
646    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
647}
648
649#[inline]
650fn vec3_sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
651    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
652}
653
654#[inline]
655fn vec3_scale(a: [f32; 3], s: f32) -> [f32; 3] {
656    [a[0] * s, a[1] * s, a[2] * s]
657}
658
659#[inline]
660fn vec3_len(a: [f32; 3]) -> f32 {
661    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
662}
663
664#[inline]
665fn vec3_dot(a: [f32; 3], b: [f32; 3]) -> f32 {
666    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
667}
668
669// ---------------------------------------------------------------------------
670// Tests for XPBD system
671// ---------------------------------------------------------------------------
672
673#[cfg(test)]
674mod xpbd_tests {
675    use super::*;
676
677    /// Pin constraint holds the target position after many steps.
678    #[test]
679    fn test_pin_holds_fixed() {
680        let mut sys = SecondaryMotionSystem::new([0.0, -9.8, 0.0]);
681        sys.add_particle([1.0, 2.0, 3.0], 1.0); // particle 0 — will be pinned
682        sys.add_particle([0.0, 0.0, 0.0], 0.0); // particle 1 — static mass
683        sys.add_constraint(SecondaryConstraint::Pin {
684            vertex_idx: 0,
685            target: [1.0, 2.0, 3.0],
686        });
687
688        for _ in 0..10 {
689            sys.update(0.016);
690        }
691
692        let pos = sys.particles[0].pos;
693        assert!(
694            (pos[0] - 1.0).abs() < 1e-5
695                && (pos[1] - 2.0).abs() < 1e-5
696                && (pos[2] - 3.0).abs() < 1e-5,
697            "Pinned particle drifted: {pos:?}"
698        );
699    }
700
701    /// Length constraint keeps rest distance within 5% after 30 steps.
702    #[test]
703    fn test_length_constraint_preserves_distance() {
704        let mut sys = SecondaryMotionSystem::new([0.0, 0.0, 0.0]);
705        sys.add_particle([0.0, 0.0, 0.0], 1.0);
706        sys.add_particle([2.0, 0.0, 0.0], 1.0); // initially 2.0 apart; rest is 1.0
707        sys.add_constraint(SecondaryConstraint::Length {
708            a: 0,
709            b: 1,
710            rest_len: 1.0,
711        });
712
713        for _ in 0..30 {
714            sys.update(0.016);
715        }
716
717        let p0 = sys.particles[0].pos;
718        let p1 = sys.particles[1].pos;
719        let d = vec3_len(vec3_sub(p1, p0));
720        assert!(
721            (d - 1.0).abs() < 0.05,
722            "Length {d} deviates more than 5% from rest_len=1.0"
723        );
724    }
725
726    /// Under gravity both particles drift down, but their mutual distance stays ≈1.0.
727    #[test]
728    fn test_no_stretch_under_gravity() {
729        let mut sys = SecondaryMotionSystem::new([0.0, -9.8, 0.0]);
730        sys.add_particle([0.0, 0.0, 0.0], 1.0);
731        sys.add_particle([0.0, 1.0, 0.0], 1.0);
732        sys.add_constraint(SecondaryConstraint::Length {
733            a: 0,
734            b: 1,
735            rest_len: 1.0,
736        });
737        // Raise iteration count so the constraint is well-satisfied under gravity
738        sys.xpbd_iterations = 20;
739
740        for _ in 0..30 {
741            sys.update(0.016);
742        }
743
744        let p0 = sys.particles[0].pos;
745        let p1 = sys.particles[1].pos;
746        let d = vec3_len(vec3_sub(p1, p0));
747        assert!(
748            (d - 1.0).abs() < 0.05,
749            "Distance under gravity {d} deviates more than 5% from 1.0"
750        );
751    }
752
753    /// Two nearby particles are detected as a collision pair.
754    #[test]
755    fn test_self_collision_detection() {
756        let mut sys = SecondaryMotionSystem::new([0.0, 0.0, 0.0]);
757        sys.add_particle([0.0, 0.0, 0.0], 1.0);
758        sys.add_particle([0.1, 0.0, 0.0], 1.0);
759
760        let pairs = sys.detect_self_collisions(0.15);
761        assert_eq!(pairs.len(), 1, "Expected 1 collision pair, got {:?}", pairs);
762        assert_eq!(pairs[0], (0, 1));
763    }
764}