//! Render queue for one body still. //! //! The worker walks the design order and emits an event after every pass. //! The full production order is recorded, but this still renderer executes //! and counts only its two real color passes. The GUI may coalesce events. use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver}; use std::thread::{self, JoinHandle}; use crate::preview::{ paperdoll_yaws_deg, PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, }; use crate::still::raster_body; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PassKind { HighresImage, DetailMap, TargetResolution, Diffuse, Depth, Normal, Shiny, RimHighlight, } impl PassKind { pub fn label(self) -> &'static str { match self { Self::HighresImage => "highres", Self::DetailMap => "detail", Self::TargetResolution => "doelresolutie", Self::Diffuse => "diffuse", Self::Depth => "depth", Self::Normal => "normal", Self::Shiny => "shiny", Self::RimHighlight => "rim", } } pub fn produces_image(self) -> bool { matches!(self, Self::HighresImage | Self::TargetResolution) } } pub const PASS_ORDER: [PassKind; 8] = [ PassKind::HighresImage, PassKind::DetailMap, PassKind::TargetResolution, PassKind::Diffuse, PassKind::Depth, PassKind::Normal, PassKind::Shiny, PassKind::RimHighlight, ]; #[derive(Debug, Clone, Copy, PartialEq)] pub struct ViewAngle { pub pitch_deg: f32, pub yaw_deg: f32, } #[derive(Debug, Clone, Copy, PartialEq)] pub struct PassStep { pub pitch_deg: f32, pub yaw_deg: f32, pub kind: PassKind, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ImageBuf { pub width: u32, pub height: u32, pub rgba: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Progress { pub completed: u32, pub total: u32, pub label: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum RenderEvent { Step { index: u32, total: u32, label: String, }, Pass { index: u32, total: u32, label: String, image: Option, }, Failed(String), Finished { saved: Option, }, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenderUiState { pub progress: Progress, pub preview: Option, pub saved: Option, pub error: Option, pub running: bool, } impl RenderUiState { pub fn idle() -> Self { Self { progress: Progress { completed: 0, total: 0, label: String::new(), }, preview: None, saved: None, error: None, running: false, } } pub fn apply(&mut self, event: RenderEvent) { match event { RenderEvent::Step { index, total, label, } => { self.progress = Progress { completed: index, total, label, }; self.running = true; } RenderEvent::Pass { index, total, label, image, } => { self.progress = Progress { completed: index + 1, total, label, }; if let Some(image) = image { self.preview = Some(image); } self.running = true; } RenderEvent::Failed(message) => { self.error = Some(message); self.running = false; } RenderEvent::Finished { saved } => { self.saved = saved; self.running = false; if self.progress.total > 0 { self.progress.completed = self.progress.total; } } } } } pub fn export_views() -> Vec { let mut views = Vec::with_capacity(PAPERDOLL_PITCHES_DEG.len() * paperdoll_yaws_deg().len()); for pitch in PAPERDOLL_PITCHES_DEG { for yaw in paperdoll_yaws_deg() { views.push(ViewAngle { pitch_deg: pitch, yaw_deg: yaw, }); } } views } pub fn pass_sequence(views: &[ViewAngle]) -> Vec { let mut steps = Vec::with_capacity(views.len() * PASS_ORDER.len()); for view in views { for kind in PASS_ORDER { steps.push(PassStep { pitch_deg: view.pitch_deg, yaw_deg: view.yaw_deg, kind, }); } } steps } pub fn highres_extent(target_width: u32, target_height: u32) -> (u32, u32) { let width = target_width.max(1); let height = target_height.max(1); let mut scale = 8u32; while scale > 1 && (width.saturating_mul(scale) > 1024 || height.saturating_mul(scale) > 1024) { scale -= 1; } (width * scale, height * scale) } /// Nearest-neighbor stretch. Every destination pixel is filled, so the /// preview box has no empty border. pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec { let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4]; if sw == 0 || sh == 0 || dw == 0 || dh == 0 { return out; } for y in 0..dh { let sy = ((y as u64 * sh as u64) / dh as u64) as u32; let sy = sy.min(sh - 1); for x in 0..dw { let sx = ((x as u64 * sw as u64) / dw as u64) as u32; let sx = sx.min(sw - 1); let from = ((sy * sw + sx) * 4) as usize; let to = ((y * dw + x) * 4) as usize; out[to..to + 4].copy_from_slice(&src[from..from + 4]); } } out } pub struct RenderJob { pub character: PreviewCharacter, pub controls: PreviewControls, pub target_width: u32, pub target_height: u32, pub preview_width: u32, pub preview_height: u32, pub output: PathBuf, } impl RenderJob { pub fn steps(&self) -> Vec { pass_sequence(&[ViewAngle { pitch_deg: self.controls.pitch_deg, yaw_deg: self.controls.yaw_deg, }]) } } pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) { if job.target_width == 0 || job.target_height == 0 || job.target_width > crate::project::MAX_DIMENSION || job.target_height > crate::project::MAX_DIMENSION { emit(RenderEvent::Failed( "ongeldige afmetingen voor de render".into(), )); return; } // Count only implemented passes. Never report a skipped map as completed. let steps: Vec<_> = job .steps() .into_iter() .filter(|step| step.kind.produces_image()) .collect(); let total = steps.len() as u32; let mut highres: Option = None; let mut saved = None; for (index, step) in steps.iter().enumerate() { let index = index as u32; let label = format!( "pitch {:.0} yaw {:.0} ยท {}", step.pitch_deg, step.yaw_deg, step.kind.label() ); emit(RenderEvent::Step { index, total, label: label.clone(), }); if !step.kind.produces_image() { emit(RenderEvent::Pass { index, total, label, image: None, }); continue; } let image = match step.kind { PassKind::HighresImage => { let mut controls = job.controls.clone(); controls.pitch_deg = step.pitch_deg; controls.yaw_deg = step.yaw_deg; let (width, height) = highres_extent(job.target_width, job.target_height); match raster_body(&job.character, &controls, width, height) { Ok(rgba) => { let image = ImageBuf { width, height, rgba, }; highres = Some(image.clone()); Some(image) } Err(message) => { emit(RenderEvent::Failed(message)); return; } } } PassKind::TargetResolution => { let Some(source) = highres.as_ref() else { emit(RenderEvent::Failed( "de doelresolutie volgt op het highres-beeld".to_string(), )); return; }; let rgba = nearest_stretch( &source.rgba, source.width, source.height, job.target_width.max(1), job.target_height.max(1), ); let target = ImageBuf { width: job.target_width.max(1), height: job.target_height.max(1), rgba, }; match write_png(&job.output, &target) { Ok(()) => saved = Some(job.output.display().to_string()), Err(message) => { emit(RenderEvent::Failed(message)); return; } } let (box_w, box_h) = (job.preview_width.max(1), job.preview_height.max(1)); Some(ImageBuf { width: box_w, height: box_h, rgba: nearest_stretch(&target.rgba, target.width, target.height, box_w, box_h), }) } _ => None, }; emit(RenderEvent::Pass { index, total, label, image, }); } emit(RenderEvent::Finished { saved }); } pub struct RenderThread { pub rx: Receiver, pub handle: JoinHandle<()>, } pub fn spawn(job: RenderJob) -> RenderThread { let (tx, rx) = mpsc::channel(); let handle = thread::spawn(move || { execute(&job, &mut |event| { let _ = tx.send(event); }); }); RenderThread { rx, handle } } fn write_png(path: &Path, image: &ImageBuf) -> Result<(), String> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|err| err.to_string())?; } let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let temp = path.with_extension(format!("png.part-{}-{stamp}", std::process::id())); let file = std::fs::OpenOptions::new() .create_new(true) .write(true) .open(&temp) .map_err(|err| err.to_string())?; let mut encoder = png::Encoder::new(file, image.width, image.height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); let mut writer = encoder.write_header().map_err(|err| err.to_string())?; writer .write_image_data(&image.rgba) .map_err(|err| err.to_string())?; drop(writer); std::fs::rename(&temp, path).map_err(|err| err.to_string())?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::preview::load_gltf_file; use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn full_view_order_is_pitch_then_yaw_then_passes() { let views = export_views(); assert_eq!(views.len(), 36); assert_eq!(views[0].pitch_deg, -30.0); assert_eq!(views[0].yaw_deg, 0.0); assert_eq!(views[11].yaw_deg, 330.0); assert_eq!(views[12].pitch_deg, 0.0); let steps = pass_sequence(&views); assert_eq!(steps.len(), 36 * 8); let first: Vec<_> = steps.iter().take(8).map(|step| step.kind).collect(); assert_eq!(first, PASS_ORDER.to_vec()); assert_eq!(steps[8].yaw_deg, 30.0); assert_eq!(steps[8].kind, PassKind::HighresImage); } #[test] fn nearest_non_square_uses_source_height_for_rows() { let src = [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]; let out = nearest_stretch(&src, 1, 3, 2, 6); for y in 0..6 { assert_eq!(&out[y * 8..y * 8 + 4], &src[(y / 2) * 4..(y / 2) * 4 + 4]); } } #[test] fn nearest_stretch_fills_the_box() { let src = [ 255, 0, 0, 255, 0, 0, 255, 255, // 2x1 ]; let out = nearest_stretch(&src, 2, 1, 4, 3); assert_eq!(out.len(), 4 * 3 * 4); for y in 0..3 { for x in 0..4 { let pixel = &out[(y * 4 + x) * 4..(y * 4 + x) * 4 + 4]; if x < 2 { assert_eq!(pixel, &[255, 0, 0, 255]); } else { assert_eq!(pixel, &[0, 0, 255, 255]); } } } } #[test] fn progress_follows_steps_and_skips_do_not_replace_the_picture() { let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"); let character = load_gltf_file(&path).unwrap(); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let output = std::env::temp_dir().join(format!("paperdoll-still-{nanos}.png")); let job = RenderJob { character, controls: PreviewControls::default(), target_width: 32, target_height: 40, preview_width: 64, preview_height: 80, output: output.clone(), }; let mut events = Vec::new(); execute(&job, &mut |event| events.push(event)); let mut ui = RenderUiState::idle(); for event in events { ui.apply(event); } assert_eq!(ui.progress.completed, 2); assert_eq!(ui.progress.total, 2); assert!(ui.error.is_none()); let preview = ui.preview.expect("a real pass image"); assert_eq!((preview.width, preview.height), (64, 80)); let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count(); assert_eq!(filled, 64 * 80); assert!(output.is_file()); let _ = std::fs::remove_file(&output); } #[test] fn skipped_maps_have_no_image() { let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"); let job = RenderJob { character: load_gltf_file(&path).unwrap(), controls: PreviewControls::default(), target_width: 16, target_height: 16, preview_width: 16, preview_height: 16, output: std::env::temp_dir().join("paperdoll-skip-check.png"), }; let mut images = Vec::new(); execute(&job, &mut |event| { if let RenderEvent::Pass { label, image, .. } = event { images.push((label, image.is_some())); } }); assert_eq!(images.len(), 2); assert!(images.iter().all(|(_, has_image)| *has_image)); assert!(images[0].0.ends_with("highres")); assert!(images[1].0.ends_with("doelresolutie")); let _ = std::fs::remove_file(&job.output); } }