| 1 | //! Real native GPU/GUI proof for static (unskinned) bodies (no input automation). |
| 2 | //! Usage: xvfb-run -a cargo run --example static_body_fixture -- ROOT OUTDIR [WIDTH HEIGHT FPS] |
| 3 | //! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned |
| 4 | //! glTF body as female, screenshots the live viewport for both and the |
| 5 | //! equipment screen (bone binding unavailable), runs the production batch for |
| 6 | //! both bodies (one bind sample × 60 views × four maps), checks that an |
| 7 | //! equipment render on the static body is refused, verifies the runs with |
| 8 | //! `export_set`, and compares OBJ vs glTF coverage/depth view by view. |
| 9 | use eframe::App; |
| 10 | use paperdoll_renderer::{ |
| 11 | editor::EditorApp, |
| 12 | equipment, export_set, |
| 13 | export_set::{MapImage, MapKind}, |
| 14 | session::{adopt_file, Session, Sex}, |
| 15 | }; |
| 16 | use std::path::{Path, PathBuf}; |
| 17 | |
| 18 | const SETTLE_FRAMES: u64 = 40; |
| 19 | |
| 20 | struct Fixture { |
| 21 | editor: EditorApp, |
| 22 | out: PathBuf, |
| 23 | phase: u8, |
| 24 | waited: u64, |
| 25 | shot_requested: bool, |
| 26 | results: Vec<serde_json::Value>, |
| 27 | last: u32, |
| 28 | } |
| 29 | |
| 30 | fn save_png(path: &Path, w: usize, h: usize, rgba: &[u8]) { |
| 31 | let file = std::fs::File::create(path).unwrap(); |
| 32 | let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32); |
| 33 | enc.set_color(png::ColorType::Rgba); |
| 34 | enc.set_depth(png::BitDepth::Eight); |
| 35 | enc.write_header().unwrap().write_image_data(rgba).unwrap(); |
| 36 | } |
| 37 | |
| 38 | impl Fixture { |
| 39 | fn screenshot(&mut self, ctx: &eframe::egui::Context, name: &str) -> bool { |
| 40 | self.waited += 1; |
| 41 | if self.waited < SETTLE_FRAMES { |
| 42 | return false; |
| 43 | } |
| 44 | if !self.shot_requested { |
| 45 | self.shot_requested = true; |
| 46 | ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot); |
| 47 | return false; |
| 48 | } |
| 49 | let shot = ctx.input(|i| { |
| 50 | i.raw.events.iter().find_map(|e| match e { |
| 51 | eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()), |
| 52 | _ => None, |
| 53 | }) |
| 54 | }); |
| 55 | let Some(image) = shot else { return false }; |
| 56 | let [w, h] = image.size; |
| 57 | let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect(); |
| 58 | let path = self.out.join(name); |
| 59 | save_png(&path, w, h, &rgba); |
| 60 | println!("SCREENSHOT {}", path.display()); |
| 61 | self.waited = 0; |
| 62 | self.shot_requested = false; |
| 63 | true |
| 64 | } |
| 65 | fn render_done(&mut self, label: &str) -> bool { |
| 66 | let (completed, total, pass) = self.editor.render_progress(); |
| 67 | if completed != self.last { |
| 68 | if completed % 50 == 0 || completed == total { |
| 69 | println!("PASS {label} {completed}/{total}: {pass}"); |
| 70 | } |
| 71 | self.last = completed; |
| 72 | } |
| 73 | if self.editor.render_is_running() { |
| 74 | return false; |
| 75 | } |
| 76 | let error = self.editor.render_error().map(str::to_string); |
| 77 | let success = error.is_none() && total > 0 && completed == total; |
| 78 | self.results.push(serde_json::json!({ |
| 79 | "set": label, "success": success, "completed": completed, |
| 80 | "total": total, "last_pass": pass, "error": error, |
| 81 | })); |
| 82 | self.last = 0; |
| 83 | true |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | impl App for Fixture { |
| 88 | fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) { |
| 89 | self.editor.update(ctx, frame); |
| 90 | ctx.request_repaint(); |
| 91 | match self.phase { |
| 92 | 0 => { |
| 93 | if self.screenshot(ctx, "live-static-obj-body.png") { |
| 94 | self.editor.open_equipment_configuration(0, Sex::Male); |
| 95 | self.phase = 1; |
| 96 | } |
| 97 | } |
| 98 | 1 => { |
| 99 | if self.screenshot(ctx, "static-equipment-unavailable.png") { |
| 100 | // The button is disabled in the UI; calling the action |
| 101 | // directly must still be refused, not rendered. |
| 102 | self.editor.render_all_angles(); |
| 103 | self.phase = 2; |
| 104 | } |
| 105 | } |
| 106 | 2 => { |
| 107 | if self.render_done("equipment-on-static") { |
| 108 | self.editor.open_body_configuration(Sex::Male); |
| 109 | self.editor.render_all_angles(); |
| 110 | self.phase = 3; |
| 111 | } |
| 112 | } |
| 113 | 3 => { |
| 114 | let done = self.render_done("male-obj-body"); |
| 115 | if done { |
| 116 | self.phase = 4; |
| 117 | } |
| 118 | } |
| 119 | 4 => { |
| 120 | if self.screenshot(ctx, "production-static-obj-ui.png") { |
| 121 | self.editor.open_body_configuration(Sex::Female); |
| 122 | self.editor.show_live_3d(); |
| 123 | self.phase = 5; |
| 124 | } |
| 125 | } |
| 126 | 5 => { |
| 127 | if self.screenshot(ctx, "live-static-gltf-body.png") { |
| 128 | self.editor.render_all_angles(); |
| 129 | self.phase = 6; |
| 130 | } |
| 131 | } |
| 132 | 6 => { |
| 133 | let done = self.render_done("female-gltf-body"); |
| 134 | if done { |
| 135 | self.phase = 7; |
| 136 | ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close); |
| 137 | } |
| 138 | } |
| 139 | _ => {} |
| 140 | } |
| 141 | } |
| 142 | fn on_exit(&mut self, gl: Option<&glow::Context>) { |
| 143 | let value = serde_json::Value::Array(self.results.clone()); |
| 144 | std::fs::write( |
| 145 | self.out.join("gui-result.json"), |
| 146 | serde_json::to_vec_pretty(&value).unwrap(), |
| 147 | ) |
| 148 | .unwrap(); |
| 149 | self.editor.on_exit(gl); |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | fn walk_pngs(dir: &Path) -> Result<Vec<PathBuf>, String> { |
| 154 | let mut out = Vec::new(); |
| 155 | let mut stack = vec![dir.to_path_buf()]; |
| 156 | while let Some(d) = stack.pop() { |
| 157 | for entry in std::fs::read_dir(&d).map_err(|e| e.to_string())? { |
| 158 | let path = entry.map_err(|e| e.to_string())?.path(); |
| 159 | if path.is_dir() { |
| 160 | stack.push(path); |
| 161 | } else if path.extension().is_some_and(|x| x == "png") |
| 162 | && !path.to_string_lossy().ends_with("-atlas.png") |
| 163 | { |
| 164 | // Loose target maps only; per-map atlases are checked separately. |
| 165 | out.push(path); |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | out.sort(); |
| 170 | Ok(out) |
| 171 | } |
| 172 | |
| 173 | struct RunCheck { |
| 174 | report: serde_json::Value, |
| 175 | run: export_set::ExportRun, |
| 176 | } |
| 177 | |
| 178 | fn check_run(dir: &Path, width: u32, height: u32) -> Result<RunCheck, String> { |
| 179 | let run = export_set::ExportRun::open(dir).map_err(|e| format!("{}: {e}", dir.display()))?; |
| 180 | let mut counts = std::collections::BTreeMap::<String, u32>::new(); |
| 181 | for path in walk_pngs(dir)? { |
| 182 | let name = path.file_name().unwrap().to_string_lossy().to_string(); |
| 183 | let map = name |
| 184 | .rsplit_once('_') |
| 185 | .map(|(_, m)| m.trim_end_matches(".png").to_string()) |
| 186 | .unwrap_or_default(); |
| 187 | if !["diffuse", "normal", "depth", "specular"].contains(&map.as_str()) { |
| 188 | return Err(format!("onverwachte map {name}")); |
| 189 | } |
| 190 | *counts.entry(map).or_default() += 1; |
| 191 | let decoder = png::Decoder::new(std::io::BufReader::new( |
| 192 | std::fs::File::open(&path).map_err(|e| e.to_string())?, |
| 193 | )); |
| 194 | let reader = decoder.read_info().map_err(|e| e.to_string())?; |
| 195 | let (w, h) = (reader.info().width, reader.info().height); |
| 196 | if (w, h) != (width, height) { |
| 197 | return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}")); |
| 198 | } |
| 199 | } |
| 200 | let atlas = paperdoll_renderer::atlas::cross_check_run(dir)?; |
| 201 | let report = serde_json::json!({ |
| 202 | "atlas": atlas, |
| 203 | "dir": dir.display().to_string(), |
| 204 | "png_files": run.complete.png_files, |
| 205 | "maps": counts, |
| 206 | "body_model": run.render.body_model.path.clone(), |
| 207 | "set": run.render.set.clone(), |
| 208 | "sex": run.render.sex.clone(), |
| 209 | "clips": run.render.clips.iter().map(|c| c.token.clone()).collect::<Vec<_>>(), |
| 210 | "images": run.render.images, |
| 211 | "pivot_source": format!("{:?}", run.render.pivot_source), |
| 212 | }); |
| 213 | Ok(RunCheck { report, run }) |
| 214 | } |
| 215 | |
| 216 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 217 | let a = std::env::args().skip(1).collect::<Vec<_>>(); |
| 218 | if a.len() < 2 { |
| 219 | return Err("ROOT OUTDIR [WIDTH HEIGHT FPS]".into()); |
| 220 | } |
| 221 | let root = PathBuf::from(&a[0]); |
| 222 | let out = PathBuf::from(&a[1]); |
| 223 | std::fs::create_dir_all(&out)?; |
| 224 | let width: u32 = a.get(2).map(|v| v.parse()).transpose()?.unwrap_or(32); |
| 225 | let height: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(48); |
| 226 | let fps: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(4); |
| 227 | let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")); |
| 228 | let fixtures = repo.join("tests/fixtures/bodies/static"); |
| 229 | let mut s = Session::new(); |
| 230 | s.install_new_project(&root)?; |
| 231 | let mut obj = String::new(); |
| 232 | for name in [ |
| 233 | "static-body-male.obj", |
| 234 | "static-body-male.mtl", |
| 235 | "static-body-albedo.png", |
| 236 | ] { |
| 237 | let r = adopt_file(&root, &fixtures.join(name), "assets/body")?; |
| 238 | if name.ends_with(".obj") { |
| 239 | obj = r; |
| 240 | } |
| 241 | } |
| 242 | s.set_body_path(Sex::Male, obj); |
| 243 | let glb = adopt_file(&root, &fixtures.join("static-body-male.glb"), "assets/body")?; |
| 244 | s.set_body_path(Sex::Female, glb); |
| 245 | s.set_render(width, height, fps); |
| 246 | let mut preset = equipment::Preset::new("sword".into()); |
| 247 | preset.provenance = "CC0-1.0 fixture".into(); |
| 248 | equipment::import_model( |
| 249 | &mut preset, |
| 250 | &repo.join("tests/fixtures/equipment/sword.obj"), |
| 251 | )?; |
| 252 | let i = s.add_equipment(); |
| 253 | s.equipment[i].slug = "sword".into(); |
| 254 | s.equipment[i].preset = Some(preset); |
| 255 | s.save()?; |
| 256 | let (root2, out2) = (root.clone(), out.clone()); |
| 257 | eframe::run_native( |
| 258 | "Paperdoll Static Body Fixture", |
| 259 | eframe::NativeOptions { |
| 260 | viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]), |
| 261 | ..paperdoll_renderer::preview_gl::native_options() |
| 262 | }, |
| 263 | Box::new(move |_| { |
| 264 | let mut editor = EditorApp::default(); |
| 265 | editor.open_path(root2.join("character.ppd")); |
| 266 | editor.open_body_configuration(Sex::Male); |
| 267 | Ok(Box::new(Fixture { |
| 268 | editor, |
| 269 | out: out2, |
| 270 | phase: 0, |
| 271 | waited: 0, |
| 272 | shot_requested: false, |
| 273 | results: Vec::new(), |
| 274 | last: 0, |
| 275 | })) |
| 276 | }), |
| 277 | )?; |
| 278 | let gui: serde_json::Value = |
| 279 | serde_json::from_slice(&std::fs::read(out.join("gui-result.json"))?)?; |
| 280 | let mut dirs: Vec<PathBuf> = std::fs::read_dir(root.join("renders"))? |
| 281 | .filter_map(|e| e.ok().map(|e| e.path())) |
| 282 | .filter(|p| p.is_dir()) |
| 283 | .collect(); |
| 284 | dirs.sort(); |
| 285 | let mut checks = Vec::new(); |
| 286 | for dir in &dirs { |
| 287 | checks.push(check_run(dir, width, height)?); |
| 288 | } |
| 289 | let male = checks |
| 290 | .iter() |
| 291 | .find(|c| c.run.render.sex == "male") |
| 292 | .ok_or("male run ontbreekt")?; |
| 293 | let female = checks |
| 294 | .iter() |
| 295 | .find(|c| c.run.render.sex == "female") |
| 296 | .ok_or("female run ontbreekt")?; |
| 297 | // View-by-view: OBJ and unskinned glTF are the same geometry, so coverage |
| 298 | // and depth must match; diffuse must be the unlit albedo of each source. |
| 299 | let mut coverage_mismatch = 0u64; |
| 300 | let mut depth_max_delta = 0u32; |
| 301 | let mut covered = 0u64; |
| 302 | let mut obj_red = 0u64; |
| 303 | let mut obj_blue = 0u64; |
| 304 | let mut obj_other = 0u64; |
| 305 | let mut gltf_colors = std::collections::BTreeSet::new(); |
| 306 | let mut normal_bad = 0u64; |
| 307 | for rel in male.run.complete.png_sha256.keys() { |
| 308 | if !rel.ends_with("_depth.png") { |
| 309 | continue; |
| 310 | } |
| 311 | let female_rel = |r: &str| r.replacen("male/", "female/", 1); |
| 312 | let dm = male |
| 313 | .run |
| 314 | .load_relative(rel, MapKind::Depth) |
| 315 | .map_err(|e| e.to_string())?; |
| 316 | let df = female |
| 317 | .run |
| 318 | .load_relative(&female_rel(rel), MapKind::Depth) |
| 319 | .map_err(|e| e.to_string())?; |
| 320 | let (MapImage::Depth16 { samples: a, .. }, MapImage::Depth16 { samples: b, .. }) = |
| 321 | (&dm, &df) |
| 322 | else { |
| 323 | return Err("depth-map verwacht".into()); |
| 324 | }; |
| 325 | for (x, y) in a.chunks_exact(2).zip(b.chunks_exact(2)) { |
| 326 | if (x[1] > 0) != (y[1] > 0) { |
| 327 | coverage_mismatch += 1; |
| 328 | } else if x[1] > 0 { |
| 329 | covered += 1; |
| 330 | depth_max_delta = depth_max_delta.max((x[0] as i32 - y[0] as i32).unsigned_abs()); |
| 331 | } |
| 332 | } |
| 333 | let diffuse_rel = rel.replace("_depth.png", "_diffuse.png"); |
| 334 | if let MapImage::Rgba8 { rgba, .. } = male |
| 335 | .run |
| 336 | .load_relative(&diffuse_rel, MapKind::Diffuse) |
| 337 | .map_err(|e| e.to_string())? |
| 338 | { |
| 339 | for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) { |
| 340 | let near = |c: [u8; 3]| (0..3).all(|k| (px[k] as i32 - c[k] as i32).abs() <= 2); |
| 341 | if near([220, 40, 30]) { |
| 342 | obj_red += 1; |
| 343 | } else if near([30, 60, 220]) { |
| 344 | obj_blue += 1; |
| 345 | } else { |
| 346 | obj_other += 1; |
| 347 | } |
| 348 | } |
| 349 | } |
| 350 | if let MapImage::Rgba8 { rgba, .. } = female |
| 351 | .run |
| 352 | .load_relative(&female_rel(&diffuse_rel), MapKind::Diffuse) |
| 353 | .map_err(|e| e.to_string())? |
| 354 | { |
| 355 | for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) { |
| 356 | gltf_colors.insert([px[0], px[1], px[2]]); |
| 357 | } |
| 358 | } |
| 359 | let normal_rel = rel.replace("_depth.png", "_normal.png"); |
| 360 | if let MapImage::Rgba8 { rgba, .. } = male |
| 361 | .run |
| 362 | .load_relative(&normal_rel, MapKind::Normal) |
| 363 | .map_err(|e| e.to_string())? |
| 364 | { |
| 365 | for px in rgba.chunks_exact(4).filter(|p| p[3] > 0) { |
| 366 | let n = [0, 1, 2].map(|k| px[k] as f32 / 127.5 - 1.); |
| 367 | let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); |
| 368 | if (len - 1.).abs() > 0.05 { |
| 369 | normal_bad += 1; |
| 370 | } |
| 371 | } |
| 372 | } |
| 373 | } |
| 374 | let equipment_runs = checks.iter().filter(|c| c.run.render.set != "body").count(); |
| 375 | let report = serde_json::json!({ |
| 376 | "gui": gui, |
| 377 | "runs": checks.iter().map(|c| c.report.clone()).collect::<Vec<_>>(), |
| 378 | "equipment_runs_on_static_body": equipment_runs, |
| 379 | "obj_vs_gltf": { |
| 380 | "covered_pixels": covered, |
| 381 | "coverage_mismatch_pixels": coverage_mismatch, |
| 382 | "depth_max_delta_u16": depth_max_delta, |
| 383 | }, |
| 384 | "obj_diffuse": {"red_top": obj_red, "blue_bottom": obj_blue, "other": obj_other}, |
| 385 | "gltf_diffuse_opaque_colors": gltf_colors.iter().map(|c| format!("{c:?}")).collect::<Vec<_>>(), |
| 386 | "obj_normal_non_unit_pixels": normal_bad, |
| 387 | }); |
| 388 | std::fs::write( |
| 389 | out.join("static-body-proof.json"), |
| 390 | serde_json::to_vec_pretty(&report)?, |
| 391 | )?; |
| 392 | println!("REPORT {report}"); |
| 393 | let gui_ok = gui.as_array().is_some_and(|g| { |
| 394 | g.len() == 3 |
| 395 | && g[0]["success"] == false |
| 396 | && g[0]["error"] |
| 397 | .as_str() |
| 398 | .is_some_and(|e| e.contains("statisch")) |
| 399 | && g[1]["success"] == true |
| 400 | && g[2]["success"] == true |
| 401 | }); |
| 402 | let runs_ok = checks.len() == 2 |
| 403 | && equipment_runs == 0 |
| 404 | && checks.iter().all(|c| { |
| 405 | c.run.complete.png_files == 240 |
| 406 | && c.report["atlas"]["cells_equal_to_loose_pngs"] == 240 |
| 407 | && c.run.render.clips.len() == 1 |
| 408 | && c.run.render.clips[0].token == "clip000-bind" |
| 409 | }); |
| 410 | let maps_ok = covered > 0 |
| 411 | && coverage_mismatch * 200 <= covered |
| 412 | && obj_red > 0 |
| 413 | && obj_blue > 0 |
| 414 | && obj_other * 20 <= obj_red + obj_blue |
| 415 | && gltf_colors.len() <= 2 |
| 416 | && normal_bad == 0; |
| 417 | if !(gui_ok && runs_ok && maps_ok) { |
| 418 | return Err(format!( |
| 419 | "static-body-proof mislukt (gui {gui_ok}, runs {runs_ok}, maps {maps_ok}); zie static-body-proof.json" |
| 420 | ) |
| 421 | .into()); |
| 422 | } |
| 423 | Ok(()) |
| 424 | } |