tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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