tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-anim/src/physics.rs Rust · 284 lines · 9.5 KB History Who touched what raw

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! Física de partículas (Verlet) + constraints de distancia, para mover
2//! esqueletos con **leyes físicas**: cuerdas, péndulos, ragdolls que caen y se
3//! balancean bajo gravedad, con piso y paredes.
4//!
5//! El patrón es: una cadena de [`Particle`]s unidas por [`Link`]s (distancias
6//! rígidas = longitudes de hueso) se integra con gravedad; luego
7//! [`pose_chain_from_points`] convierte las posiciones de las partículas en poses
8//! de una cadena de huesos, y el skinning deforma la malla. Así la física maneja
9//! el esqueleto, no una animación keyframeada.
10//!
11//! Verlet (en vez de Euler con velocidades explícitas) hace los constraints de
12//! distancia triviales y estables: se resuelven por proyección iterativa.
13
14use kurbo::{Point, Vec2};
15
16use crate::skel::{BoneId, Skeleton};
17
18/// Una partícula puntual. `inv_mass = 0` la fija (pin); `damping` global la frena.
19#[derive(Debug, Clone, Copy)]
20pub struct Particle {
21    pub pos: Point,
22    pub prev: Point,
23    pub inv_mass: f64,
24}
25
26impl Particle {
27    pub fn new(pos: Point, pinned: bool) -> Self {
28        Self {
29            pos,
30            prev: pos,
31            inv_mass: if pinned { 0.0 } else { 1.0 },
32        }
33    }
34    pub fn pinned(&self) -> bool {
35        self.inv_mass == 0.0
36    }
37}
38
39/// Restricción de distancia entre dos partículas (un "hueso" rígido).
40#[derive(Debug, Clone, Copy)]
41pub struct Link {
42    pub a: usize,
43    pub b: usize,
44    pub rest: f64,
45    /// 0..1: 1 = rígido, <1 = elástico.
46    pub stiffness: f64,
47}
48
49/// Mundo físico 2D: partículas + links + gravedad + piso/paredes.
50#[derive(Debug, Clone)]
51pub struct Physics {
52    pub particles: Vec<Particle>,
53    pub links: Vec<Link>,
54    pub gravity: Vec2,
55    /// Factor de retención de velocidad por step (0..1, ~0.99).
56    pub damping: f64,
57    /// Si está, las partículas no bajan de esta `y` (piso).
58    pub floor_y: Option<f64>,
59    /// Paredes `(x0, x1)` que contienen en x.
60    pub walls_x: Option<(f64, f64)>,
61}
62
63impl Default for Physics {
64    fn default() -> Self {
65        Self {
66            particles: Vec::new(),
67            links: Vec::new(),
68            gravity: Vec2::new(0.0, 980.0), // px/s² hacia abajo (y crece para abajo)
69            damping: 0.99,
70            floor_y: None,
71            walls_x: None,
72        }
73    }
74}
75
76impl Physics {
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Agrega una partícula y devuelve su índice.
82    pub fn particle(&mut self, pos: Point, pinned: bool) -> usize {
83        let i = self.particles.len();
84        self.particles.push(Particle::new(pos, pinned));
85        i
86    }
87
88    /// Une dos partículas con `rest` = su distancia actual y rigidez total.
89    pub fn link(&mut self, a: usize, b: usize) {
90        let rest = (self.particles[b].pos - self.particles[a].pos).hypot();
91        self.links.push(Link { a, b, rest, stiffness: 1.0 });
92    }
93
94    /// Une dos partículas con una distancia de reposo y rigidez dadas.
95    pub fn link_with(&mut self, a: usize, b: usize, rest: f64, stiffness: f64) {
96        self.links.push(Link { a, b, rest, stiffness });
97    }
98
99    /// Empuja las partículas lejos de `center` dentro de `radius` (campo de
100    /// repulsión — p. ej. el cursor barriendo las cuerdas).
101    pub fn repel(&mut self, center: Point, radius: f64, strength: f64) {
102        for p in &mut self.particles {
103            if p.pinned() {
104                continue;
105            }
106            let d = p.pos - center;
107            let dist = d.hypot();
108            if dist < radius && dist > 1e-6 {
109                let push = (1.0 - dist / radius) * strength;
110                p.pos += d * (push / dist);
111            }
112        }
113    }
114
115    /// Un paso de simulación: integra (gravedad + inercia) y resuelve los
116    /// constraints `iterations` veces (más iteraciones = más rígido/estable).
117    pub fn step(&mut self, dt: f64, iterations: usize) {
118        let dt2 = dt * dt;
119        for p in &mut self.particles {
120            if p.pinned() {
121                continue;
122            }
123            let vel = (p.pos - p.prev) * self.damping;
124            p.prev = p.pos;
125            p.pos = p.pos + vel + self.gravity * dt2;
126        }
127        for _ in 0..iterations.max(1) {
128            self.solve_links();
129            self.solve_bounds();
130        }
131    }
132
133    fn solve_links(&mut self) {
134        for k in 0..self.links.len() {
135            let Link { a, b, rest, stiffness } = self.links[k];
136            let pa = self.particles[a].pos;
137            let pb = self.particles[b].pos;
138            let wa = self.particles[a].inv_mass;
139            let wb = self.particles[b].inv_mass;
140            let wsum = wa + wb;
141            if wsum < 1e-12 {
142                continue;
143            }
144            let delta = pb - pa;
145            let d = delta.hypot();
146            if d < 1e-9 {
147                continue;
148            }
149            let diff = (d - rest) / d * stiffness;
150            let corr = delta * diff;
151            self.particles[a].pos = pa + corr * (wa / wsum);
152            self.particles[b].pos = pb - corr * (wb / wsum);
153        }
154    }
155
156    fn solve_bounds(&mut self) {
157        for p in &mut self.particles {
158            if p.pinned() {
159                continue;
160            }
161            if let Some(fy) = self.floor_y {
162                if p.pos.y > fy {
163                    p.pos.y = fy;
164                    // Fricción: amortigua el deslizamiento horizontal en el piso.
165                    p.prev.x = p.pos.x + (p.prev.x - p.pos.x) * 0.5;
166                }
167            }
168            if let Some((x0, x1)) = self.walls_x {
169                p.pos.x = p.pos.x.clamp(x0, x1);
170            }
171        }
172    }
173
174    /// Posiciones actuales de las partículas (para construir poses / pintar).
175    pub fn positions(&self) -> Vec<Point> {
176        self.particles.iter().map(|p| p.pos).collect()
177    }
178}
179
180/// Posa una cadena de huesos para que siga una cadena de puntos (las partículas
181/// físicas). `points` tiene `bones.len() + 1` entradas: el hueso `i` va de
182/// `points[i]` a `points[i+1]`. Setea la translación de la raíz a `points[0]` y
183/// las rotaciones a los ángulos de cada segmento (relativos en la jerarquía).
184/// Llama `update`. Es el puente física → esqueleto.
185pub fn pose_chain_from_points(skel: &mut Skeleton, bones: &[BoneId], points: &[Point]) {
186    if bones.is_empty() || points.len() < bones.len() + 1 {
187        return;
188    }
189    let seg_angle = |i: usize| {
190        let d = points[i + 1] - points[i];
191        if d.hypot() < 1e-9 {
192            0.0
193        } else {
194            d.y.atan2(d.x)
195        }
196    };
197
198    // Raíz: su origen va a points[0]; rotación = ángulo del primer segmento.
199    let root = bones[0];
200    let mut rp = skel.pose(root);
201    rp.translation = points[0].to_vec2();
202    rp.rotation = seg_angle(0);
203    skel.set_pose(root, rp);
204
205    // Eslabones: rotación local = ángulo del segmento − ángulo del anterior
206    // (la rotación world se acumula por la jerarquía).
207    for i in 1..bones.len() {
208        let mut p = skel.pose(bones[i]);
209        p.rotation = seg_angle(i) - seg_angle(i - 1);
210        skel.set_pose(bones[i], p);
211    }
212    skel.update();
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::skel::{Pose, Skeleton};
219
220    #[test]
221    fn la_gravedad_hace_caer_una_particula_libre() {
222        let mut w = Physics::new();
223        let i = w.particle(Point::new(0.0, 0.0), false);
224        w.step(0.1, 1);
225        assert!(w.particles[i].pos.y > 0.0, "debería caer (y crece)");
226    }
227
228    #[test]
229    fn una_particula_fija_no_se_mueve() {
230        let mut w = Physics::new();
231        let i = w.particle(Point::new(5.0, 5.0), true);
232        for _ in 0..10 {
233            w.step(0.1, 2);
234        }
235        assert_eq!(w.particles[i].pos, Point::new(5.0, 5.0));
236    }
237
238    #[test]
239    fn un_pendulo_cuelga_a_la_distancia_de_reposo() {
240        let mut w = Physics::new();
241        let anchor = w.particle(Point::new(0.0, 0.0), true);
242        let bob = w.particle(Point::new(0.0, 50.0), false);
243        w.link(anchor, bob); // rest = 50
244        for _ in 0..400 {
245            w.step(1.0 / 120.0, 8);
246        }
247        // En reposo cuelga recto hacia abajo a ~50 del ancla.
248        let p = w.particles[bob].pos;
249        assert!((p.x).abs() < 1.0, "x ~ 0, fue {}", p.x);
250        assert!((p.y - 50.0).abs() < 1.0, "y ~ 50, fue {}", p.y);
251    }
252
253    #[test]
254    fn el_piso_detiene_la_caida() {
255        let mut w = Physics::new();
256        w.floor_y = Some(100.0);
257        let i = w.particle(Point::new(0.0, 0.0), false);
258        for _ in 0..200 {
259            w.step(1.0 / 120.0, 2);
260        }
261        assert!(w.particles[i].pos.y <= 100.0 + 1e-6, "no pasa el piso");
262        assert!(w.particles[i].pos.y > 90.0, "llegó cerca del piso");
263    }
264
265    #[test]
266    fn pose_chain_sigue_los_puntos() {
267        // Cadena de 2 huesos; puntos en L vertical-luego-horizontal.
268        let mut s = Skeleton::new();
269        let a = s.add_bone(None, Pose::translate(Vec2::new(0.0, 0.0)));
270        let b = s.add_bone(Some(a), Pose::translate(Vec2::new(50.0, 0.0)));
271        s.bind();
272        let points = [
273            Point::new(0.0, 0.0),
274            Point::new(0.0, 50.0),  // primer segmento apunta hacia abajo (+y)
275            Point::new(50.0, 50.0), // segundo apunta a la derecha (+x)
276        ];
277        pose_chain_from_points(&mut s, &[a, b], &points);
278        // El origen del hueso b debe estar en points[1]; su punta (local 50,0) en points[2].
279        let b_origin = s.world(b) * Point::ZERO;
280        assert!((b_origin.x - 0.0).abs() < 1e-6 && (b_origin.y - 50.0).abs() < 1e-6, "{b_origin:?}");
281        let tip = s.world(b) * Point::new(50.0, 0.0);
282        assert!((tip.x - 50.0).abs() < 1e-6 && (tip.y - 50.0).abs() < 1e-6, "tip {tip:?}");
283    }
284}