//! Real native GPU proof that every material of a glTF body is drawn with its //! own base color texture × factor, in the live 3D preview and in production. //! Usage: xvfb-run -a cargo run --example textured_body_fixture -- ROOT OUTDIR MODEL_DIR MODEL_FILE [WIDTH HEIGHT FPS] //! Copies MODEL_DIR (model + external .bin/textures) into ROOT/assets/body/, //! screenshots the live body view (`live-body.png`), renders all angles and //! writes the production front diffuse (`production-front-diffuse.png`, ×8) //! plus a final UI screenshot (`production-ui.png`). With `--expect-multi-material` //! (for `tests/fixtures/bodies/multi-material`) it checks that the production //! front diffuse holds all three material colors (factor, texture, texture × factor). use eframe::App; use paperdoll_renderer::{ editor::EditorApp, session::{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, error: Option, } 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(); } fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> { std::fs::create_dir_all(to)?; for entry in std::fs::read_dir(from)? { let entry = entry?; let target = to.join(entry.file_name()); if entry.file_type()?.is_dir() { copy_dir(&entry.path(), &target)?; } else { std::fs::copy(entry.path(), target)?; } } Ok(()) } fn find(dir: &Path, suffix: &str) -> Option { let mut entries: Vec<_> = std::fs::read_dir(dir) .ok()? .flatten() .map(|e| e.path()) .collect(); entries.sort(); for path in entries { if path.is_dir() { if let Some(found) = find(&path, suffix) { return Some(found); } } else if path.to_string_lossy().ends_with(suffix) { return Some(path); } } None } 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 } } impl App for Fixture { fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) { self.editor.update(ctx, frame); ctx.request_repaint(); let phase = self.phase; match phase { 0 => { if self.screenshot(ctx, "live-body.png") { self.editor.render_all_angles(); self.phase = 1; } } 1 => { if !self.editor.render_is_running() { self.error = self.editor.render_error().map(str::to_string); self.phase = 2; } } 2 if self.screenshot(ctx, "production-ui.png") => { if let Some(error) = &self.error { println!("RENDER ERROR {error}"); } self.phase = 3; ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close); } _ => {} } } } fn main() -> Result<(), Box> { let a = std::env::args().skip(1).collect::>(); let expect = a.iter().any(|v| v == "--expect-multi-material"); let a: Vec = a.into_iter().filter(|v| !v.starts_with("--")).collect(); if a.len() < 4 { return Err("ROOT OUTDIR MODEL_DIR MODEL_FILE [WIDTH HEIGHT FPS]".into()); } let root = PathBuf::from(&a[0]); let out = PathBuf::from(&a[1]); let model_dir = PathBuf::from(&a[2]); let model_file = &a[3]; std::fs::create_dir_all(&out)?; let width: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(48); let height: u32 = a.get(5).map(|v| v.parse()).transpose()?.unwrap_or(64); let fps: u32 = a.get(6).map(|v| v.parse()).transpose()?.unwrap_or(1); let mut s = Session::new(); s.install_new_project(&root)?; copy_dir(&model_dir, &root.join("assets/body/model"))?; let relative = format!("assets/body/model/{model_file}"); s.set_body_path(Sex::Male, relative.clone()); s.set_body_path(Sex::Female, relative); s.set_render(width, height, fps); s.save()?; let root2 = root.clone(); let out2 = out.clone(); eframe::run_native( "Paperdoll Textured 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, error: None, })) }), )?; let raw = root.join("renders/male-body-atlas/raw files"); let front = find(&raw, "p+00_y000_diffuse.png").ok_or("no production front diffuse")?; let decoder = png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&front)?)); let mut reader = decoder.read_info()?; let mut buf = vec![0; reader.output_buffer_size().ok_or("png size")?]; let info = reader.next_frame(&mut buf)?; let (w, h) = (info.width as usize, info.height as usize); let channels = buf.len() / (w * h); let scale = 8; let mut big = vec![0u8; w * h * scale * scale * 4]; for y in 0..h * scale { for x in 0..w * scale { let src = ((y / scale) * w + x / scale) * channels; let px = &buf[src..src + channels]; let rgba = if channels == 4 { [px[0], px[1], px[2], px[3]] } else { [px[0], px[1], px[2], 255] }; big[(y * w * scale + x) * 4..][..4].copy_from_slice(&rgba); } } save_png( &out.join("production-front-diffuse.png"), w * scale, h * scale, &big, ); println!("PRODUCTION {}", front.display()); if expect { // Unlit sRGB albedo; factors are linear (glTF), textures sRGB: // factor (0.2, 0.4, 0.9) → sRGB (124, 170, 243); texture (0, 200, 0); // texture (200, 200, 0) × factor (0.5, 1, 1) in linear → (146, 200, 0). for (name, want) in [ ("FactorOnly", [124u8, 170, 243]), ("Green", [0, 200, 0]), ("YellowTimesFactor", [146, 200, 0]), ] { let found = buf .chunks_exact(channels) .any(|px| px[..3].iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2)); println!( "MATERIAL {name} {want:?}: {}", if found { "ok" } else { "MISSING" } ); if !found { return Err(format!("production diffuse mist materiaal {name}").into()); } } } Ok(()) }