BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / examples / static_body_fixture.rs

static_body_fixture.rs

static_body_fixture.rs 15.4 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 × 36 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 out.push(path);
163 }
164 }
165 }
166 out.sort();
167 Ok(out)
168}
169
170struct RunCheck {
171 report: serde_json::Value,
172 run: export_set::ExportRun,
173}
174
175fn check_run(dir: &Path, width: u32, height: u32) -> Result<RunCheck, String> {
176 let run = export_set::ExportRun::open(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
177 let mut counts = std::collections::BTreeMap::<String, u32>::new();
178 for path in walk_pngs(dir)? {
179 let name = path.file_name().unwrap().to_string_lossy().to_string();
180 let map = name
181 .rsplit_once('_')
182 .map(|(_, m)| m.trim_end_matches(".png").to_string())
183 .unwrap_or_default();
184 if !["diffuse", "normal", "depth", "specular"].contains(&map.as_str()) {
185 return Err(format!("onverwachte map {name}"));
186 }
187 *counts.entry(map).or_default() += 1;
188 let decoder = png::Decoder::new(std::io::BufReader::new(
189 std::fs::File::open(&path).map_err(|e| e.to_string())?,
190 ));
191 let reader = decoder.read_info().map_err(|e| e.to_string())?;
192 let (w, h) = (reader.info().width, reader.info().height);
193 if (w, h) != (width, height) {
194 return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
195 }
196 }
197 let report = serde_json::json!({
198 "dir": dir.display().to_string(),
199 "png_files": run.complete.png_files,
200 "maps": counts,
201 "body_model": run.render.body_model.path.clone(),
202 "set": run.render.set.clone(),
203 "sex": run.render.sex.clone(),
204 "clips": run.render.clips.iter().map(|c| c.token.clone()).collect::<Vec<_>>(),
205 "images": run.render.images,
206 "pivot_source": format!("{:?}", run.render.pivot_source),
207 });
208 Ok(RunCheck { report, run })
209}
210
211fn main() -> Result<(), Box<dyn std::error::Error>> {
212 let a = std::env::args().skip(1).collect::<Vec<_>>();
213 if a.len() < 2 {
214 return Err("ROOT OUTDIR [WIDTH HEIGHT FPS]".into());
215 }
216 let root = PathBuf::from(&a[0]);
217 let out = PathBuf::from(&a[1]);
218 std::fs::create_dir_all(&out)?;
219 let width: u32 = a.get(2).map(|v| v.parse()).transpose()?.unwrap_or(32);
220 let height: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(48);
221 let fps: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(4);
222 let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
223 let fixtures = repo.join("tests/fixtures/bodies/static");
224 let mut s = Session::new();
225 s.install_new_project(&root)?;
226 let mut obj = String::new();
227 for name in [
228 "static-body-male.obj",
229 "static-body-male.mtl",
230 "static-body-albedo.png",
231 ] {
232 let r = adopt_file(&root, &fixtures.join(name), "assets/body")?;
233 if name.ends_with(".obj") {
234 obj = r;
235 }
236 }
237 s.set_body_path(Sex::Male, obj);
238 let glb = adopt_file(&root, &fixtures.join("static-body-male.glb"), "assets/body")?;
239 s.set_body_path(Sex::Female, glb);
240 s.set_render(width, height, fps);
241 let mut preset = equipment::Preset::new("sword".into());
242 preset.provenance = "CC0-1.0 fixture".into();
243 equipment::import_model(
244 &mut preset,
245 &repo.join("tests/fixtures/equipment/sword.obj"),
246 )?;
247 let i = s.add_equipment();
248 s.equipment[i].slug = "sword".into();
249 s.equipment[i].preset = Some(preset);
250 s.save()?;
251 let (root2, out2) = (root.clone(), out.clone());
252 eframe::run_native(
253 "Paperdoll Static Body Fixture",
254 eframe::NativeOptions {
255 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
256 ..paperdoll_renderer::preview_gl::native_options()
257 },
258 Box::new(move |_| {
259 let mut editor = EditorApp::default();
260 editor.open_path(root2.join("character.ppd"));
261 editor.open_body_configuration(Sex::Male);
262 Ok(Box::new(Fixture {
263 editor,
264 out: out2,
265 phase: 0,
266 waited: 0,
267 shot_requested: false,
268 results: Vec::new(),
269 last: 0,
270 }))
271 }),
272 )?;
273 let gui: serde_json::Value =
274 serde_json::from_slice(&std::fs::read(out.join("gui-result.json"))?)?;
275 let mut dirs: Vec<PathBuf> = std::fs::read_dir(root.join("renders"))?
276 .filter_map(|e| e.ok().map(|e| e.path()))
277 .filter(|p| p.is_dir())
278 .collect();
279 dirs.sort();
280 let mut checks = Vec::new();
281 for dir in &dirs {
282 checks.push(check_run(dir, width, height)?);
283 }
284 let male = checks
285 .iter()
286 .find(|c| c.run.render.sex == "male")
287 .ok_or("male run ontbreekt")?;
288 let female = checks
289 .iter()
290 .find(|c| c.run.render.sex == "female")
291 .ok_or("female run ontbreekt")?;
292 // View-by-view: OBJ and unskinned glTF are the same geometry, so coverage
293 // and depth must match; diffuse must be the unlit albedo of each source.
294 let mut coverage_mismatch = 0u64;
295 let mut depth_max_delta = 0u32;
296 let mut covered = 0u64;
297 let mut obj_red = 0u64;
298 let mut obj_blue = 0u64;
299 let mut obj_other = 0u64;
300 let mut gltf_colors = std::collections::BTreeSet::new();
301 let mut normal_bad = 0u64;
302 for rel in male.run.complete.png_sha256.keys() {
303 if !rel.ends_with("_depth.png") {
304 continue;
305 }
306 let female_rel = |r: &str| r.replacen("male/", "female/", 1);
307 let dm = male
308 .run
309 .load_relative(rel, MapKind::Depth)
310 .map_err(|e| e.to_string())?;
311 let df = female
312 .run
313 .load_relative(&female_rel(rel), MapKind::Depth)
314 .map_err(|e| e.to_string())?;
315 let (MapImage::Depth16 { samples: a, .. }, MapImage::Depth16 { samples: b, .. }) =
316 (&dm, &df)
317 else {
318 return Err("depth-map verwacht".into());
319 };
320 for (x, y) in a.chunks_exact(2).zip(b.chunks_exact(2)) {
321 if (x[1] > 0) != (y[1] > 0) {
322 coverage_mismatch += 1;
323 } else if x[1] > 0 {
324 covered += 1;
325 depth_max_delta = depth_max_delta.max((x[0] as i32 - y[0] as i32).unsigned_abs());
326 }
327 }
328 let diffuse_rel = rel.replace("_depth.png", "_diffuse.png");
329 if let MapImage::Rgba8 { rgba, .. } = male
330 .run
331 .load_relative(&diffuse_rel, MapKind::Diffuse)
332 .map_err(|e| e.to_string())?
333 {
334 for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) {
335 let near = |c: [u8; 3]| (0..3).all(|k| (px[k] as i32 - c[k] as i32).abs() <= 2);
336 if near([220, 40, 30]) {
337 obj_red += 1;
338 } else if near([30, 60, 220]) {
339 obj_blue += 1;
340 } else {
341 obj_other += 1;
342 }
343 }
344 }
345 if let MapImage::Rgba8 { rgba, .. } = female
346 .run
347 .load_relative(&female_rel(&diffuse_rel), MapKind::Diffuse)
348 .map_err(|e| e.to_string())?
349 {
350 for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) {
351 gltf_colors.insert([px[0], px[1], px[2]]);
352 }
353 }
354 let normal_rel = rel.replace("_depth.png", "_normal.png");
355 if let MapImage::Rgba8 { rgba, .. } = male
356 .run
357 .load_relative(&normal_rel, MapKind::Normal)
358 .map_err(|e| e.to_string())?
359 {
360 for px in rgba.chunks_exact(4).filter(|p| p[3] > 0) {
361 let n = [0, 1, 2].map(|k| px[k] as f32 / 127.5 - 1.);
362 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
363 if (len - 1.).abs() > 0.05 {
364 normal_bad += 1;
365 }
366 }
367 }
368 }
369 let equipment_runs = checks.iter().filter(|c| c.run.render.set != "body").count();
370 let report = serde_json::json!({
371 "gui": gui,
372 "runs": checks.iter().map(|c| c.report.clone()).collect::<Vec<_>>(),
373 "equipment_runs_on_static_body": equipment_runs,
374 "obj_vs_gltf": {
375 "covered_pixels": covered,
376 "coverage_mismatch_pixels": coverage_mismatch,
377 "depth_max_delta_u16": depth_max_delta,
378 },
379 "obj_diffuse": {"red_top": obj_red, "blue_bottom": obj_blue, "other": obj_other},
380 "gltf_diffuse_opaque_colors": gltf_colors.iter().map(|c| format!("{c:?}")).collect::<Vec<_>>(),
381 "obj_normal_non_unit_pixels": normal_bad,
382 });
383 std::fs::write(
384 out.join("static-body-proof.json"),
385 serde_json::to_vec_pretty(&report)?,
386 )?;
387 println!("REPORT {report}");
388 let gui_ok = gui.as_array().is_some_and(|g| {
389 g.len() == 3
390 && g[0]["success"] == false
391 && g[0]["error"]
392 .as_str()
393 .is_some_and(|e| e.contains("statisch"))
394 && g[1]["success"] == true
395 && g[2]["success"] == true
396 });
397 let runs_ok = checks.len() == 2
398 && equipment_runs == 0
399 && checks.iter().all(|c| {
400 c.run.complete.png_files == 144
401 && c.run.render.clips.len() == 1
402 && c.run.render.clips[0].token == "clip000-bind"
403 });
404 let maps_ok = covered > 0
405 && coverage_mismatch * 200 <= covered
406 && obj_red > 0
407 && obj_blue > 0
408 && obj_other * 20 <= obj_red + obj_blue
409 && gltf_colors.len() <= 2
410 && normal_bad == 0;
411 if !(gui_ok && runs_ok && maps_ok) {
412 return Err(format!(
413 "static-body-proof mislukt (gui {gui_ok}, runs {runs_ok}, maps {maps_ok}); zie static-body-proof.json"
414 )
415 .into());
416 }
417 Ok(())
418}