BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

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