BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

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