tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-anim-studio/src/rig.rs Rust · 444 líneas · 16.6 KB Historia Quién tocó qué crudo

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//! El **rig esqueletal**: la representación editable de una cadena de huesos con
2//! una malla-tira auto-skinneada, que se **compila** a un
3//! [`llimphi_anim::skel::Skeleton`] + [`llimphi_anim::skel::Mesh`] deformables.
4//! Es la Fase 2 del studio: sobre el mismo runtime estilo Rive, pero ahora el
5//! "clip" es una **deformación esqueletal** en vez de una máquina de estados.
6//!
7//! ## Qué autora (y qué no, todavía)
8//!
9//! Autora una **cadena** de huesos (cada uno con su largo y su ángulo de pose),
10//! una malla-tubo generada paramétricamente alrededor de la cadena (skinning
11//! suave en las articulaciones, igual que el `build_arm` canónico del demo
12//! `lottie_rive_demo`), y un **IK de 2 huesos** opcional sobre los dos primeros
13//! huesos persiguiendo un objetivo. El *weight-paint* a mano y la malla
14//! arbitraria (importada/dibujada) quedan para F2.5/F3 — aquí la malla es
15//! derivada de la cadena, no editable vértice-a-vértice.
16//!
17//! La matemática (jerarquía, LBS, IK analítico) vive entera en `llimphi-anim`;
18//! este módulo sólo **describe** el rig y lo proyecta al runtime, igual que
19//! [`crate::doc::Doc`] hace con el `StateMachine`.
20
21use llimphi_anim::constraint::solve_two_bone_ik;
22use llimphi_anim::skel::{Mesh, Pose, Skeleton, Vertex, Weight};
23use llimphi_ui::llimphi_raster::kurbo::{Point, Vec2};
24use serde::{Deserialize, Serialize};
25
26/// Un hueso de la cadena. El hueso apunta a lo largo de su eje local **+x**
27/// (convención de `skel`/`constraint`): su hijo se traslada `len` en +x.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct BoneDef {
30    /// Largo del hueso (distancia al hijo, en unidades de modelo).
31    pub len: f64,
32    /// Ángulo de pose **local** en radianes (lo que editan los sliders). En el
33    /// bind pose la cadena está recta (todos los ángulos en 0).
34    pub angle: f64,
35}
36
37impl BoneDef {
38    pub fn new(len: f64) -> Self {
39        BoneDef { len, angle: 0.0 }
40    }
41}
42
43/// Cómo se genera la malla deformable alrededor de la cadena.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub enum MeshMode {
46    /// Tira-tubo a lo largo de la cadena (skinning rígido+blend en joints).
47    /// Ideal para un miembro (brazo, cola).
48    Tube,
49    /// Rejilla rectangular que cubre toda la silueta, con cada vértice
50    /// auto-skinneado a los huesos por distancia. Es la malla para **deformar
51    /// una imagen/arte arbitrario** (sus UV mapean la textura completa).
52    Grid,
53}
54
55/// El documento del rig: la cadena + parámetros de malla + IK + textura.
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct RigDoc {
58    pub bones: Vec<BoneDef>,
59    /// Medio-grosor de la malla-tubo (mitad de la altura de la tira).
60    pub thickness: f64,
61    /// Columnas de la malla por unidad de largo (densidad del tubo).
62    pub cols: usize,
63    /// Modo de generación de la malla.
64    pub mesh_mode: MeshMode,
65    /// Resolución de la rejilla (celdas a lo largo de la cadena, modo Grid).
66    pub grid_res: usize,
67    /// Relación alto/ancho de la rejilla (modo Grid); se ajusta al cargar una
68    /// imagen para respetar su aspecto.
69    pub mesh_aspect: f64,
70    /// Path de la textura a deformar (se recarga al abrir el proyecto). Los
71    /// píxeles NO se serializan — sólo la referencia al archivo.
72    pub texture_path: Option<String>,
73    /// ¿IK de 2 huesos activo sobre los huesos 0 y 1?
74    pub ik_enabled: bool,
75    /// Objetivo del IK (espacio de modelo).
76    pub ik_target: (f64, f64),
77    /// Solución de codo (flip de la rama del IK).
78    pub ik_flip: bool,
79}
80
81impl Default for RigDoc {
82    fn default() -> Self {
83        RigDoc {
84            bones: Vec::new(),
85            thickness: 22.0,
86            cols: 16,
87            mesh_mode: MeshMode::Tube,
88            grid_res: 10,
89            mesh_aspect: 0.6,
90            texture_path: None,
91            ik_enabled: false,
92            ik_target: (200.0, 40.0),
93            ik_flip: false,
94        }
95    }
96}
97
98impl RigDoc {
99    /// Rig de arranque: un brazo de 2 huesos, el ejemplo canónico.
100    pub fn starter() -> Self {
101        RigDoc {
102            bones: vec![BoneDef::new(140.0), BoneDef::new(120.0)],
103            ik_target: (180.0, 90.0),
104            ..Default::default()
105        }
106    }
107
108    /// Largo total de la cadena (suma de los huesos).
109    pub fn total_len(&self) -> f64 {
110        self.bones.iter().map(|b| b.len).sum()
111    }
112
113    /// Posición de arranque (arc-length) de cada hueso a lo largo de la cadena
114    /// recta: `starts[i] = Σ len[0..i]`.
115    fn starts(&self) -> Vec<f64> {
116        let mut s = Vec::with_capacity(self.bones.len());
117        let mut acc = 0.0;
118        for b in &self.bones {
119            s.push(acc);
120            acc += b.len;
121        }
122        s
123    }
124
125    /// Construye el esqueleto en **bind pose** (cadena recta) y lo congela. El
126    /// orden de los `BoneId` coincide con `self.bones`.
127    fn build_skeleton_bind(&self) -> Skeleton {
128        let mut s = Skeleton::new();
129        let mut prev = None;
130        for (i, _b) in self.bones.iter().enumerate() {
131            // El hijo se traslada el largo del PADRE en +x; el root en el origen.
132            let local = if i == 0 {
133                Pose::identity()
134            } else {
135                Pose::translate(Vec2::new(self.bones[i - 1].len, 0.0))
136            };
137            prev = Some(s.add_bone(prev, local));
138        }
139        s.bind();
140        s
141    }
142
143    /// Aplica los ángulos de pose actuales (y el IK, si está activo) sobre un
144    /// esqueleto ya en bind, y lo deja `update()`-eado listo para deformar.
145    fn pose_skeleton(&self, s: &mut Skeleton) {
146        for (i, b) in self.bones.iter().enumerate() {
147            let t = if i == 0 {
148                Vec2::ZERO
149            } else {
150                Vec2::new(self.bones[i - 1].len, 0.0)
151            };
152            s.set_pose(i, Pose::new(t, b.angle, Vec2::new(1.0, 1.0)));
153        }
154        // IK sobre los dos primeros huesos: sobrescribe sus poses para que la
155        // punta del hueso 1 alcance el objetivo. Los huesos ≥2 conservan su
156        // ángulo de slider (relativo al hueso 1).
157        if self.ik_enabled && self.bones.len() >= 2 {
158            let tip_local = Vec2::new(self.bones[1].len, 0.0);
159            let target = Point::new(self.ik_target.0, self.ik_target.1);
160            solve_two_bone_ik(s, 0, 1, tip_local, target, self.ik_flip);
161        }
162        s.update();
163    }
164
165    /// Esqueleto posado (bind + poses + IK + update), listo para `deform`.
166    pub fn skeleton(&self) -> Skeleton {
167        let mut s = self.build_skeleton_bind();
168        self.pose_skeleton(&mut s);
169        s
170    }
171
172    /// Una copia del rig con una **deriva idle** procedural aplicada en la fase
173    /// `phase` ∈ `[0,1)` del loop: un vaivén suave de cada hueso (desfasado por
174    /// índice) y, si el IK está activo, una pequeña órbita del objetivo. Le da
175    /// vida al rig —como «Alley Cat»— mientras el formato del studio todavía no
176    /// serializa pistas de animación por keyframe (Fase 1). Lo comparten el bake
177    /// de fondos (`mirada-fondo`) y la reproducción en vivo del greeter, para que
178    /// el mismo `.ron` se vea igual en las tres superficies.
179    pub fn idle_at(&self, phase: f64) -> RigDoc {
180        const SWAY: f64 = 0.18;
181        const ORBIT: f64 = 24.0;
182        let p = phase * std::f64::consts::TAU;
183        let mut rig = self.clone();
184        for (k, b) in rig.bones.iter_mut().enumerate() {
185            b.angle += SWAY * (p + k as f64 * 0.6).sin();
186        }
187        if rig.ik_enabled {
188            rig.ik_target.0 += ORBIT * p.cos();
189            rig.ik_target.1 += ORBIT * (p * 2.0).sin() * 0.5;
190        }
191        rig
192    }
193
194    /// La malla deformable según el modo activo.
195    pub fn mesh(&self) -> Mesh {
196        match self.mesh_mode {
197            MeshMode::Tube => self.tube_mesh(),
198            MeshMode::Grid => self.grid_mesh(),
199        }
200    }
201
202    /// Malla-tubo skinneada alrededor de la cadena recta (bind space). Skinning
203    /// suave en las articulaciones: lejos de un joint el vértice es rígido a su
204    /// hueso; dentro de la ventana de blend mezcla con el hueso vecino.
205    fn tube_mesh(&self) -> Mesh {
206        let mut m = Mesh::new();
207        let n = self.bones.len();
208        if n == 0 {
209            return m;
210        }
211        let total = self.total_len();
212        let starts = self.starts();
213        let cols = self.cols.max(2);
214        let half = self.thickness;
215        // Ventana de blend: una fracción del hueso más corto.
216        let min_len = self.bones.iter().map(|b| b.len).fold(f64::MAX, f64::min);
217        let blend = (min_len * 0.4).max(1.0);
218
219        for i in 0..=cols {
220            let p = total * i as f64 / cols as f64;
221            let weights = self.weights_at(p, &starts, blend);
222            let u = i as f64 / cols as f64;
223            m.vertices.push(Vertex {
224                rest: Point::new(p, -half),
225                uv: (u, 0.0),
226                weights: weights.clone(),
227            });
228            m.vertices.push(Vertex {
229                rest: Point::new(p, half),
230                uv: (u, 1.0),
231                weights,
232            });
233        }
234        for i in 0..cols {
235            let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32);
236            let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32);
237            m.triangles.push([t0, t1, b1]);
238            m.triangles.push([t0, b1, b0]);
239        }
240        m
241    }
242
243    /// Pesos de un vértice a arc-position `p`: rígido a su segmento, con blend
244    /// lineal hacia el hueso vecino cerca de cada joint.
245    fn weights_at(&self, p: f64, starts: &[f64], blend: f64) -> Vec<Weight> {
246        let n = self.bones.len();
247        // Segmento que contiene a p.
248        let mut k = 0;
249        while k + 1 < n && p >= starts[k + 1] {
250            k += 1;
251        }
252        // Joint de entrada (con k-1) y de salida (con k+1).
253        if k + 1 < n {
254            let d = starts[k + 1] - p; // distancia al joint siguiente
255            if d < blend {
256                let t = (d / blend).clamp(0.0, 1.0);
257                let wk = 0.5 + 0.5 * t;
258                return vec![
259                    Weight { bone: k, weight: wk },
260                    Weight { bone: k + 1, weight: 1.0 - wk },
261                ];
262            }
263        }
264        if k > 0 {
265            let d = p - starts[k]; // distancia al joint anterior
266            if d < blend {
267                let t = (d / blend).clamp(0.0, 1.0);
268                let wk = 0.5 + 0.5 * t;
269                return vec![
270                    Weight { bone: k, weight: wk },
271                    Weight { bone: k - 1, weight: 1.0 - wk },
272                ];
273            }
274        }
275        vec![Weight { bone: k, weight: 1.0 }]
276    }
277
278    /// Malla-rejilla que cubre la silueta (`[0,total] × [-H/2,H/2]`, con
279    /// `H = total·aspect`), cada vértice **auto-skinneado** a los huesos por
280    /// distancia (inverse-distance, top-2). Sus UV mapean la textura completa
281    /// `0..1`, así que deforma una imagen arbitraria, no sólo un miembro.
282    fn grid_mesh(&self) -> Mesh {
283        let mut m = Mesh::new();
284        let n = self.bones.len();
285        if n == 0 {
286            return m;
287        }
288        let total = self.total_len();
289        let h = (total * self.mesh_aspect).max(1.0);
290        let y_top = -h * 0.5;
291        let starts = self.starts();
292        let gx = self.grid_res.max(2);
293        let gy = ((gx as f64 * self.mesh_aspect).round() as usize).max(2);
294
295        for j in 0..=gy {
296            for i in 0..=gx {
297                let x = total * i as f64 / gx as f64;
298                let y = y_top + h * j as f64 / gy as f64;
299                let weights = self.skin_weights_at(Point::new(x, y), &starts);
300                let uv = (i as f64 / gx as f64, j as f64 / gy as f64);
301                m.vertices.push(Vertex {
302                    rest: Point::new(x, y),
303                    uv,
304                    weights,
305                });
306            }
307        }
308        let stride = (gx + 1) as u32;
309        for j in 0..gy as u32 {
310            for i in 0..gx as u32 {
311                let a = j * stride + i;
312                let b = a + 1;
313                let c = a + stride;
314                let d = c + 1;
315                m.triangles.push([a, b, d]);
316                m.triangles.push([a, d, c]);
317            }
318        }
319        m
320    }
321
322    /// Pesos auto-skin de un punto: distancia a cada segmento-hueso (en bind,
323    /// recta sobre el eje x), inverse-distance², se queda con los 2 huesos más
324    /// cercanos y normaliza. Da una deformación suave de la rejilla.
325    fn skin_weights_at(&self, p: Point, starts: &[f64]) -> Vec<Weight> {
326        let n = self.bones.len();
327        // (bone, dist) por hueso.
328        let mut ds: Vec<(usize, f64)> = (0..n)
329            .map(|k| {
330                let x0 = starts[k];
331                let x1 = starts[k] + self.bones[k].len;
332                let dx = if p.x < x0 {
333                    x0 - p.x
334                } else if p.x > x1 {
335                    p.x - x1
336                } else {
337                    0.0
338                };
339                (k, dx.hypot(p.y))
340            })
341            .collect();
342        // Top-2 más cercanos.
343        ds.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
344        ds.truncate(2);
345        let eps = 1e-3;
346        let mut raw: Vec<(usize, f64)> =
347            ds.iter().map(|(k, d)| (*k, 1.0 / (d * d + eps))).collect();
348        let sum: f64 = raw.iter().map(|(_, w)| w).sum();
349        if sum <= 0.0 {
350            return vec![Weight { bone: 0, weight: 1.0 }];
351        }
352        for (_, w) in &mut raw {
353            *w /= sum;
354        }
355        raw.into_iter()
356            .map(|(bone, weight)| Weight { bone, weight })
357            .collect()
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn compila_esqueleto_y_malla() {
367        let rig = RigDoc::starter();
368        let s = rig.skeleton();
369        assert_eq!(s.len(), 2);
370        let m = rig.mesh();
371        assert!(!m.vertices.is_empty());
372        assert!(!m.triangles.is_empty());
373        // En bind (todos los ángulos 0, IK off) la deformación = reposo.
374        let pos = m.deform(&s);
375        assert_eq!(pos.len(), m.vertices.len());
376        for (p, v) in pos.iter().zip(&m.vertices) {
377            assert!((p.x - v.rest.x).abs() < 1e-6 && (p.y - v.rest.y).abs() < 1e-6);
378        }
379    }
380
381    #[test]
382    fn posar_un_hueso_deforma_la_malla() {
383        let mut rig = RigDoc::starter();
384        let rest = rig.mesh().deform(&rig.skeleton());
385        // Doblar el codo (hueso 1) 0.8 rad.
386        rig.bones[1].angle = 0.8;
387        let bent = rig.mesh().deform(&rig.skeleton());
388        // Al menos un vértice de la punta se movió respecto al reposo.
389        let moved = rest
390            .iter()
391            .zip(&bent)
392            .any(|(a, b)| (a.x - b.x).hypot(a.y - b.y) > 1.0);
393        assert!(moved, "doblar el codo debería mover la malla");
394    }
395
396    #[test]
397    fn grid_mesh_pesos_normalizados_y_deforma() {
398        let mut rig = RigDoc::starter();
399        rig.mesh_mode = MeshMode::Grid;
400        rig.grid_res = 8;
401        let m = rig.mesh();
402        assert!(!m.vertices.is_empty() && !m.triangles.is_empty());
403        // Cada vértice tiene pesos que suman ~1 (skinning bien normalizado).
404        for v in &m.vertices {
405            let s: f64 = v.weights.iter().map(|w| w.weight).sum();
406            assert!((s - 1.0).abs() < 1e-6, "pesos deben sumar 1, fue {s}");
407            assert!(v.weights.iter().all(|w| w.bone < rig.bones.len()));
408        }
409        // Posar el codo deforma la rejilla.
410        let rest = m.deform(&rig.skeleton());
411        rig.bones[1].angle = 0.9;
412        let bent = rig.mesh().deform(&rig.skeleton());
413        let moved = rest
414            .iter()
415            .zip(&bent)
416            .any(|(a, b)| (a.x - b.x).hypot(a.y - b.y) > 1.0);
417        assert!(moved, "doblar el codo debería deformar la rejilla");
418    }
419
420    #[test]
421    fn ik_alcanza_el_objetivo() {
422        let mut rig = RigDoc::starter();
423        rig.ik_enabled = true;
424        // Objetivo dentro del alcance (l1+l2 = 260).
425        rig.ik_target = (150.0, 80.0);
426        let s = rig.skeleton();
427        // Punta del hueso 1 en mundo = world(1) * (len1, 0).
428        let tip = s.world(1) * Point::new(rig.bones[1].len, 0.0);
429        let target = Point::new(rig.ik_target.0, rig.ik_target.1);
430        let err = (tip.x - target.x).hypot(tip.y - target.y);
431        assert!(err < 1.0, "el IK debería alcanzar el objetivo, err={err}");
432    }
433
434    #[test]
435    fn ik_clampa_objetivo_inalcanzable_sin_panickear() {
436        let mut rig = RigDoc::starter();
437        rig.ik_enabled = true;
438        rig.ik_target = (10_000.0, 0.0); // mucho más lejos que el alcance
439        let s = rig.skeleton();
440        // No panickea y el brazo queda estirado hacia el objetivo (x≈260).
441        let tip = s.world(1) * Point::new(rig.bones[1].len, 0.0);
442        assert!(tip.x > 200.0, "brazo estirado hacia el objetivo lejano");
443    }
444}