Skip to main content

oxiphysics_gpu/
gpu_cloth.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU-accelerated cloth simulation (CPU mock implementation).
5//!
6//! Provides a complete cloth simulation pipeline including spring–damper edges,
7//! bending constraints, XPBD position solving, and collision response against
8//! analytic primitives (sphere, plane).
9
10use crate::{cross3, dot3, length3, normalize3};
11
12// ---------------------------------------------------------------------------
13// Helper math
14// ---------------------------------------------------------------------------
15
16#[inline]
17fn sub3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
18    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
19}
20
21#[inline]
22fn add3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
23    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
24}
25
26#[inline]
27fn scale3(v: [f64; 3], s: f64) -> [f64; 3] {
28    [v[0] * s, v[1] * s, v[2] * s]
29}
30
31// ---------------------------------------------------------------------------
32// triangle_area
33// ---------------------------------------------------------------------------
34
35/// Compute the area of a triangle defined by three 3-D vertices.
36///
37/// Uses the cross-product formula: `area = ||(v1-v0) × (v2-v0)|| / 2`.
38pub fn triangle_area(v0: [f64; 3], v1: [f64; 3], v2: [f64; 3]) -> f64 {
39    let e1 = sub3(v1, v0);
40    let e2 = sub3(v2, v0);
41    length3(cross3(e1, e2)) * 0.5
42}
43
44// ---------------------------------------------------------------------------
45// dihedral_angle
46// ---------------------------------------------------------------------------
47
48/// Compute the dihedral angle (in radians) between two triangles sharing edge
49/// `(v1, v2)`.
50///
51/// `v0` and `v3` are the "wing" vertices of each triangle respectively.
52/// Returns a value in `[0, π]`.
53pub fn dihedral_angle(v0: [f64; 3], v1: [f64; 3], v2: [f64; 3], v3: [f64; 3]) -> f64 {
54    let e = sub3(v2, v1);
55    let n1 = cross3(sub3(v0, v1), e);
56    let n2 = cross3(e, sub3(v3, v1));
57    let len1 = length3(n1);
58    let len2 = length3(n2);
59    if len1 < 1e-15 || len2 < 1e-15 {
60        return 0.0;
61    }
62    let cos_a = dot3(n1, n2) / (len1 * len2);
63    cos_a.clamp(-1.0, 1.0).acos()
64}
65
66// ---------------------------------------------------------------------------
67// ClothVertex
68// ---------------------------------------------------------------------------
69
70/// A single vertex (particle) in the cloth mesh.
71#[derive(Debug, Clone)]
72pub struct ClothVertex {
73    /// World-space position `[x, y, z]`.
74    pub position: [f64; 3],
75    /// World-space velocity `[vx, vy, vz]`.
76    pub velocity: [f64; 3],
77    /// Particle mass (kg).
78    pub mass: f64,
79    /// If `true` this vertex is fixed in space and not integrated.
80    pub pinned: bool,
81}
82
83impl ClothVertex {
84    /// Create a new vertex at `position` with zero velocity.
85    pub fn new(position: [f64; 3], mass: f64, pinned: bool) -> Self {
86        Self {
87            position,
88            velocity: [0.0; 3],
89            mass,
90            pinned,
91        }
92    }
93}
94
95// ---------------------------------------------------------------------------
96// ClothEdge
97// ---------------------------------------------------------------------------
98
99/// A structural spring edge connecting two cloth vertices.
100#[derive(Debug, Clone)]
101pub struct ClothEdge {
102    /// Index of the first vertex.
103    pub v0: usize,
104    /// Index of the second vertex.
105    pub v1: usize,
106    /// Rest length of the spring (m).
107    pub rest_length: f64,
108    /// Spring stiffness (N/m).
109    pub stiffness: f64,
110    /// Damping coefficient (N·s/m).
111    pub damping: f64,
112}
113
114impl ClothEdge {
115    /// Create a new edge.
116    pub fn new(v0: usize, v1: usize, rest_length: f64, stiffness: f64, damping: f64) -> Self {
117        Self {
118            v0,
119            v1,
120            rest_length,
121            stiffness,
122            damping,
123        }
124    }
125
126    /// Compute the spring force acting **on** vertex `v0` (force on `v1` is negated).
127    ///
128    /// Includes both Hooke's law and linear damping.
129    pub fn spring_force(&self, verts: &[ClothVertex]) -> [f64; 3] {
130        let p0 = verts[self.v0].position;
131        let p1 = verts[self.v1].position;
132        let vel0 = verts[self.v0].velocity;
133        let vel1 = verts[self.v1].velocity;
134
135        let delta = sub3(p1, p0);
136        let dist = length3(delta);
137        if dist < 1e-15 {
138            return [0.0; 3];
139        }
140        let dir = scale3(delta, 1.0 / dist);
141
142        // Relative velocity projected onto the edge direction
143        let rel_vel = dot3(sub3(vel1, vel0), dir);
144
145        let spring_f = self.stiffness * (dist - self.rest_length);
146        let damp_f = self.damping * rel_vel;
147        scale3(dir, spring_f + damp_f)
148    }
149}
150
151// ---------------------------------------------------------------------------
152// BendingConstraint
153// ---------------------------------------------------------------------------
154
155/// A bending constraint connecting four vertices that share a common edge.
156///
157/// The constraint resists deviation from the `rest_angle` dihedral angle.
158#[derive(Debug, Clone)]
159pub struct BendingConstraint {
160    /// First wing vertex index.
161    pub v0: usize,
162    /// Edge vertex index (first).
163    pub v1: usize,
164    /// Edge vertex index (second).
165    pub v2: usize,
166    /// Second wing vertex index.
167    pub v3: usize,
168    /// Rest dihedral angle (radians).
169    pub rest_angle: f64,
170    /// Bending stiffness (N·m/rad).
171    pub stiffness: f64,
172}
173
174impl BendingConstraint {
175    /// Create a new bending constraint.
176    pub fn new(
177        v0: usize,
178        v1: usize,
179        v2: usize,
180        v3: usize,
181        rest_angle: f64,
182        stiffness: f64,
183    ) -> Self {
184        Self {
185            v0,
186            v1,
187            v2,
188            v3,
189            rest_angle,
190            stiffness,
191        }
192    }
193
194    /// Compute restoring forces on the four vertices.
195    ///
196    /// Returns `[f_v0, f_v1, f_v2, f_v3]` — one `[f64;3]` force per vertex.
197    pub fn compute_force(&self, verts: &[ClothVertex]) -> Vec<[f64; 3]> {
198        let p0 = verts[self.v0].position;
199        let p1 = verts[self.v1].position;
200        let p2 = verts[self.v2].position;
201        let p3 = verts[self.v3].position;
202
203        let current_angle = dihedral_angle(p0, p1, p2, p3);
204        let angle_diff = current_angle - self.rest_angle;
205
206        if angle_diff.abs() < 1e-12 {
207            return vec![[0.0; 3]; 4];
208        }
209
210        // Gradient magnitude
211        let mag = self.stiffness * angle_diff;
212
213        // Edge and normals
214        let e = sub3(p2, p1);
215        let e_len = length3(e);
216        if e_len < 1e-15 {
217            return vec![[0.0; 3]; 4];
218        }
219
220        let n1 = cross3(sub3(p0, p1), e);
221        let n2 = cross3(e, sub3(p3, p1));
222        let len1 = length3(n1);
223        let len2 = length3(n2);
224        if len1 < 1e-15 || len2 < 1e-15 {
225            return vec![[0.0; 3]; 4];
226        }
227
228        // Gradient of the dihedral angle w.r.t. wing vertices
229        let g0 = scale3(normalize3(n1), -mag / len1);
230        let g3 = scale3(normalize3(n2), -mag / len2);
231
232        // Distribute to edge vertices proportionally
233        let g1 = scale3(add3(g0, g3), -0.5);
234        let g2 = scale3(add3(g0, g3), -0.5);
235
236        vec![g0, g1, g2, g3]
237    }
238}
239
240// ---------------------------------------------------------------------------
241// ClothMesh
242// ---------------------------------------------------------------------------
243
244/// The cloth mesh consisting of vertices and spring edges.
245#[derive(Debug, Clone)]
246pub struct ClothMesh {
247    /// Mesh vertices (particles).
248    pub vertices: Vec<ClothVertex>,
249    /// Spring edges.
250    pub edges: Vec<ClothEdge>,
251}
252
253impl ClothMesh {
254    /// Create an empty cloth mesh.
255    pub fn new() -> Self {
256        Self {
257            vertices: Vec::new(),
258            edges: Vec::new(),
259        }
260    }
261
262    /// Build a regular rectangular grid of `rows × cols` vertices with given
263    /// `spacing` (m) between adjacent vertices.
264    ///
265    /// The grid is laid out in the XZ plane (Y = 0).  The top row (`row == 0`)
266    /// is pinned.
267    pub fn build_grid(&mut self, rows: usize, cols: usize, spacing: f64) {
268        self.vertices.clear();
269        self.edges.clear();
270
271        // Create vertices
272        for r in 0..rows {
273            for c in 0..cols {
274                let pos = [c as f64 * spacing, 0.0, r as f64 * spacing];
275                let pinned = r == 0;
276                self.vertices.push(ClothVertex::new(pos, 1.0, pinned));
277            }
278        }
279
280        let idx = |r: usize, c: usize| r * cols + c;
281
282        // Structural edges (horizontal + vertical)
283        for r in 0..rows {
284            for c in 0..cols {
285                if c + 1 < cols {
286                    self.edges.push(ClothEdge::new(
287                        idx(r, c),
288                        idx(r, c + 1),
289                        spacing,
290                        1000.0,
291                        0.5,
292                    ));
293                }
294                if r + 1 < rows {
295                    self.edges.push(ClothEdge::new(
296                        idx(r, c),
297                        idx(r + 1, c),
298                        spacing,
299                        1000.0,
300                        0.5,
301                    ));
302                }
303            }
304        }
305
306        // Shear edges (diagonals)
307        let diag = spacing * std::f64::consts::SQRT_2;
308        for r in 0..rows {
309            for c in 0..cols {
310                if r + 1 < rows && c + 1 < cols {
311                    self.edges.push(ClothEdge::new(
312                        idx(r, c),
313                        idx(r + 1, c + 1),
314                        diag,
315                        500.0,
316                        0.2,
317                    ));
318                    self.edges.push(ClothEdge::new(
319                        idx(r + 1, c),
320                        idx(r, c + 1),
321                        diag,
322                        500.0,
323                        0.2,
324                    ));
325                }
326            }
327        }
328    }
329
330    /// Advance the simulation by one time step `dt` (seconds) with gravity `[gx, gy, gz]`.
331    ///
332    /// Uses symplectic Euler integration with explicit spring forces.
333    pub fn step(&mut self, dt: f64, gravity: [f64; 3]) {
334        let n = self.vertices.len();
335        let mut forces = vec![[0.0f64; 3]; n];
336
337        // Gravity
338        for (i, v) in self.vertices.iter().enumerate() {
339            if !v.pinned {
340                forces[i] = add3(forces[i], scale3(gravity, v.mass));
341            }
342        }
343
344        // Spring forces
345        for edge in &self.edges {
346            let f = edge.spring_force(&self.vertices);
347            if !self.vertices[edge.v0].pinned {
348                forces[edge.v0] = add3(forces[edge.v0], f);
349            }
350            if !self.vertices[edge.v1].pinned {
351                forces[edge.v1] = sub3(forces[edge.v1], f);
352            }
353        }
354
355        // Integrate
356        for (i, v) in self.vertices.iter_mut().enumerate() {
357            if v.pinned {
358                continue;
359            }
360            let inv_m = 1.0 / v.mass;
361            let accel = scale3(forces[i], inv_m);
362            v.velocity = add3(v.velocity, scale3(accel, dt));
363            v.position = add3(v.position, scale3(v.velocity, dt));
364        }
365    }
366}
367
368impl Default for ClothMesh {
369    fn default() -> Self {
370        Self::new()
371    }
372}
373
374// ---------------------------------------------------------------------------
375// ClothCollider
376// ---------------------------------------------------------------------------
377
378/// An analytic collision primitive for cloth–object interaction.
379#[derive(Debug, Clone)]
380pub enum ClothCollider {
381    /// Sphere collider.
382    Sphere {
383        /// Center of the sphere.
384        center: [f64; 3],
385        /// Radius of the sphere.
386        radius: f64,
387    },
388    /// Half-space plane collider.  Points on the side `dot(p, normal) >= d`
389    /// are outside.
390    Plane {
391        /// Outward-facing unit normal.
392        normal: [f64; 3],
393        /// Signed distance from the origin to the plane along the normal.
394        d: f64,
395    },
396}
397
398impl ClothCollider {
399    /// Test whether a point `p` is inside (penetrating) this collider.
400    ///
401    /// Returns the penetration depth (positive means inside) and the outward
402    /// push direction.  Returns `None` if there is no penetration.
403    pub fn penetration(&self, p: [f64; 3]) -> Option<(f64, [f64; 3])> {
404        match self {
405            ClothCollider::Sphere { center, radius } => {
406                let delta = sub3(p, *center);
407                let dist = length3(delta);
408                if dist < *radius {
409                    let depth = radius - dist;
410                    let dir = if dist < 1e-15 {
411                        [0.0, 1.0, 0.0]
412                    } else {
413                        scale3(delta, 1.0 / dist)
414                    };
415                    Some((depth, dir))
416                } else {
417                    None
418                }
419            }
420            ClothCollider::Plane { normal, d } => {
421                let signed = dot3(*normal, p) - d;
422                if signed < 0.0 {
423                    Some((-signed, *normal))
424                } else {
425                    None
426                }
427            }
428        }
429    }
430}
431
432// ---------------------------------------------------------------------------
433// GpuClothSolver
434// ---------------------------------------------------------------------------
435
436/// XPBD-style cloth solver (CPU mock implementation).
437///
438/// Wraps a `ClothMesh` together with a set of collision primitives and runs
439/// position-based dynamic iterations per time step.
440#[derive(Debug, Clone)]
441pub struct GpuClothSolver {
442    /// The cloth mesh being simulated.
443    pub mesh: ClothMesh,
444    /// Collision primitives.
445    pub colliders: Vec<ClothCollider>,
446    /// Number of XPBD constraint-projection iterations per time step.
447    pub xpbd_iterations: usize,
448}
449
450impl GpuClothSolver {
451    /// Create a new solver with the given mesh.
452    pub fn new(mesh: ClothMesh) -> Self {
453        Self {
454            mesh,
455            colliders: Vec::new(),
456            xpbd_iterations: 8,
457        }
458    }
459
460    /// Add a collision primitive to the solver.
461    pub fn add_collider(&mut self, collider: ClothCollider) {
462        self.colliders.push(collider);
463    }
464
465    /// Advance the simulation by `dt` seconds.
466    ///
467    /// 1. Integrates velocities and positions with gravity.
468    /// 2. Projects spring constraints (`xpbd_iterations` times).
469    /// 3. Resolves collisions.
470    pub fn solve(&mut self, dt: f64) {
471        let gravity = [0.0, -9.81, 0.0];
472
473        // --- 1. Semi-implicit Euler predict ---
474        let mut pred_pos: Vec<[f64; 3]> = self
475            .mesh
476            .vertices
477            .iter()
478            .map(|v| {
479                if v.pinned {
480                    v.position
481                } else {
482                    add3(
483                        v.position,
484                        scale3(add3(v.velocity, scale3(gravity, dt)), dt),
485                    )
486                }
487            })
488            .collect();
489
490        // --- 2. XPBD constraint projection ---
491        for _ in 0..self.xpbd_iterations {
492            for edge in &self.mesh.edges {
493                let i = edge.v0;
494                let j = edge.v1;
495                let pi = pred_pos[i];
496                let pj = pred_pos[j];
497                let delta = sub3(pj, pi);
498                let dist = length3(delta);
499                if dist < 1e-15 {
500                    continue;
501                }
502                let constraint = dist - edge.rest_length;
503                let dir = scale3(delta, 1.0 / dist);
504
505                let wi = if self.mesh.vertices[i].pinned {
506                    0.0
507                } else {
508                    1.0 / self.mesh.vertices[i].mass
509                };
510                let wj = if self.mesh.vertices[j].pinned {
511                    0.0
512                } else {
513                    1.0 / self.mesh.vertices[j].mass
514                };
515                let w_total = wi + wj;
516                if w_total < 1e-15 {
517                    continue;
518                }
519
520                let alpha = 1.0 / (edge.stiffness * dt * dt);
521                let lambda = -constraint / (w_total + alpha);
522
523                if !self.mesh.vertices[i].pinned {
524                    pred_pos[i] = sub3(pred_pos[i], scale3(dir, wi * lambda));
525                }
526                if !self.mesh.vertices[j].pinned {
527                    pred_pos[j] = add3(pred_pos[j], scale3(dir, wj * lambda));
528                }
529            }
530        }
531
532        // --- 3. Collision resolution ---
533        for collider in &self.colliders {
534            for (i, pred) in pred_pos.iter_mut().enumerate() {
535                if self.mesh.vertices[i].pinned {
536                    continue;
537                }
538                if let Some((depth, dir)) = collider.penetration(*pred) {
539                    *pred = add3(*pred, scale3(dir, depth));
540                }
541            }
542        }
543
544        // --- 4. Update velocities and positions ---
545        for (i, &pred) in pred_pos.iter().enumerate() {
546            if !self.mesh.vertices[i].pinned {
547                let old_pos = self.mesh.vertices[i].position;
548                self.mesh.vertices[i].velocity = scale3(sub3(pred, old_pos), 1.0 / dt);
549                self.mesh.vertices[i].position = pred;
550            }
551        }
552    }
553}
554
555// ---------------------------------------------------------------------------
556// Tests
557// ---------------------------------------------------------------------------
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use std::f64::consts::PI;
563
564    // --- triangle_area ---
565
566    #[test]
567    fn test_triangle_area_unit() {
568        let v0 = [0.0, 0.0, 0.0];
569        let v1 = [1.0, 0.0, 0.0];
570        let v2 = [0.0, 1.0, 0.0];
571        let area = triangle_area(v0, v1, v2);
572        assert!((area - 0.5).abs() < 1e-12, "area={area}");
573    }
574
575    #[test]
576    fn test_triangle_area_degenerate() {
577        // Collinear points → area == 0
578        let v0 = [0.0, 0.0, 0.0];
579        let v1 = [1.0, 0.0, 0.0];
580        let v2 = [2.0, 0.0, 0.0];
581        assert!(triangle_area(v0, v1, v2) < 1e-12);
582    }
583
584    #[test]
585    fn test_triangle_area_equilateral() {
586        // Side = 2 → area = sqrt(3)
587        let v0 = [0.0, 0.0, 0.0];
588        let v1 = [2.0, 0.0, 0.0];
589        let v2 = [1.0, f64::sqrt(3.0), 0.0];
590        let expected = f64::sqrt(3.0);
591        assert!((triangle_area(v0, v1, v2) - expected).abs() < 1e-10);
592    }
593
594    #[test]
595    fn test_triangle_area_3d() {
596        // Right triangle in 3-D: legs along X and Z of length 1
597        let v0 = [0.0, 0.0, 0.0];
598        let v1 = [1.0, 0.0, 0.0];
599        let v2 = [0.0, 0.0, 1.0];
600        let area = triangle_area(v0, v1, v2);
601        assert!((area - 0.5).abs() < 1e-12);
602    }
603
604    #[test]
605    fn test_triangle_area_large() {
606        let v0 = [0.0, 0.0, 0.0];
607        let v1 = [10.0, 0.0, 0.0];
608        let v2 = [0.0, 10.0, 0.0];
609        let area = triangle_area(v0, v1, v2);
610        assert!((area - 50.0).abs() < 1e-10);
611    }
612
613    // --- dihedral_angle ---
614
615    #[test]
616    fn test_dihedral_angle_flat() {
617        // Two triangles coplanar in XZ → angle = 0
618        let v0 = [0.0, 0.0, -1.0];
619        let v1 = [-1.0, 0.0, 0.0];
620        let v2 = [1.0, 0.0, 0.0];
621        let v3 = [0.0, 0.0, 1.0];
622        let angle = dihedral_angle(v0, v1, v2, v3);
623        assert!(angle < 1e-10, "angle={angle}");
624    }
625
626    #[test]
627    fn test_dihedral_angle_ninety_degrees() {
628        // Second triangle folded 90° out of plane
629        let v0 = [0.0, 1.0, 0.0];
630        let v1 = [0.0, 0.0, 0.0];
631        let v2 = [1.0, 0.0, 0.0];
632        let v3 = [0.5, 0.0, 1.0];
633        let angle = dihedral_angle(v0, v1, v2, v3);
634        // Should be close to π/2
635        assert!((angle - PI / 2.0).abs() < 0.3, "angle={angle}");
636    }
637
638    #[test]
639    fn test_dihedral_angle_degenerate_edge() {
640        // v1 == v2 → degenerate, should not panic
641        let v0 = [0.0, 1.0, 0.0];
642        let v1 = [0.0, 0.0, 0.0];
643        let v2 = [0.0, 0.0, 0.0];
644        let v3 = [0.0, -1.0, 0.0];
645        let angle = dihedral_angle(v0, v1, v2, v3);
646        assert!(angle.is_finite());
647    }
648
649    #[test]
650    fn test_dihedral_angle_range() {
651        let v0 = [1.0, 1.0, 0.0];
652        let v1 = [0.0, 0.0, 0.0];
653        let v2 = [1.0, 0.0, 0.0];
654        let v3 = [1.0, -1.0, 0.0];
655        let angle = dihedral_angle(v0, v1, v2, v3);
656        assert!((0.0..=PI + 1e-10).contains(&angle), "angle={angle}");
657    }
658
659    // --- ClothVertex ---
660
661    #[test]
662    fn test_cloth_vertex_new() {
663        let v = ClothVertex::new([1.0, 2.0, 3.0], 2.5, false);
664        assert_eq!(v.position, [1.0, 2.0, 3.0]);
665        assert_eq!(v.velocity, [0.0; 3]);
666        assert!((v.mass - 2.5).abs() < 1e-12);
667        assert!(!v.pinned);
668    }
669
670    #[test]
671    fn test_cloth_vertex_pinned() {
672        let v = ClothVertex::new([0.0; 3], 1.0, true);
673        assert!(v.pinned);
674    }
675
676    // --- ClothEdge spring force ---
677
678    #[test]
679    fn test_spring_force_at_rest() {
680        let verts = vec![
681            ClothVertex::new([0.0, 0.0, 0.0], 1.0, false),
682            ClothVertex::new([1.0, 0.0, 0.0], 1.0, false),
683        ];
684        let edge = ClothEdge::new(0, 1, 1.0, 1000.0, 0.5);
685        let f = edge.spring_force(&verts);
686        // At rest length, spring force is zero (and zero relative velocity)
687        assert!(length3(f) < 1e-10, "f={f:?}");
688    }
689
690    #[test]
691    fn test_spring_force_stretched() {
692        let verts = vec![
693            ClothVertex::new([0.0, 0.0, 0.0], 1.0, false),
694            ClothVertex::new([2.0, 0.0, 0.0], 1.0, false),
695        ];
696        // rest = 1, stretched to 2 → force pulls v0 toward v1
697        let edge = ClothEdge::new(0, 1, 1.0, 1000.0, 0.0);
698        let f = edge.spring_force(&verts);
699        assert!(f[0] > 0.0, "force should be positive (toward v1)");
700        assert!(f[1].abs() < 1e-12);
701        assert!(f[2].abs() < 1e-12);
702    }
703
704    #[test]
705    fn test_spring_force_compressed() {
706        let verts = vec![
707            ClothVertex::new([0.0, 0.0, 0.0], 1.0, false),
708            ClothVertex::new([0.5, 0.0, 0.0], 1.0, false),
709        ];
710        // rest = 1, compressed to 0.5 → force pushes v0 away
711        let edge = ClothEdge::new(0, 1, 1.0, 1000.0, 0.0);
712        let f = edge.spring_force(&verts);
713        assert!(f[0] < 0.0, "force should be negative (push back)");
714    }
715
716    #[test]
717    fn test_spring_force_with_damping() {
718        let mut v0 = ClothVertex::new([0.0, 0.0, 0.0], 1.0, false);
719        let mut v1 = ClothVertex::new([2.0, 0.0, 0.0], 1.0, false);
720        v0.velocity = [-1.0, 0.0, 0.0];
721        v1.velocity = [1.0, 0.0, 0.0];
722        let verts = vec![v0, v1];
723        let edge = ClothEdge::new(0, 1, 1.0, 0.0, 10.0); // only damping
724        let f = edge.spring_force(&verts);
725        // Relative velocity along edge: (1 - (-1)) = 2, damping force = 10 * 2 = 20
726        assert!((f[0] - 20.0).abs() < 1e-10, "f={f:?}");
727    }
728
729    // --- ClothMesh ---
730
731    #[test]
732    fn test_build_grid_vertex_count() {
733        let mut mesh = ClothMesh::new();
734        mesh.build_grid(4, 5, 0.1);
735        assert_eq!(mesh.vertices.len(), 20);
736    }
737
738    #[test]
739    fn test_build_grid_top_row_pinned() {
740        let mut mesh = ClothMesh::new();
741        mesh.build_grid(4, 5, 0.1);
742        for c in 0..5 {
743            assert!(
744                mesh.vertices[c].pinned,
745                "vertex {c} in row 0 should be pinned"
746            );
747        }
748        for r in 1..4 {
749            for c in 0..5 {
750                assert!(!mesh.vertices[r * 5 + c].pinned);
751            }
752        }
753    }
754
755    #[test]
756    fn test_build_grid_spacing() {
757        let mut mesh = ClothMesh::new();
758        mesh.build_grid(2, 2, 0.5);
759        // Horizontal neighbor distance
760        let d = sub3(mesh.vertices[1].position, mesh.vertices[0].position);
761        assert!((length3(d) - 0.5).abs() < 1e-12);
762    }
763
764    #[test]
765    fn test_mesh_step_gravity() {
766        let mut mesh = ClothMesh::new();
767        mesh.build_grid(2, 1, 1.0);
768        // vertex 1 is not pinned
769        let y_before = mesh.vertices[1].position[1];
770        mesh.step(0.01, [0.0, -9.81, 0.0]);
771        let y_after = mesh.vertices[1].position[1];
772        assert!(y_after < y_before, "unpinned vertex should fall");
773    }
774
775    #[test]
776    fn test_mesh_step_pinned_unchanged() {
777        let mut mesh = ClothMesh::new();
778        mesh.build_grid(2, 1, 1.0);
779        let pos_before = mesh.vertices[0].position;
780        mesh.step(0.01, [0.0, -9.81, 0.0]);
781        assert_eq!(mesh.vertices[0].position, pos_before);
782    }
783
784    #[test]
785    fn test_mesh_default() {
786        let mesh = ClothMesh::default();
787        assert!(mesh.vertices.is_empty());
788        assert!(mesh.edges.is_empty());
789    }
790
791    // --- BendingConstraint ---
792
793    #[test]
794    fn test_bending_at_rest_angle() {
795        // Compute rest angle, then check zero force
796        let p0 = [0.0, 1.0, 0.0];
797        let p1 = [0.0, 0.0, 0.0];
798        let p2 = [1.0, 0.0, 0.0];
799        let p3 = [1.0, 0.0, -1.0];
800
801        let rest = dihedral_angle(p0, p1, p2, p3);
802
803        let verts = vec![
804            ClothVertex::new(p0, 1.0, false),
805            ClothVertex::new(p1, 1.0, false),
806            ClothVertex::new(p2, 1.0, false),
807            ClothVertex::new(p3, 1.0, false),
808        ];
809
810        let bc = BendingConstraint::new(0, 1, 2, 3, rest, 100.0);
811        let forces = bc.compute_force(&verts);
812        for f in &forces {
813            assert!(length3(*f) < 1e-8, "force should be ~zero at rest");
814        }
815    }
816
817    #[test]
818    fn test_bending_constraint_forces_len() {
819        let verts: Vec<ClothVertex> = (0..4)
820            .map(|i| ClothVertex::new([i as f64, 0.0, 0.0], 1.0, false))
821            .collect();
822        let bc = BendingConstraint::new(0, 1, 2, 3, 0.0, 10.0);
823        let forces = bc.compute_force(&verts);
824        assert_eq!(forces.len(), 4);
825    }
826
827    // --- ClothCollider ---
828
829    #[test]
830    fn test_sphere_collider_inside() {
831        let col = ClothCollider::Sphere {
832            center: [0.0; 3],
833            radius: 1.0,
834        };
835        let (depth, _dir) = col.penetration([0.5, 0.0, 0.0]).unwrap();
836        assert!((depth - 0.5).abs() < 1e-10);
837    }
838
839    #[test]
840    fn test_sphere_collider_outside() {
841        let col = ClothCollider::Sphere {
842            center: [0.0; 3],
843            radius: 1.0,
844        };
845        assert!(col.penetration([2.0, 0.0, 0.0]).is_none());
846    }
847
848    #[test]
849    fn test_sphere_collider_direction() {
850        let col = ClothCollider::Sphere {
851            center: [0.0; 3],
852            radius: 2.0,
853        };
854        let (_depth, dir) = col.penetration([1.0, 0.0, 0.0]).unwrap();
855        assert!((dir[0] - 1.0).abs() < 1e-10);
856    }
857
858    #[test]
859    fn test_sphere_collider_center() {
860        // Point at center → degenerate, should not panic
861        let col = ClothCollider::Sphere {
862            center: [0.0; 3],
863            radius: 1.0,
864        };
865        let result = col.penetration([0.0; 3]);
866        assert!(result.is_some());
867    }
868
869    #[test]
870    fn test_plane_collider_below() {
871        // Plane y=0, normal=(0,1,0), d=0 → point at y=-0.5 is below
872        let col = ClothCollider::Plane {
873            normal: [0.0, 1.0, 0.0],
874            d: 0.0,
875        };
876        let (depth, dir) = col.penetration([0.0, -0.5, 0.0]).unwrap();
877        assert!((depth - 0.5).abs() < 1e-10);
878        assert!((dir[1] - 1.0).abs() < 1e-10);
879    }
880
881    #[test]
882    fn test_plane_collider_above() {
883        let col = ClothCollider::Plane {
884            normal: [0.0, 1.0, 0.0],
885            d: 0.0,
886        };
887        assert!(col.penetration([0.0, 1.0, 0.0]).is_none());
888    }
889
890    // --- GpuClothSolver ---
891
892    #[test]
893    fn test_solver_new() {
894        let mesh = ClothMesh::new();
895        let solver = GpuClothSolver::new(mesh);
896        assert_eq!(solver.xpbd_iterations, 8);
897        assert!(solver.colliders.is_empty());
898    }
899
900    #[test]
901    fn test_solver_add_collider() {
902        let mesh = ClothMesh::new();
903        let mut solver = GpuClothSolver::new(mesh);
904        solver.add_collider(ClothCollider::Plane {
905            normal: [0.0, 1.0, 0.0],
906            d: -1.0,
907        });
908        assert_eq!(solver.colliders.len(), 1);
909    }
910
911    #[test]
912    fn test_solver_solve_no_penetration() {
913        let mut mesh = ClothMesh::new();
914        mesh.build_grid(2, 2, 0.5);
915        let mut solver = GpuClothSolver::new(mesh);
916        // Ground plane well below
917        solver.add_collider(ClothCollider::Plane {
918            normal: [0.0, 1.0, 0.0],
919            d: -10.0,
920        });
921        solver.solve(0.01);
922        // Unpinned vertices should still be above ground
923        for v in &solver.mesh.vertices {
924            if !v.pinned {
925                assert!(v.position[1] > -10.0);
926            }
927        }
928    }
929
930    #[test]
931    fn test_solver_sphere_prevents_penetration() {
932        let mut mesh = ClothMesh::new();
933        mesh.build_grid(2, 2, 0.1);
934        // Move free vertices inside a sphere
935        for v in mesh.vertices.iter_mut() {
936            if !v.pinned {
937                v.position = [0.0, 0.0, 0.0];
938            }
939        }
940        let mut solver = GpuClothSolver::new(mesh);
941        solver.add_collider(ClothCollider::Sphere {
942            center: [0.0; 3],
943            radius: 5.0,
944        });
945        solver.solve(0.01);
946        // All unpinned verts should now be at distance >= radius from center
947        for v in &solver.mesh.vertices {
948            if !v.pinned {
949                let dist = length3(v.position);
950                assert!(dist >= 5.0 - 1e-6, "dist={dist}");
951            }
952        }
953    }
954
955    #[test]
956    fn test_solver_pinned_stays() {
957        let mut mesh = ClothMesh::new();
958        mesh.build_grid(3, 3, 0.5);
959        let pin_pos: Vec<_> = mesh
960            .vertices
961            .iter()
962            .filter(|v| v.pinned)
963            .map(|v| v.position)
964            .collect();
965
966        let mut solver = GpuClothSolver::new(mesh);
967        for _ in 0..10 {
968            solver.solve(0.01);
969        }
970
971        let pin_pos_after: Vec<_> = solver
972            .mesh
973            .vertices
974            .iter()
975            .filter(|v| v.pinned)
976            .map(|v| v.position)
977            .collect();
978
979        assert_eq!(pin_pos, pin_pos_after);
980    }
981
982    #[test]
983    fn test_cloth_grid_has_edges() {
984        let mut mesh = ClothMesh::new();
985        mesh.build_grid(3, 3, 0.5);
986        assert!(!mesh.edges.is_empty());
987    }
988
989    #[test]
990    fn test_dihedral_symmetric() {
991        // Swapping the two wing vertices should give the same angle
992        let p0 = [0.0, 1.0, 0.0];
993        let p1 = [-1.0, 0.0, 0.0];
994        let p2 = [1.0, 0.0, 0.0];
995        let p3 = [0.0, -1.0, 0.5];
996        let a1 = dihedral_angle(p0, p1, p2, p3);
997        let a2 = dihedral_angle(p3, p1, p2, p0);
998        assert!((a1 - a2).abs() < 1e-10, "a1={a1} a2={a2}");
999    }
1000
1001    #[test]
1002    fn test_spring_force_zero_length() {
1003        // Both vertices at same position → no force (no panic)
1004        let verts = vec![
1005            ClothVertex::new([0.0, 0.0, 0.0], 1.0, false),
1006            ClothVertex::new([0.0, 0.0, 0.0], 1.0, false),
1007        ];
1008        let edge = ClothEdge::new(0, 1, 1.0, 1000.0, 0.5);
1009        let f = edge.spring_force(&verts);
1010        assert!(length3(f) < 1e-12);
1011    }
1012
1013    #[test]
1014    fn test_multiple_steps_energy_decreases() {
1015        // With damping, a stretched spring should lose energy over time
1016        let mut mesh = ClothMesh::new();
1017        mesh.build_grid(2, 1, 2.0); // rest_length=1, stretched to 2
1018        let mut solver = GpuClothSolver::new(mesh);
1019        solver.solve(0.001);
1020        // Just check no NaN
1021        for v in &solver.mesh.vertices {
1022            for x in v.position {
1023                assert!(x.is_finite());
1024            }
1025        }
1026    }
1027
1028    #[test]
1029    fn test_cloth_vertex_clone() {
1030        let v = ClothVertex::new([1.0, 2.0, 3.0], 1.0, false);
1031        let v2 = v.clone();
1032        assert_eq!(v.position, v2.position);
1033    }
1034
1035    #[test]
1036    fn test_cloth_edge_clone() {
1037        let e = ClothEdge::new(0, 1, 1.0, 100.0, 0.1);
1038        let e2 = e.clone();
1039        assert_eq!(e.v0, e2.v0);
1040        assert_eq!(e.rest_length, e2.rest_length);
1041    }
1042
1043    #[test]
1044    fn test_collider_clone() {
1045        let c = ClothCollider::Sphere {
1046            center: [1.0, 2.0, 3.0],
1047            radius: 0.5,
1048        };
1049        let c2 = c.clone();
1050        if let ClothCollider::Sphere { radius, .. } = c2 {
1051            assert!((radius - 0.5).abs() < 1e-12);
1052        } else {
1053            panic!("wrong variant");
1054        }
1055    }
1056}