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//! **Humanoide voxel REDONDEADO y expresivo** — el ser estilizado que aprovecha la
2//! no-voxelidad del motor (bisel/isosuperficie sub-voxel) para un look vinyl-toy /
3//! Fall-Guys / chibi CON carácter, en vez del muñeco de cajas tipo Minecraft.
4//!
5//! ## Por qué existe (lección 2026-07-18, ver [[detalle-sub-voxel-rugosidad]])
6//!
7//! El [`Actor`](crate::Actor) histórico es una **malla de 11 cajas** (`push_cube`): se
8//! ve cuadrado. Y voxelizar una foto de persona en LITERAL cae en el valle de inquietud
9//! (a tamaño grande da terror). La regla acordada: **la especie tiene su ESTILO PROPIO**
10//! (proporciones cuantizadas, gestos, ojos grandes) y la foto sólo regula RASGOS
11//! (piel/ropa). Este módulo materializa ese estilo como un **volumen voxel** de primitivas
12//! orgánicas (elipsoides + cápsulas) que, renderizado con `bisel` alto + `silueta_por_voxel`,
13//! el ray-marcher redondea a una figura simpática. Comparte la MISMA gramática de campo que
14//! el terreno/fauna (no otra fidelidad → sin valle).
15//!
16//! ## Diseño anclado en literatura (no intuición)
17//!
18//! Los parámetros de [`EstiloSer`] salen de la teoría del diseño de personajes y la
19//! percepción, no de tanteo visual:
20//! - **Kindchenschema / "baby schema"** (Konrad Lorenz, 1943; Glocker et al. 2009): cabeza
21//! grande respecto al cuerpo, **ojos grandes y BAJOS** (a/por debajo de la mitad de la cara),
22//! cara **redonda/ancha** (nunca alargada), nariz/boca chicas, mentón blando, mejillas y
23//! miembros rechonchos → dispara la respuesta de ternura (activa el núcleo accumbens) y
24//! **escapa del valle**. Dos correcciones de la literatura reciente que este módulo respeta:
25//! (1) *la frente grande se logra BAJANDO los ojos, no estirando el cráneo* — el lever robusto
26//! es la posición baja de los rasgos + mandíbula angosta, no la altura de la frente (estudio
27//! morfométrico 2022); (2) *lo que suma es EVITAR ojos chicos*, no inflarlos sin límite
28//! (Frontiers 2021: grande-vs-natural no fue significativo). → [`EstiloSer::ojo_tam`]/
29//! [`ojo_altura`](EstiloSer::ojo_altura)/[`mejilla`](EstiloSer::mejilla)/
30//! [`extremidad`](EstiloSer::extremidad).
31//! - **Canon de proporciones / "cabezas de alto"**: realista ≈ 7,5–8 cabezas; heroico ≈ 8,5;
32//! cartoon estilizado ≈ 4–6; niño ≈ 4–5; **chibi/super-deformado ≈ 2–4**. Menos cabezas es
33//! el **lever más fuerte** de "más tierno / más joven" (la ontogenia real va de cabeza=½ en el
34//! feto a 1/7 en el adulto → un cabezón se lee juvenil sin importar el resto). → [`EstiloSer::cabezas`]
35//! (default chibi ~2,6, NO realista).
36//! - **Shape language**: círculo = amable/suave, cuadrado = estable/fiable, triángulo =
37//! dinámico/agresivo. Un personaje se lee por su **forma dominante** (el "appeal" nace de
38//! comprometerse con UNA forma). → eje [`redondez`](EstiloSer::redondez) (círculo↔cuadrado,
39//! controla también el `bisel`) y [`angulosidad`](EstiloSer::angulosidad) (hombros anchos/
40//! cadera angosta = triángulo).
41//! - **Valle de inquietud** (Mori; Realism Inconsistency Theory, MacDorman): el valle lo
42//! dispara **mezclar fidelidades** (rasgos en distintos niveles de realismo), no la
43//! caricatura. Ojos grandes sobre cara FOTORREAL = lo más siniestro. Por eso el realismo se
44//! mantiene **uniforme y bajo** y la foto SÓLO regula color (piel/ropa), nunca inyecta
45//! geometría/textura fotográfica.
46//!
47//! ## Gestos
48//!
49//! [`humanoide_voxel`] recibe una [`Pose`](crate::Pose) (los ángulos de articulación del
50//! catálogo de [`Clip`](crate::Clip)) y la aplica a las cápsulas de miembros → el ser
51//! redondeado saluda / señala / camina con la misma expresividad que el [`Actor`], sin
52//! cajas. Es puro y determinista (testeable sin GPU).
53
54use llimphi_3d::glam::Vec3;
55use llimphi_3d::VoxelGrid;
56use serde::{Deserialize, Serialize};
57
58use crate::actor::{Age, Pose};
59
60/// **Estilo de un ser** — el juego de perillas de diseño (ver el módulo para las fuentes).
61/// Todas tienen un default con carga semántica; los presets ([`por_edad`](Self::por_edad),
62/// [`amable`](Self::amable), [`robusto`](Self::robusto), [`agil`](Self::agil)) los combinan.
63#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
64pub struct EstiloSer {
65 /// **Cabezas de alto** (canon de proporciones). Chibi ≈ 2–4, cartoon ≈ 4–6, realista
66 /// ≈ 7,5. Menos = más tierno / más lejos del valle. La cabeza se dimensiona como
67 /// `altura / cabezas`.
68 pub cabezas: f32,
69 /// **Redondez** `[0,1]` (shape language, eje círculo↔cuadrado). `1` = elipsoides casi
70 /// esféricos + bisel máximo (amable/suave); `0` = ejes desiguales y menos bisel (más
71 /// bloque, estable/fiable). Controla también el detalle sub-voxel horneado.
72 pub redondez: f32,
73 /// **Angulosidad** `[0,1]` (shape language, sesgo triángulo): hombros anchos + cadera
74 /// angosta = silueta dinámica/atlética. `0` = cuerpo parejo (más infantil/tierno).
75 pub angulosidad: f32,
76 /// **Tamaño de ojo** como fracción del radio de la cabeza (Kindchenschema: grande =
77 /// tierno). Chibi ≈ 0,26; adulto estilizado ≈ 0,18.
78 pub ojo_tam: f32,
79 /// **Altura del ojo** en la cara, fracción del radio de cabeza desde el centro (positivo =
80 /// arriba). El lever de ternura MÁS robusto (estudio morfométrico 2022): ojos **por debajo**
81 /// de la mitad (**negativo**) agrandan la frente y suben el appeal. Adulto estilizado ≈ 0
82 /// (línea media, canon Loomis); chibi ≈ −0,2..−0,3 (tercio inferior de la cara). Positivo
83 /// (ojos altos) = más adulto/serio.
84 pub ojo_altura: f32,
85 /// **Cráneo**: modulador LEVE de la altura de la cabeza. La literatura reciente advierte
86 /// que la cara tierna es **redonda/ancha, nunca alargada**, y que la frente se logra
87 /// bajando los ojos (no estirando la cúpula) → este campo casi no estira en Y (clamp a
88 /// ~±6 %) y se conserva sólo por compatibilidad/afinado. `1` = cabeza redonda.
89 pub craneo: f32,
90 /// **Mejilla**: ensancha la mitad inferior de la cabeza (cara redonda de bebé). `1` = sin
91 /// ensanche.
92 pub mejilla: f32,
93 /// **Rechonchez de miembros**: radio de brazos/piernas como fracción de la altura
94 /// (chibi ≈ 0,075; esbelto ≈ 0,05). Miembros gorditos = más tierno.
95 pub extremidad: f32,
96 /// **Panza**: redondez del torso (semieje Y/X). `>1` = barriga/pecho abombado (tierno);
97 /// `1` = torso parejo.
98 pub panza: f32,
99}
100
101impl Default for EstiloSer {
102 /// El default es un **chibi amable** (el look probado): ~2,6 cabezas, redondo, ojos
103 /// grandes a la altura media, cráneo abombado. Reproduce el personaje del `demo_elimon`.
104 fn default() -> Self {
105 Self {
106 cabezas: 2.6,
107 redondez: 1.0,
108 angulosidad: 0.0,
109 ojo_tam: 0.26,
110 ojo_altura: -0.20,
111 craneo: 1.0,
112 mejilla: 1.08,
113 extremidad: 0.075,
114 panza: 1.05,
115 }
116 }
117}
118
119impl EstiloSer {
120 /// Estilo por **edad**: el canon de cabezas y el baby-schema varían con el estadio.
121 /// Bebé = 2 cabezas, ojos enormes y bajos, cráneo grande (máxima ternura); adulto
122 /// estilizado = 4,5 cabezas, ojos moderados a la media (NO 7,5 realista: se busca appeal
123 /// y anti-valle). Escala monótona en el medio.
124 pub fn por_edad(age: Age) -> Self {
125 // Escala del "dial de ternura" (mature→cute) coordinada con las cabezas: menos
126 // cabezas ⇒ ojos más grandes y MÁS BAJOS (tercio inferior), cara más ancha, miembros
127 // más gordos. Adulto estilizado = ojos a la línea media (0), NO 7,5 realista (appeal +
128 // anti-valle). (cabezas, ojo_tam, ojo_altura, craneo, mejilla, extremidad, panza)
129 let (cab, ot, oa, cr, mj, ex, pz) = match age {
130 Age::Baby => (2.0, 0.32, -0.32, 1.0, 1.16, 0.092, 1.22),
131 Age::Child => (3.4, 0.28, -0.24, 1.0, 1.10, 0.080, 1.14),
132 Age::Teen => (4.2, 0.22, -0.12, 1.0, 1.04, 0.066, 1.06),
133 Age::Adult => (4.5, 0.19, -0.04, 1.0, 1.00, 0.060, 1.04),
134 Age::Elder => (4.3, 0.19, -0.05, 1.0, 1.02, 0.062, 1.06),
135 };
136 Self {
137 cabezas: cab,
138 redondez: 1.0,
139 angulosidad: 0.0,
140 ojo_tam: ot,
141 ojo_altura: oa,
142 craneo: cr,
143 mejilla: mj,
144 extremidad: ex,
145 panza: pz,
146 }
147 }
148
149 /// **Arquetipo redondo** (shape language: círculo → amable/suave). Chibi cabezón,
150 /// máxima redondez, ojos grandes: el default reforzado.
151 pub fn amable() -> Self {
152 Self::default()
153 }
154
155 /// **Arquetipo robusto** (shape language: cuadrado → estable/fiable). Menos redondez
156 /// (más bloque), cuerpo ancho y parejo, ojos más chicos, algo más de cabezas.
157 pub fn robusto() -> Self {
158 Self {
159 cabezas: 3.6,
160 redondez: 0.45,
161 angulosidad: 0.15,
162 ojo_tam: 0.19,
163 ojo_altura: -0.02,
164 craneo: 1.0,
165 mejilla: 1.12,
166 extremidad: 0.085,
167 panza: 1.02,
168 }
169 }
170
171 /// **Arquetipo ágil** (shape language: triángulo → dinámico/atlético). Más alto, hombros
172 /// anchos y cadera angosta (angulosidad alta), miembros más finos.
173 pub fn agil() -> Self {
174 Self {
175 cabezas: 4.6,
176 redondez: 0.7,
177 angulosidad: 0.7,
178 ojo_tam: 0.19,
179 ojo_altura: 0.02,
180 craneo: 1.0,
181 mejilla: 0.98,
182 extremidad: 0.055,
183 panza: 0.98,
184 }
185 }
186
187 /// Detalle sub-voxel `(rugosidad, bisel)` horneado por voxel: el bisel sube con la
188 /// redondez (silueta lisa), la rugosidad es un grano de piel constante suave. Es la
189 /// gramática de campo del ser vivo — misma que el terreno, con más redondeo.
190 pub fn detalle(&self) -> (f32, f32) {
191 let bisel = (0.35 + 0.65 * self.redondez.clamp(0.0, 1.0)).clamp(0.0, 1.0);
192 // Grano de piel MUY leve: de cerca (el objetivo del plan) una rugosidad fuerte se
193 // ve como escamas/beading. Suficiente para no quedar plástico plano, no más.
194 (0.06, bisel)
195 }
196}
197
198/// Colores de un ser (`[r,g,b]` en `[0,1]`, como [`Actor`](crate::Actor)): los que la foto
199/// regula (piel/remera/pantalón) + el pelo (derivado o fijo).
200#[derive(Debug, Clone, Copy)]
201pub struct Colores {
202 pub skin: [f32; 3],
203 pub shirt: [f32; 3],
204 pub pants: [f32; 3],
205 pub hair: [f32; 3],
206}
207
208impl Colores {
209 /// Colores desde un [`CharSpec`](crate::CharSpec)-like (piel/remera/pantalón), con un
210 /// pelo derivado (marrón oscuro tintado hacia la piel) para que combine. Usa
211 /// [`con_pelo`](Self::con_pelo) si tienes el color de pelo REAL (de la foto).
212 pub fn nuevos(skin: [f32; 3], shirt: [f32; 3], pants: [f32; 3]) -> Self {
213 // Pelo: oscuro cálido, apenas influido por el tono de piel (combina con retratos).
214 let hair = [
215 (0.18 + skin[0] * 0.12).min(1.0),
216 (0.13 + skin[1] * 0.10).min(1.0),
217 (0.12 + skin[2] * 0.08).min(1.0),
218 ];
219 Self { skin, shirt, pants, hair }
220 }
221
222 /// Colores con **pelo explícito** (p.ej. muestreado FIEL de la foto), no derivado.
223 pub fn con_pelo(skin: [f32; 3], shirt: [f32; 3], pants: [f32; 3], hair: [f32; 3]) -> Self {
224 Self { skin, shirt, pants, hair }
225 }
226}
227
228#[inline]
229fn u8c(c: [f32; 3]) -> [u8; 3] {
230 [
231 (c[0].clamp(0.0, 1.0) * 255.0) as u8,
232 (c[1].clamp(0.0, 1.0) * 255.0) as u8,
233 (c[2].clamp(0.0, 1.0) * 255.0) as u8,
234 ]
235}
236
237/// Rota `v` alrededor del eje X por `a` rad (plano Y-Z; adelante = +Z).
238#[inline]
239pub(crate) fn rot_x(v: Vec3, a: f32) -> Vec3 {
240 let (s, c) = a.sin_cos();
241 Vec3::new(v.x, v.y * c - v.z * s, v.y * s + v.z * c)
242}
243
244/// Rota `v` alrededor del eje Y por `a` rad (plano X-Z; girar la cabeza). Misma
245/// convención que el `yaw` del ser: `0` mira a `+Z`, positivo gira hacia `+X`.
246#[inline]
247pub(crate) fn rot_y(v: Vec3, a: f32) -> Vec3 {
248 let (s, c) = a.sin_cos();
249 Vec3::new(v.x * c + v.z * s, v.y, -v.x * s + v.z * c)
250}
251
252/// Rota `v` alrededor del eje Z por `a` rad (plano X-Y; abrir brazo al costado).
253#[inline]
254pub(crate) fn rot_z(v: Vec3, a: f32) -> Vec3 {
255 let (s, c) = a.sin_cos();
256 Vec3::new(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
257}
258
259/// **Construye el humanoide voxel** de `altura_vox` voxels de alto (referencia), con el
260/// `estilo` dado, en la `pose` dada (gestos), coloreado por `col`. Pies en `y≈0`, centrado
261/// en X/Z, mirando a `+Z`. Hornea el detalle sub-voxel por voxel (bisel según la redondez).
262/// Puro y determinista.
263pub fn humanoide_voxel(estilo: &EstiloSer, pose: &Pose, altura_vox: u32, col: Colores) -> VoxelGrid {
264 let h = (altura_vox.max(20)) as f32;
265 let cabezas = estilo.cabezas.clamp(1.6, 8.0);
266
267 // --- Proporciones derivadas del canon de cabezas + baby-schema ---
268 let head_r = h / (2.0 * cabezas); // radio de cabeza = ½ (altura / cabezas)
269 // La cabeza tierna es REDONDA/ancha, nunca alargada (lit. 2022): el cráneo casi no estira
270 // en Y (±6 %) — la frente grande la da bajar los ojos, no una cúpula alta.
271 let head_ry = head_r * (1.0 + 0.2 * (estilo.craneo - 1.0)).clamp(0.94, 1.06);
272 // La cabeza corona la figura; su tope roza `h`.
273 let head_cy = h - head_ry * 1.02;
274 let neck = head_r * 0.14;
275 let shoulder_y = (head_cy - head_ry - neck).max(head_r);
276
277 // Torso y piernas se reparten [0, shoulder_y]: chibi = piernas cortas. La fracción de
278 // pierna sube con `cabezas` (más alto = piernas proporcionalmente más largas).
279 let leg_frac = (0.28 + 0.055 * (cabezas - 2.0)).clamp(0.28, 0.52);
280 let hip_y = shoulder_y * leg_frac;
281 let torso_cy = (hip_y + shoulder_y) * 0.5;
282 let torso_ry = (shoulder_y - hip_y) * 0.5;
283 // Ancho del torso: la cabeza debe DOMINAR (head:torso ≈ 1:0,8, cabeza mayor). Base por
284 // altura, modulada por redondez (redondo = más parejo).
285 let torso_rx = h * (0.13 + 0.05 * estilo.redondez);
286 // Profundidad casi igual al ancho (torso REDONDO, no una losa chata): un torso poco
287 // profundo presenta una cara frontal casi plana donde la normal varía lentísimo → el
288 // escalonado residual del gradiente se ve como anillos anchos. Redondearlo aprieta los
289 // pasos y da una panza de peluche.
290 let torso_rz = torso_rx * 0.92;
291
292 let limb_r = h * estilo.extremidad;
293
294 // Shape language triángulo: hombros anchos, cadera angosta con la angulosidad. Los hombros
295 // se anclan JUSTO AFUERA del torso (≥ `torso_rx`) para que los brazos cuelguen al costado con
296 // una junta limpia — hundirlos DENTRO del torso (como antes, 0,72·torso_rx) enterraba la
297 // cápsula del brazo y la normal ancha escalonaba esa junta sepultada → anillos en la panza.
298 let ang = estilo.angulosidad.clamp(0.0, 1.0);
299 let shoulder_x = torso_rx + limb_r * (0.35 + 0.9 * ang);
300 let hip_x = torso_rx * (0.55 - 0.18 * ang);
301 let leg_len = hip_y; // el pie llega a ~0
302 let arm_len = (shoulder_y - hip_y) * 1.02;
303
304 // --- Grid: bbox de la figura + margen (para que el bisel no toque el borde) ---
305 let half_w = (shoulder_x + limb_r * 1.4).max(torso_rx).max(head_r * estilo.mejilla) + 2.0;
306 let half_d = (torso_rz + limb_r).max(head_r) + arm_len * 0.4 + 2.0;
307 let gw = (half_w * 2.0).ceil() as u32 + 1;
308 let gd = (half_d * 2.0).ceil() as u32 + 1;
309 let gh = (h + head_ry * 0.3).ceil() as u32 + 2;
310 let mut g = VoxelGrid::new([gw, gh, gd]);
311 let cx = gw as f32 * 0.5;
312 let cz = gd as f32 * 0.5;
313 let base = Vec3::new(cx, 0.0, cz);
314
315 // Detalle sub-voxel del cuerpo (bisel por redondez) — pincel para todo lo que estampe.
316 let (rug, bisel) = estilo.detalle();
317 g.set_detail_brush(rug, bisel);
318
319 let skin = u8c(col.skin);
320 let shirt = u8c(col.shirt);
321 let pants = u8c(col.pants);
322 let hair = u8c(col.hair);
323
324 // Transform del cuerpo superior (torso/cabeza/brazos): rebote `bob` + inclinación `lean`
325 // alrededor de la cadera (pivote en `hip_y`). Las piernas quedan plantadas.
326 let bob = pose.bob * h * 0.5;
327 let upper = |local_y: f32, off: Vec3| -> Vec3 {
328 // punto (0, local_y, 0)+off, inclinado por lean sobre el pivote de cadera y subido bob
329 let rel = Vec3::new(off.x, local_y - hip_y, off.z);
330 let r = rot_x(rel, pose.lean);
331 base + Vec3::new(r.x, hip_y + r.y + bob, r.z)
332 };
333
334 // --- Piernas: cápsulas rechonchas, articuladas en la cadera (leg swing en X) ---
335 for (sx, swing) in [(-1.0f32, pose.leg_l), (1.0, pose.leg_r)] {
336 let hip = base + Vec3::new(sx * hip_x, hip_y, 0.0);
337 let dir = rot_x(Vec3::new(0.0, -leg_len, 0.0), swing);
338 let foot = hip + dir;
339 capsula(&mut g, hip, foot, limb_r * 1.05, pants);
340 }
341
342 // --- Torso: elipsoide redondo (panza) ---
343 let torso_c = upper(torso_cy, Vec3::ZERO);
344 elipsoide(&mut g, torso_c, Vec3::new(torso_rx, torso_ry * estilo.panza, torso_rz), shirt);
345
346 // --- Brazos: cápsulas al costado, articuladas en el hombro (out en Z, swing en X) ---
347 // Splay base: aun en reposo los brazos abren un poco hacia afuera para NO fundirse con el
348 // torso ni juntar las manos al frente (leía como "manos atadas"). Se suma a la apertura del gesto.
349 let base_out = 0.16;
350 for (sx, swing, out) in [(-1.0f32, pose.arm_l, pose.arm_l_out), (1.0, pose.arm_r, pose.arm_r_out)] {
351 let shoulder = upper(shoulder_y, Vec3::new(sx * shoulder_x, 0.0, 0.0));
352 // dirección local del brazo colgando, rotada: primero swing (X), luego apertura (Z).
353 let mut dir = rot_x(Vec3::new(0.0, -arm_len, 0.0), swing);
354 dir = rot_z(dir, sx * (out + base_out)); // apertura espejada por lado
355 // el brazo también acompaña el lean del cuerpo superior
356 dir = rot_x(dir, pose.lean);
357 let hand = shoulder + dir;
358 capsula(&mut g, shoulder, hand, limb_r, shirt);
359 // Mano: esferita de piel en la punta (apenas mayor que el brazo, no un guante gigante).
360 elipsoide(&mut g, hand, Vec3::splat(limb_r * 0.98), skin);
361 }
362
363 // --- Cabeza + cara (baby-schema) ---
364 let head_c = upper(head_cy, Vec3::ZERO);
365 // Cabeza: elipsoide con cúpula (craneo) arriba y mejilla abajo. La construimos como un
366 // elipsoide con radios asimétricos por hemisferio via `elipsoide_mejilla`.
367 let head_rx = head_r * estilo.mejilla;
368 elipsoide_cabeza(&mut g, head_c, head_r, head_rx, head_ry, skin);
369
370 // **Zona de bigote/barba = coloración PLANA, sin sustancia** (no una boca protuberante,
371 // que parecía un pico de pato): re-tiñe la piel de la mitad inferior FRONTAL de la cara a
372 // un tono apenas más apagado/oscuro (sombra de barba). No agrega geometría; se aplica
373 // ANTES del pelo/ojos para no pisarlos. Sutil a propósito (que no lea como máscara).
374 let eye_y0 = head_c.y + estilo.ojo_altura * head_r;
375 tint_barba(&mut g, head_c, head_rx, head_ry, head_r, eye_y0, skin);
376
377 // Pelo: **casquete alto** sobre la corona, corrido hacia atrás (−Z) para dejar la FRENTE
378 // y las mejillas de piel a la vista (la frente grande es el rasgo tierno). No baja hasta
379 // los ojos: recorte bien por encima → cara abierta, no un antifaz enmarcado.
380 let eye_y = head_c.y + estilo.ojo_altura * head_r;
381 elipsoide_min_y(
382 &mut g,
383 head_c + Vec3::new(0.0, head_ry * 0.40, -head_r * 0.14),
384 Vec3::new(head_rx * 1.02, head_ry * 0.80, head_r * 1.0),
385 hair,
386 eye_y + head_r * 0.58,
387 );
388
389 // Ojos: **esclera clara + pupila oscura + chispa** (NO un óvalo negro suelto, que a esta
390 // resolución + isosuperficie leía como un HUECO/cuenca vacía = cara de monstruo). El blanco
391 // domina y "abre" el ojo; la pupila oscura, más chica y al frente-centro, da la mirada; una
392 // chispa arriba-interior le pone vida. Anchos > altos (ojo infantil), BIEN separados, con piel
393 // en medio y alrededor (no antifaz). A/por debajo de la media (`ojo_altura`). `head_pitch` tilta.
394 let er = head_r * estilo.ojo_tam;
395 let ex = head_rx * 0.5;
396 let zf = head_r * 0.80;
397 let eoff = eye_y - head_c.y;
398 for s in [-1.0f32, 1.0] {
399 let eye = head_c + rot_x(Vec3::new(s * ex, eoff, zf), pose.head_pitch);
400 // Esclera: óvalo claro, ancho, apenas saliente al frente para que no se hunda.
401 elipsoide(&mut g, eye, Vec3::new(er * 1.06, er * 0.86, er * 0.62), [240, 241, 244]);
402 // Pupila/iris: oscura, ~mitad, corrida al FRENTE (+Z) y apenas hacia adentro (mirada
403 // convergente amable) y abajo. Va DESPUÉS de la esclera para pisarla en el centro.
404 let pupil = head_c + rot_x(Vec3::new(s * ex - s * er * 0.12, eoff - er * 0.08, zf + er * 0.28), pose.head_pitch);
405 elipsoide(&mut g, pupil, Vec3::new(er * 0.52, er * 0.6, er * 0.5), [40, 32, 46]);
406 // Chispa (arriba-interior de la pupila) — grande para que lea de lejos.
407 let spark = head_c + rot_x(Vec3::new(s * ex - s * er * 0.28, eoff + er * 0.34, zf + er * 0.5), pose.head_pitch);
408 elipsoide(&mut g, spark, Vec3::new(er * 0.3, er * 0.3, er * 0.34), [250, 251, 253]);
409 }
410
411 // **Boca = sonrisa como tint PLANO** (sin geometría protuberante: una boca de color en
412 // relieve leía como pico de pato). Re-tiñe una banda corta, curvada hacia ARRIBA (feliz),
413 // de la piel frontal-baja de la cara a un tono cálido apagado. Pequeña y centrada. Va
414 // después de barba/ojos para no pisarse.
415 let boca_y = eye_y - head_r * 0.44;
416 sonrisa(&mut g, head_c, head_rx, head_r, boca_y, skin);
417
418 g.clear_detail_brush();
419 g.reset_dirty();
420 g
421}
422
423/// Rellena un elipsoide de semiejes `r` centrado en `c` con `color`.
424fn elipsoide(g: &mut VoxelGrid, c: Vec3, r: Vec3, color: [u8; 3]) {
425 elipsoide_min_y(g, c, r, color, f32::NEG_INFINITY);
426}
427
428/// Elipsoide recortado por abajo (`y >= y_min`) — para casquetes de pelo.
429fn elipsoide_min_y(g: &mut VoxelGrid, c: Vec3, r: Vec3, color: [u8; 3], y_min: f32) {
430 let [dx, dy, dz] = g.dim();
431 let x0 = (c.x - r.x).floor().max(0.0) as u32;
432 let x1 = (c.x + r.x).ceil().min(dx as f32) as u32;
433 let y0 = (c.y - r.y).floor().max(0.0).max(y_min.max(0.0)) as u32;
434 let y1 = (c.y + r.y).ceil().min(dy as f32) as u32;
435 let z0 = (c.z - r.z).floor().max(0.0) as u32;
436 let z1 = (c.z + r.z).ceil().min(dz as f32) as u32;
437 for x in x0..x1 {
438 for y in y0..y1 {
439 if (y as f32) < y_min {
440 continue;
441 }
442 for z in z0..z1 {
443 let d = Vec3::new(
444 (x as f32 + 0.5 - c.x) / r.x,
445 (y as f32 + 0.5 - c.y) / r.y,
446 (z as f32 + 0.5 - c.z) / r.z,
447 );
448 if d.length_squared() <= 1.0 {
449 g.set(x, y, z, color);
450 }
451 }
452 }
453 }
454}
455
456/// Cabeza: elipsoide con **cúpula arriba** (semieje Y superior) y **mejillas** (radio X
457/// pleno abajo, afinado arriba) — la forma del baby-schema. `rx` ya lleva el ensanche de
458/// mejilla; arriba de la mitad se afina hacia `head_r` para no quedar cuadrada.
459fn elipsoide_cabeza(g: &mut VoxelGrid, c: Vec3, head_r: f32, rx: f32, ry: f32, color: [u8; 3]) {
460 let [dx, dy, dz] = g.dim();
461 let x0 = (c.x - rx).floor().max(0.0) as u32;
462 let x1 = (c.x + rx).ceil().min(dx as f32) as u32;
463 let y0 = (c.y - ry).floor().max(0.0) as u32;
464 let y1 = (c.y + ry).ceil().min(dy as f32) as u32;
465 let z0 = (c.z - rx).floor().max(0.0) as u32;
466 let z1 = (c.z + rx).ceil().min(dz as f32) as u32;
467 for y in y0..y1 {
468 let ty = (y as f32 + 0.5 - c.y) / ry; // -1..1
469 // Radio horizontal por altura: mejilla plena abajo (ty<0), afina hacia head_r arriba.
470 let rh = if ty >= 0.0 { rx + (head_r - rx) * ty } else { rx };
471 let rz = rh * 0.96;
472 for x in x0..x1 {
473 for z in z0..z1 {
474 let dxx = (x as f32 + 0.5 - c.x) / rh;
475 let dzz = (z as f32 + 0.5 - c.z) / rz;
476 if dxx * dxx + ty * ty + dzz * dzz <= 1.0 {
477 g.set(x, y, z, color);
478 }
479 }
480 }
481 }
482}
483
484/// **Sombra de barba/bigote como tint PLANO** (sin geometría): re-tiñe la piel de la mitad
485/// inferior FRONTAL de la cara a un tono apenas más oscuro/apagado. Sólo toca voxels que HOY
486/// son piel (`== skin`), así no pisa ojos/pelo (que se estampan después). Sutil a propósito.
487fn tint_barba(g: &mut VoxelGrid, c: Vec3, rx: f32, ry: f32, head_r: f32, eye_y: f32, skin: [u8; 3]) {
488 // Barba = piel un 6% más oscura, apenas más fría (sombra tenue, no una máscara: fuerte
489 // ensuciaba la cara y la volvía adusta/monstruosa).
490 let beard = [
491 (skin[0] as f32 * 0.94) as u8,
492 (skin[1] as f32 * 0.93) as u8,
493 (skin[2] as f32 * 0.94) as u8,
494 ];
495 let [dx, dy, dz] = g.dim();
496 // De la barbilla hasta un poco por debajo de los ojos (zona bigote+barba+mentón).
497 let y0 = (c.y - ry).floor().max(0.0) as u32;
498 let y1 = (eye_y - head_r * 0.30).floor().max(0.0).min(dy as f32) as u32;
499 let x0 = (c.x - rx).floor().max(0.0) as u32;
500 let x1 = (c.x + rx).ceil().min(dx as f32) as u32;
501 // Sólo el frente de la cara (+Z), no la nuca/mandíbula lateral.
502 let z0 = (c.z + head_r * 0.28).floor().max(0.0) as u32;
503 let z1 = (c.z + head_r * 1.1).ceil().min(dz as f32) as u32;
504 for x in x0..x1 {
505 for y in y0..y1 {
506 for z in z0..z1 {
507 if let Some(px) = g.get(x, y, z) {
508 if px[3] > 0 && [px[0], px[1], px[2]] == skin {
509 g.set(x, y, z, beard);
510 }
511 }
512 }
513 }
514 }
515}
516
517/// **Sonrisa como tint PLANO** (sin geometría): re-tiñe una banda corta de piel FRONTAL a la
518/// altura `boca_y`, curvada hacia arriba en los extremos (feliz), a un tono cálido apagado
519/// (labios/sombra de boca, no rojo saturado que a esta resolución grita "pico de pato"). Sólo
520/// pisa voxels que HOY son piel, así no borra ojos/pelo. Centrada en X, en el hemisferio +Z.
521fn sonrisa(g: &mut VoxelGrid, c: Vec3, rx: f32, head_r: f32, boca_y: f32, skin: [u8; 3]) {
522 // Boca = piel bastante más oscura y algo más cálida (rojiza), pero NO roja pura.
523 let mouth = [
524 (skin[0] as f32 * 0.72 + 22.0).min(255.0) as u8,
525 (skin[1] as f32 * 0.55).min(255.0) as u8,
526 (skin[2] as f32 * 0.55).min(255.0) as u8,
527 ];
528 let [dx, dy, dz] = g.dim();
529 let half_w = rx * 0.42; // media boca
530 let thick = head_r * 0.12; // grosor vertical de la línea
531 let curve = head_r * 0.22; // cuánto suben los extremos (sonrisa)
532 let x0 = (c.x - half_w).floor().max(0.0) as u32;
533 let x1 = (c.x + half_w).ceil().min(dx as f32) as u32;
534 // Sólo el frente de la cara (+Z).
535 let z0 = (c.z + head_r * 0.5).floor().max(0.0) as u32;
536 let z1 = (c.z + head_r * 1.1).ceil().min(dz as f32) as u32;
537 for x in x0..x1 {
538 // Altura de la línea de sonrisa en esta X: sube hacia los extremos (parábola).
539 let t = ((x as f32 + 0.5 - c.x) / half_w).clamp(-1.0, 1.0);
540 let ly = boca_y + curve * t * t;
541 let y0 = (ly - thick * 0.5).floor().max(0.0) as u32;
542 let y1 = (ly + thick * 0.5).ceil().min(dy as f32) as u32;
543 for y in y0..y1 {
544 for z in z0..z1 {
545 if let Some(px) = g.get(x, y, z) {
546 if px[3] > 0 && [px[0], px[1], px[2]] == skin {
547 g.set(x, y, z, mouth);
548 }
549 }
550 }
551 }
552 }
553}
554
555/// Cápsula (cilindro con tapas esféricas) de radio `r` entre los puntos `a` y `b`.
556fn capsula(g: &mut VoxelGrid, a: Vec3, b: Vec3, r: f32, color: [u8; 3]) {
557 let [dx, dy, dz] = g.dim();
558 let lo = a.min(b) - Vec3::splat(r + 1.0);
559 let hi = a.max(b) + Vec3::splat(r + 1.0);
560 let x0 = lo.x.floor().max(0.0) as u32;
561 let x1 = hi.x.ceil().min(dx as f32) as u32;
562 let y0 = lo.y.floor().max(0.0) as u32;
563 let y1 = hi.y.ceil().min(dy as f32) as u32;
564 let z0 = lo.z.floor().max(0.0) as u32;
565 let z1 = hi.z.ceil().min(dz as f32) as u32;
566 let ab = b - a;
567 let ab2 = ab.length_squared().max(1e-6);
568 let r2 = r * r;
569 for x in x0..x1 {
570 for y in y0..y1 {
571 for z in z0..z1 {
572 let p = Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
573 let t = ((p - a).dot(ab) / ab2).clamp(0.0, 1.0);
574 let closest = a + ab * t;
575 if (p - closest).length_squared() <= r2 {
576 g.set(x, y, z, color);
577 }
578 }
579 }
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 fn solids(g: &VoxelGrid) -> usize {
588 let [w, h, d] = g.dim();
589 let mut n = 0;
590 for x in 0..w {
591 for y in 0..h {
592 for z in 0..d {
593 if g.is_solid(x as i32, y as i32, z as i32) {
594 n += 1;
595 }
596 }
597 }
598 }
599 n
600 }
601
602 fn col() -> Colores {
603 Colores::nuevos([0.85, 0.68, 0.54], [0.2, 0.6, 0.55], [0.18, 0.22, 0.34])
604 }
605
606 #[test]
607 fn construye_un_ser_con_volumen() {
608 let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col());
609 assert!(solids(&g) > 3000, "el ser tiene volumen: {}", solids(&g));
610 }
611
612 #[test]
613 fn pies_en_el_piso() {
614 // El voxel sólido más bajo está cerca de y=0 (pies apoyados).
615 let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col());
616 let [w, h, d] = g.dim();
617 let mut ymin = h;
618 for x in 0..w {
619 for y in 0..h {
620 for z in 0..d {
621 if g.is_solid(x as i32, y as i32, z as i32) {
622 ymin = ymin.min(y);
623 }
624 }
625 }
626 }
627 assert!(ymin <= 2, "pies cerca del piso: ymin={ymin}");
628 }
629
630 #[test]
631 fn el_bebe_es_mas_cabezon_que_el_adulto() {
632 // Menos cabezas ⇒ la cabeza ocupa una fracción mayor de la altura.
633 let baby = EstiloSer::por_edad(Age::Baby);
634 let adult = EstiloSer::por_edad(Age::Adult);
635 assert!(baby.cabezas < adult.cabezas, "bebé menos cabezas");
636 // Radio de cabeza relativo a la altura.
637 let hr = |e: &EstiloSer| 1.0 / (2.0 * e.cabezas);
638 assert!(hr(&baby) > hr(&adult) + 0.05, "bebé cabezón");
639 }
640
641 #[test]
642 fn los_gestos_cambian_la_silueta() {
643 // Festejar (brazos arriba) ensancha/eleva la ocupación respecto a idle.
644 let idle = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col());
645 let cheer = crate::Clip::Cheer.pose(1.2);
646 let festeja = humanoide_voxel(&EstiloSer::default(), &cheer, 46, col());
647 // Ocupación en la franja alta (por encima de la cabeza-hombro) crece al levantar brazos.
648 let alto = |g: &VoxelGrid| {
649 let [w, h, d] = g.dim();
650 let y0 = h * 3 / 4;
651 let mut n = 0;
652 for x in 0..w {
653 for y in y0..h {
654 for z in 0..d {
655 if g.is_solid(x as i32, y as i32, z as i32) {
656 n += 1;
657 }
658 }
659 }
660 }
661 n
662 };
663 assert!(alto(&festeja) > alto(&idle), "festejar sube volumen alto: {} vs {}", alto(&festeja), alto(&idle));
664 }
665
666 #[test]
667 fn hornea_detalle_por_voxel() {
668 // Con redondez alta el bisel horneado es alto (silueta lisa).
669 let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 40, col());
670 let [w, h, d] = g.dim();
671 let mut con_detalle = 0;
672 for x in 0..w {
673 for y in 0..h {
674 for z in 0..d {
675 if g.is_solid(x as i32, y as i32, z as i32) {
676 if let Some(det) = g.detail_at(x, y, z) {
677 if det != [0, 0] {
678 con_detalle += 1;
679 }
680 }
681 }
682 }
683 }
684 }
685 assert!(con_detalle > 1000, "el cuerpo lleva detalle por voxel: {con_detalle}");
686 }
687
688 #[test]
689 fn presets_de_arquetipo_difieren() {
690 assert!(EstiloSer::amable().redondez > EstiloSer::robusto().redondez);
691 assert!(EstiloSer::agil().angulosidad > EstiloSer::amable().angulosidad);
692 assert!(EstiloSer::agil().cabezas > EstiloSer::amable().cabezas);
693 }
694}