tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! `Actor` — un **muñeco de cajas articuladas** (humanoide voxel estilo
2//! Minecraft/MagicaVoxel) para *actuar* en una escena filmada, con una pequeña
3//! **librería de clips de animación** ([`Clip`]: quieto/caminar/correr/saludar/
4//! señalar/festejar). Es el tercer ingrediente de la rama de juego (tras
5//! [`Player`](crate::Player) y [`raycast`](crate::raycast)): un personaje
6//! **posable y animable**.
7//!
8//! El cuerpo son 6 cajas (cabeza/torso/2 brazos/2 piernas); cada miembro rota en
9//! su articulación (cadera/hombro). Un [`Clip`] es una función `fase → `[`Pose`]
10//! (los ángulos de todas las articulaciones), así agregar una animación nueva es
11//! escribir una pose, no tocar el render. No toca la GPU: produce una **malla**
12//! (`Vec<Vertex3d>` + índices) en espacio local (pies en el origen, mirando a
13//! `+Z`) que la app sube a un [`Renderer3d`](llimphi_3d::Renderer3d) por frame
14//! (`set_geometry`) y compone con los voxels en [`Scene3d`](llimphi_3d::Scene3d).
15
16use llimphi_3d::glam::{Mat4, Vec3};
17use llimphi_3d::{push_cube, Vertex3d};
18use serde::{Deserialize, Serialize};
19
20/// Amplitud base de balanceo de miembros al caminar (rad).
21const SWING: f32 = 0.7;
22/// Duración del **cross-fade** al cambiar de clip (seg): el cuerpo mezcla la pose
23/// saliente con la entrante en este lapso, en vez de saltar en seco.
24const BLEND_DUR: f32 = 0.22;
25
26/// Suavizado Hermite `3t²−2t³` (deriva nula en los extremos) para el cross-fade.
27fn smoothstep(x: f32) -> f32 {
28 let x = x.clamp(0.0, 1.0);
29 x * x * (3.0 - 2.0 * x)
30}
31
32/// Ángulos de todas las articulaciones del muñeco en un instante. Una animación
33/// ([`Clip`]) produce una `Pose`; [`Actor::mesh`] la hornea a cajas. Ángulos en
34/// radianes; `0` = postura neutra (de pie, brazos colgando).
35#[derive(Debug, Clone, Copy, Default)]
36pub struct Pose {
37 /// Balanceo de la pierna izquierda/derecha en la cadera (eje X, adelante+).
38 pub leg_l: f32,
39 pub leg_r: f32,
40 /// Balanceo del brazo izquierdo/derecho en el hombro (eje X, adelante+).
41 pub arm_l: f32,
42 pub arm_r: f32,
43 /// Apertura del brazo izquierdo/derecho hacia el costado/arriba (eje Z). El
44 /// signo se espeja por lado dentro de [`Actor::mesh`]; positivo = levantar.
45 pub arm_l_out: f32,
46 pub arm_r_out: f32,
47 /// Cabeceo de la cabeza (eje X).
48 pub head_pitch: f32,
49 /// Desplazamiento vertical del cuerpo (rebote/respiración), en unidades.
50 pub bob: f32,
51 /// Inclinación del torso hacia adelante (eje X, alrededor de los pies).
52 pub lean: f32,
53 /// **Aplastar y estirar** (el principio de animación): positivo = aplastado
54 /// (más bajo y más ancho, el cuerpo acusando el impacto), negativo = estirado
55 /// (más alto y más fino, el cuerpo en vuelo). `0` = sin deformar. La escala es
56 /// fracción de la altura: `0.05` ya se lee.
57 ///
58 /// Sólo lo aplica el camino **SDF** ([`ser_sdf`](crate::ser_sdf)), donde deformar
59 /// es escalar el ensamble: la malla de cajas y el volumen voxel lo ignoran (sus
60 /// primitivas se hornean a una grilla y una escala no uniforme les rompería el
61 /// bisel).
62 pub squash: f32,
63 /// **Apoyo del pie izquierdo/derecho** (IK de pies): cuánto sube ese pie por
64 /// encima de la raíz del cuerpo, en **fracción del largo de la pierna** (`0` =
65 /// pie a la altura de la raíz, `0.25` = un cuarto de pierna más arriba). Es
66 /// fracción y no unidades a propósito: la misma pose sirve al muñeco de cajas y
67 /// al ser SDF, que viven a escalas distintas. La calcula
68 /// [`Actor::apoyar_en`](Actor::apoyar_en) muestreando el relieve bajo cada pie.
69 pub pie_l: f32,
70 pub pie_r: f32,
71 /// **Alabeo** del cuerpo (eje Z, radianes): positivo = el lado derecho sube. Lo
72 /// pone el apoyo en una pendiente lateral, para que el cuerpo acompañe el
73 /// terreno en vez de quedar vertical con un pie en el aire.
74 pub roll: f32,
75}
76
77impl Pose {
78 /// Interpola campo a campo entre dos poses (`t=0`→`a`, `t=1`→`b`). Lo usa el
79 /// cross-fade entre clips.
80 pub fn lerp(a: &Pose, b: &Pose, t: f32) -> Pose {
81 let l = |x: f32, y: f32| x + (y - x) * t;
82 Pose {
83 leg_l: l(a.leg_l, b.leg_l),
84 leg_r: l(a.leg_r, b.leg_r),
85 arm_l: l(a.arm_l, b.arm_l),
86 arm_r: l(a.arm_r, b.arm_r),
87 arm_l_out: l(a.arm_l_out, b.arm_l_out),
88 arm_r_out: l(a.arm_r_out, b.arm_r_out),
89 head_pitch: l(a.head_pitch, b.head_pitch),
90 bob: l(a.bob, b.bob),
91 lean: l(a.lean, b.lean),
92 squash: l(a.squash, b.squash),
93 pie_l: l(a.pie_l, b.pie_l),
94 pie_r: l(a.pie_r, b.pie_r),
95 roll: l(a.roll, b.roll),
96 }
97 }
98}
99
100/// Animación: una función determinista `fase → `[`Pose`]. La fase la acumula
101/// [`Actor::advance`] a la [`cadence`](Clip::cadence) del clip.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103pub enum Clip {
104 /// De pie, respirando apenas.
105 Idle,
106 /// Caminata: piernas/brazos en oposición.
107 Walk,
108 /// Trote: balanceo amplio + inclinación hacia adelante.
109 Run,
110 /// Saludo: un brazo levantado al costado, oscilando.
111 Wave,
112 /// Señalar: un brazo extendido hacia adelante, firme.
113 Point,
114 /// Festejo: ambos brazos arriba, rebotando.
115 Cheer,
116}
117
118impl Clip {
119 /// Todos los clips, en orden de catálogo (para ciclar en un editor).
120 pub const ALL: [Clip; 6] = [
121 Clip::Idle,
122 Clip::Walk,
123 Clip::Run,
124 Clip::Wave,
125 Clip::Point,
126 Clip::Cheer,
127 ];
128
129 /// Nombre legible (español).
130 pub fn label(self) -> &'static str {
131 match self {
132 Clip::Idle => "quieto",
133 Clip::Walk => "caminar",
134 Clip::Run => "correr",
135 Clip::Wave => "saludar",
136 Clip::Point => "señalar",
137 Clip::Cheer => "festejar",
138 }
139 }
140
141 /// El clip siguiente (cicla).
142 pub fn next(self) -> Clip {
143 let i = Clip::ALL.iter().position(|&c| c == self).unwrap_or(0);
144 Clip::ALL[(i + 1) % Clip::ALL.len()]
145 }
146
147 /// `true` si el clip es un **gesto** (no locomoción) — un momento expresivo que
148 /// merece un acento musical. Lo usa el director para derivar los "beats del guion".
149 pub fn is_emote(self) -> bool {
150 matches!(self, Clip::Wave | Clip::Point | Clip::Cheer)
151 }
152
153 /// Velocidad de avance de la fase (rad/seg): pasos más rápidos = más cadencia.
154 pub fn cadence(self) -> f32 {
155 match self {
156 Clip::Idle => 2.0,
157 Clip::Walk => 8.0,
158 Clip::Run => 13.0,
159 Clip::Wave => 9.0,
160 Clip::Point => 3.0,
161 Clip::Cheer => 7.0,
162 }
163 }
164
165 /// La pose de este clip en la `fase` dada.
166 ///
167 /// Los clips con rebote llevan **aplastar-y-estirar** sincronizado con el `bob`:
168 /// ver [`squash_del_rebote`].
169 pub fn pose(self, phase: f32) -> Pose {
170 let s = phase.sin();
171 match self {
172 Clip::Idle => Pose {
173 bob: 0.02 * s,
174 // Respiración: el pecho sube y el cuerpo se estira apenas con él.
175 squash: -0.012 * s,
176 head_pitch: 0.03 * s,
177 arm_l_out: 0.07,
178 arm_r_out: 0.07,
179 ..Pose::default()
180 },
181 Clip::Walk => Pose {
182 leg_l: s * SWING,
183 leg_r: -s * SWING,
184 arm_l: -s * SWING,
185 arm_r: s * SWING,
186 bob: (phase * 2.0).sin().abs() * 0.03,
187 squash: squash_del_rebote(phase, 0.045),
188 ..Pose::default()
189 },
190 Clip::Run => Pose {
191 leg_l: s,
192 leg_r: -s,
193 arm_l: -s * 1.1,
194 arm_r: s * 1.1,
195 lean: 0.38,
196 bob: (phase * 2.0).sin().abs() * 0.06,
197 squash: squash_del_rebote(phase, 0.085),
198 ..Pose::default()
199 },
200 Clip::Wave => Pose {
201 arm_r_out: 2.35 + 0.18 * s, // levantado al costado, saludando
202 arm_l_out: 0.08,
203 head_pitch: -0.05,
204 ..Pose::default()
205 },
206 Clip::Point => Pose {
207 arm_r: -1.5, // extendido hacia adelante (+Z)
208 arm_l_out: 0.07,
209 head_pitch: 0.08,
210 ..Pose::default()
211 },
212 Clip::Cheer => Pose {
213 arm_l_out: 2.6,
214 arm_r_out: 2.6,
215 bob: (phase * 2.0).sin().abs() * 0.08,
216 squash: squash_del_rebote(phase, 0.10),
217 head_pitch: -0.1,
218 ..Pose::default()
219 },
220 }
221 }
222}
223
224/// **Aplastar y estirar sincronizado con el rebote** (`bob = |sin(2φ)|·k`): el cuerpo
225/// se **aplasta** cuando el pie golpea el suelo (rebote abajo) y se **estira** en el
226/// apogeo del paso. Es el principio de animación más viejo del oficio: sin él un
227/// muñeco que sube y baja parece un ascensor; con él, parece que pesa.
228///
229/// `amp` es la amplitud en fracción de altura. Devuelve `+amp` en el contacto y
230/// `−amp` en el apogeo.
231pub fn squash_del_rebote(phase: f32, amp: f32) -> f32 {
232 amp * (1.0 - 2.0 * (phase * 2.0).sin().abs())
233}
234
235/// **Edad cuantizada** del personaje: estadios discretos que cambian las
236/// proporciones del cuerpo (un bebé es cabezón y de miembros cortos; un adulto es
237/// alto y proporcionado). Sirve para *mostrar al niño primero* (el corto: nace en el
238/// desierto) y envejecerlo por etapas. Cada edad deriva un [`Build`].
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240pub enum Age {
241 /// Bebé/recién nacido: chiquito, cabezón, miembros cortos.
242 Baby,
243 /// Niño.
244 Child,
245 /// Joven/adolescente.
246 Teen,
247 /// Adulto (proporciones de referencia).
248 Adult,
249 /// Anciano (apenas más bajo que el adulto).
250 Elder,
251}
252
253impl Age {
254 /// Todas las edades, de menor a mayor (para que un editor cicle entre ellas).
255 pub const ALL: [Age; 5] = [Age::Baby, Age::Child, Age::Teen, Age::Adult, Age::Elder];
256
257 /// Nombre legible (español) para la UI.
258 pub fn label(self) -> &'static str {
259 match self {
260 Age::Baby => "bebé",
261 Age::Child => "niño",
262 Age::Teen => "joven",
263 Age::Adult => "adulto",
264 Age::Elder => "anciano",
265 }
266 }
267
268 /// La edad siguiente (cicla) — para botones de ciclo.
269 pub fn next(self) -> Age {
270 let i = Age::ALL.iter().position(|&a| a == self).unwrap_or(0);
271 Age::ALL[(i + 1) % Age::ALL.len()]
272 }
273
274 /// `(escala_total, refuerzo_cabeza, escala_miembros)` por edad. Más joven =
275 /// más chico, cabeza proporcionalmente más grande y miembros más cortos.
276 fn params(self) -> (f32, f32, f32) {
277 match self {
278 Age::Baby => (0.50, 1.55, 0.70),
279 Age::Child => (0.66, 1.28, 0.82),
280 Age::Teen => (0.84, 1.08, 0.93),
281 Age::Adult => (1.00, 1.00, 1.00),
282 Age::Elder => (0.96, 1.00, 0.97),
283 }
284 }
285}
286
287/// **Constitución** del muñeco: las medidas de cada parte (posiciones de
288/// articulación y tamaños de caja), en el espacio local del actor (pies en el
289/// origen, mirando a `+Z`). Es el "esqueleto + modelado" configurable: cambiarla
290/// hace personajes distintos (alto/bajo, cabezón, etc.). Se construye por
291/// [`Age`](Age) ([`Build::for_age`]) y los pies quedan **siempre en `y=0`** por
292/// construcción (`hip_y == leg_len`).
293#[derive(Debug, Clone, Copy)]
294pub struct Build {
295 /// Altura aproximada total (referencia).
296 pub height: f32,
297 /// Centro y tamaño del torso.
298 pub torso_y: f32,
299 pub torso: Vec3,
300 /// Centro y tamaño de la cabeza.
301 pub head_y: f32,
302 pub head: Vec3,
303 /// Altura del hombro y separación lateral; largo y tamaño del brazo.
304 pub shoulder_y: f32,
305 pub shoulder_x: f32,
306 pub arm_len: f32,
307 pub arm: Vec3,
308 /// Altura de la cadera (= `leg_len`, pies en el piso), separación; largo/tamaño
309 /// de la pierna.
310 pub hip_y: f32,
311 pub hip_x: f32,
312 pub leg_len: f32,
313 pub leg: Vec3,
314 /// Tamaño de la mano.
315 pub hand: Vec3,
316}
317
318impl Build {
319 /// Construye la constitución de una [`Age`]. Bottom-up desde los pies (`y=0`),
320 /// así cualquier edad queda con los pies en el piso. `Adult` reproduce las
321 /// proporciones históricas del muñeco (los 11 cubos de siempre).
322 pub fn for_age(age: Age) -> Build {
323 let (s, head_boost, limb) = age.params();
324 let neck = 0.02 * s;
325 let leg_len = 0.80 * s * limb;
326 let hip_y = leg_len; // pies en el piso
327 let torso = Vec3::new(0.55 * s, 0.60 * s, 0.30 * s);
328 let torso_y = hip_y + torso.y * 0.5;
329 let shoulder_y = hip_y + torso.y;
330 let head = Vec3::new(0.42 * s * head_boost, 0.40 * s * head_boost, 0.42 * s * head_boost);
331 let head_y = shoulder_y + head.y * 0.5 + neck;
332 Build {
333 height: head_y + head.y * 0.5,
334 torso_y,
335 torso,
336 head_y,
337 head,
338 shoulder_y,
339 shoulder_x: 0.36 * s,
340 arm_len: 0.60 * s * limb,
341 arm: Vec3::new(0.18 * s, 0.60 * s * limb, 0.18 * s),
342 hip_y,
343 hip_x: 0.14 * s,
344 leg_len,
345 leg: Vec3::new(0.22 * s, leg_len, 0.22 * s),
346 hand: Vec3::new(0.20 * s, 0.18 * s, 0.20 * s),
347 }
348 }
349
350 /// Constitución adulta de referencia.
351 pub fn adult() -> Build {
352 Build::for_age(Age::Adult)
353 }
354}
355
356/// Personaje articulado. `pos` es el **centro de los pies** en espacio de mundo
357/// (las mismas coordenadas del terreno/grid); `facing` el rumbo (yaw, `0`=`+Z`).
358/// `clip`/`phase` definen la animación actual. Colores por zona (piel/remera/
359/// pantalón). La [`Build`] define las proporciones (edad/personaje).
360#[derive(Debug, Clone, Copy)]
361pub struct Actor {
362 /// Centro de los pies, en mundo.
363 pub pos: Vec3,
364 /// Rumbo (yaw, radianes; `0` mira a `+Z`).
365 pub facing: f32,
366 /// Animación actual.
367 pub clip: Clip,
368 /// Fase del clip (acumulada por [`advance`](Self::advance)).
369 pub phase: f32,
370 /// Clip saliente durante un cross-fade (`None` si no hay transición en curso).
371 prev_clip: Option<Clip>,
372 /// Fase del clip saliente (sigue avanzando durante la mezcla).
373 prev_phase: f32,
374 /// Progreso del cross-fade `0..1` (a `1` se descarta el clip saliente).
375 blend: f32,
376 /// Color de la piel (cabeza).
377 pub skin: [f32; 3],
378 /// Color de la remera (torso + brazos).
379 pub shirt: [f32; 3],
380 /// Color del pantalón (piernas).
381 pub pants: [f32; 3],
382 /// **IK de mirada** (look-at constraint): si está, la cabeza gira (yaw+pitch,
383 /// dentro de un rango creíble) para **mirar ese punto de mundo**, por encima del
384 /// cabeceo del clip — los ojos siguen al objetivo. `None` = cabeza alineada al
385 /// cuerpo. La fija [`look_at`](Self::look_at).
386 look_target: Option<Vec3>,
387 /// **IK de brazo señalador**: si está, el brazo del lado del objetivo se estira
388 /// hacia ese punto de mundo (el hombro apunta la mano al blanco), por encima de
389 /// lo que diga el clip. `None` = brazos como los anima el clip. Lo fija
390 /// [`apuntar_a`](Self::apuntar_a).
391 point_target: Option<Vec3>,
392 /// **IK de pies**: cuánto sube cada pie y cuánto se alabea el cuerpo por el
393 /// relieve de abajo. Lo calcula [`apoyar_en`](Self::apoyar_en); se suma a la pose
394 /// del clip.
395 apoyo: Apoyo,
396 /// Constitución (proporciones por edad/personaje). La fija
397 /// [`with_age`](Self::with_age) / [`with_build`](Self::with_build).
398 pub build: Build,
399 /// Edad actual (estadio cuantizado) — informativo; el cuerpo lo da `build`.
400 pub age: Age,
401}
402
403/// Resultado del **IK de pies**: cuánto sube cada pie sobre la raíz del cuerpo
404/// (en fracción del largo de la pierna) y cuánto se alabea el cuerpo. Lo produce
405/// [`Actor::apoyar_en`] y lo consume la pose.
406#[derive(Debug, Clone, Copy, Default)]
407struct Apoyo {
408 lift_l: f32,
409 lift_r: f32,
410 roll: f32,
411}
412
413impl Actor {
414 /// Actor parado en `pos` (centro de pies, mundo) mirando a `facing`, en
415 /// [`Clip::Idle`], con una paleta por defecto (piel clara, remera teal,
416 /// pantalón azul).
417 pub fn new(pos: Vec3, facing: f32) -> Self {
418 Self {
419 pos,
420 facing,
421 clip: Clip::Idle,
422 phase: 0.0,
423 prev_clip: None,
424 prev_phase: 0.0,
425 blend: 1.0,
426 skin: [0.86, 0.68, 0.54],
427 shirt: [0.20, 0.62, 0.55],
428 pants: [0.18, 0.22, 0.34],
429 look_target: None,
430 point_target: None,
431 apoyo: Apoyo::default(),
432 build: Build::adult(),
433 age: Age::Adult,
434 }
435 }
436
437 /// Fija la **edad** (estadio cuantizado) → recalcula la constitución del cuerpo.
438 /// Encadenable: `Actor::new(pos, yaw).with_age(Age::Baby)`. Para *mostrar al
439 /// niño primero* y envejecerlo por etapas.
440 pub fn with_age(mut self, age: Age) -> Self {
441 self.set_age(age);
442 self
443 }
444
445 /// Cambia la edad en caliente (recalcula `build`).
446 pub fn set_age(&mut self, age: Age) {
447 self.age = age;
448 self.build = Build::for_age(age);
449 }
450
451 /// Fija una constitución arbitraria (personaje a medida, no atado a una edad).
452 pub fn with_build(mut self, build: Build) -> Self {
453 self.build = build;
454 self
455 }
456
457 /// Fija (o limpia con `None`) el **objetivo de mirada** (IK de cabeza): la cabeza
458 /// y los ojos se orientan hacia ese punto de mundo, dentro de un rango creíble,
459 /// sin mover el cuerpo. Útil para que un actor "mire a cámara" o siga algo.
460 pub fn look_at(&mut self, target: Option<Vec3>) {
461 self.look_target = target;
462 }
463
464 /// Fija (o limpia con `None`) el **objetivo de señalado** (IK de brazo): el brazo
465 /// del lado del objetivo se estira para apuntarlo, y la mano queda en la línea
466 /// hombro→blanco. Es el complemento del IK de mirada: uno dice *dónde mira*, éste
467 /// *qué señala*. El clip sigue animando el otro brazo.
468 pub fn apuntar_a(&mut self, target: Option<Vec3>) {
469 self.point_target = target;
470 }
471
472 /// **Planta los pies en el relieve** (IK de pies). `altura(x, z)` es la cota del
473 /// suelo en ese punto de mundo — un `VoxelGrid`, el terreno procedural o un plano.
474 ///
475 /// Muestrea bajo cada pie (separados por el ancho de cadera, girados por el rumbo),
476 /// baja la raíz del actor al pie **más bajo** y anota cuánto sube el otro: la
477 /// pierna corta se acorta, y el cuerpo se alabea hacia la pendiente. Sin esto, en
478 /// una ladera un pie flota y el otro se entierra.
479 ///
480 /// Cambia `pos.y`. Llamar después de mover al actor y antes de dibujarlo.
481 pub fn apoyar_en(&mut self, altura: impl Fn(f32, f32) -> f32) {
482 let (sf, cf) = (self.facing.sin(), self.facing.cos());
483 let right = Vec3::new(cf, 0.0, -sf); // costado derecho del actor
484 let half = self.build.hip_x.max(1e-3);
485 let pr = self.pos + right * half;
486 let pl = self.pos - right * half;
487 let hr = altura(pr.x, pr.z);
488 let hl = altura(pl.x, pl.z);
489 let base = hr.min(hl);
490 self.pos.y = base;
491 let leg = self.build.leg_len.max(1e-3);
492 // Un pie no puede subir más que la pierna: un escalón más alto que eso no se
493 // sube estirando, se sube caminando (y eso lo decide la dirección, no el IK).
494 let tope = 0.6 * leg;
495 self.apoyo = Apoyo {
496 lift_l: ((hl - base).clamp(0.0, tope)) / leg,
497 lift_r: ((hr - base).clamp(0.0, tope)) / leg,
498 roll: ((hr - hl) / (2.0 * half)).atan().clamp(-0.45, 0.45),
499 };
500 }
501
502 /// Olvida el apoyo calculado (vuelve a pies a nivel, sin alabeo).
503 pub fn despegar(&mut self) {
504 self.apoyo = Apoyo::default();
505 }
506
507 /// Tinta el actor (piel/remera/pantalón) — encadenable tras [`new`](Self::new).
508 pub fn with_colors(mut self, skin: [f32; 3], shirt: [f32; 3], pants: [f32; 3]) -> Self {
509 self.skin = skin;
510 self.shirt = shirt;
511 self.pants = pants;
512 self
513 }
514
515 /// Cambia la animación. Si es un clip distinto, arranca un **cross-fade**: la
516 /// pose saliente se mezcla con la nueva durante [`BLEND_DUR`] segundos (sin
517 /// saltos). Repetir el mismo clip no corta nada.
518 pub fn set_clip(&mut self, clip: Clip) {
519 if self.clip != clip {
520 self.prev_clip = Some(self.clip);
521 self.prev_phase = self.phase;
522 self.clip = clip;
523 self.phase = 0.0;
524 self.blend = 0.0;
525 }
526 }
527
528 /// Avanza la animación `dt` segundos: la fase a la cadencia del clip, y —si hay
529 /// transición— la fase saliente y el progreso del cross-fade. El movimiento de
530 /// `pos`/`facing` lo maneja el llamador (la dirección).
531 pub fn advance(&mut self, dt: f32) {
532 self.phase += dt * self.clip.cadence();
533 if let Some(pc) = self.prev_clip {
534 self.prev_phase += dt * pc.cadence();
535 self.blend += dt / BLEND_DUR;
536 if self.blend >= 1.0 {
537 self.prev_clip = None;
538 }
539 }
540 }
541
542 /// La pose actual del cuerpo: la del clip vigente, o —durante un cambio— la
543 /// **mezcla** suave entre el clip saliente y el entrante.
544 pub fn pose(&self) -> Pose {
545 let target = self.clip.pose(self.phase);
546 let mut p = match self.prev_clip {
547 Some(pc) => Pose::lerp(&pc.pose(self.prev_phase), &target, smoothstep(self.blend)),
548 None => target,
549 };
550 // El suelo manda sobre el clip: un clip no sabe en qué pendiente está parado.
551 p.pie_l = self.apoyo.lift_l;
552 p.pie_r = self.apoyo.lift_r;
553 p.roll = self.apoyo.roll;
554 if let Some(t) = self.point_target {
555 self.señalar(&mut p, t);
556 }
557 p
558 }
559
560 /// Resuelve el **IK del brazo señalador**: lleva el blanco al espacio local, elige
561 /// el brazo del lado correcto y despeja los dos ángulos del hombro que ponen la
562 /// mano en la línea hombro→blanco.
563 ///
564 /// La dirección del brazo derecho para `(apertura o, balanceo p)` es
565 /// `(cos p·sin o, −cos p·cos o, −sin p)` (así lo arma [`mesh`](Self::mesh)), de
566 /// donde salen `p = −asin(u.z)` y `o = atan2(u.x, −u.y)`; el brazo izquierdo
567 /// espeja la apertura. Los topes evitan que el hombro se disloque.
568 fn señalar(&self, p: &mut Pose, target: Vec3) {
569 let b = &self.build;
570 let d = target - self.pos;
571 let local = Mat4::from_rotation_y(-self.facing).transform_vector3(d);
572 let derecho = local.x >= 0.0;
573 let sh = Vec3::new(if derecho { b.shoulder_x } else { -b.shoulder_x }, b.shoulder_y, 0.0);
574 let v = local - sh;
575 if v.length_squared() < 1e-8 {
576 return;
577 }
578 let u = v.normalize();
579 let pitch = (-u.z.clamp(-1.0, 1.0)).asin().clamp(-2.2, 2.2);
580 let out_r = u.x.atan2(-u.y);
581 let out_l = (-u.x).atan2(-u.y);
582 if derecho {
583 p.arm_r = pitch;
584 p.arm_r_out = out_r.clamp(-0.5, 2.9);
585 } else {
586 p.arm_l = pitch;
587 p.arm_l_out = out_l.clamp(-0.5, 2.9);
588 }
589 }
590
591 /// Orienta al actor para mirar hacia `target` (sólo el plano horizontal).
592 pub fn face_towards(&mut self, target: Vec3) {
593 let d = target - self.pos;
594 if d.x.abs() + d.z.abs() > 1e-4 {
595 self.facing = d.x.atan2(d.z); // yaw=0 → +Z, consistente con forward_h
596 }
597 }
598
599 /// Matriz de ubicación en mundo: traslada a `pos` y rota por `facing`. La
600 /// malla de [`mesh`](Self::mesh) está en espacio local; este es el `model`
601 /// del [`Renderer3d`](llimphi_3d::Renderer3d).
602 pub fn model(&self) -> Mat4 {
603 Mat4::from_translation(self.pos) * Mat4::from_rotation_y(self.facing)
604 }
605
606 /// Construye la **malla del cuerpo** en espacio local (pies en el origen,
607 /// mirando a `+Z`) para la pose del clip/fase actuales. 6 cajas. El cuerpo
608 /// superior (torso/cabeza/brazos) lleva el `bob`+`lean` de la pose; las
609 /// piernas quedan plantadas (sólo su balanceo de cadera) para no levantar los
610 /// pies del suelo. Subir con `Renderer3d::set_geometry` y ubicar con
611 /// [`model`](Self::model).
612 pub fn mesh(&self) -> (Vec<Vertex3d>, Vec<u16>) {
613 let p = self.pose();
614 let b = &self.build;
615 let mut v = Vec::with_capacity(8 * 11);
616 let mut i = Vec::with_capacity(36 * 11);
617
618 // Transform del cuerpo superior: rebote vertical + inclinación adelante
619 // (rotación en X alrededor de los pies/origen).
620 let body = Mat4::from_translation(Vec3::new(0.0, p.bob, 0.0))
621 * Mat4::from_rotation_z(p.roll)
622 * Mat4::from_rotation_x(p.lean);
623
624 // Torso.
625 push_cube(&mut v, &mut i, body * trs(Vec3::new(0.0, b.torso_y, 0.0), Mat4::IDENTITY, b.torso), self.shirt);
626
627 // Cabeza: cabeceo del clip + IK de mirada (yaw/pitch hacia el objetivo). El
628 // `head_anchor` (sin escala) ancla cabeza, ojos y boca para que giren juntos.
629 let (look_yaw, look_pitch) = self.look_angles();
630 let head_rot = Mat4::from_rotation_y(look_yaw) * Mat4::from_rotation_x(p.head_pitch + look_pitch);
631 let head_anchor = body * Mat4::from_translation(Vec3::new(0.0, b.head_y, 0.0)) * head_rot;
632 push_cube(&mut v, &mut i, head_anchor * Mat4::from_scale(b.head), self.skin);
633
634 // Cara: dos ojos + boca en la cara `+Z` de la cabeza. Las posiciones/tamaños
635 // van como **fracción del tamaño de la cabeza** → escalan con la edad (un bebé
636 // cabezón tiene ojos más grandes). Decales finos (apenas sobresalen, estilo
637 // Minecraft). Parpadeo determinista + boca que se abre con los gestos.
638 let (hw, hh, hd) = (b.head.x, b.head.y, b.head.z);
639 let blink = self.blink(); // 1 = abierto, ~0 = cerrado
640 let eye_sz = Vec3::new(0.095 * hw, (0.15 * hh * blink).max(0.012), 0.05 * hd);
641 let face_z = hd * 0.49; // casi en la cara +Z
642 for sx in [0.26_f32, -0.26] {
643 push_cube(
644 &mut v,
645 &mut i,
646 head_anchor * trs(Vec3::new(sx * hw, 0.12 * hh, face_z), Mat4::IDENTITY, eye_sz),
647 EYE_COLOR,
648 );
649 }
650 let mouth_open = self.mouth_open();
651 push_cube(
652 &mut v,
653 &mut i,
654 head_anchor * trs(Vec3::new(0.0, -0.25 * hh, face_z), Mat4::IDENTITY, Vec3::new(0.38 * hw, 0.05 * hh + mouth_open, 0.05 * hd)),
655 MOUTH_COLOR,
656 );
657
658 // Piernas (sin `body`: pies plantados). Articulación en la cadera. Con apoyo
659 // en relieve, la pierna del pie más alto se **acorta** (la cadera queda donde
660 // está y el pie sube), que es lo que hace una rodilla en una ladera.
661 let leg_rot_r = Mat4::from_rotation_x(p.leg_r);
662 let leg_rot_l = Mat4::from_rotation_x(p.leg_l);
663 let largo = |lift: f32| (b.leg_len * (1.0 - lift.clamp(0.0, 0.85))).max(1e-3);
664 let (len_r, len_l) = (largo(p.pie_r), largo(p.pie_l));
665 let caja = |len: f32| Vec3::new(b.leg.x, len, b.leg.z);
666 limb(&mut v, &mut i, Mat4::IDENTITY, Vec3::new(b.hip_x, b.hip_y, 0.0), len_r, caja(len_r), leg_rot_r, self.pants);
667 limb(&mut v, &mut i, Mat4::IDENTITY, Vec3::new(-b.hip_x, b.hip_y, 0.0), len_l, caja(len_l), leg_rot_l, self.pants);
668
669 // Brazos (con `body`). Rotación = apertura(Z)·balanceo(X); apertura espejada.
670 let arm_r_rot = Mat4::from_rotation_z(p.arm_r_out) * Mat4::from_rotation_x(p.arm_r);
671 let arm_l_rot = Mat4::from_rotation_z(-p.arm_l_out) * Mat4::from_rotation_x(p.arm_l);
672 let (sh_r, sh_l) = (Vec3::new(b.shoulder_x, b.shoulder_y, 0.0), Vec3::new(-b.shoulder_x, b.shoulder_y, 0.0));
673 limb(&mut v, &mut i, body, sh_r, b.arm_len, b.arm, arm_r_rot, self.shirt);
674 limb(&mut v, &mut i, body, sh_l, b.arm_len, b.arm, arm_l_rot, self.shirt);
675
676 // Manos: una caja de piel en la punta de cada brazo (a `arm_len` del hombro).
677 hand_at(&mut v, &mut i, body, sh_r, b.arm_len, arm_r_rot, b.hand, self.skin);
678 hand_at(&mut v, &mut i, body, sh_l, b.arm_len, arm_l_rot, b.hand, self.skin);
679
680 (v, i)
681 }
682
683 /// Ángulos `(yaw, pitch)` de la cabeza para el IK de mirada: dirección al objetivo
684 /// llevada al espacio local del actor (deshaciendo `facing`) y acotada a un rango
685 /// creíble (±70° yaw, ±50° pitch) para que el cuello no se quiebre. `(0,0)` si no
686 /// hay objetivo.
687 fn look_angles(&self) -> (f32, f32) {
688 use std::f32::consts::FRAC_PI_2;
689 let Some(target) = self.look_target else { return (0.0, 0.0) };
690 // Posición aproximada de la cabeza en mundo (pies + 1.62 de altura).
691 let head_pos = self.pos + Vec3::new(0.0, 1.62, 0.0);
692 let d = target - head_pos;
693 if d.length_squared() < 1e-6 {
694 return (0.0, 0.0);
695 }
696 // A espacio local: deshacer el yaw del cuerpo.
697 let local = Mat4::from_rotation_y(-self.facing).transform_vector3(d).normalize();
698 let yaw = local.x.atan2(local.z).clamp(-1.22, 1.22); // ±70°
699 let pitch = (-local.y).asin().clamp(-0.87, 0.87); // ±50°, mirar arriba/abajo
700 let _ = FRAC_PI_2;
701 (yaw, pitch)
702 }
703
704 /// Apertura de párpados `0..1` (1 = abierto). Parpadeo determinista por la fase:
705 /// un cierre breve cada ~3 unidades de fase. Idle no necesita objetivo.
706 fn blink(&self) -> f32 {
707 let ph = self.phase * 0.33; // ciclos lentos
708 let f = ph - ph.floor();
709 // Cerrado sólo en una ventana corta (~6% del ciclo).
710 if f > 0.94 {
711 // Sube y baja rápido dentro de la ventana (triángulo invertido).
712 let w = (f - 0.94) / 0.06; // 0..1
713 (1.0 - (1.0 - (2.0 * w - 1.0).abs())).clamp(0.0, 1.0)
714 } else {
715 1.0
716 }
717 }
718
719 /// Apertura de la boca (unidades): abierta en los gestos expresivos (festejar más
720 /// que saludar/señalar), cerrada el resto → la cara "reacciona" al clip.
721 fn mouth_open(&self) -> f32 {
722 match self.clip {
723 Clip::Cheer => 0.10 + 0.04 * self.phase.sin().abs(),
724 Clip::Wave | Clip::Point => 0.05,
725 _ => 0.0,
726 }
727 }
728}
729
730/// Color de los ojos (casi negro).
731const EYE_COLOR: [f32; 3] = [0.08, 0.07, 0.09];
732/// Color de la boca (marrón oscuro).
733const MOUTH_COLOR: [f32; 3] = [0.30, 0.12, 0.12];
734
735/// Apila la **mano** en la punta de un miembro: una caja de tamaño `hand` centrada a
736/// `len` del pivote `joint` (donde termina la caja del brazo), con la misma rotación
737/// `rot` del brazo y el mismo prefijo `pre`.
738#[allow(clippy::too_many_arguments)]
739fn hand_at(
740 v: &mut Vec<Vertex3d>,
741 i: &mut Vec<u16>,
742 pre: Mat4,
743 joint: Vec3,
744 len: f32,
745 rot: Mat4,
746 hand: Vec3,
747 color: [f32; 3],
748) {
749 let m = pre
750 * Mat4::from_translation(joint)
751 * rot
752 * Mat4::from_translation(Vec3::new(0.0, -len, 0.0))
753 * Mat4::from_scale(hand);
754 push_cube(v, i, m, color);
755}
756
757/// `T(center) · R · S(size)` — caja centrada en `center`, rotada por `rot`,
758/// escalada a `size` (un cubo unitario → su caja en el cuerpo).
759fn trs(center: Vec3, rot: Mat4, size: Vec3) -> Mat4 {
760 Mat4::from_translation(center) * rot * Mat4::from_scale(size)
761}
762
763/// Apila un **miembro articulado**: caja de tamaño `size` y largo `len` que
764/// cuelga del pivote `joint` (su extremo superior) y rota por `rot` en torno a
765/// ese pivote; todo prefijado por `pre` (el transform del cuerpo, o identidad
766/// para las piernas). El centro de la caja queda a `len/2` por debajo del pivote
767/// antes de rotar.
768#[allow(clippy::too_many_arguments)]
769fn limb(
770 v: &mut Vec<Vertex3d>,
771 i: &mut Vec<u16>,
772 pre: Mat4,
773 joint: Vec3,
774 len: f32,
775 size: Vec3,
776 rot: Mat4,
777 color: [f32; 3],
778) {
779 let m = pre
780 * Mat4::from_translation(joint)
781 * rot
782 * Mat4::from_translation(Vec3::new(0.0, -len / 2.0, 0.0))
783 * Mat4::from_scale(size);
784 push_cube(v, i, m, color);
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use std::f32::consts::FRAC_PI_2;
791
792 /// Rango en Z de los vértices de la malla (cuánto adelantan/atrasan miembros).
793 fn z_span(a: &Actor) -> f32 {
794 let z: Vec<f32> = a.mesh().0.iter().map(|v| v.pos[2]).collect();
795 z.iter().cloned().fold(f32::MIN, f32::max) - z.iter().cloned().fold(f32::MAX, f32::min)
796 }
797
798 #[test]
799 fn malla_tiene_once_cajas() {
800 // 6 del cuerpo (torso/cabeza/2 piernas/2 brazos) + 2 manos + 2 ojos + boca.
801 let a = Actor::new(Vec3::ZERO, 0.0);
802 let (v, idx) = a.mesh();
803 assert_eq!(v.len(), 8 * 11, "11 cajas × 8 vértices");
804 assert_eq!(idx.len(), 36 * 11, "11 cajas × 36 índices");
805 }
806
807 #[test]
808 fn edades_cambian_proporciones_y_dejan_los_pies_en_el_piso() {
809 let baby = Build::for_age(Age::Baby);
810 let adult = Build::for_age(Age::Adult);
811 // El bebé es más bajo que el adulto.
812 assert!(baby.height < adult.height, "bebé {} < adulto {}", baby.height, adult.height);
813 // ...y CABEZÓN: la cabeza ocupa una fracción mayor de su altura.
814 let head_frac = |b: &Build| b.head.y / b.height;
815 assert!(head_frac(&baby) > head_frac(&adult) + 0.05, "bebé cabezón: {} vs {}", head_frac(&baby), head_frac(&adult));
816 // Toda edad apoya los pies en y=0 (el voxel más bajo de la malla ≈ 0).
817 for age in [Age::Baby, Age::Child, Age::Teen, Age::Adult, Age::Elder] {
818 let a = Actor::new(Vec3::ZERO, 0.0).with_age(age);
819 let ymin = a.mesh().0.iter().map(|v| v.pos[1]).fold(f32::MAX, f32::min);
820 assert!(ymin.abs() < 1e-3, "pies en el piso para {age:?}: ymin={ymin}");
821 }
822 }
823
824 #[test]
825 fn adulto_conserva_los_once_cubos_y_altura_historica() {
826 // El refactor a Build no cambió al adulto: 11 cajas y altura ~1.82.
827 let a = Actor::new(Vec3::ZERO, 0.0);
828 assert_eq!(a.mesh().0.len(), 8 * 11);
829 assert!((a.build.height - 1.82).abs() < 0.05, "altura adulta {}", a.build.height);
830 }
831
832 /// Vértices de una de las 11 cajas de la malla, en el orden en que `mesh` las
833 /// apila: torso, cabeza, ojo, ojo, boca, pierna der, pierna izq, brazo der,
834 /// brazo izq, mano der, mano izq.
835 fn caja(a: &Actor, k: usize) -> Vec<Vec3> {
836 a.mesh().0[k * 8..(k + 1) * 8].iter().map(|v| Vec3::from_array(v.pos)).collect()
837 }
838
839 #[test]
840 fn los_pies_pisan_cada_uno_su_altura_en_la_ladera() {
841 // Ladera que sube hacia +X: el pie derecho queda más alto que el izquierdo.
842 let suelo = |x: f32, _z: f32| 0.5 * x;
843 let mut a = Actor::new(Vec3::new(0.0, 0.0, 0.0), 0.0);
844 a.apoyar_en(suelo);
845
846 let modelo = a.model();
847 let planta = |k: usize| {
848 caja(&a, k)
849 .iter()
850 .map(|v| modelo.transform_point3(*v).y)
851 .fold(f32::MAX, f32::min)
852 };
853 let hip_x = a.build.hip_x;
854 let (pie_r, pie_l) = (planta(5), planta(6));
855 assert!(
856 (pie_r - suelo(hip_x, 0.0)).abs() < 1e-3,
857 "el pie derecho apoya en su cota: {pie_r} vs {}",
858 suelo(hip_x, 0.0)
859 );
860 assert!(
861 (pie_l - suelo(-hip_x, 0.0)).abs() < 1e-3,
862 "el pie izquierdo apoya en la suya: {pie_l} vs {}",
863 suelo(-hip_x, 0.0)
864 );
865 assert!(pie_r > pie_l, "en esta ladera el derecho va más alto");
866 // La raíz baja al pie más bajo, y el cuerpo se alabea hacia la pendiente.
867 assert!((a.pos.y - suelo(-hip_x, 0.0)).abs() < 1e-3, "raíz en el pie bajo");
868 assert!(a.pose().roll > 0.1, "el lado derecho sube: roll={}", a.pose().roll);
869 }
870
871 #[test]
872 fn en_lo_llano_el_apoyo_no_toca_nada() {
873 // Un piso plano no debe mover un solo vértice respecto del actor sin apoyo:
874 // el IK tiene que ser invisible donde no hace falta.
875 let mut plantado = Actor::new(Vec3::new(3.0, 0.0, -2.0), 0.7);
876 plantado.set_clip(Clip::Walk);
877 plantado.advance(0.4);
878 let mut suelto = plantado;
879 plantado.apoyar_en(|_, _| 0.0);
880 assert_eq!(plantado.pos, suelto.pos, "el piso plano no mueve la raíz");
881 let (a, _) = plantado.mesh();
882 let (b, _) = suelto.mesh();
883 assert_eq!(a.len(), b.len());
884 for (x, y) in a.iter().zip(&b) {
885 assert_eq!(x.pos, y.pos, "malla idéntica en lo llano");
886 }
887 // Y `despegar` deshace un apoyo previo.
888 suelto.apoyar_en(|x, _| 0.4 * x);
889 suelto.despegar();
890 assert_eq!(suelto.pose().pie_r, 0.0);
891 assert_eq!(suelto.pose().roll, 0.0);
892 }
893
894 #[test]
895 fn un_escalon_altisimo_no_disloca_la_pierna() {
896 // El IK acorta la pierna, no la invierte: un muro no se sube estirando.
897 let mut a = Actor::new(Vec3::ZERO, 0.0);
898 a.apoyar_en(|x, _| if x > 0.0 { 50.0 } else { 0.0 });
899 let p = a.pose();
900 assert!(p.pie_r > 0.0 && p.pie_r <= 0.6, "el tope del apoyo es 0,6 de pierna: {}", p.pie_r);
901 let planta_r = caja(&a, 5).iter().map(|v| v.y).fold(f32::MAX, f32::min);
902 assert!(planta_r >= 0.0, "el pie no atraviesa la cadera: {planta_r}");
903 }
904
905 #[test]
906 fn el_brazo_senalador_pone_la_mano_en_la_linea_del_blanco() {
907 // Actor quieto en fase 0 (sin rebote ni inclinación) para que el único efecto
908 // sobre el brazo sea el IK.
909 let comprobar = |blanco: Vec3, mano_derecha: bool| {
910 let mut a = Actor::new(Vec3::new(1.0, 0.0, 2.0), 0.9);
911 a.apuntar_a(Some(blanco));
912 let modelo = a.model();
913 let k_mano = if mano_derecha { 9 } else { 10 };
914 let centro: Vec3 =
915 caja(&a, k_mano).iter().fold(Vec3::ZERO, |acc, v| acc + *v) / 8.0;
916 let mano = modelo.transform_point3(centro);
917 let sx = if mano_derecha { a.build.shoulder_x } else { -a.build.shoulder_x };
918 let hombro = modelo.transform_point3(Vec3::new(sx, a.build.shoulder_y, 0.0));
919 let al_blanco = (blanco - hombro).normalize();
920 let al_puno = (mano - hombro).normalize();
921 let desvio = al_blanco.cross(al_puno).length();
922 assert!(
923 desvio < 0.02 && al_blanco.dot(al_puno) > 0.99,
924 "la mano no cae en la línea hombro→blanco (desvío {desvio})"
925 );
926 };
927 // A la derecha del actor (su +X local) señala con la derecha; a la izquierda,
928 // con la izquierda; y arriba también.
929 let mut a = Actor::new(Vec3::new(1.0, 0.0, 2.0), 0.9);
930 let der = a.model().transform_point3(Vec3::new(6.0, 1.4, 0.0));
931 let izq = a.model().transform_point3(Vec3::new(-6.0, 1.4, 0.0));
932 let arriba = a.model().transform_point3(Vec3::new(2.0, 6.0, 1.0));
933 comprobar(der, true);
934 comprobar(izq, false);
935 comprobar(arriba, true);
936 // Sin objetivo, el clip manda: el brazo vuelve a lo que anima Idle.
937 a.apuntar_a(Some(der));
938 let apuntando = a.pose().arm_r_out;
939 a.apuntar_a(None);
940 assert!((a.pose().arm_r_out - Clip::Idle.pose(0.0).arm_r_out).abs() < 1e-6);
941 assert!(apuntando > 0.5, "señalar al costado abre el hombro: {apuntando}");
942 }
943
944 #[test]
945 fn ik_de_mirada_gira_la_cabeza_hacia_el_objetivo() {
946 // Mirar a la derecha (mundo +X) vs a la izquierda (−X): los ojos (vértices más
947 // adelantados en la cara) deben desplazarse en X en sentidos opuestos.
948 let eye_centroid_x = |a: &Actor| {
949 // Los ojos están en la cara +Z, son los vértices con mayor z y |x|≈0.11.
950 let verts = a.mesh().0;
951 let zmax = verts.iter().map(|v| v.pos[2]).fold(f32::MIN, f32::max);
952 let front: Vec<f32> =
953 verts.iter().filter(|v| v.pos[2] > zmax - 0.05).map(|v| v.pos[0]).collect();
954 front.iter().sum::<f32>() / front.len().max(1) as f32
955 };
956 let mut right = Actor::new(Vec3::ZERO, 0.0);
957 right.look_at(Some(Vec3::new(10.0, 1.62, 1.0)));
958 let mut left = Actor::new(Vec3::ZERO, 0.0);
959 left.look_at(Some(Vec3::new(-10.0, 1.62, 1.0)));
960 // Mirar a +X adelanta la cara hacia +X; a −X, hacia −X.
961 assert!(eye_centroid_x(&right) > eye_centroid_x(&left) + 0.05, "la cabeza sigue al objetivo");
962 }
963
964 #[test]
965 fn caminar_balancea_las_piernas() {
966 // A fase π/2 el seno es máximo → las piernas separan al máximo.
967 let mut a = Actor::new(Vec3::ZERO, 0.0);
968 a.set_clip(Clip::Walk);
969 a.advance(FRAC_PI_2 / Clip::Walk.cadence());
970 assert!(z_span(&a) > 0.5, "al caminar los miembros adelantan/atrasan: {}", z_span(&a));
971 }
972
973 #[test]
974 fn idle_casi_quieto() {
975 // En Idle los miembros no se balancean: el span en Z es chico.
976 let mut a = Actor::new(Vec3::ZERO, 0.0); // Idle por defecto
977 a.advance(FRAC_PI_2 / Clip::Idle.cadence());
978 assert!(z_span(&a) < 0.45, "Idle no debería balancear: {}", z_span(&a));
979 }
980
981 #[test]
982 fn cambiar_de_clip_reinicia_la_fase() {
983 let mut a = Actor::new(Vec3::ZERO, 0.0);
984 a.set_clip(Clip::Walk);
985 a.advance(1.0);
986 assert!(a.phase > 0.0);
987 a.set_clip(Clip::Run);
988 assert_eq!(a.phase, 0.0, "un clip nuevo arranca la pose desde 0");
989 // Repetir el mismo clip NO corta la fase.
990 a.advance(0.5);
991 let ph = a.phase;
992 a.set_clip(Clip::Run);
993 assert_eq!(a.phase, ph);
994 }
995
996 #[test]
997 fn cambio_de_clip_hace_cross_fade() {
998 // Caminando con las piernas bien abiertas…
999 let mut a = Actor::new(Vec3::ZERO, 0.0);
1000 a.set_clip(Clip::Walk);
1001 a.advance(FRAC_PI_2 / Clip::Walk.cadence());
1002 let span_walk = z_span(&a);
1003 assert!(span_walk > 0.5);
1004
1005 // …al pasar a Idle, JUSTO después la pose sigue siendo ~la de caminar
1006 // (blend≈0), no salta de golpe a quieto.
1007 a.set_clip(Clip::Idle);
1008 let span_inicio = z_span(&a);
1009 assert!((span_inicio - span_walk).abs() < 0.05, "el cross-fade arranca desde la pose saliente");
1010
1011 // Pasado el blend, ya es Idle (piernas juntas).
1012 a.advance(BLEND_DUR + 0.1);
1013 assert!(z_span(&a) < 0.45, "tras el cross-fade la pose es Idle");
1014 }
1015
1016 #[test]
1017 fn face_towards_mira_a_mas_z() {
1018 let mut a = Actor::new(Vec3::ZERO, 0.0);
1019 a.face_towards(Vec3::new(0.0, 0.0, 5.0));
1020 assert!(a.facing.abs() < 1e-4, "mirar a +Z → yaw≈0");
1021 }
1022}