gioser

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Sutichaykuna

⎇ main llimphi-voxel/src/actor.rs Rust · 1022 siq'ikuna · 42.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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}