//! `studio` — el **modelo-documento del creador de mundos**: un [`Project`] //! agnóstico de UI que junta los *artefactos* (mundos, personajes, escenas) bajo //! nombre, para que **una interfaz** los cree/edite y la **IA** los emita o lea. //! //! Es contenido puro y **serializable** (RON para edición a mano / salida de la IA; //! postcard para la CAS): no toca GPU ni ventana. La studio app (o cualquier otra) //! lo carga, lo pinta con sus widgets y lo guarda. Cada artefacto referencia tipos //! que ya existen en este crate ([`WorldRecipe`], [`Age`]) — el `Project` sólo les //! pone nombre y los agrupa. use serde::{Deserialize, Serialize}; use crate::actor::{Actor, Age, Clip}; use crate::humano::{humanoide_voxel, Colores, EstiloSer}; use llimphi_3d::VoxelGrid; use crate::director::{ActorKey, ActorScript}; use crate::bicho::Bicho; use crate::conducta::Conducta; use crate::ser_sdf::Atuendo; use crate::ecuacion::{Assign, Expr, FieldDef, Program, Symbols}; use crate::casa::{CasaTipo, CasaUso}; use crate::mineral::{Mineral, MineralUso}; use crate::rig::{Andar, Movimiento, Rig}; use crate::worldgen::{Bioma, BiomaPalette, Forma, Material, ResolvedMaterial}; use llimphi_3d::glam::{Mat4, Vec3}; use llimphi_3d::{Camera3d, Vertex3d}; /// Dimensión por defecto de la grilla con la que el editor previsualiza un mundo /// (cúbica en XZ, alto = 0.4·lado, mínimo 48) — el mismo criterio que la app. pub const PREVIEW_DIM_XZ: u32 = 128; /// **Dirección del sol** de la escena (hacia el sol, sin normalizar). Una sola /// fuente de verdad: la usa el preview/export para iluminar (`voxel.sun_dir`) y el /// plano [`ShotKind::Backlight`] para saber adónde mirar. Sol algo bajo → luz /// rasante (claroscuro) y, en contraluz, **god rays** legibles. pub const SCENE_SUN: [f32; 3] = [0.5, 0.36, 0.45]; /// Calcula el `dim` `[x, y, z]` de un mundo de lado `xz` (alto derivado). pub fn world_dim(xz: u32) -> [u32; 3] { let dy = (xz * 4 / 10).max(48); [xz, dy, xz] } /// **Paso de reubicación** de la ventana de una escena (voxels). El origen sólo /// salta a múltiplos de este valor: así la ventana no se regenera cada cuadro al /// caminar el reparto, sólo al cruzar un paso. Como la cámara mira al centroide del /// reparto (en espacio de ventana), el salto traslada terreno + cámara + actores /// **juntos** → sin pop visible (la imagen es continua), pero el mundo no tiene /// borde: el reparto puede caminar indefinidamente. Ver [`window_origin_for_cast`]. pub const SCENE_WINDOW_STEP: i32 = 16; /// Redondea `v` al múltiplo de `step` hacia abajo (floor con signo) — espeja el /// `snap` del [`WorldStream`](crate::WorldStream). #[inline] fn snap(v: i32, step: i32) -> i32 { v.div_euclid(step) * step } /// **Origen de ventana (esquina, coords de mundo) que centra al reparto** de una /// escena en el instante `t`: promedia las columnas `(gx, gz)` de los guiones /// (tomadas en su tiempo cuantizado, igual que al posar) y resta medio `dim`, /// snappeado a [`SCENE_WINDOW_STEP`]. Es lo que hace que **toda escena viva en un /// mundo infinito**: la ventana voxel sigue al reparto en vez de ser una caja fija /// centrada en el origen. Sin reparto, devuelve `[0, 0]` (mundo centrado, como /// antes). pub fn window_origin_for_cast(scripts: &[ActorScript], t: f32, dim: [u32; 3]) -> [i32; 2] { if scripts.is_empty() { return [0, 0]; } let (mut cwx, mut cwz) = (0.0_f32, 0.0_f32); for s in scripts { let smp = s.sample(s.quantize(t)); cwx += smp.gx; cwz += smp.gz; } let n = scripts.len() as f32; let (cwx, cwz) = (cwx / n, cwz / n); [ snap(cwx as i32 - dim[0] as i32 / 2, SCENE_WINDOW_STEP), snap(cwz as i32 - dim[2] as i32 / 2, SCENE_WINDOW_STEP), ] } // ============================================================================= // Nivel 1 — Leyes (físicas / comportamientos) // ============================================================================= /// Un **parámetro autorado** de una ley [`Ecuacion`](LeyKind::Ecuacion): nombre (que /// la fórmula referencia), valor y rango (para el slider). Es el equivalente dinámico /// de los `(nombre, valor, min, max)` cableados de `Fluir`/`Crecer`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ParamDef { pub name: String, pub value: f32, pub min: f32, pub max: f32, } impl ParamDef { pub fn new(name: impl Into, value: f32, min: f32, max: f32) -> Self { Self { name: name.into(), value, min, max } } } /// **Tipo de ley** (comportamiento matemático). Enum extensible: `Fluir` (líquidos, /// autómata nativo que mueve celdas) y `Crecer` (flora) son leyes **nativas** con /// runtime cableado; `Ecuacion` es la ley **autorable** — un sistema de ecuaciones de /// campo escrito por el usuario y compilado (ver [`crate::ecuacion`]). Cada variante /// nativa expone sus parámetros editables vía [`params`](Self::params) para que la UI /// arme sliders; la `Ecuacion` declara sus propios campos y parámetros. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum LeyKind { /// Líquido: se esparce a vecinos en horizontal y cae con gravedad (cascadas). Fluir { gravedad: f32, horizontal: f32 }, /// Crece a una velocidad VARIABLE (flora). Crecer { velocidad: f32 }, /// **Ley autorable**: campos de estado + parámetros + una ecuación /// `Δcampoₖ/dt = fuenteₖ` por campo (misma cantidad y orden que `campos`). El /// texto `fuentes[k]` se parsea con los nombres de `campos`/`params`. Generaliza /// difusión, reacción‑difusión, calor, crecimiento… con la misma máquina. Ecuacion { campos: Vec, params: Vec, fuentes: Vec, }, } impl LeyKind { /// Un ejemplar de cada tipo, con valores por defecto (para crear/ciclar). pub fn all_defaults() -> Vec { vec![ LeyKind::Fluir { gravedad: 1.0, horizontal: 0.6 }, LeyKind::Crecer { velocidad: 1.0 }, LeyKind::ecuacion_default(), ] } /// Ley autorable por defecto: **difusión** de un campo `c` (`Δc/dt = k·lap(c)`). /// El punto de partida más simple que ya *hace algo* visible en el laboratorio. pub fn ecuacion_default() -> LeyKind { LeyKind::Ecuacion { campos: vec![FieldDef::new("c", 0.0, 0.0, 1.0)], params: vec![ParamDef::new("k", 0.15, 0.0, 0.25)], fuentes: vec!["k * lap(c)".to_string()], } } /// Nombre legible (español). pub fn label(&self) -> &'static str { match self { LeyKind::Fluir { .. } => "fluir", LeyKind::Crecer { .. } => "crecer", LeyKind::Ecuacion { .. } => "ecuación", } } /// El tipo siguiente (cicla por los defaults), preservando nada (resetea params). pub fn next(&self) -> LeyKind { let all = Self::all_defaults(); let i = all.iter().position(|k| k.label() == self.label()).unwrap_or(0); all[(i + 1) % all.len()].clone() } /// Parámetros editables: `(nombre, valor, min, max)`. La UI arma un slider por cada. pub fn params(&self) -> Vec<(&'static str, f32, f32, f32)> { match self { LeyKind::Fluir { gravedad, horizontal } => vec![ ("gravedad", *gravedad, 0.0, 1.0), ("horizontal", *horizontal, 0.0, 1.0), ], LeyKind::Crecer { velocidad } => vec![("velocidad", *velocidad, 0.0, 5.0)], // La `Ecuacion` tiene parámetros dinámicos (nombres `String`): no caben en // esta firma `&'static str`; la UI los edita vía [`ecuacion_params`]. LeyKind::Ecuacion { .. } => vec![], } } /// Fija el parámetro `i` (en el orden de [`params`](Self::params)) a `v`. pub fn set_param(&mut self, i: usize, v: f32) { match self { LeyKind::Fluir { gravedad, horizontal } => match i { 0 => *gravedad = v, 1 => *horizontal = v, _ => {} }, LeyKind::Crecer { velocidad } => { if i == 0 { *velocidad = v; } } // Para `Ecuacion` se usa [`set_ecuacion_param`] (params dinámicos). LeyKind::Ecuacion { .. } => {} } } // --- Autoría de leyes por ecuación ------------------------------------- /// Parámetros de una ley `Ecuacion` como `(nombre, valor, min, max)` para armar /// sliders. Vacío si no es una `Ecuacion`. pub fn ecuacion_params(&self) -> Vec<(String, f32, f32, f32)> { match self { LeyKind::Ecuacion { params, .. } => { params.iter().map(|p| (p.name.clone(), p.value, p.min, p.max)).collect() } _ => Vec::new(), } } /// Valores de los parámetros de la `Ecuacion` en orden (los que consume el motor). pub fn ecuacion_param_values(&self) -> Vec { match self { LeyKind::Ecuacion { params, .. } => params.iter().map(|p| p.value).collect(), _ => Vec::new(), } } /// Fija el valor del parámetro `i` de una ley `Ecuacion` (clampeado a su rango). pub fn set_ecuacion_param(&mut self, i: usize, v: f32) { if let LeyKind::Ecuacion { params, .. } = self { if let Some(p) = params.get_mut(i) { p.value = v.clamp(p.min, p.max); } } } /// Tabla de símbolos (campos + params por nombre) para parsear/imprimir sus /// fórmulas. `None` si no es una `Ecuacion`. pub fn ecuacion_symbols(&self) -> Option { match self { LeyKind::Ecuacion { campos, params, .. } => Some(Symbols { campos: campos.iter().map(|c| c.name.clone()).collect(), params: params.iter().map(|p| p.name.clone()).collect(), }), _ => None, } } /// Compila una ley `Ecuacion` a un [`Program`] ejecutable: parsea cada fuente con /// los símbolos de la ley y arma un `Δcampoₖ/dt` por campo. `None` si no es /// `Ecuacion`; `Err(msg)` con el primer error de parseo (legible, ubicando el campo). pub fn compile_ecuacion(&self) -> Option> { let LeyKind::Ecuacion { campos, fuentes, .. } = self else { return None; }; let sym = self.ecuacion_symbols()?; let mut asignaciones = Vec::with_capacity(fuentes.len()); for (i, src) in fuentes.iter().enumerate() { match Expr::parse(src, &sym) { Ok(expr) => asignaciones.push(Assign { campo: i as u16, expr }), Err(err) => { let campo = campos.get(i).map(|c| c.name.as_str()).unwrap_or("?"); return Some(Err(format!("campo «{campo}»: {err}"))); } } } Some(Ok(Program::compile(&asignaciones))) } } /// Una **Ley**: nombre + tipo (con sus parámetros). El nivel más básico de la /// composición; los materiales la adoptan vía [`LeyUso`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ley { pub id: u64, pub name: String, pub kind: LeyKind, } // ============================================================================= // Nivel 2 — Materiales // ============================================================================= /// **Rol de un material**: relleno de terreno o un objeto colocable (con su forma). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum MatRole { /// Relleno del terreno (suelo, acantilado, cumbre…). Terreno, /// Objeto colocado sobre el suelo (cactus, árbol, roca suelta…). Objeto(Forma), } impl MatRole { pub fn label(&self) -> &'static str { match self { MatRole::Terreno => "terreno", MatRole::Objeto(_) => "objeto", } } } /// **Uso de una ley por un material**: a qué ley refiere y con qué parámetros /// concretos (overridean los defaults de la ley — «espesor del líquido», etc.). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LeyUso { pub ley: u64, pub params: Vec, } /// Un **material autorable**: color/grano (texturas a futuro) + rol + leyes /// aplicadas, con **herencia viva por padre**. Los campos visuales son `Option`: /// `None` = heredar del padre (o del builtin). [`Project::resolve_material`] aplana /// la cadena. Así «cactus amarillo» = hijo de «cactus verde» que sólo redefine el /// color. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MaterialDef { pub id: u64, pub name: String, /// Material del que hereda lo no redefinido (`None` = raíz). #[serde(default)] pub parent: Option, pub role: MatRole, /// Color `[r,g,b]` en `[0,1]`; `None` = heredar. #[serde(default)] pub color: Option<[f32; 3]>, /// Grano de materia `[0,1]`; `None` = heredar. #[serde(default)] pub grain: Option, /// Brillo/reflectividad `[0,1]`; `None` = heredar (del padre o del builtin). #[serde(default)] pub brillo: Option, /// **Rugosidad sub-voxel** `[0,~1.5]` (bump procedural): rompe la cara plana del /// voxel a corta distancia. `None` = heredar (default efectivo 0 = liso). Es un /// look GLOBAL del ray-march; el preview aplica el del material en foco (ver /// `Project::detalle_material`). Roca alta, metal 0. #[serde(default)] pub rugosidad: Option, /// **Bisel sub-voxel** `[0,1]` (suavizado por isosuperficie): pendientes /// diagonales y esquinas biseladas. `None` = heredar (default 0 = voxel duro). /// También look global del preview. #[serde(default)] pub bisel: Option, /// Leyes que adopta, con sus parámetros. #[serde(default)] pub leyes: Vec, /// Tag de paleta semilla (de fábrica): deja que la IA/preset encuentre «la /// arena» sin clavar un id. `None` en materiales hechos por el usuario. #[serde(default)] pub builtin: Option, } impl MaterialDef { /// Material de fábrica a partir de una variante semilla (color/grano/rol). pub fn from_builtin(id: u64, m: Material) -> Self { let role = match m { Material::Cactus => MatRole::Objeto(Forma::Columnar), Material::Leaf => MatRole::Objeto(Forma::Arbol), Material::Wheat => MatRole::Objeto(Forma::Trigo), Material::Bloom => MatRole::Objeto(Forma::Lavanda), _ => MatRole::Terreno, }; let c = m.color(); Self { id, name: m.label().to_string(), parent: None, role, color: Some([c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0]), grain: Some(m.grain()), brillo: None, // hereda del builtin (Material::brillo) rugosidad: None, bisel: None, leyes: Vec::new(), builtin: Some(m), } } } // ============================================================================= // Nivel 5 — Mundos // ============================================================================= /// Un **Mundo**: semilla + uno o más biomas. La semilla es lo único «numérico» que /// el usuario teclea (con botón de random en la UI). El stage 1 genera con el primer /// bioma; la distribución multi-bioma queda como refinamiento. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Mundo { pub id: u64, pub name: String, pub seed: u32, #[serde(default)] pub biomas: Vec, /// **Fuente de mapa real** (si el mundo se importó de un GeoJSON): persiste la /// receta, no el grid. `None` = mundo procedural (bioma+semilla). Ver /// [`MapaFuente`]. #[serde(default)] pub mapa: Option, } /// **Fuente de un mundo importado de un mapa real**, persistible dentro del /// [`Project`]. Guarda el GeoJSON del visor (y un DEM `.pgm` opcional) **embebidos** /// —el documento queda autocontenido— para que el grid se **regenere** al abrir (un /// mundo es una receta, no un grid). El puente que lo materializa a `VoxelGrid` vive /// en el frontend (`nahual-geo-voxel`), no en este core: aquí sólo son datos. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MapaFuente { /// GeoJSON crudo (una `FeatureCollection`) del mapa. pub geojson: String, /// DEM opcional (PGM binario P5) embebido; `None` = sin relieve por esta vía. #[serde(default)] pub pgm: Option>, /// DEM opcional en **GeoTIFF** (`.tif`) embebido — georreferenciado por sí mismo /// (tiene prioridad sobre `pgm`). `None` = sin relieve por esta vía. #[serde(default)] pub dem_tif: Option>, /// Rango de altura `[0, rango]` (metros) al que se mapea el DEM **PGM** (el /// GeoTIFF trae sus metros absolutos, no usa esto). #[serde(default = "default_rango_dem")] pub rango_dem_m: f32, } fn default_rango_dem() -> f32 { 300.0 } /// Lo que el render necesita de un mundo: el bioma (relieve), la semilla y la paleta /// **ya resuelta** (colores concretos). Lo arma [`Project::render_mundo`]. #[derive(Debug, Clone)] pub struct MundoRender { pub bioma: Bioma, pub seed: u32, pub palette: BiomaPalette, } /// **Especificación serializable de un personaje**: lo que un editor/IA fija (edad /// + colores). Se materializa con [`to_actor`](Self::to_actor) en un [`Actor`] /// posable. Los colores son `[r, g, b]` en `[0,1]` (como [`Actor`]). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CharSpec { /// Id estable (asignado por el [`Project`]); `0` hasta que se agrega. #[serde(default)] pub id: u64, pub name: String, pub age: Age, pub skin: [f32; 3], pub shirt: [f32; 3], pub pants: [f32; 3], /// **Pelo** `[r,g,b]` en `[0,1]`. `None` = derivado de la piel; `Some` = color explícito /// (p.ej. muestreado FIEL de una foto). Serde default para compat con proyectos viejos. #[serde(default)] pub hair: Option<[f32; 3]>, /// **Cuerpo**: `None` = humanoide (muñeco [`Actor`] rico: cara, gestos, cross-fade, /// look-at); `Some(mov)` = una morfología arbitraria ([`Movimiento`]: rig + /// andares por estado) — cuadrúpedo, ave, serpiente… Default `None` (compat). #[serde(default)] pub cuerpo: Option, /// **Conducta** (capa 3): cómo se mueve por el mundo (deambular/gregario/miedo). /// Aplica a cualquier cuerpo (humanoide o rig). #[serde(default)] pub conducta: Conducta, /// **Vestuario** (peinado, barba, sombrero/corona, anteojos, falda, manto). Sólo /// lo viste el camino SDF del humanoide —el que filma escenas—; la malla de cajas /// y el volumen voxel lo ignoran. Un `.ron` viejo, sin la clave, abre desnudo. /// Se escribe SIEMPRE, aunque esté vacío: el documento también viaja en postcard, /// que es posicional, y un campo salteado al escribir desalinea todo lo que sigue /// al leer (`skip_serializing_if` rompió `round_trip_postcard_nativo`). #[serde(default)] pub atuendo: Atuendo, /// **Especie**: `Some(b)` = el ser ES ese animal del bestiario (el elefante del /// rey, el caballo del príncipe) y se filma con su cuerpo SDF /// ([`bicho_sdf_lod`](crate::bicho_sdf_lod)), el mismo de la fauna, dirigido por /// su guion de keyframes como cualquier actor. Gana sobre [`cuerpo`](Self::cuerpo). /// Va al final y se escribe siempre, por lo mismo que el atuendo (postcard). #[serde(default)] pub bicho: Option, } impl CharSpec { /// Un personaje con la paleta por defecto de [`Actor::new`] a la edad dada. /// `id = 0` hasta que el [`Project`] se lo asigna al agregarlo. pub fn new(name: impl Into, age: Age) -> Self { let a = Actor::new(Vec3::ZERO, 0.0); Self { id: 0, name: name.into(), age, skin: a.skin, shirt: a.shirt, pants: a.pants, hair: None, cuerpo: None, conducta: Conducta::default(), atuendo: Atuendo::default(), bicho: None, } } /// `true` si el ser se filma como SDF analítico (humanoide o bicho del /// bestiario); `false` si es un rig genérico, que sólo tiene malla de cajas. pub fn es_sdf(&self) -> bool { self.bicho.is_some() || self.cuerpo.is_none() } /// Cicla la especie: persona/rig → primer bicho del bestiario → … → último → sin /// especie. `atras` recorre al revés (son setenta: de a uno no se llega nunca). pub fn cycle_bicho(&mut self, atras: bool) { let todos = Bicho::ALL; let n = todos.len(); self.bicho = match self.bicho { None => Some(if atras { todos[n - 1] } else { todos[0] }), Some(b) => { let i = todos.iter().position(|&x| x == b).unwrap_or(0); match (atras, i) { (false, i) if i + 1 < n => Some(todos[i + 1]), (true, i) if i > 0 => Some(todos[i - 1]), _ => None, } } }; } /// Nombre legible del cuerpo (para la UI). pub fn cuerpo_label(&self) -> &str { match &self.cuerpo { None => "humanoide", Some(m) => m.rig.nombre.as_str(), } } /// Cicla el cuerpo: humanoide → cuadrúpedo → ave → serpiente → humanoide. Al /// cambiar de rig, regenera andares por defecto (siempre en sync con la morfología). pub fn cycle_cuerpo(&mut self) { let presets = Rig::presets(); self.cuerpo = match &self.cuerpo { None => Some(Movimiento::preset(presets[1].clone())), // humanoide → cuadrúpedo Some(m) => { let i = presets.iter().position(|p| p.nombre == m.rig.nombre).unwrap_or(0); if i + 1 < presets.len() { Some(Movimiento::preset(presets[i + 1].clone())) } else { None // serpiente → humanoide } } }; } /// **Meta de render** del ser en `pos`/`facing` para una animación: la matriz de /// modelo + la malla. Humanoide (`rig = None`) usa el [`Actor`] rico (clip, fase, /// look-at); un rig usa su [`Andar::caminar`] al andar (Walk/Run) o pose neutra si /// no. Centraliza el branch para el preview y las escenas. pub fn to_meta( &self, pos: Vec3, facing: f32, clip: Clip, phase: f32, look: Option, ) -> (Mat4, Vec, Vec) { if let Some(b) = self.bicho { // Un bicho no tiene malla propia: cuando el reparto lo baja a cajas (no // entró en el uniform), lo hace el rig genérico de su familia, con su // pelaje y escalado a su alto. Lejos, una silueta que camina alcanza. let receta = crate::bicho_receta::receta(b); let rigs = Rig::presets(); let rig = match receta { crate::bicho_receta::Receta::Ave(_) => rigs[2].clone(), crate::bicho_receta::Receta::Reptante(_) | crate::bicho_receta::Receta::Nadador(_) => { rigs[3].clone() } _ => rigs[1].clone(), }; let mov = Movimiento::preset(rig); let pose = match clip { Clip::Walk => mov.andares.caminar.pose(phase), Clip::Run => mov.andares.correr.pose(phase), _ => mov.andares.quieto.pose(phase), }; let p = receta.pinta(); let (v, i) = mov.rig.mesh(&pose, p.cuerpo, p.vientre, p.detalle); let alto_rig = v.iter().map(|x| x.pos[1]).fold(0.0f32, f32::max).max(0.1); let s = crate::bicho_receta::alto_tipico(b) / alto_rig; let model = Mat4::from_translation(pos) * Mat4::from_rotation_y(facing) * Mat4::from_scale(Vec3::splat(s)); return (model, v, i); } match &self.cuerpo { None => { let mut a = self.to_actor(pos, facing); a.set_clip(clip); a.advance(phase); a.look_at(look); let (v, i) = a.mesh(); (a.model(), v, i) } Some(mov) => { // El andar según el clip: locomoción (editable) o gesto (procedural). let pose = match clip { Clip::Walk => mov.andares.caminar.pose(phase), Clip::Run => mov.andares.correr.pose(phase), Clip::Wave => Andar::saludar(&mov.rig).pose(phase), Clip::Point => Andar::senalar(&mov.rig).pose(phase), Clip::Cheer => Andar::festejar(&mov.rig).pose(phase), Clip::Idle => mov.andares.quieto.pose(phase), }; let (v, i) = mov.rig.mesh(&pose, self.skin, self.shirt, self.pants); let model = Mat4::from_translation(pos) * Mat4::from_rotation_y(facing); (model, v, i) } } } /// Materializa el spec en un [`Actor`] parado en `pos` mirando a `facing`. pub fn to_actor(&self, pos: Vec3, facing: f32) -> Actor { Actor::new(pos, facing) .with_age(self.age) .with_colors(self.skin, self.shirt, self.pants) } /// **Estilo de personaje** (proporciones/rasgos anclados en literatura) derivado de la /// edad. Ver [`EstiloSer`]. Futuro: guardarlo en el spec para elegir arquetipo a mano. pub fn estilo(&self) -> EstiloSer { EstiloSer::por_edad(self.age) } /// **Materializa el ser como VOLUMEN VOXEL redondeado** (no la malla de cajas del /// [`Actor`]): el humanoide estilizado del kit ([`humanoide_voxel`]) en la pose del `clip`/ /// `phase`, coloreado por sus rasgos (piel/remera/pantalón), con el detalle sub-voxel /// horneado. `altura` = alto en voxels. Se rinde con `bisel` + `silueta_por_voxel` + /// AO baja para que el ray-marcher lo redondee a un look vinyl-toy/chibi (sin cajas, sin /// valle). Sólo para el humanoide (`cuerpo = None`); un rig usa su propia malla. pub fn to_voxel(&self, clip: Clip, phase: f32, altura: u32) -> VoxelGrid { let pose = clip.pose(phase); let col = match self.hair { Some(h) => Colores::con_pelo(self.skin, self.shirt, self.pants, h), None => Colores::nuevos(self.skin, self.shirt, self.pants), }; humanoide_voxel(&self.estilo(), &pose, altura, col) } /// **Materializa el ser como SDF ANALÍTICO** ([`ser_sdf`](crate::ser_sdf)): el /// mismo diseño (EstiloSer/Pose/colores) que [`to_voxel`](Self::to_voxel) pero /// sin voxelizar — silueta lisa, cara por decals, sombreado toon en el motor. /// A **escala de Actor** (misma altura que la malla de cajas, ~1.8 para un /// adulto) para que reemplace 1:1 al actor en escenas. Sin posar (local, pies /// en el origen): el caller lo posa con [`SdfSer::posado`](llimphi_3d::SdfSer::posado) /// (coords de voxel `[0, dim]` + yaw). Sólo humanoides (`cuerpo = None`); un /// rig usa su malla propia. pub fn to_sdf(&self, clip: Clip, phase: f32) -> llimphi_3d::SdfSer { self.to_sdf_con(clip, phase, &crate::Gesto::neutro()) } /// Igual que [`to_sdf`](Self::to_sdf) pero con la **cara del cuadro** /// ([`Gesto`](crate::Gesto)): párpados, boca, pupilas y giro de cabeza. pub fn to_sdf_con(&self, clip: Clip, phase: f32, gesto: &crate::Gesto) -> llimphi_3d::SdfSer { if let Some(b) = self.bicho { return bicho_en_clip(b, clip, phase, crate::DetalleSer::Hero); } let pose = clip.pose(phase); let col = match self.hair { Some(h) => Colores::con_pelo(self.skin, self.shirt, self.pants, h), None => Colores::nuevos(self.skin, self.shirt, self.pants), }; let altura = self.to_actor(Vec3::ZERO, 0.0).build.height; crate::ser_sdf::ser_sdf_atuendo( &self.estilo(), &pose, altura, col, gesto, crate::DetalleSer::Hero, &self.atuendo, ) } /// **El ser vivo, posado y listo para el renderer**: la cara se anima sola con la /// fase ([`Gesto::vivo`](crate::Gesto::vivo) — parpadeo, boca según el clip, /// micro-sacadas) y, si le pasás un `look` (un punto de mundo, típicamente el ojo /// de la cámara), cabeza y pupilas lo siguen. /// /// `pos`/`look` van en las MISMAS coordenadas que consume /// [`VoxelRenderer::sers`](llimphi_3d::VoxelRenderer) (voxel centrado). Es el /// camino que deberían usar preview y export: un solo lugar que decide cómo se ve /// la cara → los dos coinciden cuadro a cuadro. pub fn to_sdf_posado( &self, clip: Clip, phase: f32, pos: Vec3, yaw: f32, look: Option, ) -> llimphi_3d::SdfSer { self.to_sdf_lod(clip, phase, pos, yaw, look, crate::DetalleSer::Hero) } /// [`to_sdf_posado`](Self::to_sdf_posado) con el nivel de detalle de la cara a /// elección — lo usa [`reparto_sdf`] para repartir el presupuesto del uniform. pub fn to_sdf_lod( &self, clip: Clip, phase: f32, pos: Vec3, yaw: f32, look: Option, detalle: crate::DetalleSer, ) -> llimphi_3d::SdfSer { if let Some(b) = self.bicho { // El animal no sigue al ojo con la cabeza: eso es de la gente. return bicho_en_clip(b, clip, phase, detalle).posado([pos.x, pos.y, pos.z], yaw); } let mut gesto = crate::Gesto::vivo(phase, clip); let altura = self.to_actor(Vec3::ZERO, 0.0).build.height; if let Some(t) = look { let ojos = crate::altura_ojos(&self.estilo(), altura); gesto = gesto.mirar(t, pos, yaw, ojos); } let pose = clip.pose(phase); let col = match self.hair { Some(h) => Colores::con_pelo(self.skin, self.shirt, self.pants, h), None => Colores::nuevos(self.skin, self.shirt, self.pants), }; crate::ser_sdf::ser_sdf_atuendo(&self.estilo(), &pose, altura, col, &gesto, detalle, &self.atuendo) .posado([pos.x, pos.y, pos.z], yaw) } /// Cota de primitivas de este ser al `detalle` dado: la cara peor caso /// ([`DetalleSer::costo`](crate::DetalleSer::costo)) más lo que suma su atuendo. /// Es la que tienen que usar los repartos, o un ser vestido se pasa del uniform. pub fn costo(&self, detalle: crate::DetalleSer) -> usize { if let Some(b) = self.bicho { // Medido armándolo: un bicho no tiene cota escrita, y la cantidad de // primitivas no depende de la fase sino del clip (alas, cabeza baja). return [Clip::Idle, Clip::Walk, Clip::Run, Clip::Wave] .into_iter() .map(|c| bicho_en_clip(b, c, 0.0, detalle).prims.len()) .max() .unwrap_or(0); } detalle.costo() + self.atuendo.costo_extra(detalle) } /// **Dónde tiene la mano** el ser en este clip y fase, en su espacio LOCAL (pies /// en el origen, mirando `+Z`): ahí se apoya la utilería que lleva /// ([`UtileriaSpec::lleva`](crate::UtileriaSpec)). En el humanoide es la mano /// derecha que se dibuja, así que la estrella sube cuando saluda; un bicho la /// lleva sobre el lomo, y un rig genérico, que no tiene mano medida, al costado. pub fn mano(&self, clip: Clip, phase: f32) -> Vec3 { let alto = self.altura(); if self.bicho.is_some() { return Vec3::new(0.0, alto, 0.0); } if self.cuerpo.is_some() { return Vec3::new(0.20 * alto, 0.50 * alto, 0.16 * alto); } crate::ser_sdf::mano_derecha(&self.estilo(), &clip.pose(phase), alto) } /// Alto del ser en unidades de mundo (el del [`Actor`] equivalente). pub fn altura(&self) -> f32 { if let Some(b) = self.bicho { return crate::bicho_receta::alto_tipico(b); } self.to_actor(Vec3::ZERO, 0.0).build.height } } /// El bicho del bestiario haciendo lo que el clip del guion le pide. El clip es de /// la gente (saludar, señalar, festejar); a un animal le llega como lo más parecido /// que sabe hacer: andar, trotar, o levantar la cabeza alerta. El que vuela o nada /// lo hace siempre — un pez quieto en el aire no es un pez. La `phase` del guion va /// en segundos y la del bicho en radianes de su paso: ~0,6 pasos por segundo al /// andar, el doble al trotar. fn bicho_en_clip(b: Bicho, clip: Clip, phase: f32, detalle: crate::DetalleSer) -> llimphi_3d::SdfSer { use crate::bicho_receta::Receta; use crate::Andanza; let receta = crate::bicho_receta::receta(b); let (andanza, ritmo) = match (&receta, clip) { (Receta::Insecto(_), _) | (Receta::Ave(_), Clip::Run) => (Andanza::Volar, 6.0), (Receta::Nadador(_), _) => (Andanza::Nadar, 3.0), (_, Clip::Walk) => (Andanza::Caminar, 3.8), (_, Clip::Run) => (Andanza::Trotar, 7.5), (_, Clip::Wave | Clip::Point | Clip::Cheer) => (Andanza::Alerta, 2.0), (_, Clip::Idle) => (Andanza::Quieto, 2.0), }; crate::bicho_sdf::bicho_sdf_lod(b, crate::bicho_receta::alto_tipico(b), andanza, phase * ritmo, detalle) } /// Un humanoide en el cuadro, tal como lo ve el reparto: quién es, dónde está (coords /// de voxel `[0, dim]`, las que consume el renderer), hacia dónde mira el cuerpo y qué /// está haciendo. #[derive(Debug, Clone, Copy)] pub struct EnCuadro<'a> { pub spec: &'a CharSpec, pub pos: Vec3, pub yaw: f32, pub clip: Clip, pub fase: f32, } /// **Arma el reparto SDF de un cuadro dentro del presupuesto del uniform.** /// /// El renderer no tiene lista dinámica: entran [`VOXEL_MAX_SERS`] seres y /// [`VOXEL_MAX_SER_PRIMS`] primitivas EN TOTAL. Con la cara completa (~31 prims) eso /// son 7 humanoides; una bandada de 16 no entra. Pasarle la lista cruda al empacador /// no es una opción: los que sobran se caen del cuadro sin aviso. /// /// Acá se decide con criterio: se ordena por distancia al `ojo`, los de cerca se /// llevan la cara entera y los del fondo la pierden ([`DetalleSer`]) — que es donde no /// se nota— y si aun así no alcanza, se degrada antes de descartar. Todos miran al /// `ojo` y parpadean solos ([`Gesto::vivo`](crate::Gesto::vivo)). /// /// Devuelve los seres de cerca a lejos, listos para `VoxelRenderer::sers`. pub fn reparto_sdf(actores: &[EnCuadro<'_>], ojo: Vec3, mirar: bool) -> Vec { use llimphi_3d::{VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS}; let mut orden: Vec<(f32, usize)> = actores .iter() .enumerate() .map(|(i, a)| { let alto = a.spec.altura(); let centro = a.pos + Vec3::new(0.0, alto * 0.5, 0.0); (ojo.distance(centro), i) }) .collect(); orden.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let mut out = Vec::new(); let mut gastado = 0usize; for (dist, i) in orden { if out.len() >= VOXEL_MAX_SERS { break; } let a = &actores[i]; let alto = a.spec.altura(); let mut detalle = crate::DetalleSer::por_distancia(dist, alto); // Si no entra con su detalle, se degrada antes de quedar afuera: un ser sin // rasgos al fondo es mucho menos notorio que un ser que desaparece. while gastado + a.spec.costo(detalle) > VOXEL_MAX_SER_PRIMS { detalle = match detalle { crate::DetalleSer::Hero => crate::DetalleSer::Medio, crate::DetalleSer::Medio => crate::DetalleSer::Lejos, crate::DetalleSer::Lejos => break, }; } if gastado + a.spec.costo(detalle) > VOXEL_MAX_SER_PRIMS { break; // ni el más barato entra: de acá en adelante tampoco } let look = if mirar { Some(ojo) } else { None }; let ser = a .spec .to_sdf_lod(a.clip, a.fase, a.pos, a.yaw, look, detalle); gastado += ser.prims.len(); out.push(ser); } out } /// El cuadro repartido entre las **dos técnicas de graficación**: los de cerca como /// seres SDF (analíticos, caros y con cara), los del fondo como malla de cajas /// (baratos e ilimitados). Ver [`reparto_mixto`]. pub struct Reparto { /// Los de cerca, listos para `VoxelRenderer::sers` (coords de grilla). pub sers: Vec, /// Los del fondo, listos para `Renderer3d` (espacio **centrado**: `pos − half`). pub mallas: Vec<(Mat4, Vec, Vec)>, } /// **Reparto automático entre técnicas**: el mismo orden por cercanía de /// [`reparto_sdf`], pero el que no entra en el presupuesto del uniform **no /// desaparece**: se dibuja como malla de cajas, que no tiene tope. /// /// Ése era el agujero: `reparto_sdf` corta cuando ni el detalle más barato entra, y /// una multitud de treinta perdía a los últimos catorce sin aviso. Cerca importa la /// cara (SDF); a veinte metros, una silueta que se mueve bien alcanza — y la malla la /// da gratis. /// /// `half` es la media-dimensión del mundo: las mallas se ubican en el espacio centrado /// del `Renderer3d` (`pos − half`), mientras el SDF trabaja en coords de grilla. pub fn reparto_mixto( actores: &[EnCuadro<'_>], ojo: Vec3, mirar: bool, half: Vec3, ) -> Reparto { reparto_mixto_con(actores, ojo, mirar, half, &[]) } /// [`reparto_mixto`] cuando en el cuadro ya hay otros SDF que comparten el uniform /// —la utilería de la escena—: `ya` se cuenta primero, en lugares y en primitivas, /// y los actores se reparten lo que queda. `ya` NO va en el resultado: el caller /// lo suma él, que es quien lo armó. pub fn reparto_mixto_con( actores: &[EnCuadro<'_>], ojo: Vec3, mirar: bool, half: Vec3, ya: &[llimphi_3d::SdfSer], ) -> Reparto { use llimphi_3d::{VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS}; let mut orden: Vec<(f32, usize)> = actores .iter() .enumerate() .map(|(i, a)| { let alto = a.spec.altura(); let centro = a.pos + Vec3::new(0.0, alto * 0.5, 0.0); (ojo.distance(centro), i) }) .collect(); orden.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); let mut rep = Reparto { sers: Vec::new(), mallas: Vec::new() }; let mut gastado: usize = ya.iter().map(|s| s.prims.len()).sum(); let mut de_malla = false; // una vez que uno cae a malla, los de atrás también for (dist, i) in orden { let a = &actores[i]; let alto = a.spec.altura(); let look = if mirar { Some(ojo) } else { None }; if !de_malla && ya.len() + rep.sers.len() < VOXEL_MAX_SERS { let mut detalle = crate::DetalleSer::por_distancia(dist, alto); while gastado + a.spec.costo(detalle) > VOXEL_MAX_SER_PRIMS { detalle = match detalle { crate::DetalleSer::Hero => crate::DetalleSer::Medio, crate::DetalleSer::Medio => crate::DetalleSer::Lejos, crate::DetalleSer::Lejos => break, }; } if gastado + a.spec.costo(detalle) <= VOXEL_MAX_SER_PRIMS { let ser = a.spec.to_sdf_lod(a.clip, a.fase, a.pos, a.yaw, look, detalle); gastado += ser.prims.len(); rep.sers.push(ser); continue; } } de_malla = true; let look_centrado = look.map(|o| o - half); rep.mallas.push(a.spec.to_meta(a.pos - half, a.yaw, a.clip, a.fase, look_centrado)); } rep } /// **Keyframe serializable** de un actor (espejo de [`ActorKey`]): dónde está en /// la grilla en `t`, y opcionalmente qué clip reproduce y hacia dónde mira. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct ActorKeySpec { pub t: f32, pub gx: f32, pub gz: f32, #[serde(default)] pub clip: Option, #[serde(default)] pub face: Option, } impl ActorKeySpec { /// Compila a un [`ActorKey`] del director. pub fn to_key(self) -> ActorKey { let mut k = ActorKey::at(self.t, self.gx, self.gz); if let Some(c) = self.clip { k = k.play(c); } if let Some(f) = self.face { k = k.facing(f); } k } } /// **Actor de una escena**: qué personaje del proyecto lo interpreta (`character`, /// índice en [`Project::characters`]) y su guion de keyframes. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActorSpec { pub character: usize, pub keys: Vec, /// **Tasa de cuadros propia** del actor (`None` = fluido/nativo). Con un valor /// bajo (12–15) el actor se anima *en doses* (stop-motion): es el sello que /// separa al Héroe del Avatar. Ver [`ActorScript::quantize`]. #[serde(default)] pub frame_rate: Option, } impl ActorSpec { /// Compila los keyframes a un [`ActorScript`] reproducible (con su tasa de /// cuadros propia, si la tiene). pub fn to_script(&self) -> ActorScript { ActorScript::new(self.keys.iter().map(|k| k.to_key()).collect()) .with_frame_rate(self.frame_rate) } } /// **Tipo de plano** de cámara: un encuadre cinematográfico de alto nivel que se /// resuelve contra el **centroide del reparto** (no contra `eye/target` crudos), así /// es trivial de elegir y de generar por IA. [`resolve`](Self::resolve) produce la /// [`Camera3d`] del frame. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ShotKind { /// Establecedor: lejos y alto, presenta la escena. Establishing, /// Primer plano: cerca, a la altura del pecho. CloseUp, /// Lateral: desde el costado. Side, /// Órbita: gira lento alrededor del reparto. Orbit, /// **Contraluz**: cámara del lado opuesto al sol, mirando hacia él con el reparto /// a contraluz (silueta en el tercio bajo) y el disco solar en lo alto del cuadro /// → rim light + **god rays** (haces volumétricos). El plano "héroe" del motor. Backlight, /// **Frontal**: de frente y sin sesgo de costado, un poco alto. Es el plano /// de la arquitectura: el establecedor se corre media distancia a un lado y /// un palacio simétrico, filmado así, se lee torcido. Va al final: postcard /// numera las variantes. Frontal, } impl ShotKind { /// Todos los planos (para ciclar en un editor). pub const ALL: [ShotKind; 6] = [ ShotKind::Establishing, ShotKind::CloseUp, ShotKind::Side, ShotKind::Orbit, ShotKind::Backlight, ShotKind::Frontal, ]; /// Nombre legible (español). pub fn label(self) -> &'static str { match self { ShotKind::Establishing => "establecedor", ShotKind::CloseUp => "primer plano", ShotKind::Side => "lateral", ShotKind::Orbit => "órbita", ShotKind::Backlight => "contraluz", ShotKind::Frontal => "frontal", } } /// El plano siguiente (cicla). pub fn next(self) -> ShotKind { let i = ShotKind::ALL.iter().position(|&k| k == self).unwrap_or(0); ShotKind::ALL[(i + 1) % ShotKind::ALL.len()] } /// Resuelve la cámara del plano: mira a `look` (centroide del reparto, ya /// elevado a la altura del pecho), con el ojo según el tipo, a distancia base /// `d` (escala con el tamaño del reparto). `t` (seg) anima la órbita. pub fn resolve(self, look: Vec3, d: f32, t: f32) -> Camera3d { // `(eye, fov, target)` por tipo. Casi todos miran al reparto (`look`); el // contraluz sube el objetivo hacia el sol para meterlo en el cuadro. let (eye, fov, target) = match self { ShotKind::Establishing => { (look + Vec3::new(-0.5 * d, 0.9 * d, -1.6 * d), 50.0, look) } ShotKind::CloseUp => (look + Vec3::new(0.25 * d, 0.45 * d, -0.85 * d), 40.0, look), // El objetivo sube un poco: con el reparto en el tercio bajo, lo que // tiene detrás (el palacio) entra entero en el cuadro. // // Y todo el plano se TRASLADA 0,06·d a un costado (sin girar): con el // ojo justo sobre el plano de simetría de lo filmado, el ray-march // dibuja una raya oscura de arriba abajo por el eje (medido en // Margarita: la cúpula, el portal y el camino partidos por la mitad; // corriendo el ojo 0,3 voxel se atenúa, con 0,9 desaparece). La causa // en el shader está sin diagnosticar; esto evita el caso degenerado // que el plano frontal provoca a propósito. ShotKind::Frontal => { let lado = Vec3::new(0.06 * d, 0.0, 0.0); (look + lado + Vec3::new(0.0, 0.55 * d, -1.7 * d), 50.0, look + lado + Vec3::new(0.0, 0.35 * d, 0.0)) } ShotKind::Side => (look + Vec3::new(1.35 * d, 0.4 * d, 0.15 * d), 46.0, look), ShotKind::Orbit => { let a = t * 0.6; (look + Vec3::new(a.cos() * 1.3 * d, 0.6 * d, a.sin() * 1.3 * d), 48.0, look) } ShotKind::Backlight => { // Detrás del reparto respecto al sol (horizontal anti-sol), apenas // elevado; el objetivo sube hacia el cielo → la cámara mira hacia el // sol con el reparto a contraluz en el tercio bajo. let s = Vec3::new(SCENE_SUN[0], SCENE_SUN[1], SCENE_SUN[2]).normalize(); let back = Vec3::new(-s.x, 0.0, -s.z).normalize(); // El objetivo sube 0,4·d: el reparto cae a ~19° bajo el centro, // en el tercio bajo de un cuadro de 55°. Con 0,55·d caía a 25°, // pegado al borde (medido en Margarita: de la princesa asomaba // la coronilla), y el sol/luna a 28° de alto sigue entrando. let eye = look + back * (1.2 * d) + Vec3::new(0.0, 0.15 * d, 0.0); let target = look + Vec3::new(0.0, 0.4 * d, 0.0); (eye, 55.0, target) } }; Camera3d { eye, target, fovy_rad: fov_f32_to_rad(fov), ..Camera3d::default() } } } /// Grados → radianes (helper local para no depender de glam en el call site). fn fov_f32_to_rad(deg: f32) -> f32 { deg * std::f32::consts::PI / 180.0 } /// Un **plano** de la escena: el tipo de encuadre y desde qué instante (seg) está /// activo. El plano vigente en `t` es el último con `start ≤ t` (corte duro entre /// planos). #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct ShotSpec { pub start: f32, pub kind: ShotKind, } /// Qué clase de SUCESO se dicta en un punto de la escena. Se dictan desde /// adentro (caminando/volando el mundo) y se autoran aquí. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum TipoSuceso { /// Aparece un ser (id en [`Project::seres`]; `0` = todavía por asignar). Aparece(u64), /// La cámara mira a este punto (una marca para dirigir un plano). Camara, /// Punto de interés genérico — "aquí pasa algo". Marca, /// **El mundo se desmorona acá**: al cruzar el instante del suceso, `motas` /// voxels de la esfera `radio` se desprenden y salen a volar como ceniza /// ([`Enjambre`](crate::Enjambre)). Es el efecto de crisis del guion, dictado /// como cualquier otro suceso — con su instante y su lugar, no como un botón. Desmorona { radio: f32, motas: u32 }, } /// Un SUCESO dictado en la escena: qué pasa, dónde y cuándo. El creador lo deja /// caer mientras recorre el mundo en primera persona. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Suceso { /// Instante en la escena (segundos). pub t: f32, /// Dónde, en coordenadas del mundo/grilla `[x, y, z]`. pub pos: [f32; 3], /// Qué clase de suceso. pub tipo: TipoSuceso, /// Nota libre del creador ("aquí despierta", "corte a primer plano"…). pub nota: String, } /// **Especificación serializable de una escena**: el mundo de fondo (`world`, /// índice en [`Project::worlds`]), la duración, el reparto guionado y los **planos** /// de cámara. Es la versión editable/IA-emisible del [`Sequence`](crate::Sequence) /// del director; se compila con [`scripts`](Self::scripts) y se reproduce posando /// cada actor en `sample(t)` con la cámara del plano vigente. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SceneSpec { /// Id estable (asignado por el [`Project`]). #[serde(default)] pub id: u64, pub name: String, /// El **mundo** de fondo (id en [`Project::mundos`]). pub mundo: u64, pub duration: f32, pub actors: Vec, #[serde(default)] pub shots: Vec, /// **Cámara en mano**: intensidad del temblor orgánico (`0` = trípode fijo, /// look de dron; `~1` = respiración/pulso de camarógrafo). Ensucia la cámara /// matemáticamente perfecta del motor — es el sello que mete al espectador en /// el "barro" de la escena. Ver [`handheld_shake`]. #[serde(default)] pub handheld: f32, /// **Cuán cerca está la cámara**, en unidades del mundo. `None` ⇒ la /// distancia se deduce del tamaño del reparto (`6 + actores × 1,2`), que es /// lo que había y lo que sirve para una escena con gente. /// /// Existe porque esa deducción supone que lo filmado es una ESCENA, y una /// escena de un solo ser que se muestra como objeto —un emblema, una pieza /// de museo— pide lo contrario: acercarse hasta que la cosa llene el /// cuadro. Con la fórmula, un animal solo salía del tamaño de una moneda /// en medio de un campo, que es «mostrar el campo», no mostrarlo a él. #[serde(default)] pub distancia: Option, /// **Atmósfera** de apertura: la bruma + el fundido desde negro. `None` = /// aire despejado, sin fundido (compatibilidad con escenas viejas). #[serde(default)] pub atmosfera: Option, /// **Caída** de apertura: la cámara pura cae del cielo (sin cuerpo) hasta /// aterrizar. `None` = cámara normal por planos. #[serde(default)] pub caida: Option, /// **Sucesos** dictados desde adentro: qué pasa, dónde y cuándo. #[serde(default)] pub sucesos: Vec, /// **Utilería**: la estrella, la flor, la perla… puestas en el mundo o en la /// mano de un actor, con su instante de entrada y de salida. Va al final y se /// escribe siempre (postcard es posicional). #[serde(default)] pub utileria: Vec, /// **Cielo de noche** (estrellas, luna, luz de luna). `None` = de día, con /// la luz de quien filma. Va al final y se escribe siempre (postcard). #[serde(default)] pub cielo: Option, /// **Decorado**: el palacio, el kiosko… plantados a mano en su lugar del /// mundo. Va al final y se escribe siempre (postcard). #[serde(default)] pub decorado: Vec, /// **Jardín**: explanada llana, caminos de tierra y una orla de flores y /// plantas. `None` = el relieve tal cual. Va al final y se escribe siempre /// (postcard). #[serde(default)] pub jardin: Option, } /// **Temblor de cámara en mano**, determinista (función pura de `t` → la peli sale /// reproducible cuadro a cuadro). Suma de senos en frecuencias inconmensurables: /// una **respiración** lenta (bob vertical) + un **micro-pulso** rápido en los tres /// ejes para el ojo, y una **deriva** aún más lenta para el objetivo (el encuadre /// flota, no sólo tiembla). `amt ≤ 0` → sin offset (trípode). Devuelve /// `(offset_ojo, offset_objetivo)` en unidades de mundo, escalado un poco con la /// distancia `d` del plano para que también respire en planos lejanos. pub fn handheld_shake(t: f32, amt: f32, d: f32) -> (Vec3, Vec3) { if amt <= 0.0 { return (Vec3::ZERO, Vec3::ZERO); } let scale = amt * (1.0 + d * 0.03); // Respiración (bob) + micro-pulso por eje (fases y frecuencias dispares). let breath = (t * 1.7).sin() * 0.6 + (t * 0.9).sin() * 0.4; let jx = (t * 9.3).sin() * 0.5 + (t * 4.7 + 1.3).sin() * 0.5; let jy = (t * 8.1 + 2.1).sin() * 0.5 + (t * 5.3 + 0.7).sin() * 0.5; let jz = (t * 7.4 + 0.4).sin() * 0.5 + (t * 3.9 + 2.7).sin() * 0.5; let eye = Vec3::new(jx * 0.10, breath * 0.12 + jy * 0.08, jz * 0.10) * scale; // Deriva del objetivo: más lenta y desfasada → el cuadro "busca" al sujeto. let tgt = Vec3::new( (t * 1.3 + 0.5).sin() * 0.06, (t * 1.1 + 1.9).sin() * 0.05, 0.0, ) * scale; (eye, tgt) } impl SceneSpec { /// La **utilería** que se ve en `t`, posada y lista para el renderer (coords de /// grilla). `actores` son los de [`actors`](Self::actors) en ese instante, en su /// orden: `(pies en coords de grilla, rumbo, mano local)` — la mano sale de /// [`CharSpec::mano`] con el clip y la fase del cuadro. Se corta a lo que entra en un cuarto del uniform: una /// escena con cien perlas no puede dejar sin cara al reparto. pub fn utileria_en(&self, t: f32, actores: &[(Vec3, f32, Vec3)]) -> Vec { use llimphi_3d::{VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS}; let mut out = Vec::new(); let mut gastado = 0usize; for u in &self.utileria { let Some(s) = u.en_t(t, actores) else { continue }; if out.len() >= VOXEL_MAX_SERS / 4 || gastado + s.prims.len() > VOXEL_MAX_SER_PRIMS / 4 { break; } gastado += s.prims.len(); out.push(s); } out } /// Las **luces** que da la utilería con luz propia en `t` (la estrella que /// alumbra), en coords de grilla, con los mismos `actores` que /// [`utileria_en`](Self::utileria_en). Sólo **de noche**: de día no se notan y /// cada una cuesta un rayo de sombra por píxel. El alcance crece con la pieza /// cuando aparece. Se corta al máximo de luces del renderer. pub fn luces_en(&self, t: f32, actores: &[(Vec3, f32, Vec3)]) -> Vec { if self.cielo.is_none() { return Vec::new(); } let mut out = Vec::new(); for u in self.utileria.iter().filter(|u| u.cosa.luz_propia()) { if out.len() >= llimphi_3d::VOXEL_MAX_LIGHTS { break; } let Some(s) = u.en_t(t, actores) else { continue }; let (c, _) = s.bound(); let centro = Vec3::from_array(s.pos) + crate::sdf_kit::rot_y(Vec3::from_array(c), s.yaw); let color = u.color.unwrap_or(u.cosa.color()); out.push(crate::luz_de(centro, color, crate::ALCANCE_LUZ_PROPIA * u.escala_en(t))); } out } /// Los guiones de los actores, listos para `sample(t)`. /// **Lo que la escena le pone al mundo** encima del relieve: el jardín, los /// corredores por donde camina cada actor (las columnas de sus keys, en /// orden) y el decorado. La ventana lo estampa cada vez que se arma. pub fn puesta(&self) -> crate::Puesta { crate::Puesta { jardin: self.jardin.clone(), andares: self.actors.iter().map(|a| a.keys.iter().map(|k| [k.gx, k.gz]).collect()).collect(), decorado: self.decorado.clone(), } } pub fn scripts(&self) -> Vec { self.actors.iter().map(|a| a.to_script()).collect() } /// Los **instantes (seg) que merecen un acento musical**: los cortes de cámara /// (inicio de cada plano salvo el primero) y los **gestos** de los actores (keys /// con un clip *emote*). Es lo que deja caer la banda sonora *sobre la acción*. /// Ordenados, sin repetir (dos a menos de `EPS` se funden). Espeja /// [`Sequence::beat_times`](crate::Sequence::beat_times). pub fn beat_times(&self) -> Vec { const EPS: f32 = 0.05; let mut ts: Vec = Vec::new(); for s in self.shots.iter().skip(1) { ts.push(s.start); } for a in &self.actors { for k in &a.keys { if k.clip.is_some_and(|c| c.is_emote()) { ts.push(k.t); } } } ts.retain(|&t| t >= 0.0 && t <= self.duration + EPS); ts.sort_by(f32::total_cmp); ts.dedup_by(|a, b| (*a - *b).abs() < EPS); ts } /// La **cámara de la escena** en `t`: resuelve el plano vigente mirando a /// `look` (centroide del reparto) a distancia `d`, y le suma el temblor de /// **cámara en mano** ([`handheld_shake`]) según [`Self::handheld`]. Es el /// único punto por el que deberían pasar el preview y el export para que el /// sello de cámara salga igual en ambos. pub fn camera_at(&self, look: Vec3, d: f32, t: f32) -> Camera3d { // Apertura por CAÍDA (cámara pura, sin cuerpo): mientras dura la caída, la // cámara desciende del cielo hacia `look`; al aterrizar, cede a los planos. if let Some(caida) = self.caida { if t < caida.duracion { let mut cam = caida.camara_desde(look, t); let (eo, to) = handheld_shake(t, self.handheld, d); cam.eye += eo; cam.target += to; return cam; } } let mut cam = self.active_shot(t).resolve(look, d, t); let (eo, to) = handheld_shake(t, self.handheld, d); cam.eye += eo; cam.target += to; cam } /// El plano vigente en `t` (el último con `start ≤ t`); `Establishing` si no /// hay planos definidos. pub fn active_shot(&self, t: f32) -> ShotKind { self.shots .iter() .filter(|s| s.start <= t) .last() .map(|s| s.kind) .unwrap_or(ShotKind::Establishing) } /// **Escena patrón "entran y saludan"**: `n` actores entran caminando por el /// centro del mundo de izquierda a derecha, se giran y hacen `gesture`. Coords /// de grilla (la altura del terreno se aplica al reproducir). La base tanto del /// arranque como de la generación por IA. pub fn walk_and_emote( name: impl Into, mundo: u64, n: usize, gesture: Clip, dim: [u32; 3], ) -> Self { use std::f32::consts::{FRAC_PI_2, PI}; let n = n.clamp(1, 5); let margin = 18.0_f32; let gx0 = margin; let gx1 = (dim[0] as f32 - margin).max(gx0 + 1.0); let cz = dim[2] as f32 * 0.5; let (t_walk, t_turn, dur) = (2.6_f32, 3.0_f32, 5.6_f32); let mut actors = Vec::with_capacity(n); for i in 0..n { let off = (i as f32 - (n as f32 - 1.0) / 2.0) * 3.0; let gz = cz + off; actors.push(ActorSpec { character: i, keys: vec![ ActorKeySpec { t: 0.0, gx: gx0, gz, clip: None, face: None }, ActorKeySpec { t: t_walk, gx: gx1, gz, clip: None, face: Some(FRAC_PI_2) }, ActorKeySpec { t: t_turn, gx: gx1, gz, clip: Some(gesture), face: Some(PI) }, ActorKeySpec { t: dur, gx: gx1, gz, clip: Some(gesture), face: Some(PI) }, ], // El **Héroe** (primer actor) se anima en doses (12 fps): se mueve a // tirones, pesado, contra los demás (Avatares) que van fluidos. Es el // sello de animación, visible ya en la escena de arranque. frame_rate: if i == 0 { Some(12) } else { None }, }); } // Tres planos: establecedor, **contraluz** a mitad de la caminata (el reparto // entra al sol → luce los god rays), y primer plano en el gesto. let shots = vec![ ShotSpec { start: 0.0, kind: ShotKind::Establishing }, ShotSpec { start: t_walk * 0.5, kind: ShotKind::Backlight }, ShotSpec { start: t_turn, kind: ShotKind::CloseUp }, ]; // Cámara en mano suave por defecto: el sello se ve sin tener que pedirlo. Self { id: 0, name: name.into(), mundo, duration: dur, actors, shots, handheld: 0.7, // La distancia sale del tamaño del reparto: esto arma una escena // con gente caminando, que es justo el caso para el que esa // deducción sirve. distancia: None, atmosfera: None, caida: None, sucesos: Vec::new(), utileria: Vec::new(), cielo: None, decorado: Vec::new(), jardin: None, } } } /// El **proyecto**: la bolsa de artefactos del creador, por niveles de composición /// (leyes → materiales → seres → biomas → mundos → escenas). Cada item lleva un `id` /// estable (separado del nombre) para referenciarse, renombrarse y duplicarse. /// Vacío por defecto; [`starter`](Self::starter) trae algo que tocar. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Project { /// Contador de ids; `0` queda reservado como «ninguno». #[serde(default)] pub next_id: u64, #[serde(default)] pub leyes: Vec, #[serde(default)] pub materiales: Vec, /// Los **seres** (personajes); el tipo se llama `CharSpec` por historia. #[serde(default)] pub seres: Vec, #[serde(default)] pub biomas: Vec, #[serde(default)] pub mundos: Vec, #[serde(default)] pub escenas: Vec, /// Interiores amueblables (editor de interiores). #[serde(default)] pub interiores: Vec, } impl Project { /// Reserva el próximo id estable. pub fn alloc_id(&mut self) -> u64 { if self.next_id == 0 { self.next_id = 1; } let id = self.next_id; self.next_id += 1; id } /// Proyecto de arranque: leyes base, los 6 materiales semilla, un trío de seres, /// los biomas desierto/pradera y dos mundos que los envuelven, más la escena demo. pub fn starter() -> Self { let mut p = Project { next_id: 1, ..Default::default() }; // --- Leyes base --- let ley_fluir = p.alloc_id(); p.leyes.push(Ley { id: ley_fluir, name: "fluir".into(), kind: LeyKind::Fluir { gravedad: 1.0, horizontal: 0.6 }, }); let ley_crecer = p.alloc_id(); p.leyes.push(Ley { id: ley_crecer, name: "crecer".into(), kind: LeyKind::Crecer { velocidad: 1.0 }, }); // --- Materiales semilla (arena/pasto/roca/nieve/agua/cactus) --- let mut mat_ids = std::collections::HashMap::new(); for m in Material::ALL { let id = p.alloc_id(); let mut def = MaterialDef::from_builtin(id, m); // El agua fluye; el cactus crece — leyes de muestra (sin simular aún). match m { Material::Water => def.leyes.push(LeyUso { ley: ley_fluir, params: vec![1.0, 0.6] }), Material::Cactus => def.leyes.push(LeyUso { ley: ley_crecer, params: vec![1.0] }), _ => {} } mat_ids.insert(m, id); p.materiales.push(def); } let mid = |m: Material| mat_ids[&m]; // --- Seres --- let mut sere_ids = Vec::new(); for (name, skin, shirt, pants) in [ ("rojo", [0.90, 0.72, 0.58], [0.82, 0.28, 0.26], [0.20, 0.20, 0.28]), ("azul", [0.86, 0.68, 0.54], [0.22, 0.55, 0.78], [0.18, 0.20, 0.24]), ("amarillo", [0.92, 0.78, 0.62], [0.92, 0.80, 0.30], [0.26, 0.22, 0.20]), ] { let id = p.alloc_id(); sere_ids.push(id); p.seres.push(CharSpec { id, name: name.into(), age: Age::Adult, skin, shirt, pants, hair: None, cuerpo: None, conducta: Conducta::default(), atuendo: Atuendo::default(), bicho: None, }); } // --- Biomas --- let bioma_desierto = p.alloc_id(); p.biomas.push(Bioma { id: bioma_desierto, name: "desierto".into(), base: 0.30, dune: 0.05, relief: 0.45, mountains: 0.12, water_level: 0.26, rivers: 0.18, peak_at: 1.0, ground: mid(Material::Sand), cliff: mid(Material::Rock), peak: None, objetos: vec![ crate::worldgen::ObjetoUso { material: mid(Material::Cactus), densidad: 0.010, forma: Forma::Columnar }, // Palmeras ralas (oasis): follaje verde sobre tronco leñoso. crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.004, forma: Forma::Palmera }, ], minerales: vec![ MineralUso::of(Mineral::Sal), MineralUso::of(Mineral::Cobre), MineralUso::of(Mineral::Hierro), MineralUso::of(Mineral::Oro), MineralUso::of(Mineral::Rubi), ], densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![ crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Conejo, densidad: 0.25 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Serpiente, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Zorro, densidad: 0.2 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Camello, densidad: 0.2 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Lagarto, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Escorpion, densidad: 0.25 }, ], seres: vec![crate::worldgen::SereUso { sere: sere_ids[0], probabilidad: 0.5 }], }); let bioma_pradera = p.alloc_id(); p.biomas.push(Bioma { id: bioma_pradera, name: "pradera".into(), base: 0.22, dune: 0.10, relief: 0.7, mountains: 0.5, water_level: 0.30, rivers: 0.25, peak_at: 0.80, ground: mid(Material::Grass), cliff: mid(Material::Rock), peak: Some(mid(Material::Snow)), // Vegetación de pradera: árboles y arbustos ralos sobre un manto de grama // con flores. El orden importa — la primera forma que brota gana la columna, // así los árboles no quedan tapados por la grama densa. objetos: vec![ crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.008, forma: Forma::Arbol }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.012, forma: Forma::Arbusto }, crate::worldgen::ObjetoUso { material: mid(Material::Wheat), densidad: 0.05, forma: Forma::Trigo }, crate::worldgen::ObjetoUso { material: mid(Material::Bloom), densidad: 0.020, forma: Forma::Lavanda }, crate::worldgen::ObjetoUso { material: mid(Material::Bloom), densidad: 0.012, forma: Forma::Flor }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.010, forma: Forma::Junco }, crate::worldgen::ObjetoUso { material: mid(Material::Grass), densidad: 0.05, forma: Forma::Pasto }, ], minerales: vec![ MineralUso::of(Mineral::Carbon), MineralUso::of(Mineral::Hierro), MineralUso::of(Mineral::Cobre), MineralUso::of(Mineral::Cuarzo), ], densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![ crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Vaca, densidad: 0.35 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Oveja, densidad: 0.35 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Conejo, densidad: 0.4 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Mariposa, densidad: 0.5 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Pato, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Cabra, densidad: 0.25 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Burro, densidad: 0.15 }, ], seres: vec![ crate::worldgen::SereUso { sere: sere_ids[1], probabilidad: 0.5 }, crate::worldgen::SereUso { sere: sere_ids[2], probabilidad: 0.4 }, ], }); let bioma_bosque = p.alloc_id(); p.biomas.push(Bioma { id: bioma_bosque, name: "bosque".into(), base: 0.24, dune: 0.06, relief: 0.6, mountains: 0.35, water_level: 0.28, rivers: 0.22, peak_at: 0.85, ground: mid(Material::Grass), cliff: mid(Material::Rock), peak: Some(mid(Material::Snow)), // Bosque denso: robles y árboles + coníferas, con tocones, hongos, helechos // y grama de sotobosque. objetos: vec![ crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.015, forma: Forma::Roble }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.040, forma: Forma::Arbol }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.030, forma: Forma::Conifera }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.025, forma: Forma::Arbusto }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.006, forma: Forma::Tocon }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.012, forma: Forma::Hongo }, crate::worldgen::ObjetoUso { material: mid(Material::Cactus), densidad: 0.025, forma: Forma::Helecho }, crate::worldgen::ObjetoUso { material: mid(Material::Grass), densidad: 0.04, forma: Forma::Pasto }, ], minerales: vec![ MineralUso::of(Mineral::Carbon), MineralUso::of(Mineral::Plata), MineralUso::of(Mineral::Amatista), MineralUso::of(Mineral::Lapis), ], densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![ crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Ciervo, densidad: 0.4 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Zorro, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Oso, densidad: 0.15 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Conejo, densidad: 0.4 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Ardilla, densidad: 0.35 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Buho, densidad: 0.2 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Erizo, densidad: 0.25 }, ], seres: vec![crate::worldgen::SereUso { sere: sere_ids[1], probabilidad: 0.4 }], }); let bioma_selva = p.alloc_id(); p.biomas.push(Bioma { id: bioma_selva, name: "selva".into(), base: 0.26, dune: 0.05, relief: 0.55, mountains: 0.30, water_level: 0.34, rivers: 0.4, peak_at: 0.9, ground: mid(Material::Grass), cliff: mid(Material::Rock), peak: None, // Selva húmeda: dosel de árboles, bambú, plataneras, helechos y hongos. objetos: vec![ crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.050, forma: Forma::Arbol }, crate::worldgen::ObjetoUso { material: mid(Material::Cactus), densidad: 0.050, forma: Forma::Bambu }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.030, forma: Forma::Platanera }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.030, forma: Forma::Arbusto }, crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.012, forma: Forma::Hongo }, crate::worldgen::ObjetoUso { material: mid(Material::Cactus), densidad: 0.045, forma: Forma::Helecho }, crate::worldgen::ObjetoUso { material: mid(Material::Grass), densidad: 0.04, forma: Forma::Pasto }, ], minerales: vec![ MineralUso::of(Mineral::Esmeralda), MineralUso::of(Mineral::Oro), MineralUso::of(Mineral::Diamante), MineralUso::of(Mineral::Cobre), ], densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![ crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Rana, densidad: 0.4 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Serpiente, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Pajaro, densidad: 0.4 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Mariposa, densidad: 0.5 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Mono, densidad: 0.35 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Loro, densidad: 0.35 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Tigre, densidad: 0.15 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Elefante, densidad: 0.12 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Cocodrilo, densidad: 0.15 }, ], seres: vec![crate::worldgen::SereUso { sere: sere_ids[2], probabilidad: 0.4 }], }); let bioma_volcan = p.alloc_id(); p.biomas.push(Bioma { id: bioma_volcan, name: "volcán".into(), base: 0.20, dune: 0.04, relief: 0.9, mountains: 0.85, water_level: 0.0, rivers: 0.0, peak_at: 0.72, // La tanda nueva de materiales: laderas de basalto, cumbre de lava. ground: mid(Material::Basalto), cliff: mid(Material::Basalto), peak: Some(mid(Material::Lava)), objetos: vec![], // Veta rica: el fuego trae metales, gemas rojas y exóticos volcánicos. minerales: vec![ MineralUso::of(Mineral::Hierro), MineralUso::of(Mineral::Oro), MineralUso::of(Mineral::Rubi), MineralUso::of(Mineral::Obsidiana), MineralUso::of(Mineral::Cinabrio), ], densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![], seres: vec![], }); let bioma_gruta = p.alloc_id(); p.biomas.push(Bioma { id: bioma_gruta, name: "gruta de cristales".into(), base: 0.30, dune: 0.05, relief: 0.8, mountains: 0.7, water_level: 0.10, rivers: 0.05, peak_at: 0.9, // Roca clara (granito) para que resalten las gemas exóticas. ground: mid(Material::Granito), cliff: mid(Material::Granito), peak: None, objetos: vec![], // Vitrina de exóticos: geodas, cristales y vetas de colores raros. minerales: vec![ MineralUso::of(Mineral::Amatista), MineralUso::of(Mineral::Zafiro), MineralUso::of(Mineral::Topacio), MineralUso::of(Mineral::Turquesa), MineralUso::of(Mineral::Opalo), MineralUso::of(Mineral::Fluorita), MineralUso::of(Mineral::Jade), MineralUso::of(Mineral::Bismuto), MineralUso::of(Mineral::Uraninita), ], densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![], seres: vec![], }); let bioma_pueblo = p.alloc_id(); p.biomas.push(Bioma { id: bioma_pueblo, name: "pueblo".into(), base: 0.16, dune: 0.03, relief: 0.22, mountains: 0.12, water_level: 0.0, rivers: 0.0, peak_at: 1.0, ground: mid(Material::Grass), cliff: mid(Material::Rock), peak: None, objetos: vec![ crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.006, forma: Forma::Arbol }, crate::worldgen::ObjetoUso { material: mid(Material::Grass), densidad: 0.04, forma: Forma::Pasto }, ], minerales: vec![], // Un pueblo mezclado, esparcido por la semilla del mundo. densidad_urbana: 0.55, casas: vec![ CasaUso { tipo: CasaTipo::Cabana, densidad: 0.35 }, CasaUso { tipo: CasaTipo::Moderna, densidad: 0.3 }, CasaUso { tipo: CasaTipo::Flintstone, densidad: 0.2 }, CasaUso { tipo: CasaTipo::Hobbit, densidad: 0.2 }, CasaUso { tipo: CasaTipo::Edificio, densidad: 0.15 }, ], fauna: vec![ crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Gallina, densidad: 0.5 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Cerdo, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Vaca, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Pato, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Cabra, densidad: 0.2 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Burro, densidad: 0.15 }, ], seres: vec![], }); // La costa: el suelo a la altura del agua, así las lomas de arena hacen // playa e islas y lo bajo es mar — «está linda la mar». let bioma_mar = p.alloc_id(); p.biomas.push(Bioma { id: bioma_mar, name: "mar".into(), base: 0.295, dune: 0.12, relief: 0.18, mountains: 0.08, water_level: 0.30, rivers: 0.0, peak_at: 1.0, ground: mid(Material::Sand), cliff: mid(Material::Arenisca), peak: None, objetos: vec![ crate::worldgen::ObjetoUso { material: mid(Material::Leaf), densidad: 0.010, forma: Forma::Palmera }, crate::worldgen::ObjetoUso { material: mid(Material::Grass), densidad: 0.015, forma: Forma::Junco }, ], minerales: vec![], densidad_urbana: 0.0, casas: Vec::new(), fauna: vec![ crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Cangrejo, densidad: 0.3 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Pelicano, densidad: 0.2 }, crate::bicho::BichoUso { bicho: crate::bicho::Bicho::Tortuga, densidad: 0.15 }, ], seres: vec![], }); // La luna: polvo gris, lomas suaves, ni agua ni nada que crezca. Va con // el cielo de noche de la escena (`SceneSpec::cielo`). let bioma_luna = p.alloc_id(); p.biomas.push(Bioma { id: bioma_luna, name: "luna".into(), base: 0.22, dune: 0.06, relief: 0.30, mountains: 0.35, water_level: 0.0, rivers: 0.0, peak_at: 1.0, ground: mid(Material::Grava), cliff: mid(Material::Basalto), peak: None, objetos: vec![], minerales: vec![], densidad_urbana: 0.0, casas: Vec::new(), fauna: vec![], seres: vec![], }); // --- Mundos --- let mundo_desierto = p.alloc_id(); p.mundos.push(Mundo { id: mundo_desierto, name: "desierto".into(), seed: 1337, biomas: vec![bioma_desierto], mapa: None, }); let mundo_pradera = p.alloc_id(); p.mundos.push(Mundo { id: mundo_pradera, name: "pradera".into(), seed: 1337, biomas: vec![bioma_pradera], mapa: None, }); let mundo_bosque = p.alloc_id(); p.mundos.push(Mundo { id: mundo_bosque, name: "bosque".into(), seed: 2027, biomas: vec![bioma_bosque], mapa: None, }); let mundo_selva = p.alloc_id(); p.mundos.push(Mundo { id: mundo_selva, name: "selva".into(), seed: 3141, biomas: vec![bioma_selva], mapa: None, }); let mundo_volcan = p.alloc_id(); p.mundos.push(Mundo { id: mundo_volcan, name: "volcán".into(), seed: 777, biomas: vec![bioma_volcan], mapa: None, }); let mundo_gruta = p.alloc_id(); p.mundos.push(Mundo { id: mundo_gruta, name: "gruta de cristales".into(), seed: 2718, biomas: vec![bioma_gruta], mapa: None, }); let mundo_pueblo = p.alloc_id(); p.mundos.push(Mundo { id: mundo_pueblo, name: "pueblo".into(), seed: 1234, biomas: vec![bioma_pueblo], mapa: None, }); let mundo_mar = p.alloc_id(); p.mundos.push(Mundo { id: mundo_mar, name: "mar".into(), seed: 1916, biomas: vec![bioma_mar], mapa: None }); let mundo_luna = p.alloc_id(); p.mundos.push(Mundo { id: mundo_luna, name: "luna".into(), seed: 1969, biomas: vec![bioma_luna], mapa: None }); // --- Escena demo --- let dim = world_dim(PREVIEW_DIM_XZ); let mut escena = SceneSpec::walk_and_emote("saludo en el desierto", mundo_desierto, 3, Clip::Wave, dim); escena.id = p.alloc_id(); p.escenas.push(escena); // --- Interior demo (sala amueblada) --- let mut sala = crate::interior::Interior::nuevo(p.alloc_id(), "sala"); sala.colocar(crate::Mueble::Alfombra, 12, 10, 0); sala.colocar(crate::Mueble::Sofa, 12, 6, 0); sala.colocar(crate::Mueble::Mesa, 12, 10, 0); sala.colocar(crate::Mueble::Silla, 9, 10, 1); sala.colocar(crate::Mueble::Estante, 4, 3, 0); sala.colocar(crate::Mueble::Lampara, 20, 4, 0); sala.colocar(crate::Mueble::Maceta, 4, 16, 0); sala.colocar(crate::Mueble::Ficus, 20, 16, 0); sala.colocar(crate::Mueble::Cuadro, 12, 1, 0); sala.colocar(crate::Mueble::Espejo, 8, 1, 0); sala.colocar(crate::Mueble::Colgante, 16, 2, 0); sala.colocar(crate::Mueble::Aplique, 1, 8, 1); p.interiores.push(sala); p } // --- Búsquedas por id --- pub fn ley(&self, id: u64) -> Option<&Ley> { self.leyes.iter().find(|x| x.id == id) } pub fn material(&self, id: u64) -> Option<&MaterialDef> { self.materiales.iter().find(|x| x.id == id) } pub fn sere(&self, id: u64) -> Option<&CharSpec> { self.seres.iter().find(|x| x.id == id) } pub fn bioma(&self, id: u64) -> Option<&Bioma> { self.biomas.iter().find(|x| x.id == id) } pub fn mundo(&self, id: u64) -> Option<&Mundo> { self.mundos.iter().find(|x| x.id == id) } /// Id del material de fábrica de la variante `m`, si está sembrado (para que la /// IA/presets encuentren «la arena» sin clavar números). pub fn material_id_for(&self, m: Material) -> Option { self.materiales.iter().find(|d| d.builtin == Some(m)).map(|d| d.id) } /// **Resuelve un material** aplanando la cadena de herencia: el primer `Some` /// hacia arriba gana; si nada lo define, cae al color/grano del `builtin`, y por /// último a un gris neutro. Corta a profundidad 32 (anti-ciclo). pub fn resolve_material(&self, id: u64) -> ResolvedMaterial { let mut color: Option<[f32; 3]> = None; let mut grain: Option = None; let mut brillo: Option = None; let mut builtin: Option = None; let mut cur = Some(id); let mut depth = 0; while let Some(cid) = cur { if depth > 32 { break; } let Some(m) = self.material(cid) else { break }; if color.is_none() { color = m.color; } if grain.is_none() { grain = m.grain; } if brillo.is_none() { brillo = m.brillo; } if builtin.is_none() { builtin = m.builtin; } cur = m.parent; depth += 1; } let color_u8 = color .map(|c| { [ (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, ] }) .or_else(|| builtin.map(|b| b.color())) .unwrap_or([150, 150, 150]); let grain = grain.or_else(|| builtin.map(|b| b.grain())).unwrap_or(0.0); let brillo = brillo.or_else(|| builtin.map(|b| b.brillo())).unwrap_or(0.0); // El detalle sub-voxel (rugosidad/bisel) viaja POR VOXEL con el material, así // dos materiales del bioma se ven distinto a la vez (lo que el look global no // podía). Aplana la herencia igual que color/brillo (`detalle_material`). ResolvedMaterial::new_b(color_u8, grain, brillo).con_detalle(self.detalle_material(id)) } /// **Detalle sub-voxel resuelto** `(rugosidad, bisel)` de un material, aplanando /// la herencia (primer `Some` hacia arriba gana; default `0.0` = liso/duro). Es un /// look GLOBAL del ray-march (no hay id de material por voxel todavía), así que el /// preview aplica el del material en foco. Espeja [`Self::resolve_material`]. pub fn detalle_material(&self, id: u64) -> (f32, f32) { let mut rugosidad: Option = None; let mut bisel: Option = None; let mut cur = Some(id); let mut depth = 0; while let Some(cid) = cur { if depth > 32 { break; } let Some(m) = self.material(cid) else { break }; if rugosidad.is_none() { rugosidad = m.rugosidad; } if bisel.is_none() { bisel = m.bisel; } cur = m.parent; depth += 1; } (rugosidad.unwrap_or(0.0).max(0.0), bisel.unwrap_or(0.0).clamp(0.0, 1.0)) } /// **Leyes efectivas** de un material: las propias + las heredadas de sus padres, /// cada una con sus parámetros aplicados (la `LeyUso.params` overridea los /// defaults de la ley). Es lo que dispara la simulación: un material *fluye* /// porque tiene una [`LeyKind::Fluir`], *crece* porque tiene [`LeyKind::Crecer`]. pub fn resolve_laws(&self, material_id: u64) -> Vec { let mut out = Vec::new(); let mut cur = Some(material_id); let mut depth = 0; while let Some(cid) = cur { if depth > 32 { break; } let Some(m) = self.material(cid) else { break }; for u in &m.leyes { if let Some(ley) = self.ley(u.ley) { let mut kind = ley.kind.clone(); for (i, &v) in u.params.iter().enumerate() { kind.set_param(i, v); } out.push(kind); } } cur = m.parent; depth += 1; } out } /// Si el material tiene una ley [`Fluir`](LeyKind::Fluir), sus parámetros /// `(gravedad, horizontal)` — lo que vuelve líquido a un material. `None` = no fluye. pub fn fluir_params(&self, material_id: u64) -> Option<(f32, f32)> { self.resolve_laws(material_id).into_iter().find_map(|k| match k { LeyKind::Fluir { gravedad, horizontal } => Some((gravedad, horizontal)), _ => None, }) } /// Si el material tiene una ley [`Crecer`](LeyKind::Crecer), su `velocidad`. /// `None` = no crece. pub fn crecer_velocidad(&self, material_id: u64) -> Option { self.resolve_laws(material_id).into_iter().find_map(|k| match k { LeyKind::Crecer { velocidad } => Some(velocidad), _ => None, }) } /// Parámetros de Fluir del **agua** (el material semilla `Water`), si los tiene. /// El studio lo usa para que el agua del bioma fluya según su ley. pub fn water_fluir(&self) -> Option<(f32, f32)> { self.fluir_params(self.material_id_for(Material::Water)?) } /// Si el material tiene una ley [`Ecuacion`](LeyKind::Ecuacion) (con herencia), su /// `(campos, programa compilado, valores de parámetros)` — listo para correr con /// [`EcuacionSim`](crate::EcuacionSim) sobre las celdas del material en el preview. /// `None` si no tiene una, o si su fórmula no compila. pub fn ecuacion_de_material( &self, material_id: u64, ) -> Option<(Vec, Program, Vec)> { self.resolve_laws(material_id).into_iter().find_map(|k| match &k { LeyKind::Ecuacion { campos, .. } => { let prog = k.compile_ecuacion()?.ok()?; Some((campos.clone(), prog, k.ecuacion_param_values())) } _ => None, }) } /// Arma la [`BiomaPalette`] resuelta de un bioma (colores concretos para el render). pub fn bioma_palette(&self, b: &Bioma) -> BiomaPalette { let agua = self .material_id_for(Material::Water) .map(|id| self.resolve_material(id).color) .unwrap_or_else(|| Material::Water.color()); BiomaPalette { ground: self.resolve_material(b.ground), cliff: self.resolve_material(b.cliff), peak: b.peak.map(|id| self.resolve_material(id)), agua, objetos: b .objetos .iter() .map(|o| (self.resolve_material(o.material), o.densidad, o.forma)) .collect(), } } /// Todo lo que el render necesita de un mundo: su primer bioma (relieve), la /// semilla y la paleta resuelta. `None` si el mundo o su bioma no existen. pub fn render_mundo(&self, mundo_id: u64) -> Option { let m = self.mundo(mundo_id)?; let bid = *m.biomas.first()?; let bioma = self.bioma(bid)?.clone(); let palette = self.bioma_palette(&bioma); Some(MundoRender { bioma, seed: m.seed, palette }) } /// El render de un **bioma** suelto (modo Biomas), con una semilla fija de /// previsualización. `None` si el bioma no existe. pub fn render_bioma(&self, bioma_id: u64) -> Option { let bioma = self.bioma(bioma_id)?.clone(); let palette = self.bioma_palette(&bioma); Some(MundoRender { bioma, seed: 1337, palette }) } /// Ser `i` (por índice, como referencian los actores de una escena), o uno por /// defecto si el índice se sale. pub fn character_or_default(&self, i: usize) -> CharSpec { self.seres .get(i) .cloned() .unwrap_or_else(|| CharSpec::new("actor", Age::Adult)) } } #[cfg(test)] mod tests { use super::*; /// Una bandada de `n` humanoides en fila, alejándose del ojo en Z. fn bandada(n: usize) -> Vec { (0..n).map(|i| CharSpec::new(format!("ser{i}"), Age::Adult)).collect() } fn en_cuadro<'a>(specs: &'a [CharSpec], paso: f32) -> Vec> { specs .iter() .enumerate() .map(|(i, spec)| EnCuadro { spec, pos: Vec3::new(0.0, 0.0, i as f32 * paso), yaw: 0.0, clip: Clip::Walk, fase: i as f32 * 0.3, }) .collect() } /// El rey del cuento: corona, manto largo, barba entera, pelo largo y anteojos /// —lo más caro que se le puede poner a un ser—. fn rey() -> CharSpec { use crate::ser_sdf::{Peinado, Sombrero}; let mut c = CharSpec::new("rey", Age::Elder); c.atuendo = Atuendo { peinado: Peinado::Larga, barba: 1.0, sombrero: Sombrero::Corona, anteojos: true, falda: 0.9, manto: 1.0, ..Atuendo::default() }; c } #[test] fn el_atuendo_viaja_en_el_documento_y_uno_viejo_abre_desnudo() { // Un ser escrito antes de que existiera el vestuario abre sin ropa. let viejo = r#"(name: "a", age: Adult, skin: (0.9, 0.7, 0.6), shirt: (0.8, 0.3, 0.3), pants: (0.2, 0.2, 0.3))"#; let v: CharSpec = ron::from_str(viejo).unwrap(); assert!(v.atuendo.es_default()); // Y por postcard, el formato nativo, el vestido va y vuelve. let bytes = postcard::to_allocvec(&rey()).unwrap(); let de_postcard: CharSpec = postcard::from_bytes(&bytes).unwrap(); assert_eq!(de_postcard.atuendo, rey().atuendo); // Y uno vestido va y vuelve igual. let r = rey(); let txt = ron::to_string(&r).unwrap(); let vuelta: CharSpec = ron::from_str(&txt).unwrap(); assert_eq!(vuelta.atuendo, r.atuendo); // A mano se nombra sólo lo que se le pone; lo demás es default. let escrito = r#"(name: "princesa", age: Child, skin: (0.9, 0.75, 0.62), shirt: (0.95, 0.80, 0.86), pants: (0.3, 0.3, 0.4), atuendo: (peinado: Larga, falda: 1.0, sombrero: Corona))"#; let p: CharSpec = ron::from_str(escrito).unwrap(); assert_eq!(p.atuendo.falda, 1.0); assert_eq!(p.atuendo.manto, 0.0); assert_eq!(p.atuendo.sombrero, crate::ser_sdf::Sombrero::Corona); } #[test] fn el_ser_que_filma_la_escena_lleva_puesto_su_atuendo() { // Brazo de control: el mismo ser, desnudo, en el MISMO camino (`to_sdf_lod`, // el que usan los repartos y por ende `ende`). let vestido = rey(); let desnudo = CharSpec { atuendo: Atuendo::default(), ..vestido.clone() }; for d in [crate::DetalleSer::Hero, crate::DetalleSer::Medio, crate::DetalleSer::Lejos] { let n = |c: &CharSpec| c.to_sdf_lod(Clip::Idle, 0.0, Vec3::ZERO, 0.0, None, d).prims.len(); assert!(n(&vestido) > n(&desnudo), "{d:?}: {} vs {}", n(&vestido), n(&desnudo)); assert_eq!(n(&vestido) - n(&desnudo), vestido.atuendo.costo_extra(d), "{d:?}"); // El costo es COTA del ser real: es lo único que el reparto mira antes // de armarlo. Con la cuenta desnuda, el rey costaba lo de un ser sin ropa. for fase in [0.0, 0.7, 1.9, 3.3] { let real = vestido.to_sdf_lod(Clip::Walk, fase, Vec3::ZERO, 0.0, None, d).prims.len(); assert!(real <= vestido.costo(d), "{d:?} fase {fase}: {real} > {}", vestido.costo(d)); } } assert_eq!(desnudo.costo(crate::DetalleSer::Hero), crate::DetalleSer::Hero.costo()); } #[test] fn un_reparto_de_seres_vestidos_no_se_pasa_del_uniform() { use llimphi_3d::{VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS}; // El techo por detalle se calibró sin ropa: con la cuenta vieja, una bandada // de reyes se pasaba y el empacador les cortaba la cola en silencio. // Se barren separaciones: el desborde pasa sólo cuando el último admitido cae // justo en el borde del presupuesto, y con un solo caso eso es suerte. let specs: Vec = (0..VOXEL_MAX_SERS * 2).map(|_| rey()).collect(); let ojo = Vec3::new(0.0, 1.6, -4.0); for paso in [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.5, 6.0, 9.0] { let sers = reparto_sdf(&en_cuadro(&specs, paso), ojo, true); let total: usize = sers.iter().map(|s| s.prims.len()).sum(); assert!(total <= VOXEL_MAX_SER_PRIMS, "reparto_sdf paso {paso}: {total} primitivas"); let rep = reparto_mixto(&en_cuadro(&specs, paso), ojo, true, Vec3::new(64.0, 32.0, 64.0)); let total: usize = rep.sers.iter().map(|s| s.prims.len()).sum(); assert!(total <= VOXEL_MAX_SER_PRIMS, "reparto_mixto paso {paso}: {total} primitivas"); assert_eq!(rep.sers.len() + rep.mallas.len(), specs.len(), "nadie se cae del cuadro"); } } fn elefante() -> CharSpec { let mut c = CharSpec::new("elefante", Age::Adult); c.bicho = Some(Bicho::Elefante); c } #[test] fn el_elefante_es_un_actor_que_se_filma_con_su_cuerpo_del_bestiario() { use crate::bicho_receta::alto_tipico; let e = elefante(); assert!(e.es_sdf(), "el elefante va por el ray-marcher, no por cajas"); assert_eq!(e.altura(), alto_tipico(Bicho::Elefante)); // Es el MISMO cuerpo que el de la fauna, posado donde dice el guion. let pos = Vec3::new(10.0, 4.0, 20.0); let ser = e.to_sdf_lod(Clip::Idle, 0.0, pos, 0.7, Some(Vec3::ZERO), crate::DetalleSer::Hero); let fauna = crate::bicho_sdf_lod( Bicho::Elefante, alto_tipico(Bicho::Elefante), crate::Andanza::Quieto, 0.0, crate::DetalleSer::Hero, ); assert_eq!(ser.prims.len(), fauna.prims.len()); assert_eq!(ser.pos, [pos.x, pos.y, pos.z]); assert_eq!(ser.yaw, 0.7); // Camina: dos instantes del paso no son la misma pose. let a = e.to_sdf(Clip::Walk, 0.0); let b = e.to_sdf(Clip::Walk, 0.4); assert_ne!(format!("{:?}", a.prims), format!("{:?}", b.prims), "el paso no se mueve"); // La cuenta del reparto cubre lo que de verdad se arma, en cada clip y detalle. for d in [crate::DetalleSer::Hero, crate::DetalleSer::Medio, crate::DetalleSer::Lejos] { for c in [Clip::Idle, Clip::Walk, Clip::Run, Clip::Wave, Clip::Point, Clip::Cheer] { let n = e.to_sdf_lod(c, 1.3, pos, 0.0, None, d).prims.len(); assert!(n <= e.costo(d), "{d:?}/{c:?}: arma {n} y declara {}", e.costo(d)); } } // Cuando cae a cajas, la silueta mide lo que mide el elefante. let (model, v, _) = e.to_meta(Vec3::ZERO, 0.0, Clip::Walk, 0.0, None); let alto = v .iter() .map(|x| model.transform_point3(Vec3::from_array(x.pos)).y) .fold(0.0f32, f32::max); assert!((alto - e.altura()).abs() < 0.05, "la malla de respaldo mide {alto}"); } #[test] fn el_rey_y_su_manada_de_elefantes_entran_en_el_cuadro() { use llimphi_3d::VOXEL_MAX_SER_PRIMS; // «un rebaño de elefantes»: el rey y ocho, barriendo separaciones como en el // reparto de los vestidos. Nadie se cae del cuadro y el uniform no desborda. let mut specs = vec![rey()]; specs.extend((0..8).map(|_| elefante())); let ojo = Vec3::new(0.0, 1.6, -6.0); for paso in [0.0, 1.0, 2.5, 4.0, 7.0] { let sers = reparto_sdf(&en_cuadro(&specs, paso), ojo, true); let total: usize = sers.iter().map(|s| s.prims.len()).sum(); assert!(total <= VOXEL_MAX_SER_PRIMS, "reparto_sdf paso {paso}: {total}"); let rep = reparto_mixto(&en_cuadro(&specs, paso), ojo, true, Vec3::new(64.0, 32.0, 64.0)); let total: usize = rep.sers.iter().map(|s| s.prims.len()).sum(); assert!(total <= VOXEL_MAX_SER_PRIMS, "reparto_mixto paso {paso}: {total}"); assert_eq!(rep.sers.len() + rep.mallas.len(), specs.len(), "nadie se cae del cuadro"); } } /// La escena de «El prendedor»: la princesa con la estrella en la mano y la flor, /// la pluma y la perla en el mundo. fn escena_del_prendedor() -> SceneSpec { use crate::{Utileria, UtileriaSpec}; let mut s = SceneSpec::walk_and_emote("prendedor", 0, 2, Clip::Wave, [96, 24, 96]); let mut estrella = UtileriaSpec::en(Utileria::Estrella, [0.0; 3]); estrella.lleva = Some(0); estrella.desde = 1.0; s.utileria.push(estrella); for (i, u) in [Utileria::Flor, Utileria::Pluma, Utileria::Perla].into_iter().enumerate() { s.utileria.push(UtileriaSpec::en(u, [40.0 + i as f32, 10.0, 40.0])); } s } #[test] fn la_utileria_se_reserva_su_lugar_y_el_reparto_se_reparte_el_resto() { use llimphi_3d::{VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS}; let escena = escena_del_prendedor(); let specs: Vec = (0..VOXEL_MAX_SERS * 2).map(|_| rey()).collect(); let ojo = Vec3::new(0.0, 1.6, -4.0); let mano = [(Vec3::new(30.0, 10.0, 30.0), 0.0, Vec3::new(0.3, 0.9, 0.2))]; assert!(escena.utileria_en(0.5, &mano).len() == 3, "la estrella aún no apareció"); let utileria = escena.utileria_en(2.0, &mano); assert_eq!(utileria.len(), 4, "las cuatro piezas en cuadro"); for paso in [0.0, 0.5, 1.0, 2.0, 4.5, 9.0] { let rep = reparto_mixto_con(&en_cuadro(&specs, paso), ojo, true, Vec3::new(64.0, 32.0, 64.0), &utileria); let total: usize = utileria.iter().chain(&rep.sers).map(|s| s.prims.len()).sum(); assert!(total <= VOXEL_MAX_SER_PRIMS, "paso {paso}: {total} primitivas con la utilería"); assert!(utileria.len() + rep.sers.len() <= VOXEL_MAX_SERS, "paso {paso}: más seres que lugares"); assert_eq!(rep.sers.len() + rep.mallas.len(), specs.len(), "nadie se cae del cuadro"); } // Sin utilería, el reparto con reserva es el de siempre. let a = reparto_mixto(&en_cuadro(&specs, 1.0), ojo, true, Vec3::splat(64.0)); let b = reparto_mixto_con(&en_cuadro(&specs, 1.0), ojo, true, Vec3::splat(64.0), &[]); assert_eq!((a.sers.len(), a.mallas.len()), (b.sers.len(), b.mallas.len())); } #[test] fn la_estrella_sube_cuando_la_princesa_saluda() { use crate::{Utileria, UtileriaSpec}; let princesa = CharSpec::new("princesa", Age::Child); let mut s = SceneSpec::walk_and_emote("estrella", 0, 1, Clip::Wave, [96, 24, 96]); let mut e = UtileriaSpec::en(Utileria::Estrella, [0.0; 3]); e.lleva = Some(0); s.utileria.push(e); let pies = Vec3::new(40.0, 10.0, 40.0); let en = |clip: Clip| { let u = s.utileria_en(1.0, &[(pies, 0.3, princesa.mano(clip, 1.2))]); Vec3::from_array(u[0].pos) }; let quieta = en(Clip::Idle); let saludando = en(Clip::Wave); assert!(saludando.y > quieta.y + 0.2, "la estrella no subió: {quieta} → {saludando}"); // Y está donde la mano que se dibuja: la del ser filmado, posado igual. let ser = princesa.to_sdf_lod(Clip::Wave, 1.2, pies, 0.3, None, crate::DetalleSer::Hero); let manos: Vec = ser .prims .iter() .filter(|p| p.kind == llimphi_3d::SdfKind::Esfera && p.color == princesa.skin) .map(|p| pies + crate::sdf_kit::rot_y(Vec3::from_array(p.a), 0.3)) .collect(); assert!( manos.iter().any(|m| m.distance(saludando) < 1e-4), "la estrella no está en ninguna de las manos dibujadas" ); // El elefante la lleva sobre el lomo. let mut el = CharSpec::new("elefante", Age::Adult); el.bicho = Some(Bicho::Elefante); assert_eq!(el.mano(Clip::Walk, 0.0).y, el.altura()); } #[test] fn cien_perlas_no_le_quitan_la_cara_al_reparto() { use crate::{Utileria, UtileriaSpec}; use llimphi_3d::{VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS}; let mut s = SceneSpec::walk_and_emote("perlas", 0, 1, Clip::Idle, [96, 24, 96]); s.utileria = (0..100).map(|i| UtileriaSpec::en(Utileria::Estrella, [i as f32, 10.0, 0.0])).collect(); let u = s.utileria_en(1.0, &[]); assert!(!u.is_empty()); assert!(u.len() <= VOXEL_MAX_SERS / 4); assert!(u.iter().map(|x| x.prims.len()).sum::() <= VOXEL_MAX_SER_PRIMS / 4); } #[test] fn la_utileria_viaja_en_el_documento_y_una_escena_vieja_abre_sin_ella() { let e = escena_del_prendedor(); let vuelta: SceneSpec = ron::from_str(&ron::to_string(&e).unwrap()).unwrap(); assert_eq!(vuelta.utileria, e.utileria); let bytes = postcard::to_allocvec(&e).unwrap(); let de_postcard: SceneSpec = postcard::from_bytes(&bytes).unwrap(); assert_eq!(de_postcard.utileria, e.utileria); // Escrita a mano, como la escribe un guion: sólo lo que importa. let txt = r#"(name: "a", mundo: 0, duration: 4.0, actors: [], utileria: [(cosa: Estrella, pos: (10.0, 20.0, 30.0), desde: 1.5, giro: 0.1)])"#; let a_mano: SceneSpec = ron::from_str(txt).unwrap(); assert_eq!(a_mano.utileria[0].cosa, crate::Utileria::Estrella); assert_eq!(a_mano.utileria[0].lleva, None); let vieja: SceneSpec = ron::from_str(r#"(name: "a", mundo: 0, duration: 4.0, actors: [])"#).unwrap(); assert!(vieja.utileria.is_empty()); } /// **La estrella alumbra de noche, y sólo de noche**: con cielo de noche la /// estrella en la mano da una luz centrada en ella, que crece mientras la /// estrella aparece; de día no da ninguna. Y es la única pieza con luz propia /// (el shader la lee de un canal de color > 1,5). #[test] fn la_estrella_alumbra_de_noche_y_solo_de_noche() { let mut s = escena_del_prendedor(); let mano = [(Vec3::new(30.0, 10.0, 30.0), 0.4, Vec3::new(0.3, 0.9, 0.2))]; assert!(s.luces_en(2.0, &mano).is_empty(), "de día la estrella no alumbra"); s.cielo = Some(crate::Cielo::noche()); assert!(s.luces_en(0.5, &mano).is_empty(), "antes de aparecer no alumbra"); let luces = s.luces_en(2.0, &mano); assert_eq!(luces.len(), 1, "sólo la estrella alumbra, no la flor ni la perla"); let estrella = &s.utileria_en(2.0, &mano)[0]; let (c, r) = estrella.bound(); let centro = Vec3::from_array(estrella.pos) + crate::sdf_kit::rot_y(Vec3::from_array(c), estrella.yaw); assert!(Vec3::from_array(luces[0].pos).distance(centro) < 1e-4, "la luz sale de la estrella"); assert!(luces[0].range > r * 4.0, "alumbra más allá de sí misma: {}", luces[0].range); let creciendo = s.luces_en(1.0 + crate::APARECER * 0.5, &mano)[0].range; assert!(creciendo < luces[0].range, "la luz crece con la estrella"); // Luz propia: la estrella cruza el umbral del shader; las demás piezas no. let piezas = s.utileria_en(2.0, &mano); let brilla = |x: &llimphi_3d::SdfSer| x.prims.iter().any(|p| p.color.iter().any(|&k| k > 1.5)); assert!(brilla(&piezas[0]), "la estrella no tiene luz propia"); assert!(piezas[1..].iter().all(|x| !brilla(x)), "una pieza sin luz propia brilla"); // Viaja en el documento, en los dos formatos. let vuelta: SceneSpec = ron::from_str(&ron::to_string(&s).unwrap()).unwrap(); assert_eq!(vuelta.cielo, s.cielo); let de_postcard: SceneSpec = postcard::from_bytes(&postcard::to_allocvec(&s).unwrap()).unwrap(); assert_eq!(de_postcard.cielo, s.cielo); let vieja: SceneSpec = ron::from_str(r#"(name: "a", mundo: 0, duration: 4.0, actors: [])"#).unwrap(); assert!(vieja.cielo.is_none(), "una escena vieja abre de día"); } /// El decorado viaja en el documento (RON y postcard), y una escena vieja /// abre sin decorado. Y el arranque trae el mar y la luna como mundos. #[test] fn el_decorado_viaja_en_el_documento_y_el_arranque_trae_mar_y_luna() { let mut s = SceneSpec::walk_and_emote("palacio", 0, 1, Clip::Idle, [96, 24, 96]); s.decorado.push(crate::Decorado::en(crate::CasaTipo::Palacio, 48.0, 70.0)); s.decorado.push(crate::Decorado::en(crate::CasaTipo::Kiosko, 30.0, 60.0)); let vuelta: SceneSpec = ron::from_str(&ron::to_string(&s).unwrap()).unwrap(); assert_eq!(vuelta.decorado, s.decorado); let de_postcard: SceneSpec = postcard::from_bytes(&postcard::to_allocvec(&s).unwrap()).unwrap(); assert_eq!(de_postcard.decorado, s.decorado); let vieja: SceneSpec = ron::from_str(r#"(name: "a", mundo: 0, duration: 4.0, actors: [])"#).unwrap(); assert!(vieja.decorado.is_empty()); let p = Project::starter(); for nombre in ["mar", "luna"] { let m = p.mundos.iter().find(|m| m.name == nombre).unwrap_or_else(|| panic!("falta el mundo {nombre}")); assert!(p.render_mundo(m.id).is_some(), "{nombre} no se puede pintar"); } } #[test] fn la_especie_viaja_en_el_documento_y_uno_viejo_abre_persona() { let viejo = r#"(name: "a", age: Adult, skin: (0.9, 0.7, 0.6), shirt: (0.8, 0.3, 0.3), pants: (0.2, 0.2, 0.3))"#; let v: CharSpec = ron::from_str(viejo).unwrap(); assert_eq!(v.bicho, None); let e = elefante(); let vuelta: CharSpec = ron::from_str(&ron::to_string(&e).unwrap()).unwrap(); assert_eq!(vuelta.bicho, Some(Bicho::Elefante)); let bytes = postcard::to_allocvec(&e).unwrap(); let de_postcard: CharSpec = postcard::from_bytes(&bytes).unwrap(); assert_eq!(de_postcard.bicho, Some(Bicho::Elefante)); } #[test] fn ciclar_la_especie_recorre_el_bestiario_y_vuelve_a_ninguna() { let mut c = CharSpec::new("x", Age::Adult); let mut vistos = 0; loop { c.cycle_bicho(false); if c.bicho.is_none() { break; } vistos += 1; } assert_eq!(vistos, Bicho::ALL.len()); c.cycle_bicho(true); assert_eq!(c.bicho, Some(Bicho::ALL[Bicho::ALL.len() - 1])); } #[test] fn el_reparto_entra_en_el_presupuesto_del_uniform() { use llimphi_3d::{VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS}; // 16 humanoides con la cara entera son ~500 primitivas: NO entran. El reparto // tiene que hacerlos entrar bajando detalle, no dejándolos afuera. let specs = bandada(16); let sers = reparto_sdf(&en_cuadro(&specs, 6.0), Vec3::new(0.0, 1.6, -4.0), true); let total: usize = sers.iter().map(|s| s.prims.len()).sum(); assert!(sers.len() <= VOXEL_MAX_SERS); assert!(total <= VOXEL_MAX_SER_PRIMS, "{total} primitivas"); assert_eq!(sers.len(), 16, "los 16 entran (con LOD)"); // Nadie sale mutilado: todos tienen al menos el cuerpo completo. for s in &sers { assert!(s.prims.len() >= 10, "cuerpo incompleto: {}", s.prims.len()); } } #[test] fn los_de_cerca_llevan_mas_cara_que_los_del_fondo() { let specs = bandada(8); let sers = reparto_sdf(&en_cuadro(&specs, 12.0), Vec3::new(0.0, 1.6, -2.0), true); // Vienen ordenados de cerca a lejos, y el detalle decae monótonamente. assert!( sers[0].prims.len() > sers[sers.len() - 1].prims.len(), "{} vs {}", sers[0].prims.len(), sers[sers.len() - 1].prims.len() ); for par in sers.windows(2) { assert!(par[0].prims.len() >= par[1].prims.len(), "el detalle no puede subir con la distancia"); } } #[test] fn una_multitud_no_pierde_a_nadie_el_fondo_pasa_a_malla() { use llimphi_3d::{VOXEL_MAX_SERS, VOXEL_MAX_SER_PRIMS}; // Más humanoides que los que admite el uniform. Antes, los del fondo // desaparecían del cuadro; ahora bajan a malla de cajas. // // El número sale del TOPE, no de una constante escrita a mano: cuando el // uniform pasó de 16 seres a 40 (2026-09-06, la multitud de la vereda) este // test se puso rojo con 30 porque ya entraban todos — y lo que quería fijar // no era «30 no entran» sino «el que no entra no se pierde». let n = VOXEL_MAX_SERS * 2; let specs = bandada(n); let cuadro = en_cuadro(&specs, 4.0); let ojo = Vec3::new(0.0, 1.6, -4.0); let half = Vec3::new(64.0, 32.0, 64.0); let rep = reparto_mixto(&cuadro, ojo, true, half); assert_eq!(rep.sers.len() + rep.mallas.len(), n, "nadie se cae del cuadro"); assert!(rep.sers.len() <= VOXEL_MAX_SERS); let total: usize = rep.sers.iter().map(|s| s.prims.len()).sum(); assert!(total <= VOXEL_MAX_SER_PRIMS, "{total} primitivas"); assert!(!rep.mallas.is_empty(), "con {n}, alguno cae a malla"); // Las mallas traen geometría de verdad (no cascarones vacíos). for (_, v, i) in &rep.mallas { assert!(!v.is_empty() && !i.is_empty()); } } #[test] fn el_sdf_se_lo_llevan_los_de_cerca() { let specs = bandada(24); let cuadro = en_cuadro(&specs, 5.0); let ojo = Vec3::new(0.0, 1.6, -4.0); let half = Vec3::ZERO; let rep = reparto_mixto(&cuadro, ojo, true, half); // El más lejano de los SDF está más cerca que el más cercano de las mallas // (la frontera entre técnicas es la distancia, no el orden de la lista). let z_sdf = rep.sers.iter().map(|s| s.pos[2]).fold(f32::MIN, f32::max); let z_malla = rep .mallas .iter() .map(|(m, _, _)| m.to_cols_array()[14]) .fold(f32::MAX, f32::min); assert!(z_sdf < z_malla, "SDF hasta z={z_sdf}, malla desde z={z_malla}"); } #[test] fn el_reparto_ordena_por_cercania_no_por_indice() { let specs = bandada(3); let mut cuadro = en_cuadro(&specs, 20.0); // El ÚLTIMO de la lista es el que está pegado al ojo. cuadro[2].pos = Vec3::new(0.0, 0.0, -1.0); let ojo = Vec3::new(0.0, 1.6, -3.0); let sers = reparto_sdf(&cuadro, ojo, true); assert!((sers[0].pos[2] - (-1.0)).abs() < 1e-3, "el más cercano va primero"); } #[test] fn ley_ecuacion_compila_y_corre() { use crate::ecuacion::FieldEngine; // Autoría de una ley Gray–Scott con los tipos del studio (como haría la UI). let ley = LeyKind::Ecuacion { campos: vec![ FieldDef::new("u", 1.0, 0.0, 1.0), FieldDef::new("v", 0.0, 0.0, 1.0), ], params: vec![ ParamDef::new("Du", 0.16, 0.0, 1.0), ParamDef::new("Dv", 0.08, 0.0, 1.0), ParamDef::new("F", 0.06, 0.0, 0.1), ParamDef::new("k", 0.062, 0.0, 0.1), ], fuentes: vec![ "Du * lap(u) - u * v * v + F * (1 - u)".into(), "Dv * lap(v) + u * v * v - (F + k) * v".into(), ], }; let prog = ley.compile_ecuacion().expect("es Ecuacion").expect("compila"); let LeyKind::Ecuacion { campos, .. } = &ley else { unreachable!() }; let mut eng = FieldEngine::new([32, 1, 32], campos.clone()); for z in 14..18 { for x in 14..18 { eng.set(1, x, 0, z, 0.5); eng.set(0, x, 0, z, 0.25); } } let params = ley.ecuacion_param_values(); for _ in 0..300 { eng.step(&prog, ¶ms, 1.0); } let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY); for z in 0..32 { for x in 0..32 { let v = eng.get(1, x, 0, z); lo = lo.min(v); hi = hi.max(v); } } assert!(hi - lo > 0.05, "la ley autorada formó patrón (rango={})", hi - lo); } #[test] fn ley_ecuacion_error_de_parseo_ubicado() { let ley = LeyKind::Ecuacion { campos: vec![FieldDef::new("c", 0.0, 0.0, 1.0)], params: vec![], fuentes: vec!["k * lap(c)".into()], // `k` no declarado }; let err = ley.compile_ecuacion().unwrap().unwrap_err(); assert!(err.contains("campo «c»"), "ubica el campo: {err}"); assert!(err.contains('k'), "menciona el símbolo desconocido: {err}"); } #[test] fn proyecto_round_trip_ron() { let p = Project::starter(); let s = ron::ser::to_string(&p).expect("serializa a ron"); let back: Project = ron::from_str(&s).expect("deserializa de ron"); assert_eq!(back.biomas.len(), p.biomas.len()); assert_eq!(back.mundos.len(), p.mundos.len()); assert_eq!(back.biomas[0].name, "desierto"); // El relieve del bioma sobrevive el viaje (un parámetro de muestra). assert!((back.biomas[0].base - p.biomas[0].base).abs() < 1e-6); // Y los ids siguen apuntando: la escena referencia un mundo que existe. assert!(back.mundo(back.escenas[0].mundo).is_some()); } #[test] fn mundo_desde_mapa_persiste_en_ron() { let mut p = Project::starter(); // Adjuntá una fuente de mapa a un mundo (como al importar en el studio). let mid = p.mundos[0].id; p.mundos[0].mapa = Some(MapaFuente { geojson: r#"{"type":"FeatureCollection","features":[]}"#.into(), pgm: Some(vec![1, 2, 3, 4]), dem_tif: None, rango_dem_m: 250.0, }); let s = ron::ser::to_string(&p).expect("serializa a ron"); let back: Project = ron::from_str(&s).expect("deserializa de ron"); let m = back.mundo(mid).expect("el mundo sigue ahí"); let fuente = m.mapa.as_ref().expect("la fuente del mapa sobrevive el viaje"); assert!(fuente.geojson.contains("FeatureCollection")); assert_eq!(fuente.pgm.as_deref(), Some(&[1, 2, 3, 4][..])); assert!((fuente.rango_dem_m - 250.0).abs() < 1e-6); } #[test] fn ron_viejo_sin_mapa_carga_como_procedural() { // Un mundo serializado ANTES de que existiera el campo `mapa` debe abrir // con `mapa: None` (compat hacia atrás vía `#[serde(default)]`). let ron_viejo = r#"(id:7,name:"viejo",seed:1,biomas:[])"#; let m: Mundo = ron::from_str(ron_viejo).expect("mundo legacy deserializa"); assert!(m.mapa.is_none(), "sin campo mapa = procedural"); } #[test] fn herencia_de_material_overridea_solo_lo_redefinido() { let mut p = Project::starter(); // «cactus amarillo» hijo de «cactus»: sólo redefine el color. let cactus = p.material_id_for(Material::Cactus).unwrap(); let verde = p.resolve_material(cactus); let hijo_id = p.alloc_id(); p.materiales.push(MaterialDef { id: hijo_id, name: "cactus amarillo".into(), parent: Some(cactus), role: MatRole::Objeto(Forma::Columnar), color: Some([0.9, 0.85, 0.2]), grain: None, // hereda brillo: None, rugosidad: None, bisel: None, leyes: vec![], builtin: None, }); let hijo = p.resolve_material(hijo_id); assert_ne!(hijo.color, verde.color, "el color se redefinió"); assert_eq!(hijo.grain, verde.grain, "el grano se heredó del padre"); } #[test] fn agua_fluye_por_su_ley_y_hereda_leyes() { let p = Project::starter(); // El agua semilla tiene la ley Fluir con sus params. let (g, h) = p.water_fluir().expect("el agua tiene ley Fluir"); assert!(g > 0.0 && h >= 0.0); // Un material sin leyes no fluye. let roca = p.material_id_for(Material::Rock).unwrap(); assert!(p.fluir_params(roca).is_none()); // El cactus crece (ley Crecer en el starter). let cactus = p.material_id_for(Material::Cactus).unwrap(); assert!(p.crecer_velocidad(cactus).is_some()); // Herencia de leyes: un hijo del agua hereda Fluir aunque no la redefina. let mut p2 = p.clone(); let agua = p2.material_id_for(Material::Water).unwrap(); let hijo = p2.alloc_id(); p2.materiales.push(MaterialDef { id: hijo, name: "agua turbia".into(), parent: Some(agua), role: MatRole::Terreno, color: Some([0.2, 0.4, 0.5]), grain: None, brillo: None, rugosidad: None, bisel: None, leyes: vec![], builtin: None, }); assert!(p2.fluir_params(hijo).is_some(), "el hijo hereda Fluir del padre"); } #[test] fn detalle_sub_voxel_hereda_y_overridea() { let mut p = Project::starter(); // Default = liso/duro (0,0) para un material sin detalle declarado. let roca = p.material_id_for(Material::Rock).unwrap(); assert_eq!(p.detalle_material(roca), (0.0, 0.0), "default = sin detalle"); // Padre con rugosidad; hijo sin nada → la hereda. Nieto redefine bisel. let padre = p.alloc_id(); p.materiales.push(MaterialDef { id: padre, name: "roca rugosa".into(), parent: None, role: MatRole::Terreno, color: None, grain: None, brillo: None, rugosidad: Some(0.9), bisel: None, leyes: vec![], builtin: None, }); let hijo = p.alloc_id(); p.materiales.push(MaterialDef { id: hijo, name: "hijo".into(), parent: Some(padre), role: MatRole::Terreno, color: None, grain: None, brillo: None, rugosidad: None, bisel: Some(0.7), leyes: vec![], builtin: None, }); let (rug, bis) = p.detalle_material(hijo); assert!((rug - 0.9).abs() < 1e-6, "rugosidad heredada del padre"); assert!((bis - 0.7).abs() < 1e-6, "bisel propio gana"); } #[test] fn detalle_por_voxel_se_hornea_por_material() { // Cierra la limitación HONESTA: dos materiales del bioma deben lucir distinto a // la vez → cada uno hornea su detalle POR VOXEL en el grid, vía el pipeline real // (resolve_material → bioma_palette → generate_window → grid.detail_at). let mut p = Project::starter(); let b0 = p.biomas[0].clone(); let (g_id, c_id) = (b0.ground, b0.cliff); assert_ne!(g_id, c_id, "el bioma starter distingue suelo de acantilado"); // Suelo RUGOSO+duro (rug alto, bisel 0); acantilado LISO (rug 0, bisel 1). let set_det = |p: &mut Project, id: u64, r: f32, b: f32| { let m = p.materiales.iter_mut().find(|m| m.id == id).unwrap(); m.rugosidad = Some(r); m.bisel = Some(b); }; set_det(&mut p, g_id, 1.4, 0.0); set_det(&mut p, c_id, 0.0, 1.0); // La paleta resuelta lleva el detalle POR MATERIAL (lo que el escalar global // no distinguía). let rg = p.resolve_material(g_id); let rc = p.resolve_material(c_id); assert_eq!(rg.detalle, (1.4, 0.0), "suelo rugoso/duro"); assert_eq!(rc.detalle, (0.0, 1.0), "acantilado liso/biselado"); assert_ne!(rg.detalle, rc.detalle, "dos materiales, dos looks"); // Y se hornea por voxel al generar la ventana: aparecen ambos códigos distintos // + aire sin autor (retrocompat). Byte de detalle = 1 + round(norm·254): el // suelo da rug alto (~238) y bisel piso (1); el acantilado, rug piso y bisel alto. let palette = p.bioma_palette(&b0); let grid = b0.generate_window(7, &palette, [40, 48, 40], [0, 0]); let (mut suelo, mut cliff, mut aire) = (false, false, false); let dim = grid.dim(); for z in 0..dim[2] { for y in 0..dim[1] { for x in 0..dim[0] { match grid.detail_at(x, y, z).unwrap() { [r, b] if r >= 200 && b <= 5 => suelo = true, [r, b] if r <= 5 && b >= 200 => cliff = true, [0, 0] => aire = true, _ => {} } } } } assert!(suelo, "el suelo rugoso se horneó por voxel"); assert!(cliff, "el acantilado liso/biselado se horneó por voxel (distinto del suelo)"); assert!(aire, "quedan voxels sin autor (aire/agua) en look global — retrocompat"); } #[test] fn herencia_con_ciclo_no_cuelga() { let mut p = Project::starter(); let a = p.alloc_id(); let b = p.alloc_id(); p.materiales.push(MaterialDef { id: a, name: "a".into(), parent: Some(b), role: MatRole::Terreno, color: None, grain: None, brillo: None, rugosidad: None, bisel: None, leyes: vec![], builtin: None }); p.materiales.push(MaterialDef { id: b, name: "b".into(), parent: Some(a), role: MatRole::Terreno, color: None, grain: None, brillo: None, rugosidad: None, bisel: None, leyes: vec![], builtin: None }); // No debe colgarse; cae al gris neutro. let r = p.resolve_material(a); assert_eq!(r.color, [150, 150, 150]); } #[test] fn charspec_se_materializa_con_la_edad() { let spec = CharSpec::new("nene", Age::Baby); let actor = spec.to_actor(Vec3::ZERO, 0.0); assert_eq!(actor.age, Age::Baby); } #[test] fn world_dim_minimo_48_de_alto() { assert_eq!(world_dim(64)[1], 48); // 64*0.4=25.6 → clamp a 48 assert_eq!(world_dim(192)[1], 76); } #[test] fn escena_round_trip_y_compila_a_guiones() { let dim = world_dim(128); let s = SceneSpec::walk_and_emote("demo", 0, 3, Clip::Wave, dim); // RON ida y vuelta. let txt = ron::ser::to_string(&s).expect("ron"); let back: SceneSpec = ron::from_str(&txt).expect("de-ron"); assert_eq!(back.actors.len(), 3); // Compila a guiones reproducibles: a mitad de la caminata el actor se movió. let scripts = back.scripts(); let start = scripts[0].sample(0.0); let mid = scripts[0].sample(1.3); assert!(mid.gx > start.gx, "el actor avanza en X: {} → {}", start.gx, mid.gx); } #[test] fn plano_vigente_corta_en_el_tiempo() { let dim = world_dim(128); let s = SceneSpec::walk_and_emote("demo", 0, 2, Clip::Wave, dim); // Arranca en establecedor; tras el giro (t≈3) pasa a primer plano. assert_eq!(s.active_shot(0.5), ShotKind::Establishing); assert_eq!(s.active_shot(3.5), ShotKind::CloseUp); // El plano resuelve una cámara que mira al centroide. let look = Vec3::new(10.0, 2.0, 10.0); let cam = ShotKind::CloseUp.resolve(look, 9.0, 1.0); assert_eq!(cam.target, look); assert!((cam.eye - look).length() > 1.0, "el ojo está separado del objetivo"); } #[test] fn beats_son_cortes_y_gestos() { let dim = world_dim(128); let s = SceneSpec::walk_and_emote("demo", 0, 2, Clip::Wave, dim); // walk_and_emote: corte de cámara a t_turn (3.0) + el gesto Wave a t_turn. // Caen en el mismo instante → se funden en un solo beat. let beats = s.beat_times(); assert!(!beats.is_empty(), "hay al menos un acento"); assert!(beats.iter().all(|&t| t >= 0.0 && t <= s.duration + 0.1)); assert!(beats.iter().any(|&t| (t - 3.0).abs() < 0.2), "acento cerca del gesto/corte"); } #[test] fn camara_en_mano_es_determinista_y_apagable() { // amt=0 → trípode: sin offset, exactamente cero. let (e0, t0) = handheld_shake(1.234, 0.0, 30.0); assert_eq!(e0, Vec3::ZERO); assert_eq!(t0, Vec3::ZERO); // amt>0 → tiembla (offset no nulo) y es función pura de t (reproducible). let (e1, _) = handheld_shake(1.234, 0.7, 30.0); let (e2, _) = handheld_shake(1.234, 0.7, 30.0); assert_eq!(e1, e2, "mismo t → mismo temblor (peli reproducible)"); assert!(e1.length() > 0.0, "con intensidad la cámara se mueve"); // Instantes distintos → temblor distinto (no está congelado). let (e3, _) = handheld_shake(1.235, 0.7, 30.0); assert!((e1 - e3).length() > 0.0, "el temblor evoluciona en el tiempo"); } #[test] fn frame_rate_del_heroe_viaja_al_guion() { let dim = world_dim(128); let s = SceneSpec::walk_and_emote("demo", 0, 3, Clip::Wave, dim); // El primer actor (Héroe) anima en doses; los demás, fluidos. assert_eq!(s.actors[0].frame_rate, Some(12)); assert_eq!(s.actors[1].frame_rate, None); // Y sobrevive la compilación a guion. assert_eq!(s.scripts()[0].frame_rate(), Some(12)); // Cámara en mano por defecto encendida. assert!(s.handheld > 0.0); } #[test] fn ventana_de_escena_sigue_al_reparto() { let dim = world_dim(128); // [128, 51, 128] → medio = 64 // Un actor que camina lejos del origen (más allá de la caja finita vieja): // un keyframe en grilla 18, otro en 600 → coords de MUNDO, no acotadas. let s = SceneSpec { distancia: None, id: 0, name: "caminata larga".into(), mundo: 0, duration: 10.0, actors: vec![ActorSpec { character: 0, keys: vec![ ActorKeySpec { t: 0.0, gx: 18.0, gz: 64.0, clip: None, face: None }, ActorKeySpec { t: 10.0, gx: 600.0, gz: 64.0, clip: None, face: None }, ], frame_rate: None, }], shots: vec![], handheld: 0.0, atmosfera: None, caida: None, sucesos: Vec::new(), utileria: Vec::new(), cielo: None, decorado: Vec::new(), jardin: None, }; let scripts = s.scripts(); // Al final el reparto está cerca de wx=600: la ventana lo centra (su columna // local cae cerca de dim/2), cosa imposible en una caja fija centrada en 0. let o_fin = window_origin_for_cast(&scripts, 10.0, dim); let local_x = 600 - o_fin[0]; assert!( (local_x - dim[0] as i32 / 2).abs() <= SCENE_WINDOW_STEP, "el reparto queda centrado en la ventana (local_x={local_x})" ); // El origen se snappea al paso (sólo salta a múltiplos) → no regenera por cuadro. assert_eq!(o_fin[0] % SCENE_WINDOW_STEP, 0); assert_eq!(o_fin[1] % SCENE_WINDOW_STEP, 0); // Y la ventana se MUEVE entre el principio y el final (mundo infinito, no caja). let o_ini = window_origin_for_cast(&scripts, 0.0, dim); assert_ne!(o_ini[0], o_fin[0], "la ventana siguió al reparto en X"); // Sin reparto: mundo centrado en el origen (compatibilidad con el preview fijo). assert_eq!(window_origin_for_cast(&[], 0.0, dim), [0, 0]); } #[test] fn starter_trae_escena_y_personajes() { let p = Project::starter(); assert_eq!(p.seres.len(), 3); assert_eq!(p.escenas.len(), 1); // La escena referencia un mundo válido por id. assert!(p.mundo(p.escenas[0].mundo).is_some()); // Los 30 materiales semilla están sembrados (flora + terreno + casa). assert_eq!(p.materiales.len(), 31); assert!(p.material_id_for(Material::Sand).is_some()); assert!(p.material_id_for(Material::Basalto).is_some()); assert!(p.material_id_for(Material::Ladrillo).is_some()); assert!(p.material_id_for(Material::Vidrio).is_some()); // El vidrio (y las rocas pulidas) resuelven brillo > 0; el ladrillo, mate. assert!(p.resolve_material(p.material_id_for(Material::Vidrio).unwrap()).brillo > 0.5); assert_eq!(p.resolve_material(p.material_id_for(Material::Ladrillo).unwrap()).brillo, 0.0); } }