tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-voxel/src/humano.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
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]
239fn 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 Z por `a` rad (plano X-Y; abrir brazo al costado).
245#[inline]
246fn rot_z(v: Vec3, a: f32) -> Vec3 {
247 let (s, c) = a.sin_cos();
248 Vec3::new(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
249}
250
251/// **Construye el humanoide voxel** de `altura_vox` voxels de alto (referencia), con el
252/// `estilo` dado, en la `pose` dada (gestos), coloreado por `col`. Pies en `y≈0`, centrado
253/// en X/Z, mirando a `+Z`. Hornea el detalle sub-voxel por voxel (bisel según la redondez).
254/// Puro y determinista.
255pub fn humanoide_voxel(estilo: &EstiloSer, pose: &Pose, altura_vox: u32, col: Colores) -> VoxelGrid {
256 let h = (altura_vox.max(20)) as f32;
257 let cabezas = estilo.cabezas.clamp(1.6, 8.0);
258
259 // --- Proporciones derivadas del canon de cabezas + baby-schema ---
260 let head_r = h / (2.0 * cabezas); // radio de cabeza = ½ (altura / cabezas)
261 // La cabeza tierna es REDONDA/ancha, nunca alargada (lit. 2022): el cráneo casi no estira
262 // en Y (±6 %) — la frente grande la da bajar los ojos, no una cúpula alta.
263 let head_ry = head_r * (1.0 + 0.2 * (estilo.craneo - 1.0)).clamp(0.94, 1.06);
264 // La cabeza corona la figura; su tope roza `h`.
265 let head_cy = h - head_ry * 1.02;
266 let neck = head_r * 0.14;
267 let shoulder_y = (head_cy - head_ry - neck).max(head_r);
268
269 // Torso y piernas se reparten [0, shoulder_y]: chibi = piernas cortas. La fracción de
270 // pierna sube con `cabezas` (más alto = piernas proporcionalmente más largas).
271 let leg_frac = (0.28 + 0.055 * (cabezas - 2.0)).clamp(0.28, 0.52);
272 let hip_y = shoulder_y * leg_frac;
273 let torso_cy = (hip_y + shoulder_y) * 0.5;
274 let torso_ry = (shoulder_y - hip_y) * 0.5;
275 // Ancho del torso: la cabeza debe DOMINAR (head:torso ≈ 1:0,8, cabeza mayor). Base por
276 // altura, modulada por redondez (redondo = más parejo).
277 let torso_rx = h * (0.13 + 0.05 * estilo.redondez);
278 // Profundidad casi igual al ancho (torso REDONDO, no una losa chata): un torso poco
279 // profundo presenta una cara frontal casi plana donde la normal varía lentísimo → el
280 // escalonado residual del gradiente se ve como anillos anchos. Redondearlo aprieta los
281 // pasos y da una panza de peluche.
282 let torso_rz = torso_rx * 0.92;
283
284 let limb_r = h * estilo.extremidad;
285
286 // Shape language triángulo: hombros anchos, cadera angosta con la angulosidad. Los hombros
287 // se anclan JUSTO AFUERA del torso (≥ `torso_rx`) para que los brazos cuelguen al costado con
288 // una junta limpia — hundirlos DENTRO del torso (como antes, 0,72·torso_rx) enterraba la
289 // cápsula del brazo y la normal ancha escalonaba esa junta sepultada → anillos en la panza.
290 let ang = estilo.angulosidad.clamp(0.0, 1.0);
291 let shoulder_x = torso_rx + limb_r * (0.35 + 0.9 * ang);
292 let hip_x = torso_rx * (0.55 - 0.18 * ang);
293 let leg_len = hip_y; // el pie llega a ~0
294 let arm_len = (shoulder_y - hip_y) * 1.02;
295
296 // --- Grid: bbox de la figura + margen (para que el bisel no toque el borde) ---
297 let half_w = (shoulder_x + limb_r * 1.4).max(torso_rx).max(head_r * estilo.mejilla) + 2.0;
298 let half_d = (torso_rz + limb_r).max(head_r) + arm_len * 0.4 + 2.0;
299 let gw = (half_w * 2.0).ceil() as u32 + 1;
300 let gd = (half_d * 2.0).ceil() as u32 + 1;
301 let gh = (h + head_ry * 0.3).ceil() as u32 + 2;
302 let mut g = VoxelGrid::new([gw, gh, gd]);
303 let cx = gw as f32 * 0.5;
304 let cz = gd as f32 * 0.5;
305 let base = Vec3::new(cx, 0.0, cz);
306
307 // Detalle sub-voxel del cuerpo (bisel por redondez) — pincel para todo lo que estampe.
308 let (rug, bisel) = estilo.detalle();
309 g.set_detail_brush(rug, bisel);
310
311 let skin = u8c(col.skin);
312 let shirt = u8c(col.shirt);
313 let pants = u8c(col.pants);
314 let hair = u8c(col.hair);
315
316 // Transform del cuerpo superior (torso/cabeza/brazos): rebote `bob` + inclinación `lean`
317 // alrededor de la cadera (pivote en `hip_y`). Las piernas quedan plantadas.
318 let bob = pose.bob * h * 0.5;
319 let upper = |local_y: f32, off: Vec3| -> Vec3 {
320 // punto (0, local_y, 0)+off, inclinado por lean sobre el pivote de cadera y subido bob
321 let rel = Vec3::new(off.x, local_y - hip_y, off.z);
322 let r = rot_x(rel, pose.lean);
323 base + Vec3::new(r.x, hip_y + r.y + bob, r.z)
324 };
325
326 // --- Piernas: cápsulas rechonchas, articuladas en la cadera (leg swing en X) ---
327 for (sx, swing) in [(-1.0f32, pose.leg_l), (1.0, pose.leg_r)] {
328 let hip = base + Vec3::new(sx * hip_x, hip_y, 0.0);
329 let dir = rot_x(Vec3::new(0.0, -leg_len, 0.0), swing);
330 let foot = hip + dir;
331 capsula(&mut g, hip, foot, limb_r * 1.05, pants);
332 }
333
334 // --- Torso: elipsoide redondo (panza) ---
335 let torso_c = upper(torso_cy, Vec3::ZERO);
336 elipsoide(&mut g, torso_c, Vec3::new(torso_rx, torso_ry * estilo.panza, torso_rz), shirt);
337
338 // --- Brazos: cápsulas al costado, articuladas en el hombro (out en Z, swing en X) ---
339 // Splay base: aun en reposo los brazos abren un poco hacia afuera para NO fundirse con el
340 // torso ni juntar las manos al frente (leía como "manos atadas"). Se suma a la apertura del gesto.
341 let base_out = 0.16;
342 for (sx, swing, out) in [(-1.0f32, pose.arm_l, pose.arm_l_out), (1.0, pose.arm_r, pose.arm_r_out)] {
343 let shoulder = upper(shoulder_y, Vec3::new(sx * shoulder_x, 0.0, 0.0));
344 // dirección local del brazo colgando, rotada: primero swing (X), luego apertura (Z).
345 let mut dir = rot_x(Vec3::new(0.0, -arm_len, 0.0), swing);
346 dir = rot_z(dir, sx * (out + base_out)); // apertura espejada por lado
347 // el brazo también acompaña el lean del cuerpo superior
348 dir = rot_x(dir, pose.lean);
349 let hand = shoulder + dir;
350 capsula(&mut g, shoulder, hand, limb_r, shirt);
351 // Mano: esferita de piel en la punta (apenas mayor que el brazo, no un guante gigante).
352 elipsoide(&mut g, hand, Vec3::splat(limb_r * 0.98), skin);
353 }
354
355 // --- Cabeza + cara (baby-schema) ---
356 let head_c = upper(head_cy, Vec3::ZERO);
357 // Cabeza: elipsoide con cúpula (craneo) arriba y mejilla abajo. La construimos como un
358 // elipsoide con radios asimétricos por hemisferio via `elipsoide_mejilla`.
359 let head_rx = head_r * estilo.mejilla;
360 elipsoide_cabeza(&mut g, head_c, head_r, head_rx, head_ry, skin);
361
362 // **Zona de bigote/barba = coloración PLANA, sin sustancia** (no una boca protuberante,
363 // que parecía un pico de pato): re-tiñe la piel de la mitad inferior FRONTAL de la cara a
364 // un tono apenas más apagado/oscuro (sombra de barba). No agrega geometría; se aplica
365 // ANTES del pelo/ojos para no pisarlos. Sutil a propósito (que no lea como máscara).
366 let eye_y0 = head_c.y + estilo.ojo_altura * head_r;
367 tint_barba(&mut g, head_c, head_rx, head_ry, head_r, eye_y0, skin);
368
369 // Pelo: **casquete alto** sobre la corona, corrido hacia atrás (−Z) para dejar la FRENTE
370 // y las mejillas de piel a la vista (la frente grande es el rasgo tierno). No baja hasta
371 // los ojos: recorte bien por encima → cara abierta, no un antifaz enmarcado.
372 let eye_y = head_c.y + estilo.ojo_altura * head_r;
373 elipsoide_min_y(
374 &mut g,
375 head_c + Vec3::new(0.0, head_ry * 0.40, -head_r * 0.14),
376 Vec3::new(head_rx * 1.02, head_ry * 0.80, head_r * 1.0),
377 hair,
378 eye_y + head_r * 0.58,
379 );
380
381 // Ojos: **esclera clara + pupila oscura + chispa** (NO un óvalo negro suelto, que a esta
382 // resolución + isosuperficie leía como un HUECO/cuenca vacía = cara de monstruo). El blanco
383 // domina y "abre" el ojo; la pupila oscura, más chica y al frente-centro, da la mirada; una
384 // chispa arriba-interior le pone vida. Anchos > altos (ojo infantil), BIEN separados, con piel
385 // en medio y alrededor (no antifaz). A/por debajo de la media (`ojo_altura`). `head_pitch` tilta.
386 let er = head_r * estilo.ojo_tam;
387 let ex = head_rx * 0.5;
388 let zf = head_r * 0.80;
389 let eoff = eye_y - head_c.y;
390 for s in [-1.0f32, 1.0] {
391 let eye = head_c + rot_x(Vec3::new(s * ex, eoff, zf), pose.head_pitch);
392 // Esclera: óvalo claro, ancho, apenas saliente al frente para que no se hunda.
393 elipsoide(&mut g, eye, Vec3::new(er * 1.06, er * 0.86, er * 0.62), [240, 241, 244]);
394 // Pupila/iris: oscura, ~mitad, corrida al FRENTE (+Z) y apenas hacia adentro (mirada
395 // convergente amable) y abajo. Va DESPUÉS de la esclera para pisarla en el centro.
396 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);
397 elipsoide(&mut g, pupil, Vec3::new(er * 0.52, er * 0.6, er * 0.5), [40, 32, 46]);
398 // Chispa (arriba-interior de la pupila) — grande para que lea de lejos.
399 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);
400 elipsoide(&mut g, spark, Vec3::new(er * 0.3, er * 0.3, er * 0.34), [250, 251, 253]);
401 }
402
403 // **Boca = sonrisa como tint PLANO** (sin geometría protuberante: una boca de color en
404 // relieve leía como pico de pato). Re-tiñe una banda corta, curvada hacia ARRIBA (feliz),
405 // de la piel frontal-baja de la cara a un tono cálido apagado. Pequeña y centrada. Va
406 // después de barba/ojos para no pisarse.
407 let boca_y = eye_y - head_r * 0.44;
408 sonrisa(&mut g, head_c, head_rx, head_r, boca_y, skin);
409
410 g.clear_detail_brush();
411 g.reset_dirty();
412 g
413}
414
415/// Rellena un elipsoide de semiejes `r` centrado en `c` con `color`.
416fn elipsoide(g: &mut VoxelGrid, c: Vec3, r: Vec3, color: [u8; 3]) {
417 elipsoide_min_y(g, c, r, color, f32::NEG_INFINITY);
418}
419
420/// Elipsoide recortado por abajo (`y >= y_min`) — para casquetes de pelo.
421fn elipsoide_min_y(g: &mut VoxelGrid, c: Vec3, r: Vec3, color: [u8; 3], y_min: f32) {
422 let [dx, dy, dz] = g.dim();
423 let x0 = (c.x - r.x).floor().max(0.0) as u32;
424 let x1 = (c.x + r.x).ceil().min(dx as f32) as u32;
425 let y0 = (c.y - r.y).floor().max(0.0).max(y_min.max(0.0)) as u32;
426 let y1 = (c.y + r.y).ceil().min(dy as f32) as u32;
427 let z0 = (c.z - r.z).floor().max(0.0) as u32;
428 let z1 = (c.z + r.z).ceil().min(dz as f32) as u32;
429 for x in x0..x1 {
430 for y in y0..y1 {
431 if (y as f32) < y_min {
432 continue;
433 }
434 for z in z0..z1 {
435 let d = Vec3::new(
436 (x as f32 + 0.5 - c.x) / r.x,
437 (y as f32 + 0.5 - c.y) / r.y,
438 (z as f32 + 0.5 - c.z) / r.z,
439 );
440 if d.length_squared() <= 1.0 {
441 g.set(x, y, z, color);
442 }
443 }
444 }
445 }
446}
447
448/// Cabeza: elipsoide con **cúpula arriba** (semieje Y superior) y **mejillas** (radio X
449/// pleno abajo, afinado arriba) — la forma del baby-schema. `rx` ya lleva el ensanche de
450/// mejilla; arriba de la mitad se afina hacia `head_r` para no quedar cuadrada.
451fn elipsoide_cabeza(g: &mut VoxelGrid, c: Vec3, head_r: f32, rx: f32, ry: f32, color: [u8; 3]) {
452 let [dx, dy, dz] = g.dim();
453 let x0 = (c.x - rx).floor().max(0.0) as u32;
454 let x1 = (c.x + rx).ceil().min(dx as f32) as u32;
455 let y0 = (c.y - ry).floor().max(0.0) as u32;
456 let y1 = (c.y + ry).ceil().min(dy as f32) as u32;
457 let z0 = (c.z - rx).floor().max(0.0) as u32;
458 let z1 = (c.z + rx).ceil().min(dz as f32) as u32;
459 for y in y0..y1 {
460 let ty = (y as f32 + 0.5 - c.y) / ry; // -1..1
461 // Radio horizontal por altura: mejilla plena abajo (ty<0), afina hacia head_r arriba.
462 let rh = if ty >= 0.0 { rx + (head_r - rx) * ty } else { rx };
463 let rz = rh * 0.96;
464 for x in x0..x1 {
465 for z in z0..z1 {
466 let dxx = (x as f32 + 0.5 - c.x) / rh;
467 let dzz = (z as f32 + 0.5 - c.z) / rz;
468 if dxx * dxx + ty * ty + dzz * dzz <= 1.0 {
469 g.set(x, y, z, color);
470 }
471 }
472 }
473 }
474}
475
476/// **Sombra de barba/bigote como tint PLANO** (sin geometría): re-tiñe la piel de la mitad
477/// inferior FRONTAL de la cara a un tono apenas más oscuro/apagado. Sólo toca voxels que HOY
478/// son piel (`== skin`), así no pisa ojos/pelo (que se estampan después). Sutil a propósito.
479fn tint_barba(g: &mut VoxelGrid, c: Vec3, rx: f32, ry: f32, head_r: f32, eye_y: f32, skin: [u8; 3]) {
480 // Barba = piel un 6% más oscura, apenas más fría (sombra tenue, no una máscara: fuerte
481 // ensuciaba la cara y la volvía adusta/monstruosa).
482 let beard = [
483 (skin[0] as f32 * 0.94) as u8,
484 (skin[1] as f32 * 0.93) as u8,
485 (skin[2] as f32 * 0.94) as u8,
486 ];
487 let [dx, dy, dz] = g.dim();
488 // De la barbilla hasta un poco por debajo de los ojos (zona bigote+barba+mentón).
489 let y0 = (c.y - ry).floor().max(0.0) as u32;
490 let y1 = (eye_y - head_r * 0.30).floor().max(0.0).min(dy as f32) as u32;
491 let x0 = (c.x - rx).floor().max(0.0) as u32;
492 let x1 = (c.x + rx).ceil().min(dx as f32) as u32;
493 // Sólo el frente de la cara (+Z), no la nuca/mandíbula lateral.
494 let z0 = (c.z + head_r * 0.28).floor().max(0.0) as u32;
495 let z1 = (c.z + head_r * 1.1).ceil().min(dz as f32) as u32;
496 for x in x0..x1 {
497 for y in y0..y1 {
498 for z in z0..z1 {
499 if let Some(px) = g.get(x, y, z) {
500 if px[3] > 0 && [px[0], px[1], px[2]] == skin {
501 g.set(x, y, z, beard);
502 }
503 }
504 }
505 }
506 }
507}
508
509/// **Sonrisa como tint PLANO** (sin geometría): re-tiñe una banda corta de piel FRONTAL a la
510/// altura `boca_y`, curvada hacia arriba en los extremos (feliz), a un tono cálido apagado
511/// (labios/sombra de boca, no rojo saturado que a esta resolución grita "pico de pato"). Sólo
512/// pisa voxels que HOY son piel, así no borra ojos/pelo. Centrada en X, en el hemisferio +Z.
513fn sonrisa(g: &mut VoxelGrid, c: Vec3, rx: f32, head_r: f32, boca_y: f32, skin: [u8; 3]) {
514 // Boca = piel bastante más oscura y algo más cálida (rojiza), pero NO roja pura.
515 let mouth = [
516 (skin[0] as f32 * 0.72 + 22.0).min(255.0) as u8,
517 (skin[1] as f32 * 0.55).min(255.0) as u8,
518 (skin[2] as f32 * 0.55).min(255.0) as u8,
519 ];
520 let [dx, dy, dz] = g.dim();
521 let half_w = rx * 0.42; // media boca
522 let thick = head_r * 0.12; // grosor vertical de la línea
523 let curve = head_r * 0.22; // cuánto suben los extremos (sonrisa)
524 let x0 = (c.x - half_w).floor().max(0.0) as u32;
525 let x1 = (c.x + half_w).ceil().min(dx as f32) as u32;
526 // Sólo el frente de la cara (+Z).
527 let z0 = (c.z + head_r * 0.5).floor().max(0.0) as u32;
528 let z1 = (c.z + head_r * 1.1).ceil().min(dz as f32) as u32;
529 for x in x0..x1 {
530 // Altura de la línea de sonrisa en esta X: sube hacia los extremos (parábola).
531 let t = ((x as f32 + 0.5 - c.x) / half_w).clamp(-1.0, 1.0);
532 let ly = boca_y + curve * t * t;
533 let y0 = (ly - thick * 0.5).floor().max(0.0) as u32;
534 let y1 = (ly + thick * 0.5).ceil().min(dy as f32) as u32;
535 for y in y0..y1 {
536 for z in z0..z1 {
537 if let Some(px) = g.get(x, y, z) {
538 if px[3] > 0 && [px[0], px[1], px[2]] == skin {
539 g.set(x, y, z, mouth);
540 }
541 }
542 }
543 }
544 }
545}
546
547/// Cápsula (cilindro con tapas esféricas) de radio `r` entre los puntos `a` y `b`.
548fn capsula(g: &mut VoxelGrid, a: Vec3, b: Vec3, r: f32, color: [u8; 3]) {
549 let [dx, dy, dz] = g.dim();
550 let lo = a.min(b) - Vec3::splat(r + 1.0);
551 let hi = a.max(b) + Vec3::splat(r + 1.0);
552 let x0 = lo.x.floor().max(0.0) as u32;
553 let x1 = hi.x.ceil().min(dx as f32) as u32;
554 let y0 = lo.y.floor().max(0.0) as u32;
555 let y1 = hi.y.ceil().min(dy as f32) as u32;
556 let z0 = lo.z.floor().max(0.0) as u32;
557 let z1 = hi.z.ceil().min(dz as f32) as u32;
558 let ab = b - a;
559 let ab2 = ab.length_squared().max(1e-6);
560 let r2 = r * r;
561 for x in x0..x1 {
562 for y in y0..y1 {
563 for z in z0..z1 {
564 let p = Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5);
565 let t = ((p - a).dot(ab) / ab2).clamp(0.0, 1.0);
566 let closest = a + ab * t;
567 if (p - closest).length_squared() <= r2 {
568 g.set(x, y, z, color);
569 }
570 }
571 }
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 fn solids(g: &VoxelGrid) -> usize {
580 let [w, h, d] = g.dim();
581 let mut n = 0;
582 for x in 0..w {
583 for y in 0..h {
584 for z in 0..d {
585 if g.is_solid(x as i32, y as i32, z as i32) {
586 n += 1;
587 }
588 }
589 }
590 }
591 n
592 }
593
594 fn col() -> Colores {
595 Colores::nuevos([0.85, 0.68, 0.54], [0.2, 0.6, 0.55], [0.18, 0.22, 0.34])
596 }
597
598 #[test]
599 fn construye_un_ser_con_volumen() {
600 let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col());
601 assert!(solids(&g) > 3000, "el ser tiene volumen: {}", solids(&g));
602 }
603
604 #[test]
605 fn pies_en_el_piso() {
606 // El voxel sólido más bajo está cerca de y=0 (pies apoyados).
607 let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col());
608 let [w, h, d] = g.dim();
609 let mut ymin = h;
610 for x in 0..w {
611 for y in 0..h {
612 for z in 0..d {
613 if g.is_solid(x as i32, y as i32, z as i32) {
614 ymin = ymin.min(y);
615 }
616 }
617 }
618 }
619 assert!(ymin <= 2, "pies cerca del piso: ymin={ymin}");
620 }
621
622 #[test]
623 fn el_bebe_es_mas_cabezon_que_el_adulto() {
624 // Menos cabezas ⇒ la cabeza ocupa una fracción mayor de la altura.
625 let baby = EstiloSer::por_edad(Age::Baby);
626 let adult = EstiloSer::por_edad(Age::Adult);
627 assert!(baby.cabezas < adult.cabezas, "bebé menos cabezas");
628 // Radio de cabeza relativo a la altura.
629 let hr = |e: &EstiloSer| 1.0 / (2.0 * e.cabezas);
630 assert!(hr(&baby) > hr(&adult) + 0.05, "bebé cabezón");
631 }
632
633 #[test]
634 fn los_gestos_cambian_la_silueta() {
635 // Festejar (brazos arriba) ensancha/eleva la ocupación respecto a idle.
636 let idle = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col());
637 let cheer = crate::Clip::Cheer.pose(1.2);
638 let festeja = humanoide_voxel(&EstiloSer::default(), &cheer, 46, col());
639 // Ocupación en la franja alta (por encima de la cabeza-hombro) crece al levantar brazos.
640 let alto = |g: &VoxelGrid| {
641 let [w, h, d] = g.dim();
642 let y0 = h * 3 / 4;
643 let mut n = 0;
644 for x in 0..w {
645 for y in y0..h {
646 for z in 0..d {
647 if g.is_solid(x as i32, y as i32, z as i32) {
648 n += 1;
649 }
650 }
651 }
652 }
653 n
654 };
655 assert!(alto(&festeja) > alto(&idle), "festejar sube volumen alto: {} vs {}", alto(&festeja), alto(&idle));
656 }
657
658 #[test]
659 fn hornea_detalle_por_voxel() {
660 // Con redondez alta el bisel horneado es alto (silueta lisa).
661 let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 40, col());
662 let [w, h, d] = g.dim();
663 let mut con_detalle = 0;
664 for x in 0..w {
665 for y in 0..h {
666 for z in 0..d {
667 if g.is_solid(x as i32, y as i32, z as i32) {
668 if let Some(det) = g.detail_at(x, y, z) {
669 if det != [0, 0] {
670 con_detalle += 1;
671 }
672 }
673 }
674 }
675 }
676 }
677 assert!(con_detalle > 1000, "el cuerpo lleva detalle por voxel: {con_detalle}");
678 }
679
680 #[test]
681 fn presets_de_arquetipo_difieren() {
682 assert!(EstiloSer::amable().redondez > EstiloSer::robusto().redondez);
683 assert!(EstiloSer::agil().angulosidad > EstiloSer::amable().angulosidad);
684 assert!(EstiloSer::agil().cabezas > EstiloSer::amable().cabezas);
685 }
686}