//! Pantallazo headless del studio: monta la **view real** de la app (la misma //! `Studio::view`) con el `Model` de arranque, pinta el chrome (vello) con //! `render_to_view` y luego compone el **preview 3D** en su canvas con //! `paint_gpu` sobre el mismo target — un cuadro completo, sin abrir ventana. //! //! `cargo run -p llimphi-voxel-studio --release -- --shot [out.png]` use std::fs::File; use std::io::BufWriter; use llimphi_ui::llimphi_hal::{wgpu, Hal}; use llimphi_ui::llimphi_layout::taffy; use llimphi_ui::llimphi_layout::LayoutTree; use llimphi_ui::llimphi_raster::peniko::Color; use llimphi_ui::llimphi_raster::{vello, Renderer}; use llimphi_ui::llimphi_text::Typesetter; use llimphi_ui::{measure_text_node, mount, paint, paint_gpu, App}; use crate::{demo_model, Level, Model, Studio}; const W: u32 = 1180; const H: u32 = 760; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Renderiza la pantalla completa del studio a PNG: una toma del modo Mundos y /// otra del modo Escenas (con los actores posados a mitad del guion). pub fn shot() { let out = std::env::args() .skip_while(|a| a != "--shot") .nth(1) .unwrap_or_else(|| "/tmp/voxel_studio.png".to_string()); if let Some(dir) = std::path::Path::new(&out).parent() { std::fs::create_dir_all(dir).ok(); } // Toma 1: editor de mundos. render_model(demo_model(), &out); // Toma 2: dirección de escena (pestaña Reparto), con los marcadores del recorrido. let mut scene = demo_model(); scene.level = Level::Escenas; scene.tool_tab = 1; // Reparto → dibuja los waypoints scene.time = 1.6; let scene_out = out.replace(".png", "_scene.png"); let scene_out = if scene_out == out { "/tmp/voxel_studio_scene.png".to_string() } else { scene_out }; render_model(scene, &scene_out); // Toma 3: editor de seres (turntable del muñeco). let mut chars = demo_model(); chars.level = Level::Seres; chars.time = 0.6; let chars_out = out.replace(".png", "_chars.png"); let chars_out = if chars_out == out { "/tmp/voxel_studio_chars.png".to_string() } else { chars_out }; render_model(chars, &chars_out); // Toma 4: un mundo VIVO — simulando (agua + plantas + bandada de seres poblándolo). let mut vivo = demo_model(); vivo.level = Level::Mundos; vivo.simulating = true; vivo.dist = 70.0; // más cerca, para ver a los habitantes vivo.time = 0.4; let vivo_out = out.replace(".png", "_vivo.png"); let vivo_out = if vivo_out == out { "/tmp/voxel_studio_vivo.png".to_string() } else { vivo_out }; render_model(vivo, &vivo_out); // Toma 5: editor de MINERALES del bioma (todos sus factores editables). let mut min = demo_model(); min.level = Level::Biomas; min.tool_tab = 3; // diente «Minerales» if let Some(b) = min.project.biomas.first() { min.sel[3] = b.id; // selecciona un bioma real (trae minerales) } let min_out = out.replace(".png", "_minerales.png"); let min_out = if min_out == out { "/tmp/voxel_studio_minerales.png".to_string() } else { min_out }; render_model(min, &min_out); // Toma 6: editor de INTERIORES — la sala amueblada de arranque. let mut inter = demo_model(); inter.level = Level::Interiores; if let Some(it) = inter.project.interiores.first() { inter.sel[6] = it.id; } inter.pitch = 34_f32.to_radians(); inter.dist = 240.0; let inter_out = out.replace(".png", "_interior.png"); let inter_out = if inter_out == out { "/tmp/voxel_studio_interior.png".to_string() } else { inter_out }; render_model(inter, &inter_out); // Toma 7: MUNDO DESDE MAPA — una ciudad sintética (GeoJSON). Se adjunta la // FUENTE al mundo seleccionado (como al persistir) y se genera el grid de ahí. let mut mapa = demo_model(); mapa.level = crate::Level::Mundos; let fuente = llimphi_voxel::MapaFuente { geojson: ciudad_geojson(), pgm: None, dem_tif: None, rango_dem_m: 300.0 }; let dim = llimphi_voxel::world_dim(llimphi_voxel::PREVIEW_DIM_XZ); if let Ok((grid, _)) = crate::mapa_import::generar(&fuente, dim) { // Persistencia-forma: la fuente vive en el Mundo (se guardaría con el Project). let mid = mapa.sel[crate::Level::Mundos.index()]; if let Some(m) = mapa.project.mundos.iter_mut().find(|m| m.id == mid) { m.mapa = Some(fuente); } mapa.mapa = Some(std::sync::Arc::new(grid)); mapa.dist = 118.0; mapa.pitch = 16_f32.to_radians(); // ángulo bajo → sensación de skyline mapa.yaw = 42_f32.to_radians(); mapa.status = "mundo desde mapa (GeoJSON): ciudad importada".into(); let mapa_out = out.replace(".png", "_mapa.png"); let mapa_out = if mapa_out == out { "/tmp/voxel_studio_mapa.png".to_string() } else { mapa_out }; render_model(mapa, &mapa_out); } // Toma 8: editor de MATERIALES — el swatch 3D VIVO del material, con su detalle // sub-voxel (rugosidad + bisel) subido, para ver el look sobre el blob. let mut mat = demo_model(); mat.level = crate::Level::Materiales; if let Some(m) = mat.project.materiales.get_mut(0) { m.rugosidad = Some(0.8); m.bisel = Some(0.9); } if let Some(id) = mat.project.materiales.first().map(|m| m.id) { mat.sel[crate::Level::Materiales.index()] = id; } mat.pitch = 12_f32.to_radians(); let mat_out = out.replace(".png", "_materiales.png"); let mat_out = if mat_out == out { "/tmp/voxel_studio_materiales.png".to_string() } else { mat_out }; render_model(mat, &mat_out); // Toma 9: SER DESDE FOTO — la foto REGULA los rasgos (piel/ropa) de un personaje // ESTILIZADO del kit (NO se voxeliza literal: eso da el valle). Se muestra el actor // con la piel y la ropa muestreadas de un retrato sintético. let mut foto = demo_model(); foto.level = crate::Level::Seres; let (rgba, w, h) = retrato_rgba(96, 128); let alpha: Vec = rgba.chunks_exact(4).map(|p| p[3]).collect(); let r = crate::rostro::muestrear_rasgos(&rgba, &alpha, w, h, true); let id = foto.project.alloc_id(); let mut cs = llimphi_voxel::CharSpec::new("retrato", llimphi_voxel::Age::Adult); cs.id = id; cs.skin = r.skin; cs.shirt = r.shirt; cs.pants = r.pants; foto.project.seres.push(cs); foto.set_selected(id); foto.time = 0.6; foto.status = "ser desde foto (rasgos): personaje estilizado".into(); let foto_out = out.replace(".png", "_rasgos.png"); let foto_out = if foto_out == out { "/tmp/voxel_studio_rasgos.png".to_string() } else { foto_out }; render_model(foto, &foto_out); } /// Retrato sintético RGBA (cabeza de piel cálida + torso con color de ropa, sobre fondo /// transparente) para la toma «ser desde foto». Es lo que u2net entregaría recortado; el /// muestreo de rasgos saca piel (arriba) y ropa (abajo). Devuelve `(rgba, w, h)`. fn retrato_rgba(w: u32, h: u32) -> (Vec, u32, u32) { let (cx, cy) = (w as f32 / 2.0, h as f32 * 0.32); let (rx, ry) = (w as f32 * 0.26, h as f32 * 0.26); let mut px = vec![0u8; (w * h * 4) as usize]; for y in 0..h { for x in 0..w { let i = ((y * w + x) * 4) as usize; let nx = (x as f32 - cx) / rx; let ny = (y as f32 - cy) / ry; let en_cabeza = nx * nx + ny * ny <= 1.0; // Torso: trapecio inferior (hombros) desde ~55% de alto. let en_torso = y as f32 > h as f32 * 0.55 && (x as f32 - cx).abs() < rx * 1.7; let (r, g, b) = if en_cabeza { (216u8, 172u8, 138u8) // piel cálida } else if en_torso { (44u8, 120u8, 150u8) // ropa teal } else { px[i + 3] = 0; // fondo continue; }; px[i] = r; px[i + 1] = g; px[i + 2] = b; px[i + 3] = 255; } } (px, w, h) } /// GeoJSON sintético de una ciudad (grilla de edificios + calles + un lago) para la /// toma «mundo desde mapa» del studio. fn ciudad_geojson() -> String { let mut f: Vec = Vec::new(); let (lo, hi) = (0.0006f64, 0.0075f64); // ~ 750 m let lerp = |t: f64| lo + (hi - lo) * t; // Lago SO. f.push(format!( r#"{{"type":"Feature","properties":{{"natural":"water"}},"geometry":{{"type":"Polygon","coordinates":[[[{},{}],[{},{}],[{},{}],[{},{}],[{},{}]]]}}}}"#, 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) )); // Calles: 3 verticales + 3 horizontales. for k in 1..=3 { let t = k as f64 / 4.0; f.push(format!( r#"{{"type":"Feature","properties":{{"highway":"residential"}},"geometry":{{"type":"LineString","coordinates":[[{},{}],[{},{}]]}}}}"#, lerp(t), lo, lerp(t), hi )); f.push(format!( r#"{{"type":"Feature","properties":{{"highway":"residential"}},"geometry":{{"type":"LineString","coordinates":[[{},{}],[{},{}]]}}}}"#, lo, lerp(t), hi, lerp(t) )); } // Edificios: grilla de cuadras, altura creciente. for gx in 0..3 { for gz in 0..3 { let cx = lerp(0.125 + gx as f64 * 0.25); let cz = lerp(0.125 + gz as f64 * 0.25); if cx < lerp(0.32) && cz < lerp(0.28) { continue; // dentro del lago } let r = (hi - lo) * 0.06; let n = 2 + (gx + gz) * 3; f.push(format!( r#"{{"type":"Feature","properties":{{"building":"yes","building:levels":{n}}},"geometry":{{"type":"Polygon","coordinates":[[[{},{}],[{},{}],[{},{}],[{},{}],[{},{}]]]}}}}"#, cx - r, cz - r, cx + r, cz - r, cx + r, cz + r, cx - r, cz + r, cx - r, cz - r )); } } format!(r#"{{"type":"FeatureCollection","features":[{}]}}"#, f.join(",")) } /// Renderiza un `Model` concreto (su view real) a un PNG. fn render_model(model: Model, out: &str) { let theme = model.theme.clone(); let root = Studio::view(&model); // view → layout → scene (misma secuencia que el eventloop real). let mut layout = LayoutTree::new(); let mounted = mount(&mut layout, root); let mut ts = Typesetter::new(); let computed = { let tmap = &mounted.text_measures; layout .compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| { match tmap.get(&nid) { Some(tm) => measure_text_node(&mut ts, tm, known, avail), None => taffy::Size::ZERO, } }) .expect("layout") }; let mut scene = vello::Scene::new(); paint(&mut scene, &mounted, &computed, &mut ts, None, None); let hal = pollster::block_on(Hal::new(None)).expect("hal"); let mut renderer = Renderer::new(&hal).expect("renderer"); let target = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("pantallazo-studio"), size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = target.create_view(&wgpu::TextureViewDescriptor::default()); // 1) chrome (vello) → target. let [r, g, b, _] = theme.bg_app.components; let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255); renderer .render_to_view(&hal, &scene, &view, W, H, bg) .expect("render_to_view"); // 2) preview 3D (gpu_paint_with) sobre el canvas, en el mismo target. let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") }); let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &view, (W, H)); hal.queue.submit(std::iter::once(enc.finish())); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); assert!(any, "el gpu_painter del canvas no corrió"); write_png(&hal, &target, out); eprintln!("pantallazo_studio: escrito {out} ({W}x{H})"); } /// Lee la textura a CPU y la vuelca como PNG RGBA8. fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) { let unpadded = (W * 4) as usize; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; let padded = unpadded.div_ceil(align) * align; let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { label: Some("readback"), size: (padded * H as usize) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(H), }, }, wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 }, ); hal.queue.submit(std::iter::once(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().unwrap().unwrap(); let data = slice.get_mapped_range(); let mut pixels = Vec::with_capacity((W * H * 4) as usize); for row in 0..H as usize { let s = row * padded; pixels.extend_from_slice(&data[s..s + unpadded]); } drop(data); buf.unmap(); let file = File::create(path).expect("png"); let mut e = png::Encoder::new(BufWriter::new(file), W, H); e.set_color(png::ColorType::Rgba); e.set_depth(png::BitDepth::Eight); let mut w = e.write_header().unwrap(); w.write_image_data(&pixels).unwrap(); }