gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-voxel/src/interior.rs Rust · 454 siq'ikuna · 16.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! **Interior**: una habitación amueblada — un piso (opcionalmente con muros) y una
2//! lista de [`Pieza`]s (un [`Mueble`] colocado en una celda con su orientación). Es
3//! el modelo-documento del *editor de interiores* del studio: se **construye a un
4//! [`VoxelGrid`]** con [`Interior::build_grid`] para previsualizarlo, y la UI agrega
5//! piezas al hacer click sobre el piso.
6
7use serde::{Deserialize, Serialize};
8
9use crate::mueble::{stamp_mueble, Mueble};
10use crate::worldgen::Material;
11use llimphi_3d::{PointLight, VoxelGrid, VOXEL_MAX_LIGHTS};
12
13/// Un mueble colocado: qué [`Mueble`], en qué celda del piso `(gx, gz)` y con qué
14/// orientación (`facing` 0=+Z … 3=−X).
15#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
16pub struct Pieza {
17    pub mueble: Mueble,
18    pub gx: u32,
19    pub gz: u32,
20    pub facing: u8,
21}
22
23/// **Tipo de techo** de la habitación: forma + material.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum TechoTipo {
26    /// Losa plana (cielorraso de hormigón).
27    Plano,
28    /// Dos aguas de tejas de barro.
29    Tejas,
30    /// Dos aguas de chapa de zinc (acanalada).
31    Zinc,
32    /// Dos aguas de pizarra oscura.
33    Pizarra,
34    /// Dos aguas de paja.
35    Paja,
36}
37
38impl TechoTipo {
39    pub const ALL: [TechoTipo; 5] =
40        [TechoTipo::Plano, TechoTipo::Tejas, TechoTipo::Zinc, TechoTipo::Pizarra, TechoTipo::Paja];
41
42    /// Material con que se pinta.
43    pub fn material(self) -> Material {
44        match self {
45            TechoTipo::Plano => Material::Hormigon,
46            TechoTipo::Tejas => Material::Teja,
47            TechoTipo::Zinc => Material::Acero,
48            TechoTipo::Pizarra => Material::Pizarra,
49            TechoTipo::Paja => Material::Paja,
50        }
51    }
52
53    /// `true` si es una losa plana (los demás son a dos aguas).
54    pub fn es_plano(self) -> bool {
55        matches!(self, TechoTipo::Plano)
56    }
57
58    pub fn label(self) -> &'static str {
59        match self {
60            TechoTipo::Plano => "plano",
61            TechoTipo::Tejas => "tejas",
62            TechoTipo::Zinc => "zinc",
63            TechoTipo::Pizarra => "pizarra",
64            TechoTipo::Paja => "paja",
65        }
66    }
67
68    pub fn next(self) -> TechoTipo {
69        let i = TechoTipo::ALL.iter().position(|&t| t == self).unwrap_or(0);
70        TechoTipo::ALL[(i + 1) % TechoTipo::ALL.len()]
71    }
72}
73
74/// **Recorte tipo casa de muñecas**: qué muros (y el rincón de techo) ocultar para
75/// mirar la habitación por dentro. Se calcula del rumbo (`yaw`) de la cámara: se
76/// ocultan los muros del lado del que mira. (`oeste`=−X, `este`=+X, `norte`=−Z, `sur`=+Z.)
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub struct Cutaway {
79    pub oeste: bool,
80    pub este: bool,
81    pub norte: bool,
82    pub sur: bool,
83}
84
85impl Cutaway {
86    /// Sin recorte (habitación entera).
87    pub fn none() -> Self {
88        Self::default()
89    }
90
91    /// Recorte según el rumbo de cámara `yaw` (radianes): oculta los 1–2 muros que
92    /// quedan entre la cámara y el interior.
93    pub fn from_yaw(yaw: f32) -> Self {
94        let (s, c) = (yaw.sin(), yaw.cos());
95        Self { este: s > 0.2, oeste: s < -0.2, sur: c > 0.2, norte: c < -0.2 }
96    }
97
98    /// Código estable `[0,15]` (para saber si cambió y regenerar el grid).
99    pub fn code(self) -> u64 {
100        (self.oeste as u64) | ((self.este as u64) << 1) | ((self.norte as u64) << 2) | ((self.sur as u64) << 3)
101    }
102}
103
104/// Una habitación amueblable.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct Interior {
107    pub id: u64,
108    pub name: String,
109    /// Ancho del piso (eje X), en celdas.
110    pub ancho: u32,
111    /// Fondo del piso (eje Z), en celdas.
112    pub fondo: u32,
113    /// Material del piso.
114    pub piso: Material,
115    /// Material de los muros.
116    pub muro: Material,
117    /// Dibujar muros perimetrales.
118    pub muros: bool,
119    /// Abrir ventanas de vidrio en los muros (dejan entrar la luz del día).
120    #[serde(default = "default_true")]
121    pub ventanas: bool,
122    /// Poner techo (con recorte casa de muñecas al previsualizar).
123    #[serde(default = "default_true")]
124    pub con_techo: bool,
125    /// Tipo de techo.
126    #[serde(default = "default_techo")]
127    pub techo: TechoTipo,
128    /// Muebles colocados.
129    pub piezas: Vec<Pieza>,
130}
131
132fn default_true() -> bool {
133    true
134}
135
136fn default_techo() -> TechoTipo {
137    TechoTipo::Tejas
138}
139
140impl Interior {
141    /// Grosor del piso (los muebles se apoyan en `y = FLOOR_H`).
142    pub const FLOOR_H: u32 = 2;
143    /// Alto de los muros.
144    pub const WALL_H: u32 = 9;
145
146    /// Un interior de arranque: sala vacía de baldosas con muros.
147    pub fn nuevo(id: u64, name: impl Into<String>) -> Self {
148        Self {
149            id,
150            name: name.into(),
151            ancho: 24,
152            fondo: 20,
153            piso: Material::Ceramica,
154            muro: Material::Ladrillo,
155            muros: true,
156            ventanas: true,
157            con_techo: true,
158            techo: TechoTipo::Tejas,
159            piezas: Vec::new(),
160        }
161    }
162
163    /// `y` sobre el que se apoyan los muebles.
164    pub fn floor_top(&self) -> u32 {
165        Self::FLOOR_H
166    }
167
168    /// Agrega una pieza (recortando la celda al piso). Devuelve `false` si cae fuera.
169    pub fn colocar(&mut self, mueble: Mueble, gx: u32, gz: u32, facing: u8) -> bool {
170        if gx >= self.ancho || gz >= self.fondo {
171            return false;
172        }
173        self.piezas.push(Pieza { mueble, gx, gz, facing });
174        true
175    }
176
177    /// **Luces puntuales** que emiten los muebles luminosos (lámparas, apliques,
178    /// estufa): una [`PointLight`] cálida por pieza que ilumina, en coordenadas de
179    /// grilla. Se recorta a [`VOXEL_MAX_LIGHTS`] (el motor sostiene unas pocas);
180    /// las primeras piezas colocadas ganan.
181    pub fn luces(&self) -> Vec<PointLight> {
182        let mut out = Vec::new();
183        for p in &self.piezas {
184            if p.gx >= self.ancho || p.gz >= self.fondo {
185                continue;
186            }
187            if let Some((h, color, range)) = p.mueble.luz() {
188                out.push(PointLight {
189                    pos: [p.gx as f32 + 0.5, Self::FLOOR_H as f32 + h, p.gz as f32 + 0.5],
190                    color,
191                    range,
192                    radius: 3.0, // fuente de área ancha → penumbra suave marcada
193                });
194                if out.len() >= VOXEL_MAX_LIGHTS {
195                    break;
196                }
197            }
198        }
199        out
200    }
201
202    /// Construye el [`VoxelGrid`] de la habitación dentro de un volumen `dim`: piso de
203    /// baldosas, muros con ventanas (si `muros`), techo del tipo elegido (si
204    /// `con_techo`) y los muebles. El `cut` recorta muros y el rincón cercano del
205    /// techo (casa de muñecas) para mirar por dentro; pasar [`Cutaway::none`] arma la
206    /// habitación entera. El piso arranca en la esquina `(0, 0)` del volumen.
207    pub fn build_grid(&self, dim: [u32; 3], cut: Cutaway) -> VoxelGrid {
208        let mut g = VoxelGrid::new(dim);
209        let ancho = self.ancho.min(dim[0]);
210        let fondo = self.fondo.min(dim[2]);
211        if ancho == 0 || fondo == 0 {
212            return g;
213        }
214
215        // Piso (con leve damero para leer las baldosas).
216        let alt = self.piso.next_shade();
217        for x in 0..ancho {
218            for z in 0..fondo {
219                let tile = ((x / 2 + z / 2) % 2) == 0;
220                let (col, brillo) = if tile { (self.piso.color(), self.piso.brillo()) } else { alt };
221                for y in 0..Self::FLOOR_H.min(dim[1]) {
222                    let b = if y + 1 == Self::FLOOR_H { brillo } else { 0.0 };
223                    g.set_rgba(x, y, z, col, VoxelGrid::shine_to_alpha(b));
224                }
225            }
226        }
227
228        // Muros perimetrales, con ventanas de vidrio a media altura (dejan pasar la
229        // luz del día). Cada lado se omite si el recorte casa-de-muñecas lo oculta.
230        let top = (Self::FLOOR_H + Self::WALL_H).min(dim[1]);
231        if self.muros {
232            let mcol = self.muro.color();
233            let ma = VoxelGrid::shine_to_alpha(self.muro.brillo());
234            let vcol = Material::Vidrio.color();
235            let va = VoxelGrid::shine_to_alpha(Material::Vidrio.brillo());
236            let es_ventana = |along: u32, len: u32, y: u32| -> bool {
237                let banda = y >= Self::FLOOR_H + 3 && y < Self::FLOOR_H + 6;
238                let hueco = along >= 3 && along + 3 < len && (along % 7) < 3;
239                self.ventanas && banda && hueco
240            };
241            for y in Self::FLOOR_H..top {
242                for x in 0..ancho {
243                    let (c, a) = if es_ventana(x, ancho, y) { (vcol, va) } else { (mcol, ma) };
244                    if !cut.norte {
245                        g.set_rgba(x, y, 0, c, a); // muro −Z
246                    }
247                    if !cut.sur {
248                        g.set_rgba(x, y, fondo - 1, c, a); // muro +Z
249                    }
250                }
251                for z in 0..fondo {
252                    let (c, a) = if es_ventana(z, fondo, y) { (vcol, va) } else { (mcol, ma) };
253                    if !cut.oeste {
254                        g.set_rgba(0, y, z, c, a); // muro −X
255                    }
256                    if !cut.este {
257                        g.set_rgba(ancho - 1, y, z, c, a); // muro +X
258                    }
259                }
260            }
261        }
262
263        // Techo (con el rincón cercano recortado por el cutaway).
264        if self.con_techo {
265            self.build_techo(&mut g, dim, ancho, fondo, top, cut);
266        }
267
268        // Muebles.
269        for p in &self.piezas {
270            if p.gx < ancho && p.gz < fondo {
271                stamp_mueble(&mut g, p.mueble, p.gx, Self::FLOOR_H, p.gz, p.facing);
272            }
273        }
274        g
275    }
276
277    /// Dibuja el techo (`self.techo`) sobre la planta, salvo el rincón que el
278    /// `cut` oculta (para ver adentro). Plano = losa; los demás = dos aguas (la
279    /// cumbrera corre por el eje más largo). Zinc va acanalado.
280    fn build_techo(&self, g: &mut VoxelGrid, _dim: [u32; 3], ancho: u32, fondo: u32, top: u32, cut: Cutaway) {
281        let rmat = self.techo.material();
282        let base = rmat.color();
283        let ra = VoxelGrid::shine_to_alpha(rmat.brillo());
284        // El rincón cercano del techo se oculta como los muros → se mira adentro.
285        let oculto = |x: u32, z: u32| -> bool {
286            (cut.este && x * 5 > ancho * 3)
287                || (cut.oeste && x * 5 < ancho * 2)
288                || (cut.sur && z * 5 > fondo * 3)
289                || (cut.norte && z * 5 < fondo * 2)
290        };
291        let ridge_x = ancho >= fondo; // cumbrera a lo largo del eje más largo
292        let cap = 6u32;
293        for x in 0..ancho {
294            for z in 0..fondo {
295                if oculto(x, z) {
296                    continue;
297                }
298                let slope = if self.techo.es_plano() {
299                    0
300                } else if ridge_x {
301                    z.min(fondo - 1 - z).min(cap)
302                } else {
303                    x.min(ancho - 1 - x).min(cap)
304                };
305                let y = (top + slope).min(_dim[1] - 1);
306                // Zinc: acanalado → franjas alternas más oscuras perpendiculares a la cumbrera.
307                let mut col = base;
308                if matches!(self.techo, TechoTipo::Zinc) {
309                    let stripe = if ridge_x { x } else { z };
310                    if stripe % 2 == 0 {
311                        col = [
312                            (base[0] as f32 * 0.82) as u8,
313                            (base[1] as f32 * 0.82) as u8,
314                            (base[2] as f32 * 0.82) as u8,
315                        ];
316                    }
317                }
318                g.set_rgba(x, y, z, col, ra);
319            }
320        }
321    }
322}
323
324/// Extensión interna: un tono alterno del material para el damero del piso.
325trait Shade {
326    fn next_shade(self) -> ([u8; 3], f32);
327}
328impl Shade for Material {
329    fn next_shade(self) -> ([u8; 3], f32) {
330        let c = self.color();
331        let d = |v: u8| (v as f32 * 0.88) as u8;
332        ([d(c[0]), d(c[1]), d(c[2])], self.brillo())
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn solids(g: &VoxelGrid, dim: [u32; 3]) -> usize {
341        let mut n = 0;
342        for x in 0..dim[0] {
343            for y in 0..dim[1] {
344                for z in 0..dim[2] {
345                    if g.is_solid(x as i32, y as i32, z as i32) {
346                        n += 1;
347                    }
348                }
349            }
350        }
351        n
352    }
353
354    #[test]
355    fn interior_vacio_tiene_piso_y_muros() {
356        let dim = [40u32, 20, 36];
357        let it = Interior::nuevo(1, "sala");
358        let g = it.build_grid(dim, Cutaway::none());
359        // Piso completo + algún muro.
360        assert!(solids(&g, dim) >= (it.ancho * it.fondo * Interior::FLOOR_H) as usize);
361        // Hay muro por encima del piso en el borde.
362        assert!(g.is_solid(0, Interior::FLOOR_H as i32 + 1, 0));
363    }
364
365    #[test]
366    fn las_lamparas_emiten_luz() {
367        let mut it = Interior::nuevo(1, "sala");
368        assert!(it.luces().is_empty(), "sala vacía sin luces");
369        it.colocar(Mueble::Lampara, 6, 6, 0);
370        it.colocar(Mueble::Mesa, 8, 8, 0); // no ilumina
371        it.colocar(Mueble::Aplique, 1, 5, 0);
372        let luces = it.luces();
373        assert_eq!(luces.len(), 2, "lámpara + aplique iluminan, la mesa no");
374        // La luz de la lámpara cae sobre su celda, a cierta altura.
375        assert_eq!(luces[0].pos[0], 6.5);
376        assert!(luces[0].pos[1] > Interior::FLOOR_H as f32);
377    }
378
379    #[test]
380    fn las_ventanas_ponen_vidrio_en_los_muros() {
381        let dim = [40u32, 24, 36];
382        let it = Interior::nuevo(1, "sala"); // ventanas = true por defecto
383        let g = it.build_grid(dim, Cutaway::none());
384        // En la banda de ventana del muro del fondo hay vidrio (alpha lustroso < muro).
385        let vidrio_alpha = VoxelGrid::shine_to_alpha(Material::Vidrio.brillo());
386        let mut hay_vidrio = false;
387        for x in 0..it.ancho {
388            for y in Interior::FLOOR_H..(Interior::FLOOR_H + 6) {
389                if let Some(px) = g.get(x, y, 0) {
390                    if px[3] == vidrio_alpha {
391                        hay_vidrio = true;
392                    }
393                }
394            }
395        }
396        assert!(hay_vidrio, "el muro del fondo tiene celdas de vidrio (ventanas)");
397        // Sin ventanas, el muro es opaco (sin vidrio).
398        let mut sin = Interior::nuevo(2, "sala");
399        sin.ventanas = false;
400        let g2 = sin.build_grid(dim, Cutaway::none());
401        let mut hay_vidrio2 = false;
402        for x in 0..sin.ancho {
403            for y in Interior::FLOOR_H..(Interior::FLOOR_H + 6) {
404                if let Some(px) = g2.get(x, y, 0) {
405                    if px[3] == vidrio_alpha {
406                        hay_vidrio2 = true;
407                    }
408                }
409            }
410        }
411        assert!(!hay_vidrio2, "sin ventanas el muro no tiene vidrio");
412    }
413
414    #[test]
415    fn el_techo_y_el_recorte_casa_de_munecas() {
416        let dim = [40u32, 30, 36];
417        let it = Interior::nuevo(1, "sala"); // con_techo = true, techo = Tejas
418        // Sin recorte hay techo por encima de los muros.
419        let entera = it.build_grid(dim, Cutaway::none());
420        let top = Interior::FLOOR_H + Interior::WALL_H;
421        let mut hay_techo = false;
422        for x in 0..it.ancho {
423            for z in 0..it.fondo {
424                if entera.is_solid(x as i32, top as i32, z as i32) {
425                    hay_techo = true;
426                }
427            }
428        }
429        assert!(hay_techo, "hay techo por encima de los muros");
430        // El recorte oculta el muro del lado indicado (para ver adentro).
431        let cut = Cutaway { este: true, sur: true, ..Cutaway::none() };
432        let recortada = it.build_grid(dim, cut);
433        // El muro +X (este) ya no está a media altura.
434        assert!(
435            !recortada.is_solid((it.ancho - 1) as i32, (Interior::FLOOR_H + 2) as i32, 5),
436            "el muro este quedó recortado"
437        );
438        // El muro −X (oeste, no recortado) sigue.
439        assert!(recortada.is_solid(0, (Interior::FLOOR_H + 2) as i32, 5), "el muro oeste sigue");
440    }
441
442    #[test]
443    fn colocar_agrega_mueble_visible() {
444        let dim = [40u32, 20, 36];
445        let mut it = Interior::nuevo(1, "sala");
446        it.muros = false;
447        let vacio = solids(&it.build_grid(dim, Cutaway::none()), dim);
448        assert!(it.colocar(Mueble::Mesa, 10, 8, 0));
449        let con = solids(&it.build_grid(dim, Cutaway::none()), dim);
450        assert!(con > vacio, "la mesa sumó voxels ({vacio} → {con})");
451        // Fuera del piso: rechazado.
452        assert!(!it.colocar(Mueble::Silla, 999, 8, 0));
453    }
454}