//! Real native GPU proof for FBX bodies and rigid FBX equipment (no input automation). //! Usage: xvfb-run -a cargo run --example fbx_live_fixture -- ROOT OUTDIR [WIDTH HEIGHT FPS] //! Builds a project with the FBX male body and an FBX sword bound to its RightHand, //! screenshots the live viewport (body, then equipment), runs the production batch //! for the body set and the equipment set, and verifies both runs with `export_set`. use eframe::App; use paperdoll_renderer::{ editor::EditorApp, equipment, export_set, preview, session::{adopt_file, Session, Sex}, }; use std::path::{Path, PathBuf}; const SETTLE_FRAMES: u64 = 40; struct Fixture { editor: EditorApp, out: PathBuf, phase: u8, waited: u64, shot_requested: bool, results: Vec, last: u32, } fn save_png(path: &Path, w: usize, h: usize, rgba: &[u8]) { let file = std::fs::File::create(path).unwrap(); let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); enc.write_header().unwrap().write_image_data(rgba).unwrap(); } impl Fixture { /// Returns true once the screenshot was written. fn screenshot(&mut self, ctx: &eframe::egui::Context, name: &str) -> bool { self.waited += 1; if self.waited < SETTLE_FRAMES { return false; } if !self.shot_requested { self.shot_requested = true; ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot); return false; } let shot = ctx.input(|i| { i.raw.events.iter().find_map(|e| match e { eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()), _ => None, }) }); let Some(image) = shot else { return false }; let [w, h] = image.size; let rgba: Vec = image.pixels.iter().flat_map(|p| p.to_array()).collect(); let path = self.out.join(name); save_png(&path, w, h, &rgba); println!("SCREENSHOT {}", path.display()); self.waited = 0; self.shot_requested = false; true } fn render_done(&mut self, label: &str) -> bool { let (completed, total, pass) = self.editor.render_progress(); if completed != self.last { if completed % 50 == 0 || completed == total { println!("PASS {label} {completed}/{total}: {pass}"); } self.last = completed; } if self.editor.render_is_running() { return false; } let error = self.editor.render_error().map(str::to_string); let success = error.is_none() && total > 0 && completed == total; self.results.push(serde_json::json!({ "set": label, "success": success, "completed": completed, "total": total, "last_pass": pass, "error": error, })); self.last = 0; true } } impl App for Fixture { fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) { self.editor.update(ctx, frame); ctx.request_repaint(); match self.phase { 0 => { if self.screenshot(ctx, "live-fbx-body.png") { self.editor.open_equipment_configuration(0, Sex::Male); self.phase = 1; } } 1 => { if self.screenshot(ctx, "live-fbx-equipment.png") { self.editor.open_body_configuration(Sex::Male); self.editor.render_all_angles(); self.phase = 2; } } 2 => { if self.render_done("body") { self.editor.open_equipment_configuration(0, Sex::Male); self.editor.render_all_angles(); self.phase = 3; } } 3 => { if self.render_done("fbxsword") { self.phase = 4; } } 4 => { let written = self.screenshot(ctx, "production-fbx-equipment-ui.png"); if written { self.phase = 5; ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close); } } _ => {} } } fn on_exit(&mut self, gl: Option<&glow::Context>) { let value = serde_json::Value::Array(self.results.clone()); std::fs::write( self.out.join("gui-result.json"), serde_json::to_vec_pretty(&value).unwrap(), ) .unwrap(); self.editor.on_exit(gl); } } fn verify(root: &Path, width: u32, height: u32) -> Result { let mut runs = Vec::new(); let mut dirs = std::fs::read_dir(root.join("renders")) .map_err(|e| e.to_string())? .map(|e| e.map(|e| e.path()).map_err(|e| e.to_string())) .collect::, _>>()?; dirs.sort(); for dir in dirs.into_iter().filter(|d| d.is_dir()) { let run = export_set::ExportRun::open(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut counts = std::collections::BTreeMap::::new(); let mut opaque = 0u64; let mut textured = 0u64; let mut stack = vec![dir.clone()]; while let Some(d) = stack.pop() { for entry in std::fs::read_dir(&d).map_err(|e| e.to_string())? { let path = entry.map_err(|e| e.to_string())?.path(); if path.is_dir() { stack.push(path); continue; } let name = path.file_name().unwrap().to_string_lossy().to_string(); if !name.ends_with(".png") { continue; } let map = name .rsplit_once('_') .map(|(_, m)| m.trim_end_matches(".png").to_string()) .unwrap_or_default(); if !["diffuse", "normal", "depth", "specular"].contains(&map.as_str()) { return Err(format!("onverwachte map {name}")); } *counts.entry(map.clone()).or_default() += 1; let decoder = png::Decoder::new(std::io::BufReader::new( std::fs::File::open(&path).map_err(|e| e.to_string())?, )); let mut reader = decoder.read_info().map_err(|e| e.to_string())?; let (w, h) = (reader.info().width, reader.info().height); if (w, h) != (width, height) { return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}")); } if map == "diffuse" { let mut buf = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?; if info.color_type == png::ColorType::Rgba { for px in buf[..info.buffer_size()].chunks_exact(4) { if px[3] > 0 { opaque += 1; // Brass guard albedo (0.75,0.55,0.2) or checker blade: colourful. if px[0] as i32 - px[2] as i32 > 60 { textured += 1; } } } } } } } runs.push(serde_json::json!({ "dir": dir.display().to_string(), "png_files": run.complete.png_files, "maps": counts, "diffuse_opaque_pixels": opaque, "diffuse_warm_pixels": textured, "body_model": run.render.body_model.path.clone(), "set": run.render.set.clone(), "clips": run.render.clips.len(), "images": run.render.images, })); } Ok(serde_json::Value::Array(runs)) } fn main() -> Result<(), Box> { let a = std::env::args().skip(1).collect::>(); if a.len() < 2 { return Err("ROOT OUTDIR [WIDTH HEIGHT FPS]".into()); } let root = PathBuf::from(&a[0]); let out = PathBuf::from(&a[1]); std::fs::create_dir_all(&out)?; let width: u32 = a.get(2).map(|v| v.parse()).transpose()?.unwrap_or(32); let height: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(48); let fps: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(4); let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let mut s = Session::new(); s.install_new_project(&root)?; for (sex, file) in [ (Sex::Male, "tests/fixtures/bodies/test-body-male.fbx"), (Sex::Female, "tests/fixtures/bodies/test-body-female.glb"), ] { let path = adopt_file(&root, &repo.join(file), "assets/body")?; s.set_body_path(sex, path); } s.set_render(width, height, fps); let body = preview::load_body_file(&root.join(s.body_path(Sex::Male).unwrap()))?; let mut preset = equipment::Preset::new("fbxsword".into()); preset.provenance = "CC0-1.0 generated fixture (Blender FBX, embedded PNG)".into(); equipment::import_model( &mut preset, &repo.join("tests/fixtures/equipment/sword-fbx.fbx"), )?; let joint = body .joint_names .iter() .position(|n| n == "RightHand") .ok_or("RightHand ontbreekt")?; preset.bind(0, &body, 0, joint)?; preset.applies = [true, false]; preset.attachments[0].rotation_deg = [0., 0., -15.]; let i = s.add_equipment(); s.equipment[i].slug = "fbxsword".into(); s.equipment[i].preset = Some(preset); s.save()?; println!( "PLAN male FBX: {:?}", paperdoll_renderer::production::plan(&body, fps)? ); let (root2, out2) = (root.clone(), out.clone()); eframe::run_native( "Paperdoll FBX Live Fixture", eframe::NativeOptions { viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]), ..paperdoll_renderer::preview_gl::native_options() }, Box::new(move |_| { let mut editor = EditorApp::default(); editor.open_path(root2.join("character.ppd")); editor.open_body_configuration(Sex::Male); Ok(Box::new(Fixture { editor, out: out2, phase: 0, waited: 0, shot_requested: false, results: Vec::new(), last: 0, })) }), )?; let gui: serde_json::Value = serde_json::from_slice(&std::fs::read(out.join("gui-result.json"))?)?; let runs = verify(&root, width, height)?; let report = serde_json::json!({"gui": gui, "runs": runs}); std::fs::write( out.join("fbx-live-proof.json"), serde_json::to_vec_pretty(&report)?, )?; println!("REPORT {report}"); let ok = gui .as_array() .is_some_and(|g| g.len() == 2 && g.iter().all(|r| r["success"] == true)) && runs.as_array().is_some_and(|r| r.len() == 2); if !ok { return Err("FBX-proof mislukt; zie fbx-live-proof.json".into()); } Ok(()) }