BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

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