gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-voxel-studio/src/lienzo.rs Rust · 512 siq'ikuna · 26.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1use super::*;
2
3/// Canvas 3D: terreno del artefacto + (en escenas) los actores posados.
4pub(crate) fn canvas_3d(model: &Model) -> View<Msg> {
5    let (yaw, pitch, dist, gen) = (model.yaw, model.pitch, model.dist, model.gen);
6    let preview = model.preview.clone();
7    // Dirigiendo (Escenas + pestaña Reparto): el click coloca la clave seleccionada.
8    let directing = model.level == Level::Escenas && model.tool_tab == 1 && sel_scene(model).is_some();
9    let preview_click = model.preview.clone();
10    let preview_click_int = model.preview.clone();
11    let interior_sel = model.project.interiores.iter().find(|x| x.id == model.sel[6]).cloned();
12    let is_interiores = model.level == Level::Interiores;
13    let mr = model.preview_render();
14    // Detalle sub-voxel del preview 3D (Biomas/Mundos): la rugosidad y el bisel finos
15    // ya viajan **horneados POR VOXEL** en el grid (cada material del bioma con el suyo,
16    // ver `ResolvedMaterial::detalle`/`generate_window`) → dos materiales lucen distinto
17    // a la vez, lo que el look global no podía. Aquí sólo derivamos el **bisel GLOBAL** =
18    // el mayor bisel del bioma, que enciende el trazado de isosuperficie (requisito para
19    // que el bisel por voxel module la normal). La rugosidad global va en 0 (el por-voxel
20    // manda; los voxels sin detalle —agua, minerales— quedan lisos, que es lo correcto).
21    // El nivel Materiales usa su propio swatch 3D (single-material, look global).
22    let bisel_global = {
23        let p = &mr.palette;
24        let pk = p.peak.map(|m| m.detalle.1).unwrap_or(0.0);
25        p.ground.detalle.1.max(p.cliff.detalle.1).max(pk)
26    };
27    // Silueta sub-voxel POR MATERIAL: prende el contorno diferenciado (uno duro/blocky,
28    // otro liso/redondeado en el mismo frame) sólo si el bioma tiene bisel CON CONTRASTE
29    // entre terreno/acantilado/cumbre (si son todos iguales, no aporta y cuesta samplear
30    // el detalle vecino por evaluación del campo). El fauna/flora aporta su propio bisel
31    // por voxel, pero el contraste del terreno es el que domina la silueta visible.
32    let silueta_pv = {
33        let p = &mr.palette;
34        let g = p.ground.detalle.1;
35        let c = p.cliff.detalle.1;
36        let pk = p.peak.map(|m| m.detalle.1).unwrap_or(g);
37        bisel_global > 0.01 && ((g - c).abs() > 0.01 || (g - pk).abs() > 0.01 || (c - pk).abs() > 0.01)
38    };
39    // Mundo desde mapa real: sólo pisa el preview en el nivel Mundos (Escenas usa
40    // ventana móvil; Biomas/Seres/Interiores tienen su propio grid). `Arc` clonado.
41    let mapa_override = if model.level == Level::Mundos { model.mapa.clone() } else { None };
42    let simulating = model.simulating;
43    let agua = mr.palette.agua;
44    // El agua fluye SÓLO si su material tiene la ley Fluir (con sus params).
45    let fluir = model.project.water_fluir();
46    // Y las plantas (objetos) crecen si su material tiene la ley Crecer.
47    let crecer = mr.bioma.objetos.iter().find_map(|o| {
48        model
49            .project
50            .crecer_velocidad(o.material)
51            .map(|v| (model.project.resolve_material(o.material).color, v))
52    });
53    // Ley autorada (Ecuacion): el primer material de la escena que la tenga corre sobre
54    // sus celdas (recoloreándolas por el campo). `(color, tol, campos, programa, params, vis)`.
55    let ecuacion = {
56        let b = &mr.bioma;
57        let mut ids = vec![b.ground, b.cliff];
58        if let Some(p) = b.peak {
59            ids.push(p);
60        }
61        ids.extend(b.objetos.iter().map(|o| o.material));
62        ids.into_iter().find_map(|id| {
63            model.project.ecuacion_de_material(id).map(|(campos, prog, params)| {
64                let color = model.project.resolve_material(id).color;
65                let vis = campos.len().saturating_sub(1);
66                (color, campos, prog, params, vis)
67            })
68        })
69    };
70    // Pobladores del bioma: cada SereUso → unos cuantos habitantes (por probabilidad).
71    let pobladores: Vec<(CharSpec, usize)> = mr
72        .bioma
73        .seres
74        .iter()
75        .filter_map(|su| {
76            model
77                .project
78                .sere(su.sere)
79                .map(|cs| (cs.clone(), ((su.probabilidad.clamp(0.0, 1.0) * 6.0).round() as usize).max(1)))
80        })
81        .collect();
82    let absolute = Style {
83        position: Position::Absolute,
84        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
85        ..Default::default()
86    };
87
88    let mut canvas = match model.level {
89        Level::Escenas => {
90            let scene = sel_scene(model).cloned();
91            let script_cam = model.script_cam;
92            // Recorrido a dibujar en 3D mientras se dirige (pestaña Reparto): los
93            // waypoints del actor seleccionado + cuál está seleccionado.
94            let path: Option<(Vec<(f32, f32)>, usize)> = if model.tool_tab == 1 {
95                scene.as_ref().and_then(|s| s.actors.get(model.actor_sel)).map(|act| {
96                    (act.keys.iter().map(|k| (k.gx, k.gz)).collect(), model.key_sel)
97                })
98            } else {
99                None
100            };
101            let script_cam = if path.is_some() { false } else { script_cam }; // órbita para dirigir
102            let scripts: Vec<ActorScript> = scene.as_ref().map(|s| s.scripts()).unwrap_or_default();
103            let puesta = scene.as_ref().map(|s| s.puesta()).unwrap_or_default();
104            let chars: Vec<CharSpec> = scene
105                .as_ref()
106                .map(|s| s.actors.iter().map(|a| model.project.character_or_default(a.character)).collect())
107                .unwrap_or_default();
108            let time = model.time;
109            let jugando = model.jugando;
110            let jugador = model.jugador.clone();
111            let input_juego = model.input_juego;
112            let volar = model.volar;
113            let respawn_jugador = model.respawn_jugador.clone();
114            View::new(absolute).gpu_paint_with(move |device, queue, encoder, target, rect, vp| {
115                let dim = world_dim(PREVIEW_DIM_XZ);
116                let mut guard = preview.lock().unwrap();
117                let p = guard.get_or_insert_with(|| WorldPreview::build(device, queue, &mr, dim, gen, None));
118                // Al dirigir, ventana fija en el origen → la columna de grilla coincide
119                // con la de mundo (el picking del click queda directo).
120                let origin = if directing { [0, 0] } else { window_origin_for_cast(&scripts, time, dim) };
121                p.ensure_window_con(device, queue, &mr, gen, origin, &puesta);
122                let half = Vec3::new(dim[0] as f32, dim[1] as f32, dim[2] as f32) * 0.5;
123                let mut poses = Vec::with_capacity(scripts.len());
124                let mut centroid = Vec3::ZERO;
125                for (script, ch) in scripts.iter().zip(&chars) {
126                    let at = script.quantize(time);
127                    let s = script.sample(at);
128                    let pos = p.pie_en_mundo(s.gx, s.gz) - half;
129                    centroid += pos;
130                    poses.push((pos, s, ch, at));
131                }
132                let look = if poses.is_empty() {
133                    orbit_center(dim) - half
134                } else {
135                    centroid / poses.len() as f32 + Vec3::new(0.0, 1.0, 0.0)
136                };
137                let cast_d = 6.0 + poses.len() as f32 * 1.2;
138                let scene_dist = (dist * 0.18).clamp(10.0, 70.0);
139                let camera = if jugando {
140                    // Primera persona: reposar al entrar, luego avanzar la física
141                    // (caminar o volar) desde las teclas + el rumbo del drag.
142                    if std::mem::take(&mut *respawn_jugador.lock().unwrap()) {
143                        *jugador.lock().unwrap() = p.spawn_jugador();
144                    }
145                    let mut wish = Vec3::ZERO;
146                    if input_juego.fwd {
147                        wish += forward_h(yaw);
148                    }
149                    if input_juego.back {
150                        wish -= forward_h(yaw);
151                    }
152                    if input_juego.right {
153                        wish += right_h(yaw);
154                    }
155                    if input_juego.left {
156                        wish -= right_h(yaw);
157                    }
158                    if volar {
159                        if input_juego.arriba {
160                            wish += Vec3::Y;
161                        }
162                        if input_juego.abajo {
163                            wish -= Vec3::Y;
164                        }
165                    }
166                    let eye = {
167                        let mut j = jugador.lock().unwrap();
168                        p.step_jugador(&mut j, wish, input_juego.salto, 1.0 / 30.0, volar)
169                    };
170                    Camera3d::fly(eye, yaw, pitch)
171                } else {
172                    match (script_cam, &scene) {
173                        (true, Some(sc)) => sc.camera_at(look, cast_d, time),
174                        _ => Camera3d::orbit(look, yaw, pitch, scene_dist),
175                    }
176                };
177                // Guardar la cámara para resolver clicks → suelo (plano a la altura de pies).
178                if directing && rect.h > 0.0 {
179                    let inv_vp = camera.view_proj(rect.w / rect.h).inverse();
180                    p.set_pick(inv_vp, camera.eye, look.y - 1.0);
181                }
182                let mut metas = Vec::with_capacity(poses.len());
183                let mut sers = Vec::new();
184                for (pos, s, ch, at) in &poses {
185                    if ch.es_sdf() {
186                        // Humanoide o bicho → SER SDF analítico (toon + cara por decals), posado
187                        // en coords de GRILLA (el `pos` de la escena está centrado).
188                        let g = *pos + half;
189                        sers.push(ch.to_sdf(s.clip, *at).posado([g.x, g.y, g.z], s.facing));
190                    } else {
191                        // Rig (cuadrúpedo/ave/serpiente) → malla de su andar.
192                        metas.push(ch.to_meta(*pos, s.facing, s.clip, *at, Some(camera.eye)));
193                    }
194                }
195                // La utilería de la escena (estrella, flor…), en el mundo o en la mano
196                // de su actor: la misma que filma `ende`.
197                if let Some(sc) = &scene {
198                    let en_mano: Vec<(Vec3, f32, Vec3)> =
199                        poses.iter().map(|(pos, s, ch, at)| (*pos + half, s.facing, ch.mano(s.clip, *at))).collect();
200                    sers.extend(sc.utileria_en(time, &en_mano));
201                    p.set_luces(sc.luces_en(time, &en_mano));
202                } else {
203                    p.set_luces(Vec::new());
204                }
205                // Recorrido: un marcador (cubo) por waypoint del actor dirigido; el
206                // seleccionado, más grande y dorado.
207                if let Some((keys, sel)) = &path {
208                    for (i, (gx, gz)) in keys.iter().enumerate() {
209                        let pos = p.ground_at_world(*gx as i32, *gz as i32) - half + Vec3::new(0.0, 1.4, 0.0);
210                        let (color, sz) = if i == *sel { ([0.96, 0.84, 0.20], 0.8) } else { ([0.55, 0.58, 0.66], 0.5) };
211                        let (mut mv, mut mi) = (Vec::new(), Vec::new());
212                        push_cube(&mut mv, &mut mi, Mat4::from_translation(pos) * Mat4::from_scale(Vec3::splat(sz)), color);
213                        metas.push((Mat4::IDENTITY, mv, mi));
214                    }
215                }
216                // Atmósfera de apertura (bruma + fundido desde negro): cada cuadro,
217                // después de `ensure_window` (que resetea la niebla) y antes de pintar.
218                // Marcadores de los SUCESOS dictados (siempre visibles: se dirige
219                // viéndolos). Verde = aparece un ser, azul = cámara, amarillo = marca.
220                if let Some(sc) = &scene {
221                    for s in &sc.sucesos {
222                        let pos = Vec3::new(s.pos[0], s.pos[1], s.pos[2]) - half
223                            + Vec3::new(0.0, 1.2, 0.0);
224                        let color = match s.tipo {
225                            TipoSuceso::Aparece(_) => [0.35, 0.85, 0.45],
226                            TipoSuceso::Camara => [0.40, 0.65, 0.95],
227                            TipoSuceso::Marca => [0.95, 0.82, 0.30],
228                            // Naranja ceniza: acá el mundo se cae a pedazos.
229                            TipoSuceso::Desmorona { .. } => [0.92, 0.45, 0.20],
230                        };
231                        let (mut mv, mut mi) = (Vec::new(), Vec::new());
232                        push_cube(
233                            &mut mv,
234                            &mut mi,
235                            Mat4::from_translation(pos) * Mat4::from_scale(Vec3::splat(0.6)),
236                            color,
237                        );
238                        metas.push((Mat4::IDENTITY, mv, mi));
239                    }
240                }
241                // El cielo de noche (o la vuelta al día): antes de la apertura, que gana.
242                p.set_cielo(scene.as_ref().and_then(|s| s.cielo.as_ref()));
243                if !jugando {
244                    if let Some(atm) = scene.as_ref().and_then(|s| s.atmosfera) {
245                        p.set_atmosfera_escena(&atm, time);
246                    }
247                }
248                // Las flores de los canteros, como seres: lo mismo que filma `ende`.
249                let mira = (camera.target - camera.eye).normalize_or_zero();
250                llimphi_voxel::sumar_macizos(&mut sers, p.macizos(), (camera.eye + half).into(), mira.into());
251                p.render_scene(device, queue, encoder, target, vp, (rect.x, rect.y, rect.w, rect.h), &camera, &metas, sers);
252            })
253        }
254        Level::Seres => {
255            let sere = sel_sere(model).cloned();
256            let time = model.time;
257            let manada = model.manada;
258            let _andar_estado = model.andar_estado;
259            // Previsualizar el estado de andar que se está editando.
260            let estado_clip = match model.andar_estado {
261                0 => Clip::Idle,
262                1 => Clip::Walk,
263                _ => Clip::Run,
264            };
265            View::new(absolute).gpu_paint_with(move |device, queue, encoder, target, rect, vp| {
266                let dim = world_dim(PREVIEW_DIM_XZ);
267                let mut guard = preview.lock().unwrap();
268                let p = guard.get_or_insert_with(|| WorldPreview::build(device, queue, &mr, dim, gen, None));
269                p.rebuild_if(device, queue, &mr, dim, gen, None);
270                let half = Vec3::new(dim[0] as f32, dim[1] as f32, dim[2] as f32) * 0.5;
271                match (&sere, manada) {
272                    // Manada viva: una bandada del ser deambula/se junta por su conducta.
273                    (Some(cs), true) => {
274                        p.ensure_manada(&[(cs.clone(), 9)]);
275                        let look = p.ground_at(dim[0] / 2, dim[2] / 2) - half + Vec3::new(0.0, 1.0, 0.0);
276                        let camera = Camera3d::orbit(look, yaw, pitch, 32.0);
277                        // La bandada mira a cámara (ojo en coords de GRILLA: los seres
278                        // SDF viven en [0,dim], la cámara en centrado).
279                        let (metas, sers) = p.manada_metas(1.0 / 30.0, None, Some(camera.eye + half));
280                        p.render_scene(device, queue, encoder, target, vp, (rect.x, rect.y, rect.w, rect.h), &camera, &metas, sers);
281                    }
282                    // Turntable de un solo ser.
283                    other => {
284                        p.clear_manada();
285                        // Humanoide → **SER SDF analítico** (toon + cara por decals): el
286                        // mismo cuerpo que rinde una escena, parado sobre el terreno del
287                        // preview, girando en turntable. Reemplaza al volumen voxel
288                        // redondeado (`to_voxel` + show_grid), que queda de LOD lejano.
289                        let pos = p.ground_at(dim[0] / 2, dim[2] / 2); // grilla (ser SDF)
290                        let pos_c = pos - half; // centrado (mallas + camara)
291                        let look = pos_c + Vec3::new(0.0, 1.0, 0.0);
292                        // Primer plano para el humanoide (~1.8 de alto): editar la cara
293                        // exige verla. El rig conserva el rango amplio clásico.
294                        let es_humanoide = matches!(other.0, Some(cs) if cs.cuerpo.is_none() && cs.bicho.is_none());
295                        let cam_dist = if es_humanoide {
296                            (dist * 0.06).clamp(2.4, 5.0)
297                        } else {
298                            (dist * 0.06).clamp(3.5, 14.0)
299                        };
300                        let camera = Camera3d::orbit(look, yaw, pitch, cam_dist);
301                        let (metas, sers) = match other.0 {
302                            Some(cs) if cs.es_sdf() => (
303                                Vec::new(),
304                                // Vivo y MIRANDO A CÁMARA: parpadea, la boca sigue al
305                                // clip y la cabeza te busca mientras el turntable gira
306                                // (editar una cara exige que la cara te mire).
307                                vec![cs.to_sdf_posado(
308                                    estado_clip,
309                                    time,
310                                    pos,
311                                    time * 0.6,
312                                    Some(camera.eye + half),
313                                )],
314                            ),
315                            // Rig (cuadrúpedo/ave/serpiente) → malla propia (clásico).
316                            Some(cs) => (
317                                vec![cs.to_meta(pos_c, time * 0.6, estado_clip, time, None)],
318                                Vec::new(),
319                            ),
320                            None => (Vec::new(), Vec::new()),
321                        };
322                        p.render_scene(device, queue, encoder, target, vp, (rect.x, rect.y, rect.w, rect.h), &camera, &metas, sers);
323                    }
324                }
325            })
326        }
327        Level::Interiores => {
328            let interior = interior_sel;
329            View::new(absolute).gpu_paint_with(move |device, queue, encoder, target, rect, vp| {
330                let dim = world_dim(PREVIEW_DIM_XZ);
331                let mut guard = preview.lock().unwrap();
332                let p = guard.get_or_insert_with(|| WorldPreview::build(device, queue, &mr, dim, gen, None));
333                let (ancho, fondo) = interior.as_ref().map(|i| (i.ancho, i.fondo)).unwrap_or((24, 20));
334                // Recorte casa de muñecas: se ocultan los muros/techo del lado de la
335                // cámara para ver adentro. Cambiar el rumbo regenera el grid.
336                let cut = Cutaway::from_yaw(yaw);
337                let key = gen.wrapping_mul(16).wrapping_add(cut.code());
338                if let Some(it) = &interior {
339                    p.show_grid(device, queue, it.build_grid(dim, cut), dim, key);
340                }
341                let half = Vec3::new(dim[0] as f32, dim[1] as f32, dim[2] as f32) * 0.5;
342                let floor_top = Interior::FLOOR_H as f32;
343                let look = Vec3::new(ancho as f32 * 0.5, floor_top + 2.0, fondo as f32 * 0.5) - half;
344                let cam_dist = (dist * 0.18).clamp(14.0, 90.0);
345                let camera = Camera3d::orbit(look, yaw, pitch, cam_dist);
346                if rect.h > 0.0 {
347                    let inv_vp = camera.view_proj(rect.w / rect.h).inverse();
348                    p.set_pick(inv_vp, camera.eye, floor_top - half.y);
349                }
350                // Iluminación de interior: ambiente atenuado (cálido) + las lámparas
351                // encendidas como luces puntuales, para que sus charcos de luz se lean.
352                let luces = interior.as_ref().map(|i| i.luces()).unwrap_or_default();
353                p.set_interior_lighting(
354                    [0.55, 0.5, 0.42],
355                    Atmosphere {
356                        sky_zenith: [40, 46, 62],
357                        sky_horizon: [82, 86, 100],
358                        fog_density: 0.0,
359                        god_rays: 0.0,
360                    },
361                    luces,
362                );
363                p.render(device, queue, encoder, target, vp, (rect.x, rect.y, rect.w, rect.h), &camera);
364            })
365        }
366        // Mundos / Biomas: sólo el terreno, en órbita. Con «simular», corre la ley
367        // Fluir: el agua cae/esparce/cae por cornisas, paso por cuadro.
368        _ => View::new(absolute).gpu_paint_with(move |device, queue, encoder, target, rect, vp| {
369            let dim = world_dim(PREVIEW_DIM_XZ);
370            let ov = mapa_override.as_deref();
371            let mut guard = preview.lock().unwrap();
372            let p = guard.get_or_insert_with(|| WorldPreview::build(device, queue, &mr, dim, gen, ov));
373            p.rebuild_if(device, queue, &mr, dim, gen, ov);
374            p.set_detalle(0.0, 1.6, bisel_global);
375            p.set_silueta_por_voxel(silueta_pv);
376            if simulating {
377                if let Some((g, h)) = fluir {
378                    p.ensure_sim(agua, g, h);
379                    p.sim_step(queue, agua);
380                }
381                if let Some((col, vel)) = crecer {
382                    p.ensure_growth(queue, col, vel);
383                    p.growth_step(queue);
384                }
385                if let Some((color, campos, prog, params, vis)) = &ecuacion {
386                    p.ensure_ecuacion(*color, ECUACION_TOL, campos, *vis);
387                    p.ecuacion_step(queue, prog, params);
388                }
389                if !pobladores.is_empty() {
390                    p.ensure_manada(&pobladores);
391                }
392            } else {
393                p.clear_sim();
394                p.clear_growth();
395                p.clear_ecuacion();
396                p.clear_manada();
397            }
398            let camera = Camera3d::orbit(orbit_center(dim), yaw, pitch, dist);
399            // Con bandada, el mundo se ve vivo: se componen los habitantes caminando.
400            if simulating && p.tiene_manada() {
401                let half = Vec3::new(dim[0] as f32, dim[1] as f32, dim[2] as f32) * 0.5;
402                let (metas, sers) = p.manada_metas(1.0 / 30.0, None, Some(camera.eye + half));
403                p.render_scene(device, queue, encoder, target, vp, (rect.x, rect.y, rect.w, rect.h), &camera, &metas, sers);
404            } else {
405                p.render(device, queue, encoder, target, vp, (rect.x, rect.y, rect.w, rect.h), &camera);
406            }
407        }),
408    }
409    .draggable(|phase, dx, dy| match phase {
410        DragPhase::Move => Some(Msg::Orbit(dx, dy)),
411        DragPhase::End => None,
412    });
413
414    // Al dirigir, un click (sin arrastrar) coloca la clave seleccionada sobre el suelo.
415    if directing {
416        canvas = canvas.on_click_at(move |lx, ly, rw, rh| {
417            if rw <= 0.0 || rh <= 0.0 {
418                return None;
419            }
420            let ndc_x = lx / rw * 2.0 - 1.0;
421            let ndc_y = 1.0 - ly / rh * 2.0;
422            let guard = preview_click.lock().ok()?;
423            let (gx, gz) = guard.as_ref()?.pick_world(ndc_x, ndc_y)?;
424            Some(Msg::PlaceKeyAt(gx, gz))
425        });
426    }
427    // En el editor de interiores, un click coloca el mueble-pincel sobre el piso.
428    if is_interiores {
429        canvas = canvas.on_click_at(move |lx, ly, rw, rh| {
430            if rw <= 0.0 || rh <= 0.0 {
431                return None;
432            }
433            let ndc_x = lx / rw * 2.0 - 1.0;
434            let ndc_y = 1.0 - ly / rh * 2.0;
435            let guard = preview_click_int.lock().ok()?;
436            let (gx, gz) = guard.as_ref()?.pick_world(ndc_x, ndc_y)?;
437            Some(Msg::PlaceMuebleAt(gx, gz))
438        });
439    }
440    canvas
441}
442
443/// Centro de órbita: el medio del mundo, algo por debajo del tope.
444pub(crate) fn orbit_center(dim: [u32; 3]) -> Vec3 {
445    Vec3::new(dim[0] as f32 * 0.5, dim[1] as f32 * 0.32, dim[2] as f32 * 0.5)
446}
447
448// =============================================================================
449//  Editores por nivel (rail derecho)
450// =============================================================================
451
452/// Editor de interiores: parámetros de la sala (tab 0) y el mueble-pincel (tab 1).
453/// Las piezas se agregan haciendo click sobre el piso en el preview 3D.
454pub(crate) fn interior_editor(model: &Model, tab: usize) -> Vec<View<Msg>> {
455    let theme = &model.theme;
456    let sp = SliderPalette::from_theme(theme);
457    let btn = ButtonPalette::from_theme(theme);
458    let Some(it) = model.project.interiores.iter().find(|x| x.id == model.sel[6]) else {
459        return vec![section_title("sin interior — crea uno", theme)];
460    };
461    if tab == 0 {
462        let ancho = it.ancho as f32;
463        let fondo = it.fondo as f32;
464        vec![
465            section_title("SALA", theme),
466            slider_view("ancho", ancho, 6.0, 60.0, &sp, move |_p, dv| Some(Msg::SetInteriorAncho(ancho + dv))),
467            slider_view("fondo", fondo, 6.0, 60.0, &sp, move |_p, dv| Some(Msg::SetInteriorFondo(fondo + dv))),
468            spacer(4.0),
469            button_view(format!("piso: {}", it.piso.label()), &btn, Msg::CycleInteriorPiso),
470            spacer(3.0),
471            button_view(format!("muro: {}", it.muro.label()), &btn, Msg::CycleInteriorMuro),
472            spacer(3.0),
473            button_view(if it.muros { "muros: sí" } else { "muros: no" }, &btn, Msg::ToggleInteriorMuros),
474            button_view(if it.ventanas { "ventanas: sí" } else { "ventanas: no" }, &btn, Msg::ToggleInteriorVentanas),
475            spacer(3.0),
476            button_view(if it.con_techo { "techo: sí" } else { "techo: no" }, &btn, Msg::ToggleInteriorTecho),
477            spacer(3.0),
478            button_view(format!("tipo techo: {}", it.techo.label()), &btn, Msg::CycleInteriorTecho),
479            spacer(3.0),
480            spacer(8.0),
481            section_title("MUEBLES", theme),
482            body_text(format!("{} colocados", it.piezas.len()), theme.fg_muted, theme),
483            spacer(4.0),
484            button_view("- quitar último", &btn, Msg::RemoveLastPieza),
485            spacer(3.0),
486            button_view("vaciar", &btn, Msg::ClearPiezas),
487        ]
488    } else {
489        let rumbo = ["norte", "este", "sur", "oeste"][(model.mueble_facing & 3) as usize];
490        vec![
491            section_title("MUEBLE (PINCEL)", theme),
492            button_view(format!("mueble: {}", model.mueble_brush.label()), &btn, Msg::CycleMuebleBrush),
493            spacer(3.0),
494            button_view(format!("orientación: {rumbo}"), &btn, Msg::CycleMuebleFacing),
495            spacer(8.0),
496            body_text("click en el piso para colocar el mueble".into(), theme.fg_placeholder, theme),
497        ]
498    }
499}
500
501pub(crate) fn editor(model: &Model, tab: usize) -> Vec<View<Msg>> {
502    match model.level {
503        Level::Leyes => ley_editor(model),
504        Level::Materiales => material_editor(model, tab),
505        Level::Seres => sere_editor(model, tab),
506        Level::Biomas => bioma_editor(model, tab),
507        Level::Mundos => mundo_editor(model, tab),
508        Level::Escenas => scene_editor(model, tab),
509        Level::Interiores => interior_editor(model, tab),
510    }
511}
512