tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-showreel/src/main.rs Rust · 1620 siq'ikuna · 71.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! **Showreel de Llimphi** — la vitrina filmada, cuadro a cuadro y sin ventana.
2//!
3//! Un guion de 15,5 s, todo pintado por el motor:
4//!
5//! 1. Un prompt de terminal (`❯`) donde se tipea `llimphi`.
6//! 2. La línea se vuelve el input de shuma, sobre un vidrio esmerilado.
7//! 3. Enter: abajo se despliegan controles REALES, del más simple al más
8//!    complejo (botón → editor de código).
9//! 4. Scroll: los controles suben y entra un escenario que pasa por el cielo
10//!    de cosmos, un pasillo de supay (el motor 2.5D tipo Doom, sobre un nivel
11//!    sintético sin un solo asset de id) que termina en una fuente, y —por el
12//!    agua— un vuelo sobre el mundo voxel infinito bajo un nevado andino, hasta
13//!    un muñequito que salta celebrando y se va.
14//! 5. Mientras se va, la mano de ende escribe `llimphi` a mano.
15//!
16//! Headless y determinista: el cuadro `i` de `N` es el instante
17//! `s = i/(N-1)·DURACION`, y todo deriva de `s`. Los widgets se montan con el
18//! mismo `mount`/`compute_with_measure`/`paint` del eventloop. El voxel es un
19//! pase wgpu propio: se rinde aparte dos veces (con niebla, y sin ella sobre
20//! magenta, que da la máscara exacta del cielo), se compone con la cordillera
21//! pintada por vello y entra a la escena como imagen.
22//!
23//! ```text
24//! cargo run -p llimphi-showreel --release -- \
25//!     [out_dir] [n_frames] [W] [H]
26//! ```
27//! Defaults: `out_dir=showreel_frames`, `n_frames=465`, `W=1600`, `H=900`.
28//! `SOLO=<desde>,<hasta>` (segundos) rinde sólo ese tramo.
29
30use std::f32::consts::{FRAC_PI_2, PI};
31use std::fs::{create_dir_all, File};
32use std::io::BufWriter;
33use std::sync::Arc;
34
35use chrono::NaiveDate;
36use llimphi_theme::{motion, Theme};
37use llimphi_ui::llimphi_hal::{wgpu, Hal};
38use llimphi_ui::llimphi_layout::taffy;
39use llimphi_ui::llimphi_layout::taffy::prelude::{
40    auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
41};
42use llimphi_ui::llimphi_layout::taffy::Rect;
43use llimphi_ui::llimphi_layout::LayoutTree;
44use llimphi_ui::llimphi_raster::peniko::{
45    self, Blob, Color, Fill, Gradient, ImageAlphaType, ImageBrush, ImageData, ImageFormat, Mix,
46};
47use llimphi_ui::llimphi_raster::vello::kurbo::{
48    Affine, BezPath, Circle, Point, RoundedRect, Stroke,
49};
50use llimphi_ui::llimphi_raster::{vello, Renderer};
51use llimphi_ui::llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter, MONOSPACE};
52use llimphi_ui::{measure_text_node, mount, paint, DragPhase, PaintRect, Shadow, View};
53
54use llimphi_widget_button::{button_view, ButtonPalette};
55use llimphi_widget_calendar::{calendar_view, CalendarPalette, CalendarSpec, WeekStart};
56use llimphi_widget_nodegraph::{
57    nodegraph_view_spec, Camera, NodeSpec, NodegraphMetrics, NodegraphPalette, NodegraphSpec, Wire,
58};
59use llimphi_widget_progress::linear_progress_view;
60use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
61use llimphi_widget_slider::{slider_view, SliderPalette};
62use llimphi_widget_switch::{switch_view, SwitchPalette};
63use llimphi_widget_table::{table_view, TablePalette};
64use llimphi_widget_text_editor::{
65    text_editor_view_highlighted, Buffer, EditorMetrics, EditorPalette, EditorState, Language,
66};
67
68const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
69/// Duración del guion en segundos. Los tramos de abajo están escritos en segundos.
70const DURACION: f32 = 15.5;
71
72// ───────────────────────── utilidades ─────────────────────────
73
74fn with_alpha(c: Color, a: f32) -> Color {
75    let [r, g, b, a0] = c.components;
76    Color::new([r, g, b, a0 * a.clamp(0.0, 1.0)])
77}
78
79fn mix(a: Color, b: Color, t: f32) -> Color {
80    let t = t.clamp(0.0, 1.0);
81    let (x, y) = (a.components, b.components);
82    Color::new([
83        x[0] + (y[0] - x[0]) * t,
84        x[1] + (y[1] - x[1]) * t,
85        x[2] + (y[2] - x[2]) * t,
86        x[3] + (y[3] - x[3]) * t,
87    ])
88}
89
90fn lerp(a: f64, b: f64, t: f64) -> f64 {
91    a + (b - a) * t
92}
93
94/// `s` reescalado del tramo `[lo,hi]` a `[0,1]`, clampado.
95fn seg(s: f32, lo: f32, hi: f32) -> f32 {
96    ((s - lo) / (hi - lo)).clamp(0.0, 1.0)
97}
98
99fn ease(x: f32) -> f32 {
100    motion::ease_in_out_cubic(x)
101}
102
103#[derive(Clone, Copy)]
104struct Caja {
105    x: f64,
106    y: f64,
107    w: f64,
108    h: f64,
109}
110
111impl Caja {
112    fn lerp(self, b: Caja, t: f64) -> Caja {
113        Caja { x: lerp(self.x, b.x, t), y: lerp(self.y, b.y, t), w: lerp(self.w, b.w, t), h: lerp(self.h, b.h, t) }
114    }
115    fn rr(self, r: f64) -> RoundedRect {
116        RoundedRect::new(self.x, self.y, self.x + self.w, self.y + self.h, r)
117    }
118}
119
120fn absoluto(c: Caja) -> Style {
121    Style {
122        position: Position::Absolute,
123        inset: Rect { left: length(c.x as f32), top: length(c.y as f32), right: auto(), bottom: auto() },
124        size: Size { width: length(c.w as f32), height: length(c.h as f32) },
125        ..Default::default()
126    }
127}
128
129fn spacer(h: f32) -> View<()> {
130    View::new(Style {
131        size: Size { width: percent(1.0_f32), height: length(h) },
132        flex_shrink: 0.0,
133        ..Default::default()
134    })
135}
136
137// ───────────────────────── paleta ─────────────────────────
138
139#[derive(Clone)]
140struct Skin {
141    theme: Theme,
142    accent: Color,
143    panel: Color,
144    panel_hi: Color,
145    border: Color,
146    fg: Color,
147    fg_muted: Color,
148    bg: Color,
149    /// Los tres colores de las luces de fondo que el vidrio deja ver.
150    luces: [Color; 3],
151}
152
153// ───────────────────────── 1–2. prompt → input de shuma ─────────────────────────
154
155const PALABRA: &str = "llimphi";
156/// Cuándo cae cada tecla (s). Irregular a propósito: así tipea una persona.
157const TECLAS: [f32; 7] = [0.42, 0.55, 0.64, 0.80, 0.90, 1.03, 1.12];
158
159fn tipeado(s: f32) -> usize {
160    TECLAS.iter().filter(|&&k| s >= k).count()
161}
162
163/// Las luces que viven detrás del vidrio: tres manchas que derivan despacio.
164fn luces(s: f32, cw: f64, ch: f64) -> [(Point, f64); 3] {
165    let t = s as f64;
166    [
167        (Point::new(cw * 0.30 + 80.0 * (t * 0.5).sin(), ch * 0.42 + 40.0 * (t * 0.7).cos()), 340.0),
168        (Point::new(cw * 0.62 + 90.0 * (t * 0.4).cos(), ch * 0.55 + 50.0 * (t * 0.6).sin()), 300.0),
169        (Point::new(cw * 0.48 + 60.0 * (t * 0.9).sin(), ch * 0.30 + 30.0 * (t * 0.5).sin()), 260.0),
170    ]
171}
172
173fn pintar_luces(scene: &mut vello::Scene, s: f32, cw: f64, ch: f64, sk: &Skin, alpha: f32, ensanche: f64) {
174    for ((p, r), col) in luces(s, cw, ch).iter().zip(sk.luces) {
175        let r = r * ensanche;
176        let g = Gradient::new_radial(*p, r as f32).with_stops(
177            [with_alpha(col, 0.55 * alpha), with_alpha(col, 0.18 * alpha), with_alpha(col, 0.0)].as_slice(),
178        );
179        scene.fill(Fill::NonZero, Affine::IDENTITY, &g, None, &Circle::new(*p, r));
180    }
181}
182
183/// El avioncito de enviar del botón de shuma, en una caja de lado `l`.
184fn avioncito(c: Point, l: f64) -> BezPath {
185    let mut p = BezPath::new();
186    let k = l / 24.0;
187    let pt = |x: f64, y: f64| Point::new(c.x + (x - 12.0) * k, c.y + (y - 12.0) * k);
188    p.move_to(pt(3.0, 11.0));
189    p.line_to(pt(21.0, 3.5));
190    p.line_to(pt(14.5, 21.0));
191    p.line_to(pt(11.0, 13.0));
192    p.close_path();
193    p
194}
195
196/// La línea de comando: de prompt de terminal a input de shuma. Todo en un
197/// `paint_with`, porque el texto viaja de un lugar a otro sin re-maquetarse.
198fn input_view(s: f32, cw: f64, ch: f64, sk: &Skin) -> View<()> {
199    let sk = sk.clone();
200    View::new(absoluto(Caja { x: 0.0, y: 0.0, w: cw, h: ch })).paint_with(move |scene, ts, _r: PaintRect| {
201        let morph = ease(seg(s, 1.65, 2.45)) as f64;
202        let sube = ease(seg(s, 2.75, 3.25)) as f64;
203        let fuera = seg(s, 10.3, 10.6); // el escenario voxel se lleva la pantalla
204        if fuera >= 1.0 {
205            return;
206        }
207        let vivo = 1.0 - fuera;
208
209        // Las luces de fondo: aparecen con el vidrio y se apagan cuando llegan
210        // los controles (quedan como un brillo tenue).
211        let luz = (morph as f32) * (1.0 - seg(s, 2.9, 3.6)) * vivo;
212        if luz > 0.001 {
213            pintar_luces(scene, s, cw, ch, &sk, luz, 1.0);
214        }
215
216        let size_t = 46.0_f32; // tamaño del texto en la terminal
217        let size_i = 26.0_f32; // en el input
218        let size = lerp(size_t as f64, size_i as f64, morph) as f32;
219
220        // La caja final del input (centrada, y después arriba).
221        let iw = 980.0;
222        let ih = 72.0;
223        let centro_y = lerp(ch * 0.5, 74.0, sube);
224        let caja_fin = Caja { x: (cw - iw) / 2.0, y: centro_y - ih / 2.0, w: iw, h: ih };
225
226        // Medidas del texto para colocar el caret.
227        let escrito = &PALABRA[..tipeado(s)];
228        let lay = ts.layout(escrito, size, None, Alignment::Start, 1.0, false, Some(MONOSPACE), 400.0, false, false, 0.0, 0.0);
229        let m = measurement(&lay);
230        let prompt = ts.layout("❯ ", size, None, Alignment::Start, 1.0, false, Some(MONOSPACE), 600.0, false, false, 0.0, 0.0);
231        let pm = measurement(&prompt);
232
233        // Dónde arranca el texto: en la terminal, a la izquierda tras el prompt;
234        // en el input, adentro con su padding.
235        let x_term = cw * 0.5 - 150.0 + 14.0;
236        let y_term = ch * 0.5 - m.height.max(size * 1.2) as f64 / 2.0;
237        let x_in = caja_fin.x + 26.0;
238        let y_in = caja_fin.y + (ih - size_i as f64 * 1.2) / 2.0;
239        let tx = lerp(x_term, x_in, morph);
240        let ty = lerp(y_term, y_in, morph);
241
242        // ── el vidrio ──
243        if morph > 0.001 {
244            let a = (morph as f32) * vivo;
245            // La caja crece desde la línea de texto hasta su tamaño.
246            let caja_ini = Caja { x: tx - 20.0, y: ty - 10.0, w: m.width as f64 + 60.0, h: size as f64 * 1.2 + 20.0 };
247            let c = caja_ini.lerp(caja_fin, morph);
248            let rr = c.rr(16.0);
249            // Sombra suave debajo del vidrio.
250            scene.fill(Fill::NonZero, Affine::translate((0.0, 14.0)), with_alpha(Color::BLACK, 0.35 * a), None, &c.rr(20.0));
251            scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &rr);
252            // Esmerilado: las mismas luces de atrás, más anchas y lavadas, y un
253            // velo claro encima. Es lo que el ojo lee como vidrio.
254            scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(sk.panel, 0.72), None, &rr);
255            pintar_luces(scene, s, cw, ch, &sk, 0.9, 1.6);
256            let velo = Gradient::new_linear(Point::new(c.x, c.y), Point::new(c.x, c.y + c.h)).with_stops(
257                [with_alpha(Color::WHITE, 0.13), with_alpha(Color::WHITE, 0.04)].as_slice(),
258            );
259            scene.fill(Fill::NonZero, Affine::IDENTITY, &velo, None, &rr);
260            scene.pop_layer();
261            // El borde vivo de shuma: un degradé diagonal de tres paradas que gira.
262            let g = s as f64 * 0.6;
263            let borde = Gradient::new_linear(
264                Point::new(c.x + c.w * (0.5 + 0.5 * g.cos()), c.y),
265                Point::new(c.x + c.w * (0.5 - 0.5 * g.cos()), c.y + c.h),
266            )
267            .with_stops([sk.luces[0], sk.accent, sk.luces[1]].as_slice());
268            scene.push_layer(Fill::NonZero, Mix::Normal, a, Affine::IDENTITY, &c.rr(17.0));
269            scene.stroke(&Stroke::new(1.6), Affine::IDENTITY, &borde, None, &rr);
270            // Brillo de canto arriba: el filo del vidrio.
271            scene.stroke(
272                &Stroke::new(1.0),
273                Affine::IDENTITY,
274                with_alpha(Color::WHITE, 0.35),
275                None,
276                &RoundedRect::new(c.x + 14.0, c.y + 1.0, c.x + c.w - 14.0, c.y + 1.5, 0.5),
277            );
278            scene.pop_layer();
279
280            // El botón de enviar (con texto ya es el avioncito). Se hunde al Enter.
281            let b_a = seg(s, 2.05, 2.45) * vivo;
282            if b_a > 0.001 {
283                let golpe = (seg(s, 2.5, 2.6) - seg(s, 2.6, 2.78)) as f64;
284                let r = 22.0 * (1.0 - 0.14 * golpe) * (0.6 + 0.4 * b_a as f64);
285                let bc = Point::new(c.x + c.w - 40.0, c.y + c.h / 2.0);
286                scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(sk.accent, b_a), None, &Circle::new(bc, r));
287                scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(sk.bg, b_a), None, &avioncito(bc, r * 1.1));
288                // La onda del Enter.
289                let onda = seg(s, 2.55, 3.0);
290                if onda > 0.0 && onda < 1.0 {
291                    scene.stroke(
292                        &Stroke::new(2.0),
293                        Affine::IDENTITY,
294                        with_alpha(sk.accent, (1.0 - onda) * 0.8),
295                        None,
296                        &Circle::new(bc, r + 30.0 * onda as f64),
297                    );
298                }
299            }
300        }
301
302        // ── el prompt (sólo en la terminal) ──
303        let prompt_a = (1.0 - morph as f32) * seg(s, 0.1, 0.3);
304        if prompt_a > 0.001 {
305            let brush = peniko::Brush::Solid(with_alpha(sk.accent, prompt_a));
306            draw_layout_brush_xf(scene, &prompt, &brush, Affine::translate((tx - pm.width as f64 - 14.0, ty)));
307        }
308
309        // ── el texto: blanco de terminal → color de comando de shuma ──
310        let col = mix(sk.fg, sk.accent, morph as f32);
311        if !escrito.is_empty() {
312            let brush = peniko::Brush::Solid(with_alpha(col, vivo));
313            draw_layout_brush_xf(scene, &lay, &brush, Affine::translate((tx, ty)));
314        }
315
316        // ── el caret: parpadea mientras espera, fijo mientras se tipea ──
317        let tipeando = s > TECLAS[0] - 0.05 && s < TECLAS[6] + 0.15;
318        let parpadeo = tipeando || (s * 2.0).fract() < 0.55;
319        if parpadeo && s < 2.6 {
320            let cx = tx + m.width as f64 + 3.0;
321            let ca = if morph > 0.0 { 1.0 } else { 1.0 };
322            scene.fill(
323                Fill::NonZero,
324                Affine::IDENTITY,
325                with_alpha(mix(sk.fg, sk.accent, morph as f32), ca * vivo),
326                None,
327                &RoundedRect::new(cx, ty + 2.0, cx + lerp(14.0, 2.5, morph), ty + size as f64 * 1.15, 1.0),
328            );
329        }
330    })
331}
332
333// ───────────────────────── 3. los controles ─────────────────────────
334
335const N_TILES: usize = 9;
336const TITULOS: [&str; N_TILES] =
337    ["button", "switch", "slider", "progress", "segmented", "calendar", "table", "nodegraph", "editor"];
338
339fn tiles(cw: f64) -> [Caja; N_TILES] {
340    let m = 60.0;
341    let gap = 20.0;
342    let ancho = cw - 2.0 * m;
343    let w1 = (ancho - 4.0 * gap) / 5.0;
344    let mut out = [Caja { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }; N_TILES];
345    for (i, c) in out.iter_mut().enumerate().take(5) {
346        *c = Caja { x: m + i as f64 * (w1 + gap), y: 136.0, w: w1, h: 172.0 };
347    }
348    // Abajo los complejos, con anchos a medida de lo que muestran.
349    let anchos = [300.0, 340.0, 400.0, 0.0];
350    let resto = ancho - anchos[..3].iter().sum::<f64>() - 3.0 * gap;
351    let mut x = m;
352    for (k, w) in [anchos[0], anchos[1], anchos[2], resto].into_iter().enumerate() {
353        out[5 + k] = Caja { x, y: 328.0, w, h: 512.0 };
354        x += w + gap;
355    }
356    out
357}
358
359fn tile_header(title: &str, sk: &Skin) -> View<()> {
360    let chip = View::new(Style { size: Size { width: length(26.0), height: length(7.0) }, flex_shrink: 0.0, ..Default::default() })
361        .radius(4.0)
362        .fill(sk.accent);
363    View::new(Style {
364        size: Size { width: percent(1.0_f32), height: length(18.0) },
365        flex_direction: FlexDirection::Row,
366        align_items: Some(AlignItems::CENTER),
367        gap: Size { width: length(10.0), height: length(0.0) },
368        flex_shrink: 0.0,
369        ..Default::default()
370    })
371    .children(vec![
372        chip,
373        View::new(Style { flex_grow: 1.0, ..Default::default() })
374            .text_aligned(title.to_string(), 12.5, sk.fg_muted, Alignment::Start)
375            .bold(),
376    ])
377}
378
379fn value_line(text: &str, color: Color, size: f32) -> View<()> {
380    View::new(Style { size: Size { width: percent(1.0_f32), height: length(size + 6.0) }, flex_shrink: 0.0, ..Default::default() })
381        .text_aligned(text.to_string(), size, color, Alignment::Start)
382        .bold()
383}
384
385fn fila(alto: f32, hijos: Vec<View<()>>) -> View<()> {
386    View::new(Style {
387        size: Size { width: percent(1.0_f32), height: length(alto) },
388        flex_direction: FlexDirection::Row,
389        align_items: Some(AlignItems::CENTER),
390        gap: Size { width: length(12.0), height: length(0.0) },
391        flex_shrink: 0.0,
392        ..Default::default()
393    })
394    .children(hijos)
395}
396
397fn caja_fija(w: f32, h: f32, hijo: View<()>) -> View<()> {
398    View::new(Style { size: Size { width: length(w), height: length(h) }, flex_shrink: 0.0, ..Default::default() })
399        .children(vec![hijo])
400}
401
402const CODIGO: &str = "fn update(m: Model, msg: Msg) -> Model {\n    match msg {\n        Msg::Sumar => Model { n: m.n + 1, ..m },\n        Msg::Restar => Model { n: m.n - 1, ..m },\n        Msg::Nombre(s) => Model { nombre: s, ..m },\n    }\n}\n";
403
404/// El cuerpo de cada tarjeta. `p∈[0,1]` es el avance de su propia animación.
405fn tile_body(i: usize, p: f32, s: f32, sk: &Skin) -> Vec<View<()>> {
406    let th = &sk.theme;
407    match i {
408        0 => {
409            // Botón: se aprieta (cambia al color de "hover") y vuelve.
410            let apretado = p > 0.35 && p < 0.55;
411            let mut prim = ButtonPalette::from_theme(th);
412            prim.bg = if apretado { mix(sk.accent, Color::WHITE, 0.25) } else { sk.accent };
413            prim.bg_hover = prim.bg;
414            prim.fg = sk.bg;
415            prim.radius = 8.0;
416            let mut ghost = ButtonPalette::from_theme(th);
417            ghost.bg = th.bg_button;
418            ghost.fg = sk.fg;
419            ghost.radius = 8.0;
420            vec![
421                tile_header(TITULOS[i], sk),
422                spacer(22.0),
423                fila(38.0, vec![caja_fija(112.0, 38.0, button_view("Publicar", &prim, ())), caja_fija(100.0, 38.0, button_view("Cancelar", &ghost, ()))]),
424                spacer(16.0),
425                value_line(if p > 0.45 { "publicado" } else { "listo" }, sk.fg_muted, 13.0),
426            ]
427        }
428        1 => {
429            let prog = ease(seg(p, 0.2, 0.55));
430            let pal = SwitchPalette::from_theme(th);
431            vec![
432                tile_header(TITULOS[i], sk),
433                spacer(22.0),
434                fila(26.0, vec![switch_view(prog, (), &pal), View::new(Style { flex_grow: 1.0, ..Default::default() }).text_aligned("Sincronizar".to_string(), 13.0, sk.fg, Alignment::Start)]),
435                spacer(18.0),
436                value_line(if prog > 0.5 { "ENCENDIDO" } else { "apagado" }, if prog > 0.5 { sk.accent } else { sk.fg_muted }, 20.0),
437            ]
438        }
439        2 => {
440            let v = lerp(0.18, 0.74, ease(seg(p, 0.1, 0.7)) as f64) as f32;
441            let mut pal = SliderPalette::from_theme(th);
442            pal.track_width = 170.0;
443            pal.label_width = 0.0;
444            pal.value_width = 0.0;
445            pal.track_thickness = 8.0;
446            pal.row_height = 26.0;
447            vec![
448                tile_header(TITULOS[i], sk),
449                spacer(24.0),
450                slider_view::<(), _>("", v, 0.0, 1.0, &pal, |_: DragPhase, _: f32| None),
451                spacer(14.0),
452                value_line(&format!("{:>3.0}%", v * 100.0), sk.fg, 22.0),
453            ]
454        }
455        3 => {
456            let v = motion::ease_out_cubic(seg(p, 0.05, 0.8));
457            let bar = View::new(Style { size: Size { width: percent(1.0_f32), height: length(12.0) }, position: Position::Relative, flex_shrink: 0.0, ..Default::default() })
458                .fill(th.bg_button)
459                .radius(6.0)
460                .children(vec![linear_progress_view(v, th.bg_button, sk.accent, 12.0)]);
461            vec![
462                tile_header(TITULOS[i], sk),
463                spacer(30.0),
464                bar,
465                spacer(18.0),
466                value_line(&if v < 1.0 { format!("{:>3.0}%  compilando", v * 100.0) } else { "100%  listo".to_string() }, sk.fg_muted, 13.0),
467            ]
468        }
469        4 => {
470            let active = ((seg(p, 0.1, 0.9) * 3.0).floor() as usize).min(2);
471            let labels = ["Día", "Semana", "Mes"];
472            let pal = SegmentedPalette::from_theme(th);
473            vec![
474                tile_header(TITULOS[i], sk),
475                spacer(22.0),
476                segmented_view::<(), _>(&labels, active, |_| (), &pal),
477                spacer(16.0),
478                value_line(labels[active], sk.accent, 20.0),
479            ]
480        }
481        5 => {
482            // El día elegido salta por el mes.
483            let dias = [12, 16, 23, 29];
484            let d = dias[((seg(p, 0.15, 0.95) * 4.0).floor() as usize).min(3)];
485            let cal = calendar_view::<()>(CalendarSpec {
486                view_year: 2026,
487                view_month: 10,
488                selected: NaiveDate::from_ymd_opt(2026, 10, d),
489                today: NaiveDate::from_ymd_opt(2026, 10, 12),
490                week_start: WeekStart::Monday,
491                palette: CalendarPalette::from_theme(th),
492                on_select: Arc::new(|_| ()),
493                on_view_change: Arc::new(|_, _| ()),
494                marcados: vec![NaiveDate::from_ymd_opt(2026, 10, 21).unwrap()],
495            });
496            vec![tile_header(TITULOS[i], sk), spacer(14.0), cal]
497        }
498        6 => {
499            // Las filas llegan una a una: la pila real del motor.
500            let todas = [
501                ["llimphi-ui", "bucle Elm"],
502                ["llimphi-layout", "taffy"],
503                ["llimphi-text", "parley"],
504                ["llimphi-raster", "vello"],
505                ["llimphi-hal", "wgpu"],
506                ["llimphi-voxel", "3D"],
507            ];
508            let n = 1 + (seg(p, 0.0, 0.8) * 5.0).floor() as usize;
509            let rows: Vec<Vec<String>> = todas[..n.min(6)].iter().map(|r| r.iter().map(|c| c.to_string()).collect()).collect();
510            let headers = vec!["crate".to_string(), "sobre".to_string()];
511            vec![
512                tile_header(TITULOS[i], sk),
513                spacer(14.0),
514                table_view::<(), _, _, _>(&headers, &rows, None, None, "+ fila", &TablePalette::from_theme(th), |_, _| (), |_| (), || ()),
515            ]
516        }
517        7 => {
518            // El bucle de una app Llimphi, como grafo. Los cables llegan de a uno.
519            let n = (seg(p, 0.05, 0.75) * 6.0).floor() as usize;
520            let etapas = ["input", "update", "view", "layout", "raster", "present"];
521            let nodes: Vec<NodeSpec> = etapas
522                .iter()
523                .enumerate()
524                .map(|(k, e)| NodeSpec {
525                    id: k as u32,
526                    label: e.to_string(),
527                    x: if k % 2 == 0 { 10.0 } else { 190.0 },
528                    y: 10.0 + k as f32 * 66.0,
529                    inputs: if k == 0 { vec![] } else { vec!["in".into()] },
530                    outputs: if k == 5 { vec![] } else { vec!["out".into()] },
531                })
532                .collect();
533            let wires: Vec<Wire> = (0..n.min(5))
534                .map(|k| Wire { from_node: k as u32, from_output: 0, to_node: k as u32 + 1, to_input: 0 })
535                .collect();
536            let palette = NodegraphPalette::from_theme(th);
537            let mut metrics = NodegraphMetrics::default();
538            metrics.node_width = 150.0;
539            let g = nodegraph_view_spec(NodegraphSpec {
540                nodes: &nodes,
541                wires: &wires,
542                edges: &[],
543                palette: &palette,
544                metrics: &metrics,
545                camera: Camera { pan_x: 0.0, pan_y: 0.0, zoom: 0.92 },
546                on_drag_node: Arc::new(|_, _, _, _| None),
547                on_connect: Arc::new(|_, _, _, _| None),
548                on_right_click_node: None,
549                node_tint: None,
550                wire_tint: None,
551                wire_dash: None,
552                edge_tint: None,
553                edge_dash: None,
554                flujo: s * 1.5,
555                selected: None,
556                on_select_node: None,
557                on_canvas_click: None,
558                on_pan: None,
559                on_zoom: None,
560                cuerpo: None,
561            });
562            vec![
563                tile_header(TITULOS[i], sk),
564                spacer(10.0),
565                View::new(Style { size: Size { width: percent(1.0_f32), height: length(440.0) }, flex_shrink: 0.0, ..Default::default() }).children(vec![g]),
566            ]
567        }
568        _ => {
569            // El editor: el código se escribe solo, con resaltado de Rust.
570            let n = (seg(p, 0.0, 0.85) * CODIGO.chars().count() as f32) as usize;
571            let texto: String = CODIGO.chars().take(n).collect();
572            let mut st = EditorState::new();
573            st.buffer = Buffer::from_str(&texto);
574            let lineas = texto.matches('\n').count();
575            let col = texto.rsplit('\n').next().map(|l| l.chars().count()).unwrap_or(0);
576            st.cursor = llimphi_widget_text_editor::Cursor::at(lineas, col);
577            let mut metrics = EditorMetrics::default();
578            metrics.font_size = 12.0;
579            metrics.line_height = 18.0;
580            metrics.char_width = 7.2;
581            let ed = text_editor_view_highlighted(&st, &EditorPalette::from_theme(th), metrics, 22, Language::Rust, |_| None);
582            vec![
583                tile_header(TITULOS[i], sk),
584                spacer(10.0),
585                View::new(Style { size: Size { width: percent(1.0_f32), height: length(440.0) }, flex_shrink: 0.0, ..Default::default() }).children(vec![ed]),
586            ]
587        }
588    }
589}
590
591fn tile_view(i: usize, c: Caja, alpha: f32, scale: f64, p: f32, s: f32, sk: &Skin) -> View<()> {
592    let (cx, cy) = (c.x + c.w / 2.0, c.y + c.h / 2.0);
593    let xf = Affine::translate((cx, cy)) * Affine::scale(scale) * Affine::translate((-cx, -cy));
594    let mut st = absoluto(c);
595    st.flex_direction = FlexDirection::Column;
596    st.padding = Rect { left: length(18.0), right: length(18.0), top: length(16.0), bottom: length(14.0) };
597    st.overflow = taffy::Point { x: taffy::Overflow::Hidden, y: taffy::Overflow::Hidden };
598    View::new(st)
599        .fill_gradient(Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0)).with_stops([sk.panel_hi, sk.panel].as_slice()))
600        .radius(16.0)
601        .border(1.0, sk.border)
602        .shadow(Shadow::soft(110, 22.0).offset(0.0, 10.0))
603        .clip(true)
604        .transform(xf)
605        .alpha(alpha)
606        .children(tile_body(i, p, s, sk))
607}
608
609// ───────────────────────── 4. el escenario: cosmos → supay → voxel ─────────────────────────
610
611fn escenario(s: f32, cw: f64, ch: f64) -> Caja {
612    let chico = Caja { x: 60.0, y: 136.0, w: cw - 120.0, h: ch - 196.0 };
613    let pantalla = Caja { x: 0.0, y: 0.0, w: cw, h: ch };
614    chico.lerp(pantalla, ease(seg(s, 10.3, 10.6)) as f64)
615}
616
617/// El cielo de cosmos: la esfera celeste con la Vía Láctea y las
618/// constelaciones, girando. Es la misma `compose_sphere` de la app.
619fn cielo_view(cielo: &cosmos_render::RenderModel, s: f32, lado: f32) -> View<()> {
620    let opts = cosmos_render::SphereOpts {
621        size: lado,
622        palette: cosmos_render::Palette::dark(),
623        show_bodies: false,
624        show_signs: false,
625        show_earth: false,
626        show_horizon: false,
627        show_equator: false,
628        show_sky: true,
629        show_constellations: true,
630        show_grid: true,
631        ..Default::default()
632    };
633    let vista = cosmos_render::SphereView { yaw_deg: 20.0 + (s - 8.0) * 38.0, pitch_deg: -58.0 + 6.0 * ((s - 8.0) * 0.8).sin() };
634    let cmds = cosmos_render::compose_sphere(cielo, &vista, &opts);
635    cosmos_canvas_llimphi::canvas_view_ex::<()>(cmds, lado, Some(Color::from_rgba8(6, 8, 16, 255)), cosmos_canvas_llimphi::ViewTransform::default())
636}
637
638fn carta() -> cosmos_model::Chart {
639    use cosmos_model::*;
640    Chart {
641        id: ChartId::new(),
642        contact_id: ContactId::new(),
643        kind: ChartKind::Natal,
644        label: "llimphi".to_string(),
645        birth_data: StoredBirthData {
646            year: 2026,
647            month: 10,
648            day: 12,
649            hour: 21,
650            minute: 0,
651            second: 0.0,
652            tz_offset_minutes: -300,
653            latitude_deg: -13.53,
654            longitude_deg: -71.97,
655            altitude_m: 3399.0,
656            time_certainty: TimeCertainty::Exact,
657            subject_name: None,
658            birthplace_label: None,
659        },
660        config: StoredChartConfig::default(),
661        related_chart_id: None,
662        created_at_ms: 0,
663    }
664}
665
666/// El pasillo de supay: un sector convexo con antorchas a los costados que
667/// termina en una fuente. Sin WAD ni atlas: colores planos.
668const ANCHO_P: f32 = 384.0;
669const LARGO_P: f32 = 1500.0;
670/// Dónde está la fuente (centro de la pileta, en unidades de Doom).
671const FUENTE_Y: f32 = 1180.0;
672/// Altura de los ojos del jugador.
673const OJOS: f32 = 41.0;
674
675fn jugador_y(u: f32) -> f32 {
676    // Avanza parejo y en el último tramo se acerca a la pileta.
677    200.0 + (FUENTE_Y - 120.0 - 200.0) * motion::ease_in_out_cubic(u)
678}
679
680fn pasillo(u: f32) -> supay_scene::SceneSnapshot {
681    use supay_scene::*;
682    let wall = |x1, y1, x2, y2| WallSeg {
683        x1,
684        y1,
685        x2,
686        y2,
687        front_sector: 0,
688        back_sector: NO_SECTOR,
689        flags: 0,
690        textures: [[0; 8]; 6],
691        tex_x_offsets: [0.0; 2],
692        tex_y_offsets: [0.0; 2],
693    };
694    let seg = |x1, y1, x2, y2, linedef| SegSnap { x1, y1, x2, y2, solid: true, linedef };
695    let tick = (u * 45.0) as u64;
696    let t = tick as f32 * 0.03;
697    let player = PlayerSnap { x: ANCHO_P * 0.5, y: jugador_y(u), z: 0.0, angle: FRAC_PI_2, view_height: OJOS, view_pitch: 0.0 };
698    let walls = vec![
699        wall(0.0, 0.0, 0.0, LARGO_P),
700        wall(0.0, LARGO_P, ANCHO_P, LARGO_P),
701        wall(ANCHO_P, LARGO_P, ANCHO_P, 0.0),
702        wall(ANCHO_P, 0.0, 0.0, 0.0),
703    ];
704    let light = (235.0 + (t * 0.9).sin() * 20.0) as u8;
705    let sectors = vec![SectorSnap { floor_height: 0.0, ceiling_height: 192.0, light_level: light, floor_pic: 0, ceiling_pic: 0 }];
706    let segs = vec![
707        seg(ANCHO_P, 0.0, 0.0, 0.0, 3),
708        seg(0.0, 0.0, 0.0, LARGO_P, 0),
709        seg(0.0, LARGO_P, ANCHO_P, LARGO_P, 1),
710        seg(ANCHO_P, LARGO_P, ANCHO_P, 0.0, 2),
711    ];
712    let fb = |f: u64| ((f / 4 % 4) as u8) | 0x80;
713    let mut sprites = Vec::new();
714    for k in 0..4 {
715        let y = 420.0 + k as f32 * 300.0;
716        for (x, spr) in [(36.0, 5u16), (ANCHO_P - 36.0, 7u16)] {
717            sprites.push(SpriteSnap { x, y, z: 72.0 + (t * 2.0 + k as f32).sin() * 3.0, angle: 0.0, sprite: spr, frame: fb(tick + k), sector: 0 });
718        }
719    }
720    SceneSnapshot {
721        tick,
722        player,
723        walls: Arc::from(walls),
724        sectors: Arc::from(sectors),
725        sprites: Arc::from(sprites),
726        subsectors: Arc::from(vec![SubsectorSnap { sector: 0, first_seg: 0, num_segs: 4 }]),
727        segs: Arc::from(segs),
728        nodes: Arc::from(Vec::<NodeSnap>::new()),
729        sky_pic: NO_SKY_PIC,
730        player_overlays: PlayerOverlays::default(),
731        weapon: WeaponSpriteSnap::default(),
732        weapon_flash: WeaponSpriteSnap::default(),
733        player_stats: PlayerStats {
734            health: 100,
735            armor_points: 0,
736            armor_type: 0,
737            ready_weapon: 1,
738            ammo: [50, 0, 0, 0],
739            max_ammo: [200, 50, 300, 50],
740            cards: [false; 6],
741        },
742    }
743}
744
745/// La fuente, pintada en la MISMA perspectiva que el pasillo: cada punto se
746/// proyecta con el foco del renderer de supay (fov vertical de 75°), así que la
747/// pileta apoya en el piso y crece a medida que el jugador se acerca.
748fn pintar_fuente(scene: &mut vello::Scene, r: PaintRect, u: f32, s: f32) {
749    let (w, h) = (r.w as f64, r.h as f64);
750    let foco = (h / 2.0) / (37.5f64.to_radians()).tan();
751    let py = jugador_y(u) as f64;
752    let cx = ANCHO_P as f64 * 0.5;
753    // Proyección de un punto del mundo (x, y, z) a la pantalla.
754    let pr = |x: f64, y: f64, z: f64| -> Point {
755        let d = (y - py).max(4.0);
756        Point::new(r.x as f64 + w / 2.0 + foco * (x - cx) / d, r.y as f64 + h / 2.0 - foco * (z - OJOS as f64) / d)
757    };
758    let fy = FUENTE_Y as f64;
759    let piedra = Color::from_rgba8(150, 132, 112, 255);
760    let piedra_osc = Color::from_rgba8(96, 82, 70, 255);
761    let agua = Color::from_rgba8(70, 160, 210, 255);
762    let espuma = Color::from_rgba8(225, 244, 255, 255);
763
764    // Una elipse horizontal (un aro de la fuente) proyectada.
765    let aro = |radio: f64, z: f64| -> BezPath {
766        let mut bp = BezPath::new();
767        for k in 0..=48 {
768            let a = k as f64 / 48.0 * std::f64::consts::TAU;
769            let p = pr(cx + radio * a.cos(), fy + radio * a.sin(), z);
770            if k == 0 {
771                bp.move_to(p)
772            } else {
773                bp.line_to(p)
774            }
775        }
776        bp.close_path();
777        bp
778    };
779    // Una pared cilíndrica vista de frente: la mitad cercana del aro entre dos alturas.
780    let pared = |radio: f64, z0: f64, z1: f64| -> BezPath {
781        let mut bp = BezPath::new();
782        for k in 0..=24 {
783            let a = std::f64::consts::PI + k as f64 / 24.0 * std::f64::consts::PI;
784            let p = pr(cx + radio * a.cos(), fy + radio * a.sin(), z0);
785            if k == 0 {
786                bp.move_to(p)
787            } else {
788                bp.line_to(p)
789            }
790        }
791        for k in (0..=24).rev() {
792            let a = std::f64::consts::PI + k as f64 / 24.0 * std::f64::consts::PI;
793            bp.line_to(pr(cx + radio * a.cos(), fy + radio * a.sin(), z1));
794        }
795        bp.close_path();
796        bp
797    };
798
799    // La pileta: pared, borde y espejo de agua con su brillo.
800    scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(Color::BLACK, 0.35), None, &aro(78.0, 0.5));
801    scene.fill(Fill::NonZero, Affine::IDENTITY, piedra_osc, None, &pared(70.0, 0.0, 24.0));
802    scene.fill(Fill::NonZero, Affine::IDENTITY, piedra, None, &aro(70.0, 24.0));
803    scene.fill(Fill::NonZero, Affine::IDENTITY, agua, None, &aro(62.0, 22.0));
804    let onda = 0.5 + 0.5 * (s as f64 * 9.0).sin();
805    scene.stroke(&Stroke::new(1.5), Affine::IDENTITY, with_alpha(espuma, 0.5), None, &aro(30.0 + 18.0 * onda, 22.0));
806    scene.stroke(&Stroke::new(1.2), Affine::IDENTITY, with_alpha(espuma, 0.35), None, &aro(46.0 + 12.0 * (1.0 - onda), 22.0));
807    // El pie y el plato de arriba.
808    scene.fill(Fill::NonZero, Affine::IDENTITY, piedra_osc, None, &pared(10.0, 22.0, 74.0));
809    scene.fill(Fill::NonZero, Affine::IDENTITY, piedra_osc, None, &pared(28.0, 74.0, 82.0));
810    scene.fill(Fill::NonZero, Affine::IDENTITY, piedra, None, &aro(28.0, 82.0));
811    scene.fill(Fill::NonZero, Affine::IDENTITY, agua, None, &aro(23.0, 81.0));
812
813    // El chorro: sube del plato y cae en arcos hacia la pileta.
814    let tope = 132.0;
815    let chorro = |a: f64| -> BezPath {
816        let mut bp = BezPath::new();
817        for k in 0..=20 {
818            let f = k as f64 / 20.0;
819            let radio = 4.0 + 50.0 * f;
820            let z = 82.0 + (tope - 82.0) * (1.0 - (2.0 * f - 0.35).powi(2)).max(-1.5) * 0.9;
821            let p = pr(cx + radio * a.cos(), fy + radio * a.sin(), z.max(24.0));
822            if k == 0 {
823                bp.move_to(p)
824            } else {
825                bp.line_to(p)
826            }
827        }
828        bp
829    };
830    let escala = foco / (fy - py).max(40.0);
831    for k in 0..6 {
832        let a = std::f64::consts::PI + (k as f64 + 0.5) / 6.0 * std::f64::consts::PI;
833        let sc = Stroke::new((3.0 * escala).max(1.0)).with_caps(peniko::kurbo::Cap::Round);
834        scene.stroke(&sc, Affine::IDENTITY, with_alpha(agua, 0.85), None, &chorro(a));
835        scene.stroke(&Stroke::new((1.2 * escala).max(0.6)), Affine::IDENTITY, with_alpha(espuma, 0.8), None, &chorro(a));
836    }
837    // El surtidor central y las gotas que caen.
838    let base = pr(cx, fy, 82.0);
839    let cima = pr(cx, fy, tope + 8.0);
840    scene.stroke(&Stroke::new((5.0 * escala).max(1.5)).with_caps(peniko::kurbo::Cap::Round), Affine::IDENTITY, espuma, None, &peniko::kurbo::Line::new(base, cima));
841    for k in 0..28 {
842        let fase = ((s as f64 * 1.7 + k as f64 * 0.137) % 1.0).abs();
843        let a = std::f64::consts::PI + ((k * 7) % 13) as f64 / 13.0 * std::f64::consts::PI;
844        let radio = 10.0 + 52.0 * fase;
845        let z = 82.0 + (tope - 82.0) * (1.0 - (2.0 * fase - 0.35).powi(2)) * 0.9;
846        let p = pr(cx + radio * a.cos(), fy + radio * a.sin(), z.max(23.0));
847        scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(espuma, 0.9), None, &Circle::new(p, (1.6 * escala).max(0.8)));
848    }
849}
850
851fn pasillo_view(u: f32, s: f32, w: f32, h: f32) -> View<()> {
852    View::new(Style { size: Size { width: length(w), height: length(h) }, ..Default::default() }).paint_with(move |scene, ts, r: PaintRect| {
853        let snap = pasillo(u);
854        let cfg = supay_render_llimphi::RenderConfig {
855            atlas: None,
856            crosshair: false,
857            hud: true,
858            sprite_shadows: true,
859            world_lights_enabled: true,
860            far_fog: 6000.0,
861            wall_vertical_bands: 4,
862            wall_vertical_gradient: true,
863            plane_depth_gradient: true,
864            ..supay_render_llimphi::RenderConfig::default()
865        };
866        let mut sub = vello::Scene::new();
867        supay_render_llimphi::render_snapshot(&mut sub, ts, r.w, r.h, &snap, &cfg);
868        scene.append(&sub, Some(Affine::translate((r.x as f64, r.y as f64))));
869        // La fuente va encima del pasillo pero debajo del HUD visualmente
870        // basta: el HUD es la franja de abajo y la fuente vive en el medio.
871        scene.push_layer(Fill::NonZero, Mix::Normal, 1.0, Affine::IDENTITY, &peniko::kurbo::Rect::new(r.x as f64, r.y as f64, (r.x + r.w) as f64, (r.y + r.h * 0.9) as f64));
872        pintar_fuente(scene, r, u, s);
873        scene.pop_layer();
874    })
875}
876
877/// Rótulo chico del escenario («cosmos», «supay», «voxel»).
878fn rotulo(texto: &str, a: f32, sk: &Skin) -> View<()> {
879    View::new(Style {
880        position: Position::Absolute,
881        inset: Rect { left: length(22.0), top: length(18.0), right: auto(), bottom: auto() },
882        size: Size { width: auto(), height: length(26.0) },
883        padding: Rect { left: length(12.0), right: length(12.0), top: length(4.0), bottom: length(4.0) },
884        ..Default::default()
885    })
886    .fill(with_alpha(Color::BLACK, 0.45))
887    .radius(13.0)
888    .alpha(a)
889    .text_aligned(texto.to_string(), 13.0, with_alpha(sk.fg, 1.0), Alignment::Start)
890    .bold()
891}
892
893// ───────────────────────── 5. la mano escribe «llimphi» ─────────────────────────
894
895/// Los nudos de la letra cursiva, en unidades de altura-x (y hacia arriba,
896/// base en 0). Una curva Catmull-Rom pasa por todos: es un solo trazo, como
897/// se escribe a mano sin levantar el lápiz.
898fn nudos_llimphi() -> (Vec<(f32, f32)>, Vec<(f32, f32)>) {
899    let l: &[(f32, f32)] = &[(0.25, 0.8), (0.5, 1.9), (0.45, 2.45), (0.28, 2.4), (0.24, 1.8), (0.26, 0.6), (0.35, 0.05), (0.6, 0.12)];
900    let i: &[(f32, f32)] = &[(0.25, 0.6), (0.38, 1.0), (0.33, 0.5), (0.36, 0.05), (0.6, 0.15)];
901    let m: &[(f32, f32)] = &[
902        (0.2, 0.8), (0.28, 1.0), (0.3, 0.5), (0.27, 0.02), (0.36, 0.72), (0.55, 1.0), (0.7, 0.78), (0.69, 0.02),
903        (0.8, 0.72), (1.0, 1.0), (1.15, 0.78), (1.13, 0.2), (1.25, 0.03), (1.4, 0.15),
904    ];
905    let p: &[(f32, f32)] = &[(0.2, 0.7), (0.32, 1.0), (0.3, 0.2), (0.27, -1.1), (0.31, -0.3), (0.36, 0.6), (0.58, 1.0), (0.8, 0.72), (0.72, 0.12), (0.45, 0.05), (0.38, 0.25), (0.62, 0.12), (0.85, 0.2)];
906    let h: &[(f32, f32)] = &[(0.25, 0.8), (0.5, 1.9), (0.45, 2.45), (0.28, 2.4), (0.24, 1.8), (0.25, 0.02), (0.36, 0.72), (0.56, 1.0), (0.72, 0.8), (0.7, 0.2), (0.82, 0.03), (0.95, 0.15)];
907    let i_fin: &[(f32, f32)] = &[(0.25, 0.6), (0.38, 1.0), (0.33, 0.5), (0.36, 0.05), (0.62, 0.2), (0.9, 0.5)];
908    let letras: [(&[(f32, f32)], f32); 7] = [(l, 0.6), (l, 0.6), (i, 0.6), (m, 1.4), (p, 0.85), (h, 0.95), (i_fin, 0.9)];
909    let mut nudos = vec![(-0.2, 0.0), (0.0, 0.1)];
910    let mut puntos = Vec::new();
911    let mut x0 = 0.0;
912    for (k, (letra, avance)) in letras.iter().enumerate() {
913        for &(x, y) in letra.iter() {
914            nudos.push((x0 + x, y));
915        }
916        if k == 2 || k == 6 {
917            puntos.push((x0 + 0.42, 1.5));
918        }
919        x0 += avance;
920    }
921    (nudos, puntos)
922}
923
924/// Muestrea la Catmull-Rom por los nudos.
925fn catmull(n: &[(f32, f32)], pasos: usize) -> Vec<(f32, f32)> {
926    let mut out = Vec::new();
927    for k in 1..n.len() - 2 {
928        let (p0, p1, p2, p3) = (n[k - 1], n[k], n[k + 1], n[k + 2]);
929        for j in 0..pasos {
930            let t = j as f32 / pasos as f32;
931            let (t2, t3) = (t * t, t * t * t);
932            let f = |a: f32, b: f32, c: f32, d: f32| 0.5 * (2.0 * b + (-a + c) * t + (2.0 * a - 5.0 * b + 4.0 * c - d) * t2 + (-a + 3.0 * b - 3.0 * c + d) * t3);
933            out.push((f(p0.0, p1.0, p2.0, p3.0), f(p0.1, p1.1, p2.1, p3.1)));
934        }
935    }
936    out.push(n[n.len() - 2]);
937    out
938}
939
940/// La mano de ende (`ende_pinta::mano::silueta`), posada con la punta del
941/// marcador en `punta`. Su espacio es de 0..1000 con la punta en el origen.
942fn pintar_mano(scene: &mut vello::Scene, punta: Point, alto: f64, alpha: f32) {
943    use ende_pinta::figura::{Relleno, Rol};
944    use tullpu_core::ComandoPath as C;
945    let k = alto / ende_pinta::mano::ALTO_BASE as f64;
946    let xf = Affine::translate((punta.x, punta.y)) * Affine::scale(k);
947    let tinta = with_alpha(Color::from_rgba8(30, 28, 34, 255), alpha);
948    let piel = with_alpha(Color::from_rgba8(232, 188, 152, 255), alpha);
949    let papel = with_alpha(Color::from_rgba8(246, 243, 236, 255), alpha);
950    for pz in ende_pinta::mano::silueta() {
951        let mut bp = BezPath::new();
952        for c in &pz.comandos {
953            match *c {
954                C::MoverA { x, y } => bp.move_to((x as f64, y as f64)),
955                C::LineaA { x, y } => bp.line_to((x as f64, y as f64)),
956                C::CurvaA { c1x, c1y, c2x, c2y, x, y } => bp.curve_to((c1x as f64, c1y as f64), (c2x as f64, c2y as f64), (x as f64, y as f64)),
957                C::Cerrar => bp.close_path(),
958                #[allow(unreachable_patterns)]
959                _ => {}
960            }
961        }
962        match pz.relleno {
963            Relleno::Piel => scene.fill(Fill::NonZero, xf, piel, None, &bp),
964            Relleno::Fondo => scene.fill(Fill::NonZero, xf, papel, None, &bp),
965            Relleno::Tinta => scene.fill(Fill::NonZero, xf, tinta, None, &bp),
966            _ => {}
967        }
968        let ancho = if pz.rol == Rol::Tinta { 3.2 } else { 2.0 } * pz.grosor as f64;
969        scene.stroke(&Stroke::new(ancho / k), xf, tinta, None, &bp);
970    }
971}
972
973fn firma_view(s: f32, cw: f64, ch: f64) -> View<()> {
974    View::new(absoluto(Caja { x: 0.0, y: 0.0, w: cw, h: ch })).paint_with(move |scene, _ts, _r: PaintRect| {
975        let avance = motion::ease_in_out_cubic(seg(s, 12.9, 14.5)) as f64;
976        if avance <= 0.0 {
977            return;
978        }
979        let (nudos, puntos) = nudos_llimphi();
980        let curva = catmull(&nudos, 14);
981        // Escala y lugar: «llimphi» a lo ancho del centro, arriba del muñequito.
982        let u = 92.0; // px por altura-x
983        let ancho_total = 6.0 * u;
984        let ox = (cw - ancho_total) / 2.0 - 30.0;
985        let oy = ch * 0.40;
986        let a_px = |(x, y): (f32, f32)| Point::new(ox + x as f64 * u, oy - y as f64 * u);
987        // Largo acumulado, para revelar por distancia (la mano va pareja).
988        let mut acum = vec![0.0f64];
989        for w in curva.windows(2) {
990            let (a, b) = (a_px(w[0]), a_px(w[1]));
991            acum.push(acum.last().unwrap() + a.distance(b));
992        }
993        let total = *acum.last().unwrap();
994        // El trazo principal ocupa el 88 % del tiempo; los puntos de las íes, el resto.
995        let largo = total * (avance / 0.88).min(1.0);
996        let mut bp = BezPath::new();
997        let mut punta = a_px(curva[0]);
998        bp.move_to(punta);
999        for (k, p) in curva.iter().enumerate().skip(1) {
1000            if acum[k] > largo {
1001                let a = acum[k - 1];
1002                let f = ((largo - a) / (acum[k] - a)).clamp(0.0, 1.0);
1003                let q = a_px(curva[k - 1]).lerp(a_px(*p), f);
1004                bp.line_to(q);
1005                punta = q;
1006                break;
1007            }
1008            punta = a_px(*p);
1009            bp.line_to(punta);
1010        }
1011        let tinta = Color::from_rgba8(250, 250, 246, 255);
1012        let sombra = Stroke::new(11.0).with_caps(peniko::kurbo::Cap::Round).with_join(peniko::kurbo::Join::Round);
1013        let trazo = Stroke::new(7.0).with_caps(peniko::kurbo::Cap::Round).with_join(peniko::kurbo::Join::Round);
1014        scene.stroke(&sombra, Affine::translate((0.0, 3.0)), with_alpha(Color::BLACK, 0.35), None, &bp);
1015        scene.stroke(&trazo, Affine::IDENTITY, tinta, None, &bp);
1016        // Los puntos de las íes, con un toque cada uno.
1017        let pa = seg(avance as f32, 0.9, 1.0);
1018        for (j, &pt) in puntos.iter().enumerate() {
1019            if pa > j as f32 * 0.5 {
1020                let c = a_px(pt);
1021                scene.fill(Fill::NonZero, Affine::translate((0.0, 3.0)), with_alpha(Color::BLACK, 0.35), None, &Circle::new(c, 6.5));
1022                scene.fill(Fill::NonZero, Affine::IDENTITY, tinta, None, &Circle::new(c, 5.0));
1023                if pa < 1.0 {
1024                    punta = c;
1025                }
1026            }
1027        }
1028        // La mano sigue la punta y después se retira hacia abajo a la derecha.
1029        let retiro = motion::ease_in_out_cubic(seg(s, 14.55, 15.2)) as f64;
1030        let mano_a = 1.0 - seg(s, 14.95, 15.3);
1031        if mano_a > 0.0 {
1032            let p = Point::new(punta.x + 520.0 * retiro, punta.y + 420.0 * retiro);
1033            pintar_mano(scene, p, 260.0, mano_a);
1034        }
1035    })
1036}
1037
1038// ───────────────────────── 4.c el voxel: mundo infinito bajo el nevado ─────────────────────────
1039
1040/// La ventana del mundo que vive en la GPU. El terreno es infinito porque sale
1041/// de `column_height(wx, wz)`, una función pura de las coordenadas de MUNDO:
1042/// la ventana se rellena alrededor de la cámara en cada cuadro y encaja sola.
1043const VDIM: [u32; 3] = [192, 80, 192];
1044const SEMILLA: u32 = 1337;
1045/// Cuándo empieza el tramo voxel.
1046const T_VOX: f32 = 10.45;
1047/// Alto del muñequito, en voxels.
1048const ALTO_SER: f32 = 9.0;
1049/// La bruma del pie de la montaña: es el color hacia el que se desvanece el
1050/// terreno lejano, y el del cielo pintado en el horizonte, para que no haya costura.
1051const BRUMA: [u8; 3] = [214, 226, 238];
1052
1053fn altura(wx: f32, wz: f32) -> f32 {
1054    llimphi_voxel::column_height(wx as i32, wz as i32, VDIM, SEMILLA) as f32
1055}
1056
1057/// La superficie: el terreno o el agua, lo que esté más alto. El terreno lleno
1058/// de agua hasta el 30 % del alto (el nivel del mar de `fill_terrain_window`),
1059/// y la cámara se mide contra esto: contra el fondo del lago, se mete abajo del agua.
1060fn superficie(wx: f32, wz: f32) -> f32 {
1061    altura(wx, wz).max((VDIM[1] as f32 * 0.30).floor())
1062}
1063
1064/// Un lugar de pasto parejo, sobre el agua, para que el muñequito festeje.
1065fn lugar_del_festejo() -> (f32, f32, f32) {
1066    // El lugar con pasto más parejo de una franja del mundo, con dos
1067    // condiciones: atrás (por donde llega la cámara, -z) no hay nada más alto
1068    // que él, y adelante (por donde se va, +z) el suelo es parejo. Se elige por
1069    // puntaje y no por umbral: con esta semilla casi no hay mesetas.
1070    let dy = VDIM[1] as f32;
1071    let mut mejor: Option<(f32, (f32, f32, f32))> = None;
1072    for z in (400..3000).step_by(4) {
1073        for x in (-160..=160).step_by(4) {
1074            let (x, z) = (x as f32, z as f32);
1075            let h = altura(x, z);
1076            if h / dy < 0.33 || h / dy > 0.6 {
1077                continue;
1078            }
1079            let despejado = (1..=24).all(|k| {
1080                let d = k as f32 * 4.0;
1081                (-1..=1).all(|j| altura(x + j as f32 * 6.0, z - d) <= h + 2.0)
1082            });
1083            if !despejado {
1084                continue;
1085            }
1086            let rugosidad: f32 = (-1..=8)
1087                .flat_map(|k| [(-2.0, k as f32 * 4.0), (2.0, k as f32 * 4.0)])
1088                .map(|(dx, dz)| (altura(x + dx, z + dz) - h).abs())
1089                .sum();
1090            if mejor.map_or(true, |(r, _)| rugosidad < r) {
1091                mejor = Some((rugosidad, (x, h, z)));
1092            }
1093        }
1094    }
1095    mejor.expect("ninguna columna con pasto despejada en la franja").1
1096}
1097
1098/// El suelo más alto alrededor de un punto: la cámara nunca baja de ahí.
1099fn techo_cercano(x: f32, z: f32, radio: f32) -> f32 {
1100    let mut m = 0.0f32;
1101    for i in -2..=2 {
1102        for j in -2..=2 {
1103            m = m.max(superficie(x + i as f32 * radio * 0.5, z + j as f32 * radio * 0.5));
1104        }
1105    }
1106    m
1107}
1108
1109struct Voxel {
1110    vr: llimphi_3d::VoxelRenderer,
1111    grid: llimphi_3d::VoxelGrid,
1112    origen: [i32; 2],
1113    tex: wgpu::Texture,
1114    view: wgpu::TextureView,
1115    w: u32,
1116    h: u32,
1117    festejo: (f32, f32, f32),
1118}
1119
1120/// La cordillera pintada detrás del mundo: cielo de altura, una sierra lejana
1121/// en bruma y el nevado. `horizonte` es la `y` de pantalla del horizonte de la
1122/// cámara: así la montaña se queda quieta en el mundo cuando la cámara cabecea.
1123fn pintar_andes(scene: &mut vello::Scene, w: f64, h: f64, horizonte: f64) {
1124    let rgb = |c: [u8; 3]| Color::from_rgba8(c[0], c[1], c[2], 255);
1125    let cielo = Gradient::new_linear(Point::new(0.0, horizonte - h * 0.9), Point::new(0.0, horizonte)).with_stops(
1126        [Color::from_rgba8(38, 84, 164, 255), Color::from_rgba8(96, 146, 206, 255), rgb(BRUMA)].as_slice(),
1127    );
1128    scene.fill(Fill::NonZero, Affine::IDENTITY, &cielo, None, &peniko::kurbo::Rect::new(0.0, 0.0, w, h));
1129
1130    // Un perfil quebrado y determinista: desplazamiento del punto medio.
1131    let perfil = |x0: f64, y0: f64, x1: f64, y1: f64, rugosidad: f64, semilla: u32| -> Vec<Point> {
1132        let mut pts = vec![Point::new(x0, y0), Point::new(x1, y1)];
1133        let mut amp = rugosidad;
1134        let mut k = semilla;
1135        for _ in 0..7 {
1136            let mut nuevos = Vec::with_capacity(pts.len() * 2);
1137            for par in pts.windows(2) {
1138                nuevos.push(par[0]);
1139                k = k.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
1140                let r = (k >> 8) as f64 / (1u64 << 24) as f64 - 0.5;
1141                let m = par[0].midpoint(par[1]);
1142                nuevos.push(Point::new(m.x, m.y + r * amp));
1143            }
1144            nuevos.push(*pts.last().unwrap());
1145            pts = nuevos;
1146            amp *= 0.52;
1147        }
1148        pts
1149    };
1150    let poligono = |pts: &[Point], base: f64| -> BezPath {
1151        let mut bp = BezPath::new();
1152        bp.move_to(Point::new(pts[0].x, base));
1153        for p in pts {
1154            bp.line_to(*p);
1155        }
1156        bp.line_to(Point::new(pts.last().unwrap().x, base));
1157        bp.close_path();
1158        bp
1159    };
1160
1161    // La sierra lejana, en bruma.
1162    let lejos = Color::from_rgba8(168, 186, 210, 255);
1163    let mut sierra = perfil(-40.0, horizonte - h * 0.10, w * 0.5, horizonte - h * 0.16, h * 0.10, 7);
1164    sierra.extend(perfil(w * 0.5, horizonte - h * 0.16, w + 40.0, horizonte - h * 0.08, h * 0.10, 11).into_iter().skip(1));
1165    scene.fill(Fill::NonZero, Affine::IDENTITY, lejos, None, &poligono(&sierra, horizonte + 4.0));
1166
1167    // El nevado: un pico grande, roca en sombra y la nieve arriba.
1168    let cumbre = Point::new(w * 0.60, horizonte - h * 0.50);
1169    let izq = perfil(w * 0.18, horizonte, cumbre.x, cumbre.y, h * 0.09, 23);
1170    let der = perfil(cumbre.x, cumbre.y, w * 1.02, horizonte - h * 0.02, h * 0.09, 29);
1171    let mut ladera: Vec<Point> = izq.clone();
1172    ladera.extend(der.iter().skip(1).copied());
1173    let roca = Color::from_rgba8(92, 104, 128, 255);
1174    scene.fill(Fill::NonZero, Affine::IDENTITY, roca, None, &poligono(&ladera, horizonte + 4.0));
1175
1176    // La línea de nieve: un borde dentado por debajo de la cumbre.
1177    let linea = horizonte - h * 0.30;
1178    let nieve_de = |pts: &[Point]| -> Vec<Point> { pts.iter().copied().filter(|p| p.y < linea).collect() };
1179    let (ni, nd) = (nieve_de(&izq), nieve_de(&der));
1180    if ni.len() > 1 && nd.len() > 1 {
1181        let dientes = perfil(nd.last().unwrap().x, linea, ni[0].x, linea, h * 0.07, 41);
1182        // Lado al sol (izquierda) y lado en sombra (derecha), partidos por la arista.
1183        let mut sol = BezPath::new();
1184        sol.move_to(ni[0]);
1185        for p in ni.iter().skip(1) {
1186            sol.line_to(*p);
1187        }
1188        let medio = dientes.len() / 2;
1189        for p in dientes.iter().skip(medio) {
1190            sol.line_to(*p);
1191        }
1192        sol.close_path();
1193        let mut sombra = BezPath::new();
1194        sombra.move_to(cumbre);
1195        for p in nd.iter().skip(1) {
1196            sombra.line_to(*p);
1197        }
1198        for p in dientes.iter().take(medio + 1) {
1199            sombra.line_to(*p);
1200        }
1201        sombra.close_path();
1202        scene.fill(Fill::NonZero, Affine::IDENTITY, Color::from_rgba8(246, 249, 253, 255), None, &sol);
1203        scene.fill(Fill::NonZero, Affine::IDENTITY, Color::from_rgba8(190, 204, 228, 255), None, &sombra);
1204        // Las canaletas de los glaciares.
1205        for k in 0..5 {
1206            let x = cumbre.x + (k as f64 - 2.0) * w * 0.035;
1207            let a = Point::new(x, cumbre.y + h * 0.06 + (k % 2) as f64 * h * 0.03);
1208            let b = Point::new(x + (k as f64 - 2.0) * 10.0, linea + h * 0.02);
1209            scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, Color::from_rgba8(160, 176, 204, 200), None, &peniko::kurbo::Line::new(a, b));
1210        }
1211    }
1212    // Bruma al pie: la costura con el terreno voxel se funde acá.
1213    let pie = Gradient::new_linear(Point::new(0.0, horizonte - h * 0.12), Point::new(0.0, horizonte + 2.0))
1214        .with_stops([with_alpha(rgb(BRUMA), 0.0), rgb(BRUMA)].as_slice());
1215    scene.fill(Fill::NonZero, Affine::IDENTITY, &pie, None, &peniko::kurbo::Rect::new(0.0, horizonte - h * 0.12, w, h));
1216}
1217
1218impl Voxel {
1219    fn new(hal: &Hal, w: u32, h: u32) -> Self {
1220        let festejo = lugar_del_festejo();
1221        eprintln!("festejo en {:?} (alto {:.0} % del mundo)", festejo, 100.0 * festejo.1 / VDIM[1] as f32);
1222        let mut grid = llimphi_3d::VoxelGrid::new(VDIM);
1223        let origen = [-96, festejo.2 as i32 - 600];
1224        llimphi_voxel::fill_terrain_window(&mut grid, origen, SEMILLA);
1225        let mut vr = llimphi_3d::VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
1226        grid.reset_dirty();
1227        let sol = [0.25f32, 0.75, -0.6];
1228        let l = (sol[0] * sol[0] + sol[1] * sol[1] + sol[2] * sol[2]).sqrt();
1229        vr.sun_dir = [sol[0] / l, sol[1] / l, sol[2] / l];
1230        let tex = hal.device.create_texture(&wgpu::TextureDescriptor {
1231            label: Some("voxel"),
1232            size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
1233            mip_level_count: 1,
1234            sample_count: 1,
1235            dimension: wgpu::TextureDimension::D2,
1236            format: FMT,
1237            usage: wgpu::TextureUsages::STORAGE_BINDING
1238                | wgpu::TextureUsages::TEXTURE_BINDING
1239                | wgpu::TextureUsages::RENDER_ATTACHMENT
1240                | wgpu::TextureUsages::COPY_SRC,
1241            view_formats: &[],
1242        });
1243        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
1244        Voxel { vr, grid, origen, tex, view, w, h, festejo }
1245    }
1246
1247    /// Un pase voxel sobre `fondo`, con o sin niebla, leído a memoria.
1248    fn pase(&mut self, hal: &Hal, renderer: &mut Renderer, cam: &llimphi_3d::Camera3d, niebla: bool, fondo: Color) -> Vec<u8> {
1249        self.vr.atmosphere = llimphi_3d::Atmosphere {
1250            sky_zenith: [52, 98, 170],
1251            sky_horizon: BRUMA,
1252            fog_density: if niebla { 0.003 } else { 0.0 },
1253            god_rays: 0.0,
1254        };
1255        renderer.render_to_view(hal, &vello::Scene::new(), &self.view, self.w, self.h, fondo).expect("fondo voxel");
1256        let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
1257        self.vr.render(&hal.device, &hal.queue, &mut enc, &self.view, (self.w, self.h), cam);
1258        hal.queue.submit(std::iter::once(enc.finish()));
1259        leer(hal, &self.tex, self.w, self.h)
1260    }
1261
1262    /// El cuadro del instante `s`: el vuelo rápido sobre el mundo, la llegada
1263    /// al muñequito, su festejo y su partida con la cámara subiendo.
1264    fn cuadro(&mut self, hal: &Hal, renderer: &mut Renderer, s: f32) -> ImageBrush {
1265        use llimphi_3d::glam::Vec3;
1266        use llimphi_voxel::{ser_sdf, Clip, Colores, EstiloSer, Pose};
1267        let tv = s - T_VOX;
1268        let (xs, hs, zs) = self.festejo;
1269
1270        // ── la cámara (coordenadas de MUNDO; y = índice de voxel) ──
1271        let llega = motion::ease_out_cubic(seg(tv, 0.0, 2.1));
1272        let zc = zs - 34.0 - 520.0 * (1.0 - llega);
1273        // El balanceo corre el ojo Y lo que mira (ver `mira_ser`): con la mirada
1274        // apenas torcida en x, la columna de rayos casi paralelos al eje z abre
1275        // una grieta recta en el DDA del motor voxel (medido con `raya.py`: 0
1276        // filas con la mirada derecha, ~200 con el ojo corrido medio voxel).
1277        let balanceo = 10.0 * ((tv * 1.3).sin()) * (1.0 - llega);
1278        let xc = xs + balanceo;
1279        let mut adelante = 0.0f32;
1280        for k in 0..12 {
1281            adelante = adelante.max(superficie(xc, zc + k as f32 * 5.0));
1282        }
1283        let aterriza = ease(seg(tv, 1.3, 2.1));
1284        let mut ojo = Vec3::new(xc, (adelante + 13.0) * (1.0 - aterriza) + (hs + 8.0) * aterriza, zc);
1285        // Margen de 11: los árboles asoman hasta 9 voxels sobre el suelo.
1286        ojo.y = ojo.y.max(techo_cercano(xc, zc, 8.0) + 11.0);
1287        let grua = ease(seg(tv, 2.6, 4.9));
1288        ojo.y += 30.0 * grua;
1289        ojo.z -= 16.0 * grua;
1290
1291        // ── el muñequito: festeja mirando a la cámara, se da vuelta y se va ──
1292        let camina = seg(tv, 3.15, 5.0);
1293        let zser = zs + 26.0 * ease(camina);
1294        let hser = altura(xs, zser);
1295        let festejo = Clip::Cheer.pose(tv * 7.0);
1296        let paso = Clip::Walk.pose((tv - 3.1).max(0.0) * 9.0);
1297        let pose = Pose::lerp(&festejo, &paso, seg(tv, 3.0, 3.25));
1298        let salto_t = seg(tv, 1.85, 3.0);
1299        let salto = if salto_t > 0.0 && salto_t < 1.0 { (salto_t * 2.0 * PI).sin().abs() * 3.0 } else { 0.0 };
1300        let yaw = PI * (1.0 - ease(seg(tv, 2.95, 3.25)));
1301
1302        let mira_vuelo = ojo + Vec3::new(0.0, -0.24, 1.0) * 60.0;
1303        let mira_ser = Vec3::new(xc, hser + ALTO_SER * 0.5, zser);
1304        // Al subir, la mirada se levanta hacia el horizonte: el nevado queda a la vista.
1305        let mira_lejos = Vec3::new(xc, hs + 26.0, zs + 260.0);
1306        let mira = mira_vuelo.lerp(mira_ser, ease(seg(tv, 1.2, 2.1))).lerp(mira_lejos, 0.55 * grua);
1307
1308        // ── la ventana del mundo sigue a la cámara ──
1309        let b = 8;
1310        let origen = [((xc as i32 - 96).div_euclid(b)) * b, ((ojo.z as i32 - 40).div_euclid(b)) * b];
1311        if origen != self.origen {
1312            llimphi_voxel::fill_terrain_window(&mut self.grid, origen, SEMILLA);
1313            self.vr.sync(&hal.queue, &mut self.grid);
1314            self.origen = origen;
1315        }
1316        let (ox, oz) = (self.origen[0] as f32, self.origen[1] as f32);
1317        let mitad = Vec3::new(VDIM[0] as f32, VDIM[1] as f32, VDIM[2] as f32) * 0.5;
1318        let local = |p: Vec3| Vec3::new(p.x - ox, p.y, p.z - oz) - mitad;
1319        let col = Colores::nuevos([0.92, 0.74, 0.60], [0.17, 0.85, 0.65], [0.20, 0.24, 0.40]);
1320        let ser = ser_sdf(&EstiloSer::amable(), &pose, ALTO_SER, col).posado([xs - ox, hser + 1.0 + salto, zser - oz], yaw);
1321        self.vr.sers.clear();
1322        self.vr.sers.push(ser);
1323        let cam = llimphi_3d::Camera3d { eye: local(ojo), target: local(mira), ..Default::default() };
1324
1325        // ── tres pases: el mundo con niebla, la máscara del cielo, la cordillera ──
1326        let magenta = Color::from_rgba8(255, 0, 255, 255);
1327        let mundo = self.pase(hal, renderer, &cam, true, Color::BLACK);
1328        let mascara = self.pase(hal, renderer, &cam, false, magenta);
1329        let d = mira - ojo;
1330        let cabeceo = d.y.atan2((d.x * d.x + d.z * d.z).sqrt()) as f64;
1331        let (wf, hf) = (self.w as f64, self.h as f64);
1332        let foco = (hf / 2.0) / (cam.fovy_rad as f64 / 2.0).tan();
1333        let horizonte = hf / 2.0 - foco * cabeceo.tan();
1334        let mut andes = vello::Scene::new();
1335        pintar_andes(&mut andes, wf, hf, horizonte);
1336        renderer.render_to_view(hal, &andes, &self.view, self.w, self.h, Color::BLACK).expect("andes");
1337        let mut px = leer(hal, &self.tex, self.w, self.h);
1338        // Donde el rayo no tocó nada va la cordillera; el resto, el mundo.
1339        for ((o, m), c) in px.chunks_exact_mut(4).zip(mascara.chunks_exact(4)).zip(mundo.chunks_exact(4)) {
1340            let cielo = m[0] == 255 && m[1] == 0 && m[2] == 255;
1341            if !cielo {
1342                o.copy_from_slice(c);
1343            }
1344        }
1345        tapar_grietas_en_el_agua(&mut px, self.w as usize, self.h as usize);
1346        ImageBrush::new(ImageData {
1347            data: Blob::new(Arc::new(px)),
1348            format: ImageFormat::Rgba8,
1349            alpha_type: ImageAlphaType::Alpha,
1350            width: self.w,
1351            height: self.h,
1352        })
1353    }
1354}
1355
1356/// **Parche cosmético**, no arreglo: el render voxel abre a veces una grieta
1357/// vertical punteada de 1–2 px sobre el agua mientras la cámara vuela (no es
1358/// el ser, ni el sol, ni la x entera de la cámara, ni el umbral de la guarda
1359/// del DDA: los cuatro se descartaron midiendo). Hasta encontrar la causa en
1360/// `llimphi-3d`, un píxel oscuro con agua a los dos lados toma el agua de al lado.
1361fn tapar_grietas_en_el_agua(px: &mut [u8], w: usize, h: usize) {
1362    let es_agua = |p: &[u8]| p[2] as i32 > p[0] as i32 + 30 && p[2] >= p[1] && p[2] > 70;
1363    let lum = |p: &[u8]| p[0] as i32 + p[1] as i32 + p[2] as i32;
1364    for y in 0..h {
1365        let fila = &mut px[y * w * 4..(y + 1) * w * 4];
1366        let mut x = 1;
1367        while x + 8 < w {
1368            // ¿Arranca acá una franja oscura de 1 a 6 px con agua clara a los dos lados?
1369            let izq = &fila[(x - 1) * 4..(x - 1) * 4 + 4];
1370            let (li, agua_izq) = (lum(izq), es_agua(izq));
1371            let mut ancho = 0;
1372            while ancho < 7 && lum(&fila[(x + ancho) * 4..(x + ancho) * 4 + 4]) + 90 < li {
1373                ancho += 1;
1374            }
1375            if agua_izq && (1..=6).contains(&ancho) {
1376                let der: [u8; 4] = fila[(x + ancho) * 4..(x + ancho) * 4 + 4].try_into().unwrap();
1377                if es_agua(&der) && lum(&der) > li - 60 {
1378                    let izq: [u8; 4] = izq.try_into().unwrap();
1379                    for k in 0..ancho {
1380                        let f = (k + 1) as f32 / (ancho + 1) as f32;
1381                        for c in 0..3 {
1382                            fila[(x + k) * 4 + c] = (izq[c] as f32 * (1.0 - f) + der[c] as f32 * f) as u8;
1383                        }
1384                    }
1385                    x += ancho;
1386                    continue;
1387                }
1388            }
1389            x += 1;
1390        }
1391    }
1392}
1393
1394// ───────────────────────── la escena entera ─────────────────────────
1395
1396fn build_view(s: f32, cw: f64, ch: f64, sk: &Skin, cielo: &cosmos_render::RenderModel, voxel: Option<ImageBrush>) -> View<()> {
1397    let mut hijos: Vec<View<()>> = Vec::new();
1398
1399    // El scroll: los controles suben y el escenario entra desde abajo.
1400    let desliza = ease(seg(s, 7.0, 7.6)) as f64;
1401    let fuera = seg(s, 7.6, 7.7);
1402
1403    // Los controles: entran en cascada, del más simple al más complejo.
1404    if s > 3.0 && fuera < 1.0 {
1405        for (i, c) in tiles(cw).iter().enumerate() {
1406            let t0 = 3.05 + i as f32 * 0.2;
1407            let entra = motion::ease_out_back(seg(s, t0, t0 + 0.45));
1408            if entra <= 0.001 {
1409                continue;
1410            }
1411            let p = seg(s, t0 + 0.2, t0 + 2.4);
1412            let escala = lerp(0.86, 1.0, entra.min(1.0) as f64);
1413            let alfa = entra.min(1.0);
1414            let movida = Caja { y: c.y - desliza * ch, ..*c };
1415            hijos.push(tile_view(i, movida, alfa, escala, p, s, sk));
1416        }
1417    }
1418
1419    // El escenario.
1420    if desliza > 0.0 {
1421        let mut e = escenario(s, cw, ch);
1422        e.y += (1.0 - desliza) * ch;
1423        let radio = lerp(18.0, 0.0, seg(s, 10.3, 10.6) as f64);
1424        let mut contenido: Vec<View<()>> = Vec::new();
1425        let a_doom = seg(s, 9.0, 9.2);
1426        let a_vox = seg(s, 10.42, 10.5);
1427        let lleno = |st: Style| -> Style {
1428            Style {
1429                position: Position::Absolute,
1430                inset: Rect { left: length(0.0), top: length(0.0), right: auto(), bottom: auto() },
1431                size: Size { width: length(e.w as f32), height: length(e.h as f32) },
1432                ..st
1433            }
1434        };
1435        // Cosmos.
1436        if a_doom < 1.0 {
1437            let lado = e.h as f32;
1438            contenido.push(
1439                View::new(lleno(Style { justify_content: Some(JustifyContent::CENTER), align_items: Some(AlignItems::CENTER), ..Default::default() }))
1440                    .fill(Color::from_rgba8(6, 8, 16, 255))
1441                    .alpha(1.0 - a_doom)
1442                    .children(vec![View::new(Style { size: Size { width: length(lado), height: length(lado) }, flex_shrink: 0.0, ..Default::default() }).children(vec![cielo_view(cielo, s, lado)])]),
1443            );
1444        }
1445        // Supay: el pasillo hasta la fuente.
1446        if a_doom > 0.0 && a_vox < 1.0 {
1447            let u = seg(s, 9.0, 10.45);
1448            contenido.push(View::new(lleno(Style::default())).alpha(a_doom * (1.0 - a_vox)).children(vec![pasillo_view(u, s, e.w as f32, e.h as f32)]));
1449        }
1450        // Voxel (ya rendido a imagen, del tamaño de la pantalla).
1451        if let Some(img) = voxel {
1452            contenido.push(View::new(lleno(Style::default())).alpha(a_vox).image(img));
1453        }
1454        // Rótulos.
1455        let r_cosmos = seg(s, 7.4, 7.7) * (1.0 - a_doom);
1456        let r_supay = a_doom * (1.0 - seg(s, 10.1, 10.3));
1457        let r_vox = seg(s, 10.8, 11.0) * (1.0 - seg(s, 11.8, 12.1));
1458        if r_cosmos > 0.0 {
1459            contenido.push(rotulo("cosmos", r_cosmos, sk));
1460        }
1461        if r_supay > 0.0 {
1462            contenido.push(rotulo("supay", r_supay, sk));
1463        }
1464        if r_vox > 0.0 {
1465            contenido.push(rotulo("voxel", r_vox, sk));
1466        }
1467        let mut st = absoluto(e);
1468        st.overflow = taffy::Point { x: taffy::Overflow::Hidden, y: taffy::Overflow::Hidden };
1469        hijos.push(
1470            View::new(st)
1471                .fill(Color::from_rgba8(6, 8, 16, 255))
1472                .radius(radio)
1473                .shadow(Shadow::soft(140, 30.0).offset(0.0, 14.0))
1474                .clip(true)
1475                .children(contenido),
1476        );
1477    }
1478
1479    // El salto al agua: un destello del color del agua que tapa el cambio al 3D.
1480    let destello = seg(s, 10.15, 10.45) * (1.0 - seg(s, 10.5, 10.9));
1481    if destello > 0.0 {
1482        hijos.push(View::new(absoluto(Caja { x: 0.0, y: 0.0, w: cw, h: ch })).fill(with_alpha(Color::from_rgba8(196, 232, 250, 255), destello)));
1483    }
1484
1485    // La línea de comando va encima de todo lo de arriba.
1486    hijos.push(input_view(s, cw, ch, sk));
1487    // Y la firma, encima de todo.
1488    hijos.push(firma_view(s, cw, ch));
1489
1490    View::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, position: Position::Relative, ..Default::default() })
1491        .fill(sk.bg)
1492        .children(hijos)
1493}
1494
1495fn main() {
1496    let mut args = std::env::args().skip(1);
1497    let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string());
1498    let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(465);
1499    let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
1500    let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
1501    // SOLO=<desde>,<hasta> en segundos: rinde sólo ese tramo (para mirar uno sin pagar todos).
1502    let solo: Option<(f32, f32)> = std::env::var("SOLO").ok().and_then(|v| {
1503        let mut it = v.split(',').filter_map(|x| x.trim().parse().ok());
1504        Some((it.next()?, it.next()?))
1505    });
1506    create_dir_all(&out_dir).expect("mkdir out_dir");
1507
1508    let theme = Theme::by_name("Tawa").expect("tema Tawa");
1509    let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF);
1510    let sk = Skin {
1511        accent,
1512        panel: theme.bg_panel,
1513        panel_hi: theme.bg_button,
1514        border: theme.border,
1515        fg: theme.fg_text,
1516        fg_muted: theme.fg_muted,
1517        bg: theme.bg_app,
1518        luces: [Color::from_rgba8(110, 140, 220, 255), Color::from_rgba8(190, 110, 230, 255), accent],
1519        theme,
1520    };
1521    let [br, bg, bb, _] = sk.bg.components;
1522    let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
1523
1524    let cielo = cosmos_engine::compute_mock(&carta());
1525
1526    let hal = pollster::block_on(Hal::new(None)).expect("hal");
1527    let mut renderer = Renderer::new(&hal).expect("renderer");
1528    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
1529        label: Some("showreel"),
1530        size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
1531        mip_level_count: 1,
1532        sample_count: 1,
1533        dimension: wgpu::TextureDimension::D2,
1534        format: FMT,
1535        usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1536        view_formats: &[],
1537    });
1538    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
1539    let mut voxel = Voxel::new(&hal, w, h);
1540
1541    let mut ts = Typesetter::new();
1542    let (cw, ch) = (w as f64, h as f64);
1543
1544    for i in 0..n {
1545        let s = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) * DURACION };
1546        if let Some((a, b)) = solo {
1547            if s < a || s > b {
1548                continue;
1549            }
1550        }
1551        let img = if s >= 10.4 { Some(voxel.cuadro(&hal, &mut renderer, s)) } else { None };
1552        let root = build_view(s, cw, ch, &sk, &cielo, img);
1553
1554        let mut layout = LayoutTree::new();
1555        let mounted = mount(&mut layout, root);
1556        let computed = {
1557            let tmap = &mounted.text_measures;
1558            layout
1559                .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| match tmap.get(&nid) {
1560                    Some(tm) => measure_text_node(&mut ts, tm, known, avail),
1561                    None => taffy::Size::ZERO,
1562                })
1563                .expect("layout")
1564        };
1565        let mut scene = vello::Scene::new();
1566        paint(&mut scene, &mounted, &computed, &mut ts, None, None);
1567        renderer.render_to_view(&hal, &scene, &view, w, h, base).expect("render_to_view");
1568        let px = leer(&hal, &target, w, h);
1569        escribir_png(&px, &format!("{out_dir}/frame_{i:04}.png"), w, h);
1570        if i % 30 == 0 || i == n - 1 {
1571            eprintln!("showreel: cuadro {}/{} (s={:.2})", i + 1, n, s);
1572        }
1573    }
1574    eprintln!("showreel: {n} cuadros en {out_dir}/ ({w}x{h})");
1575}
1576
1577fn leer(hal: &Hal, tex: &wgpu::Texture, w: u32, h: u32) -> Vec<u8> {
1578    let unpadded = (w * 4) as usize;
1579    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
1580    let padded = unpadded.div_ceil(align) * align;
1581    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
1582        label: Some("readback"),
1583        size: (padded * h as usize) as u64,
1584        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
1585        mapped_at_creation: false,
1586    });
1587    let mut enc = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
1588    enc.copy_texture_to_buffer(
1589        wgpu::TexelCopyTextureInfo { texture: tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
1590        wgpu::TexelCopyBufferInfo {
1591            buffer: &buf,
1592            layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(h) },
1593        },
1594        wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
1595    );
1596    hal.queue.submit(std::iter::once(enc.finish()));
1597    let slice = buf.slice(..);
1598    let (tx, rx) = std::sync::mpsc::channel();
1599    slice.map_async(wgpu::MapMode::Read, move |r| {
1600        let _ = tx.send(r);
1601    });
1602    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
1603    rx.recv().unwrap().unwrap();
1604    let data = slice.get_mapped_range();
1605    let mut px = Vec::with_capacity((w * h * 4) as usize);
1606    for r in 0..h as usize {
1607        px.extend_from_slice(&data[r * padded..r * padded + unpadded]);
1608    }
1609    drop(data);
1610    buf.unmap();
1611    px
1612}
1613
1614fn escribir_png(px: &[u8], path: &str, w: u32, h: u32) {
1615    let file = File::create(path).expect("png");
1616    let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
1617    enc.set_color(png::ColorType::Rgba);
1618    enc.set_depth(png::BitDepth::Eight);
1619    enc.write_header().unwrap().write_image_data(px).unwrap();
1620}