BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / examples / static_body_fixture.rs

static_body_fixture.rs

static_body_fixture.rs 15.9 KiB Download Raw
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.
9use eframe::App;
10use paperdoll_renderer::{
11 editor::EditorApp,
12 equipment, export_set,
13 export_set::{MapImage, MapKind},
14 session::{adopt_file, Session, Sex},
15};
16use std::path::{Path, PathBuf};
17
18const SETTLE_FRAMES: u64 = 40;
19
20struct 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
30fn 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
38impl 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
87impl 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
153fn 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
173struct RunCheck {
174 report: serde_json::Value,
175 run: export_set::ExportRun,
176}
177
178fn 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
216fn 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 // Mixed red/blue seam pixels from the coverage resolve; the steep
415 // ±60° pitch rows (t955u) see the seam over more pixels than ±30°.
416 && obj_other * 10 <= obj_red + obj_blue
417 && gltf_colors.len() <= 2
418 && normal_bad == 0;
419 if !(gui_ok && runs_ok && maps_ok) {
420 return Err(format!(
421 "static-body-proof mislukt (gui {gui_ok}, runs {runs_ok}, maps {maps_ok}); zie static-body-proof.json"
422 )
423 .into());
424 }
425 Ok(())
426}