//! Native GUI smoke for the rendered 2D preview tab (schema-3 export fixtures). //! Usage: preview2d_smoke OUTPUT_DIR use eframe::App; use paperdoll_renderer::digest; use paperdoll_renderer::editor::EditorApp; use paperdoll_renderer::export_set::complete_identity_json; use paperdoll_renderer::preview2d::discover_completed_runs; use paperdoll_renderer::production::{write_depth, write_rgba, FAR, NEAR, PASSES}; use paperdoll_renderer::project::CameraFraming; use paperdoll_renderer::render_job::ImageBuf; use paperdoll_renderer::session::Session; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; struct Smoke { editor: EditorApp, out: PathBuf, started: Instant, frames: u64, done: bool, } impl App for Smoke { fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) { self.frames += 1; self.editor.update(ctx, frame); if self.frames == 2 { self.editor.open_preview2d(); } let ready = self.editor.preview2d_image().is_some() || self.editor.preview2d_state().diagnostic.is_some(); let timeout = self.started.elapsed() > Duration::from_secs(8); if self.done { return; } if (self.frames >= 12 && ready) || timeout { self.done = true; let state = self.editor.preview2d_state(); let image_ok = if let Some(img) = self.editor.preview2d_image() { write_png(&self.out.join("preview2d-composed.png"), img).expect("png"); true } else { false }; let value = serde_json::json!({ "success": image_ok, "gui_updates": self.frames, "sex": state.sex_token(), "clip": state.clip_token, "frame": state.frame_index(), "yaw": state.yaw_deg(), "pitch": state.pitch_deg(), "playing": state.playing, "diagnostic": state.diagnostic, "elapsed_secs": state.elapsed_secs, }); fs::write( self.out.join("gui-smoke.json"), serde_json::to_vec_pretty(&value).unwrap(), ) .unwrap(); println!("RESULT {value}"); ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close); } } fn on_exit(&mut self, gl: Option<&glow::Context>) { self.editor.on_exit(gl); } } fn write_png(path: &Path, img: &ImageBuf) -> Result<(), String> { let file = fs::File::create(path).map_err(|e| e.to_string())?; let mut encoder = png::Encoder::new(file, img.width, img.height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer .write_image_data(&img.rgba) .map_err(|e| e.to_string()) } fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> { let data = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; let path = root.join(name); let temp = path.with_extension(format!("part-{}", std::process::id())); fs::write(&temp, &data).map_err(|e| e.to_string())?; fs::hard_link(&temp, &path).map_err(|e| e.to_string())?; let _ = fs::remove_file(temp); Ok(()) } #[allow(clippy::too_many_arguments)] fn publish_pose( root: &Path, sex: &str, set: &str, clip: &str, frame: u32, pitch: i32, yaw: u32, diffuse: [u8; 4], depth_gray: u16, ) -> Result, String> { let mut map = BTreeMap::new(); let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}"); let normal = [128u8, 128, 255, 255]; let specular = [0u8, 0, 0, 255]; for (rel, kind) in [ (format!("{base}_diffuse.png"), "diffuse"), (format!("{base}_depth.png"), "depth"), (format!("{base}_normal.png"), "normal"), (format!("{base}_specular.png"), "specular"), ] { let path = Path::new(&rel); match kind { "depth" => { write_depth( root, path, 2, 2, &[ depth_gray, 65535, depth_gray, 65535, depth_gray, 65535, depth_gray, 65535, ], )?; } "normal" => { write_rgba( root, path, &ImageBuf { width: 2, height: 2, rgba: normal.repeat(4), }, )?; } "specular" => { write_rgba( root, path, &ImageBuf { width: 2, height: 2, rgba: specular.repeat(4), }, )?; } _ => { write_rgba( root, path, &ImageBuf { width: 2, height: 2, rgba: diffuse.repeat(4), }, )?; } } let bytes = fs::read(root.join(&rel)).map_err(|e| e.to_string())?; map.insert(rel, digest::sha256_hex(&bytes)); } Ok(map) } fn install_body_export(project: &Path) -> Result<(), String> { let run = project.join("renders/run-preview2d-body"); fs::create_dir_all(&run).map_err(|e| e.to_string())?; let body_bytes = b"preview2d-smoke-body"; let body_hash = digest::sha256_hex(body_bytes); let clips = vec![serde_json::json!({ "name": "walk", "token": "clip000-walk", "frames": 2, "duration": 0.1667 })]; let mut checksums = BTreeMap::new(); for frame in [0u32, 1] { for pitch in [-30i32, 0, 30] { for yaw in [0u32, 30, 90] { let tint = [40u8.wrapping_add(frame as u8 * 80), 90, 140, 255]; checksums.extend(publish_pose( &run, "male", "body", "clip000-walk", frame, pitch, yaw, tint, 25000 - frame as u16 * 1000, )?); } } } let png_files = checksums.len() as u32; let render = serde_json::json!({ "format_version": 3, "status": "incomplete", "project": {"id": "smoke-preview2d", "name": "Preview2d Smoke"}, "sex": "male", "set": "body", "body_model": {"path": "assets/body/test-body-male.glb", "sha256": body_hash}, "equipment_preset": null, "target": [2, 2], "fps": 12, "highres_scale": 8, "framing": CameraFraming { height_milli: 1800, center_y_milli: 900, pivot_y_milli: Some(1500), }, "pivot_source": {"kind": "manual"}, "near": NEAR, "far": FAR, "camera_distance": 10.0, "yaw_zero": "camera on -Z", "normal_space": "view (+Y up), PNG rows top-down", "pitches": [-30, 0, 30], "yaw_step": 30, "sampling": "smoke", "alpha": "straight", "diffuse": "unlit", "depth": "16-bit", "specular": "scalar", "pass_order": PASSES, "saved_maps": ["diffuse", "depth", "normal", "specular"], "clips": clips, "images": 2 * 36, "passes": 2 * 36 * 6, "png_files": png_files }); write_atomic_json(&run, "render.json", &render)?; let identity = complete_identity_json(&render)?; write_atomic_json( &run, "complete.json", &serde_json::json!({ "format_version": 3, "status": "complete", "identity": identity, "passes": 2 * 36 * 6, "png_files": png_files, "png_sha256": checksums }), )?; Ok(()) } fn main() -> Result<(), Box> { let out = PathBuf::from( std::env::args() .nth(1) .ok_or("usage: preview2d_smoke OUTPUT_DIR")?, ); fs::create_dir_all(&out)?; let project = out.join("project"); let mut session = Session::new(); session.install_new_project(&project)?; session.save()?; install_body_export(&project)?; let catalog = discover_completed_runs(&project); fs::write( out.join("catalog.json"), serde_json::to_vec_pretty(&serde_json::json!({ "count": catalog.len(), "sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::>(), }))?, )?; let mut editor = EditorApp::new(Box::new(paperdoll_renderer::dialogs::SystemDialogs::new())); editor.open_path(project.join("character.ppd")); eframe::run_native( "Paperdoll Preview2d Smoke", eframe::NativeOptions { viewport: eframe::egui::ViewportBuilder::default() .with_inner_size([1100.0, 720.0]) .with_visible(true), ..Default::default() }, Box::new(move |_cc| { Ok(Box::new(Smoke { editor, out: out.clone(), started: Instant::now(), frames: 0, done: false, }) as Box) }), ) .map_err(|e| e.to_string())?; Ok(()) }