use super::*; pub(crate) fn ley_editor(model: &Model) -> Vec> { let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let Some(l) = model.project.ley(model.sel[0]) else { return vec![section_title("sin ley — crea una", theme)]; }; // El cabezal ("Parámetros") lo pinta el widget; aquí va el contenido directo. let mut v = vec![button_view(format!("tipo: {}", l.kind.label()), &btn, Msg::CycleLeyKind)]; match &l.kind { // --- Ley autorable: editor de ecuaciones + laboratorio ----------------- LeyKind::Ecuacion { campos, fuentes, params } => { v.push(spacer(4.0)); v.push(button_view("↻ preset (catálogo)", &btn, Msg::LeyPreset)); v.push(spacer(4.0)); v.push(button_view( if model.leyes_node_mode { "▦ vista: grafo → laboratorio" } else { "▦ vista: laboratorio → grafo" }, &btn, Msg::ToggleLeyesView, )); v.push(spacer(6.0)); v.push(button_view( if model.simulating { "⏸ pausar" } else { "▶ simular" }, &btn, Msg::ToggleSim, )); v.push(spacer(4.0)); v.push(button_view("⟲ resembrar", &btn, Msg::ReseedLab)); if campos.len() > 1 { let vis = model.lab.as_ref().map(|x| x.vis).unwrap_or(0); let vname = campos.get(vis).map(|c| c.name.as_str()).unwrap_or("?"); v.push(spacer(4.0)); v.push(button_view(format!("ver campo: {vname}"), &btn, Msg::CycleLabField)); } // Error de compilación, si la fórmula no parsea. if let Some(Err(err)) = l.kind.compile_ecuacion() { v.push(spacer(6.0)); v.push(body_text(format!("⚠ {err}"), Color::from_rgba8(232, 128, 96, 255), theme)); } v.push(spacer(8.0)); v.push(section_title("ECUACIONES (Δcampo/dt)", theme)); for (i, campo) in campos.iter().enumerate() { v.push(body_text(format!("Δ{}/dt =", campo.name), theme.fg_muted, theme)); let editing = model.formula_focused && model.formula_target == i; if editing { v.push(text_input_view_full( &model.formula_input, "fórmula…", true, &TextInputPalette::from_theme(theme), Msg::FormulaCampo, )); } else { let src = fuentes.get(i).cloned().unwrap_or_default(); let label = if src.is_empty() { "(tocar para editar)".to_string() } else { src }; v.push(button_view(label, &btn, Msg::FormulaFocus(i))); } v.push(spacer(4.0)); } v.push(button_view("+ campo", &btn, Msg::AddCampo)); if campos.len() > 1 { v.push(spacer(4.0)); v.push(button_view("− campo", &btn, Msg::RemoveCampo)); } v.push(spacer(8.0)); v.push(section_title("PARÁMETROS", theme)); for (i, (name, value, min, max)) in l.kind.ecuacion_params().into_iter().enumerate() { v.push(slider_view(name, value, min, max, &sp, move |_p, dv| { Some(Msg::SetEcuParam(i, value + dv)) })); } v.push(spacer(4.0)); v.push(button_view("+ parámetro", &btn, Msg::AddEcuParam)); v.push(spacer(4.0)); v.push(button_view("− parámetro", &btn, Msg::RemoveEcuParam)); v.push(spacer(8.0)); v.push(body_text( "términos: lap(c) · avg(c) · min6(c)/max6(c)/sum6(c) · abajo(c)… · clamp/min/max · < > · dt".into(), theme.fg_placeholder, theme, )); // Paleta de nodos (sólo en modo grafo): tocar = agregar un nodo suelto. if model.leyes_node_mode { v.push(spacer(8.0)); v.push(section_title("NODOS (tocar = agregar)", theme)); v.push(button_view("const 1", &btn, Msg::GraphAddNode(NodeOp::Const(1.0)))); v.push(button_view("dt", &btn, Msg::GraphAddNode(NodeOp::Dt))); for (f, c) in campos.iter().enumerate() { let f = f as u16; v.push(button_view(format!("campo {}", c.name), &btn, Msg::GraphAddNode(NodeOp::Field(f)))); v.push(button_view(format!("lap {}", c.name), &btn, Msg::GraphAddNode(NodeOp::Lap(f)))); v.push(button_view(format!("avg {}", c.name), &btn, Msg::GraphAddNode(NodeOp::Vecinos(Reduce::Promedio, f)))); } for (p, pd) in params.iter().enumerate() { v.push(button_view(format!("param {}", pd.name), &btn, Msg::GraphAddNode(NodeOp::Param(p as u16)))); } for (lbl, op) in [ ("+", NodeOp::Bin(BinOp::Add)), ("−", NodeOp::Bin(BinOp::Sub)), ("×", NodeOp::Bin(BinOp::Mul)), ("÷", NodeOp::Bin(BinOp::Div)), ("min", NodeOp::Bin(BinOp::Min)), ("max", NodeOp::Bin(BinOp::Max)), ("<", NodeOp::Bin(BinOp::Lt)), (">", NodeOp::Bin(BinOp::Gt)), ("abs", NodeOp::Un(UnOp::Abs)), ("−x", NodeOp::Un(UnOp::Neg)), ("clamp", NodeOp::Clamp), ] { v.push(button_view(lbl, &btn, Msg::GraphAddNode(op))); } v.push(spacer(4.0)); v.push(body_text( "arrastra salida→entrada para conectar · right‑click borra un nodo".into(), theme.fg_placeholder, theme, )); } } // --- Leyes nativas (Fluir/Crecer): sliders cableados ------------------- _ => { v.push(spacer(6.0)); v.push(section_title("PARÁMETROS", theme)); if l.kind.params().is_empty() { v.push(body_text("este tipo no tiene parámetros".into(), theme.fg_placeholder, theme)); } for (i, (name, value, min, max)) in l.kind.params().into_iter().enumerate() { v.push(slider_view(name, value, min, max, &sp, move |_p, dv| { Some(Msg::SetLeyParam(i, value + dv)) })); } } } v } pub(crate) fn material_editor(model: &Model, tab: usize) -> Vec> { let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let Some(m) = model.project.material(model.sel[1]) else { return vec![section_title("sin material — crea uno", theme)]; }; if tab == 0 { let resolved = model.project.resolve_material(m.id); let parent_name = m .parent .and_then(|pid| model.project.material(pid)) .map(|p| p.name.clone()) .unwrap_or_else(|| "— ninguno".into()); let rgb = m.color.unwrap_or([ resolved.color[0] as f32 / 255.0, resolved.color[1] as f32 / 255.0, resolved.color[2] as f32 / 255.0, ]); let grain = m.grain.unwrap_or(resolved.grain); let mut v = vec![ button_view(format!("rol: {}", m.role.label()), &btn, Msg::CycleMatRole), spacer(4.0), button_view(format!("padre: {parent_name}"), &btn, Msg::CycleMatParent), spacer(6.0), section_title("COLOR", theme), ]; for (ch, label) in [(0usize, "rojo"), (1, "verde"), (2, "azul")] { let val = rgb[ch]; v.push(slider_view(label, val, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetMatColor(ch, val + dv)))); } v.push(slider_view("grano", grain, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetMatGrain(grain + dv)))); let brillo = m.brillo.unwrap_or(resolved.brillo); v.push(slider_view("brillo (lustre)", brillo, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetMatBrillo(brillo + dv)))); // Detalle sub-voxel (look global del ray-march; el preview aplica el del // material en foco). Rompe la rectangularidad del voxel a corta distancia. v.push(spacer(6.0)); v.push(section_title("DETALLE SUB-VOXEL", theme)); let (rug, bis) = model.project.detalle_material(m.id); let rug_v = m.rugosidad.unwrap_or(rug); v.push(slider_view("rugosidad", rug_v, 0.0, 1.5, &sp, move |_p, dv| Some(Msg::SetMatRugosidad(rug_v + dv)))); let bis_v = m.bisel.unwrap_or(bis); v.push(slider_view("bisel (pendientes)", bis_v, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetMatBisel(bis_v + dv)))); v } else { let mut v: Vec> = Vec::new(); for (i, u) in m.leyes.iter().enumerate() { let name = model.project.ley(u.ley).map(|l| l.name.clone()).unwrap_or_else(|| "?".into()); v.push(button_view(format!("· {name} (cambiar)"), &btn, Msg::CycleMatLey(i))); } if m.leyes.is_empty() { v.push(body_text("sin leyes aplicadas".into(), theme.fg_placeholder, theme)); } v.push(spacer(6.0)); v.push(button_view("+ agregar ley", &btn, Msg::AddMatLey)); v.push(spacer(4.0)); v.push(button_view("− quitar última", &btn, Msg::RemoveMatLey)); v } } pub(crate) fn sere_editor(model: &Model, tab: usize) -> Vec> { let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let Some(c) = sel_sere(model) else { return vec![section_title("sin ser — crea o genera uno", theme)]; }; match tab { 0 => vec![ button_view(format!("forma: {}", c.cuerpo_label()), &btn, Msg::CycleSereCuerpo), spacer(4.0), especie_tools(c, &btn), spacer(4.0), button_view(format!("edad: {}", c.age.label()), &btn, Msg::CycleSereAge), ], 1 => andar_tools(model, c), 2 => conducta_tools(model, c), 3 => color_tools(Part::Skin, c.skin, &sp), 4 => color_tools(Part::Shirt, c.shirt, &sp), 5 => color_tools(Part::Pants, c.pants, &sp), _ => vestuario_tools(model, c), } } /// La **especie** del ser: ninguna (persona o rig) o un bicho del bestiario, que se /// filma con su cuerpo SDF y se dirige como cualquier actor. Dos botones porque son /// setenta y de a uno hacia adelante no se vuelve nunca. /// El nombre del animal sale de `Bicho::label`, que está en castellano igual que en el /// editor de fauna del bioma. pub(crate) fn especie_tools(c: &CharSpec, btn: &ButtonPalette) -> View { use rimay_localize::{t, t_args}; let nombre = match c.bicho { Some(b) => b.label().to_string(), None => t("voxelstudio-species-none"), }; let rotulo = t_args("voxelstudio-species", &[("nombre", nombre.into())]); crud_pair("◀", Msg::CycleSereBicho(true), &rotulo, Msg::CycleSereBicho(false), btn) } /// Editor del **vestuario** de un ser: peinado, barba, sombrero, anteojos, falda y /// manto, y el color de cada prenda que lo tiene. Lo que se ve acá es lo que viste el /// ser SDF —el del preview y el que filma `ende`—. pub(crate) fn vestuario_tools(model: &Model, c: &CharSpec) -> Vec> { use rimay_localize::{t, t_args}; let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let a = &c.atuendo; let nombre = |k: &str| t_args(k, &[("nombre", t(&format!("voxelstudio-{}", clave_prenda(a, k))).into())]); let mut v = vec![ button_view(nombre("voxelstudio-hair"), &btn, Msg::CycleSerePeinado), spacer(4.0), button_view(nombre("voxelstudio-hat"), &btn, Msg::CycleSereSombrero), spacer(4.0), button_view( t(if a.anteojos { "voxelstudio-glasses-on" } else { "voxelstudio-glasses-off" }), &btn, Msg::ToggleSereAnteojos, ), spacer(6.0), ]; for (campo, clave, val) in [ (PrendaField::Barba, "voxelstudio-beard", a.barba), (PrendaField::Falda, "voxelstudio-skirt", a.falda), (PrendaField::Manto, "voxelstudio-cloak", a.manto), ] { v.push(slider_view(t(clave), val, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetSerePrenda(campo, val + dv)))); } // El color de una prenda sólo aparece cuando la prenda está puesta: un color // de falda sin falda es una perilla que no mueve nada. if a.sombrero != Sombrero::Ninguno { v.push(spacer(6.0)); v.push(section_title(&t("voxelstudio-color-hat"), theme)); v.extend(color_tools(Part::Sombrero, a.color_sombrero_efectivo(c.shirt), &sp)); } if a.falda > 0.01 { v.push(spacer(6.0)); v.push(section_title(&t("voxelstudio-color-skirt"), theme)); v.extend(color_tools(Part::Falda, a.color_falda_efectivo(c.shirt), &sp)); } if a.manto > 0.01 { v.push(spacer(6.0)); v.push(section_title(&t("voxelstudio-color-cloak"), theme)); v.extend(color_tools(Part::Manto, a.color_manto_efectivo(c.shirt), &sp)); } v } /// El sufijo de la clave del nombre de la prenda vigente: `hair-` o /// `hat-`. Va en inglés porque es un identificador, no un rótulo. pub(crate) fn clave_prenda(a: &llimphi_voxel::Atuendo, cual: &str) -> &'static str { if cual == "voxelstudio-hair" { match a.peinado { Peinado::Casquete => "hair-cap", Peinado::Corto => "hair-short", Peinado::Melena => "hair-bob", Peinado::Larga => "hair-long", Peinado::Rodete => "hair-bun", Peinado::Coleta => "hair-ponytail", Peinado::DosColas => "hair-pigtails", Peinado::Afro => "hair-afro", Peinado::Cresta => "hair-mohawk", Peinado::Calvo => "hair-bald", } } else { match a.sombrero { Sombrero::Ninguno => "hat-none", Sombrero::Gorra => "hat-cap", Sombrero::Ala => "hat-brimmed", Sombrero::Gorro => "hat-beanie", Sombrero::Panuelo => "hat-headscarf", Sombrero::Casco => "hat-helmet", Sombrero::Corona => "hat-crown", } } } /// Si el ser todavía no tiene impulsos propios (datos viejos), los siembra con los de /// fábrica para que el editor tenga qué mostrar/editar (mismo comportamiento en vivo). pub(crate) fn materializar_impulsos(c: &mut llimphi_voxel::CharSpec) { if c.conducta.impulsos.is_empty() { c.conducta.impulsos = llimphi_voxel::default_impulsos(); } } /// Editor de la **conducta** (capa 3) de un ser: sliders de locomoción (params) + /// **impulsos de steering autorables** (primitiva ciclable + fórmula de peso, la misma /// máquina de ecuaciones que las Leyes) + toggle para soltar una manada viva. pub(crate) fn conducta_tools(model: &Model, c: &llimphi_voxel::CharSpec) -> Vec> { let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); // El cabezal ("Conducta") lo pinta el widget; arranca con los sliders de params. let mut v: Vec> = Vec::new(); for (i, (name, value, min, max)) in c.conducta.params().into_iter().enumerate() { v.push(slider_view(name, value, min, max, &sp, move |_p, dv| Some(Msg::SetConducta(i, value + dv)))); } // --- Impulsos de steering (dirección base × fórmula de peso) ----------------- v.push(spacer(8.0)); v.push(section_title("IMPULSOS (dirección × peso)", theme)); let impulsos = c.conducta.impulsos_efectivos(); let sym = llimphi_voxel::Conducta::symbols(); for (i, imp) in impulsos.iter().enumerate() { v.push(button_view(format!("{} · {}", i + 1, imp.primitiva.label()), &btn, Msg::CondCycleImpulso(i))); let editing = model.cond_formula_focused && model.cond_formula_target == i; if editing { v.push(text_input_view_full( &model.cond_formula_input, "peso…", true, &TextInputPalette::from_theme(theme), Msg::CondFormulaCampo, )); } else { let src = if imp.peso.is_empty() { "(tocar para editar)".to_string() } else { imp.peso.clone() }; v.push(button_view(format!("peso = {src}"), &btn, Msg::CondFormulaFocus(i))); } // Error de parseo de esta fórmula, si lo hay. if let Err(err) = llimphi_voxel::Expr::parse(&imp.peso, &sym) { v.push(body_text(format!("⚠ {err}"), Color::from_rgba8(232, 128, 96, 255), theme)); } v.push(spacer(4.0)); } v.push(button_view("+ impulso", &btn, Msg::CondAddImpulso)); if impulsos.len() > 1 { v.push(spacer(4.0)); v.push(button_view("− impulso", &btn, Msg::CondRemoveImpulso)); } v.push(spacer(6.0)); v.push(body_text( "percepts: cercania_manada · cercania_amenaza · apinamiento · azar · en_suelo · dt".into(), theme.fg_placeholder, theme, )); v.push(spacer(8.0)); v.push(section_title("PREVIEW", theme)); v.push(button_view( if model.manada { "⏸ detener manada" } else { "▶ soltar manada" }, &btn, Msg::ToggleManada, )); v.push(spacer(4.0)); v.push(body_text("una manada del ser deambula/se junta/huye según sus impulsos".into(), theme.fg_placeholder, theme)); v } /// Editor del **andar** (capa 2) de un ser-rig: estado a editar/previsualizar + /// cadencia + cuánto balancea cada articulación. El humanoide usa animaciones fijas. pub(crate) fn andar_tools<'a>(model: &Model, c: &'a llimphi_voxel::CharSpec) -> Vec> { let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let Some(mov) = &c.cuerpo else { return vec![body_text( "el humanoide usa animaciones fijas — cambia la «forma» a un rig (cuadrúpedo, ave…) para editar su andar".into(), theme.fg_placeholder, theme, )]; }; let est = model.andar_estado.min(2); let andar = mov.andares.estado(est); let mut v = vec![ button_view( format!("estado: {}", llimphi_voxel::Andares::LABELS[est]), &btn, Msg::CycleAndarEstado, ), spacer(4.0), slider_view("cadencia (ritmo)", andar.cadencia, 0.0, 18.0, &sp, { let cad = andar.cadencia; move |_p, dv| Some(Msg::SetAndarCadencia(cad + dv)) }), spacer(6.0), section_title("BALANCEO POR PARTE", theme), ]; for (i, seg) in mov.rig.segmentos.iter().enumerate() { let amp = andar.osc.get(i).map(|o| o.amplitud).unwrap_or(0.0); v.push(slider_view(&seg.nombre, amp, 0.0, 1.5, &sp, move |_p, dv| { Some(Msg::SetAndarAmplitud(i, amp + dv)) })); } v } pub(crate) fn bioma_editor(model: &Model, tab: usize) -> Vec> { let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let Some(b) = model.project.bioma(model.sel[3]) else { return vec![section_title("sin bioma — crea uno", theme)]; }; let mat_name = |id: u64| model.project.material(id).map(|m| m.name.clone()).unwrap_or_else(|| "—".into()); match tab { 0 => vec![ bslider(&sp, "base (llanura)", b.base, 0.0, 0.9, BiomaField::Base), bslider(&sp, "dunas", b.dune, 0.0, 0.4, BiomaField::Dune), bslider(&sp, "relieve (alto)", b.relief, 0.0, 1.0, BiomaField::Relief), bslider(&sp, "densidad montañas", b.mountains, 0.0, 1.0, BiomaField::Mountains), bslider(&sp, "nivel del agua", b.water_level, 0.0, 0.9, BiomaField::Water), bslider(&sp, "densidad ríos", b.rivers, 0.0, 1.0, BiomaField::Rivers), bslider(&sp, "altura cumbre", b.peak_at, 0.0, 1.0, BiomaField::PeakAt), spacer(8.0), section_title("SIMULAR LEYES", theme), button_view(if model.simulating { "⏸ detener" } else { "▶ simular (agua · plantas)" }, &btn, Msg::ToggleSim), ], 1 => vec![ button_view(format!("suelo: {}", mat_name(b.ground)), &btn, Msg::CycleBiomaGround), spacer(4.0), button_view(format!("acantilado: {}", mat_name(b.cliff)), &btn, Msg::CycleBiomaCliff), spacer(4.0), button_view( format!("cumbre: {}", b.peak.map(mat_name).unwrap_or_else(|| "ninguna".into())), &btn, Msg::CycleBiomaPeak, ), ], 2 => { let mut v: Vec> = Vec::new(); for (i, o) in b.objetos.iter().enumerate() { let name = mat_name(o.material); v.push(button_view(format!("forma: {}", o.forma.label()), &btn, Msg::CycleObjetoForma(i))); v.push(spacer(3.0)); v.push(button_view(format!("material: {name}"), &btn, Msg::CycleObjetoMaterial(i))); let d = o.densidad; v.push(slider_view("densidad", d, 0.0, 0.05, &sp, move |_p, dv| Some(Msg::SetObjetoDensidad(i, d + dv)))); v.push(spacer(6.0)); } if b.objetos.is_empty() { v.push(body_text("sin objetos".into(), theme.fg_placeholder, theme)); } v.push(spacer(6.0)); v.push(button_view("+ objeto", &btn, Msg::AddBiomaObjeto)); v.push(spacer(4.0)); v.push(button_view("− quitar", &btn, Msg::RemoveBiomaObjeto)); v } 3 => mineral_editor(model, &sp, &btn), 4 => casa_editor(model, &sp, &btn), 5 => fauna_editor(model, &sp, &btn), _ => { let mut v: Vec> = Vec::new(); for (i, u) in b.seres.iter().enumerate() { let name = model.project.sere(u.sere).map(|s| s.name.clone()).unwrap_or_else(|| "—".into()); v.push(button_view(format!("ser: {name}"), &btn, Msg::CycleBiomaSere(i))); let prob = u.probabilidad; v.push(slider_view("cantidad", prob, 0.0, 1.0, &sp, move |_p, dv| { Some(Msg::SetBiomaSereProb(i, prob + dv)) })); v.push(spacer(4.0)); } if b.seres.is_empty() { v.push(body_text("sin seres — agrega para poblar el bioma".into(), theme.fg_placeholder, theme)); } v.push(spacer(4.0)); v.push(button_view("+ ser", &btn, Msg::AddBiomaSere)); v.push(spacer(4.0)); v.push(button_view("− quitar", &btn, Msg::RemoveBiomaSere)); v.push(spacer(8.0)); v.push(body_text("se ven con «▶ simular» en Mundos/Biomas".into(), theme.fg_placeholder, theme)); v } } } /// Editor de las **casas** que el bioma esparce (pueblo procedural por semilla): /// por uso, el estilo y su densidad. El mundo las genera al cambiar la semilla. pub(crate) fn casa_editor(model: &Model, sp: &SliderPalette, btn: &ButtonPalette) -> Vec> { let theme = &model.theme; let Some(b) = model.project.bioma(model.sel[3]) else { return vec![section_title("sin bioma — crea uno", theme)]; }; let mut v = vec![ section_title("CASAS (PUEBLO)", theme), body_text("se esparcen por la semilla del mundo".into(), theme.fg_placeholder, theme), spacer(6.0), ]; // Preset de tipo de pueblo: rellena la mezcla de casas + densidad de un toque. v.push(button_view(format!("preset: {}", model.pueblo_brush.label()), btn, Msg::CyclePuebloTipo)); v.push(spacer(3.0)); v.push(button_view("aplicar preset", btn, Msg::AplicarPuebloTipo)); v.push(spacer(8.0)); // Densidad urbana: cuánto se agrupan las casas (manchas de pueblo) vs campo abierto. let du = b.densidad_urbana; v.push(slider_view("densidad urbana", du, 0.05, 0.95, sp, move |_p, dv| { Some(Msg::SetDensidadUrbana(du + dv)) })); v.push(spacer(8.0)); for (i, cu) in b.casas.iter().enumerate() { v.push(button_view(format!("estilo: {}", cu.tipo.label()), btn, Msg::CycleCasaTipo(i))); let d = cu.densidad; v.push(slider_view("densidad", d, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetCasaDensidad(i, d + dv)))); v.push(spacer(6.0)); } if b.casas.is_empty() { v.push(body_text("sin casas — pon algunas para armar un pueblo".into(), theme.fg_placeholder, theme)); } v.push(spacer(6.0)); v.push(button_view("+ casa", btn, Msg::AddBiomaCasa)); v.push(spacer(4.0)); v.push(button_view("- quitar", btn, Msg::RemoveBiomaCasa)); v } /// Editor de la **fauna** del bioma: qué animales ([`Bicho`]) pueblan el mundo y /// con qué densidad. El mundo los esparce por semilla, parados sobre el terreno. pub(crate) fn fauna_editor(model: &Model, sp: &SliderPalette, btn: &ButtonPalette) -> Vec> { let theme = &model.theme; let Some(b) = model.project.bioma(model.sel[3]) else { return vec![section_title("sin bioma — crea uno", theme)]; }; let mut v = vec![ section_title("FAUNA (SERES)", theme), body_text("animales esparcidos por la semilla del mundo".into(), theme.fg_placeholder, theme), spacer(6.0), ]; for (i, fu) in b.fauna.iter().enumerate() { v.push(button_view(format!("ser: {}", fu.bicho.label()), btn, Msg::CycleFaunaBicho(i))); let d = fu.densidad; v.push(slider_view("densidad", d, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetFaunaDensidad(i, d + dv)))); v.push(spacer(6.0)); } if b.fauna.is_empty() { v.push(body_text("sin fauna — agrega animales para dar vida al bioma".into(), theme.fg_placeholder, theme)); } v.push(spacer(6.0)); v.push(button_view("+ animal", btn, Msg::AddBiomaFauna)); v.push(spacer(4.0)); v.push(button_view("- quitar", btn, Msg::RemoveBiomaFauna)); v } /// Editor de los **minerales** del bioma seleccionado, con **todos sus factores**: /// tipo semilla, aspecto, abundancia, banda de profundidad, color y —lo que el /// ray-march sí puede pintar— dureza (grano) y brillo (lustre especular). pub(crate) fn mineral_editor(model: &Model, sp: &SliderPalette, btn: &ButtonPalette) -> Vec> { let theme = &model.theme; let Some(b) = model.project.bioma(model.sel[3]) else { return vec![section_title("sin bioma — crea uno", theme)]; }; let mut v: Vec> = Vec::new(); for (i, mu) in b.minerales.iter().enumerate() { v.push(button_view(format!("mineral: {}", mu.mineral.label()), btn, Msg::CycleMineralTipo(i))); v.push(spacer(3.0)); v.push(button_view(format!("aspecto: {}", mu.aspecto.label()), btn, Msg::CycleMineralAspecto(i))); let ab = mu.abundancia; v.push(slider_view("abundancia", ab, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralAbundancia(i, ab + dv)))); let pmin = mu.prof_min; v.push(slider_view("prof. mín", pmin, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralProfMin(i, pmin + dv)))); let pmax = mu.prof_max; v.push(slider_view("prof. máx", pmax, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralProfMax(i, pmax + dv)))); // Color (RGB). 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); v.push(slider_view("color R", r, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralColor(i, 0, r + dv)))); v.push(slider_view("color G", g, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralColor(i, 1, g + dv)))); v.push(slider_view("color B", bl, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralColor(i, 2, bl + dv)))); // Dureza Mohs (grano) y brillo (lustre especular). let dur = mu.dureza; v.push(slider_view("dureza (Mohs)", dur, 1.0, 10.0, sp, move |_p, dv| Some(Msg::SetMineralDureza(i, dur + dv)))); let br = mu.brillo; v.push(slider_view("brillo (lustre)", br, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetMineralBrillo(i, br + dv)))); v.push(spacer(8.0)); } if b.minerales.is_empty() { v.push(body_text("sin minerales — el terreno queda de pura roca".into(), theme.fg_placeholder, theme)); } v.push(spacer(6.0)); v.push(button_view("+ mineral", btn, Msg::AddBiomaMineral)); v.push(spacer(4.0)); v.push(button_view("− quitar", btn, Msg::RemoveBiomaMineral)); v } pub(crate) fn mundo_editor(model: &Model, tab: usize) -> Vec> { let theme = &model.theme; let btn = ButtonPalette::from_theme(theme); let Some(m) = model.project.mundo(model.sel[4]) else { return vec![section_title("sin mundo — crea uno", theme)]; }; if tab == 0 { vec![ text_input_view_full( &model.seed_input, "número…", model.seed_focused, &TextInputPalette::from_theme(theme), Msg::SeedCampo, ), spacer(6.0), button_view("🎲 random", &btn, Msg::SeedRandom), spacer(8.0), body_text(format!("semilla actual: {}", m.seed), theme.fg_muted, theme), spacer(10.0), section_title("SIMULAR LEYES", theme), button_view(if model.simulating { "⏸ detener" } else { "▶ simular (agua · plantas)" }, &btn, Msg::ToggleSim), ] } else { let bname = m .biomas .first() .and_then(|&id| model.project.bioma(id)) .map(|b| b.name.clone()) .unwrap_or_else(|| "—".into()); vec![ button_view(format!("bioma: {bname}"), &btn, Msg::CycleMundoBioma), spacer(8.0), body_text("un mundo se compone de uno o más biomas".into(), theme.fg_placeholder, theme), ] } } pub(crate) fn scene_editor(model: &Model, tab: usize) -> Vec> { let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let Some(s) = sel_scene(model) else { return vec![section_title("sin escena — genera una con IA", theme)]; }; match tab { 0 => { let dur = s.duration; let mundo_name = model.project.mundo(s.mundo).map(|m| m.name.clone()).unwrap_or_else(|| "—".into()); let t = model.time; vec![ button_view(format!("mundo: {mundo_name}"), &btn, Msg::CycleSceneMundo), spacer(6.0), slider_view("duración (s)", dur, 1.0, 20.0, &sp, move |_p, dv| Some(Msg::SetSceneDur(dur + dv))), spacer(6.0), button_view(if model.playing { "❚❚ pausa" } else { "▶ reproducir" }, &btn, Msg::TogglePlay), spacer(4.0), slider_view("tiempo", t.min(dur), 0.0, dur, &sp, move |_p, dv| Some(Msg::Scrub(t + dv))), spacer(6.0), body_text(format!("{} actores", s.actors.len()), theme.fg_muted, theme), ] .into_iter() .chain(cielo_tools(model, s)) .collect() } 1 => reparto_tools(model, s), 2 => { let cam_label = if model.script_cam { "cámara: guion" } else { "cámara: órbita" }; vec![ button_view(cam_label, &btn, Msg::ToggleSceneCam), spacer(4.0), body_text(format!("{} planos", s.shots.len()), theme.fg_muted, theme), spacer(4.0), button_view("+ plano aquí", &btn, Msg::AddShot), spacer(4.0), button_view("− quitar plano", &btn, Msg::RemoveShot), ] } 3 => utileria_tools(model, s), 4 => decorado_tools(model, s), _ => vec![ button_view(if model.exporting { "exportando…" } else { "🎬 exportar video" }, &btn, Msg::ExportVideo), spacer(8.0), body_text("renderiza el guion a un .mkv (puede tardar)".into(), theme.fg_placeholder, theme), ], } } /// **Decorado** de la escena: qué construcción (el palacio, el kiosko…), dónde /// va en el mundo y qué variante. Se planta nivelando el suelo bajo su huella. pub(crate) fn decorado_tools(model: &Model, s: &SceneSpec) -> Vec> { use rimay_localize::{t, t_args}; let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let i = model.decorado_sel.min(s.decorado.len().saturating_sub(1)); let mut v: Vec> = Vec::new(); for (k, d) in s.decorado.iter().enumerate() { let label = t_args( "voxelstudio-set-row", &[ ("nombre", t(clave_casa(d.casa)).into()), ("x", format!("{:.0}", d.pos[0]).into()), ("z", format!("{:.0}", d.pos[1]).into()), ], ); v.push(selectable_row(label, k == i, Msg::SelectDecorado(k), theme)); } if s.decorado.is_empty() { v.push(body_text(t("voxelstudio-set-empty"), theme.fg_placeholder, theme)); } v.push(spacer(6.0)); v.push(crud_pair(&t("voxelstudio-set-add"), Msg::AddDecorado, &t("voxelstudio-set-remove"), Msg::RemoveDecorado, &btn)); let Some(d) = s.decorado.get(i) else { return v }; v.push(spacer(8.0)); v.push(button_view(t_args("voxelstudio-set-kind", &[("nombre", t(clave_casa(d.casa)).into())]), &btn, Msg::CycleDecoradoCasa)); v.push(spacer(4.0)); v.push(button_view(t("voxelstudio-set-variant"), &btn, Msg::OtraVarianteDecorado)); v.push(spacer(6.0)); let (x, z) = (d.pos[0], d.pos[1]); 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)))); 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)))); v } /// La clave del nombre de cada estilo de casa en los catálogos. pub(crate) fn clave_casa(c: CasaTipo) -> &'static str { match c { CasaTipo::Flintstone => "voxelstudio-house-flintstone", CasaTipo::Hobbit => "voxelstudio-house-hobbit", CasaTipo::Cabana => "voxelstudio-house-cabin", CasaTipo::Moderna => "voxelstudio-house-modern", CasaTipo::Edificio => "voxelstudio-house-apartments", CasaTipo::Iglu => "voxelstudio-house-igloo", CasaTipo::Palafito => "voxelstudio-house-stilt", CasaTipo::Adobe => "voxelstudio-house-adobe", CasaTipo::Rascacielos => "voxelstudio-house-skyscraper", CasaTipo::Granja => "voxelstudio-house-farm", CasaTipo::Torre => "voxelstudio-house-tower", CasaTipo::Faro => "voxelstudio-house-lighthouse", CasaTipo::Choza => "voxelstudio-house-hut", CasaTipo::Tienda => "voxelstudio-house-shop", CasaTipo::Duplex => "voxelstudio-house-duplex", CasaTipo::Palacio => "voxelstudio-house-palace", CasaTipo::Kiosko => "voxelstudio-house-kiosk", } } /// **Cielo** de la escena: de día (la luz de quien filma) o de noche, con cuántas /// estrellas y qué tan grande la luna. pub(crate) fn cielo_tools(model: &Model, s: &SceneSpec) -> Vec> { use rimay_localize::t; let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let mut v = vec![spacer(10.0)]; let Some(c) = s.cielo else { v.push(button_view(t("voxelstudio-sky-day"), &btn, Msg::ToggleNoche)); return v; }; v.push(button_view(t("voxelstudio-sky-night"), &btn, Msg::ToggleNoche)); v.push(spacer(4.0)); let (estrellas, luna) = (c.estrellas, c.luna_radio); v.push(slider_view(t("voxelstudio-sky-stars"), estrellas, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetCielo(CieloField::Estrellas, estrellas + dv)))); v.push(slider_view(t("voxelstudio-sky-moon"), luna, 0.0, 0.15, &sp, move |_p, dv| Some(Msg::SetCielo(CieloField::Luna, luna + dv)))); v } /// **Utilería** de la escena: qué pieza, dónde (en el mundo o en la mano de un /// actor), de qué tamaño, desde cuándo y si gira. Lo que se ve en el preview es lo /// que filma `ende`. pub(crate) fn utileria_tools(model: &Model, s: &SceneSpec) -> Vec> { use rimay_localize::{t, t_args}; let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let dim = world_dim(PREVIEW_DIM_XZ); let i = model.utileria_sel.min(s.utileria.len().saturating_sub(1)); let nombre_de = |u: &UtileriaSpec| t(clave_utileria(u.cosa)); let mut v: Vec> = Vec::new(); for (k, u) in s.utileria.iter().enumerate() { let label = t_args( "voxelstudio-prop-row", &[("nombre", nombre_de(u).into()), ("desde", format!("{:.1}", u.desde).into())], ); v.push(selectable_row(label, k == i, Msg::SelectUtileria(k), theme)); } if s.utileria.is_empty() { v.push(body_text(t("voxelstudio-prop-empty"), theme.fg_placeholder, theme)); } v.push(spacer(6.0)); v.push(crud_pair(&t("voxelstudio-prop-add"), Msg::AddUtileria, &t("voxelstudio-prop-remove"), Msg::RemoveUtileria, &btn)); let Some(u) = s.utileria.get(i) else { return v }; v.push(spacer(8.0)); v.push(button_view(t_args("voxelstudio-prop-kind", &[("nombre", nombre_de(u).into())]), &btn, Msg::CycleUtileriaCosa)); v.push(spacer(4.0)); let quien = match u.lleva { Some(a) => { let nombre = s .actors .get(a) .and_then(|act| model.project.seres.get(act.character)) .map(|c| c.name.clone()) .unwrap_or_else(|| "—".into()); t_args("voxelstudio-prop-held-by", &[("nombre", nombre.into())]) } None => t("voxelstudio-prop-held-nobody"), }; v.push(button_view(quien, &btn, Msg::CycleUtileriaLleva)); v.push(spacer(6.0)); if u.lleva.is_none() { for (campo, clave, val, max) in [ (UtileriaField::X, "voxelstudio-prop-x", u.pos[0], dim[0] as f32), (UtileriaField::Y, "voxelstudio-prop-y", u.pos[1], dim[1] as f32), (UtileriaField::Z, "voxelstudio-prop-z", u.pos[2], dim[2] as f32), ] { v.push(slider_view(t(clave), val, 0.0, max, &sp, move |_p, dv| Some(Msg::SetUtileria(campo, val + dv)))); } } let alto = if u.alto > 0.0 { u.alto } else { u.cosa.alto() }; let (desde, giro) = (u.desde, u.giro); v.push(slider_view(t("voxelstudio-prop-size"), alto, 0.02, 4.0, &sp, move |_p, dv| Some(Msg::SetUtileria(UtileriaField::Alto, alto + dv)))); v.push(slider_view(t("voxelstudio-prop-from"), desde, 0.0, s.duration, &sp, move |_p, dv| Some(Msg::SetUtileria(UtileriaField::Desde, desde + dv)))); v.push(slider_view(t("voxelstudio-prop-spin"), giro, 0.0, 1.0, &sp, move |_p, dv| Some(Msg::SetUtileria(UtileriaField::Giro, giro + dv)))); v } /// La clave del nombre de cada pieza en los catálogos. pub(crate) fn clave_utileria(u: Utileria) -> &'static str { match u { Utileria::Estrella => "voxelstudio-prop-star", Utileria::Flor => "voxelstudio-prop-flower", Utileria::Perla => "voxelstudio-prop-pearl", Utileria::Pluma => "voxelstudio-prop-feather", Utileria::Prendedor => "voxelstudio-prop-brooch", } } /// **Reparto** (filmografía): dirigir quién actúa y su recorrido (waypoints). pub(crate) fn reparto_tools(model: &Model, s: &SceneSpec) -> Vec> { let theme = &model.theme; let sp = SliderPalette::from_theme(theme); let btn = ButtonPalette::from_theme(theme); let dimx = world_dim(PREVIEW_DIM_XZ)[0] as f32; let a = model.actor_sel.min(s.actors.len().saturating_sub(1)); let mut v: Vec> = Vec::new(); for (i, act) in s.actors.iter().enumerate() { let nombre = model.project.seres.get(act.character).map(|c| c.name.clone()).unwrap_or_else(|| "—".into()); let label = format!("{nombre} · {} claves", act.keys.len()); v.push(selectable_row(label, i == a, Msg::SelectActor(i), theme)); } v.push(spacer(6.0)); v.push(crud_pair("+ actor", Msg::AddActor, "− actor", Msg::RemoveActor, &btn)); let Some(act) = s.actors.get(a) else { return v }; let nombre = model.project.seres.get(act.character).map(|c| c.name.clone()).unwrap_or_else(|| "—".into()); v.push(spacer(8.0)); v.push(button_view(format!("actúa: {nombre}"), &btn, Msg::CycleActorSere)); // Recorrido: las claves (waypoints) del actor seleccionado. v.push(spacer(8.0)); v.push(section_title("RECORRIDO", theme)); let k = model.key_sel.min(act.keys.len().saturating_sub(1)); for (i, key) in act.keys.iter().enumerate() { let clip = key.clip.unwrap_or(Clip::Idle).label(); v.push(selectable_row(format!("t={:.1}s · {clip}", key.t), i == k, Msg::SelectKey(i), theme)); } v.push(spacer(4.0)); v.push(crud_pair("+ clave", Msg::AddKey, "− clave", Msg::RemoveKey, &btn)); if let Some(key) = act.keys.get(k) { v.push(spacer(8.0)); v.push(section_title("CLAVE", theme)); let (gx, gz, kt) = (key.gx, key.gz, key.t); v.push(slider_view("posición X", gx, 0.0, dimx, &sp, move |_p, dv| Some(Msg::SetKeyPos(true, gx + dv)))); v.push(slider_view("posición Z", gz, 0.0, dimx, &sp, move |_p, dv| Some(Msg::SetKeyPos(false, gz + dv)))); v.push(slider_view("tiempo (s)", kt, 0.0, s.duration, &sp, move |_p, dv| Some(Msg::SetKeyTime(kt + dv)))); v.push(spacer(4.0)); v.push(button_view(format!("acción: {}", key.clip.unwrap_or(Clip::Idle).label()), &btn, Msg::CycleKeyClip)); } v.push(spacer(8.0)); v.push(body_text("dale ▶ reproducir en «Escena» para ver el recorrido".into(), theme.fg_placeholder, theme)); v } /// Par de botones en una fila (p.ej. agregar/quitar). pub(crate) fn crud_pair(a: &str, ma: Msg, b: &str, mb: Msg, btn: &ButtonPalette) -> View { View::new(Style { flex_direction: FlexDirection::Row, size: Size { width: percent(1.0_f32), height: Dimension::auto() }, gap: Size { width: length(4.0_f32), height: length(0.0_f32) }, ..Default::default() }) .children(vec![cell(button_view(a, btn, ma)), cell(button_view(b, btn, mb))]) } /// Slider de un campo de relieve del bioma. pub(crate) fn bslider(sp: &SliderPalette, label: &str, value: f32, min: f32, max: f32, field: BiomaField) -> View { slider_view(label, value, min, max, sp, move |_p, dv| Some(Msg::SetBiomaField(field, value + dv))) } /// Set de sliders R/G/B de una parte coloreable de un ser. El cabezal (Piel/Camiseta/ /// Pantalón) lo pinta el widget del sidebar, así que aquí van sólo los sliders. pub(crate) fn color_tools(part: Part, rgb: [f32; 3], sp: &SliderPalette) -> Vec> { let mut v: Vec> = Vec::new(); for (ch, label) in [(0usize, "rojo"), (1, "verde"), (2, "azul")] { let value = rgb[ch]; v.push(slider_view(label, value, 0.0, 1.0, sp, move |_p, dv| Some(Msg::SetSereColor(part, ch, value + dv)))); } v } // ============================================================================= // CRUD: crear / duplicar / borrar / renombrar / reseleccionar // =============================================================================