BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

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