//! **Interior**: una habitación amueblada — un piso (opcionalmente con muros) y una //! lista de [`Pieza`]s (un [`Mueble`] colocado en una celda con su orientación). Es //! el modelo-documento del *editor de interiores* del studio: se **construye a un //! [`VoxelGrid`]** con [`Interior::build_grid`] para previsualizarlo, y la UI agrega //! piezas al hacer click sobre el piso. use serde::{Deserialize, Serialize}; use crate::mueble::{stamp_mueble, Mueble}; use crate::worldgen::Material; use llimphi_3d::{PointLight, VoxelGrid, VOXEL_MAX_LIGHTS}; /// Un mueble colocado: qué [`Mueble`], en qué celda del piso `(gx, gz)` y con qué /// orientación (`facing` 0=+Z … 3=−X). #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct Pieza { pub mueble: Mueble, pub gx: u32, pub gz: u32, pub facing: u8, } /// **Tipo de techo** de la habitación: forma + material. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TechoTipo { /// Losa plana (cielorraso de hormigón). Plano, /// Dos aguas de tejas de barro. Tejas, /// Dos aguas de chapa de zinc (acanalada). Zinc, /// Dos aguas de pizarra oscura. Pizarra, /// Dos aguas de paja. Paja, } impl TechoTipo { pub const ALL: [TechoTipo; 5] = [TechoTipo::Plano, TechoTipo::Tejas, TechoTipo::Zinc, TechoTipo::Pizarra, TechoTipo::Paja]; /// Material con que se pinta. pub fn material(self) -> Material { match self { TechoTipo::Plano => Material::Hormigon, TechoTipo::Tejas => Material::Teja, TechoTipo::Zinc => Material::Acero, TechoTipo::Pizarra => Material::Pizarra, TechoTipo::Paja => Material::Paja, } } /// `true` si es una losa plana (los demás son a dos aguas). pub fn es_plano(self) -> bool { matches!(self, TechoTipo::Plano) } pub fn label(self) -> &'static str { match self { TechoTipo::Plano => "plano", TechoTipo::Tejas => "tejas", TechoTipo::Zinc => "zinc", TechoTipo::Pizarra => "pizarra", TechoTipo::Paja => "paja", } } pub fn next(self) -> TechoTipo { let i = TechoTipo::ALL.iter().position(|&t| t == self).unwrap_or(0); TechoTipo::ALL[(i + 1) % TechoTipo::ALL.len()] } } /// **Recorte tipo casa de muñecas**: qué muros (y el rincón de techo) ocultar para /// mirar la habitación por dentro. Se calcula del rumbo (`yaw`) de la cámara: se /// ocultan los muros del lado del que mira. (`oeste`=−X, `este`=+X, `norte`=−Z, `sur`=+Z.) #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Cutaway { pub oeste: bool, pub este: bool, pub norte: bool, pub sur: bool, } impl Cutaway { /// Sin recorte (habitación entera). pub fn none() -> Self { Self::default() } /// Recorte según el rumbo de cámara `yaw` (radianes): oculta los 1–2 muros que /// quedan entre la cámara y el interior. pub fn from_yaw(yaw: f32) -> Self { let (s, c) = (yaw.sin(), yaw.cos()); Self { este: s > 0.2, oeste: s < -0.2, sur: c > 0.2, norte: c < -0.2 } } /// Código estable `[0,15]` (para saber si cambió y regenerar el grid). pub fn code(self) -> u64 { (self.oeste as u64) | ((self.este as u64) << 1) | ((self.norte as u64) << 2) | ((self.sur as u64) << 3) } } /// Una habitación amueblable. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Interior { pub id: u64, pub name: String, /// Ancho del piso (eje X), en celdas. pub ancho: u32, /// Fondo del piso (eje Z), en celdas. pub fondo: u32, /// Material del piso. pub piso: Material, /// Material de los muros. pub muro: Material, /// Dibujar muros perimetrales. pub muros: bool, /// Abrir ventanas de vidrio en los muros (dejan entrar la luz del día). #[serde(default = "default_true")] pub ventanas: bool, /// Poner techo (con recorte casa de muñecas al previsualizar). #[serde(default = "default_true")] pub con_techo: bool, /// Tipo de techo. #[serde(default = "default_techo")] pub techo: TechoTipo, /// Muebles colocados. pub piezas: Vec, } fn default_true() -> bool { true } fn default_techo() -> TechoTipo { TechoTipo::Tejas } impl Interior { /// Grosor del piso (los muebles se apoyan en `y = FLOOR_H`). pub const FLOOR_H: u32 = 2; /// Alto de los muros. pub const WALL_H: u32 = 9; /// Un interior de arranque: sala vacía de baldosas con muros. pub fn nuevo(id: u64, name: impl Into) -> Self { Self { id, name: name.into(), ancho: 24, fondo: 20, piso: Material::Ceramica, muro: Material::Ladrillo, muros: true, ventanas: true, con_techo: true, techo: TechoTipo::Tejas, piezas: Vec::new(), } } /// `y` sobre el que se apoyan los muebles. pub fn floor_top(&self) -> u32 { Self::FLOOR_H } /// Agrega una pieza (recortando la celda al piso). Devuelve `false` si cae fuera. pub fn colocar(&mut self, mueble: Mueble, gx: u32, gz: u32, facing: u8) -> bool { if gx >= self.ancho || gz >= self.fondo { return false; } self.piezas.push(Pieza { mueble, gx, gz, facing }); true } /// **Luces puntuales** que emiten los muebles luminosos (lámparas, apliques, /// estufa): una [`PointLight`] cálida por pieza que ilumina, en coordenadas de /// grilla. Se recorta a [`VOXEL_MAX_LIGHTS`] (el motor sostiene unas pocas); /// las primeras piezas colocadas ganan. pub fn luces(&self) -> Vec { let mut out = Vec::new(); for p in &self.piezas { if p.gx >= self.ancho || p.gz >= self.fondo { continue; } if let Some((h, color, range)) = p.mueble.luz() { out.push(PointLight { pos: [p.gx as f32 + 0.5, Self::FLOOR_H as f32 + h, p.gz as f32 + 0.5], color, range, radius: 3.0, // fuente de área ancha → penumbra suave marcada }); if out.len() >= VOXEL_MAX_LIGHTS { break; } } } out } /// Construye el [`VoxelGrid`] de la habitación dentro de un volumen `dim`: piso de /// baldosas, muros con ventanas (si `muros`), techo del tipo elegido (si /// `con_techo`) y los muebles. El `cut` recorta muros y el rincón cercano del /// techo (casa de muñecas) para mirar por dentro; pasar [`Cutaway::none`] arma la /// habitación entera. El piso arranca en la esquina `(0, 0)` del volumen. pub fn build_grid(&self, dim: [u32; 3], cut: Cutaway) -> VoxelGrid { let mut g = VoxelGrid::new(dim); let ancho = self.ancho.min(dim[0]); let fondo = self.fondo.min(dim[2]); if ancho == 0 || fondo == 0 { return g; } // Piso (con leve damero para leer las baldosas). let alt = self.piso.next_shade(); for x in 0..ancho { for z in 0..fondo { let tile = ((x / 2 + z / 2) % 2) == 0; let (col, brillo) = if tile { (self.piso.color(), self.piso.brillo()) } else { alt }; for y in 0..Self::FLOOR_H.min(dim[1]) { let b = if y + 1 == Self::FLOOR_H { brillo } else { 0.0 }; g.set_rgba(x, y, z, col, VoxelGrid::shine_to_alpha(b)); } } } // Muros perimetrales, con ventanas de vidrio a media altura (dejan pasar la // luz del día). Cada lado se omite si el recorte casa-de-muñecas lo oculta. let top = (Self::FLOOR_H + Self::WALL_H).min(dim[1]); if self.muros { let mcol = self.muro.color(); let ma = VoxelGrid::shine_to_alpha(self.muro.brillo()); let vcol = Material::Vidrio.color(); let va = VoxelGrid::shine_to_alpha(Material::Vidrio.brillo()); let es_ventana = |along: u32, len: u32, y: u32| -> bool { let banda = y >= Self::FLOOR_H + 3 && y < Self::FLOOR_H + 6; let hueco = along >= 3 && along + 3 < len && (along % 7) < 3; self.ventanas && banda && hueco }; for y in Self::FLOOR_H..top { for x in 0..ancho { let (c, a) = if es_ventana(x, ancho, y) { (vcol, va) } else { (mcol, ma) }; if !cut.norte { g.set_rgba(x, y, 0, c, a); // muro −Z } if !cut.sur { g.set_rgba(x, y, fondo - 1, c, a); // muro +Z } } for z in 0..fondo { let (c, a) = if es_ventana(z, fondo, y) { (vcol, va) } else { (mcol, ma) }; if !cut.oeste { g.set_rgba(0, y, z, c, a); // muro −X } if !cut.este { g.set_rgba(ancho - 1, y, z, c, a); // muro +X } } } } // Techo (con el rincón cercano recortado por el cutaway). if self.con_techo { self.build_techo(&mut g, dim, ancho, fondo, top, cut); } // Muebles. for p in &self.piezas { if p.gx < ancho && p.gz < fondo { stamp_mueble(&mut g, p.mueble, p.gx, Self::FLOOR_H, p.gz, p.facing); } } g } /// Dibuja el techo (`self.techo`) sobre la planta, salvo el rincón que el /// `cut` oculta (para ver adentro). Plano = losa; los demás = dos aguas (la /// cumbrera corre por el eje más largo). Zinc va acanalado. fn build_techo(&self, g: &mut VoxelGrid, _dim: [u32; 3], ancho: u32, fondo: u32, top: u32, cut: Cutaway) { let rmat = self.techo.material(); let base = rmat.color(); let ra = VoxelGrid::shine_to_alpha(rmat.brillo()); // El rincón cercano del techo se oculta como los muros → se mira adentro. let oculto = |x: u32, z: u32| -> bool { (cut.este && x * 5 > ancho * 3) || (cut.oeste && x * 5 < ancho * 2) || (cut.sur && z * 5 > fondo * 3) || (cut.norte && z * 5 < fondo * 2) }; let ridge_x = ancho >= fondo; // cumbrera a lo largo del eje más largo let cap = 6u32; for x in 0..ancho { for z in 0..fondo { if oculto(x, z) { continue; } let slope = if self.techo.es_plano() { 0 } else if ridge_x { z.min(fondo - 1 - z).min(cap) } else { x.min(ancho - 1 - x).min(cap) }; let y = (top + slope).min(_dim[1] - 1); // Zinc: acanalado → franjas alternas más oscuras perpendiculares a la cumbrera. let mut col = base; if matches!(self.techo, TechoTipo::Zinc) { let stripe = if ridge_x { x } else { z }; if stripe % 2 == 0 { col = [ (base[0] as f32 * 0.82) as u8, (base[1] as f32 * 0.82) as u8, (base[2] as f32 * 0.82) as u8, ]; } } g.set_rgba(x, y, z, col, ra); } } } } /// Extensión interna: un tono alterno del material para el damero del piso. trait Shade { fn next_shade(self) -> ([u8; 3], f32); } impl Shade for Material { fn next_shade(self) -> ([u8; 3], f32) { let c = self.color(); let d = |v: u8| (v as f32 * 0.88) as u8; ([d(c[0]), d(c[1]), d(c[2])], self.brillo()) } } #[cfg(test)] mod tests { use super::*; fn solids(g: &VoxelGrid, dim: [u32; 3]) -> usize { let mut n = 0; for x in 0..dim[0] { for y in 0..dim[1] { for z in 0..dim[2] { if g.is_solid(x as i32, y as i32, z as i32) { n += 1; } } } } n } #[test] fn interior_vacio_tiene_piso_y_muros() { let dim = [40u32, 20, 36]; let it = Interior::nuevo(1, "sala"); let g = it.build_grid(dim, Cutaway::none()); // Piso completo + algún muro. assert!(solids(&g, dim) >= (it.ancho * it.fondo * Interior::FLOOR_H) as usize); // Hay muro por encima del piso en el borde. assert!(g.is_solid(0, Interior::FLOOR_H as i32 + 1, 0)); } #[test] fn las_lamparas_emiten_luz() { let mut it = Interior::nuevo(1, "sala"); assert!(it.luces().is_empty(), "sala vacía sin luces"); it.colocar(Mueble::Lampara, 6, 6, 0); it.colocar(Mueble::Mesa, 8, 8, 0); // no ilumina it.colocar(Mueble::Aplique, 1, 5, 0); let luces = it.luces(); assert_eq!(luces.len(), 2, "lámpara + aplique iluminan, la mesa no"); // La luz de la lámpara cae sobre su celda, a cierta altura. assert_eq!(luces[0].pos[0], 6.5); assert!(luces[0].pos[1] > Interior::FLOOR_H as f32); } #[test] fn las_ventanas_ponen_vidrio_en_los_muros() { let dim = [40u32, 24, 36]; let it = Interior::nuevo(1, "sala"); // ventanas = true por defecto let g = it.build_grid(dim, Cutaway::none()); // En la banda de ventana del muro del fondo hay vidrio (alpha lustroso < muro). let vidrio_alpha = VoxelGrid::shine_to_alpha(Material::Vidrio.brillo()); let mut hay_vidrio = false; for x in 0..it.ancho { for y in Interior::FLOOR_H..(Interior::FLOOR_H + 6) { if let Some(px) = g.get(x, y, 0) { if px[3] == vidrio_alpha { hay_vidrio = true; } } } } assert!(hay_vidrio, "el muro del fondo tiene celdas de vidrio (ventanas)"); // Sin ventanas, el muro es opaco (sin vidrio). let mut sin = Interior::nuevo(2, "sala"); sin.ventanas = false; let g2 = sin.build_grid(dim, Cutaway::none()); let mut hay_vidrio2 = false; for x in 0..sin.ancho { for y in Interior::FLOOR_H..(Interior::FLOOR_H + 6) { if let Some(px) = g2.get(x, y, 0) { if px[3] == vidrio_alpha { hay_vidrio2 = true; } } } } assert!(!hay_vidrio2, "sin ventanas el muro no tiene vidrio"); } #[test] fn el_techo_y_el_recorte_casa_de_munecas() { let dim = [40u32, 30, 36]; let it = Interior::nuevo(1, "sala"); // con_techo = true, techo = Tejas // Sin recorte hay techo por encima de los muros. let entera = it.build_grid(dim, Cutaway::none()); let top = Interior::FLOOR_H + Interior::WALL_H; let mut hay_techo = false; for x in 0..it.ancho { for z in 0..it.fondo { if entera.is_solid(x as i32, top as i32, z as i32) { hay_techo = true; } } } assert!(hay_techo, "hay techo por encima de los muros"); // El recorte oculta el muro del lado indicado (para ver adentro). let cut = Cutaway { este: true, sur: true, ..Cutaway::none() }; let recortada = it.build_grid(dim, cut); // El muro +X (este) ya no está a media altura. assert!( !recortada.is_solid((it.ancho - 1) as i32, (Interior::FLOOR_H + 2) as i32, 5), "el muro este quedó recortado" ); // El muro −X (oeste, no recortado) sigue. assert!(recortada.is_solid(0, (Interior::FLOOR_H + 2) as i32, 5), "el muro oeste sigue"); } #[test] fn colocar_agrega_mueble_visible() { let dim = [40u32, 20, 36]; let mut it = Interior::nuevo(1, "sala"); it.muros = false; let vacio = solids(&it.build_grid(dim, Cutaway::none()), dim); assert!(it.colocar(Mueble::Mesa, 10, 8, 0)); let con = solids(&it.build_grid(dim, Cutaway::none()), dim); assert!(con > vacio, "la mesa sumó voxels ({vacio} → {con})"); // Fuera del piso: rechazado. assert!(!it.colocar(Mueble::Silla, 999, 8, 0)); } }