//! **Humanoide voxel REDONDEADO y expresivo** — el ser estilizado que aprovecha la //! no-voxelidad del motor (bisel/isosuperficie sub-voxel) para un look vinyl-toy / //! Fall-Guys / chibi CON carácter, en vez del muñeco de cajas tipo Minecraft. //! //! ## Por qué existe (lección 2026-07-18, ver [[detalle-sub-voxel-rugosidad]]) //! //! El [`Actor`](crate::Actor) histórico es una **malla de 11 cajas** (`push_cube`): se //! ve cuadrado. Y voxelizar una foto de persona en LITERAL cae en el valle de inquietud //! (a tamaño grande da terror). La regla acordada: **la especie tiene su ESTILO PROPIO** //! (proporciones cuantizadas, gestos, ojos grandes) y la foto sólo regula RASGOS //! (piel/ropa). Este módulo materializa ese estilo como un **volumen voxel** de primitivas //! orgánicas (elipsoides + cápsulas) que, renderizado con `bisel` alto + `silueta_por_voxel`, //! el ray-marcher redondea a una figura simpática. Comparte la MISMA gramática de campo que //! el terreno/fauna (no otra fidelidad → sin valle). //! //! ## Diseño anclado en literatura (no intuición) //! //! Los parámetros de [`EstiloSer`] salen de la teoría del diseño de personajes y la //! percepción, no de tanteo visual: //! - **Kindchenschema / "baby schema"** (Konrad Lorenz, 1943; Glocker et al. 2009): cabeza //! grande respecto al cuerpo, **ojos grandes y BAJOS** (a/por debajo de la mitad de la cara), //! cara **redonda/ancha** (nunca alargada), nariz/boca chicas, mentón blando, mejillas y //! miembros rechonchos → dispara la respuesta de ternura (activa el núcleo accumbens) y //! **escapa del valle**. Dos correcciones de la literatura reciente que este módulo respeta: //! (1) *la frente grande se logra BAJANDO los ojos, no estirando el cráneo* — el lever robusto //! es la posición baja de los rasgos + mandíbula angosta, no la altura de la frente (estudio //! morfométrico 2022); (2) *lo que suma es EVITAR ojos chicos*, no inflarlos sin límite //! (Frontiers 2021: grande-vs-natural no fue significativo). → [`EstiloSer::ojo_tam`]/ //! [`ojo_altura`](EstiloSer::ojo_altura)/[`mejilla`](EstiloSer::mejilla)/ //! [`extremidad`](EstiloSer::extremidad). //! - **Canon de proporciones / "cabezas de alto"**: realista ≈ 7,5–8 cabezas; heroico ≈ 8,5; //! cartoon estilizado ≈ 4–6; niño ≈ 4–5; **chibi/super-deformado ≈ 2–4**. Menos cabezas es //! el **lever más fuerte** de "más tierno / más joven" (la ontogenia real va de cabeza=½ en el //! feto a 1/7 en el adulto → un cabezón se lee juvenil sin importar el resto). → [`EstiloSer::cabezas`] //! (default chibi ~2,6, NO realista). //! - **Shape language**: círculo = amable/suave, cuadrado = estable/fiable, triángulo = //! dinámico/agresivo. Un personaje se lee por su **forma dominante** (el "appeal" nace de //! comprometerse con UNA forma). → eje [`redondez`](EstiloSer::redondez) (círculo↔cuadrado, //! controla también el `bisel`) y [`angulosidad`](EstiloSer::angulosidad) (hombros anchos/ //! cadera angosta = triángulo). //! - **Valle de inquietud** (Mori; Realism Inconsistency Theory, MacDorman): el valle lo //! dispara **mezclar fidelidades** (rasgos en distintos niveles de realismo), no la //! caricatura. Ojos grandes sobre cara FOTORREAL = lo más siniestro. Por eso el realismo se //! mantiene **uniforme y bajo** y la foto SÓLO regula color (piel/ropa), nunca inyecta //! geometría/textura fotográfica. //! //! ## Gestos //! //! [`humanoide_voxel`] recibe una [`Pose`](crate::Pose) (los ángulos de articulación del //! catálogo de [`Clip`](crate::Clip)) y la aplica a las cápsulas de miembros → el ser //! redondeado saluda / señala / camina con la misma expresividad que el [`Actor`], sin //! cajas. Es puro y determinista (testeable sin GPU). use llimphi_3d::glam::Vec3; use llimphi_3d::VoxelGrid; use serde::{Deserialize, Serialize}; use crate::actor::{Age, Pose}; /// **Estilo de un ser** — el juego de perillas de diseño (ver el módulo para las fuentes). /// Todas tienen un default con carga semántica; los presets ([`por_edad`](Self::por_edad), /// [`amable`](Self::amable), [`robusto`](Self::robusto), [`agil`](Self::agil)) los combinan. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct EstiloSer { /// **Cabezas de alto** (canon de proporciones). Chibi ≈ 2–4, cartoon ≈ 4–6, realista /// ≈ 7,5. Menos = más tierno / más lejos del valle. La cabeza se dimensiona como /// `altura / cabezas`. pub cabezas: f32, /// **Redondez** `[0,1]` (shape language, eje círculo↔cuadrado). `1` = elipsoides casi /// esféricos + bisel máximo (amable/suave); `0` = ejes desiguales y menos bisel (más /// bloque, estable/fiable). Controla también el detalle sub-voxel horneado. pub redondez: f32, /// **Angulosidad** `[0,1]` (shape language, sesgo triángulo): hombros anchos + cadera /// angosta = silueta dinámica/atlética. `0` = cuerpo parejo (más infantil/tierno). pub angulosidad: f32, /// **Tamaño de ojo** como fracción del radio de la cabeza (Kindchenschema: grande = /// tierno). Chibi ≈ 0,26; adulto estilizado ≈ 0,18. pub ojo_tam: f32, /// **Altura del ojo** en la cara, fracción del radio de cabeza desde el centro (positivo = /// arriba). El lever de ternura MÁS robusto (estudio morfométrico 2022): ojos **por debajo** /// de la mitad (**negativo**) agrandan la frente y suben el appeal. Adulto estilizado ≈ 0 /// (línea media, canon Loomis); chibi ≈ −0,2..−0,3 (tercio inferior de la cara). Positivo /// (ojos altos) = más adulto/serio. pub ojo_altura: f32, /// **Cráneo**: modulador LEVE de la altura de la cabeza. La literatura reciente advierte /// que la cara tierna es **redonda/ancha, nunca alargada**, y que la frente se logra /// bajando los ojos (no estirando la cúpula) → este campo casi no estira en Y (clamp a /// ~±6 %) y se conserva sólo por compatibilidad/afinado. `1` = cabeza redonda. pub craneo: f32, /// **Mejilla**: ensancha la mitad inferior de la cabeza (cara redonda de bebé). `1` = sin /// ensanche. pub mejilla: f32, /// **Rechonchez de miembros**: radio de brazos/piernas como fracción de la altura /// (chibi ≈ 0,075; esbelto ≈ 0,05). Miembros gorditos = más tierno. pub extremidad: f32, /// **Panza**: redondez del torso (semieje Y/X). `>1` = barriga/pecho abombado (tierno); /// `1` = torso parejo. pub panza: f32, } impl Default for EstiloSer { /// El default es un **chibi amable** (el look probado): ~2,6 cabezas, redondo, ojos /// grandes a la altura media, cráneo abombado. Reproduce el personaje del `demo_elimon`. fn default() -> Self { Self { cabezas: 2.6, redondez: 1.0, angulosidad: 0.0, ojo_tam: 0.26, ojo_altura: -0.20, craneo: 1.0, mejilla: 1.08, extremidad: 0.075, panza: 1.05, } } } impl EstiloSer { /// Estilo por **edad**: el canon de cabezas y el baby-schema varían con el estadio. /// Bebé = 2 cabezas, ojos enormes y bajos, cráneo grande (máxima ternura); adulto /// estilizado = 4,5 cabezas, ojos moderados a la media (NO 7,5 realista: se busca appeal /// y anti-valle). Escala monótona en el medio. pub fn por_edad(age: Age) -> Self { // Escala del "dial de ternura" (mature→cute) coordinada con las cabezas: menos // cabezas ⇒ ojos más grandes y MÁS BAJOS (tercio inferior), cara más ancha, miembros // más gordos. Adulto estilizado = ojos a la línea media (0), NO 7,5 realista (appeal + // anti-valle). (cabezas, ojo_tam, ojo_altura, craneo, mejilla, extremidad, panza) let (cab, ot, oa, cr, mj, ex, pz) = match age { Age::Baby => (2.0, 0.32, -0.32, 1.0, 1.16, 0.092, 1.22), Age::Child => (3.4, 0.28, -0.24, 1.0, 1.10, 0.080, 1.14), Age::Teen => (4.2, 0.22, -0.12, 1.0, 1.04, 0.066, 1.06), Age::Adult => (4.5, 0.19, -0.04, 1.0, 1.00, 0.060, 1.04), Age::Elder => (4.3, 0.19, -0.05, 1.0, 1.02, 0.062, 1.06), }; Self { cabezas: cab, redondez: 1.0, angulosidad: 0.0, ojo_tam: ot, ojo_altura: oa, craneo: cr, mejilla: mj, extremidad: ex, panza: pz, } } /// **Arquetipo redondo** (shape language: círculo → amable/suave). Chibi cabezón, /// máxima redondez, ojos grandes: el default reforzado. pub fn amable() -> Self { Self::default() } /// **Arquetipo robusto** (shape language: cuadrado → estable/fiable). Menos redondez /// (más bloque), cuerpo ancho y parejo, ojos más chicos, algo más de cabezas. pub fn robusto() -> Self { Self { cabezas: 3.6, redondez: 0.45, angulosidad: 0.15, ojo_tam: 0.19, ojo_altura: -0.02, craneo: 1.0, mejilla: 1.12, extremidad: 0.085, panza: 1.02, } } /// **Arquetipo ágil** (shape language: triángulo → dinámico/atlético). Más alto, hombros /// anchos y cadera angosta (angulosidad alta), miembros más finos. pub fn agil() -> Self { Self { cabezas: 4.6, redondez: 0.7, angulosidad: 0.7, ojo_tam: 0.19, ojo_altura: 0.02, craneo: 1.0, mejilla: 0.98, extremidad: 0.055, panza: 0.98, } } /// Detalle sub-voxel `(rugosidad, bisel)` horneado por voxel: el bisel sube con la /// redondez (silueta lisa), la rugosidad es un grano de piel constante suave. Es la /// gramática de campo del ser vivo — misma que el terreno, con más redondeo. pub fn detalle(&self) -> (f32, f32) { let bisel = (0.35 + 0.65 * self.redondez.clamp(0.0, 1.0)).clamp(0.0, 1.0); // Grano de piel MUY leve: de cerca (el objetivo del plan) una rugosidad fuerte se // ve como escamas/beading. Suficiente para no quedar plástico plano, no más. (0.06, bisel) } } /// Colores de un ser (`[r,g,b]` en `[0,1]`, como [`Actor`](crate::Actor)): los que la foto /// regula (piel/remera/pantalón) + el pelo (derivado o fijo). #[derive(Debug, Clone, Copy)] pub struct Colores { pub skin: [f32; 3], pub shirt: [f32; 3], pub pants: [f32; 3], pub hair: [f32; 3], } impl Colores { /// Colores desde un [`CharSpec`](crate::CharSpec)-like (piel/remera/pantalón), con un /// pelo derivado (marrón oscuro tintado hacia la piel) para que combine. Usa /// [`con_pelo`](Self::con_pelo) si tienes el color de pelo REAL (de la foto). pub fn nuevos(skin: [f32; 3], shirt: [f32; 3], pants: [f32; 3]) -> Self { // Pelo: oscuro cálido, apenas influido por el tono de piel (combina con retratos). let hair = [ (0.18 + skin[0] * 0.12).min(1.0), (0.13 + skin[1] * 0.10).min(1.0), (0.12 + skin[2] * 0.08).min(1.0), ]; Self { skin, shirt, pants, hair } } /// Colores con **pelo explícito** (p.ej. muestreado FIEL de la foto), no derivado. pub fn con_pelo(skin: [f32; 3], shirt: [f32; 3], pants: [f32; 3], hair: [f32; 3]) -> Self { Self { skin, shirt, pants, hair } } } #[inline] fn u8c(c: [f32; 3]) -> [u8; 3] { [ (c[0].clamp(0.0, 1.0) * 255.0) as u8, (c[1].clamp(0.0, 1.0) * 255.0) as u8, (c[2].clamp(0.0, 1.0) * 255.0) as u8, ] } /// Rota `v` alrededor del eje X por `a` rad (plano Y-Z; adelante = +Z). #[inline] fn rot_x(v: Vec3, a: f32) -> Vec3 { let (s, c) = a.sin_cos(); Vec3::new(v.x, v.y * c - v.z * s, v.y * s + v.z * c) } /// Rota `v` alrededor del eje Z por `a` rad (plano X-Y; abrir brazo al costado). #[inline] fn rot_z(v: Vec3, a: f32) -> Vec3 { let (s, c) = a.sin_cos(); Vec3::new(v.x * c - v.y * s, v.x * s + v.y * c, v.z) } /// **Construye el humanoide voxel** de `altura_vox` voxels de alto (referencia), con el /// `estilo` dado, en la `pose` dada (gestos), coloreado por `col`. Pies en `y≈0`, centrado /// en X/Z, mirando a `+Z`. Hornea el detalle sub-voxel por voxel (bisel según la redondez). /// Puro y determinista. pub fn humanoide_voxel(estilo: &EstiloSer, pose: &Pose, altura_vox: u32, col: Colores) -> VoxelGrid { let h = (altura_vox.max(20)) as f32; let cabezas = estilo.cabezas.clamp(1.6, 8.0); // --- Proporciones derivadas del canon de cabezas + baby-schema --- let head_r = h / (2.0 * cabezas); // radio de cabeza = ½ (altura / cabezas) // La cabeza tierna es REDONDA/ancha, nunca alargada (lit. 2022): el cráneo casi no estira // en Y (±6 %) — la frente grande la da bajar los ojos, no una cúpula alta. let head_ry = head_r * (1.0 + 0.2 * (estilo.craneo - 1.0)).clamp(0.94, 1.06); // La cabeza corona la figura; su tope roza `h`. let head_cy = h - head_ry * 1.02; let neck = head_r * 0.14; let shoulder_y = (head_cy - head_ry - neck).max(head_r); // Torso y piernas se reparten [0, shoulder_y]: chibi = piernas cortas. La fracción de // pierna sube con `cabezas` (más alto = piernas proporcionalmente más largas). let leg_frac = (0.28 + 0.055 * (cabezas - 2.0)).clamp(0.28, 0.52); let hip_y = shoulder_y * leg_frac; let torso_cy = (hip_y + shoulder_y) * 0.5; let torso_ry = (shoulder_y - hip_y) * 0.5; // Ancho del torso: la cabeza debe DOMINAR (head:torso ≈ 1:0,8, cabeza mayor). Base por // altura, modulada por redondez (redondo = más parejo). let torso_rx = h * (0.13 + 0.05 * estilo.redondez); // Profundidad casi igual al ancho (torso REDONDO, no una losa chata): un torso poco // profundo presenta una cara frontal casi plana donde la normal varía lentísimo → el // escalonado residual del gradiente se ve como anillos anchos. Redondearlo aprieta los // pasos y da una panza de peluche. let torso_rz = torso_rx * 0.92; let limb_r = h * estilo.extremidad; // Shape language triángulo: hombros anchos, cadera angosta con la angulosidad. Los hombros // se anclan JUSTO AFUERA del torso (≥ `torso_rx`) para que los brazos cuelguen al costado con // una junta limpia — hundirlos DENTRO del torso (como antes, 0,72·torso_rx) enterraba la // cápsula del brazo y la normal ancha escalonaba esa junta sepultada → anillos en la panza. let ang = estilo.angulosidad.clamp(0.0, 1.0); let shoulder_x = torso_rx + limb_r * (0.35 + 0.9 * ang); let hip_x = torso_rx * (0.55 - 0.18 * ang); let leg_len = hip_y; // el pie llega a ~0 let arm_len = (shoulder_y - hip_y) * 1.02; // --- Grid: bbox de la figura + margen (para que el bisel no toque el borde) --- let half_w = (shoulder_x + limb_r * 1.4).max(torso_rx).max(head_r * estilo.mejilla) + 2.0; let half_d = (torso_rz + limb_r).max(head_r) + arm_len * 0.4 + 2.0; let gw = (half_w * 2.0).ceil() as u32 + 1; let gd = (half_d * 2.0).ceil() as u32 + 1; let gh = (h + head_ry * 0.3).ceil() as u32 + 2; let mut g = VoxelGrid::new([gw, gh, gd]); let cx = gw as f32 * 0.5; let cz = gd as f32 * 0.5; let base = Vec3::new(cx, 0.0, cz); // Detalle sub-voxel del cuerpo (bisel por redondez) — pincel para todo lo que estampe. let (rug, bisel) = estilo.detalle(); g.set_detail_brush(rug, bisel); let skin = u8c(col.skin); let shirt = u8c(col.shirt); let pants = u8c(col.pants); let hair = u8c(col.hair); // Transform del cuerpo superior (torso/cabeza/brazos): rebote `bob` + inclinación `lean` // alrededor de la cadera (pivote en `hip_y`). Las piernas quedan plantadas. let bob = pose.bob * h * 0.5; let upper = |local_y: f32, off: Vec3| -> Vec3 { // punto (0, local_y, 0)+off, inclinado por lean sobre el pivote de cadera y subido bob let rel = Vec3::new(off.x, local_y - hip_y, off.z); let r = rot_x(rel, pose.lean); base + Vec3::new(r.x, hip_y + r.y + bob, r.z) }; // --- Piernas: cápsulas rechonchas, articuladas en la cadera (leg swing en X) --- for (sx, swing) in [(-1.0f32, pose.leg_l), (1.0, pose.leg_r)] { let hip = base + Vec3::new(sx * hip_x, hip_y, 0.0); let dir = rot_x(Vec3::new(0.0, -leg_len, 0.0), swing); let foot = hip + dir; capsula(&mut g, hip, foot, limb_r * 1.05, pants); } // --- Torso: elipsoide redondo (panza) --- let torso_c = upper(torso_cy, Vec3::ZERO); elipsoide(&mut g, torso_c, Vec3::new(torso_rx, torso_ry * estilo.panza, torso_rz), shirt); // --- Brazos: cápsulas al costado, articuladas en el hombro (out en Z, swing en X) --- // Splay base: aun en reposo los brazos abren un poco hacia afuera para NO fundirse con el // torso ni juntar las manos al frente (leía como "manos atadas"). Se suma a la apertura del gesto. let base_out = 0.16; for (sx, swing, out) in [(-1.0f32, pose.arm_l, pose.arm_l_out), (1.0, pose.arm_r, pose.arm_r_out)] { let shoulder = upper(shoulder_y, Vec3::new(sx * shoulder_x, 0.0, 0.0)); // dirección local del brazo colgando, rotada: primero swing (X), luego apertura (Z). let mut dir = rot_x(Vec3::new(0.0, -arm_len, 0.0), swing); dir = rot_z(dir, sx * (out + base_out)); // apertura espejada por lado // el brazo también acompaña el lean del cuerpo superior dir = rot_x(dir, pose.lean); let hand = shoulder + dir; capsula(&mut g, shoulder, hand, limb_r, shirt); // Mano: esferita de piel en la punta (apenas mayor que el brazo, no un guante gigante). elipsoide(&mut g, hand, Vec3::splat(limb_r * 0.98), skin); } // --- Cabeza + cara (baby-schema) --- let head_c = upper(head_cy, Vec3::ZERO); // Cabeza: elipsoide con cúpula (craneo) arriba y mejilla abajo. La construimos como un // elipsoide con radios asimétricos por hemisferio via `elipsoide_mejilla`. let head_rx = head_r * estilo.mejilla; elipsoide_cabeza(&mut g, head_c, head_r, head_rx, head_ry, skin); // **Zona de bigote/barba = coloración PLANA, sin sustancia** (no una boca protuberante, // que parecía un pico de pato): re-tiñe la piel de la mitad inferior FRONTAL de la cara a // un tono apenas más apagado/oscuro (sombra de barba). No agrega geometría; se aplica // ANTES del pelo/ojos para no pisarlos. Sutil a propósito (que no lea como máscara). let eye_y0 = head_c.y + estilo.ojo_altura * head_r; tint_barba(&mut g, head_c, head_rx, head_ry, head_r, eye_y0, skin); // Pelo: **casquete alto** sobre la corona, corrido hacia atrás (−Z) para dejar la FRENTE // y las mejillas de piel a la vista (la frente grande es el rasgo tierno). No baja hasta // los ojos: recorte bien por encima → cara abierta, no un antifaz enmarcado. let eye_y = head_c.y + estilo.ojo_altura * head_r; elipsoide_min_y( &mut g, head_c + Vec3::new(0.0, head_ry * 0.40, -head_r * 0.14), Vec3::new(head_rx * 1.02, head_ry * 0.80, head_r * 1.0), hair, eye_y + head_r * 0.58, ); // Ojos: **esclera clara + pupila oscura + chispa** (NO un óvalo negro suelto, que a esta // resolución + isosuperficie leía como un HUECO/cuenca vacía = cara de monstruo). El blanco // domina y "abre" el ojo; la pupila oscura, más chica y al frente-centro, da la mirada; una // chispa arriba-interior le pone vida. Anchos > altos (ojo infantil), BIEN separados, con piel // en medio y alrededor (no antifaz). A/por debajo de la media (`ojo_altura`). `head_pitch` tilta. let er = head_r * estilo.ojo_tam; let ex = head_rx * 0.5; let zf = head_r * 0.80; let eoff = eye_y - head_c.y; for s in [-1.0f32, 1.0] { let eye = head_c + rot_x(Vec3::new(s * ex, eoff, zf), pose.head_pitch); // Esclera: óvalo claro, ancho, apenas saliente al frente para que no se hunda. elipsoide(&mut g, eye, Vec3::new(er * 1.06, er * 0.86, er * 0.62), [240, 241, 244]); // Pupila/iris: oscura, ~mitad, corrida al FRENTE (+Z) y apenas hacia adentro (mirada // convergente amable) y abajo. Va DESPUÉS de la esclera para pisarla en el centro. 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); elipsoide(&mut g, pupil, Vec3::new(er * 0.52, er * 0.6, er * 0.5), [40, 32, 46]); // Chispa (arriba-interior de la pupila) — grande para que lea de lejos. 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); elipsoide(&mut g, spark, Vec3::new(er * 0.3, er * 0.3, er * 0.34), [250, 251, 253]); } // **Boca = sonrisa como tint PLANO** (sin geometría protuberante: una boca de color en // relieve leía como pico de pato). Re-tiñe una banda corta, curvada hacia ARRIBA (feliz), // de la piel frontal-baja de la cara a un tono cálido apagado. Pequeña y centrada. Va // después de barba/ojos para no pisarse. let boca_y = eye_y - head_r * 0.44; sonrisa(&mut g, head_c, head_rx, head_r, boca_y, skin); g.clear_detail_brush(); g.reset_dirty(); g } /// Rellena un elipsoide de semiejes `r` centrado en `c` con `color`. fn elipsoide(g: &mut VoxelGrid, c: Vec3, r: Vec3, color: [u8; 3]) { elipsoide_min_y(g, c, r, color, f32::NEG_INFINITY); } /// Elipsoide recortado por abajo (`y >= y_min`) — para casquetes de pelo. fn elipsoide_min_y(g: &mut VoxelGrid, c: Vec3, r: Vec3, color: [u8; 3], y_min: f32) { let [dx, dy, dz] = g.dim(); let x0 = (c.x - r.x).floor().max(0.0) as u32; let x1 = (c.x + r.x).ceil().min(dx as f32) as u32; let y0 = (c.y - r.y).floor().max(0.0).max(y_min.max(0.0)) as u32; let y1 = (c.y + r.y).ceil().min(dy as f32) as u32; let z0 = (c.z - r.z).floor().max(0.0) as u32; let z1 = (c.z + r.z).ceil().min(dz as f32) as u32; for x in x0..x1 { for y in y0..y1 { if (y as f32) < y_min { continue; } for z in z0..z1 { let d = Vec3::new( (x as f32 + 0.5 - c.x) / r.x, (y as f32 + 0.5 - c.y) / r.y, (z as f32 + 0.5 - c.z) / r.z, ); if d.length_squared() <= 1.0 { g.set(x, y, z, color); } } } } } /// Cabeza: elipsoide con **cúpula arriba** (semieje Y superior) y **mejillas** (radio X /// pleno abajo, afinado arriba) — la forma del baby-schema. `rx` ya lleva el ensanche de /// mejilla; arriba de la mitad se afina hacia `head_r` para no quedar cuadrada. fn elipsoide_cabeza(g: &mut VoxelGrid, c: Vec3, head_r: f32, rx: f32, ry: f32, color: [u8; 3]) { let [dx, dy, dz] = g.dim(); let x0 = (c.x - rx).floor().max(0.0) as u32; let x1 = (c.x + rx).ceil().min(dx as f32) as u32; let y0 = (c.y - ry).floor().max(0.0) as u32; let y1 = (c.y + ry).ceil().min(dy as f32) as u32; let z0 = (c.z - rx).floor().max(0.0) as u32; let z1 = (c.z + rx).ceil().min(dz as f32) as u32; for y in y0..y1 { let ty = (y as f32 + 0.5 - c.y) / ry; // -1..1 // Radio horizontal por altura: mejilla plena abajo (ty<0), afina hacia head_r arriba. let rh = if ty >= 0.0 { rx + (head_r - rx) * ty } else { rx }; let rz = rh * 0.96; for x in x0..x1 { for z in z0..z1 { let dxx = (x as f32 + 0.5 - c.x) / rh; let dzz = (z as f32 + 0.5 - c.z) / rz; if dxx * dxx + ty * ty + dzz * dzz <= 1.0 { g.set(x, y, z, color); } } } } } /// **Sombra de barba/bigote como tint PLANO** (sin geometría): re-tiñe la piel de la mitad /// inferior FRONTAL de la cara a un tono apenas más oscuro/apagado. Sólo toca voxels que HOY /// son piel (`== skin`), así no pisa ojos/pelo (que se estampan después). Sutil a propósito. fn tint_barba(g: &mut VoxelGrid, c: Vec3, rx: f32, ry: f32, head_r: f32, eye_y: f32, skin: [u8; 3]) { // Barba = piel un 6% más oscura, apenas más fría (sombra tenue, no una máscara: fuerte // ensuciaba la cara y la volvía adusta/monstruosa). let beard = [ (skin[0] as f32 * 0.94) as u8, (skin[1] as f32 * 0.93) as u8, (skin[2] as f32 * 0.94) as u8, ]; let [dx, dy, dz] = g.dim(); // De la barbilla hasta un poco por debajo de los ojos (zona bigote+barba+mentón). let y0 = (c.y - ry).floor().max(0.0) as u32; let y1 = (eye_y - head_r * 0.30).floor().max(0.0).min(dy as f32) as u32; let x0 = (c.x - rx).floor().max(0.0) as u32; let x1 = (c.x + rx).ceil().min(dx as f32) as u32; // Sólo el frente de la cara (+Z), no la nuca/mandíbula lateral. let z0 = (c.z + head_r * 0.28).floor().max(0.0) as u32; let z1 = (c.z + head_r * 1.1).ceil().min(dz as f32) as u32; for x in x0..x1 { for y in y0..y1 { for z in z0..z1 { if let Some(px) = g.get(x, y, z) { if px[3] > 0 && [px[0], px[1], px[2]] == skin { g.set(x, y, z, beard); } } } } } } /// **Sonrisa como tint PLANO** (sin geometría): re-tiñe una banda corta de piel FRONTAL a la /// altura `boca_y`, curvada hacia arriba en los extremos (feliz), a un tono cálido apagado /// (labios/sombra de boca, no rojo saturado que a esta resolución grita "pico de pato"). Sólo /// pisa voxels que HOY son piel, así no borra ojos/pelo. Centrada en X, en el hemisferio +Z. fn sonrisa(g: &mut VoxelGrid, c: Vec3, rx: f32, head_r: f32, boca_y: f32, skin: [u8; 3]) { // Boca = piel bastante más oscura y algo más cálida (rojiza), pero NO roja pura. let mouth = [ (skin[0] as f32 * 0.72 + 22.0).min(255.0) as u8, (skin[1] as f32 * 0.55).min(255.0) as u8, (skin[2] as f32 * 0.55).min(255.0) as u8, ]; let [dx, dy, dz] = g.dim(); let half_w = rx * 0.42; // media boca let thick = head_r * 0.12; // grosor vertical de la línea let curve = head_r * 0.22; // cuánto suben los extremos (sonrisa) let x0 = (c.x - half_w).floor().max(0.0) as u32; let x1 = (c.x + half_w).ceil().min(dx as f32) as u32; // Sólo el frente de la cara (+Z). let z0 = (c.z + head_r * 0.5).floor().max(0.0) as u32; let z1 = (c.z + head_r * 1.1).ceil().min(dz as f32) as u32; for x in x0..x1 { // Altura de la línea de sonrisa en esta X: sube hacia los extremos (parábola). let t = ((x as f32 + 0.5 - c.x) / half_w).clamp(-1.0, 1.0); let ly = boca_y + curve * t * t; let y0 = (ly - thick * 0.5).floor().max(0.0) as u32; let y1 = (ly + thick * 0.5).ceil().min(dy as f32) as u32; for y in y0..y1 { for z in z0..z1 { if let Some(px) = g.get(x, y, z) { if px[3] > 0 && [px[0], px[1], px[2]] == skin { g.set(x, y, z, mouth); } } } } } } /// Cápsula (cilindro con tapas esféricas) de radio `r` entre los puntos `a` y `b`. fn capsula(g: &mut VoxelGrid, a: Vec3, b: Vec3, r: f32, color: [u8; 3]) { let [dx, dy, dz] = g.dim(); let lo = a.min(b) - Vec3::splat(r + 1.0); let hi = a.max(b) + Vec3::splat(r + 1.0); let x0 = lo.x.floor().max(0.0) as u32; let x1 = hi.x.ceil().min(dx as f32) as u32; let y0 = lo.y.floor().max(0.0) as u32; let y1 = hi.y.ceil().min(dy as f32) as u32; let z0 = lo.z.floor().max(0.0) as u32; let z1 = hi.z.ceil().min(dz as f32) as u32; let ab = b - a; let ab2 = ab.length_squared().max(1e-6); let r2 = r * r; for x in x0..x1 { for y in y0..y1 { for z in z0..z1 { let p = Vec3::new(x as f32 + 0.5, y as f32 + 0.5, z as f32 + 0.5); let t = ((p - a).dot(ab) / ab2).clamp(0.0, 1.0); let closest = a + ab * t; if (p - closest).length_squared() <= r2 { g.set(x, y, z, color); } } } } } #[cfg(test)] mod tests { use super::*; fn solids(g: &VoxelGrid) -> usize { let [w, h, d] = g.dim(); let mut n = 0; for x in 0..w { for y in 0..h { for z in 0..d { if g.is_solid(x as i32, y as i32, z as i32) { n += 1; } } } } n } fn col() -> Colores { Colores::nuevos([0.85, 0.68, 0.54], [0.2, 0.6, 0.55], [0.18, 0.22, 0.34]) } #[test] fn construye_un_ser_con_volumen() { let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col()); assert!(solids(&g) > 3000, "el ser tiene volumen: {}", solids(&g)); } #[test] fn pies_en_el_piso() { // El voxel sólido más bajo está cerca de y=0 (pies apoyados). let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col()); let [w, h, d] = g.dim(); let mut ymin = h; for x in 0..w { for y in 0..h { for z in 0..d { if g.is_solid(x as i32, y as i32, z as i32) { ymin = ymin.min(y); } } } } assert!(ymin <= 2, "pies cerca del piso: ymin={ymin}"); } #[test] fn el_bebe_es_mas_cabezon_que_el_adulto() { // Menos cabezas ⇒ la cabeza ocupa una fracción mayor de la altura. let baby = EstiloSer::por_edad(Age::Baby); let adult = EstiloSer::por_edad(Age::Adult); assert!(baby.cabezas < adult.cabezas, "bebé menos cabezas"); // Radio de cabeza relativo a la altura. let hr = |e: &EstiloSer| 1.0 / (2.0 * e.cabezas); assert!(hr(&baby) > hr(&adult) + 0.05, "bebé cabezón"); } #[test] fn los_gestos_cambian_la_silueta() { // Festejar (brazos arriba) ensancha/eleva la ocupación respecto a idle. let idle = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 46, col()); let cheer = crate::Clip::Cheer.pose(1.2); let festeja = humanoide_voxel(&EstiloSer::default(), &cheer, 46, col()); // Ocupación en la franja alta (por encima de la cabeza-hombro) crece al levantar brazos. let alto = |g: &VoxelGrid| { let [w, h, d] = g.dim(); let y0 = h * 3 / 4; let mut n = 0; for x in 0..w { for y in y0..h { for z in 0..d { if g.is_solid(x as i32, y as i32, z as i32) { n += 1; } } } } n }; assert!(alto(&festeja) > alto(&idle), "festejar sube volumen alto: {} vs {}", alto(&festeja), alto(&idle)); } #[test] fn hornea_detalle_por_voxel() { // Con redondez alta el bisel horneado es alto (silueta lisa). let g = humanoide_voxel(&EstiloSer::default(), &Pose::default(), 40, col()); let [w, h, d] = g.dim(); let mut con_detalle = 0; for x in 0..w { for y in 0..h { for z in 0..d { if g.is_solid(x as i32, y as i32, z as i32) { if let Some(det) = g.detail_at(x, y, z) { if det != [0, 0] { con_detalle += 1; } } } } } } assert!(con_detalle > 1000, "el cuerpo lleva detalle por voxel: {con_detalle}"); } #[test] fn presets_de_arquetipo_difieren() { assert!(EstiloSer::amable().redondez > EstiloSer::robusto().redondez); assert!(EstiloSer::agil().angulosidad > EstiloSer::amable().angulosidad); assert!(EstiloSer::agil().cabezas > EstiloSer::amable().cabezas); } }