tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}