tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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