BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

preview2d.rs 54.5 KiB Download Raw
1//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
2//!
3//! Consumes completed exports through their `.ppda` + per-map atlases
4//! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and
5//! `compositor`. Loose PNGs are not read here.
6//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::atlas::{AtlasSet, LoadedAtlas};
13use crate::compositor::{
14 assert_compatible_keys, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,
15 LayerMaps, Light,
16};
17use crate::export_set::ExportError;
18use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
19use crate::render_job::ImageBuf;
20use crate::session::Sex;
21
22/// Degrees per second while a light hold button stays pressed.
23pub const LIGHT_AZIMUTH_RATE_DEG_PER_SEC: f32 = 90.0;
24pub const LIGHT_ELEVATION_RATE_DEG_PER_SEC: f32 = 60.0;
25
26/// Elevation clamp (degrees). Not cyclic; soft limit keeps the light off the horizon flip.
27pub const LIGHT_ELEVATION_MIN_DEG: f32 = -80.0;
28pub const LIGHT_ELEVATION_MAX_DEG: f32 = 80.0;
29
30/// Default light pose: slightly above and from the front (+Z in view space).
31pub const DEFAULT_LIGHT_AZIMUTH_DEG: f32 = 25.0;
32pub const DEFAULT_LIGHT_ELEVATION_DEG: f32 = 35.0;
33
34/// Row of pitch 0° in [`PAPERDOLL_PITCHES_DEG`] (−60,−30,0,+30,+60).
35pub const ZERO_PITCH_ROW: i32 = 2;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum LightAxis {
39 Azimuth,
40 Elevation,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum HoldSign {
45 Positive,
46 Negative,
47}
48
49impl HoldSign {
50 pub fn as_f32(self) -> f32 {
51 match self {
52 Self::Positive => 1.0,
53 Self::Negative => -1.0,
54 }
55 }
56}
57
58/// What the 2D preview shows for the current composed pose.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub enum ViewMode {
61 /// Relit composite (ambient + Lambert + Blinn-Phong). Default.
62 #[default]
63 Resultaat,
64 /// Depth-composited diffuse albedo, unlit.
65 DiffuseOnly,
66 /// Depth-composited normal map as stored RGB, unlit.
67 NormalOnly,
68 /// Depth-composited depth map as stored (raw RGB for rgb_bands/rgb_packed), unlit.
69 DepthOnly,
70 /// Depth-composited specular map as stored RGB, unlit.
71 SpecularOnly,
72}
73
74impl ViewMode {
75 pub fn label_nl(self) -> &'static str {
76 match self {
77 Self::Resultaat => "Resultaat",
78 Self::DiffuseOnly => "Alleen diffuse",
79 Self::NormalOnly => "Alleen normal map",
80 Self::DepthOnly => "Alleen depth map",
81 Self::SpecularOnly => "Alleen specular map",
82 }
83 }
84
85 pub fn all() -> [Self; 5] {
86 [
87 Self::Resultaat,
88 Self::DiffuseOnly,
89 Self::NormalOnly,
90 Self::DepthOnly,
91 Self::SpecularOnly,
92 ]
93 }
94
95 pub fn uses_light(self) -> bool {
96 matches!(self, Self::Resultaat)
97 }
98}
99
100/// Render a composed map mode to RGBA8 (unlit inspection).
101pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf {
102 let pixels = (composed.width as usize) * (composed.height as usize);
103 let mut rgba = vec![0u8; pixels * 4];
104 match mode {
105 ViewMode::Resultaat => {
106 // Caller should use relight; fall back to diffuse if misused.
107 rgba.copy_from_slice(&composed.diffuse);
108 }
109 ViewMode::DiffuseOnly => {
110 rgba.copy_from_slice(&composed.diffuse);
111 }
112 ViewMode::NormalOnly => {
113 rgba.copy_from_slice(&composed.normal);
114 }
115 ViewMode::SpecularOnly => {
116 rgba.copy_from_slice(&composed.specular);
117 }
118 ViewMode::DepthOnly if composed.depth_display.is_some() => {
119 // As stored in the atlas (Tim t992u): rgb_bands / rgb_packed show
120 // their raw RGB, 16-bit at display precision; no decoding.
121 let display = composed.depth_display.as_ref().unwrap();
122 for i in 0..pixels {
123 if composed.winner[i] != u8::MAX {
124 rgba[i * 4..i * 4 + 4].copy_from_slice(&display[i * 4..i * 4 + 4]);
125 }
126 }
127 }
128 ViewMode::DepthOnly => {
129 // Sources without stored pixels (synthetic layers): canonical gray.
130 for i in 0..pixels {
131 let o = i * 4;
132 if composed.winner[i] == u8::MAX {
133 continue;
134 }
135 let gray = composed.depth[i * 2];
136 let alpha16 = composed.depth[i * 2 + 1];
137 let g = ((u32::from(gray) * 255 + 32767) / 65535) as u8;
138 let a = ((u32::from(alpha16) * 255 + 32767) / 65535) as u8;
139 rgba[o] = g;
140 rgba[o + 1] = g;
141 rgba[o + 2] = g;
142 rgba[o + 3] = a;
143 }
144 }
145 }
146 ImageBuf {
147 width: composed.width,
148 height: composed.height,
149 rgba,
150 }
151}
152
153/// One export (`.ppda` + atlases) discovered under a project `renders/` tree.
154#[derive(Debug, Clone)]
155pub struct CatalogEntry {
156 /// Run directory containing the `.ppda`.
157 pub path: PathBuf,
158 pub sex: String,
159 pub set: String,
160 pub project_id: String,
161 pub fps: u32,
162 pub target: [u32; 2],
163 pub clips: Vec<(String, String, u32, f32)>,
164 pub body_model_sha256: String,
165 pub equipment_preset_sha256: Option<String>,
166 pub key: CompatKey,
167}
168
169impl CatalogEntry {
170 pub fn from_atlas(path: PathBuf, set: &AtlasSet) -> Self {
171 let p = &set.ppda;
172 Self {
173 path,
174 sex: p.sex.clone(),
175 set: p.settype.clone(),
176 project_id: p.project.id.clone(),
177 fps: p.fps,
178 target: [p.cell_width, p.cell_height],
179 clips: p
180 .clips
181 .iter()
182 .map(|c| (c.token.clone(), c.name.clone(), c.frame_count, c.duration))
183 .collect(),
184 body_model_sha256: p.identity.body_model_sha256.clone(),
185 equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(),
186 key: CompatKey::from_ppda(p),
187 }
188 }
189
190 pub fn is_body(&self) -> bool {
191 self.set == "body"
192 }
193}
194
195/// Run directories under `renders/` that were skipped, with the reason.
196pub type Skipped = Vec<(PathBuf, String)>;
197
198/// Scan `project_root/renders/<sex>-<set>-atlas/` and open every export whose `.ppda` and
199/// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,
200/// damaged) are skipped.
201pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
202 discover_exports(project_root).0
203}
204
205/// Like [`discover_completed_runs`], also returning skipped runs + reasons.
206pub fn discover_exports(project_root: &Path) -> (Vec<CatalogEntry>, Skipped) {
207 let renders = project_root.join("renders");
208 let Ok(entries) = fs::read_dir(&renders) else {
209 return (Vec::new(), Vec::new());
210 };
211 let mut out = Vec::new();
212 let mut skipped = Vec::new();
213 for entry in entries.flatten() {
214 let path = entry.path();
215 if !path.is_dir() {
216 continue;
217 }
218 let name = entry.file_name().to_string_lossy().to_string();
219 if name.starts_with(crate::resume::IN_PROGRESS_PREFIX)
220 || name.starts_with(crate::resume::REPLACED_PREFIX)
221 {
222 continue; // unfinished build / swap leftover, never a result
223 }
224 if name.starts_with("run-") {
225 skipped.push((
226 path,
227 "oude run-map uit een eerdere versie; genegeerd (opnieuw renderen naar <sekse>-<settype>-atlas)".into(),
228 ));
229 continue;
230 }
231 if !name.ends_with("-atlas") {
232 continue;
233 }
234 match AtlasSet::open_run(&path) {
235 Ok(set) => {
236 let entry = CatalogEntry::from_atlas(path.clone(), &set);
237 if name == crate::resume::result_dir_name(&entry.sex, &entry.set) {
238 out.push(entry);
239 } else {
240 skipped.push((
241 path,
242 format!(
243 "mapnaam past niet bij inhoud ({})",
244 crate::resume::result_dir_name(&entry.sex, &entry.set)
245 ),
246 ));
247 }
248 }
249 Err(err) => skipped.push((path, err.to_string())),
250 }
251 }
252 out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));
253 skipped.sort();
254 (out, skipped)
255}
256
257/// Discrete yaw index 0..11 → degrees 0,30,…,330.
258pub fn yaw_deg_from_index(index: i32) -> u32 {
259 let wrapped = index.rem_euclid(PAPERDOLL_YAW_COUNT as i32) as u32;
260 wrapped * 30
261}
262
263pub fn pitch_deg_from_row(row: i32) -> i32 {
264 let row = row.clamp(0, (PAPERDOLL_PITCHES_DEG.len() as i32) - 1) as usize;
265 PAPERDOLL_PITCHES_DEG[row] as i32
266}
267
268pub fn pitch_row_from_deg(pitch: i32) -> i32 {
269 PAPERDOLL_PITCHES_DEG
270 .iter()
271 .position(|p| *p as i32 == pitch)
272 .unwrap_or(ZERO_PITCH_ROW as usize) as i32
273}
274
275/// View-space light direction toward the light.
276///
277/// Convention (right-hand, view +Y up, camera looks −Z toward the character so
278/// character front faces +Z in view):
279/// - azimuth 0° = light from +Z (in front of the character)
280/// - positive azimuth rotates toward +X (character's left when facing camera)
281/// - elevation 0° = horizon; positive raises the light
282pub fn light_direction_view(azimuth_deg: f32, elevation_deg: f32) -> [f32; 3] {
283 let az = azimuth_deg.to_radians();
284 let el = elevation_deg.to_radians();
285 let cos_el = el.cos();
286 [cos_el * az.sin(), el.sin(), cos_el * az.cos()]
287}
288
289pub fn wrap_azimuth_deg(deg: f32) -> f32 {
290 let mut d = deg % 360.0;
291 if d < 0.0 {
292 d += 360.0;
293 }
294 d
295}
296
297pub fn clamp_elevation_deg(deg: f32) -> f32 {
298 deg.clamp(LIGHT_ELEVATION_MIN_DEG, LIGHT_ELEVATION_MAX_DEG)
299}
300
301fn wrap_time(time: f32, duration: f32) -> f32 {
302 if !(duration.is_finite() && duration > 0.0 && time.is_finite()) {
303 return 0.0;
304 }
305 let mut t = time % duration;
306 if t < 0.0 {
307 t += duration;
308 }
309 t
310}
311
312/// Playback / view / light state for the rendered 2D tab.
313#[derive(Debug, Clone)]
314pub struct Preview2dState {
315 pub sex: Sex,
316 /// Equipment set names currently selected (body is always included when present).
317 pub selected_equipment: Vec<String>,
318 pub clip_token: String,
319 pub clip_frames: u32,
320 pub clip_duration: f32,
321 pub fps: u32,
322 pub playback_speed: f32,
323 pub playing: bool,
324 /// Elapsed animation seconds (preserved across yaw/pitch changes).
325 pub elapsed_secs: f32,
326 pub yaw_index: i32,
327 pub pitch_row: i32,
328 pub light_azimuth_deg: f32,
329 pub light_elevation_deg: f32,
330 pub hold_azimuth: Option<HoldSign>,
331 pub hold_elevation: Option<HoldSign>,
332 /// Preview display mode (Resultaat vs raw composed maps).
333 pub view_mode: ViewMode,
334 pub diagnostic: Option<String>,
335}
336
337impl Default for Preview2dState {
338 fn default() -> Self {
339 Self {
340 sex: Sex::Male,
341 selected_equipment: Vec::new(),
342 clip_token: String::new(),
343 clip_frames: 1,
344 clip_duration: 0.0,
345 fps: crate::project::DEFAULT_FPS,
346 playback_speed: 1.0,
347 playing: true,
348 elapsed_secs: 0.0,
349 yaw_index: 0,
350 pitch_row: ZERO_PITCH_ROW,
351 light_azimuth_deg: DEFAULT_LIGHT_AZIMUTH_DEG,
352 light_elevation_deg: DEFAULT_LIGHT_ELEVATION_DEG,
353 hold_azimuth: None,
354 hold_elevation: None,
355 view_mode: ViewMode::Resultaat,
356 diagnostic: None,
357 }
358 }
359}
360
361impl Preview2dState {
362 pub fn sex_token(&self) -> &'static str {
363 match self.sex {
364 Sex::Male => "male",
365 Sex::Female => "female",
366 }
367 }
368
369 pub fn yaw_deg(&self) -> u32 {
370 yaw_deg_from_index(self.yaw_index)
371 }
372
373 pub fn pitch_deg(&self) -> i32 {
374 pitch_deg_from_row(self.pitch_row)
375 }
376
377 pub fn frame_index(&self) -> u32 {
378 let frames = self.clip_frames.max(1);
379 if self.fps == 0 {
380 return 0;
381 }
382 // `elapsed_secs` already advances by `dt * playback_speed` in `tick`.
383 let samples = self.elapsed_secs * self.fps as f32;
384 if !samples.is_finite() || samples < 0.0 {
385 return 0;
386 }
387 (samples.floor() as u64 % u64::from(frames)) as u32
388 }
389
390 pub fn set_clip(&mut self, token: String, frames: u32, duration: f32) {
391 if self.clip_token != token {
392 self.clip_token = token;
393 self.elapsed_secs = 0.0;
394 }
395 self.clip_frames = frames.max(1);
396 self.clip_duration = if duration.is_finite() && duration >= 0.0 {
397 duration
398 } else {
399 0.0
400 };
401 self.diagnostic = None;
402 }
403
404 pub fn step_yaw(&mut self, delta: i32) {
405 self.yaw_index = (self.yaw_index + delta).rem_euclid(PAPERDOLL_YAW_COUNT as i32);
406 // Animation time intentionally preserved.
407 }
408
409 pub fn step_pitch(&mut self, delta: i32) {
410 self.pitch_row = (self.pitch_row + delta).clamp(0, PAPERDOLL_PITCHES_DEG.len() as i32 - 1);
411 // Animation time intentionally preserved; pitch is clamped, not cyclic.
412 }
413
414 pub fn hold_light(&mut self, axis: LightAxis, sign: HoldSign) {
415 match axis {
416 LightAxis::Azimuth => self.hold_azimuth = Some(sign),
417 LightAxis::Elevation => self.hold_elevation = Some(sign),
418 }
419 }
420
421 pub fn release_light(&mut self, axis: LightAxis) {
422 match axis {
423 LightAxis::Azimuth => self.hold_azimuth = None,
424 LightAxis::Elevation => self.hold_elevation = None,
425 }
426 }
427
428 pub fn release_all_holds(&mut self) {
429 self.hold_azimuth = None;
430 self.hold_elevation = None;
431 }
432
433 /// Stop continuous light motion (button release, focus loss, pointer cancel).
434 pub fn on_interaction_cancel(&mut self) {
435 self.release_all_holds();
436 }
437
438 pub fn light(&self) -> Light {
439 Light {
440 direction_view: light_direction_view(self.light_azimuth_deg, self.light_elevation_deg),
441 ..Light::default()
442 }
443 }
444
445 /// Advance playback and held light controls by `dt` seconds.
446 /// (Holds are set each frame from [`raw_pointer_hold`].)
447 pub fn tick(&mut self, dt: f32) {
448 let dt = if dt.is_finite() && dt > 0.0 {
449 dt.min(0.25)
450 } else {
451 0.0
452 };
453 if self.playing && dt > 0.0 {
454 // Loop over exactly frames/fps so every exported frame, including the
455 // last, lasts a full 1/fps (not shortened by source clip_duration).
456 let duration = if self.fps > 0 {
457 self.clip_frames.max(1) as f32 / self.fps as f32
458 } else if self.clip_duration > 0.0 {
459 self.clip_duration
460 } else {
461 1.0
462 };
463 let speed = if self.playback_speed.is_finite() {
464 self.playback_speed.max(0.0)
465 } else {
466 0.0
467 };
468 self.elapsed_secs = wrap_time(self.elapsed_secs + dt * speed, duration);
469 }
470 if let Some(sign) = self.hold_azimuth {
471 self.light_azimuth_deg = wrap_azimuth_deg(
472 self.light_azimuth_deg + sign.as_f32() * LIGHT_AZIMUTH_RATE_DEG_PER_SEC * dt,
473 );
474 }
475 if let Some(sign) = self.hold_elevation {
476 self.light_elevation_deg = clamp_elevation_deg(
477 self.light_elevation_deg + sign.as_f32() * LIGHT_ELEVATION_RATE_DEG_PER_SEC * dt,
478 );
479 }
480 }
481
482 pub fn toggle_equipment(&mut self, set: &str) {
483 if set == "body" {
484 return;
485 }
486 if let Some(pos) = self.selected_equipment.iter().position(|s| s == set) {
487 self.selected_equipment.remove(pos);
488 } else {
489 self.selected_equipment.push(set.to_string());
490 self.selected_equipment.sort();
491 }
492 self.diagnostic = None;
493 }
494
495 pub fn set_sex(&mut self, sex: Sex) {
496 if self.sex != sex {
497 self.sex = sex;
498 self.selected_equipment.clear();
499 self.clip_token.clear();
500 self.elapsed_secs = 0.0;
501 self.diagnostic = None;
502 }
503 }
504}
505
506#[derive(Debug, Clone)]
507pub struct ComposeRequest<'a> {
508 pub body: &'a LoadedAtlas,
509 pub equipment: &'a [LoadedAtlas],
510 pub clip_token: &'a str,
511 pub frame: u32,
512 pub pitch: i32,
513 pub yaw: u32,
514 pub light: Light,
515 pub view_mode: ViewMode,
516}
517
518#[derive(Debug)]
519pub enum Preview2dError {
520 Compatible(ComposeError),
521 Export(ExportError),
522 Missing {
523 set: String,
524 clip: String,
525 frame: u32,
526 pitch: i32,
527 yaw: u32,
528 detail: String,
529 },
530 NoBody,
531}
532
533impl std::fmt::Display for Preview2dError {
534 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535 match self {
536 Self::Compatible(e) => write!(f, "{e}"),
537 Self::Export(e) => write!(f, "{e}"),
538 Self::Missing {
539 set,
540 clip,
541 frame,
542 pitch,
543 yaw,
544 detail,
545 } => write!(
546 f,
547 "ontbrekende maps voor set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}"
548 ),
549 Self::NoBody => write!(f, "geen body-exportset voor dit geslacht"),
550 }
551 }
552}
553
554impl std::error::Error for Preview2dError {}
555
556impl From<ComposeError> for Preview2dError {
557 fn from(value: ComposeError) -> Self {
558 Self::Compatible(value)
559 }
560}
561
562impl From<ExportError> for Preview2dError {
563 fn from(value: ExportError) -> Self {
564 Self::Export(value)
565 }
566}
567
568/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.
569pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
570 let mut atlases: Vec<&LoadedAtlas> = Vec::with_capacity(1 + req.equipment.len());
571 atlases.push(req.body);
572 atlases.extend(req.equipment.iter());
573 let keys: Vec<CompatKey> = atlases
574 .iter()
575 .map(|a| CompatKey::from_ppda(a.ppda()))
576 .collect();
577 assert_compatible_keys(&keys)?;
578 let mut layers = Vec::with_capacity(atlases.len());
579 for atlas in atlases {
580 let set = atlas.ppda().settype.as_str();
581 match LayerMaps::from_atlas(atlas, set, req.clip_token, req.frame, req.pitch, req.yaw) {
582 Ok(layer) => layers.push(layer),
583 Err(err) => {
584 return Err(Preview2dError::Missing {
585 set: set.to_string(),
586 clip: req.clip_token.to_string(),
587 frame: req.frame,
588 pitch: req.pitch,
589 yaw: req.yaw,
590 detail: err.to_string(),
591 });
592 }
593 }
594 }
595 if req.view_mode.uses_light() {
596 let (_composed, lit) = compose_and_relight(&layers, &req.light)?;
597 Ok(lit)
598 } else {
599 let composed = compose(&layers)?;
600 Ok(composed_map_image(&composed, req.view_mode))
601 }
602}
603
604/// Run directories (body first, then equipment in the given order) that
605/// [`open_selection`] would load; used as a cache key by the editor.
606pub fn selection_paths(
607 catalog: &[CatalogEntry],
608 sex: Sex,
609 equipment_sets: &[String],
610) -> Result<Vec<PathBuf>, Preview2dError> {
611 let sex_token = match sex {
612 Sex::Male => "male",
613 Sex::Female => "female",
614 };
615 let body = catalog
616 .iter()
617 .filter(|e| e.sex == sex_token && e.is_body())
618 .max_by_key(|e| e.path.clone())
619 .ok_or(Preview2dError::NoBody)?;
620 let mut paths = vec![body.path.clone()];
621 for set in equipment_sets {
622 let entry = catalog
623 .iter()
624 .filter(|e| e.sex == sex_token && e.set == *set && !e.is_body())
625 .max_by_key(|e| e.path.clone())
626 .ok_or_else(|| Preview2dError::Missing {
627 set: set.clone(),
628 clip: String::new(),
629 frame: 0,
630 pitch: 0,
631 yaw: 0,
632 detail: "geen voltooide exportset voor dit equipment".into(),
633 })?;
634 paths.push(entry.path.clone());
635 }
636 Ok(paths)
637}
638
639/// Open and decode the `.ppda` atlases of body + selected equipment for one
640/// sex, re-validating checksums and compatibility.
641pub fn open_selection(
642 catalog: &[CatalogEntry],
643 sex: Sex,
644 equipment_sets: &[String],
645) -> Result<(LoadedAtlas, Vec<LoadedAtlas>), Preview2dError> {
646 let paths = selection_paths(catalog, sex, equipment_sets)?;
647 let mut loaded = Vec::with_capacity(paths.len());
648 for path in &paths {
649 loaded.push(AtlasSet::open_run(path)?.load()?);
650 }
651 let keys: Vec<CompatKey> = loaded
652 .iter()
653 .map(|a| CompatKey::from_ppda(a.ppda()))
654 .collect();
655 assert_compatible_keys(&keys)?;
656 let body = loaded.remove(0);
657 Ok((body, loaded))
658}
659
660/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).
661pub fn compatible_equipment_sets(catalog: &[CatalogEntry], sex: Sex) -> Vec<String> {
662 let sex_token = match sex {
663 Sex::Male => "male",
664 Sex::Female => "female",
665 };
666 let Some(body) = catalog
667 .iter()
668 .filter(|e| e.sex == sex_token && e.is_body())
669 .max_by_key(|e| e.path.clone())
670 else {
671 return Vec::new();
672 };
673 let mut names = BTreeMap::new();
674 for entry in catalog {
675 if entry.sex != sex_token || entry.is_body() {
676 continue;
677 }
678 if entry.key.project_id != body.key.project_id
679 || entry.key.sex != body.key.sex
680 || entry.key.body_model_sha256 != body.key.body_model_sha256
681 || entry.key.target != body.key.target
682 || entry.key.fps != body.key.fps
683 || entry.key.framing != body.key.framing
684 || (entry.key.near - body.key.near).abs() > f32::EPSILON
685 || (entry.key.far - body.key.far).abs() > f32::EPSILON
686 || entry.key.clip_tokens != body.key.clip_tokens
687 {
688 continue;
689 }
690 names.insert(entry.set.clone(), ());
691 }
692 names.into_keys().collect()
693}
694
695/// Apply catalog defaults when sex/clip inventory changes.
696pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEntry]) {
697 let sex_token = state.sex_token();
698 let body = catalog
699 .iter()
700 .filter(|e| e.sex == sex_token && e.is_body())
701 .max_by_key(|e| e.path.clone());
702 let Some(body) = body else {
703 state.diagnostic = Some("geen voltooide body-export voor dit geslacht".into());
704 return;
705 };
706 state.fps = body.fps;
707 let allowed = compatible_equipment_sets(catalog, state.sex);
708 state
709 .selected_equipment
710 .retain(|s| allowed.iter().any(|a| a == s));
711 if state.clip_token.is_empty() || !body.clips.iter().any(|(t, _, _, _)| t == &state.clip_token)
712 {
713 if let Some((token, _name, frames, duration)) = body.clips.first() {
714 state.set_clip(token.clone(), *frames, *duration);
715 }
716 } else if let Some((_, _, frames, duration)) = body
717 .clips
718 .iter()
719 .find(|(t, _, _, _)| t == &state.clip_token)
720 {
721 state.clip_frames = (*frames).max(1);
722 state.clip_duration = *duration;
723 }
724}
725
726/// Whether a light hold-button is held, from raw pointer state rather than
727/// egui's click sense (which stops reporting `is_pointer_button_down_on` once
728/// a press outlasts a click, ~1 s in egui 0.29). Held = primary button down,
729/// press started on the button, pointer still over it. Release, pointer gone
730/// (left the window) or a press that started elsewhere all yield false.
731pub fn raw_pointer_hold(rect: eframe::egui::Rect, input: &eframe::egui::InputState) -> bool {
732 let p = &input.pointer;
733 p.primary_down()
734 && p.press_origin().is_some_and(|o| rect.contains(o))
735 && p.hover_pos().is_some_and(|h| rect.contains(h))
736}
737
738/// Export the current preview pose: composed map PNGs (depth in the atlas
739/// encoding) plus the lit preview image as shown under Resultaat (Tim t1065u).
740pub fn export_preview_frame(
741 out_dir: &Path,
742 req: &ComposeRequest<'_>,
743) -> Result<Vec<PathBuf>, Preview2dError> {
744 std::fs::create_dir_all(out_dir)
745 .map_err(|e| Preview2dError::Export(ExportError::Io(e.to_string())))?;
746 let mut atlases: Vec<&LoadedAtlas> = Vec::with_capacity(1 + req.equipment.len());
747 atlases.push(req.body);
748 atlases.extend(req.equipment.iter());
749 let keys: Vec<CompatKey> = atlases
750 .iter()
751 .map(|a| CompatKey::from_ppda(a.ppda()))
752 .collect();
753 assert_compatible_keys(&keys)?;
754 let mut layers = Vec::with_capacity(atlases.len());
755 for atlas in &atlases {
756 let set = atlas.ppda().settype.as_str();
757 match LayerMaps::from_atlas(atlas, set, req.clip_token, req.frame, req.pitch, req.yaw) {
758 Ok(layer) => layers.push(layer),
759 Err(err) => {
760 return Err(Preview2dError::Missing {
761 set: set.to_string(),
762 clip: req.clip_token.to_string(),
763 frame: req.frame,
764 pitch: req.pitch,
765 yaw: req.yaw,
766 detail: err.to_string(),
767 });
768 }
769 }
770 }
771 let (composed, lit) = compose_and_relight(&layers, &req.light)?;
772 let stem = format!(
773 "{}_f{:06}_p{:+03}_y{:03}",
774 req.clip_token, req.frame, req.pitch, req.yaw
775 );
776 let mut written = Vec::new();
777 let write_rgba = |name: &str, rgba: &[u8], srgb: bool| -> Result<PathBuf, Preview2dError> {
778 let img = ImageBuf {
779 width: composed.width,
780 height: composed.height,
781 rgba: rgba.to_vec(),
782 };
783 let bytes = crate::production::encode_rgba_png(&img, srgb)
784 .map_err(|e| Preview2dError::Export(ExportError::Corrupt(e)))?;
785 let path = out_dir.join(name);
786 std::fs::write(&path, bytes)
787 .map_err(|e| Preview2dError::Export(ExportError::Io(e.to_string())))?;
788 Ok(path)
789 };
790 written.push(write_rgba(
791 &format!("{stem}_diffuse.png"),
792 &composed.diffuse,
793 true,
794 )?);
795 written.push(write_rgba(
796 &format!("{stem}_normal.png"),
797 &composed.normal,
798 false,
799 )?);
800 written.push(write_rgba(
801 &format!("{stem}_specular.png"),
802 &composed.specular,
803 false,
804 )?);
805 let fmt = req.body.ppda().depth_format()?;
806 let pixels: Vec<(f64, f64)> = composed
807 .depth
808 .chunks_exact(2)
809 .map(|c| (f64::from(c[0]) / 65535.0, f64::from(c[1]) / 65535.0))
810 .collect();
811 let depth_bytes =
812 crate::production::encode_depth_png_fmt(composed.width, composed.height, &pixels, fmt)
813 .map_err(|e| Preview2dError::Export(ExportError::Corrupt(e)))?;
814 let depth_path = out_dir.join(format!("{stem}_depth.png"));
815 std::fs::write(&depth_path, depth_bytes)
816 .map_err(|e| Preview2dError::Export(ExportError::Io(e.to_string())))?;
817 written.push(depth_path);
818 let preview_path = write_rgba(&format!("{stem}_preview.png"), &lit.rgba, true)?;
819 written.push(preview_path);
820 Ok(written)
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use crate::digest;
827 use crate::export_fixture::{Pixel, SyntheticRun};
828 use crate::project::CameraFraming;
829 use std::sync::atomic::{AtomicU64, Ordering};
830
831 static COUNTER: AtomicU64 = AtomicU64::new(0);
832
833 struct Temp(PathBuf);
834 impl Temp {
835 fn new() -> Self {
836 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
837 let path = std::env::temp_dir().join(format!(
838 "paperdoll-preview2d-{}-{}-{}",
839 std::process::id(),
840 n,
841 std::time::SystemTime::now()
842 .duration_since(std::time::UNIX_EPOCH)
843 .unwrap()
844 .as_nanos()
845 ));
846 fs::create_dir_all(&path).unwrap();
847 Self(path)
848 }
849 }
850 impl Drop for Temp {
851 fn drop(&mut self) {
852 let _ = fs::remove_dir_all(&self.0);
853 }
854 }
855
856 fn framing() -> CameraFraming {
857 CameraFraming {
858 height_milli: 1800,
859 center_y_milli: 900,
860 pivot_y_milli: Some(1500),
861 depth_range_milli: None,
862 }
863 }
864
865 fn pixel(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
866 Pixel {
867 diffuse,
868 normal,
869 specular,
870 depth,
871 }
872 }
873
874 /// Complete synthetic run (all 60 views) with uniform maps per run.
875 #[allow(clippy::too_many_arguments)]
876 fn write_run(
877 root: &Path,
878 project_id: &str,
879 set: &str,
880 equipment_sha: Option<&str>,
881 body_bytes: &[u8],
882 clips: &[(&str, u32, f32)],
883 px: Pixel,
884 ) {
885 let mut spec = SyntheticRun::body(project_id, "male", body_bytes);
886 spec.framing = framing();
887 spec.set = set.into();
888 spec.equipment_sha256 = equipment_sha.map(str::to_string);
889 spec.clips = clips
890 .iter()
891 .map(|(n, f, d)| (n.to_string(), *f, *d))
892 .collect();
893 spec.write(root, |_, _, _, _| px).unwrap();
894 }
895
896 #[test]
897 fn yaw_wraps_twelve_steps_pitch_clamps_five_rows() {
898 let mut state = Preview2dState {
899 elapsed_secs: 1.25,
900 ..Preview2dState::default()
901 };
902 assert_eq!(state.yaw_deg(), 0);
903 state.step_yaw(-1);
904 assert_eq!(state.yaw_deg(), 330);
905 assert_eq!(state.elapsed_secs, 1.25);
906 state.step_yaw(1);
907 assert_eq!(state.yaw_deg(), 0);
908 for _ in 0..12 {
909 state.step_yaw(1);
910 }
911 assert_eq!(state.yaw_deg(), 0);
912 assert_eq!(PAPERDOLL_YAW_COUNT, 12);
913
914 state.pitch_row = ZERO_PITCH_ROW;
915 state.step_pitch(1);
916 assert_eq!(state.pitch_deg(), 30);
917 state.step_pitch(1);
918 assert_eq!(state.pitch_deg(), 60);
919 state.step_pitch(1);
920 assert_eq!(state.pitch_deg(), 60);
921 state.step_pitch(-3);
922 assert_eq!(state.pitch_deg(), -30);
923 state.step_pitch(-1);
924 assert_eq!(state.pitch_deg(), -60);
925 state.step_pitch(-1);
926 assert_eq!(state.pitch_deg(), -60);
927 assert_eq!(state.elapsed_secs, 1.25);
928 }
929
930 #[test]
931 fn playback_accumulates_elapsed_and_loops_by_fps() {
932 let mut state = Preview2dState {
933 fps: 10,
934 clip_frames: 4,
935 clip_duration: 0.4,
936 playback_speed: 1.0,
937 playing: true,
938 ..Preview2dState::default()
939 };
940 assert_eq!(state.frame_index(), 0);
941 state.tick(0.15);
942 assert_eq!(state.frame_index(), 1);
943 state.tick(0.25);
944 assert_eq!(state.frame_index(), 0); // wrapped: 0.4s * 10fps = frame 0 again after loop
945 // 0.15+0.25=0.40 → wrap to 0 → frame 0
946 state.playing = false;
947 let frozen = state.elapsed_secs;
948 state.tick(1.0);
949 assert_eq!(state.elapsed_secs, frozen);
950 state.playing = true;
951 state.playback_speed = 2.0;
952 state.elapsed_secs = 0.0;
953 state.tick(0.05);
954 // elapsed += 0.05*2 = 0.10; samples = 0.10*10 = 1 → frame 1
955 assert_eq!(state.frame_index(), 1);
956 }
957
958 #[test]
959 fn light_hold_uses_dt_and_stops_on_release_or_cancel() {
960 let mut state = Preview2dState::default();
961 let start_az = state.light_azimuth_deg;
962 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
963 // Per-tick dt is capped at 0.25s (hitch guard).
964 state.tick(0.25);
965 state.tick(0.25);
966 let mid = state.light_azimuth_deg;
967 assert!((mid - start_az - 45.0).abs() < 1e-3);
968 state.release_light(LightAxis::Azimuth);
969 state.tick(0.25);
970 assert_eq!(state.light_azimuth_deg, mid);
971
972 state.hold_light(LightAxis::Elevation, HoldSign::Positive);
973 state.tick(0.25); // +15°
974 assert!(state.light_elevation_deg <= LIGHT_ELEVATION_MAX_DEG);
975 // Drive to clamp
976 state.light_elevation_deg = LIGHT_ELEVATION_MAX_DEG - 1.0;
977 state.tick(0.25);
978 assert_eq!(state.light_elevation_deg, LIGHT_ELEVATION_MAX_DEG);
979 state.on_interaction_cancel();
980 assert!(state.hold_azimuth.is_none() && state.hold_elevation.is_none());
981
982 state.light_azimuth_deg = 350.0;
983 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
984 state.tick(0.2); // +18 → 368 → 8
985 assert!((state.light_azimuth_deg - 8.0).abs() < 1e-3);
986 }
987
988 #[test]
989 fn light_direction_convention_front_and_up() {
990 let front = light_direction_view(0.0, 0.0);
991 assert!((front[0]).abs() < 1e-5);
992 assert!((front[1]).abs() < 1e-5);
993 assert!((front[2] - 1.0).abs() < 1e-5);
994 let up = light_direction_view(0.0, 90.0);
995 assert!((up[1] - 1.0).abs() < 1e-5);
996 let leftish = light_direction_view(90.0, 0.0);
997 assert!((leftish[0] - 1.0).abs() < 1e-5);
998 }
999
1000 #[test]
1001 fn missing_frame_is_diagnostic_not_silent_substitute() {
1002 let project = Temp::new();
1003 let dir = project.0.join("renders/male-body-atlas");
1004 write_run(
1005 &dir,
1006 "demo",
1007 "body",
1008 None,
1009 b"shared-body",
1010 &[("walk", 2, 0.5)],
1011 pixel(
1012 [10, 20, 30, 255],
1013 [128, 128, 255, 255],
1014 [0, 0, 0, 255],
1015 20000,
1016 ),
1017 );
1018 let catalog = discover_completed_runs(&project.0);
1019 let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1020 let req = ComposeRequest {
1021 body: &body,
1022 equipment: &[],
1023 clip_token: "clip000-walk",
1024 frame: 1,
1025 pitch: 60,
1026 yaw: 330,
1027 light: Light::default(),
1028 view_mode: ViewMode::Resultaat,
1029 };
1030 assert_eq!(compose_preview_frame(req.clone()).unwrap().width, 1);
1031 let err = compose_preview_frame(ComposeRequest { frame: 2, ..req }).unwrap_err();
1032 match err {
1033 Preview2dError::Missing {
1034 set, clip, frame, ..
1035 } => {
1036 assert_eq!(set, "body");
1037 assert_eq!(clip, "clip000-walk");
1038 assert_eq!(frame, 2);
1039 }
1040 other => panic!("expected Missing, got {other}"),
1041 }
1042 }
1043
1044 #[test]
1045 fn catalog_compatibility_filters_equipment_by_export_contract() {
1046 let project = Temp::new();
1047 let renders = project.0.join("renders");
1048 let clips = [("bind", 1u32, 0.0f32)];
1049 let body_bytes = b"shared-body";
1050 write_run(
1051 &renders.join("male-body-atlas"),
1052 "proj-a",
1053 "body",
1054 None,
1055 body_bytes,
1056 &clips,
1057 pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),
1058 );
1059 write_run(
1060 &renders.join("male-sword-atlas"),
1061 "proj-a",
1062 "sword",
1063 Some(&digest::sha256_hex(b"sword")),
1064 body_bytes,
1065 &clips,
1066 pixel(
1067 [200, 10, 10, 255],
1068 [255, 128, 128, 255],
1069 [180, 180, 180, 255],
1070 500,
1071 ),
1072 );
1073 // Different body fingerprint → incompatible.
1074 write_run(
1075 &renders.join("male-helm-atlas"),
1076 "proj-a",
1077 "helm",
1078 Some(&digest::sha256_hex(b"helm")),
1079 b"other-body",
1080 &clips,
1081 pixel([9, 9, 9, 255], [128, 128, 255, 255], [0, 0, 0, 255], 800),
1082 );
1083
1084 let catalog = discover_completed_runs(&project.0);
1085 assert_eq!(catalog.len(), 3);
1086 let allowed = compatible_equipment_sets(&catalog, Sex::Male);
1087 assert_eq!(allowed, vec!["sword".to_string()]);
1088 assert!(open_selection(&catalog, Sex::Male, &["helm".to_string()]).is_err());
1089
1090 let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();
1091 assert_eq!(body.ppda().settype, "body");
1092 assert_eq!(gear.len(), 1);
1093 let lit = compose_preview_frame(ComposeRequest {
1094 body: &body,
1095 equipment: &gear,
1096 clip_token: "clip000-bind",
1097 frame: 0,
1098 pitch: -60,
1099 yaw: 90,
1100 light: Light::default(),
1101 view_mode: ViewMode::Resultaat,
1102 })
1103 .unwrap();
1104 assert_eq!(lit.rgba[3], 255);
1105 // Foreground equipment (closer depth) wins.
1106 assert!(lit.rgba[0] > 100);
1107 }
1108
1109 #[test]
1110 fn playback_loops_on_frames_over_fps_not_source_duration() {
1111 // Non-integer duration: 1.1s at 4 fps → ceil = 5 frames.
1112 // Each frame must last a full 1/fps = 0.25s, so the loop wraps at 1.25s
1113 // (not at 1.1s, which would shorten the last frame to 0.1s).
1114 let mut state = Preview2dState {
1115 fps: 4,
1116 clip_frames: 5,
1117 clip_duration: 1.1,
1118 playback_speed: 1.0,
1119 playing: true,
1120 elapsed_secs: 0.0,
1121 ..Preview2dState::default()
1122 };
1123 // Frames 0..3 occupy [0.00,1.00); frame 4 occupies [1.00,1.25).
1124 state.elapsed_secs = 1.0;
1125 assert_eq!(state.frame_index(), 4, "last frame starts at 1.0s");
1126 state.tick(0.2); // 1.20 — still last frame
1127 assert!((state.elapsed_secs - 1.2).abs() < 1e-5);
1128 assert_eq!(state.frame_index(), 4, "last frame must last full 0.25s");
1129 state.tick(0.05); // 1.25 → wrap to 0
1130 assert!(
1131 state.elapsed_secs < 1e-5,
1132 "wrap at frames/fps=1.25, got {}",
1133 state.elapsed_secs
1134 );
1135 assert_eq!(state.frame_index(), 0);
1136 // Old bug: wrapping on clip_duration=1.1 would have wrapped at 1.1 → never a full last frame.
1137 state.elapsed_secs = 1.05;
1138 assert_eq!(state.frame_index(), 4);
1139 state.tick(0.1); // 1.15 — still within 1.25 loop (would have wrapped under the bug)
1140 assert!(
1141 (state.elapsed_secs - 1.15).abs() < 1e-5,
1142 "must NOT wrap on source duration 1.1; elapsed={}",
1143 state.elapsed_secs
1144 );
1145 assert_eq!(state.frame_index(), 4);
1146 }
1147
1148 #[test]
1149 fn azimuth_hold_wraps_full_circle_continuously() {
1150 let mut state = Preview2dState {
1151 light_azimuth_deg: 10.0,
1152 ..Preview2dState::default()
1153 };
1154 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
1155 // 90°/s * many ticks past 360.
1156 for _ in 0..20 {
1157 state.tick(0.25); // +22.5 each, capped dt
1158 }
1159 // 20 * 22.5 = 450 → 10+450 = 460 → 100
1160 assert!(
1161 (state.light_azimuth_deg - 100.0).abs() < 1e-2,
1162 "got {}",
1163 state.light_azimuth_deg
1164 );
1165 assert!(state.light_azimuth_deg >= 0.0 && state.light_azimuth_deg < 360.0);
1166 // Negative wrap
1167 state.light_azimuth_deg = 5.0;
1168 state.hold_light(LightAxis::Azimuth, HoldSign::Negative);
1169 state.tick(0.25); // -22.5 → -17.5 → 342.5
1170 assert!((state.light_azimuth_deg - 342.5).abs() < 1e-2);
1171 assert_eq!(wrap_azimuth_deg(-0.1), 359.9);
1172 assert_eq!(wrap_azimuth_deg(360.0), 0.0);
1173 assert_eq!(wrap_azimuth_deg(720.5), 0.5);
1174 }
1175
1176 #[test]
1177 fn view_modes_show_composited_maps_unlit_except_resultaat() {
1178 let project = Temp::new();
1179 // Distinct channels so modes are distinguishable; scalar specular.
1180 write_run(
1181 &project.0.join("renders/male-body-atlas"),
1182 "demo",
1183 "body",
1184 None,
1185 b"shared-body",
1186 &[("bind", 1, 0.0)],
1187 pixel(
1188 [10, 20, 30, 255],
1189 [40, 50, 60, 255],
1190 [70, 70, 70, 255],
1191 32768,
1192 ),
1193 );
1194 let catalog = discover_completed_runs(&project.0);
1195 let (run, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1196 let base = ComposeRequest {
1197 body: &run,
1198 equipment: &[],
1199 clip_token: "clip000-bind",
1200 frame: 0,
1201 pitch: 0,
1202 yaw: 0,
1203 light: Light::default(),
1204 view_mode: ViewMode::Resultaat,
1205 };
1206 let diffuse = compose_preview_frame(ComposeRequest {
1207 view_mode: ViewMode::DiffuseOnly,
1208 ..base.clone()
1209 })
1210 .unwrap();
1211 assert_eq!(&diffuse.rgba[0..4], &[10, 20, 30, 255]);
1212
1213 let normal = compose_preview_frame(ComposeRequest {
1214 view_mode: ViewMode::NormalOnly,
1215 ..base.clone()
1216 })
1217 .unwrap();
1218 assert_eq!(&normal.rgba[0..4], &[40, 50, 60, 255]);
1219
1220 let specular = compose_preview_frame(ComposeRequest {
1221 view_mode: ViewMode::SpecularOnly,
1222 ..base.clone()
1223 })
1224 .unwrap();
1225 assert_eq!(&specular.rgba[0..4], &[70, 70, 70, 255]);
1226
1227 let depth = compose_preview_frame(ComposeRequest {
1228 view_mode: ViewMode::DepthOnly,
1229 ..base.clone()
1230 })
1231 .unwrap();
1232 // 32768/65535 ≈ 0.5 → ~128
1233 assert_eq!(depth.rgba[0], depth.rgba[1]);
1234 assert_eq!(depth.rgba[1], depth.rgba[2]);
1235 assert!((depth.rgba[0] as i32 - 128).abs() <= 1);
1236 assert_eq!(depth.rgba[3], 255);
1237
1238 let result = compose_preview_frame(ComposeRequest {
1239 view_mode: ViewMode::Resultaat,
1240 ..base.clone()
1241 })
1242 .unwrap();
1243 // Relit differs from raw diffuse under default light.
1244 assert_ne!(&result.rgba[0..3], &diffuse.rgba[0..3]);
1245 assert_eq!(result.rgba[3], 255);
1246
1247 // Light must not change map modes.
1248 let dark = Light {
1249 ambient: 0.0,
1250 diffuse: 0.0,
1251 specular: 0.0,
1252 ..Light::default()
1253 };
1254 let diffuse_dark = compose_preview_frame(ComposeRequest {
1255 light: dark,
1256 view_mode: ViewMode::DiffuseOnly,
1257 ..base
1258 })
1259 .unwrap();
1260 assert_eq!(diffuse_dark.rgba, diffuse.rgba);
1261 }
1262
1263 #[test]
1264 fn discover_skips_incomplete_and_wrong_schema() {
1265 let project = Temp::new();
1266 let renders = project.0.join("renders");
1267 let incomplete = renders.join("male-body-atlas");
1268 fs::create_dir_all(&incomplete).unwrap();
1269 fs::write(incomplete.join("render.json"), b"{}").unwrap();
1270 // no .ppda at all
1271 fs::create_dir_all(renders.join("run-legacy")).unwrap();
1272 fs::create_dir_all(renders.join("inprogress-1-1-male-body")).unwrap();
1273 let (catalog, skipped) = discover_exports(&project.0);
1274 assert!(catalog.is_empty());
1275 assert_eq!(skipped.len(), 2, "{skipped:?}");
1276 assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);
1277 assert!(skipped[1].1.contains("oude run-map"), "{}", skipped[1].1);
1278 }
1279
1280 #[test]
1281 fn preview_reads_atlas_not_loose_pngs() {
1282 // Each view gets a distinct colour; the preview must return exactly the
1283 // atlas cell even after every loose PNG is deleted.
1284 let project = Temp::new();
1285 let dir = project.0.join("renders/male-body-atlas");
1286 let mut spec = SyntheticRun::body("demo", "male", b"b");
1287 spec.clips = vec![("walk".into(), 2, 0.5)];
1288 spec.target = [2, 3];
1289 spec.write(&dir, |_, f, p, y| {
1290 pixel(
1291 [(y / 30) as u8 * 20, (p + 60) as u8, f as u8 * 100 + 1, 255],
1292 [128, 128, 255, 255],
1293 [0, 0, 0, 255],
1294 1000,
1295 )
1296 })
1297 .unwrap();
1298 // Atlas-only fixtures already omit loose PNGs; tolerate a leftover.
1299 let _ = fs::remove_dir_all(dir.join("raw files"));
1300 let _ = fs::remove_dir_all(dir.join("checkpoint"));
1301 let catalog = discover_completed_runs(&project.0);
1302 assert_eq!(catalog.len(), 1);
1303 assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);
1304 let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1305 for (frame, pitch, yaw) in [(0u32, -60i32, 0u32), (1, 60, 330), (1, -30, 150)] {
1306 let img = compose_preview_frame(ComposeRequest {
1307 body: &body,
1308 equipment: &[],
1309 clip_token: "clip000-walk",
1310 frame,
1311 pitch,
1312 yaw,
1313 light: Light::default(),
1314 view_mode: ViewMode::DiffuseOnly,
1315 })
1316 .unwrap();
1317 assert_eq!((img.width, img.height), (2, 3));
1318 let want = [
1319 (yaw / 30) as u8 * 20,
1320 (pitch + 60) as u8,
1321 frame as u8 * 100 + 1,
1322 255,
1323 ];
1324 for px in img.rgba.chunks_exact(4) {
1325 assert_eq!(px, want);
1326 }
1327 }
1328 }
1329
1330 #[test]
1331 fn depth_view_shows_stored_pixels_as_is_resultaat_still_decodes() {
1332 use crate::camera::{DepthEncoding, DepthFormat};
1333 let depth = 40000u16;
1334 let mut gray16_result = None;
1335 for bits in [16u8, 8] {
1336 for encoding in DepthEncoding::ALL {
1337 let fmt = DepthFormat::new(bits, encoding).unwrap();
1338 let project = Temp::new();
1339 let dir = project.0.join("renders/male-body-atlas");
1340 let mut spec = SyntheticRun::body("demo", "male", b"b");
1341 spec.clips = vec![("walk".into(), 1, 0.5)];
1342 spec.target = [2, 2];
1343 spec.depth_format = fmt;
1344 spec.write(&dir, |_, _, _, _| {
1345 pixel(
1346 [200, 100, 50, 255],
1347 [128, 128, 255, 255],
1348 [0, 0, 0, 255],
1349 depth,
1350 )
1351 })
1352 .unwrap();
1353 let catalog = discover_completed_runs(&project.0);
1354 let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1355 let request = |view_mode| ComposeRequest {
1356 body: &body,
1357 equipment: &[],
1358 clip_token: "clip000-walk",
1359 frame: 0,
1360 pitch: 0,
1361 yaw: 0,
1362 light: Light::default(),
1363 view_mode,
1364 };
1365 let shown = compose_preview_frame(request(ViewMode::DepthOnly)).unwrap();
1366 // Expected: the stored channels, untranslated; 16-bit → 8-bit display.
1367 let raw = fmt.encode_value(f64::from(depth) / 65535.);
1368 let to8 = |v: u64| {
1369 if bits == 16 {
1370 ((v * 255 + 32767) / 65535) as u8
1371 } else {
1372 v as u8
1373 }
1374 };
1375 let want = match encoding {
1376 DepthEncoding::Gray => [to8(raw[0]), to8(raw[0]), to8(raw[0]), 255],
1377 _ => [to8(raw[0]), to8(raw[1]), to8(raw[2]), 255],
1378 };
1379 for px in shown.rgba.chunks_exact(4) {
1380 assert_eq!(px, want, "{fmt:?}");
1381 }
1382 // 16-bit rgb_packed of a 16-bit canonical value d stores d in every
1383 // channel (d/65535 repeats in base 65536), so only check the others.
1384 if encoding == DepthEncoding::RgbBands
1385 || (encoding == DepthEncoding::RgbPacked && bits == 8)
1386 {
1387 assert!(
1388 want[0] != want[1] || want[1] != want[2],
1389 "{fmt:?}: raw RGB must not look like decoded gray: {want:?}"
1390 );
1391 }
1392 // Resultaat decodes depth: same relit image as gray16 within 1 level.
1393 let lit = compose_preview_frame(request(ViewMode::Resultaat)).unwrap();
1394 if bits == 16 {
1395 match &gray16_result {
1396 None => gray16_result = Some(lit.rgba.clone()),
1397 Some(reference) => {
1398 for (a, b) in lit.rgba.iter().zip(reference) {
1399 assert!((*a as i32 - *b as i32).abs() <= 1, "{fmt:?}");
1400 }
1401 }
1402 }
1403 }
1404 }
1405 }
1406 }
1407
1408 #[test]
1409 fn five_pitch_rows_clamp_and_default_is_zero() {
1410 let mut state = Preview2dState::default();
1411 assert_eq!(state.pitch_deg(), 0);
1412 state.step_pitch(1);
1413 state.step_pitch(1);
1414 assert_eq!(state.pitch_deg(), 60);
1415 state.step_pitch(1);
1416 assert_eq!(state.pitch_deg(), 60);
1417 for _ in 0..10 {
1418 state.step_pitch(-1);
1419 }
1420 assert_eq!(state.pitch_deg(), -60);
1421 assert_eq!(pitch_row_from_deg(30), 3);
1422 assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);
1423 assert_eq!(state.fps, crate::project::DEFAULT_FPS);
1424 }
1425
1426 /// Regression (t964u): a light button held for several seconds keeps
1427 /// rotating (egui click sense alone stopped after ~1 s).
1428 #[test]
1429 fn light_hold_button_keeps_rotating_for_multi_second_hold() {
1430 use eframe::egui;
1431 let ctx = egui::Context::default();
1432 let rect = std::cell::Cell::new(egui::Rect::NOTHING);
1433 let mut state = Preview2dState::default();
1434 let start_az = state.light_azimuth_deg;
1435 let dt = 1.0 / 60.0;
1436 let frame = |events: Vec<egui::Event>, t: f64, state: &mut Preview2dState| {
1437 let input = egui::RawInput {
1438 screen_rect: Some(egui::Rect::from_min_size(
1439 egui::Pos2::ZERO,
1440 egui::vec2(400., 300.),
1441 )),
1442 time: Some(t),
1443 predicted_dt: dt,
1444 focused: true,
1445 events,
1446 ..Default::default()
1447 };
1448 let mut held = false;
1449 let _ = ctx.run(input, |ctx| {
1450 egui::CentralPanel::default().show(ctx, |ui| {
1451 let r = ui.button("Azimut +");
1452 rect.set(r.rect);
1453 held = ui.ctx().input(|i| raw_pointer_hold(r.rect, i));
1454 });
1455 });
1456 if held {
1457 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
1458 } else {
1459 state.release_light(LightAxis::Azimuth);
1460 }
1461 held
1462 };
1463 // Layout frame to learn the button rect.
1464 assert!(!frame(vec![], 0.0, &mut state));
1465 let at = rect.get().center();
1466 let press = vec![
1467 egui::Event::PointerMoved(at),
1468 egui::Event::PointerButton {
1469 pos: at,
1470 button: egui::PointerButton::Primary,
1471 pressed: true,
1472 modifiers: Default::default(),
1473 },
1474 ];
1475 assert!(frame(press, dt as f64, &mut state));
1476 // 3.5 s without any further input events, button still physically down.
1477 let frames = 210;
1478 for n in 0..frames {
1479 state.tick(dt);
1480 assert!(
1481 frame(vec![], (n + 2) as f64 * dt as f64, &mut state),
1482 "hold dropped after {:.2} s",
1483 n as f32 * dt
1484 );
1485 }
1486 let expected =
1487 wrap_azimuth_deg(start_az + LIGHT_AZIMUTH_RATE_DEG_PER_SEC * dt * frames as f32);
1488 let diff = (state.light_azimuth_deg - expected).abs();
1489 assert!(
1490 diff.min(360. - diff) < 0.5,
1491 "{} vs {expected}",
1492 state.light_azimuth_deg
1493 );
1494 // Release stops it.
1495 let release = vec![egui::Event::PointerButton {
1496 pos: at,
1497 button: egui::PointerButton::Primary,
1498 pressed: false,
1499 modifiers: Default::default(),
1500 }];
1501 assert!(!frame(release, 4.0, &mut state));
1502 assert!(state.hold_azimuth.is_none());
1503 // Pointer leaving the button / window stops a hold too.
1504 let press = vec![egui::Event::PointerButton {
1505 pos: at,
1506 button: egui::PointerButton::Primary,
1507 pressed: true,
1508 modifiers: Default::default(),
1509 }];
1510 assert!(frame(press, 4.1, &mut state));
1511 assert!(!frame(vec![egui::Event::PointerGone], 4.2, &mut state));
1512 // A press that started elsewhere does not count when dragged onto it.
1513 let away = egui::pos2(390., 290.);
1514 let drag = vec![
1515 egui::Event::PointerButton {
1516 pos: at,
1517 button: egui::PointerButton::Primary,
1518 pressed: false,
1519 modifiers: Default::default(),
1520 },
1521 egui::Event::PointerMoved(away),
1522 ];
1523 frame(drag, 4.3, &mut state);
1524 let press = vec![egui::Event::PointerButton {
1525 pos: away,
1526 button: egui::PointerButton::Primary,
1527 pressed: true,
1528 modifiers: Default::default(),
1529 }];
1530 assert!(!frame(press, 4.4, &mut state));
1531 assert!(!frame(vec![egui::Event::PointerMoved(at)], 4.5, &mut state));
1532 }
1533
1534 #[test]
1535 fn export_preview_frame_writes_maps_and_lit_preview() {
1536 let project = Temp::new();
1537 let dir = project.0.join("renders/male-body-atlas");
1538 let mut spec = SyntheticRun::body("export-frame", "male", b"body");
1539 spec.clips = vec![("walk".into(), 1, 0.25)];
1540 spec.target = [2, 2];
1541 spec.write(&dir, |_, _, _, _| Pixel {
1542 diffuse: [40, 80, 120, 255],
1543 normal: [128, 128, 255, 255],
1544 specular: [10, 10, 10, 255],
1545 depth: 20000,
1546 })
1547 .unwrap();
1548 let set = AtlasSet::open_run(&dir).unwrap();
1549 let body = set.load().unwrap();
1550 let out = Temp::new();
1551 let written = export_preview_frame(
1552 &out.0,
1553 &ComposeRequest {
1554 body: &body,
1555 equipment: &[],
1556 clip_token: "clip000-walk",
1557 frame: 0,
1558 pitch: 0,
1559 yaw: 0,
1560 light: Light::default(),
1561 view_mode: ViewMode::Resultaat,
1562 },
1563 )
1564 .unwrap();
1565 assert_eq!(written.len(), 5);
1566 for name in [
1567 "clip000-walk_f000000_p+00_y000_diffuse.png",
1568 "clip000-walk_f000000_p+00_y000_normal.png",
1569 "clip000-walk_f000000_p+00_y000_specular.png",
1570 "clip000-walk_f000000_p+00_y000_depth.png",
1571 "clip000-walk_f000000_p+00_y000_preview.png",
1572 ] {
1573 assert!(out.0.join(name).is_file(), "{name}");
1574 }
1575 // Diffuse keeps sRGB chunk; preview is lit (not identical to unlit diffuse).
1576 let diffuse = fs::read(out.0.join("clip000-walk_f000000_p+00_y000_diffuse.png")).unwrap();
1577 let preview = fs::read(out.0.join("clip000-walk_f000000_p+00_y000_preview.png")).unwrap();
1578 assert_ne!(diffuse, preview);
1579 assert!(diffuse.windows(4).any(|w| w == b"sRGB") || diffuse.len() > 100);
1580 }
1581}