Skip to main content

oxihuman_physics/
cloth.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cloth spring-mass simulation using Verlet integration.
5//!
6//! Simulates cloth behavior for clothing meshes with structural, shear,
7//! and bending springs so that clothing can drape realistically over a body.
8
9use std::collections::HashSet;
10
11// ── ClothParticle ─────────────────────────────────────────────────────────────
12
13/// A particle in the cloth simulation.
14#[derive(Debug, Clone)]
15pub struct ClothParticle {
16    /// Current world-space position.
17    pub position: [f32; 3],
18    /// Previous position (used by Verlet integration).
19    pub prev_position: [f32; 3],
20    /// Particle mass.
21    pub mass: f32,
22    /// When `true` the particle is anchored and never moves.
23    pub pinned: bool,
24}
25
26impl ClothParticle {
27    /// Create a new particle at `position` with the given `mass`.
28    pub fn new(position: [f32; 3], mass: f32) -> Self {
29        Self {
30            position,
31            prev_position: position,
32            mass,
33            pinned: false,
34        }
35    }
36
37    /// Builder helper: mark this particle as pinned.
38    pub fn pinned(mut self) -> Self {
39        self.pinned = true;
40        self
41    }
42}
43
44// ── SpringKind ────────────────────────────────────────────────────────────────
45
46/// The structural role of a spring in the cloth lattice.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum SpringKind {
49    /// Direct edge connections between adjacent vertices.
50    Structural,
51    /// Diagonal connections within a triangle face.
52    Shear,
53    /// Skip-one connections for resistance to out-of-plane bending.
54    Bending,
55}
56
57// ── Spring ────────────────────────────────────────────────────────────────────
58
59/// A spring connecting two particles.
60#[derive(Debug, Clone)]
61pub struct Spring {
62    /// Index of the first particle.
63    pub a: usize,
64    /// Index of the second particle.
65    pub b: usize,
66    /// Natural (rest) length of the spring.
67    pub rest_length: f32,
68    /// Stiffness coefficient in `[0, 1]`; higher values are stiffer.
69    pub stiffness: f32,
70    /// Structural role of this spring.
71    pub kind: SpringKind,
72}
73
74// ── ClothSim ──────────────────────────────────────────────────────────────────
75
76/// Cloth simulation state.
77pub struct ClothSim {
78    /// All particles in the cloth.
79    pub particles: Vec<ClothParticle>,
80    /// All springs connecting particles.
81    pub springs: Vec<Spring>,
82    /// Gravitational acceleration vector (world units / s²).
83    pub gravity: [f32; 3],
84    /// Velocity damping factor in `[0, 1]` applied each step; e.g. `0.99`.
85    pub damping: f32,
86}
87
88// ── helpers ───────────────────────────────────────────────────────────────────
89
90#[inline]
91fn dist3(a: [f32; 3], b: [f32; 3]) -> f32 {
92    let dx = b[0] - a[0];
93    let dy = b[1] - a[1];
94    let dz = b[2] - a[2];
95    (dx * dx + dy * dy + dz * dz).sqrt()
96}
97
98/// Canonical (smaller, larger) index pair for deduplication.
99#[inline]
100fn edge_key(i: usize, j: usize) -> (usize, usize) {
101    if i < j {
102        (i, j)
103    } else {
104        (j, i)
105    }
106}
107
108// ── ClothSim impl ─────────────────────────────────────────────────────────────
109
110impl ClothSim {
111    /// Build a cloth simulation from a mesh.
112    ///
113    /// * `positions` — vertex positions.
114    /// * `indices`   — triangle index list (every 3 indices form one triangle).
115    /// * `stiffness` — spring stiffness coefficient applied to all springs.
116    pub fn from_mesh(positions: &[[f32; 3]], indices: &[u32], stiffness: f32) -> Self {
117        // Build particles ──────────────────────────────────────────────────────
118        let particles: Vec<ClothParticle> = positions
119            .iter()
120            .map(|&p| ClothParticle::new(p, 1.0))
121            .collect();
122
123        let n_tris = indices.len() / 3;
124
125        // Track which edges/springs have been added so we don't duplicate ─────
126        let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
127        let mut springs: Vec<Spring> = Vec::new();
128
129        /// Add a spring if we haven't seen this edge before.
130        macro_rules! add_spring {
131            ($a:expr, $b:expr, $kind:expr) => {{
132                let key = edge_key($a, $b);
133                if edge_set.insert(key) {
134                    let rest_length = dist3(positions[$a], positions[$b]);
135                    if rest_length > 1e-10 {
136                        springs.push(Spring {
137                            a: $a,
138                            b: $b,
139                            rest_length,
140                            stiffness,
141                            kind: $kind,
142                        });
143                    }
144                }
145            }};
146        }
147
148        // ── Structural springs (triangle edges) ──────────────────────────────
149        for tri in 0..n_tris {
150            let i0 = indices[tri * 3] as usize;
151            let i1 = indices[tri * 3 + 1] as usize;
152            let i2 = indices[tri * 3 + 2] as usize;
153            add_spring!(i0, i1, SpringKind::Structural);
154            add_spring!(i1, i2, SpringKind::Structural);
155            add_spring!(i2, i0, SpringKind::Structural);
156        }
157
158        // ── Bending springs (opposite-vertex pairs sharing an edge) ──────────
159        // Build edge → list of triangles that contain the edge.
160        use std::collections::HashMap;
161        let mut edge_tris: HashMap<(usize, usize), Vec<[usize; 3]>> = HashMap::new();
162        for tri in 0..n_tris {
163            let i0 = indices[tri * 3] as usize;
164            let i1 = indices[tri * 3 + 1] as usize;
165            let i2 = indices[tri * 3 + 2] as usize;
166            let face = [i0, i1, i2];
167            for &(ea, eb) in &[(i0, i1), (i1, i2), (i2, i0)] {
168                edge_tris.entry(edge_key(ea, eb)).or_default().push(face);
169            }
170        }
171
172        for tris_for_edge in edge_tris.values() {
173            if tris_for_edge.len() == 2 {
174                let t0 = tris_for_edge[0];
175                let t1 = tris_for_edge[1];
176                // Find the vertex in t0 not shared with t1, and vice-versa.
177                let opp0 = t0.iter().find(|&&v| !t1.contains(&v)).copied();
178                let opp1 = t1.iter().find(|&&v| !t0.contains(&v)).copied();
179                if let (Some(a), Some(b)) = (opp0, opp1) {
180                    add_spring!(a, b, SpringKind::Bending);
181                }
182            }
183        }
184
185        Self {
186            particles,
187            springs,
188            gravity: [0.0, -9.81, 0.0],
189            damping: 0.99,
190        }
191    }
192
193    /// Pin all particles whose Y coordinate is at or above `y_threshold`.
194    pub fn pin_by_y(mut self, y_threshold: f32) -> Self {
195        for p in &mut self.particles {
196            if p.position[1] >= y_threshold {
197                p.pinned = true;
198            }
199        }
200        self
201    }
202
203    /// Advance the simulation by `dt` seconds with `sub_steps` Verlet sub-iterations.
204    pub fn step(&mut self, dt: f32, sub_steps: usize) {
205        let sub_dt = dt / sub_steps.max(1) as f32;
206
207        for _ in 0..sub_steps {
208            // ── Verlet integration ────────────────────────────────────────────
209            for p in &mut self.particles {
210                if p.pinned {
211                    continue;
212                }
213                let vx = p.position[0] - p.prev_position[0];
214                let vy = p.position[1] - p.prev_position[1];
215                let vz = p.position[2] - p.prev_position[2];
216
217                let new_x = p.position[0] + vx * self.damping + self.gravity[0] * sub_dt * sub_dt;
218                let new_y = p.position[1] + vy * self.damping + self.gravity[1] * sub_dt * sub_dt;
219                let new_z = p.position[2] + vz * self.damping + self.gravity[2] * sub_dt * sub_dt;
220
221                p.prev_position = p.position;
222                p.position = [new_x, new_y, new_z];
223            }
224
225            // ── Constraints ───────────────────────────────────────────────────
226            self.satisfy_springs();
227            self.apply_floor_constraint();
228        }
229    }
230
231    /// Return the current position of every particle.
232    pub fn positions(&self) -> Vec<[f32; 3]> {
233        self.particles.iter().map(|p| p.position).collect()
234    }
235
236    /// Prevent particles from falling below `y = 0`.
237    fn apply_floor_constraint(&mut self) {
238        for p in &mut self.particles {
239            if p.position[1] < 0.0 {
240                p.position[1] = 0.0;
241            }
242        }
243    }
244
245    /// One pass of Position-Based Dynamics spring constraint satisfaction.
246    fn satisfy_springs(&mut self) {
247        // We need split borrows; copy spring data to avoid borrow conflicts.
248        let springs: Vec<Spring> = self.springs.clone();
249
250        for s in &springs {
251            let pa = self.particles[s.a].position;
252            let pb = self.particles[s.b].position;
253
254            let dx = pb[0] - pa[0];
255            let dy = pb[1] - pa[1];
256            let dz = pb[2] - pa[2];
257            let dist = (dx * dx + dy * dy + dz * dz).sqrt();
258
259            if dist < 1e-10 {
260                continue;
261            }
262
263            let factor = (1.0 - s.rest_length / dist) * s.stiffness;
264            let cx = dx * factor;
265            let cy = dy * factor;
266            let cz = dz * factor;
267
268            if !self.particles[s.a].pinned {
269                self.particles[s.a].position[0] += cx * 0.5;
270                self.particles[s.a].position[1] += cy * 0.5;
271                self.particles[s.a].position[2] += cz * 0.5;
272            }
273            if !self.particles[s.b].pinned {
274                self.particles[s.b].position[0] -= cx * 0.5;
275                self.particles[s.b].position[1] -= cy * 0.5;
276                self.particles[s.b].position[2] -= cz * 0.5;
277            }
278        }
279    }
280}
281
282// ── Tests ─────────────────────────────────────────────────────────────────────
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn quad_cloth() -> ClothSim {
289        // 4-vertex quad split into 2 triangles, no pins.
290        let positions = vec![
291            [0.0f32, 1.0, 0.0],
292            [1.0, 1.0, 0.0],
293            [0.0, 0.0, 0.0],
294            [1.0, 0.0, 0.0],
295        ];
296        let indices = vec![0u32, 1, 2, 1, 3, 2];
297        ClothSim::from_mesh(&positions, &indices, 0.8)
298    }
299
300    #[test]
301    fn cloth_from_mesh_particle_count() {
302        assert_eq!(quad_cloth().particles.len(), 4);
303    }
304
305    #[test]
306    fn cloth_from_mesh_has_springs() {
307        assert!(!quad_cloth().springs.is_empty());
308    }
309
310    #[test]
311    fn cloth_step_moves_particles() {
312        let mut sim = quad_cloth();
313        let before: Vec<[f32; 3]> = sim.positions();
314        sim.step(0.016, 4);
315        let after: Vec<[f32; 3]> = sim.positions();
316        let moved = before.iter().zip(after.iter()).any(|(b, a)| b != a);
317        assert!(moved, "at least one particle should move under gravity");
318    }
319
320    #[test]
321    fn cloth_pinned_particles_dont_move() {
322        // Pin the two top vertices (y == 1.0).
323        let positions = vec![
324            [0.0f32, 1.0, 0.0],
325            [1.0, 1.0, 0.0],
326            [0.0, 0.0, 0.0],
327            [1.0, 0.0, 0.0],
328        ];
329        let indices = vec![0u32, 1, 2, 1, 3, 2];
330        let mut sim = ClothSim::from_mesh(&positions, &indices, 0.8).pin_by_y(1.0);
331
332        let pinned_before: Vec<[f32; 3]> = sim
333            .particles
334            .iter()
335            .filter(|p| p.pinned)
336            .map(|p| p.position)
337            .collect();
338
339        sim.step(0.016, 8);
340
341        let pinned_after: Vec<[f32; 3]> = sim
342            .particles
343            .iter()
344            .filter(|p| p.pinned)
345            .map(|p| p.position)
346            .collect();
347
348        assert_eq!(
349            pinned_before, pinned_after,
350            "pinned particles must not move"
351        );
352    }
353
354    #[test]
355    fn cloth_positions_returns_vec() {
356        let sim = quad_cloth();
357        assert_eq!(sim.positions().len(), sim.particles.len());
358    }
359
360    #[test]
361    fn cloth_spring_rest_length_positive() {
362        let sim = quad_cloth();
363        for s in &sim.springs {
364            assert!(
365                s.rest_length > 0.0,
366                "spring ({},{}) has rest_length <= 0",
367                s.a,
368                s.b
369            );
370        }
371    }
372}