tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-voxel-studio/src/shot.rs Rust · 335 líneas · 14.2 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! Pantallazo headless del studio: monta la **view real** de la app (la misma
2//! `Studio::view`) con el `Model` de arranque, pinta el chrome (vello) con
3//! `render_to_view` y luego compone el **preview 3D** en su canvas con
4//! `paint_gpu` sobre el mismo target — un cuadro completo, sin abrir ventana.
5//!
6//! `cargo run -p llimphi-voxel-studio --release -- --shot [out.png]`
7
8use std::fs::File;
9use std::io::BufWriter;
10
11use llimphi_ui::llimphi_hal::{wgpu, Hal};
12use llimphi_ui::llimphi_layout::taffy;
13use llimphi_ui::llimphi_layout::LayoutTree;
14use llimphi_ui::llimphi_raster::peniko::Color;
15use llimphi_ui::llimphi_raster::{vello, Renderer};
16use llimphi_ui::llimphi_text::Typesetter;
17use llimphi_ui::{measure_text_node, mount, paint, paint_gpu, App};
18
19use crate::{demo_model, Level, Model, Studio};
20
21const W: u32 = 1180;
22const H: u32 = 760;
23const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
24
25/// Renderiza la pantalla completa del studio a PNG: una toma del modo Mundos y
26/// otra del modo Escenas (con los actores posados a mitad del guion).
27pub fn shot() {
28    let out = std::env::args()
29        .skip_while(|a| a != "--shot")
30        .nth(1)
31        .unwrap_or_else(|| "/tmp/voxel_studio.png".to_string());
32    if let Some(dir) = std::path::Path::new(&out).parent() {
33        std::fs::create_dir_all(dir).ok();
34    }
35
36    // Toma 1: editor de mundos.
37    render_model(demo_model(), &out);
38
39    // Toma 2: dirección de escena (pestaña Reparto), con los marcadores del recorrido.
40    let mut scene = demo_model();
41    scene.level = Level::Escenas;
42    scene.tool_tab = 1; // Reparto → dibuja los waypoints
43    scene.time = 1.6;
44    let scene_out = out.replace(".png", "_scene.png");
45    let scene_out = if scene_out == out { "/tmp/voxel_studio_scene.png".to_string() } else { scene_out };
46    render_model(scene, &scene_out);
47
48    // Toma 3: editor de seres (turntable del muñeco).
49    let mut chars = demo_model();
50    chars.level = Level::Seres;
51    chars.time = 0.6;
52    let chars_out = out.replace(".png", "_chars.png");
53    let chars_out = if chars_out == out { "/tmp/voxel_studio_chars.png".to_string() } else { chars_out };
54    render_model(chars, &chars_out);
55
56    // Toma 4: un mundo VIVO — simulando (agua + plantas + bandada de seres poblándolo).
57    let mut vivo = demo_model();
58    vivo.level = Level::Mundos;
59    vivo.simulating = true;
60    vivo.dist = 70.0; // más cerca, para ver a los habitantes
61    vivo.time = 0.4;
62    let vivo_out = out.replace(".png", "_vivo.png");
63    let vivo_out = if vivo_out == out { "/tmp/voxel_studio_vivo.png".to_string() } else { vivo_out };
64    render_model(vivo, &vivo_out);
65
66    // Toma 5: editor de MINERALES del bioma (todos sus factores editables).
67    let mut min = demo_model();
68    min.level = Level::Biomas;
69    min.tool_tab = 3; // diente «Minerales»
70    if let Some(b) = min.project.biomas.first() {
71        min.sel[3] = b.id; // selecciona un bioma real (trae minerales)
72    }
73    let min_out = out.replace(".png", "_minerales.png");
74    let min_out = if min_out == out { "/tmp/voxel_studio_minerales.png".to_string() } else { min_out };
75    render_model(min, &min_out);
76
77    // Toma 6: editor de INTERIORES — la sala amueblada de arranque.
78    let mut inter = demo_model();
79    inter.level = Level::Interiores;
80    if let Some(it) = inter.project.interiores.first() {
81        inter.sel[6] = it.id;
82    }
83    inter.pitch = 34_f32.to_radians();
84    inter.dist = 240.0;
85    let inter_out = out.replace(".png", "_interior.png");
86    let inter_out = if inter_out == out { "/tmp/voxel_studio_interior.png".to_string() } else { inter_out };
87    render_model(inter, &inter_out);
88
89    // Toma 7: MUNDO DESDE MAPA — una ciudad sintética (GeoJSON). Se adjunta la
90    // FUENTE al mundo seleccionado (como al persistir) y se genera el grid de ahí.
91    let mut mapa = demo_model();
92    mapa.level = crate::Level::Mundos;
93    let fuente =
94        llimphi_voxel::MapaFuente { geojson: ciudad_geojson(), pgm: None, dem_tif: None, rango_dem_m: 300.0 };
95    let dim = llimphi_voxel::world_dim(llimphi_voxel::PREVIEW_DIM_XZ);
96    if let Ok((grid, _)) = crate::mapa_import::generar(&fuente, dim) {
97        // Persistencia-forma: la fuente vive en el Mundo (se guardaría con el Project).
98        let mid = mapa.sel[crate::Level::Mundos.index()];
99        if let Some(m) = mapa.project.mundos.iter_mut().find(|m| m.id == mid) {
100            m.mapa = Some(fuente);
101        }
102        mapa.mapa = Some(std::sync::Arc::new(grid));
103        mapa.dist = 118.0;
104        mapa.pitch = 16_f32.to_radians(); // ángulo bajo → sensación de skyline
105        mapa.yaw = 42_f32.to_radians();
106        mapa.status = "mundo desde mapa (GeoJSON): ciudad importada".into();
107        let mapa_out = out.replace(".png", "_mapa.png");
108        let mapa_out = if mapa_out == out { "/tmp/voxel_studio_mapa.png".to_string() } else { mapa_out };
109        render_model(mapa, &mapa_out);
110    }
111
112    // Toma 8: editor de MATERIALES — el swatch 3D VIVO del material, con su detalle
113    // sub-voxel (rugosidad + bisel) subido, para ver el look sobre el blob.
114    let mut mat = demo_model();
115    mat.level = crate::Level::Materiales;
116    if let Some(m) = mat.project.materiales.get_mut(0) {
117        m.rugosidad = Some(0.8);
118        m.bisel = Some(0.9);
119    }
120    if let Some(id) = mat.project.materiales.first().map(|m| m.id) {
121        mat.sel[crate::Level::Materiales.index()] = id;
122    }
123    mat.pitch = 12_f32.to_radians();
124    let mat_out = out.replace(".png", "_materiales.png");
125    let mat_out = if mat_out == out { "/tmp/voxel_studio_materiales.png".to_string() } else { mat_out };
126    render_model(mat, &mat_out);
127
128    // Toma 9: SER DESDE FOTO — la foto REGULA los rasgos (piel/ropa) de un personaje
129    // ESTILIZADO del kit (NO se voxeliza literal: eso da el valle). Se muestra el actor
130    // con la piel y la ropa muestreadas de un retrato sintético.
131    let mut foto = demo_model();
132    foto.level = crate::Level::Seres;
133    let (rgba, w, h) = retrato_rgba(96, 128);
134    let alpha: Vec<u8> = rgba.chunks_exact(4).map(|p| p[3]).collect();
135    let r = crate::rostro::muestrear_rasgos(&rgba, &alpha, w, h, true);
136    let id = foto.project.alloc_id();
137    let mut cs = llimphi_voxel::CharSpec::new("retrato", llimphi_voxel::Age::Adult);
138    cs.id = id;
139    cs.skin = r.skin;
140    cs.shirt = r.shirt;
141    cs.pants = r.pants;
142    foto.project.seres.push(cs);
143    foto.set_selected(id);
144    foto.time = 0.6;
145    foto.status = "ser desde foto (rasgos): personaje estilizado".into();
146    let foto_out = out.replace(".png", "_rasgos.png");
147    let foto_out = if foto_out == out { "/tmp/voxel_studio_rasgos.png".to_string() } else { foto_out };
148    render_model(foto, &foto_out);
149}
150
151/// Retrato sintético RGBA (cabeza de piel cálida + torso con color de ropa, sobre fondo
152/// transparente) para la toma «ser desde foto». Es lo que u2net entregaría recortado; el
153/// muestreo de rasgos saca piel (arriba) y ropa (abajo). Devuelve `(rgba, w, h)`.
154fn retrato_rgba(w: u32, h: u32) -> (Vec<u8>, u32, u32) {
155    let (cx, cy) = (w as f32 / 2.0, h as f32 * 0.32);
156    let (rx, ry) = (w as f32 * 0.26, h as f32 * 0.26);
157    let mut px = vec![0u8; (w * h * 4) as usize];
158    for y in 0..h {
159        for x in 0..w {
160            let i = ((y * w + x) * 4) as usize;
161            let nx = (x as f32 - cx) / rx;
162            let ny = (y as f32 - cy) / ry;
163            let en_cabeza = nx * nx + ny * ny <= 1.0;
164            // Torso: trapecio inferior (hombros) desde ~55% de alto.
165            let en_torso = y as f32 > h as f32 * 0.55 && (x as f32 - cx).abs() < rx * 1.7;
166            let (r, g, b) = if en_cabeza {
167                (216u8, 172u8, 138u8) // piel cálida
168            } else if en_torso {
169                (44u8, 120u8, 150u8) // ropa teal
170            } else {
171                px[i + 3] = 0; // fondo
172                continue;
173            };
174            px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = 255;
175        }
176    }
177    (px, w, h)
178}
179
180/// GeoJSON sintético de una ciudad (grilla de edificios + calles + un lago) para la
181/// toma «mundo desde mapa» del studio.
182fn ciudad_geojson() -> String {
183    let mut f: Vec<String> = Vec::new();
184    let (lo, hi) = (0.0006f64, 0.0075f64); // ~ 750 m
185    let lerp = |t: f64| lo + (hi - lo) * t;
186    // Lago SO.
187    f.push(format!(
188        r#"{{"type":"Feature","properties":{{"natural":"water"}},"geometry":{{"type":"Polygon","coordinates":[[[{},{}],[{},{}],[{},{}],[{},{}],[{},{}]]]}}}}"#,
189        lerp(0.05), lerp(0.05), lerp(0.32), lerp(0.05), lerp(0.32), lerp(0.28), lerp(0.05), lerp(0.28), lerp(0.05), lerp(0.05)
190    ));
191    // Calles: 3 verticales + 3 horizontales.
192    for k in 1..=3 {
193        let t = k as f64 / 4.0;
194        f.push(format!(
195            r#"{{"type":"Feature","properties":{{"highway":"residential"}},"geometry":{{"type":"LineString","coordinates":[[{},{}],[{},{}]]}}}}"#,
196            lerp(t), lo, lerp(t), hi
197        ));
198        f.push(format!(
199            r#"{{"type":"Feature","properties":{{"highway":"residential"}},"geometry":{{"type":"LineString","coordinates":[[{},{}],[{},{}]]}}}}"#,
200            lo, lerp(t), hi, lerp(t)
201        ));
202    }
203    // Edificios: grilla de cuadras, altura creciente.
204    for gx in 0..3 {
205        for gz in 0..3 {
206            let cx = lerp(0.125 + gx as f64 * 0.25);
207            let cz = lerp(0.125 + gz as f64 * 0.25);
208            if cx < lerp(0.32) && cz < lerp(0.28) {
209                continue; // dentro del lago
210            }
211            let r = (hi - lo) * 0.06;
212            let n = 2 + (gx + gz) * 3;
213            f.push(format!(
214                r#"{{"type":"Feature","properties":{{"building":"yes","building:levels":{n}}},"geometry":{{"type":"Polygon","coordinates":[[[{},{}],[{},{}],[{},{}],[{},{}],[{},{}]]]}}}}"#,
215                cx - r, cz - r, cx + r, cz - r, cx + r, cz + r, cx - r, cz + r, cx - r, cz - r
216            ));
217        }
218    }
219    format!(r#"{{"type":"FeatureCollection","features":[{}]}}"#, f.join(","))
220}
221
222/// Renderiza un `Model` concreto (su view real) a un PNG.
223fn render_model(model: Model, out: &str) {
224    let theme = model.theme.clone();
225    let root = Studio::view(&model);
226
227    // view → layout → scene (misma secuencia que el eventloop real).
228    let mut layout = LayoutTree::new();
229    let mounted = mount(&mut layout, root);
230    let mut ts = Typesetter::new();
231    let computed = {
232        let tmap = &mounted.text_measures;
233        layout
234            .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
235                match tmap.get(&nid) {
236                    Some(tm) => measure_text_node(&mut ts, tm, known, avail),
237                    None => taffy::Size::ZERO,
238                }
239            })
240            .expect("layout")
241    };
242    let mut scene = vello::Scene::new();
243    paint(&mut scene, &mounted, &computed, &mut ts, None, None);
244
245    let hal = pollster::block_on(Hal::new(None)).expect("hal");
246    let mut renderer = Renderer::new(&hal).expect("renderer");
247    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
248        label: Some("pantallazo-studio"),
249        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
250        mip_level_count: 1,
251        sample_count: 1,
252        dimension: wgpu::TextureDimension::D2,
253        format: FMT,
254        usage: wgpu::TextureUsages::STORAGE_BINDING
255            | wgpu::TextureUsages::TEXTURE_BINDING
256            | wgpu::TextureUsages::RENDER_ATTACHMENT
257            | wgpu::TextureUsages::COPY_SRC,
258        view_formats: &[],
259    });
260    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
261
262    // 1) chrome (vello) → target.
263    let [r, g, b, _] = theme.bg_app.components;
264    let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
265    renderer
266        .render_to_view(&hal, &scene, &view, W, H, bg)
267        .expect("render_to_view");
268
269    // 2) preview 3D (gpu_paint_with) sobre el canvas, en el mismo target.
270    let mut enc = hal
271        .device
272        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
273    let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &view, (W, H));
274    hal.queue.submit(std::iter::once(enc.finish()));
275    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
276    assert!(any, "el gpu_painter del canvas no corrió");
277
278    write_png(&hal, &target, out);
279    eprintln!("pantallazo_studio: escrito {out} ({W}x{H})");
280}
281
282/// Lee la textura a CPU y la vuelca como PNG RGBA8.
283fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
284    let unpadded = (W * 4) as usize;
285    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
286    let padded = unpadded.div_ceil(align) * align;
287    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
288        label: Some("readback"),
289        size: (padded * H as usize) as u64,
290        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
291        mapped_at_creation: false,
292    });
293    let mut enc = hal
294        .device
295        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
296    enc.copy_texture_to_buffer(
297        wgpu::TexelCopyTextureInfo {
298            texture: target,
299            mip_level: 0,
300            origin: wgpu::Origin3d::ZERO,
301            aspect: wgpu::TextureAspect::All,
302        },
303        wgpu::TexelCopyBufferInfo {
304            buffer: &buf,
305            layout: wgpu::TexelCopyBufferLayout {
306                offset: 0,
307                bytes_per_row: Some(padded as u32),
308                rows_per_image: Some(H),
309            },
310        },
311        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
312    );
313    hal.queue.submit(std::iter::once(enc.finish()));
314    let slice = buf.slice(..);
315    let (tx, rx) = std::sync::mpsc::channel();
316    slice.map_async(wgpu::MapMode::Read, move |r| {
317        let _ = tx.send(r);
318    });
319    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
320    rx.recv().unwrap().unwrap();
321    let data = slice.get_mapped_range();
322    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
323    for row in 0..H as usize {
324        let s = row * padded;
325        pixels.extend_from_slice(&data[s..s + unpadded]);
326    }
327    drop(data);
328    buf.unmap();
329    let file = File::create(path).expect("png");
330    let mut e = png::Encoder::new(BufWriter::new(file), W, H);
331    e.set_color(png::ColorType::Rgba);
332    e.set_depth(png::BitDepth::Eight);
333    let mut w = e.write_header().unwrap();
334    w.write_image_data(&pixels).unwrap();
335}