tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-voxel-studio/src/editores.rs Rust · 930 líneas · 43.3 KB Historia Quién tocó qué crudo

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

1use super::*;
2
3pub(crate) fn ley_editor(model: &Model) -> Vec<View<Msg>> {
4    let theme = &model.theme;
5    let sp = SliderPalette::from_theme(theme);
6    let btn = ButtonPalette::from_theme(theme);
7    let Some(l) = model.project.ley(model.sel[0]) else {
8        return vec![section_title("sin ley — crea una", theme)];
9    };
10    // El cabezal ("Parámetros") lo pinta el widget; aquí va el contenido directo.
11    let mut v = vec![button_view(format!("tipo: {}", l.kind.label()), &btn, Msg::CycleLeyKind)];
12    match &l.kind {
13        // --- Ley autorable: editor de ecuaciones + laboratorio -----------------
14        LeyKind::Ecuacion { campos, fuentes, params } => {
15            v.push(spacer(4.0));
16            v.push(button_view("↻ preset (catálogo)", &btn, Msg::LeyPreset));
17            v.push(spacer(4.0));
18            v.push(button_view(
19                if model.leyes_node_mode { "▦ vista: grafo → laboratorio" } else { "▦ vista: laboratorio → grafo" },
20                &btn,
21                Msg::ToggleLeyesView,
22            ));
23            v.push(spacer(6.0));
24            v.push(button_view(
25                if model.simulating { "⏸ pausar" } else { "▶ simular" },
26                &btn,
27                Msg::ToggleSim,
28            ));
29            v.push(spacer(4.0));
30            v.push(button_view("⟲ resembrar", &btn, Msg::ReseedLab));
31            if campos.len() > 1 {
32                let vis = model.lab.as_ref().map(|x| x.vis).unwrap_or(0);
33                let vname = campos.get(vis).map(|c| c.name.as_str()).unwrap_or("?");
34                v.push(spacer(4.0));
35                v.push(button_view(format!("ver campo: {vname}"), &btn, Msg::CycleLabField));
36            }
37            // Error de compilación, si la fórmula no parsea.
38            if let Some(Err(err)) = l.kind.compile_ecuacion() {
39                v.push(spacer(6.0));
40                v.push(body_text(format!("⚠ {err}"), Color::from_rgba8(232, 128, 96, 255), theme));
41            }
42            v.push(spacer(8.0));
43            v.push(section_title("ECUACIONES  (Δcampo/dt)", theme));
44            for (i, campo) in campos.iter().enumerate() {
45                v.push(body_text(format!("Δ{}/dt =", campo.name), theme.fg_muted, theme));
46                let editing = model.formula_focused && model.formula_target == i;
47                if editing {
48                    v.push(text_input_view_full(
49                        &model.formula_input,
50                        "fórmula…",
51                        true,
52                        &TextInputPalette::from_theme(theme),
53                        Msg::FormulaCampo,
54                    ));
55                } else {
56                    let src = fuentes.get(i).cloned().unwrap_or_default();
57                    let label = if src.is_empty() { "(tocar para editar)".to_string() } else { src };
58                    v.push(button_view(label, &btn, Msg::FormulaFocus(i)));
59                }
60                v.push(spacer(4.0));
61            }
62            v.push(button_view("+ campo", &btn, Msg::AddCampo));
63            if campos.len() > 1 {
64                v.push(spacer(4.0));
65                v.push(button_view("− campo", &btn, Msg::RemoveCampo));
66            }
67            v.push(spacer(8.0));
68            v.push(section_title("PARÁMETROS", theme));
69            for (i, (name, value, min, max)) in l.kind.ecuacion_params().into_iter().enumerate() {
70                v.push(slider_view(name, value, min, max, &sp, move |_p, dv| {
71                    Some(Msg::SetEcuParam(i, value + dv))
72                }));
73            }
74            v.push(spacer(4.0));
75            v.push(button_view("+ parámetro", &btn, Msg::AddEcuParam));
76            v.push(spacer(4.0));
77            v.push(button_view("− parámetro", &btn, Msg::RemoveEcuParam));
78            v.push(spacer(8.0));
79            v.push(body_text(
80                "términos: lap(c) · avg(c) · min6(c)/max6(c)/sum6(c) · abajo(c)… · clamp/min/max · < > · dt".into(),
81                theme.fg_placeholder,
82                theme,
83            ));
84            // Paleta de nodos (sólo en modo grafo): tocar = agregar un nodo suelto.
85            if model.leyes_node_mode {
86                v.push(spacer(8.0));
87                v.push(section_title("NODOS  (tocar = agregar)", theme));
88                v.push(button_view("const 1", &btn, Msg::GraphAddNode(NodeOp::Const(1.0))));
89                v.push(button_view("dt", &btn, Msg::GraphAddNode(NodeOp::Dt)));
90                for (f, c) in campos.iter().enumerate() {
91                    let f = f as u16;
92                    v.push(button_view(format!("campo {}", c.name), &btn, Msg::GraphAddNode(NodeOp::Field(f))));
93                    v.push(button_view(format!("lap {}", c.name), &btn, Msg::GraphAddNode(NodeOp::Lap(f))));
94                    v.push(button_view(format!("avg {}", c.name), &btn, Msg::GraphAddNode(NodeOp::Vecinos(Reduce::Promedio, f))));
95                }
96                for (p, pd) in params.iter().enumerate() {
97                    v.push(button_view(format!("param {}", pd.name), &btn, Msg::GraphAddNode(NodeOp::Param(p as u16))));
98                }
99                for (lbl, op) in [
100                    ("+", NodeOp::Bin(BinOp::Add)),
101                    ("−", NodeOp::Bin(BinOp::Sub)),
102                    ("×", NodeOp::Bin(BinOp::Mul)),
103                    ("÷", NodeOp::Bin(BinOp::Div)),
104                    ("min", NodeOp::Bin(BinOp::Min)),
105                    ("max", NodeOp::Bin(BinOp::Max)),
106                    ("<", NodeOp::Bin(BinOp::Lt)),
107                    (">", NodeOp::Bin(BinOp::Gt)),
108                    ("abs", NodeOp::Un(UnOp::Abs)),
109                    ("−x", NodeOp::Un(UnOp::Neg)),
110                    ("clamp", NodeOp::Clamp),
111                ] {
112                    v.push(button_view(lbl, &btn, Msg::GraphAddNode(op)));
113                }
114                v.push(spacer(4.0));
115                v.push(body_text(
116                    "arrastra salida→entrada para conectar · right‑click borra un nodo".into(),
117                    theme.fg_placeholder,
118                    theme,
119                ));
120            }
121        }
122        // --- Leyes nativas (Fluir/Crecer): sliders cableados -------------------
123        _ => {
124            v.push(spacer(6.0));
125            v.push(section_title("PARÁMETROS", theme));
126            if l.kind.params().is_empty() {
127                v.push(body_text("este tipo no tiene parámetros".into(), theme.fg_placeholder, theme));
128            }
129            for (i, (name, value, min, max)) in l.kind.params().into_iter().enumerate() {
130                v.push(slider_view(name, value, min, max, &sp, move |_p, dv| {
131                    Some(Msg::SetLeyParam(i, value + dv))
132                }));
133            }
134        }
135    }
136    v
137}
138
139pub(crate) fn material_editor(model: &Model, tab: usize) -> Vec<View<Msg>> {
140    let theme = &model.theme;
141    let sp = SliderPalette::from_theme(theme);
142    let btn = ButtonPalette::from_theme(theme);
143    let Some(m) = model.project.material(model.sel[1]) else {
144        return vec![section_title("sin material — crea uno", theme)];
145    };
146    if tab == 0 {
147        let resolved = model.project.resolve_material(m.id);
148        let parent_name = m
149            .parent
150            .and_then(|pid| model.project.material(pid))
151            .map(|p| p.name.clone())
152            .unwrap_or_else(|| "— ninguno".into());
153        let rgb = m.color.unwrap_or([
154            resolved.color[0] as f32 / 255.0,
155            resolved.color[1] as f32 / 255.0,
156            resolved.color[2] as f32 / 255.0,
157        ]);
158        let grain = m.grain.unwrap_or(resolved.grain);
159        let mut v = vec![
160            button_view(format!("rol: {}", m.role.label()), &btn, Msg::CycleMatRole),
161            spacer(4.0),
162            button_view(format!("padre: {parent_name}"), &btn, Msg::CycleMatParent),
163            spacer(6.0),
164            section_title("COLOR", theme),
165        ];
166        for (ch, label) in [(0usize, "rojo"), (1, "verde"), (2, "azul")] {
167            let val = rgb[ch];
168            v.push(slider_view(label, val, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetMatColor(ch, val + dv))));
169        }
170        v.push(slider_view("grano", grain, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetMatGrain(grain + dv))));
171        let brillo = m.brillo.unwrap_or(resolved.brillo);
172        v.push(slider_view("brillo (lustre)", brillo, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetMatBrillo(brillo + dv))));
173        // Detalle sub-voxel (look global del ray-march; el preview aplica el del
174        // material en foco). Rompe la rectangularidad del voxel a corta distancia.
175        v.push(spacer(6.0));
176        v.push(section_title("DETALLE SUB-VOXEL", theme));
177        let (rug, bis) = model.project.detalle_material(m.id);
178        let rug_v = m.rugosidad.unwrap_or(rug);
179        v.push(slider_view("rugosidad", rug_v, 0.0, 1.5, &sp, move |_p, dv| Some(Msg::SetMatRugosidad(rug_v + dv))));
180        let bis_v = m.bisel.unwrap_or(bis);
181        v.push(slider_view("bisel (pendientes)", bis_v, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetMatBisel(bis_v + dv))));
182        v
183    } else {
184        let mut v: Vec<View<Msg>> = Vec::new();
185        for (i, u) in m.leyes.iter().enumerate() {
186            let name = model.project.ley(u.ley).map(|l| l.name.clone()).unwrap_or_else(|| "?".into());
187            v.push(button_view(format!("· {name}  (cambiar)"), &btn, Msg::CycleMatLey(i)));
188        }
189        if m.leyes.is_empty() {
190            v.push(body_text("sin leyes aplicadas".into(), theme.fg_placeholder, theme));
191        }
192        v.push(spacer(6.0));
193        v.push(button_view("+ agregar ley", &btn, Msg::AddMatLey));
194        v.push(spacer(4.0));
195        v.push(button_view("− quitar última", &btn, Msg::RemoveMatLey));
196        v
197    }
198}
199
200pub(crate) fn sere_editor(model: &Model, tab: usize) -> Vec<View<Msg>> {
201    let theme = &model.theme;
202    let sp = SliderPalette::from_theme(theme);
203    let btn = ButtonPalette::from_theme(theme);
204    let Some(c) = sel_sere(model) else {
205        return vec![section_title("sin ser — crea o genera uno", theme)];
206    };
207    match tab {
208        0 => vec![
209            button_view(format!("forma: {}", c.cuerpo_label()), &btn, Msg::CycleSereCuerpo),
210            spacer(4.0),
211            especie_tools(c, &btn),
212            spacer(4.0),
213            button_view(format!("edad: {}", c.age.label()), &btn, Msg::CycleSereAge),
214        ],
215        1 => andar_tools(model, c),
216        2 => conducta_tools(model, c),
217        3 => color_tools(Part::Skin, c.skin, &sp),
218        4 => color_tools(Part::Shirt, c.shirt, &sp),
219        5 => color_tools(Part::Pants, c.pants, &sp),
220        _ => vestuario_tools(model, c),
221    }
222}
223
224/// La **especie** del ser: ninguna (persona o rig) o un bicho del bestiario, que se
225/// filma con su cuerpo SDF y se dirige como cualquier actor. Dos botones porque son
226/// setenta y de a uno hacia adelante no se vuelve nunca.
227/// El nombre del animal sale de `Bicho::label`, que está en castellano igual que en el
228/// editor de fauna del bioma.
229pub(crate) fn especie_tools(c: &CharSpec, btn: &ButtonPalette) -> View<Msg> {
230    use rimay_localize::{t, t_args};
231    let nombre = match c.bicho {
232        Some(b) => b.label().to_string(),
233        None => t("voxelstudio-species-none"),
234    };
235    let rotulo = t_args("voxelstudio-species", &[("nombre", nombre.into())]);
236    crud_pair("◀", Msg::CycleSereBicho(true), &rotulo, Msg::CycleSereBicho(false), btn)
237}
238
239/// Editor del **vestuario** de un ser: peinado, barba, sombrero, anteojos, falda y
240/// manto, y el color de cada prenda que lo tiene. Lo que se ve acá es lo que viste el
241/// ser SDF —el del preview y el que filma `ende`—.
242pub(crate) fn vestuario_tools(model: &Model, c: &CharSpec) -> Vec<View<Msg>> {
243    use rimay_localize::{t, t_args};
244    let theme = &model.theme;
245    let sp = SliderPalette::from_theme(theme);
246    let btn = ButtonPalette::from_theme(theme);
247    let a = &c.atuendo;
248    let nombre = |k: &str| t_args(k, &[("nombre", t(&format!("voxelstudio-{}", clave_prenda(a, k))).into())]);
249    let mut v = vec![
250        button_view(nombre("voxelstudio-hair"), &btn, Msg::CycleSerePeinado),
251        spacer(4.0),
252        button_view(nombre("voxelstudio-hat"), &btn, Msg::CycleSereSombrero),
253        spacer(4.0),
254        button_view(
255            t(if a.anteojos { "voxelstudio-glasses-on" } else { "voxelstudio-glasses-off" }),
256            &btn,
257            Msg::ToggleSereAnteojos,
258        ),
259        spacer(6.0),
260    ];
261    for (campo, clave, val) in [
262        (PrendaField::Barba, "voxelstudio-beard", a.barba),
263        (PrendaField::Falda, "voxelstudio-skirt", a.falda),
264        (PrendaField::Manto, "voxelstudio-cloak", a.manto),
265    ] {
266        v.push(slider_view(t(clave), val, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetSerePrenda(campo, val + dv))));
267    }
268    // El color de una prenda sólo aparece cuando la prenda está puesta: un color
269    // de falda sin falda es una perilla que no mueve nada.
270    if a.sombrero != Sombrero::Ninguno {
271        v.push(spacer(6.0));
272        v.push(section_title(&t("voxelstudio-color-hat"), theme));
273        v.extend(color_tools(Part::Sombrero, a.color_sombrero_efectivo(c.shirt), &sp));
274    }
275    if a.falda > 0.01 {
276        v.push(spacer(6.0));
277        v.push(section_title(&t("voxelstudio-color-skirt"), theme));
278        v.extend(color_tools(Part::Falda, a.color_falda_efectivo(c.shirt), &sp));
279    }
280    if a.manto > 0.01 {
281        v.push(spacer(6.0));
282        v.push(section_title(&t("voxelstudio-color-cloak"), theme));
283        v.extend(color_tools(Part::Manto, a.color_manto_efectivo(c.shirt), &sp));
284    }
285    v
286}
287
288/// El sufijo de la clave del nombre de la prenda vigente: `hair-<peinado>` o
289/// `hat-<sombrero>`. Va en inglés porque es un identificador, no un rótulo.
290pub(crate) fn clave_prenda(a: &llimphi_voxel::Atuendo, cual: &str) -> &'static str {
291    if cual == "voxelstudio-hair" {
292        match a.peinado {
293            Peinado::Casquete => "hair-cap",
294            Peinado::Corto => "hair-short",
295            Peinado::Melena => "hair-bob",
296            Peinado::Larga => "hair-long",
297            Peinado::Rodete => "hair-bun",
298            Peinado::Coleta => "hair-ponytail",
299            Peinado::DosColas => "hair-pigtails",
300            Peinado::Afro => "hair-afro",
301            Peinado::Cresta => "hair-mohawk",
302            Peinado::Calvo => "hair-bald",
303        }
304    } else {
305        match a.sombrero {
306            Sombrero::Ninguno => "hat-none",
307            Sombrero::Gorra => "hat-cap",
308            Sombrero::Ala => "hat-brimmed",
309            Sombrero::Gorro => "hat-beanie",
310            Sombrero::Panuelo => "hat-headscarf",
311            Sombrero::Casco => "hat-helmet",
312            Sombrero::Corona => "hat-crown",
313        }
314    }
315}
316
317/// Si el ser todavía no tiene impulsos propios (datos viejos), los siembra con los de
318/// fábrica para que el editor tenga qué mostrar/editar (mismo comportamiento en vivo).
319pub(crate) fn materializar_impulsos(c: &mut llimphi_voxel::CharSpec) {
320    if c.conducta.impulsos.is_empty() {
321        c.conducta.impulsos = llimphi_voxel::default_impulsos();
322    }
323}
324
325/// Editor de la **conducta** (capa 3) de un ser: sliders de locomoción (params) +
326/// **impulsos de steering autorables** (primitiva ciclable + fórmula de peso, la misma
327/// máquina de ecuaciones que las Leyes) + toggle para soltar una manada viva.
328pub(crate) fn conducta_tools(model: &Model, c: &llimphi_voxel::CharSpec) -> Vec<View<Msg>> {
329    let theme = &model.theme;
330    let sp = SliderPalette::from_theme(theme);
331    let btn = ButtonPalette::from_theme(theme);
332    // El cabezal ("Conducta") lo pinta el widget; arranca con los sliders de params.
333    let mut v: Vec<View<Msg>> = Vec::new();
334    for (i, (name, value, min, max)) in c.conducta.params().into_iter().enumerate() {
335        v.push(slider_view(name, value, min, max, &sp, move |_p, dv| Some(Msg::SetConducta(i, value + dv))));
336    }
337
338    // --- Impulsos de steering (dirección base × fórmula de peso) -----------------
339    v.push(spacer(8.0));
340    v.push(section_title("IMPULSOS  (dirección × peso)", theme));
341    let impulsos = c.conducta.impulsos_efectivos();
342    let sym = llimphi_voxel::Conducta::symbols();
343    for (i, imp) in impulsos.iter().enumerate() {
344        v.push(button_view(format!("{} · {}", i + 1, imp.primitiva.label()), &btn, Msg::CondCycleImpulso(i)));
345        let editing = model.cond_formula_focused && model.cond_formula_target == i;
346        if editing {
347            v.push(text_input_view_full(
348                &model.cond_formula_input,
349                "peso…",
350                true,
351                &TextInputPalette::from_theme(theme),
352                Msg::CondFormulaCampo,
353            ));
354        } else {
355            let src = if imp.peso.is_empty() { "(tocar para editar)".to_string() } else { imp.peso.clone() };
356            v.push(button_view(format!("peso = {src}"), &btn, Msg::CondFormulaFocus(i)));
357        }
358        // Error de parseo de esta fórmula, si lo hay.
359        if let Err(err) = llimphi_voxel::Expr::parse(&imp.peso, &sym) {
360            v.push(body_text(format!("⚠ {err}"), Color::from_rgba8(232, 128, 96, 255), theme));
361        }
362        v.push(spacer(4.0));
363    }
364    v.push(button_view("+ impulso", &btn, Msg::CondAddImpulso));
365    if impulsos.len() > 1 {
366        v.push(spacer(4.0));
367        v.push(button_view("− impulso", &btn, Msg::CondRemoveImpulso));
368    }
369    v.push(spacer(6.0));
370    v.push(body_text(
371        "percepts: cercania_manada · cercania_amenaza · apinamiento · azar · en_suelo · dt".into(),
372        theme.fg_placeholder,
373        theme,
374    ));
375
376    v.push(spacer(8.0));
377    v.push(section_title("PREVIEW", theme));
378    v.push(button_view(
379        if model.manada { "⏸ detener manada" } else { "▶ soltar manada" },
380        &btn,
381        Msg::ToggleManada,
382    ));
383    v.push(spacer(4.0));
384    v.push(body_text("una manada del ser deambula/se junta/huye según sus impulsos".into(), theme.fg_placeholder, theme));
385    v
386}
387
388/// Editor del **andar** (capa 2) de un ser-rig: estado a editar/previsualizar +
389/// cadencia + cuánto balancea cada articulación. El humanoide usa animaciones fijas.
390pub(crate) fn andar_tools<'a>(model: &Model, c: &'a llimphi_voxel::CharSpec) -> Vec<View<Msg>> {
391    let theme = &model.theme;
392    let sp = SliderPalette::from_theme(theme);
393    let btn = ButtonPalette::from_theme(theme);
394    let Some(mov) = &c.cuerpo else {
395        return vec![body_text(
396            "el humanoide usa animaciones fijas — cambia la «forma» a un rig (cuadrúpedo, ave…) para editar su andar".into(),
397            theme.fg_placeholder,
398            theme,
399        )];
400    };
401    let est = model.andar_estado.min(2);
402    let andar = mov.andares.estado(est);
403    let mut v = vec![
404        button_view(
405            format!("estado: {}", llimphi_voxel::Andares::LABELS[est]),
406            &btn,
407            Msg::CycleAndarEstado,
408        ),
409        spacer(4.0),
410        slider_view("cadencia (ritmo)", andar.cadencia, 0.0, 18.0, &sp, {
411            let cad = andar.cadencia;
412            move |_p, dv| Some(Msg::SetAndarCadencia(cad + dv))
413        }),
414        spacer(6.0),
415        section_title("BALANCEO POR PARTE", theme),
416    ];
417    for (i, seg) in mov.rig.segmentos.iter().enumerate() {
418        let amp = andar.osc.get(i).map(|o| o.amplitud).unwrap_or(0.0);
419        v.push(slider_view(&seg.nombre, amp, 0.0, 1.5, &sp, move |_p, dv| {
420            Some(Msg::SetAndarAmplitud(i, amp + dv))
421        }));
422    }
423    v
424}
425
426pub(crate) fn bioma_editor(model: &Model, tab: usize) -> Vec<View<Msg>> {
427    let theme = &model.theme;
428    let sp = SliderPalette::from_theme(theme);
429    let btn = ButtonPalette::from_theme(theme);
430    let Some(b) = model.project.bioma(model.sel[3]) else {
431        return vec![section_title("sin bioma — crea uno", theme)];
432    };
433    let mat_name = |id: u64| model.project.material(id).map(|m| m.name.clone()).unwrap_or_else(|| "—".into());
434    match tab {
435        0 => vec![
436            bslider(&sp, "base (llanura)", b.base, 0.0, 0.9, BiomaField::Base),
437            bslider(&sp, "dunas", b.dune, 0.0, 0.4, BiomaField::Dune),
438            bslider(&sp, "relieve (alto)", b.relief, 0.0, 1.0, BiomaField::Relief),
439            bslider(&sp, "densidad montañas", b.mountains, 0.0, 1.0, BiomaField::Mountains),
440            bslider(&sp, "nivel del agua", b.water_level, 0.0, 0.9, BiomaField::Water),
441            bslider(&sp, "densidad ríos", b.rivers, 0.0, 1.0, BiomaField::Rivers),
442            bslider(&sp, "altura cumbre", b.peak_at, 0.0, 1.0, BiomaField::PeakAt),
443            spacer(8.0),
444            section_title("SIMULAR LEYES", theme),
445            button_view(if model.simulating { "⏸ detener" } else { "▶ simular (agua · plantas)" }, &btn, Msg::ToggleSim),
446        ],
447        1 => vec![
448            button_view(format!("suelo: {}", mat_name(b.ground)), &btn, Msg::CycleBiomaGround),
449            spacer(4.0),
450            button_view(format!("acantilado: {}", mat_name(b.cliff)), &btn, Msg::CycleBiomaCliff),
451            spacer(4.0),
452            button_view(
453                format!("cumbre: {}", b.peak.map(mat_name).unwrap_or_else(|| "ninguna".into())),
454                &btn,
455                Msg::CycleBiomaPeak,
456            ),
457        ],
458        2 => {
459            let mut v: Vec<View<Msg>> = Vec::new();
460            for (i, o) in b.objetos.iter().enumerate() {
461                let name = mat_name(o.material);
462                v.push(button_view(format!("forma: {}", o.forma.label()), &btn, Msg::CycleObjetoForma(i)));
463                v.push(spacer(3.0));
464                v.push(button_view(format!("material: {name}"), &btn, Msg::CycleObjetoMaterial(i)));
465                let d = o.densidad;
466                v.push(slider_view("densidad", d, 0.0, 0.05, &sp, move |_p, dv| Some(Msg::SetObjetoDensidad(i, d + dv))));
467                v.push(spacer(6.0));
468            }
469            if b.objetos.is_empty() {
470                v.push(body_text("sin objetos".into(), theme.fg_placeholder, theme));
471            }
472            v.push(spacer(6.0));
473            v.push(button_view("+ objeto", &btn, Msg::AddBiomaObjeto));
474            v.push(spacer(4.0));
475            v.push(button_view("− quitar", &btn, Msg::RemoveBiomaObjeto));
476            v
477        }
478        3 => mineral_editor(model, &sp, &btn),
479        4 => casa_editor(model, &sp, &btn),
480        5 => fauna_editor(model, &sp, &btn),
481        _ => {
482            let mut v: Vec<View<Msg>> = Vec::new();
483            for (i, u) in b.seres.iter().enumerate() {
484                let name = model.project.sere(u.sere).map(|s| s.name.clone()).unwrap_or_else(|| "—".into());
485                v.push(button_view(format!("ser: {name}"), &btn, Msg::CycleBiomaSere(i)));
486                let prob = u.probabilidad;
487                v.push(slider_view("cantidad", prob, 0.0, 1.0, &sp, move |_p, dv| {
488                    Some(Msg::SetBiomaSereProb(i, prob + dv))
489                }));
490                v.push(spacer(4.0));
491            }
492            if b.seres.is_empty() {
493                v.push(body_text("sin seres — agrega para poblar el bioma".into(), theme.fg_placeholder, theme));
494            }
495            v.push(spacer(4.0));
496            v.push(button_view("+ ser", &btn, Msg::AddBiomaSere));
497            v.push(spacer(4.0));
498            v.push(button_view("− quitar", &btn, Msg::RemoveBiomaSere));
499            v.push(spacer(8.0));
500            v.push(body_text("se ven con «▶ simular» en Mundos/Biomas".into(), theme.fg_placeholder, theme));
501            v
502        }
503    }
504}
505
506/// Editor de las **casas** que el bioma esparce (pueblo procedural por semilla):
507/// por uso, el estilo y su densidad. El mundo las genera al cambiar la semilla.
508pub(crate) fn casa_editor(model: &Model, sp: &SliderPalette, btn: &ButtonPalette) -> Vec<View<Msg>> {
509    let theme = &model.theme;
510    let Some(b) = model.project.bioma(model.sel[3]) else {
511        return vec![section_title("sin bioma — crea uno", theme)];
512    };
513    let mut v = vec![
514        section_title("CASAS (PUEBLO)", theme),
515        body_text("se esparcen por la semilla del mundo".into(), theme.fg_placeholder, theme),
516        spacer(6.0),
517    ];
518    // Preset de tipo de pueblo: rellena la mezcla de casas + densidad de un toque.
519    v.push(button_view(format!("preset: {}", model.pueblo_brush.label()), btn, Msg::CyclePuebloTipo));
520    v.push(spacer(3.0));
521    v.push(button_view("aplicar preset", btn, Msg::AplicarPuebloTipo));
522    v.push(spacer(8.0));
523    // Densidad urbana: cuánto se agrupan las casas (manchas de pueblo) vs campo abierto.
524    let du = b.densidad_urbana;
525    v.push(slider_view("densidad urbana", du, 0.05, 0.95, sp, move |_p, dv| {
526        Some(Msg::SetDensidadUrbana(du + dv))
527    }));
528    v.push(spacer(8.0));
529    for (i, cu) in b.casas.iter().enumerate() {
530        v.push(button_view(format!("estilo: {}", cu.tipo.label()), btn, Msg::CycleCasaTipo(i)));
531        let d = cu.densidad;
532        v.push(slider_view("densidad", d, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetCasaDensidad(i, d + dv))));
533        v.push(spacer(6.0));
534    }
535    if b.casas.is_empty() {
536        v.push(body_text("sin casas — pon algunas para armar un pueblo".into(), theme.fg_placeholder, theme));
537    }
538    v.push(spacer(6.0));
539    v.push(button_view("+ casa", btn, Msg::AddBiomaCasa));
540    v.push(spacer(4.0));
541    v.push(button_view("- quitar", btn, Msg::RemoveBiomaCasa));
542    v
543}
544
545/// Editor de la **fauna** del bioma: qué animales ([`Bicho`]) pueblan el mundo y
546/// con qué densidad. El mundo los esparce por semilla, parados sobre el terreno.
547pub(crate) fn fauna_editor(model: &Model, sp: &SliderPalette, btn: &ButtonPalette) -> Vec<View<Msg>> {
548    let theme = &model.theme;
549    let Some(b) = model.project.bioma(model.sel[3]) else {
550        return vec![section_title("sin bioma — crea uno", theme)];
551    };
552    let mut v = vec![
553        section_title("FAUNA (SERES)", theme),
554        body_text("animales esparcidos por la semilla del mundo".into(), theme.fg_placeholder, theme),
555        spacer(6.0),
556    ];
557    for (i, fu) in b.fauna.iter().enumerate() {
558        v.push(button_view(format!("ser: {}", fu.bicho.label()), btn, Msg::CycleFaunaBicho(i)));
559        let d = fu.densidad;
560        v.push(slider_view("densidad", d, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetFaunaDensidad(i, d + dv))));
561        v.push(spacer(6.0));
562    }
563    if b.fauna.is_empty() {
564        v.push(body_text("sin fauna — agrega animales para dar vida al bioma".into(), theme.fg_placeholder, theme));
565    }
566    v.push(spacer(6.0));
567    v.push(button_view("+ animal", btn, Msg::AddBiomaFauna));
568    v.push(spacer(4.0));
569    v.push(button_view("- quitar", btn, Msg::RemoveBiomaFauna));
570    v
571}
572
573/// Editor de los **minerales** del bioma seleccionado, con **todos sus factores**:
574/// tipo semilla, aspecto, abundancia, banda de profundidad, color y —lo que el
575/// ray-march sí puede pintar— dureza (grano) y brillo (lustre especular).
576pub(crate) fn mineral_editor(model: &Model, sp: &SliderPalette, btn: &ButtonPalette) -> Vec<View<Msg>> {
577    let theme = &model.theme;
578    let Some(b) = model.project.bioma(model.sel[3]) else {
579        return vec![section_title("sin bioma — crea uno", theme)];
580    };
581    let mut v: Vec<View<Msg>> = Vec::new();
582    for (i, mu) in b.minerales.iter().enumerate() {
583        v.push(button_view(format!("mineral: {}", mu.mineral.label()), btn, Msg::CycleMineralTipo(i)));
584        v.push(spacer(3.0));
585        v.push(button_view(format!("aspecto: {}", mu.aspecto.label()), btn, Msg::CycleMineralAspecto(i)));
586        let ab = mu.abundancia;
587        v.push(slider_view("abundancia", ab, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralAbundancia(i, ab + dv))));
588        let pmin = mu.prof_min;
589        v.push(slider_view("prof. mín", pmin, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralProfMin(i, pmin + dv))));
590        let pmax = mu.prof_max;
591        v.push(slider_view("prof. máx", pmax, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralProfMax(i, pmax + dv))));
592        // Color (RGB).
593        let (r, g, bl) = (mu.color[0] as f32 / 255.0, mu.color[1] as f32 / 255.0, mu.color[2] as f32 / 255.0);
594        v.push(slider_view("color R", r, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralColor(i, 0, r + dv))));
595        v.push(slider_view("color G", g, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralColor(i, 1, g + dv))));
596        v.push(slider_view("color B", bl, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralColor(i, 2, bl + dv))));
597        // Dureza Mohs (grano) y brillo (lustre especular).
598        let dur = mu.dureza;
599        v.push(slider_view("dureza (Mohs)", dur, 1.0, 10.0, sp, move |_p, dv| Some(Msg::SetMineralDureza(i, dur + dv))));
600        let br = mu.brillo;
601        v.push(slider_view("brillo (lustre)", br, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralBrillo(i, br + dv))));
602        v.push(spacer(8.0));
603    }
604    if b.minerales.is_empty() {
605        v.push(body_text("sin minerales — el terreno queda de pura roca".into(), theme.fg_placeholder, theme));
606    }
607    v.push(spacer(6.0));
608    v.push(button_view("+ mineral", btn, Msg::AddBiomaMineral));
609    v.push(spacer(4.0));
610    v.push(button_view("− quitar", btn, Msg::RemoveBiomaMineral));
611    v
612}
613
614pub(crate) fn mundo_editor(model: &Model, tab: usize) -> Vec<View<Msg>> {
615    let theme = &model.theme;
616    let btn = ButtonPalette::from_theme(theme);
617    let Some(m) = model.project.mundo(model.sel[4]) else {
618        return vec![section_title("sin mundo — crea uno", theme)];
619    };
620    if tab == 0 {
621        vec![
622            text_input_view_full(
623                &model.seed_input,
624                "número…",
625                model.seed_focused,
626                &TextInputPalette::from_theme(theme),
627                Msg::SeedCampo,
628            ),
629            spacer(6.0),
630            button_view("🎲 random", &btn, Msg::SeedRandom),
631            spacer(8.0),
632            body_text(format!("semilla actual: {}", m.seed), theme.fg_muted, theme),
633            spacer(10.0),
634            section_title("SIMULAR LEYES", theme),
635            button_view(if model.simulating { "⏸ detener" } else { "▶ simular (agua · plantas)" }, &btn, Msg::ToggleSim),
636        ]
637    } else {
638        let bname = m
639            .biomas
640            .first()
641            .and_then(|&id| model.project.bioma(id))
642            .map(|b| b.name.clone())
643            .unwrap_or_else(|| "—".into());
644        vec![
645            button_view(format!("bioma: {bname}"), &btn, Msg::CycleMundoBioma),
646            spacer(8.0),
647            body_text("un mundo se compone de uno o más biomas".into(), theme.fg_placeholder, theme),
648        ]
649    }
650}
651
652pub(crate) fn scene_editor(model: &Model, tab: usize) -> Vec<View<Msg>> {
653    let theme = &model.theme;
654    let sp = SliderPalette::from_theme(theme);
655    let btn = ButtonPalette::from_theme(theme);
656    let Some(s) = sel_scene(model) else {
657        return vec![section_title("sin escena — genera una con IA", theme)];
658    };
659    match tab {
660        0 => {
661            let dur = s.duration;
662            let mundo_name = model.project.mundo(s.mundo).map(|m| m.name.clone()).unwrap_or_else(|| "—".into());
663            let t = model.time;
664            vec![
665                button_view(format!("mundo: {mundo_name}"), &btn, Msg::CycleSceneMundo),
666                spacer(6.0),
667                slider_view("duración (s)", dur, 1.0, 20.0, &sp, move |_p, dv| Some(Msg::SetSceneDur(dur + dv))),
668                spacer(6.0),
669                button_view(if model.playing { "❚❚ pausa" } else { "▶ reproducir" }, &btn, Msg::TogglePlay),
670                spacer(4.0),
671                slider_view("tiempo", t.min(dur), 0.0, dur, &sp, move |_p, dv| Some(Msg::Scrub(t + dv))),
672                spacer(6.0),
673                body_text(format!("{} actores", s.actors.len()), theme.fg_muted, theme),
674            ]
675            .into_iter()
676            .chain(cielo_tools(model, s))
677            .collect()
678        }
679        1 => reparto_tools(model, s),
680        2 => {
681            let cam_label = if model.script_cam { "cámara: guion" } else { "cámara: órbita" };
682            vec![
683                button_view(cam_label, &btn, Msg::ToggleSceneCam),
684                spacer(4.0),
685                body_text(format!("{} planos", s.shots.len()), theme.fg_muted, theme),
686                spacer(4.0),
687                button_view("+ plano aquí", &btn, Msg::AddShot),
688                spacer(4.0),
689                button_view("− quitar plano", &btn, Msg::RemoveShot),
690            ]
691        }
692        3 => utileria_tools(model, s),
693        4 => decorado_tools(model, s),
694        _ => vec![
695            button_view(if model.exporting { "exportando…" } else { "🎬 exportar video" }, &btn, Msg::ExportVideo),
696            spacer(8.0),
697            body_text("renderiza el guion a un .mkv (puede tardar)".into(), theme.fg_placeholder, theme),
698        ],
699    }
700}
701
702/// **Decorado** de la escena: qué construcción (el palacio, el kiosko…), dónde
703/// va en el mundo y qué variante. Se planta nivelando el suelo bajo su huella.
704pub(crate) fn decorado_tools(model: &Model, s: &SceneSpec) -> Vec<View<Msg>> {
705    use rimay_localize::{t, t_args};
706    let theme = &model.theme;
707    let sp = SliderPalette::from_theme(theme);
708    let btn = ButtonPalette::from_theme(theme);
709    let i = model.decorado_sel.min(s.decorado.len().saturating_sub(1));
710    let mut v: Vec<View<Msg>> = Vec::new();
711    for (k, d) in s.decorado.iter().enumerate() {
712        let label = t_args(
713            "voxelstudio-set-row",
714            &[
715                ("nombre", t(clave_casa(d.casa)).into()),
716                ("x", format!("{:.0}", d.pos[0]).into()),
717                ("z", format!("{:.0}", d.pos[1]).into()),
718            ],
719        );
720        v.push(selectable_row(label, k == i, Msg::SelectDecorado(k), theme));
721    }
722    if s.decorado.is_empty() {
723        v.push(body_text(t("voxelstudio-set-empty"), theme.fg_placeholder, theme));
724    }
725    v.push(spacer(6.0));
726    v.push(crud_pair(&t("voxelstudio-set-add"), Msg::AddDecorado, &t("voxelstudio-set-remove"), Msg::RemoveDecorado, &btn));
727    let Some(d) = s.decorado.get(i) else { return v };
728    v.push(spacer(8.0));
729    v.push(button_view(t_args("voxelstudio-set-kind", &[("nombre", t(clave_casa(d.casa)).into())]), &btn, Msg::CycleDecoradoCasa));
730    v.push(spacer(4.0));
731    v.push(button_view(t("voxelstudio-set-variant"), &btn, Msg::OtraVarianteDecorado));
732    v.push(spacer(6.0));
733    let (x, z) = (d.pos[0], d.pos[1]);
734    v.push(slider_view(t("voxelstudio-set-x"), x, x - 64.0, x + 64.0, &sp, move |_p, dv| Some(Msg::SetDecorado(DecoradoField::X, x + dv))));
735    v.push(slider_view(t("voxelstudio-set-z"), z, z - 64.0, z + 64.0, &sp, move |_p, dv| Some(Msg::SetDecorado(DecoradoField::Z, z + dv))));
736    v
737}
738
739/// La clave del nombre de cada estilo de casa en los catálogos.
740pub(crate) fn clave_casa(c: CasaTipo) -> &'static str {
741    match c {
742        CasaTipo::Flintstone => "voxelstudio-house-flintstone",
743        CasaTipo::Hobbit => "voxelstudio-house-hobbit",
744        CasaTipo::Cabana => "voxelstudio-house-cabin",
745        CasaTipo::Moderna => "voxelstudio-house-modern",
746        CasaTipo::Edificio => "voxelstudio-house-apartments",
747        CasaTipo::Iglu => "voxelstudio-house-igloo",
748        CasaTipo::Palafito => "voxelstudio-house-stilt",
749        CasaTipo::Adobe => "voxelstudio-house-adobe",
750        CasaTipo::Rascacielos => "voxelstudio-house-skyscraper",
751        CasaTipo::Granja => "voxelstudio-house-farm",
752        CasaTipo::Torre => "voxelstudio-house-tower",
753        CasaTipo::Faro => "voxelstudio-house-lighthouse",
754        CasaTipo::Choza => "voxelstudio-house-hut",
755        CasaTipo::Tienda => "voxelstudio-house-shop",
756        CasaTipo::Duplex => "voxelstudio-house-duplex",
757        CasaTipo::Palacio => "voxelstudio-house-palace",
758        CasaTipo::Kiosko => "voxelstudio-house-kiosk",
759    }
760}
761
762/// **Cielo** de la escena: de día (la luz de quien filma) o de noche, con cuántas
763/// estrellas y qué tan grande la luna.
764pub(crate) fn cielo_tools(model: &Model, s: &SceneSpec) -> Vec<View<Msg>> {
765    use rimay_localize::t;
766    let theme = &model.theme;
767    let sp = SliderPalette::from_theme(theme);
768    let btn = ButtonPalette::from_theme(theme);
769    let mut v = vec![spacer(10.0)];
770    let Some(c) = s.cielo else {
771        v.push(button_view(t("voxelstudio-sky-day"), &btn, Msg::ToggleNoche));
772        return v;
773    };
774    v.push(button_view(t("voxelstudio-sky-night"), &btn, Msg::ToggleNoche));
775    v.push(spacer(4.0));
776    let (estrellas, luna) = (c.estrellas, c.luna_radio);
777    v.push(slider_view(t("voxelstudio-sky-stars"), estrellas, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetCielo(CieloField::Estrellas, estrellas + dv))));
778    v.push(slider_view(t("voxelstudio-sky-moon"), luna, 0.0, 0.15, &sp, move |_p, dv| Some(Msg::SetCielo(CieloField::Luna, luna + dv))));
779    v
780}
781
782/// **Utilería** de la escena: qué pieza, dónde (en el mundo o en la mano de un
783/// actor), de qué tamaño, desde cuándo y si gira. Lo que se ve en el preview es lo
784/// que filma `ende`.
785pub(crate) fn utileria_tools(model: &Model, s: &SceneSpec) -> Vec<View<Msg>> {
786    use rimay_localize::{t, t_args};
787    let theme = &model.theme;
788    let sp = SliderPalette::from_theme(theme);
789    let btn = ButtonPalette::from_theme(theme);
790    let dim = world_dim(PREVIEW_DIM_XZ);
791    let i = model.utileria_sel.min(s.utileria.len().saturating_sub(1));
792    let nombre_de = |u: &UtileriaSpec| t(clave_utileria(u.cosa));
793    let mut v: Vec<View<Msg>> = Vec::new();
794    for (k, u) in s.utileria.iter().enumerate() {
795        let label = t_args(
796            "voxelstudio-prop-row",
797            &[("nombre", nombre_de(u).into()), ("desde", format!("{:.1}", u.desde).into())],
798        );
799        v.push(selectable_row(label, k == i, Msg::SelectUtileria(k), theme));
800    }
801    if s.utileria.is_empty() {
802        v.push(body_text(t("voxelstudio-prop-empty"), theme.fg_placeholder, theme));
803    }
804    v.push(spacer(6.0));
805    v.push(crud_pair(&t("voxelstudio-prop-add"), Msg::AddUtileria, &t("voxelstudio-prop-remove"), Msg::RemoveUtileria, &btn));
806    let Some(u) = s.utileria.get(i) else { return v };
807    v.push(spacer(8.0));
808    v.push(button_view(t_args("voxelstudio-prop-kind", &[("nombre", nombre_de(u).into())]), &btn, Msg::CycleUtileriaCosa));
809    v.push(spacer(4.0));
810    let quien = match u.lleva {
811        Some(a) => {
812            let nombre = s
813                .actors
814                .get(a)
815                .and_then(|act| model.project.seres.get(act.character))
816                .map(|c| c.name.clone())
817                .unwrap_or_else(|| "—".into());
818            t_args("voxelstudio-prop-held-by", &[("nombre", nombre.into())])
819        }
820        None => t("voxelstudio-prop-held-nobody"),
821    };
822    v.push(button_view(quien, &btn, Msg::CycleUtileriaLleva));
823    v.push(spacer(6.0));
824    if u.lleva.is_none() {
825        for (campo, clave, val, max) in [
826            (UtileriaField::X, "voxelstudio-prop-x", u.pos[0], dim[0] as f32),
827            (UtileriaField::Y, "voxelstudio-prop-y", u.pos[1], dim[1] as f32),
828            (UtileriaField::Z, "voxelstudio-prop-z", u.pos[2], dim[2] as f32),
829        ] {
830            v.push(slider_view(t(clave), val, 0.0, max, &sp, move |_p, dv| Some(Msg::SetUtileria(campo, val + dv))));
831        }
832    }
833    let alto = if u.alto > 0.0 { u.alto } else { u.cosa.alto() };
834    let (desde, giro) = (u.desde, u.giro);
835    v.push(slider_view(t("voxelstudio-prop-size"), alto, 0.02, 4.0, &sp, move |_p, dv| Some(Msg::SetUtileria(UtileriaField::Alto, alto + dv))));
836    v.push(slider_view(t("voxelstudio-prop-from"), desde, 0.0, s.duration, &sp, move |_p, dv| Some(Msg::SetUtileria(UtileriaField::Desde, desde + dv))));
837    v.push(slider_view(t("voxelstudio-prop-spin"), giro, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetUtileria(UtileriaField::Giro, giro + dv))));
838    v
839}
840
841/// La clave del nombre de cada pieza en los catálogos.
842pub(crate) fn clave_utileria(u: Utileria) -> &'static str {
843    match u {
844        Utileria::Estrella => "voxelstudio-prop-star",
845        Utileria::Flor => "voxelstudio-prop-flower",
846        Utileria::Perla => "voxelstudio-prop-pearl",
847        Utileria::Pluma => "voxelstudio-prop-feather",
848        Utileria::Prendedor => "voxelstudio-prop-brooch",
849    }
850}
851
852/// **Reparto** (filmografía): dirigir quién actúa y su recorrido (waypoints).
853pub(crate) fn reparto_tools(model: &Model, s: &SceneSpec) -> Vec<View<Msg>> {
854    let theme = &model.theme;
855    let sp = SliderPalette::from_theme(theme);
856    let btn = ButtonPalette::from_theme(theme);
857    let dimx = world_dim(PREVIEW_DIM_XZ)[0] as f32;
858    let a = model.actor_sel.min(s.actors.len().saturating_sub(1));
859
860    let mut v: Vec<View<Msg>> = Vec::new();
861    for (i, act) in s.actors.iter().enumerate() {
862        let nombre = model.project.seres.get(act.character).map(|c| c.name.clone()).unwrap_or_else(|| "—".into());
863        let label = format!("{nombre} · {} claves", act.keys.len());
864        v.push(selectable_row(label, i == a, Msg::SelectActor(i), theme));
865    }
866    v.push(spacer(6.0));
867    v.push(crud_pair("+ actor", Msg::AddActor, "− actor", Msg::RemoveActor, &btn));
868
869    let Some(act) = s.actors.get(a) else { return v };
870    let nombre = model.project.seres.get(act.character).map(|c| c.name.clone()).unwrap_or_else(|| "—".into());
871    v.push(spacer(8.0));
872    v.push(button_view(format!("actúa: {nombre}"), &btn, Msg::CycleActorSere));
873
874    // Recorrido: las claves (waypoints) del actor seleccionado.
875    v.push(spacer(8.0));
876    v.push(section_title("RECORRIDO", theme));
877    let k = model.key_sel.min(act.keys.len().saturating_sub(1));
878    for (i, key) in act.keys.iter().enumerate() {
879        let clip = key.clip.unwrap_or(Clip::Idle).label();
880        v.push(selectable_row(format!("t={:.1}s · {clip}", key.t), i == k, Msg::SelectKey(i), theme));
881    }
882    v.push(spacer(4.0));
883    v.push(crud_pair("+ clave", Msg::AddKey, "− clave", Msg::RemoveKey, &btn));
884
885    if let Some(key) = act.keys.get(k) {
886        v.push(spacer(8.0));
887        v.push(section_title("CLAVE", theme));
888        let (gx, gz, kt) = (key.gx, key.gz, key.t);
889        v.push(slider_view("posición X", gx, 0.0, dimx, &sp, move |_p, dv| Some(Msg::SetKeyPos(true, gx + dv))));
890        v.push(slider_view("posición Z", gz, 0.0, dimx, &sp, move |_p, dv| Some(Msg::SetKeyPos(false, gz + dv))));
891        v.push(slider_view("tiempo (s)", kt, 0.0, s.duration, &sp, move |_p, dv| Some(Msg::SetKeyTime(kt + dv))));
892        v.push(spacer(4.0));
893        v.push(button_view(format!("acción: {}", key.clip.unwrap_or(Clip::Idle).label()), &btn, Msg::CycleKeyClip));
894    }
895    v.push(spacer(8.0));
896    v.push(body_text("dale ▶ reproducir en «Escena» para ver el recorrido".into(), theme.fg_placeholder, theme));
897    v
898}
899
900/// Par de botones en una fila (p.ej. agregar/quitar).
901pub(crate) fn crud_pair(a: &str, ma: Msg, b: &str, mb: Msg, btn: &ButtonPalette) -> View<Msg> {
902    View::new(Style {
903        flex_direction: FlexDirection::Row,
904        size: Size { width: percent(1.0_f32), height: Dimension::auto() },
905        gap: Size { width: length(4.0_f32), height: length(0.0_f32) },
906        ..Default::default()
907    })
908    .children(vec![cell(button_view(a, btn, ma)), cell(button_view(b, btn, mb))])
909}
910
911/// Slider de un campo de relieve del bioma.
912pub(crate) fn bslider(sp: &SliderPalette, label: &str, value: f32, min: f32, max: f32, field: BiomaField) -> View<Msg> {
913    slider_view(label, value, min, max, sp, move |_p, dv| Some(Msg::SetBiomaField(field, value + dv)))
914}
915
916/// Set de sliders R/G/B de una parte coloreable de un ser. El cabezal (Piel/Camiseta/
917/// Pantalón) lo pinta el widget del sidebar, así que aquí van sólo los sliders.
918pub(crate) fn color_tools(part: Part, rgb: [f32; 3], sp: &SliderPalette) -> Vec<View<Msg>> {
919    let mut v: Vec<View<Msg>> = Vec::new();
920    for (ch, label) in [(0usize, "rojo"), (1, "verde"), (2, "azul")] {
921        let value = rgb[ch];
922        v.push(slider_view(label, value, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetSereColor(part, ch, value + dv))));
923    }
924    v
925}
926
927// =============================================================================
928//  CRUD: crear / duplicar / borrar / renombrar / reseleccionar
929// =============================================================================
930