//! Rendered 2D preview: playback, discrete views, continuous light, export catalog. //! //! Consumes completed exports through their `.ppda` + per-map atlases //! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and //! `compositor`. Loose PNGs are not read here. //! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode). use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use crate::atlas::{AtlasSet, LoadedAtlas}; use crate::compositor::{ assert_compatible_keys, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps, LayerMaps, Light, }; use crate::export_set::ExportError; use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT}; use crate::render_job::ImageBuf; use crate::session::Sex; /// Degrees per second while a light hold button stays pressed. pub const LIGHT_AZIMUTH_RATE_DEG_PER_SEC: f32 = 90.0; pub const LIGHT_ELEVATION_RATE_DEG_PER_SEC: f32 = 60.0; /// Elevation clamp (degrees). Not cyclic; soft limit keeps the light off the horizon flip. pub const LIGHT_ELEVATION_MIN_DEG: f32 = -80.0; pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0; /// Default light pose: slightly above and from the front (+Z in view space). pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0; pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0; /// Row of pitch 0° in [`PAPERDOLL_PITCHES_DEG`] (−60,−30,0,+30,+60). pub const ZERO_PITCH_ROW: i32 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LightAxis { Azimuth, Elevation, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HoldSign { Positive, Negative, } impl HoldSign { pub fn as_f32(self) -> f32 { match self { Self::Positive => 1.0, Self::Negative => -1.0, } } } /// What the 2D preview shows for the current composed pose. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ViewMode { /// Relit composite (ambient + Lambert + Blinn-Phong). Default. #[default] Resultaat, /// Depth-composited diffuse albedo, unlit. DiffuseOnly, /// Depth-composited normal map as stored RGB, unlit. NormalOnly, /// Depth-composited depth map as stored (raw RGB for rgb_bands/rgb_packed), unlit. DepthOnly, /// Depth-composited specular map as stored RGB, unlit. SpecularOnly, } impl ViewMode { pub fn label_nl(self) -> &'static str { match self { Self::Resultaat => "Resultaat", Self::DiffuseOnly => "Alleen diffuse", Self::NormalOnly => "Alleen normal map", Self::DepthOnly => "Alleen depth map", Self::SpecularOnly => "Alleen specular map", } } pub fn all() -> [Self; 5] { [ Self::Resultaat, Self::DiffuseOnly, Self::NormalOnly, Self::DepthOnly, Self::SpecularOnly, ] } pub fn uses_light(self) -> bool { matches!(self, Self::Resultaat) } } /// Render a composed map mode to RGBA8 (unlit inspection). pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf { let pixels = (composed.width as usize) * (composed.height as usize); let mut rgba = vec![0u8; pixels * 4]; match mode { ViewMode::Resultaat => { // Caller should use relight; fall back to diffuse if misused. rgba.copy_from_slice(&composed.diffuse); } ViewMode::DiffuseOnly => { rgba.copy_from_slice(&composed.diffuse); } ViewMode::NormalOnly => { rgba.copy_from_slice(&composed.normal); } ViewMode::SpecularOnly => { rgba.copy_from_slice(&composed.specular); } ViewMode::DepthOnly if composed.depth_display.is_some() => { // As stored in the atlas (Tim t992u): rgb_bands / rgb_packed show // their raw RGB, 16-bit at display precision; no decoding. let display = composed.depth_display.as_ref().unwrap(); for i in 0..pixels { if composed.winner[i] != u8::MAX { rgba[i * 4..i * 4 + 4].copy_from_slice(&display[i * 4..i * 4 + 4]); } } } ViewMode::DepthOnly => { // Sources without stored pixels (synthetic layers): canonical gray. for i in 0..pixels { let o = i * 4; if composed.winner[i] == u8::MAX { continue; } let gray = composed.depth[i * 2]; let alpha16 = composed.depth[i * 2 + 1]; let g = ((u32::from(gray) * 255 + 32767) / 65535) as u8; let a = ((u32::from(alpha16) * 255 + 32767) / 65535) as u8; rgba[o] = g; rgba[o + 1] = g; rgba[o + 2] = g; rgba[o + 3] = a; } } } ImageBuf { width: composed.width, height: composed.height, rgba, } } /// One export (`.ppda` + atlases) discovered under a project `renders/` tree. #[derive(Debug, Clone)] pub struct CatalogEntry { /// Run directory containing the `.ppda`. pub path: PathBuf, pub sex: String, pub set: String, pub project_id: String, pub fps: u32, pub target: [u32; 2], pub clips: Vec<(String, String, u32, f32)>, pub body_model_sha256: String, pub equipment_preset_sha256: Option, pub key: CompatKey, } impl CatalogEntry { pub fn from_atlas(path: PathBuf, set: &AtlasSet) -> Self { let p = &set.ppda; Self { path, sex: p.sex.clone(), set: p.settype.clone(), project_id: p.project.id.clone(), fps: p.fps, target: [p.cell_width, p.cell_height], clips: p .clips .iter() .map(|c| (c.token.clone(), c.name.clone(), c.frame_count, c.duration)) .collect(), body_model_sha256: p.identity.body_model_sha256.clone(), equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(), key: CompatKey::from_ppda(p), } } pub fn is_body(&self) -> bool { self.set == "body" } } /// Run directories under `renders/` that were skipped, with the reason. pub type Skipped = Vec<(PathBuf, String)>; /// Scan `project_root/renders/--atlas/` and open every export whose `.ppda` and /// atlases validate. Runs without a valid `.ppda` (incomplete, older schema, /// damaged) are skipped. pub fn discover_completed_runs(project_root: &Path) -> Vec { discover_exports(project_root).0 } /// Like [`discover_completed_runs`], also returning skipped runs + reasons. pub fn discover_exports(project_root: &Path) -> (Vec, Skipped) { let renders = project_root.join("renders"); let Ok(entries) = fs::read_dir(&renders) else { return (Vec::new(), Vec::new()); }; let mut out = Vec::new(); let mut skipped = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let name = entry.file_name().to_string_lossy().to_string(); if name.starts_with(crate::resume::IN_PROGRESS_PREFIX) || name.starts_with(crate::resume::REPLACED_PREFIX) { continue; // unfinished build / swap leftover, never a result } if name.starts_with("run-") { skipped.push(( path, "oude run-map uit een eerdere versie; genegeerd (opnieuw renderen naar --atlas)".into(), )); continue; } if !name.ends_with("-atlas") { continue; } match AtlasSet::open_run(&path) { Ok(set) => { let entry = CatalogEntry::from_atlas(path.clone(), &set); if name == crate::resume::result_dir_name(&entry.sex, &entry.set) { out.push(entry); } else { skipped.push(( path, format!( "mapnaam past niet bij inhoud ({})", crate::resume::result_dir_name(&entry.sex, &entry.set) ), )); } } Err(err) => skipped.push((path, err.to_string())), } } out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path))); skipped.sort(); (out, skipped) } /// Discrete yaw index 0..11 → degrees 0,30,…,330. pub fn yaw_deg_from_index(index: i32) -> u32 { let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as u32; wrapped * 30 } pub fn pitch_deg_from_row(row: i32) -> i32 { let row = row.clamp(0, (PAPERDOLL_PITCHES_DEG.len() as i32) - 1) as usize; PAPERDOLL_PITCHES_DEG[row] as i32 } pub fn pitch_row_from_deg(pitch: i32) -> i32 { PAPERDOLL_PITCHES_DEG .iter() .position(|p| *p as i32 == pitch) .unwrap_or(ZERO_PITCH_ROW as usize) as i32 } /// View-space light direction toward the light. /// /// Convention (right-hand, view +Y up, camera looks −Z toward the character so /// character front faces +Z in view): /// - azimuth 0° = light from +Z (in front of the character) /// - positive azimuth rotates toward +X (character's left when facing camera) /// - elevation 0° = horizon; positive raises the light pub fn light_direction_view(azimuth_deg: f32, elevation_deg: f32) -> [f32; 3] { let az = azimuth_deg.to_radians(); let el = elevation_deg.to_radians(); let cos_el = el.cos(); [cos_el * az.sin(), el.sin(), cos_el * az.cos()] } pub fn wrap_azimuth_deg(deg: f32) -> f32 { let mut d = deg % 360.0; if d < 0.0 { d += 360.0; } d } pub fn clamp_elevation_deg(deg: f32) -> f32 { deg.clamp(LIGHT_ELEVATION_MIN_DEG, LIGHT_ELEVATION_MAX_DEG) } fn wrap_time(time: f32, duration: f32) -> f32 { if !(duration.is_finite() && duration > 0.0 && time.is_finite()) { return 0.0; } let mut t = time % duration; if t < 0.0 { t += duration; } t } /// Playback / view / light state for the rendered 2D tab. #[derive(Debug, Clone)] pub struct Preview2dState { pub sex: Sex, /// Equipment set names currently selected (body is always included when present). pub selected_equipment: Vec, pub clip_token: String, pub clip_frames: u32, pub clip_duration: f32, pub fps: u32, pub playback_speed: f32, pub playing: bool, /// Elapsed animation seconds (preserved across yaw/pitch changes). pub elapsed_secs: f32, pub yaw_index: i32, pub pitch_row: i32, pub light_azimuth_deg: f32, pub light_elevation_deg: f32, pub hold_azimuth: Option, pub hold_elevation: Option, /// Preview display mode (Resultaat vs raw composed maps). pub view_mode: ViewMode, pub diagnostic: Option, } impl Default for Preview2dState { fn default() -> Self { Self { sex: Sex::Male, selected_equipment: Vec::new(), clip_token: String::new(), clip_frames: 1, clip_duration: 0.0, fps: crate::project::DEFAULT_FPS, playback_speed: 1.0, playing: true, elapsed_secs: 0.0, yaw_index: 0, pitch_row: ZERO_PITCH_ROW, light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG, light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG, hold_azimuth: None, hold_elevation: None, view_mode: ViewMode::Resultaat, diagnostic: None, } } } impl Preview2dState { pub fn sex_token(&self) -> &'static str { match self.sex { Sex::Male => "male", Sex::Female => "female", } } pub fn yaw_deg(&self) -> u32 { yaw_deg_from_index(self.yaw_index) } pub fn pitch_deg(&self) -> i32 { pitch_deg_from_row(self.pitch_row) } pub fn frame_index(&self) -> u32 { let frames = self.clip_frames.max(1); if self.fps == 0 { return 0; } // `elapsed_secs` already advances by `dt * playback_speed` in `tick`. let samples = self.elapsed_secs * self.fps as f32; if !samples.is_finite() || samples < 0.0 { return 0; } (samples.floor() as u64 % u64::from(frames)) as u32 } pub fn set_clip(&mut self, token: String, frames: u32, duration: f32) { if self.clip_token != token { self.clip_token = token; self.elapsed_secs = 0.0; } self.clip_frames = frames.max(1); self.clip_duration = if duration.is_finite() && duration >= 0.0 { duration } else { 0.0 }; self.diagnostic = None; } pub fn step_yaw(&mut self, delta: i32) { self.yaw_index = (self.yaw_index + delta).rem_euclid(PAPERDOLL_YAW_COUNT as i32); // Animation time intentionally preserved. } pub fn step_pitch(&mut self, delta: i32) { self.pitch_row = (self.pitch_row + delta).clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1); // Animation time intentionally preserved; pitch is clamped, not cyclic. } pub fn hold_light(&mut self, axis: LightAxis, sign: HoldSign) { match axis { LightAxis::Azimuth => self.hold_azimuth = Some(sign), LightAxis::Elevation => self.hold_elevation = Some(sign), } } pub fn release_light(&mut self, axis: LightAxis) { match axis { LightAxis::Azimuth => self.hold_azimuth = None, LightAxis::Elevation => self.hold_elevation = None, } } pub fn release_all_holds(&mut self) { self.hold_azimuth = None; self.hold_elevation = None; } /// Stop continuous light motion (button release, focus loss, pointer cancel). pub fn on_interaction_cancel(&mut self) { self.release_all_holds(); } pub fn light(&self) -> Light { Light { direction_view: light_direction_view(self.light_azimuth_deg, self.light_elevation_deg), ..Light::default() } } /// Advance playback and held light controls by `dt` seconds. /// (Holds are set each frame from [`raw_pointer_hold`].) pub fn tick(&mut self, dt: f32) { let dt = if dt.is_finite() && dt > 0.0 { dt.min(0.25) } else { 0.0 }; if self.playing && dt > 0.0 { // Loop over exactly frames/fps so every exported frame, including the // last, lasts a full 1/fps (not shortened by source clip_duration). let duration = if self.fps > 0 { self.clip_frames.max(1) as f32 / self.fps as f32 } else if self.clip_duration > 0.0 { self.clip_duration } else { 1.0 }; let speed = if self.playback_speed.is_finite() { self.playback_speed.max(0.0) } else { 0.0 }; self.elapsed_secs = wrap_time(self.elapsed_secs + dt * speed, duration); } if let Some(sign) = self.hold_azimuth { self.light_azimuth_deg = wrap_azimuth_deg( self.light_azimuth_deg + sign.as_f32() * LIGHT_AZIMUTH_RATE_DEG_PER_SEC * dt, ); } if let Some(sign) = self.hold_elevation { self.light_elevation_deg = clamp_elevation_deg( self.light_elevation_deg + sign.as_f32() * LIGHT_ELEVATION_RATE_DEG_PER_SEC * dt, ); } } pub fn toggle_equipment(&mut self, set: &str) { if set == "body" { return; } if let Some(pos) = self.selected_equipment.iter().position(|s| s == set) { self.selected_equipment.remove(pos); } else { self.selected_equipment.push(set.to_string()); self.selected_equipment.sort(); } self.diagnostic = None; } pub fn set_sex(&mut self, sex: Sex) { if self.sex != sex { self.sex = sex; self.selected_equipment.clear(); self.clip_token.clear(); self.elapsed_secs = 0.0; self.diagnostic = None; } } } #[derive(Debug, Clone)] pub struct ComposeRequest<'a> { pub body: &'a LoadedAtlas, pub equipment: &'a [LoadedAtlas], pub clip_token: &'a str, pub frame: u32, pub pitch: i32, pub yaw: u32, pub light: Light, pub view_mode: ViewMode, } #[derive(Debug)] pub enum Preview2dError { Compatible(ComposeError), Export(ExportError), Missing { set: String, clip: String, frame: u32, pitch: i32, yaw: u32, detail: String, }, NoBody, } impl std::fmt::Display for Preview2dError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Compatible(e) => write!(f, "{e}"), Self::Export(e) => write!(f, "{e}"), Self::Missing { set, clip, frame, pitch, yaw, detail, } => write!( f, "ontbrekende maps voor set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}" ), Self::NoBody => write!(f, "geen body-exportset voor dit geslacht"), } } } impl std::error::Error for Preview2dError {} impl From for Preview2dError { fn from(value: ComposeError) -> Self { Self::Compatible(value) } } impl From for Preview2dError { fn from(value: ExportError) -> Self { Self::Export(value) } } /// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error. pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result { let mut atlases: Vec<&LoadedAtlas> = Vec::with_capacity(1 + req.equipment.len()); atlases.push(req.body); atlases.extend(req.equipment.iter()); let keys: Vec = atlases .iter() .map(|a| CompatKey::from_ppda(a.ppda())) .collect(); assert_compatible_keys(&keys)?; let mut layers = Vec::with_capacity(atlases.len()); for atlas in atlases { let set = atlas.ppda().settype.as_str(); match LayerMaps::from_atlas(atlas, set, req.clip_token, req.frame, req.pitch, req.yaw) { Ok(layer) => layers.push(layer), Err(err) => { return Err(Preview2dError::Missing { set: set.to_string(), clip: req.clip_token.to_string(), frame: req.frame, pitch: req.pitch, yaw: req.yaw, detail: err.to_string(), }); } } } if req.view_mode.uses_light() { let (_composed, lit) = compose_and_relight(&layers, &req.light)?; Ok(lit) } else { let composed = compose(&layers)?; Ok(composed_map_image(&composed, req.view_mode)) } } /// Run directories (body first, then equipment in the given order) that /// [`open_selection`] would load; used as a cache key by the editor. pub fn selection_paths( catalog: &[CatalogEntry], sex: Sex, equipment_sets: &[String], ) -> Result, Preview2dError> { let sex_token = match sex { Sex::Male => "male", Sex::Female => "female", }; let body = catalog .iter() .filter(|e| e.sex == sex_token && e.is_body()) .max_by_key(|e| e.path.clone()) .ok_or(Preview2dError::NoBody)?; let mut paths = vec![body.path.clone()]; for set in equipment_sets { let entry = catalog .iter() .filter(|e| e.sex == sex_token && e.set == *set && !e.is_body()) .max_by_key(|e| e.path.clone()) .ok_or_else(|| Preview2dError::Missing { set: set.clone(), clip: String::new(), frame: 0, pitch: 0, yaw: 0, detail: "geen voltooide exportset voor dit equipment".into(), })?; paths.push(entry.path.clone()); } Ok(paths) } /// Open and decode the `.ppda` atlases of body + selected equipment for one /// sex, re-validating checksums and compatibility. pub fn open_selection( catalog: &[CatalogEntry], sex: Sex, equipment_sets: &[String], ) -> Result<(LoadedAtlas, Vec), Preview2dError> { let paths = selection_paths(catalog, sex, equipment_sets)?; let mut loaded = Vec::with_capacity(paths.len()); for path in &paths { loaded.push(AtlasSet::open_run(path)?.load()?); } let keys: Vec = loaded .iter() .map(|a| CompatKey::from_ppda(a.ppda())) .collect(); assert_compatible_keys(&keys)?; let body = loaded.remove(0); Ok((body, loaded)) } /// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract). pub fn compatible_equipment_sets(catalog: &[CatalogEntry], sex: Sex) -> Vec { let sex_token = match sex { Sex::Male => "male", Sex::Female => "female", }; let Some(body) = catalog .iter() .filter(|e| e.sex == sex_token && e.is_body()) .max_by_key(|e| e.path.clone()) else { return Vec::new(); }; let mut names = BTreeMap::new(); for entry in catalog { if entry.sex != sex_token || entry.is_body() { continue; } if entry.key.project_id != body.key.project_id || entry.key.sex != body.key.sex || entry.key.body_model_sha256 != body.key.body_model_sha256 || entry.key.target != body.key.target || entry.key.fps != body.key.fps || entry.key.framing != body.key.framing || (entry.key.near - body.key.near).abs() > f32::EPSILON || (entry.key.far - body.key.far).abs() > f32::EPSILON || entry.key.clip_tokens != body.key.clip_tokens { continue; } names.insert(entry.set.clone(), ()); } names.into_keys().collect() } /// Apply catalog defaults when sex/clip inventory changes. pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEntry]) { let sex_token = state.sex_token(); let body = catalog .iter() .filter(|e| e.sex == sex_token && e.is_body()) .max_by_key(|e| e.path.clone()); let Some(body) = body else { state.diagnostic = Some("geen voltooide body-export voor dit geslacht".into()); return; }; state.fps = body.fps; let allowed = compatible_equipment_sets(catalog, state.sex); state .selected_equipment .retain(|s| allowed.iter().any(|a| a == s)); if state.clip_token.is_empty() || !body.clips.iter().any(|(t, _, _, _)| t == &state.clip_token) { if let Some((token, _name, frames, duration)) = body.clips.first() { state.set_clip(token.clone(), *frames, *duration); } } else if let Some((_, _, frames, duration)) = body .clips .iter() .find(|(t, _, _, _)| t == &state.clip_token) { state.clip_frames = (*frames).max(1); state.clip_duration = *duration; } } /// Whether a light hold-button is held, from raw pointer state rather than /// egui's click sense (which stops reporting `is_pointer_button_down_on` once /// a press outlasts a click, ~1 s in egui 0.29). Held = primary button down, /// press started on the button, pointer still over it. Release, pointer gone /// (left the window) or a press that started elsewhere all yield false. pub fn raw_pointer_hold(rect: eframe::egui::Rect, input: &eframe::egui::InputState) -> bool { let p = &input.pointer; p.primary_down() && p.press_origin().is_some_and(|o| rect.contains(o)) && p.hover_pos().is_some_and(|h| rect.contains(h)) } #[cfg(test)] mod tests { use super::*; use crate::digest; use crate::export_fixture::{Pixel, SyntheticRun}; use crate::project::CameraFraming; use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); struct Temp(PathBuf); impl Temp { fn new() -> Self { let n = COUNTER.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( "paperdoll-preview2d-{}-{}-{}", std::process::id(), n, std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&path).unwrap(); Self(path) } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn framing() -> CameraFraming { CameraFraming { height_milli: 1800, center_y_milli: 900, pivot_y_milli: Some(1500), depth_range_milli: None, } } fn pixel(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel { Pixel { diffuse, normal, specular, depth, } } /// Complete synthetic run (all 60 views) with uniform maps per run. #[allow(clippy::too_many_arguments)] fn write_run( root: &Path, project_id: &str, set: &str, equipment_sha: Option<&str>, body_bytes: &[u8], clips: &[(&str, u32, f32)], px: Pixel, ) { let mut spec = SyntheticRun::body(project_id, "male", body_bytes); spec.framing = framing(); spec.set = set.into(); spec.equipment_sha256 = equipment_sha.map(str::to_string); spec.clips = clips .iter() .map(|(n, f, d)| (n.to_string(), *f, *d)) .collect(); spec.write(root, |_, _, _, _| px).unwrap(); } #[test] fn yaw_wraps_twelve_steps_pitch_clamps_five_rows() { let mut state = Preview2dState { elapsed_secs: 1.25, ..Preview2dState::default() }; assert_eq!(state.yaw_deg(), 0); state.step_yaw(-1); assert_eq!(state.yaw_deg(), 330); assert_eq!(state.elapsed_secs, 1.25); state.step_yaw(1); assert_eq!(state.yaw_deg(), 0); for _ in 0..12 { state.step_yaw(1); } assert_eq!(state.yaw_deg(), 0); assert_eq!(PAPERDOLL_YAW_COUNT, 12); state.pitch_row = ZERO_PITCH_ROW; state.step_pitch(1); assert_eq!(state.pitch_deg(), 30); state.step_pitch(1); assert_eq!(state.pitch_deg(), 60); state.step_pitch(1); assert_eq!(state.pitch_deg(), 60); state.step_pitch(-3); assert_eq!(state.pitch_deg(), -30); state.step_pitch(-1); assert_eq!(state.pitch_deg(), -60); state.step_pitch(-1); assert_eq!(state.pitch_deg(), -60); assert_eq!(state.elapsed_secs, 1.25); } #[test] fn playback_accumulates_elapsed_and_loops_by_fps() { let mut state = Preview2dState { fps: 10, clip_frames: 4, clip_duration: 0.4, playback_speed: 1.0, playing: true, ..Preview2dState::default() }; assert_eq!(state.frame_index(), 0); state.tick(0.15); assert_eq!(state.frame_index(), 1); state.tick(0.25); assert_eq!(state.frame_index(), 0); // wrapped: 0.4s * 10fps = frame 0 again after loop // 0.15+0.25=0.40 → wrap to 0 → frame 0 state.playing = false; let frozen = state.elapsed_secs; state.tick(1.0); assert_eq!(state.elapsed_secs, frozen); state.playing = true; state.playback_speed = 2.0; state.elapsed_secs = 0.0; state.tick(0.05); // elapsed += 0.05*2 = 0.10; samples = 0.10*10 = 1 → frame 1 assert_eq!(state.frame_index(), 1); } #[test] fn light_hold_uses_dt_and_stops_on_release_or_cancel() { let mut state = Preview2dState::default(); let start_az = state.light_azimuth_deg; state.hold_light(LightAxis::Azimuth, HoldSign::Positive); // Per-tick dt is capped at 0.25s (hitch guard). state.tick(0.25); state.tick(0.25); let mid = state.light_azimuth_deg; assert!((mid - start_az - 45.0).abs() < 1e-3); state.release_light(LightAxis::Azimuth); state.tick(0.25); assert_eq!(state.light_azimuth_deg, mid); state.hold_light(LightAxis::Elevation, HoldSign::Positive); state.tick(0.25); // +15° assert!(state.light_elevation_deg <= LIGHT_ELEVATION_MAX_DEG); // Drive to clamp state.light_elevation_deg = LIGHT_ELEVATION_MAX_DEG - 1.0; state.tick(0.25); assert_eq!(state.light_elevation_deg, LIGHT_ELEVATION_MAX_DEG); state.on_interaction_cancel(); assert!(state.hold_azimuth.is_none() && state.hold_elevation.is_none()); state.light_azimuth_deg = 350.0; state.hold_light(LightAxis::Azimuth, HoldSign::Positive); state.tick(0.2); // +18 → 368 → 8 assert!((state.light_azimuth_deg - 8.0).abs() < 1e-3); } #[test] fn light_direction_convention_front_and_up() { let front = light_direction_view(0.0, 0.0); assert!((front[0]).abs() < 1e-5); assert!((front[1]).abs() < 1e-5); assert!((front[2] - 1.0).abs() < 1e-5); let up = light_direction_view(0.0, 90.0); assert!((up[1] - 1.0).abs() < 1e-5); let leftish = light_direction_view(90.0, 0.0); assert!((leftish[0] - 1.0).abs() < 1e-5); } #[test] fn missing_frame_is_diagnostic_not_silent_substitute() { let project = Temp::new(); let dir = project.0.join("renders/male-body-atlas"); write_run( &dir, "demo", "body", None, b"shared-body", &[("walk", 2, 0.5)], pixel( [10, 20, 30, 255], [128, 128, 255, 255], [0, 0, 0, 255], 20000, ), ); let catalog = discover_completed_runs(&project.0); let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap(); let req = ComposeRequest { body: &body, equipment: &[], clip_token: "clip000-walk", frame: 1, pitch: 60, yaw: 330, light: Light::default(), view_mode: ViewMode::Resultaat, }; assert_eq!(compose_preview_frame(req.clone()).unwrap().width, 1); let err = compose_preview_frame(ComposeRequest { frame: 2, ..req }).unwrap_err(); match err { Preview2dError::Missing { set, clip, frame, .. } => { assert_eq!(set, "body"); assert_eq!(clip, "clip000-walk"); assert_eq!(frame, 2); } other => panic!("expected Missing, got {other}"), } } #[test] fn catalog_compatibility_filters_equipment_by_export_contract() { let project = Temp::new(); let renders = project.0.join("renders"); let clips = [("bind", 1u32, 0.0f32)]; let body_bytes = b"shared-body"; write_run( &renders.join("male-body-atlas"), "proj-a", "body", None, body_bytes, &clips, pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000), ); write_run( &renders.join("male-sword-atlas"), "proj-a", "sword", Some(&digest::sha256_hex(b"sword")), body_bytes, &clips, pixel( [200, 10, 10, 255], [255, 128, 128, 255], [180, 180, 180, 255], 500, ), ); // Different body fingerprint → incompatible. write_run( &renders.join("male-helm-atlas"), "proj-a", "helm", Some(&digest::sha256_hex(b"helm")), b"other-body", &clips, pixel([9, 9, 9, 255], [128, 128, 255, 255], [0, 0, 0, 255], 800), ); let catalog = discover_completed_runs(&project.0); assert_eq!(catalog.len(), 3); let allowed = compatible_equipment_sets(&catalog, Sex::Male); assert_eq!(allowed, vec!["sword".to_string()]); assert!(open_selection(&catalog, Sex::Male, &["helm".to_string()]).is_err()); let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap(); assert_eq!(body.ppda().settype, "body"); assert_eq!(gear.len(), 1); let lit = compose_preview_frame(ComposeRequest { body: &body, equipment: &gear, clip_token: "clip000-bind", frame: 0, pitch: -60, yaw: 90, light: Light::default(), view_mode: ViewMode::Resultaat, }) .unwrap(); assert_eq!(lit.rgba[3], 255); // Foreground equipment (closer depth) wins. assert!(lit.rgba[0] > 100); } #[test] fn playback_loops_on_frames_over_fps_not_source_duration() { // Non-integer duration: 1.1s at 4 fps → ceil = 5 frames. // Each frame must last a full 1/fps = 0.25s, so the loop wraps at 1.25s // (not at 1.1s, which would shorten the last frame to 0.1s). let mut state = Preview2dState { fps: 4, clip_frames: 5, clip_duration: 1.1, playback_speed: 1.0, playing: true, elapsed_secs: 0.0, ..Preview2dState::default() }; // Frames 0..3 occupy [0.00,1.00); frame 4 occupies [1.00,1.25). state.elapsed_secs = 1.0; assert_eq!(state.frame_index(), 4, "last frame starts at 1.0s"); state.tick(0.2); // 1.20 — still last frame assert!((state.elapsed_secs - 1.2).abs() < 1e-5); assert_eq!(state.frame_index(), 4, "last frame must last full 0.25s"); state.tick(0.05); // 1.25 → wrap to 0 assert!( state.elapsed_secs < 1e-5, "wrap at frames/fps=1.25, got {}", state.elapsed_secs ); assert_eq!(state.frame_index(), 0); // Old bug: wrapping on clip_duration=1.1 would have wrapped at 1.1 → never a full last frame. state.elapsed_secs = 1.05; assert_eq!(state.frame_index(), 4); state.tick(0.1); // 1.15 — still within 1.25 loop (would have wrapped under the bug) assert!( (state.elapsed_secs - 1.15).abs() < 1e-5, "must NOT wrap on source duration 1.1; elapsed={}", state.elapsed_secs ); assert_eq!(state.frame_index(), 4); } #[test] fn azimuth_hold_wraps_full_circle_continuously() { let mut state = Preview2dState { light_azimuth_deg: 10.0, ..Preview2dState::default() }; state.hold_light(LightAxis::Azimuth, HoldSign::Positive); // 90°/s * many ticks past 360. for _ in 0..20 { state.tick(0.25); // +22.5 each, capped dt } // 20 * 22.5 = 450 → 10+450 = 460 → 100 assert!( (state.light_azimuth_deg - 100.0).abs() < 1e-2, "got {}", state.light_azimuth_deg ); assert!(state.light_azimuth_deg >= 0.0 && state.light_azimuth_deg < 360.0); // Negative wrap state.light_azimuth_deg = 5.0; state.hold_light(LightAxis::Azimuth, HoldSign::Negative); state.tick(0.25); // -22.5 → -17.5 → 342.5 assert!((state.light_azimuth_deg - 342.5).abs() < 1e-2); assert_eq!(wrap_azimuth_deg(-0.1), 359.9); assert_eq!(wrap_azimuth_deg(360.0), 0.0); assert_eq!(wrap_azimuth_deg(720.5), 0.5); } #[test] fn view_modes_show_composited_maps_unlit_except_resultaat() { let project = Temp::new(); // Distinct channels so modes are distinguishable; scalar specular. write_run( &project.0.join("renders/male-body-atlas"), "demo", "body", None, b"shared-body", &[("bind", 1, 0.0)], pixel( [10, 20, 30, 255], [40, 50, 60, 255], [70, 70, 70, 255], 32768, ), ); let catalog = discover_completed_runs(&project.0); let (run, _) = open_selection(&catalog, Sex::Male, &[]).unwrap(); let base = ComposeRequest { body: &run, equipment: &[], clip_token: "clip000-bind", frame: 0, pitch: 0, yaw: 0, light: Light::default(), view_mode: ViewMode::Resultaat, }; let diffuse = compose_preview_frame(ComposeRequest { view_mode: ViewMode::DiffuseOnly, ..base.clone() }) .unwrap(); assert_eq!(&diffuse.rgba[0..4], &[10, 20, 30, 255]); let normal = compose_preview_frame(ComposeRequest { view_mode: ViewMode::NormalOnly, ..base.clone() }) .unwrap(); assert_eq!(&normal.rgba[0..4], &[40, 50, 60, 255]); let specular = compose_preview_frame(ComposeRequest { view_mode: ViewMode::SpecularOnly, ..base.clone() }) .unwrap(); assert_eq!(&specular.rgba[0..4], &[70, 70, 70, 255]); let depth = compose_preview_frame(ComposeRequest { view_mode: ViewMode::DepthOnly, ..base.clone() }) .unwrap(); // 32768/65535 ≈ 0.5 → ~128 assert_eq!(depth.rgba[0], depth.rgba[1]); assert_eq!(depth.rgba[1], depth.rgba[2]); assert!((depth.rgba[0] as i32 - 128).abs() <= 1); assert_eq!(depth.rgba[3], 255); let result = compose_preview_frame(ComposeRequest { view_mode: ViewMode::Resultaat, ..base.clone() }) .unwrap(); // Relit differs from raw diffuse under default light. assert_ne!(&result.rgba[0..3], &diffuse.rgba[0..3]); assert_eq!(result.rgba[3], 255); // Light must not change map modes. let dark = Light { ambient: 0.0, diffuse: 0.0, specular: 0.0, ..Light::default() }; let diffuse_dark = compose_preview_frame(ComposeRequest { light: dark, view_mode: ViewMode::DiffuseOnly, ..base }) .unwrap(); assert_eq!(diffuse_dark.rgba, diffuse.rgba); } #[test] fn discover_skips_incomplete_and_wrong_schema() { let project = Temp::new(); let renders = project.0.join("renders"); let incomplete = renders.join("male-body-atlas"); fs::create_dir_all(&incomplete).unwrap(); fs::write(incomplete.join("render.json"), b"{}").unwrap(); // no .ppda at all fs::create_dir_all(renders.join("run-legacy")).unwrap(); fs::create_dir_all(renders.join("inprogress-1-1-male-body")).unwrap(); let (catalog, skipped) = discover_exports(&project.0); assert!(catalog.is_empty()); assert_eq!(skipped.len(), 2, "{skipped:?}"); assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1); assert!(skipped[1].1.contains("oude run-map"), "{}", skipped[1].1); } #[test] fn preview_reads_atlas_not_loose_pngs() { // Each view gets a distinct colour; the preview must return exactly the // atlas cell even after every loose PNG is deleted. let project = Temp::new(); let dir = project.0.join("renders/male-body-atlas"); let mut spec = SyntheticRun::body("demo", "male", b"b"); spec.clips = vec![("walk".into(), 2, 0.5)]; spec.target = [2, 3]; spec.write(&dir, |_, f, p, y| { pixel( [(y / 30) as u8 * 20, (p + 60) as u8, f as u8 * 100 + 1, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000, ) }) .unwrap(); fs::remove_dir_all(dir.join("raw files")).unwrap(); let catalog = discover_completed_runs(&project.0); assert_eq!(catalog.len(), 1); assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS); let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap(); for (frame, pitch, yaw) in [(0u32, -60i32, 0u32), (1, 60, 330), (1, -30, 150)] { let img = compose_preview_frame(ComposeRequest { body: &body, equipment: &[], clip_token: "clip000-walk", frame, pitch, yaw, light: Light::default(), view_mode: ViewMode::DiffuseOnly, }) .unwrap(); assert_eq!((img.width, img.height), (2, 3)); let want = [ (yaw / 30) as u8 * 20, (pitch + 60) as u8, frame as u8 * 100 + 1, 255, ]; for px in img.rgba.chunks_exact(4) { assert_eq!(px, want); } } } #[test] fn depth_view_shows_stored_pixels_as_is_resultaat_still_decodes() { use crate::camera::{DepthEncoding, DepthFormat}; let depth = 40000u16; let mut gray16_result = None; for bits in [16u8, 8] { for encoding in DepthEncoding::ALL { let fmt = DepthFormat::new(bits, encoding).unwrap(); let project = Temp::new(); let dir = project.0.join("renders/male-body-atlas"); let mut spec = SyntheticRun::body("demo", "male", b"b"); spec.clips = vec![("walk".into(), 1, 0.5)]; spec.target = [2, 2]; spec.depth_format = fmt; spec.write(&dir, |_, _, _, _| { pixel( [200, 100, 50, 255], [128, 128, 255, 255], [0, 0, 0, 255], depth, ) }) .unwrap(); let catalog = discover_completed_runs(&project.0); let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap(); let request = |view_mode| ComposeRequest { body: &body, equipment: &[], clip_token: "clip000-walk", frame: 0, pitch: 0, yaw: 0, light: Light::default(), view_mode, }; let shown = compose_preview_frame(request(ViewMode::DepthOnly)).unwrap(); // Expected: the stored channels, untranslated; 16-bit → 8-bit display. let raw = fmt.encode_value(f64::from(depth) / 65535.); let to8 = |v: u64| { if bits == 16 { ((v * 255 + 32767) / 65535) as u8 } else { v as u8 } }; let want = match encoding { DepthEncoding::Gray => [to8(raw[0]), to8(raw[0]), to8(raw[0]), 255], _ => [to8(raw[0]), to8(raw[1]), to8(raw[2]), 255], }; for px in shown.rgba.chunks_exact(4) { assert_eq!(px, want, "{fmt:?}"); } // 16-bit rgb_packed of a 16-bit canonical value d stores d in every // channel (d/65535 repeats in base 65536), so only check the others. if encoding == DepthEncoding::RgbBands || (encoding == DepthEncoding::RgbPacked && bits == 8) { assert!( want[0] != want[1] || want[1] != want[2], "{fmt:?}: raw RGB must not look like decoded gray: {want:?}" ); } // Resultaat decodes depth: same relit image as gray16 within 1 level. let lit = compose_preview_frame(request(ViewMode::Resultaat)).unwrap(); if bits == 16 { match &gray16_result { None => gray16_result = Some(lit.rgba.clone()), Some(reference) => { for (a, b) in lit.rgba.iter().zip(reference) { assert!((*a as i32 - *b as i32).abs() <= 1, "{fmt:?}"); } } } } } } } #[test] fn five_pitch_rows_clamp_and_default_is_zero() { let mut state = Preview2dState::default(); assert_eq!(state.pitch_deg(), 0); state.step_pitch(1); state.step_pitch(1); assert_eq!(state.pitch_deg(), 60); state.step_pitch(1); assert_eq!(state.pitch_deg(), 60); for _ in 0..10 { state.step_pitch(-1); } assert_eq!(state.pitch_deg(), -60); assert_eq!(pitch_row_from_deg(30), 3); assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW); assert_eq!(state.fps, crate::project::DEFAULT_FPS); } /// Regression (t964u): a light button held for several seconds keeps /// rotating (egui click sense alone stopped after ~1 s). #[test] fn light_hold_button_keeps_rotating_for_multi_second_hold() { use eframe::egui; let ctx = egui::Context::default(); let rect = std::cell::Cell::new(egui::Rect::NOTHING); let mut state = Preview2dState::default(); let start_az = state.light_azimuth_deg; let dt = 1.0 / 60.0; let frame = |events: Vec, t: f64, state: &mut Preview2dState| { let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(400., 300.), )), time: Some(t), predicted_dt: dt, focused: true, events, ..Default::default() }; let mut held = false; let _ = ctx.run(input, |ctx| { egui::CentralPanel::default().show(ctx, |ui| { let r = ui.button("Azimut +"); rect.set(r.rect); held = ui.ctx().input(|i| raw_pointer_hold(r.rect, i)); }); }); if held { state.hold_light(LightAxis::Azimuth, HoldSign::Positive); } else { state.release_light(LightAxis::Azimuth); } held }; // Layout frame to learn the button rect. assert!(!frame(vec![], 0.0, &mut state)); let at = rect.get().center(); let press = vec![ egui::Event::PointerMoved(at), egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default(), }, ]; assert!(frame(press, dt as f64, &mut state)); // 3.5 s without any further input events, button still physically down. let frames = 210; for n in 0..frames { state.tick(dt); assert!( frame(vec![], (n + 2) as f64 * dt as f64, &mut state), "hold dropped after {:.2} s", n as f32 * dt ); } let expected = wrap_azimuth_deg(start_az + LIGHT_AZIMUTH_RATE_DEG_PER_SEC * dt * frames as f32); let diff = (state.light_azimuth_deg - expected).abs(); assert!( diff.min(360. - diff) < 0.5, "{} vs {expected}", state.light_azimuth_deg ); // Release stops it. let release = vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default(), }]; assert!(!frame(release, 4.0, &mut state)); assert!(state.hold_azimuth.is_none()); // Pointer leaving the button / window stops a hold too. let press = vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default(), }]; assert!(frame(press, 4.1, &mut state)); assert!(!frame(vec![egui::Event::PointerGone], 4.2, &mut state)); // A press that started elsewhere does not count when dragged onto it. let away = egui::pos2(390., 290.); let drag = vec![ egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default(), }, egui::Event::PointerMoved(away), ]; frame(drag, 4.3, &mut state); let press = vec![egui::Event::PointerButton { pos: away, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default(), }]; assert!(!frame(press, 4.4, &mut state)); assert!(!frame(vec![egui::Event::PointerMoved(at)], 4.5, &mut state)); } }