BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

preview2d.rs 39.0 KiB Download Raw
1//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
2//!
3//! Consumes completed exports through their `.ppda` + per-map atlases
4//! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and
5//! `compositor`. Loose PNGs are not read here.
6//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::atlas::{AtlasSet, LoadedAtlas};
13use crate::compositor::{
14 assert_compatible_keys, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,
15 LayerMaps, Light,
16};
17use crate::export_set::ExportError;
18use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
19use crate::render_job::ImageBuf;
20use crate::session::Sex;
21
22/// Degrees per second while a light hold button stays pressed.
23pub const LIGHT_AZIMUTH_RATE_DEG_PER_SEC: f32 = 90.0;
24pub const LIGHT_ELEVATION_RATE_DEG_PER_SEC: f32 = 60.0;
25
26/// Elevation clamp (degrees). Not cyclic; soft limit keeps the light off the horizon flip.
27pub const LIGHT_ELEVATION_MIN_DEG: f32 = -80.0;
28pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;
29
30/// Default light pose: slightly above and from the front (+Z in view space).
31pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
32pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
33
34/// Row of pitch 0° in [`PAPERDOLL_PITCHES_DEG`] (−60,−30,0,+30,+60).
35pub const ZERO_PITCH_ROW: i32 = 2;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum LightAxis {
39 Azimuth,
40 Elevation,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum HoldSign {
45 Positive,
46 Negative,
47}
48
49impl HoldSign {
50 pub fn as_f32(self) -> f32 {
51 match self {
52 Self::Positive => 1.0,
53 Self::Negative => -1.0,
54 }
55 }
56}
57
58/// What the 2D preview shows for the current composed pose.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum ViewMode {
61 /// Relit composite (ambient + Lambert + Blinn-Phong). Default.
62 #[default]
63 Resultaat,
64 /// Depth-composited diffuse albedo, unlit.
65 DiffuseOnly,
66 /// Depth-composited normal map as stored RGB, unlit.
67 NormalOnly,
68 /// Depth-composited depth as grayscale, unlit.
69 DepthOnly,
70 /// Depth-composited specular map as stored RGB, unlit.
71 SpecularOnly,
72}
73
74impl ViewMode {
75 pub fn label_nl(self) -> &'static str {
76 match self {
77 Self::Resultaat => "Resultaat",
78 Self::DiffuseOnly => "Alleen diffuse",
79 Self::NormalOnly => "Alleen normal map",
80 Self::DepthOnly => "Alleen depth map",
81 Self::SpecularOnly => "Alleen specular map",
82 }
83 }
84
85 pub fn all() -> [Self; 5] {
86 [
87 Self::Resultaat,
88 Self::DiffuseOnly,
89 Self::NormalOnly,
90 Self::DepthOnly,
91 Self::SpecularOnly,
92 ]
93 }
94
95 pub fn uses_light(self) -> bool {
96 matches!(self, Self::Resultaat)
97 }
98}
99
100/// Render a composed map mode to RGBA8 (unlit inspection).
101pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf {
102 let pixels = (composed.width as usize) * (composed.height as usize);
103 let mut rgba = vec![0u8; pixels * 4];
104 match mode {
105 ViewMode::Resultaat => {
106 // Caller should use relight; fall back to diffuse if misused.
107 rgba.copy_from_slice(&composed.diffuse);
108 }
109 ViewMode::DiffuseOnly => {
110 rgba.copy_from_slice(&composed.diffuse);
111 }
112 ViewMode::NormalOnly => {
113 rgba.copy_from_slice(&composed.normal);
114 }
115 ViewMode::SpecularOnly => {
116 rgba.copy_from_slice(&composed.specular);
117 }
118 ViewMode::DepthOnly => {
119 for i in 0..pixels {
120 let o = i * 4;
121 if composed.winner[i] == u8::MAX {
122 continue;
123 }
124 let gray = composed.depth[i * 2];
125 let alpha16 = composed.depth[i * 2 + 1];
126 let g = ((u32::from(gray) * 255 + 32767) / 65535) as u8;
127 let a = ((u32::from(alpha16) * 255 + 32767) / 65535) as u8;
128 rgba[o] = g;
129 rgba[o + 1] = g;
130 rgba[o + 2] = g;
131 rgba[o + 3] = a;
132 }
133 }
134 }
135 ImageBuf {
136 width: composed.width,
137 height: composed.height,
138 rgba,
139 }
140}
141
142/// One export (`.ppda` + atlases) discovered under a project `renders/` tree.
143#[derive(Debug, Clone)]
144pub struct CatalogEntry {
145 /// Run directory containing the `.ppda`.
146 pub path: PathBuf,
147 pub sex: String,
148 pub set: String,
149 pub project_id: String,
150 pub fps: u32,
151 pub target: [u32; 2],
152 pub clips: Vec<(String, String, u32, f32)>,
153 pub body_model_sha256: String,
154 pub equipment_preset_sha256: Option<String>,
155 pub key: CompatKey,
156}
157
158impl CatalogEntry {
159 pub fn from_atlas(path: PathBuf, set: &AtlasSet) -> Self {
160 let p = &set.ppda;
161 Self {
162 path,
163 sex: p.sex.clone(),
164 set: p.settype.clone(),
165 project_id: p.project.id.clone(),
166 fps: p.fps,
167 target: [p.cell_width, p.cell_height],
168 clips: p
169 .clips
170 .iter()
171 .map(|c| (c.token.clone(), c.name.clone(), c.frame_count, c.duration))
172 .collect(),
173 body_model_sha256: p.identity.body_model_sha256.clone(),
174 equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(),
175 key: CompatKey::from_ppda(p),
176 }
177 }
178
179 pub fn is_body(&self) -> bool {
180 self.set == "body"
181 }
182}
183
184/// Run directories under `renders/` that were skipped, with the reason.
185pub type Skipped = Vec<(PathBuf, String)>;
186
187/// Scan `project_root/renders/*/` and open every export whose `.ppda` and
188/// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,
189/// damaged) are skipped.
190pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
191 discover_exports(project_root).0
192}
193
194/// Like [`discover_completed_runs`], also returning skipped runs + reasons.
195pub fn discover_exports(project_root: &Path) -> (Vec<CatalogEntry>, Skipped) {
196 let renders = project_root.join("renders");
197 let Ok(entries) = fs::read_dir(&renders) else {
198 return (Vec::new(), Vec::new());
199 };
200 let mut out = Vec::new();
201 let mut skipped = Vec::new();
202 for entry in entries.flatten() {
203 let path = entry.path();
204 if !path.is_dir() {
205 continue;
206 }
207 match AtlasSet::open_run(&path) {
208 Ok(set) => out.push(CatalogEntry::from_atlas(path, &set)),
209 Err(err) => skipped.push((path, err.to_string())),
210 }
211 }
212 out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));
213 skipped.sort();
214 (out, skipped)
215}
216
217/// Discrete yaw index 0..11 → degrees 0,30,…,330.
218pub fn yaw_deg_from_index(index: i32) -> u32 {
219 let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as u32;
220 wrapped * 30
221}
222
223pub fn pitch_deg_from_row(row: i32) -> i32 {
224 let row = row.clamp(0, (PAPERDOLL_PITCHES_DEG.len() as i32) - 1) as usize;
225 PAPERDOLL_PITCHES_DEG[row] as i32
226}
227
228pub fn pitch_row_from_deg(pitch: i32) -> i32 {
229 PAPERDOLL_PITCHES_DEG
230 .iter()
231 .position(|p| *p as i32 == pitch)
232 .unwrap_or(ZERO_PITCH_ROW as usize) as i32
233}
234
235/// View-space light direction toward the light.
236///
237/// Convention (right-hand, view +Y up, camera looks −Z toward the character so
238/// character front faces +Z in view):
239/// - azimuth 0° = light from +Z (in front of the character)
240/// - positive azimuth rotates toward +X (character's left when facing camera)
241/// - elevation 0° = horizon; positive raises the light
242pub fn light_direction_view(azimuth_deg: f32, elevation_deg: f32) -> [f32; 3] {
243 let az = azimuth_deg.to_radians();
244 let el = elevation_deg.to_radians();
245 let cos_el = el.cos();
246 [cos_el * az.sin(), el.sin(), cos_el * az.cos()]
247}
248
249pub fn wrap_azimuth_deg(deg: f32) -> f32 {
250 let mut d = deg % 360.0;
251 if d < 0.0 {
252 d += 360.0;
253 }
254 d
255}
256
257pub fn clamp_elevation_deg(deg: f32) -> f32 {
258 deg.clamp(LIGHT_ELEVATION_MIN_DEG, LIGHT_ELEVATION_MAX_DEG)
259}
260
261fn wrap_time(time: f32, duration: f32) -> f32 {
262 if !(duration.is_finite() && duration > 0.0 && time.is_finite()) {
263 return 0.0;
264 }
265 let mut t = time % duration;
266 if t < 0.0 {
267 t += duration;
268 }
269 t
270}
271
272/// Playback / view / light state for the rendered 2D tab.
273#[derive(Debug, Clone)]
274pub struct Preview2dState {
275 pub sex: Sex,
276 /// Equipment set names currently selected (body is always included when present).
277 pub selected_equipment: Vec<String>,
278 pub clip_token: String,
279 pub clip_frames: u32,
280 pub clip_duration: f32,
281 pub fps: u32,
282 pub playback_speed: f32,
283 pub playing: bool,
284 /// Elapsed animation seconds (preserved across yaw/pitch changes).
285 pub elapsed_secs: f32,
286 pub yaw_index: i32,
287 pub pitch_row: i32,
288 pub light_azimuth_deg: f32,
289 pub light_elevation_deg: f32,
290 pub hold_azimuth: Option<HoldSign>,
291 pub hold_elevation: Option<HoldSign>,
292 /// Preview display mode (Resultaat vs raw composed maps).
293 pub view_mode: ViewMode,
294 pub diagnostic: Option<String>,
295}
296
297impl Default for Preview2dState {
298 fn default() -> Self {
299 Self {
300 sex: Sex::Male,
301 selected_equipment: Vec::new(),
302 clip_token: String::new(),
303 clip_frames: 1,
304 clip_duration: 0.0,
305 fps: crate::project::DEFAULT_FPS,
306 playback_speed: 1.0,
307 playing: true,
308 elapsed_secs: 0.0,
309 yaw_index: 0,
310 pitch_row: ZERO_PITCH_ROW,
311 light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,
312 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
313 hold_azimuth: None,
314 hold_elevation: None,
315 view_mode: ViewMode::Resultaat,
316 diagnostic: None,
317 }
318 }
319}
320
321impl Preview2dState {
322 pub fn sex_token(&self) -> &'static str {
323 match self.sex {
324 Sex::Male => "male",
325 Sex::Female => "female",
326 }
327 }
328
329 pub fn yaw_deg(&self) -> u32 {
330 yaw_deg_from_index(self.yaw_index)
331 }
332
333 pub fn pitch_deg(&self) -> i32 {
334 pitch_deg_from_row(self.pitch_row)
335 }
336
337 pub fn frame_index(&self) -> u32 {
338 let frames = self.clip_frames.max(1);
339 if self.fps == 0 {
340 return 0;
341 }
342 // `elapsed_secs` already advances by `dt * playback_speed` in `tick`.
343 let samples = self.elapsed_secs * self.fps as f32;
344 if !samples.is_finite() || samples < 0.0 {
345 return 0;
346 }
347 (samples.floor() as u64 % u64::from(frames)) as u32
348 }
349
350 pub fn set_clip(&mut self, token: String, frames: u32, duration: f32) {
351 if self.clip_token != token {
352 self.clip_token = token;
353 self.elapsed_secs = 0.0;
354 }
355 self.clip_frames = frames.max(1);
356 self.clip_duration = if duration.is_finite() && duration >= 0.0 {
357 duration
358 } else {
359 0.0
360 };
361 self.diagnostic = None;
362 }
363
364 pub fn step_yaw(&mut self, delta: i32) {
365 self.yaw_index = (self.yaw_index + delta).rem_euclid(PAPERDOLL_YAW_COUNT as i32);
366 // Animation time intentionally preserved.
367 }
368
369 pub fn step_pitch(&mut self, delta: i32) {
370 self.pitch_row = (self.pitch_row + delta).clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1);
371 // Animation time intentionally preserved; pitch is clamped, not cyclic.
372 }
373
374 pub fn hold_light(&mut self, axis: LightAxis, sign: HoldSign) {
375 match axis {
376 LightAxis::Azimuth => self.hold_azimuth = Some(sign),
377 LightAxis::Elevation => self.hold_elevation = Some(sign),
378 }
379 }
380
381 pub fn release_light(&mut self, axis: LightAxis) {
382 match axis {
383 LightAxis::Azimuth => self.hold_azimuth = None,
384 LightAxis::Elevation => self.hold_elevation = None,
385 }
386 }
387
388 pub fn release_all_holds(&mut self) {
389 self.hold_azimuth = None;
390 self.hold_elevation = None;
391 }
392
393 /// Stop continuous light motion (button release, focus loss, pointer cancel).
394 pub fn on_interaction_cancel(&mut self) {
395 self.release_all_holds();
396 }
397
398 pub fn light(&self) -> Light {
399 Light {
400 direction_view: light_direction_view(self.light_azimuth_deg, self.light_elevation_deg),
401 ..Light::default()
402 }
403 }
404
405 /// Advance playback and held light controls by `dt` seconds.
406 pub fn tick(&mut self, dt: f32) {
407 let dt = if dt.is_finite() && dt > 0.0 {
408 dt.min(0.25)
409 } else {
410 0.0
411 };
412 if self.playing && dt > 0.0 {
413 // Loop over exactly frames/fps so every exported frame, including the
414 // last, lasts a full 1/fps (not shortened by source clip_duration).
415 let duration = if self.fps > 0 {
416 self.clip_frames.max(1) as f32 / self.fps as f32
417 } else if self.clip_duration > 0.0 {
418 self.clip_duration
419 } else {
420 1.0
421 };
422 let speed = if self.playback_speed.is_finite() {
423 self.playback_speed.max(0.0)
424 } else {
425 0.0
426 };
427 self.elapsed_secs = wrap_time(self.elapsed_secs + dt * speed, duration);
428 }
429 if let Some(sign) = self.hold_azimuth {
430 self.light_azimuth_deg = wrap_azimuth_deg(
431 self.light_azimuth_deg + sign.as_f32() * LIGHT_AZIMUTH_RATE_DEG_PER_SEC * dt,
432 );
433 }
434 if let Some(sign) = self.hold_elevation {
435 self.light_elevation_deg = clamp_elevation_deg(
436 self.light_elevation_deg + sign.as_f32() * LIGHT_ELEVATION_RATE_DEG_PER_SEC * dt,
437 );
438 }
439 }
440
441 pub fn toggle_equipment(&mut self, set: &str) {
442 if set == "body" {
443 return;
444 }
445 if let Some(pos) = self.selected_equipment.iter().position(|s| s == set) {
446 self.selected_equipment.remove(pos);
447 } else {
448 self.selected_equipment.push(set.to_string());
449 self.selected_equipment.sort();
450 }
451 self.diagnostic = None;
452 }
453
454 pub fn set_sex(&mut self, sex: Sex) {
455 if self.sex != sex {
456 self.sex = sex;
457 self.selected_equipment.clear();
458 self.clip_token.clear();
459 self.elapsed_secs = 0.0;
460 self.diagnostic = None;
461 }
462 }
463}
464
465#[derive(Debug, Clone)]
466pub struct ComposeRequest<'a> {
467 pub body: &'a LoadedAtlas,
468 pub equipment: &'a [LoadedAtlas],
469 pub clip_token: &'a str,
470 pub frame: u32,
471 pub pitch: i32,
472 pub yaw: u32,
473 pub light: Light,
474 pub view_mode: ViewMode,
475}
476
477#[derive(Debug)]
478pub enum Preview2dError {
479 Compatible(ComposeError),
480 Export(ExportError),
481 Missing {
482 set: String,
483 clip: String,
484 frame: u32,
485 pitch: i32,
486 yaw: u32,
487 detail: String,
488 },
489 NoBody,
490}
491
492impl std::fmt::Display for Preview2dError {
493 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494 match self {
495 Self::Compatible(e) => write!(f, "{e}"),
496 Self::Export(e) => write!(f, "{e}"),
497 Self::Missing {
498 set,
499 clip,
500 frame,
501 pitch,
502 yaw,
503 detail,
504 } => write!(
505 f,
506 "ontbrekende maps voor set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}"
507 ),
508 Self::NoBody => write!(f, "geen body-exportset voor dit geslacht"),
509 }
510 }
511}
512
513impl std::error::Error for Preview2dError {}
514
515impl From<ComposeError> for Preview2dError {
516 fn from(value: ComposeError) -> Self {
517 Self::Compatible(value)
518 }
519}
520
521impl From<ExportError> for Preview2dError {
522 fn from(value: ExportError) -> Self {
523 Self::Export(value)
524 }
525}
526
527/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.
528pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
529 let mut atlases: Vec<&LoadedAtlas> = Vec::with_capacity(1 + req.equipment.len());
530 atlases.push(req.body);
531 atlases.extend(req.equipment.iter());
532 let keys: Vec<CompatKey> = atlases
533 .iter()
534 .map(|a| CompatKey::from_ppda(a.ppda()))
535 .collect();
536 assert_compatible_keys(&keys)?;
537 let mut layers = Vec::with_capacity(atlases.len());
538 for atlas in atlases {
539 let set = atlas.ppda().settype.as_str();
540 match LayerMaps::from_atlas(atlas, set, req.clip_token, req.frame, req.pitch, req.yaw) {
541 Ok(layer) => layers.push(layer),
542 Err(err) => {
543 return Err(Preview2dError::Missing {
544 set: set.to_string(),
545 clip: req.clip_token.to_string(),
546 frame: req.frame,
547 pitch: req.pitch,
548 yaw: req.yaw,
549 detail: err.to_string(),
550 });
551 }
552 }
553 }
554 if req.view_mode.uses_light() {
555 let (_composed, lit) = compose_and_relight(&layers, &req.light)?;
556 Ok(lit)
557 } else {
558 let composed = compose(&layers)?;
559 Ok(composed_map_image(&composed, req.view_mode))
560 }
561}
562
563/// Run directories (body first, then equipment in the given order) that
564/// [`open_selection`] would load; used as a cache key by the editor.
565pub fn selection_paths(
566 catalog: &[CatalogEntry],
567 sex: Sex,
568 equipment_sets: &[String],
569) -> Result<Vec<PathBuf>, Preview2dError> {
570 let sex_token = match sex {
571 Sex::Male => "male",
572 Sex::Female => "female",
573 };
574 let body = catalog
575 .iter()
576 .filter(|e| e.sex == sex_token && e.is_body())
577 .max_by_key(|e| e.path.clone())
578 .ok_or(Preview2dError::NoBody)?;
579 let mut paths = vec![body.path.clone()];
580 for set in equipment_sets {
581 let entry = catalog
582 .iter()
583 .filter(|e| e.sex == sex_token && e.set == *set && !e.is_body())
584 .max_by_key(|e| e.path.clone())
585 .ok_or_else(|| Preview2dError::Missing {
586 set: set.clone(),
587 clip: String::new(),
588 frame: 0,
589 pitch: 0,
590 yaw: 0,
591 detail: "geen voltooide exportset voor dit equipment".into(),
592 })?;
593 paths.push(entry.path.clone());
594 }
595 Ok(paths)
596}
597
598/// Open and decode the `.ppda` atlases of body + selected equipment for one
599/// sex, re-validating checksums and compatibility.
600pub fn open_selection(
601 catalog: &[CatalogEntry],
602 sex: Sex,
603 equipment_sets: &[String],
604) -> Result<(LoadedAtlas, Vec<LoadedAtlas>), Preview2dError> {
605 let paths = selection_paths(catalog, sex, equipment_sets)?;
606 let mut loaded = Vec::with_capacity(paths.len());
607 for path in &paths {
608 loaded.push(AtlasSet::open_run(path)?.load()?);
609 }
610 let keys: Vec<CompatKey> = loaded
611 .iter()
612 .map(|a| CompatKey::from_ppda(a.ppda()))
613 .collect();
614 assert_compatible_keys(&keys)?;
615 let body = loaded.remove(0);
616 Ok((body, loaded))
617}
618
619/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).
620pub fn compatible_equipment_sets(catalog: &[CatalogEntry], sex: Sex) -> Vec<String> {
621 let sex_token = match sex {
622 Sex::Male => "male",
623 Sex::Female => "female",
624 };
625 let Some(body) = catalog
626 .iter()
627 .filter(|e| e.sex == sex_token && e.is_body())
628 .max_by_key(|e| e.path.clone())
629 else {
630 return Vec::new();
631 };
632 let mut names = BTreeMap::new();
633 for entry in catalog {
634 if entry.sex != sex_token || entry.is_body() {
635 continue;
636 }
637 if entry.key.project_id != body.key.project_id
638 || entry.key.sex != body.key.sex
639 || entry.key.body_model_sha256 != body.key.body_model_sha256
640 || entry.key.target != body.key.target
641 || entry.key.fps != body.key.fps
642 || entry.key.framing != body.key.framing
643 || (entry.key.near - body.key.near).abs() > f32::EPSILON
644 || (entry.key.far - body.key.far).abs() > f32::EPSILON
645 || entry.key.clip_tokens != body.key.clip_tokens
646 {
647 continue;
648 }
649 names.insert(entry.set.clone(), ());
650 }
651 names.into_keys().collect()
652}
653
654/// Apply catalog defaults when sex/clip inventory changes.
655pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEntry]) {
656 let sex_token = state.sex_token();
657 let body = catalog
658 .iter()
659 .filter(|e| e.sex == sex_token && e.is_body())
660 .max_by_key(|e| e.path.clone());
661 let Some(body) = body else {
662 state.diagnostic = Some("geen voltooide body-export voor dit geslacht".into());
663 return;
664 };
665 state.fps = body.fps;
666 let allowed = compatible_equipment_sets(catalog, state.sex);
667 state
668 .selected_equipment
669 .retain(|s| allowed.iter().any(|a| a == s));
670 if state.clip_token.is_empty() || !body.clips.iter().any(|(t, _, _, _)| t == &state.clip_token)
671 {
672 if let Some((token, _name, frames, duration)) = body.clips.first() {
673 state.set_clip(token.clone(), *frames, *duration);
674 }
675 } else if let Some((_, _, frames, duration)) = body
676 .clips
677 .iter()
678 .find(|(t, _, _, _)| t == &state.clip_token)
679 {
680 state.clip_frames = (*frames).max(1);
681 state.clip_duration = *duration;
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::digest;
689 use crate::export_fixture::{Pixel, SyntheticRun};
690 use crate::project::CameraFraming;
691 use std::sync::atomic::{AtomicU64, Ordering};
692
693 static COUNTER: AtomicU64 = AtomicU64::new(0);
694
695 struct Temp(PathBuf);
696 impl Temp {
697 fn new() -> Self {
698 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
699 let path = std::env::temp_dir().join(format!(
700 "paperdoll-preview2d-{}-{}-{}",
701 std::process::id(),
702 n,
703 std::time::SystemTime::now()
704 .duration_since(std::time::UNIX_EPOCH)
705 .unwrap()
706 .as_nanos()
707 ));
708 fs::create_dir_all(&path).unwrap();
709 Self(path)
710 }
711 }
712 impl Drop for Temp {
713 fn drop(&mut self) {
714 let _ = fs::remove_dir_all(&self.0);
715 }
716 }
717
718 fn framing() -> CameraFraming {
719 CameraFraming {
720 height_milli: 1800,
721 center_y_milli: 900,
722 pivot_y_milli: Some(1500),
723 }
724 }
725
726 fn pixel(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
727 Pixel {
728 diffuse,
729 normal,
730 specular,
731 depth,
732 }
733 }
734
735 /// Complete synthetic run (all 60 views) with uniform maps per run.
736 #[allow(clippy::too_many_arguments)]
737 fn write_run(
738 root: &Path,
739 project_id: &str,
740 set: &str,
741 equipment_sha: Option<&str>,
742 body_bytes: &[u8],
743 clips: &[(&str, u32, f32)],
744 px: Pixel,
745 ) {
746 let mut spec = SyntheticRun::body(project_id, "male", body_bytes);
747 spec.framing = framing();
748 spec.set = set.into();
749 spec.equipment_sha256 = equipment_sha.map(str::to_string);
750 spec.clips = clips
751 .iter()
752 .map(|(n, f, d)| (n.to_string(), *f, *d))
753 .collect();
754 spec.write(root, |_, _, _, _| px).unwrap();
755 }
756
757 #[test]
758 fn yaw_wraps_twelve_steps_pitch_clamps_five_rows() {
759 let mut state = Preview2dState {
760 elapsed_secs: 1.25,
761 ..Preview2dState::default()
762 };
763 assert_eq!(state.yaw_deg(), 0);
764 state.step_yaw(-1);
765 assert_eq!(state.yaw_deg(), 330);
766 assert_eq!(state.elapsed_secs, 1.25);
767 state.step_yaw(1);
768 assert_eq!(state.yaw_deg(), 0);
769 for _ in 0..12 {
770 state.step_yaw(1);
771 }
772 assert_eq!(state.yaw_deg(), 0);
773 assert_eq!(PAPERDOLL_YAW_COUNT, 12);
774
775 state.pitch_row = ZERO_PITCH_ROW;
776 state.step_pitch(1);
777 assert_eq!(state.pitch_deg(), 30);
778 state.step_pitch(1);
779 assert_eq!(state.pitch_deg(), 60);
780 state.step_pitch(1);
781 assert_eq!(state.pitch_deg(), 60);
782 state.step_pitch(-3);
783 assert_eq!(state.pitch_deg(), -30);
784 state.step_pitch(-1);
785 assert_eq!(state.pitch_deg(), -60);
786 state.step_pitch(-1);
787 assert_eq!(state.pitch_deg(), -60);
788 assert_eq!(state.elapsed_secs, 1.25);
789 }
790
791 #[test]
792 fn playback_accumulates_elapsed_and_loops_by_fps() {
793 let mut state = Preview2dState {
794 fps: 10,
795 clip_frames: 4,
796 clip_duration: 0.4,
797 playback_speed: 1.0,
798 playing: true,
799 ..Preview2dState::default()
800 };
801 assert_eq!(state.frame_index(), 0);
802 state.tick(0.15);
803 assert_eq!(state.frame_index(), 1);
804 state.tick(0.25);
805 assert_eq!(state.frame_index(), 0); // wrapped: 0.4s * 10fps = frame 0 again after loop
806 // 0.15+0.25=0.40 → wrap to 0 → frame 0
807 state.playing = false;
808 let frozen = state.elapsed_secs;
809 state.tick(1.0);
810 assert_eq!(state.elapsed_secs, frozen);
811 state.playing = true;
812 state.playback_speed = 2.0;
813 state.elapsed_secs = 0.0;
814 state.tick(0.05);
815 // elapsed += 0.05*2 = 0.10; samples = 0.10*10 = 1 → frame 1
816 assert_eq!(state.frame_index(), 1);
817 }
818
819 #[test]
820 fn light_hold_uses_dt_and_stops_on_release_or_cancel() {
821 let mut state = Preview2dState::default();
822 let start_az = state.light_azimuth_deg;
823 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
824 // Per-tick dt is capped at 0.25s (hitch guard).
825 state.tick(0.25);
826 state.tick(0.25);
827 let mid = state.light_azimuth_deg;
828 assert!((mid - start_az - 45.0).abs() < 1e-3);
829 state.release_light(LightAxis::Azimuth);
830 state.tick(0.25);
831 assert_eq!(state.light_azimuth_deg, mid);
832
833 state.hold_light(LightAxis::Elevation, HoldSign::Positive);
834 state.tick(0.25); // +15°
835 assert!(state.light_elevation_deg <= LIGHT_ELEVATION_MAX_DEG);
836 // Drive to clamp
837 state.light_elevation_deg = LIGHT_ELEVATION_MAX_DEG - 1.0;
838 state.tick(0.25);
839 assert_eq!(state.light_elevation_deg, LIGHT_ELEVATION_MAX_DEG);
840 state.on_interaction_cancel();
841 assert!(state.hold_azimuth.is_none() && state.hold_elevation.is_none());
842
843 state.light_azimuth_deg = 350.0;
844 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
845 state.tick(0.2); // +18 → 368 → 8
846 assert!((state.light_azimuth_deg - 8.0).abs() < 1e-3);
847 }
848
849 #[test]
850 fn light_direction_convention_front_and_up() {
851 let front = light_direction_view(0.0, 0.0);
852 assert!((front[0]).abs() < 1e-5);
853 assert!((front[1]).abs() < 1e-5);
854 assert!((front[2] - 1.0).abs() < 1e-5);
855 let up = light_direction_view(0.0, 90.0);
856 assert!((up[1] - 1.0).abs() < 1e-5);
857 let leftish = light_direction_view(90.0, 0.0);
858 assert!((leftish[0] - 1.0).abs() < 1e-5);
859 }
860
861 #[test]
862 fn missing_frame_is_diagnostic_not_silent_substitute() {
863 let project = Temp::new();
864 let dir = project.0.join("renders/run-body");
865 write_run(
866 &dir,
867 "demo",
868 "body",
869 None,
870 b"shared-body",
871 &[("walk", 2, 0.5)],
872 pixel(
873 [10, 20, 30, 255],
874 [128, 128, 255, 255],
875 [0, 0, 0, 255],
876 20000,
877 ),
878 );
879 let catalog = discover_completed_runs(&project.0);
880 let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
881 let req = ComposeRequest {
882 body: &body,
883 equipment: &[],
884 clip_token: "clip000-walk",
885 frame: 1,
886 pitch: 60,
887 yaw: 330,
888 light: Light::default(),
889 view_mode: ViewMode::Resultaat,
890 };
891 assert_eq!(compose_preview_frame(req.clone()).unwrap().width, 1);
892 let err = compose_preview_frame(ComposeRequest { frame: 2, ..req }).unwrap_err();
893 match err {
894 Preview2dError::Missing {
895 set, clip, frame, ..
896 } => {
897 assert_eq!(set, "body");
898 assert_eq!(clip, "clip000-walk");
899 assert_eq!(frame, 2);
900 }
901 other => panic!("expected Missing, got {other}"),
902 }
903 }
904
905 #[test]
906 fn catalog_compatibility_filters_equipment_by_export_contract() {
907 let project = Temp::new();
908 let renders = project.0.join("renders");
909 let clips = [("bind", 1u32, 0.0f32)];
910 let body_bytes = b"shared-body";
911 write_run(
912 &renders.join("run-body"),
913 "proj-a",
914 "body",
915 None,
916 body_bytes,
917 &clips,
918 pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),
919 );
920 write_run(
921 &renders.join("run-sword"),
922 "proj-a",
923 "sword",
924 Some(&digest::sha256_hex(b"sword")),
925 body_bytes,
926 &clips,
927 pixel(
928 [200, 10, 10, 255],
929 [255, 128, 128, 255],
930 [180, 180, 180, 255],
931 500,
932 ),
933 );
934 // Different body fingerprint → incompatible.
935 write_run(
936 &renders.join("run-bad"),
937 "proj-a",
938 "helm",
939 Some(&digest::sha256_hex(b"helm")),
940 b"other-body",
941 &clips,
942 pixel([9, 9, 9, 255], [128, 128, 255, 255], [0, 0, 0, 255], 800),
943 );
944
945 let catalog = discover_completed_runs(&project.0);
946 assert_eq!(catalog.len(), 3);
947 let allowed = compatible_equipment_sets(&catalog, Sex::Male);
948 assert_eq!(allowed, vec!["sword".to_string()]);
949 assert!(open_selection(&catalog, Sex::Male, &["helm".to_string()]).is_err());
950
951 let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();
952 assert_eq!(body.ppda().settype, "body");
953 assert_eq!(gear.len(), 1);
954 let lit = compose_preview_frame(ComposeRequest {
955 body: &body,
956 equipment: &gear,
957 clip_token: "clip000-bind",
958 frame: 0,
959 pitch: -60,
960 yaw: 90,
961 light: Light::default(),
962 view_mode: ViewMode::Resultaat,
963 })
964 .unwrap();
965 assert_eq!(lit.rgba[3], 255);
966 // Foreground equipment (closer depth) wins.
967 assert!(lit.rgba[0] > 100);
968 }
969
970 #[test]
971 fn playback_loops_on_frames_over_fps_not_source_duration() {
972 // Non-integer duration: 1.1s at 4 fps → ceil = 5 frames.
973 // Each frame must last a full 1/fps = 0.25s, so the loop wraps at 1.25s
974 // (not at 1.1s, which would shorten the last frame to 0.1s).
975 let mut state = Preview2dState {
976 fps: 4,
977 clip_frames: 5,
978 clip_duration: 1.1,
979 playback_speed: 1.0,
980 playing: true,
981 elapsed_secs: 0.0,
982 ..Preview2dState::default()
983 };
984 // Frames 0..3 occupy [0.00,1.00); frame 4 occupies [1.00,1.25).
985 state.elapsed_secs = 1.0;
986 assert_eq!(state.frame_index(), 4, "last frame starts at 1.0s");
987 state.tick(0.2); // 1.20 — still last frame
988 assert!((state.elapsed_secs - 1.2).abs() < 1e-5);
989 assert_eq!(state.frame_index(), 4, "last frame must last full 0.25s");
990 state.tick(0.05); // 1.25 → wrap to 0
991 assert!(
992 state.elapsed_secs < 1e-5,
993 "wrap at frames/fps=1.25, got {}",
994 state.elapsed_secs
995 );
996 assert_eq!(state.frame_index(), 0);
997 // Old bug: wrapping on clip_duration=1.1 would have wrapped at 1.1 → never a full last frame.
998 state.elapsed_secs = 1.05;
999 assert_eq!(state.frame_index(), 4);
1000 state.tick(0.1); // 1.15 — still within 1.25 loop (would have wrapped under the bug)
1001 assert!(
1002 (state.elapsed_secs - 1.15).abs() < 1e-5,
1003 "must NOT wrap on source duration 1.1; elapsed={}",
1004 state.elapsed_secs
1005 );
1006 assert_eq!(state.frame_index(), 4);
1007 }
1008
1009 #[test]
1010 fn azimuth_hold_wraps_full_circle_continuously() {
1011 let mut state = Preview2dState {
1012 light_azimuth_deg: 10.0,
1013 ..Preview2dState::default()
1014 };
1015 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
1016 // 90°/s * many ticks past 360.
1017 for _ in 0..20 {
1018 state.tick(0.25); // +22.5 each, capped dt
1019 }
1020 // 20 * 22.5 = 450 → 10+450 = 460 → 100
1021 assert!(
1022 (state.light_azimuth_deg - 100.0).abs() < 1e-2,
1023 "got {}",
1024 state.light_azimuth_deg
1025 );
1026 assert!(state.light_azimuth_deg >= 0.0 && state.light_azimuth_deg < 360.0);
1027 // Negative wrap
1028 state.light_azimuth_deg = 5.0;
1029 state.hold_light(LightAxis::Azimuth, HoldSign::Negative);
1030 state.tick(0.25); // -22.5 → -17.5 → 342.5
1031 assert!((state.light_azimuth_deg - 342.5).abs() < 1e-2);
1032 assert_eq!(wrap_azimuth_deg(-0.1), 359.9);
1033 assert_eq!(wrap_azimuth_deg(360.0), 0.0);
1034 assert_eq!(wrap_azimuth_deg(720.5), 0.5);
1035 }
1036
1037 #[test]
1038 fn view_modes_show_composited_maps_unlit_except_resultaat() {
1039 let project = Temp::new();
1040 // Distinct channels so modes are distinguishable; scalar specular.
1041 write_run(
1042 &project.0.join("renders/run-body"),
1043 "demo",
1044 "body",
1045 None,
1046 b"shared-body",
1047 &[("bind", 1, 0.0)],
1048 pixel(
1049 [10, 20, 30, 255],
1050 [40, 50, 60, 255],
1051 [70, 70, 70, 255],
1052 32768,
1053 ),
1054 );
1055 let catalog = discover_completed_runs(&project.0);
1056 let (run, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1057 let base = ComposeRequest {
1058 body: &run,
1059 equipment: &[],
1060 clip_token: "clip000-bind",
1061 frame: 0,
1062 pitch: 0,
1063 yaw: 0,
1064 light: Light::default(),
1065 view_mode: ViewMode::Resultaat,
1066 };
1067 let diffuse = compose_preview_frame(ComposeRequest {
1068 view_mode: ViewMode::DiffuseOnly,
1069 ..base.clone()
1070 })
1071 .unwrap();
1072 assert_eq!(&diffuse.rgba[0..4], &[10, 20, 30, 255]);
1073
1074 let normal = compose_preview_frame(ComposeRequest {
1075 view_mode: ViewMode::NormalOnly,
1076 ..base.clone()
1077 })
1078 .unwrap();
1079 assert_eq!(&normal.rgba[0..4], &[40, 50, 60, 255]);
1080
1081 let specular = compose_preview_frame(ComposeRequest {
1082 view_mode: ViewMode::SpecularOnly,
1083 ..base.clone()
1084 })
1085 .unwrap();
1086 assert_eq!(&specular.rgba[0..4], &[70, 70, 70, 255]);
1087
1088 let depth = compose_preview_frame(ComposeRequest {
1089 view_mode: ViewMode::DepthOnly,
1090 ..base.clone()
1091 })
1092 .unwrap();
1093 // 32768/65535 ≈ 0.5 → ~128
1094 assert_eq!(depth.rgba[0], depth.rgba[1]);
1095 assert_eq!(depth.rgba[1], depth.rgba[2]);
1096 assert!((depth.rgba[0] as i32 - 128).abs() <= 1);
1097 assert_eq!(depth.rgba[3], 255);
1098
1099 let result = compose_preview_frame(ComposeRequest {
1100 view_mode: ViewMode::Resultaat,
1101 ..base.clone()
1102 })
1103 .unwrap();
1104 // Relit differs from raw diffuse under default light.
1105 assert_ne!(&result.rgba[0..3], &diffuse.rgba[0..3]);
1106 assert_eq!(result.rgba[3], 255);
1107
1108 // Light must not change map modes.
1109 let dark = Light {
1110 ambient: 0.0,
1111 diffuse: 0.0,
1112 specular: 0.0,
1113 ..Light::default()
1114 };
1115 let diffuse_dark = compose_preview_frame(ComposeRequest {
1116 light: dark,
1117 view_mode: ViewMode::DiffuseOnly,
1118 ..base
1119 })
1120 .unwrap();
1121 assert_eq!(diffuse_dark.rgba, diffuse.rgba);
1122 }
1123
1124 #[test]
1125 fn discover_skips_incomplete_and_wrong_schema() {
1126 let project = Temp::new();
1127 let renders = project.0.join("renders");
1128 let incomplete = renders.join("run-incomplete");
1129 fs::create_dir_all(&incomplete).unwrap();
1130 fs::write(incomplete.join("render.json"), b"{}").unwrap();
1131 // no .ppda at all
1132 let (catalog, skipped) = discover_exports(&project.0);
1133 assert!(catalog.is_empty());
1134 assert_eq!(skipped.len(), 1);
1135 assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);
1136 }
1137
1138 #[test]
1139 fn preview_reads_atlas_not_loose_pngs() {
1140 // Each view gets a distinct colour; the preview must return exactly the
1141 // atlas cell even after every loose PNG is deleted.
1142 let project = Temp::new();
1143 let dir = project.0.join("renders/run-body");
1144 let mut spec = SyntheticRun::body("demo", "male", b"b");
1145 spec.clips = vec![("walk".into(), 2, 0.5)];
1146 spec.target = [2, 3];
1147 spec.write(&dir, |_, f, p, y| {
1148 pixel(
1149 [(y / 30) as u8 * 20, (p + 60) as u8, f as u8 * 100 + 1, 255],
1150 [128, 128, 255, 255],
1151 [0, 0, 0, 255],
1152 1000,
1153 )
1154 })
1155 .unwrap();
1156 fs::remove_dir_all(dir.join("male")).unwrap();
1157 let catalog = discover_completed_runs(&project.0);
1158 assert_eq!(catalog.len(), 1);
1159 assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);
1160 let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1161 for (frame, pitch, yaw) in [(0u32, -60i32, 0u32), (1, 60, 330), (1, -30, 150)] {
1162 let img = compose_preview_frame(ComposeRequest {
1163 body: &body,
1164 equipment: &[],
1165 clip_token: "clip000-walk",
1166 frame,
1167 pitch,
1168 yaw,
1169 light: Light::default(),
1170 view_mode: ViewMode::DiffuseOnly,
1171 })
1172 .unwrap();
1173 assert_eq!((img.width, img.height), (2, 3));
1174 let want = [
1175 (yaw / 30) as u8 * 20,
1176 (pitch + 60) as u8,
1177 frame as u8 * 100 + 1,
1178 255,
1179 ];
1180 for px in img.rgba.chunks_exact(4) {
1181 assert_eq!(px, want);
1182 }
1183 }
1184 }
1185
1186 #[test]
1187 fn five_pitch_rows_clamp_and_default_is_zero() {
1188 let mut state = Preview2dState::default();
1189 assert_eq!(state.pitch_deg(), 0);
1190 state.step_pitch(1);
1191 state.step_pitch(1);
1192 assert_eq!(state.pitch_deg(), 60);
1193 state.step_pitch(1);
1194 assert_eq!(state.pitch_deg(), 60);
1195 for _ in 0..10 {
1196 state.step_pitch(-1);
1197 }
1198 assert_eq!(state.pitch_deg(), -60);
1199 assert_eq!(pitch_row_from_deg(30), 3);
1200 assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);
1201 assert_eq!(state.fps, 4);
1202 }
1203}