tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}