//! Real native GPU/GUI proof for static (unskinned) bodies (no input automation). //! Usage: xvfb-run -a cargo run --example static_body_fixture -- ROOT OUTDIR [WIDTH HEIGHT FPS] //! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned //! glTF body as female, screenshots the live viewport for both and the //! equipment screen (bone binding unavailable), runs the production batch for //! both bodies (one bind sample × 36 views × four maps), checks that an //! equipment render on the static body is refused, verifies the runs with //! `export_set`, and compares OBJ vs glTF coverage/depth view by view. use eframe::App; use paperdoll_renderer::{ editor::EditorApp, equipment, export_set, export_set::{MapImage, MapKind}, 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 { 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-static-obj-body.png") { self.editor.open_equipment_configuration(0, Sex::Male); self.phase = 1; } } 1 => { if self.screenshot(ctx, "static-equipment-unavailable.png") { // The button is disabled in the UI; calling the action // directly must still be refused, not rendered. self.editor.render_all_angles(); self.phase = 2; } } 2 => { if self.render_done("equipment-on-static") { self.editor.open_body_configuration(Sex::Male); self.editor.render_all_angles(); self.phase = 3; } } 3 => { let done = self.render_done("male-obj-body"); if done { self.phase = 4; } } 4 => { if self.screenshot(ctx, "production-static-obj-ui.png") { self.editor.open_body_configuration(Sex::Female); self.editor.show_live_3d(); self.phase = 5; } } 5 => { if self.screenshot(ctx, "live-static-gltf-body.png") { self.editor.render_all_angles(); self.phase = 6; } } 6 => { let done = self.render_done("female-gltf-body"); if done { self.phase = 7; 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 walk_pngs(dir: &Path) -> Result, String> { let mut out = Vec::new(); let mut stack = vec![dir.to_path_buf()]; 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); } else if path.extension().is_some_and(|x| x == "png") { out.push(path); } } } out.sort(); Ok(out) } struct RunCheck { report: serde_json::Value, run: export_set::ExportRun, } fn check_run(dir: &Path, width: u32, height: u32) -> Result { let run = export_set::ExportRun::open(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut counts = std::collections::BTreeMap::::new(); for path in walk_pngs(dir)? { let name = path.file_name().unwrap().to_string_lossy().to_string(); 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).or_default() += 1; let decoder = png::Decoder::new(std::io::BufReader::new( std::fs::File::open(&path).map_err(|e| e.to_string())?, )); let 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}")); } } let report = serde_json::json!({ "dir": dir.display().to_string(), "png_files": run.complete.png_files, "maps": counts, "body_model": run.render.body_model.path.clone(), "set": run.render.set.clone(), "sex": run.render.sex.clone(), "clips": run.render.clips.iter().map(|c| c.token.clone()).collect::>(), "images": run.render.images, "pivot_source": format!("{:?}", run.render.pivot_source), }); Ok(RunCheck { report, run }) } 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 fixtures = repo.join("tests/fixtures/bodies/static"); let mut s = Session::new(); s.install_new_project(&root)?; let mut obj = String::new(); for name in [ "static-body-male.obj", "static-body-male.mtl", "static-body-albedo.png", ] { let r = adopt_file(&root, &fixtures.join(name), "assets/body")?; if name.ends_with(".obj") { obj = r; } } s.set_body_path(Sex::Male, obj); let glb = adopt_file(&root, &fixtures.join("static-body-male.glb"), "assets/body")?; s.set_body_path(Sex::Female, glb); s.set_render(width, height, fps); let mut preset = equipment::Preset::new("sword".into()); preset.provenance = "CC0-1.0 fixture".into(); equipment::import_model( &mut preset, &repo.join("tests/fixtures/equipment/sword.obj"), )?; let i = s.add_equipment(); s.equipment[i].slug = "sword".into(); s.equipment[i].preset = Some(preset); s.save()?; let (root2, out2) = (root.clone(), out.clone()); eframe::run_native( "Paperdoll Static Body 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 mut dirs: Vec = std::fs::read_dir(root.join("renders"))? .filter_map(|e| e.ok().map(|e| e.path())) .filter(|p| p.is_dir()) .collect(); dirs.sort(); let mut checks = Vec::new(); for dir in &dirs { checks.push(check_run(dir, width, height)?); } let male = checks .iter() .find(|c| c.run.render.sex == "male") .ok_or("male run ontbreekt")?; let female = checks .iter() .find(|c| c.run.render.sex == "female") .ok_or("female run ontbreekt")?; // View-by-view: OBJ and unskinned glTF are the same geometry, so coverage // and depth must match; diffuse must be the unlit albedo of each source. let mut coverage_mismatch = 0u64; let mut depth_max_delta = 0u32; let mut covered = 0u64; let mut obj_red = 0u64; let mut obj_blue = 0u64; let mut obj_other = 0u64; let mut gltf_colors = std::collections::BTreeSet::new(); let mut normal_bad = 0u64; for rel in male.run.complete.png_sha256.keys() { if !rel.ends_with("_depth.png") { continue; } let female_rel = |r: &str| r.replacen("male/", "female/", 1); let dm = male .run .load_relative(rel, MapKind::Depth) .map_err(|e| e.to_string())?; let df = female .run .load_relative(&female_rel(rel), MapKind::Depth) .map_err(|e| e.to_string())?; let (MapImage::Depth16 { samples: a, .. }, MapImage::Depth16 { samples: b, .. }) = (&dm, &df) else { return Err("depth-map verwacht".into()); }; for (x, y) in a.chunks_exact(2).zip(b.chunks_exact(2)) { if (x[1] > 0) != (y[1] > 0) { coverage_mismatch += 1; } else if x[1] > 0 { covered += 1; depth_max_delta = depth_max_delta.max((x[0] as i32 - y[0] as i32).unsigned_abs()); } } let diffuse_rel = rel.replace("_depth.png", "_diffuse.png"); if let MapImage::Rgba8 { rgba, .. } = male .run .load_relative(&diffuse_rel, MapKind::Diffuse) .map_err(|e| e.to_string())? { for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) { let near = |c: [u8; 3]| (0..3).all(|k| (px[k] as i32 - c[k] as i32).abs() <= 2); if near([220, 40, 30]) { obj_red += 1; } else if near([30, 60, 220]) { obj_blue += 1; } else { obj_other += 1; } } } if let MapImage::Rgba8 { rgba, .. } = female .run .load_relative(&female_rel(&diffuse_rel), MapKind::Diffuse) .map_err(|e| e.to_string())? { for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) { gltf_colors.insert([px[0], px[1], px[2]]); } } let normal_rel = rel.replace("_depth.png", "_normal.png"); if let MapImage::Rgba8 { rgba, .. } = male .run .load_relative(&normal_rel, MapKind::Normal) .map_err(|e| e.to_string())? { for px in rgba.chunks_exact(4).filter(|p| p[3] > 0) { let n = [0, 1, 2].map(|k| px[k] as f32 / 127.5 - 1.); let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); if (len - 1.).abs() > 0.05 { normal_bad += 1; } } } } let equipment_runs = checks.iter().filter(|c| c.run.render.set != "body").count(); let report = serde_json::json!({ "gui": gui, "runs": checks.iter().map(|c| c.report.clone()).collect::>(), "equipment_runs_on_static_body": equipment_runs, "obj_vs_gltf": { "covered_pixels": covered, "coverage_mismatch_pixels": coverage_mismatch, "depth_max_delta_u16": depth_max_delta, }, "obj_diffuse": {"red_top": obj_red, "blue_bottom": obj_blue, "other": obj_other}, "gltf_diffuse_opaque_colors": gltf_colors.iter().map(|c| format!("{c:?}")).collect::>(), "obj_normal_non_unit_pixels": normal_bad, }); std::fs::write( out.join("static-body-proof.json"), serde_json::to_vec_pretty(&report)?, )?; println!("REPORT {report}"); let gui_ok = gui.as_array().is_some_and(|g| { g.len() == 3 && g[0]["success"] == false && g[0]["error"] .as_str() .is_some_and(|e| e.contains("statisch")) && g[1]["success"] == true && g[2]["success"] == true }); let runs_ok = checks.len() == 2 && equipment_runs == 0 && checks.iter().all(|c| { c.run.complete.png_files == 144 && c.run.render.clips.len() == 1 && c.run.render.clips[0].token == "clip000-bind" }); let maps_ok = covered > 0 && coverage_mismatch * 200 <= covered && obj_red > 0 && obj_blue > 0 && obj_other * 20 <= obj_red + obj_blue && gltf_colors.len() <= 2 && normal_bad == 0; if !(gui_ok && runs_ok && maps_ok) { return Err(format!( "static-body-proof mislukt (gui {gui_ok}, runs {runs_ok}, maps {maps_ok}); zie static-body-proof.json" ) .into()); } Ok(()) }