tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-voxel/src/interior.rs
Historia
Quién tocó qué
crudo
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}