gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-voxel-studio/src/vista.rs Rust · 638 lines · 24.0 KB History Who touched what raw

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1use super::*;
2
3/// Sidebar izquierdo unificado (widget `rag-sidebar`): rail de **niveles** (dientes
4/// coloridos por nivel) + panel con la lista del nivel + CRUD + renombrar + IA +
5/// historia. Selector **fijo**: el click en un diente cambia de nivel
6/// (`Activate`→`SelectLevel`, interceptado en el `map`); el cabezal con el nombre del
7/// nivel lo pone el widget. El resto de mensajes (ejes/buscador/control/resize) van al
8/// estado del sidebar.
9pub(crate) fn left_sidebar(model: &Model) -> View<Msg> {
10    let theme = &model.theme;
11    let btn = ButtonPalette::from_theme(theme);
12
13    // Un diente por nivel; el icono lleva el color del nivel (ignora el que resuelve
14    // el rail, para conservar la paleta colorida por nivel).
15    let teeth: Vec<RagTooth<Msg>> = Level::ALL
16        .iter()
17        .map(|&lvl| {
18            RagTooth::new(
19                lvl.index() as u64,
20                lvl.label(),
21                Arc::new(move |_size, _color| icon_view(lvl.icon(), lvl.color(), 1.8)),
22            )
23        })
24        .collect();
25
26    // El buscador filtra la LISTA de items del nivel por nombre (real, insensible).
27    let filtro = model.sidebar_left.search.trim().to_lowercase();
28
29    // Contenido del panel (el cabezal con el nombre del nivel lo pone el widget).
30    let mut rows: Vec<View<Msg>> = Vec::new();
31    for (id, name) in model.items() {
32        if !filtro.is_empty() && !name.to_lowercase().contains(&filtro) {
33            continue;
34        }
35        rows.push(selectable_row(name, id == model.selected(), Msg::SelectItem(id), theme));
36    }
37    rows.push(spacer(8.0));
38    // Botonera CRUD en fila.
39    rows.push(crud_row(&btn));
40    rows.push(spacer(8.0));
41    rows.push(section_title("NOMBRE", theme));
42    rows.push(text_input_view_full(
43        &model.name_input,
44        "nombre…",
45        model.name_focused,
46        &TextInputPalette::from_theme(theme),
47        Msg::RenameCampo,
48    ));
49
50    // IA (según nivel).
51    if matches!(model.level, Level::Biomas | Level::Mundos | Level::Seres | Level::Escenas) {
52        let hint = match model.level {
53            Level::Biomas | Level::Mundos => "p.ej. islas con ríos y nieve",
54            Level::Seres => "p.ej. una niña de remera verde",
55            _ => "p.ej. tres personajes que festejan",
56        };
57        rows.push(spacer(12.0));
58        rows.push(section_title("IA — DESCRIBE", theme));
59        rows.push(text_input_view_full(
60            &model.ai_input,
61            hint,
62            model.ai_focused,
63            &TextInputPalette::from_theme(theme),
64            Msg::AiCampo,
65        ));
66        rows.push(spacer(6.0));
67        rows.push(button_view(
68            if model.ai_busy { "generando…" } else { "generar (IA)" },
69            &btn,
70            Msg::AiGenerate,
71        ));
72    }
73
74    // Guardar/cargar + estado.
75    rows.push(spacer(12.0));
76    rows.push(button_view("guardar", &btn, Msg::Save));
77    rows.push(spacer(4.0));
78    rows.push(button_view("cargar", &btn, Msg::Load));
79
80    // Archivos: abrir / guardar-como / combinar una escena de otro archivo.
81    rows.push(spacer(10.0));
82    rows.push(section_title("ARCHIVO", theme));
83    rows.push(text_input_view_full(
84        &model.file_input,
85        "ruta.voxstudio.ron",
86        model.file_focused,
87        &TextInputPalette::from_theme(theme),
88        Msg::FileCampo,
89    ));
90    rows.push(spacer(4.0));
91    rows.push(button_view("abrir", &btn, Msg::Abrir));
92    rows.push(spacer(4.0));
93    rows.push(button_view("guardar como", &btn, Msg::GuardarComo));
94    rows.push(spacer(4.0));
95    rows.push(button_view("combinar (fundir archivo)", &btn, Msg::Combinar));
96
97    // Mundo desde mapa real (GeoJSON del visor + .pgm hermano opcional).
98    rows.push(spacer(4.0));
99    rows.push(button_view("mundo desde mapa (GeoJSON)", &btn, Msg::MundoDesdeMapa));
100    if model.mapa.is_some() {
101        rows.push(spacer(4.0));
102        rows.push(button_view("↩ quitar mapa (volver a procedural)", &btn, Msg::QuitarMapa));
103    }
104
105    // Ser desde foto (capa 3): la ruta va en el campo ARCHIVO. La foto regula los rasgos
106    // (piel/ropa) de un personaje estilizado del kit. Sólo en Seres.
107    if model.level == Level::Seres {
108        rows.push(spacer(4.0));
109        rows.push(button_view("ser desde foto (rasgos)", &btn, Msg::SerDesdeFoto));
110    }
111
112    // Historia (DAG): versiones y alternativas — el cimiento del trabajo creativo.
113    rows.push(spacer(10.0));
114    let hist_info = if model.historia.is_empty() {
115        "VERSIONES — sin versiones".to_string()
116    } else {
117        format!(
118            "VERSIONES — rama {} · {} en total",
119            model.historia.rama_actual(),
120            model.historia.len()
121        )
122    };
123    rows.push(section_title(&hist_info, theme));
124    rows.push(button_view("versionar", &btn, Msg::Versionar));
125    rows.push(spacer(4.0));
126    rows.push(button_view("ramificar (alternativa)", &btn, Msg::Ramificar));
127    // El árbol navegable: una fila por versión (orden de creación), clickeable →
128    // checkout. ● = donde estás parado; ⑂ = tiene alternativas (hermanas).
129    let actual = model.historia.actual();
130    for (id, ver) in model.historia.versiones_ordenadas() {
131        let marca = if actual == Some(id) { "●" } else { "○" };
132        let ramif = if model.historia.alternativas(&id).is_empty() { "" } else { " ⑂" };
133        let etiqueta = format!("{marca} {}  {}{ramif}", hex_corto(&id), ver.etiqueta);
134        rows.push(spacer(2.0));
135        rows.push(button_view(&etiqueta, &btn, Msg::IrAVersion(id)));
136    }
137
138    // Entrar a la escena: jugarla en primera persona (caminar/volar).
139    rows.push(spacer(10.0));
140    let jugar = if model.jugando {
141        "◄ salir de la escena"
142    } else {
143        "▶ entrar a la escena (jugar)"
144    };
145    rows.push(button_view(jugar, &btn, Msg::EntrarSalir));
146    if model.jugando {
147        let modo = if model.volar { "volando" } else { "caminando" };
148        rows.push(spacer(2.0));
149        rows.push(section_title(&format!("JUGANDO · {modo}"), theme));
150    }
151    rows.push(spacer(10.0));
152    rows.push(
153        View::new(Style {
154            size: Size { width: percent(1.0_f32), height: Dimension::auto() },
155            ..Default::default()
156        })
157        .text(model.status.clone(), 12.0, theme.fg_placeholder)
158        .max_lines(3),
159    );
160
161    let body = View::new(Style {
162        flex_direction: FlexDirection::Column,
163        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
164        padding: pad(12.0, 12.0),
165        gap: gap_y(6.0),
166        ..Default::default()
167    })
168    .children(rows);
169
170    // Source of truth = model.level: el diente del nivel activo queda abierto (robusto
171    // aunque el estado no se haya sincronizado, p.ej. en el pantallazo headless).
172    let mut st = model.sidebar_left.clone();
173    st.open = Some(model.level.index() as u64);
174    st.selected = Some(model.level.index() as u64);
175    let spec = RagSidebarView {
176        teeth,
177        body,
178        accessory: None,
179        search_hits: !model.sidebar_left.search.is_empty(),
180        t: 0.0,
181        rail_w: RAIL_W,
182        // El click en un diente cambia de nivel (selector fijo: nunca colapsa el
183        // panel); el resto de mensajes van al estado del sidebar.
184        map: Arc::new(|rm| match rm {
185            RagSidebarMsg::Activate(id) => Msg::SelectLevel(Level::from_index(id as usize)),
186            other => Msg::SidebarLeft(other),
187        }),
188        on_drop: None,
189        on_reorder: None,
190        grow: None,
191    };
192    rag_sidebar_view(&st, spec, RagSide::Left, &RagSidebarPalette::from_theme(theme))
193}
194
195/// Sidebar derecho unificado (widget `rag-sidebar`): rail de **herramientas** del
196/// nivel (dientes coloridos, espejados hacia el centro) + el editor del set activo.
197/// Selector **fijo**: `Activate`→`SelectTool` interceptado; el cabezal con el nombre
198/// de la herramienta lo pone el widget.
199pub(crate) fn right_sidebar(model: &Model) -> View<Msg> {
200    let theme = &model.theme;
201    let tools = model.level.tools();
202    let tab = model.tool_tab.min(tools.len().saturating_sub(1));
203    let lvl_color = model.level.color();
204
205    let teeth: Vec<RagTooth<Msg>> = tools
206        .iter()
207        .enumerate()
208        .map(|(i, (label, ic))| {
209            let ic = *ic;
210            RagTooth::new(
211                i as u64,
212                label.to_string(),
213                Arc::new(move |_size, _color| icon_view(ic, lvl_color, 1.8)),
214            )
215        })
216        .collect();
217
218    let body = View::new(Style {
219        flex_direction: FlexDirection::Column,
220        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
221        padding: pad(14.0, 14.0),
222        gap: gap_y(8.0),
223        ..Default::default()
224    })
225    .children(editor(model, tab));
226
227    // Source of truth = model.tool_tab: el diente del tool activo queda abierto.
228    let mut st = model.sidebar_right.clone();
229    st.open = Some(tab as u64);
230    st.selected = Some(tab as u64);
231    let spec = RagSidebarView {
232        teeth,
233        body,
234        accessory: None,
235        search_hits: !model.sidebar_right.search.is_empty(),
236        t: 0.0,
237        rail_w: RAIL_W,
238        map: Arc::new(|rm| match rm {
239            RagSidebarMsg::Activate(id) => Msg::SelectTool(id as usize),
240            other => Msg::SidebarRight(other),
241        }),
242        on_drop: None,
243        on_reorder: None,
244        grow: None,
245    };
246    rag_sidebar_view(&st, spec, RagSide::Right, &RagSidebarPalette::from_theme(theme))
247}
248
249/// Fija el diente abierto de cada sidebar unificado a la fuente de verdad
250/// (`level`/`tool_tab`): son selectores **fijos**, nunca colapsan.
251pub(crate) fn pin_sidebars(model: &mut Model) {
252    let lvl = model.level.index() as u64;
253    model.sidebar_left.open = Some(lvl);
254    model.sidebar_left.selected = Some(lvl);
255    let tab = model.tool_tab.min(model.level.tools().len().saturating_sub(1)) as u64;
256    model.sidebar_right.open = Some(tab);
257    model.sidebar_right.selected = Some(tab);
258}
259
260/// Rutea una tecla al buscador de un sidebar unificado enfocado. Esc/Enter sueltan el
261/// foco; Backspace borra el último char; el `text` del evento se inserta. Traga
262/// cualquier otra tecla (devuelve `None`) para que los atajos no disparen al tipear.
263pub(crate) fn sidebar_search_key(
264    ev: &KeyEvent,
265    current: &str,
266    wrap: impl Fn(RagSidebarMsg) -> Msg,
267) -> Option<Msg> {
268    match &ev.key {
269        Key::Named(NamedKey::Escape | NamedKey::Enter) => Some(wrap(RagSidebarMsg::SearchFocus(false))),
270        Key::Named(NamedKey::Backspace) => {
271            let mut s = current.to_string();
272            s.pop();
273            Some(wrap(RagSidebarMsg::SearchSet(s)))
274        }
275        _ => {
276            if let Some(t) = &ev.text {
277                if !t.is_empty() && !t.chars().any(char::is_control) {
278                    let mut s = current.to_string();
279                    s.push_str(t);
280                    return Some(wrap(RagSidebarMsg::SearchSet(s)));
281                }
282            }
283            None
284        }
285    }
286}
287
288/// Backdrop de click-away a pantalla completa: al clickear fuera de un sidebar
289/// flotante, emite `msg` (típicamente `Reveal(false)`) para guardarlo.
290pub(crate) fn float_backdrop(msg: Msg) -> View<Msg> {
291    use llimphi_ui::llimphi_layout::taffy::prelude::Rect;
292    View::new(Style {
293        position: Position::Absolute,
294        inset: Rect {
295            left: length(0.0_f32),
296            right: length(0.0_f32),
297            top: length(0.0_f32),
298            bottom: length(0.0_f32),
299        },
300        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
301        ..Default::default()
302    })
303    .on_click(msg)
304}
305
306/// Coloca un sidebar flotante como overlay pegado a un borde vertical
307/// (`left_side` = izquierda), de alto completo.
308pub(crate) fn float_overlay(inner: View<Msg>, left_side: bool) -> View<Msg> {
309    use llimphi_ui::llimphi_layout::taffy::prelude::Rect;
310    let inset = if left_side {
311        Rect { left: length(0.0_f32), right: auto(), top: length(0.0_f32), bottom: length(0.0_f32) }
312    } else {
313        Rect { left: auto(), right: length(0.0_f32), top: length(0.0_f32), bottom: length(0.0_f32) }
314    };
315    View::new(Style {
316        position: Position::Absolute,
317        inset,
318        size: Size { width: auto(), height: percent(1.0_f32) },
319        ..Default::default()
320    })
321    .children(vec![inner])
322}
323
324/// El multiselect de disposición (los 4 ejes) del sidebar IZQUIERDO como overlay a
325/// nivel raíz (no clipeado por el panel) con backdrop de click-away. `None` si el
326/// control ⚙ está cerrado.
327pub(crate) fn left_multiselect_overlay(model: &Model) -> Option<View<Msg>> {
328    multiselect_overlay(model, true)
329}
330
331/// Ídem para el sidebar DERECHO.
332pub(crate) fn right_multiselect_overlay(model: &Model) -> Option<View<Msg>> {
333    multiselect_overlay(model, false)
334}
335
336/// Arma el overlay del multiselect de un sidebar (izq/der). Anclado al borde interno
337/// del rail (a la derecha del rail izquierdo, a la izquierda del derecho), cerca del
338/// control ⚙ del panel; con backdrop que lo cierra.
339pub(crate) fn multiselect_overlay(model: &Model, left_side: bool) -> Option<View<Msg>> {
340    use llimphi_ui::llimphi_layout::taffy::prelude::Rect;
341    let (state, wrap): (&RagSidebarState, fn(RagSidebarMsg) -> Msg) = if left_side {
342        (&model.sidebar_left, Msg::SidebarLeft)
343    } else {
344        (&model.sidebar_right, Msg::SidebarRight)
345    };
346    if !state.control_open {
347        return None;
348    }
349    let card = rag_multiselect_view(
350        state,
351        RagOptions::default(),
352        Arc::new(wrap),
353        &RagSidebarPalette::from_theme(&model.theme),
354    );
355    let inset = if left_side {
356        Rect { left: length(RAIL_W + 8.0), right: auto(), top: length(36.0_f32), bottom: auto() }
357    } else {
358        Rect { left: auto(), right: length(RAIL_W + 8.0), top: length(36.0_f32), bottom: auto() }
359    };
360    let card_pos = View::new(Style {
361        position: Position::Absolute,
362        inset,
363        ..Default::default()
364    })
365    .children(vec![card]);
366    let backdrop = View::new(Style {
367        position: Position::Absolute,
368        inset: Rect {
369            left: length(0.0_f32),
370            right: length(0.0_f32),
371            top: length(0.0_f32),
372            bottom: length(0.0_f32),
373        },
374        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
375        ..Default::default()
376    })
377    .on_click(wrap(RagSidebarMsg::ControlToggle));
378    Some(
379        View::new(Style {
380            position: Position::Relative,
381            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
382            ..Default::default()
383        })
384        .children(vec![backdrop, card_pos]),
385    )
386}
387
388/// Fila de botones CRUD: nuevo · duplicar · borrar.
389pub(crate) fn crud_row(btn: &ButtonPalette) -> View<Msg> {
390    View::new(Style {
391        flex_direction: FlexDirection::Row,
392        size: Size { width: percent(1.0_f32), height: Dimension::auto() },
393        gap: Size { width: length(4.0_f32), height: length(0.0_f32) },
394        ..Default::default()
395    })
396    .children(vec![
397        cell(button_view("+ nuevo", btn, Msg::NewItem)),
398        cell(button_view("duplicar", btn, Msg::DupItem)),
399        cell(button_view("borrar", btn, Msg::DelItem)),
400    ])
401}
402
403/// Celda que crece para repartir el ancho de una fila.
404pub(crate) fn cell(child: View<Msg>) -> View<Msg> {
405    View::new(Style {
406        flex_grow: 1.0,
407        size: Size { width: percent(0.0_f32), height: Dimension::auto() },
408        ..Default::default()
409    })
410    .children(vec![child])
411}
412
413// =============================================================================
414//  Centro (preview)
415// =============================================================================
416
417pub(crate) fn center(model: &Model) -> View<Msg> {
418    let inner = match model.level {
419        Level::Leyes if model.leyes_node_mode => eq_graph_view(model),
420        Level::Leyes => law_lab_view(model),
421        Level::Materiales => material_swatch(model),
422        _ => canvas_3d(model),
423    };
424    View::new(Style {
425        flex_grow: 1.0,
426        size: Size { width: Dimension::auto(), height: percent(1.0_f32) },
427        ..Default::default()
428    })
429    .fill(Color::from_rgba8(12, 14, 18, 255))
430    .children(vec![inner])
431}
432
433/// Mensaje centrado para niveles sin preview 3D.
434pub(crate) fn placeholder_2d(text: &str, theme: &Theme) -> View<Msg> {
435    View::new(Style {
436        position: Position::Absolute,
437        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
438        align_items: Some(AlignItems::CENTER),
439        justify_content: Some(llimphi_ui::llimphi_layout::taffy::prelude::JustifyContent::CENTER),
440        padding: pad(40.0, 40.0),
441        ..Default::default()
442    })
443    .children(vec![View::new(Style {
444        size: Size { width: percent(0.7_f32), height: Dimension::auto() },
445        ..Default::default()
446    })
447    .text(text.to_string(), 16.0, theme.fg_muted)
448    .max_lines(4)])
449}
450
451/// Centro del nivel Leyes: el **laboratorio** — heatmap del campo visible de la ley
452/// corriendo la ecuación, o el error de parseo, o una guía si no hay ley por ecuación.
453pub(crate) fn law_lab_view(model: &Model) -> View<Msg> {
454    let theme = &model.theme;
455    let Some(lab) = &model.lab else {
456        return placeholder_2d(
457            "Elige una ley por ecuación (botón «preset» a la derecha) y edita su fórmula. ▶ para simular.",
458            theme,
459        );
460    };
461    if let Err(err) = &lab.program {
462        return placeholder_2d(&format!("La fórmula no compila:\n{err}"), theme);
463    }
464    let field_name = lab.engine.fields().get(lab.vis).map(|f| f.name.clone()).unwrap_or_default();
465    let estado = if model.simulating { "▶ corriendo" } else { "▮ en pausa" };
466    let overlay = View::new(Style {
467        position: Position::Absolute,
468        size: Size { width: percent(1.0_f32), height: Dimension::auto() },
469        padding: pad(12.0, 10.0),
470        ..Default::default()
471    })
472    .text(format!("campo «{field_name}»  ·  {LAB_DIM}²  ·  {estado}"), 13.0, theme.fg_muted);
473    View::new(Style {
474        position: Position::Absolute,
475        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
476        align_items: Some(AlignItems::CENTER),
477        justify_content: Some(llimphi_ui::llimphi_layout::taffy::prelude::JustifyContent::CENTER),
478        padding: pad(24.0, 24.0),
479        ..Default::default()
480    })
481    .children(vec![
482        View::new(Style {
483            size: Size { width: percent(0.82_f32), height: percent(0.82_f32) },
484            ..Default::default()
485        })
486        .image(heatmap_image(lab)),
487        overlay,
488    ])
489}
490
491/// Centro del nivel Leyes en **modo grafo**: la ecuación como nodos (misma AST que la
492/// fórmula). Arrastrar la barra de título reubica; arrastrar de un pin de salida a uno
493/// de entrada reconecta (y reescribe la fórmula); right‑click borra el nodo.
494pub(crate) fn eq_graph_view(model: &Model) -> View<Msg> {
495    let theme = &model.theme;
496    let (Some(g), Some(sym)) = (&model.eq_graph, sel_ley(model).and_then(|l| l.kind.ecuacion_symbols()))
497    else {
498        return placeholder_2d("Grafo no disponible (la ley no es por ecuación).", theme);
499    };
500    let specs = g.node_specs(&sym);
501    let palette = NodegraphPalette::from_theme(theme);
502    let metrics = NodegraphMetrics::default();
503    let inner = nodegraph_view_ex(
504        &specs,
505        &g.wires,
506        &palette,
507        &metrics,
508        |id, phase, dx, dy| match phase {
509            DragPhase::Move => Some(Msg::GraphDrag(id, dx, dy)),
510            _ => None,
511        },
512        |from, fp, to, tp| Some(Msg::GraphConnect(from, fp, to, tp)),
513        Some(|id| Some(Msg::GraphDeleteNode(id))),
514    );
515    View::new(Style {
516        position: Position::Absolute,
517        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
518        ..Default::default()
519    })
520    .children(vec![inner])
521}
522
523/// Heatmap RGBA del campo visible del laboratorio (rango del campo → rampa magma).
524pub(crate) fn heatmap_image(lab: &LawLab) -> Image {
525    let f = lab.vis as u16;
526    let (mn, mx) = lab.engine.fields().get(lab.vis).map(|d| (d.min, d.max)).unwrap_or((0.0, 1.0));
527    let span = (mx - mn).max(1e-6);
528    let mut rgba = vec![0u8; (LAB_DIM * LAB_DIM * 4) as usize];
529    for z in 0..LAB_DIM {
530        for x in 0..LAB_DIM {
531            let t = ((lab.engine.get(f, x, 0, z) - mn) / span).clamp(0.0, 1.0);
532            let [r, g, b] = ramp(t);
533            let i = ((z * LAB_DIM + x) * 4) as usize;
534            rgba[i] = r;
535            rgba[i + 1] = g;
536            rgba[i + 2] = b;
537            rgba[i + 3] = 255;
538        }
539    }
540    from_rgba8(rgba, LAB_DIM, LAB_DIM)
541}
542
543/// Rampa de color tipo *magma* (negro → púrpura → naranja → crema) para `t∈[0,1]`.
544pub(crate) fn ramp(t: f32) -> [u8; 3] {
545    pub(crate) const STOPS: [(f32, [f32; 3]); 4] = [
546        (0.0, [8.0, 6.0, 30.0]),
547        (0.4, [92.0, 22.0, 110.0]),
548        (0.72, [222.0, 92.0, 58.0]),
549        (1.0, [250.0, 232.0, 158.0]),
550    ];
551    let t = t.clamp(0.0, 1.0);
552    let mut out = STOPS[STOPS.len() - 1].1;
553    for w in STOPS.windows(2) {
554        let (t0, c0) = w[0];
555        let (t1, c1) = w[1];
556        if t <= t1 {
557            let k = ((t - t0) / (t1 - t0).max(1e-6)).clamp(0.0, 1.0);
558            out = [
559                c0[0] + (c1[0] - c0[0]) * k,
560                c0[1] + (c1[1] - c0[1]) * k,
561                c0[2] + (c1[2] - c0[2]) * k,
562            ];
563            break;
564        }
565    }
566    [out[0] as u8, out[1] as u8, out[2] as u8]
567}
568
569/// Lado del blob de material del swatch 3D.
570pub(crate) const BLOB_DIM: u32 = 44;
571
572/// Un **blob** (esfera) voxel del color dado, para mostrar el material en 3D. La
573/// superficie curva exhibe bien el bisel (suavizado) y la rugosidad (bump).
574pub(crate) fn material_blob(color: [u8; 3]) -> VoxelGrid {
575    let d = BLOB_DIM;
576    let mut g = VoxelGrid::new([d, d, d]);
577    let c = (d as f32 - 1.0) / 2.0;
578    let r = d as f32 * 0.42;
579    for z in 0..d {
580        for y in 0..d {
581            for x in 0..d {
582                let dx = x as f32 - c;
583                let dy = y as f32 - c;
584                let dz = z as f32 - c;
585                if dx * dx + dy * dy + dz * dz <= r * r {
586                    g.set(x, y, z, color);
587                }
588            }
589        }
590    }
591    g
592}
593
594/// **Preview 3D vivo** del material seleccionado: un blob voxel con su color + su
595/// detalle sub-voxel (rugosidad/bisel), para dialar los sliders y verlos al instante.
596/// Reusa el `preview` compartido vía `show_grid` (clave por material+color; el detalle
597/// se aplica por cuadro sin reconstruir). Arrastrar = orbitar.
598pub(crate) fn material_swatch(model: &Model) -> View<Msg> {
599    let mid = model.sel[1];
600    let color = model.project.resolve_material(mid).color;
601    let (rug, bis) = model.project.detalle_material(mid);
602    let (yaw, pitch) = (model.yaw, model.pitch);
603    let preview = model.preview.clone();
604    let mr = model.preview_render();
605    // Clave de rebuild: material + color, con bit alto para no chocar con los tags de
606    // interior/terreno. Cambia sólo al cambiar de material o su color → rugosidad/bisel
607    // se aplican sin reconstruir el grid.
608    let key = 0xF000_0000_0000_0000u64
609        | ((mid & 0xffff) << 24)
610        | ((color[0] as u64) << 16)
611        | ((color[1] as u64) << 8)
612        | color[2] as u64;
613    let absolute = Style {
614        position: Position::Absolute,
615        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
616        ..Default::default()
617    };
618    View::new(absolute)
619        .gpu_paint_with(move |device, queue, encoder, target, rect, vp| {
620            let dim = [BLOB_DIM, BLOB_DIM, BLOB_DIM];
621            let mut guard = preview.lock().unwrap();
622            let p = guard.get_or_insert_with(|| WorldPreview::build(device, queue, &mr, dim, 0, None));
623            p.show_grid(device, queue, material_blob(color), dim, key);
624            // Luz frontal limpia (sin niebla) para leer el relieve; luego el detalle.
625            p.set_lighting(
626                [0.4, 0.6, 0.7],
627                Atmosphere { sky_zenith: [40, 46, 62], sky_horizon: [82, 86, 100], fog_density: 0.0, god_rays: 0.0 },
628            );
629            p.set_detalle(rug, 1.6, bis);
630            let camera = Camera3d::orbit(Vec3::ZERO, yaw, pitch, BLOB_DIM as f32 * 1.9);
631            p.render(device, queue, encoder, target, vp, (rect.x, rect.y, rect.w, rect.h), &camera);
632        })
633        .draggable(|phase, dx, dy| match phase {
634            DragPhase::Move => Some(Msg::Orbit(dx, dy)),
635            DragPhase::End => None,
636        })
637}
638