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