BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

preview2d.rs 49.3 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#[cfg(test)]
739mod tests {
740 use super::*;
741 use crate::digest;
742 use crate::export_fixture::{Pixel, SyntheticRun};
743 use crate::project::CameraFraming;
744 use std::sync::atomic::{AtomicU64, Ordering};
745
746 static COUNTER: AtomicU64 = AtomicU64::new(0);
747
748 struct Temp(PathBuf);
749 impl Temp {
750 fn new() -> Self {
751 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
752 let path = std::env::temp_dir().join(format!(
753 "paperdoll-preview2d-{}-{}-{}",
754 std::process::id(),
755 n,
756 std::time::SystemTime::now()
757 .duration_since(std::time::UNIX_EPOCH)
758 .unwrap()
759 .as_nanos()
760 ));
761 fs::create_dir_all(&path).unwrap();
762 Self(path)
763 }
764 }
765 impl Drop for Temp {
766 fn drop(&mut self) {
767 let _ = fs::remove_dir_all(&self.0);
768 }
769 }
770
771 fn framing() -> CameraFraming {
772 CameraFraming {
773 height_milli: 1800,
774 center_y_milli: 900,
775 pivot_y_milli: Some(1500),
776 depth_range_milli: None,
777 }
778 }
779
780 fn pixel(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
781 Pixel {
782 diffuse,
783 normal,
784 specular,
785 depth,
786 }
787 }
788
789 /// Complete synthetic run (all 60 views) with uniform maps per run.
790 #[allow(clippy::too_many_arguments)]
791 fn write_run(
792 root: &Path,
793 project_id: &str,
794 set: &str,
795 equipment_sha: Option<&str>,
796 body_bytes: &[u8],
797 clips: &[(&str, u32, f32)],
798 px: Pixel,
799 ) {
800 let mut spec = SyntheticRun::body(project_id, "male", body_bytes);
801 spec.framing = framing();
802 spec.set = set.into();
803 spec.equipment_sha256 = equipment_sha.map(str::to_string);
804 spec.clips = clips
805 .iter()
806 .map(|(n, f, d)| (n.to_string(), *f, *d))
807 .collect();
808 spec.write(root, |_, _, _, _| px).unwrap();
809 }
810
811 #[test]
812 fn yaw_wraps_twelve_steps_pitch_clamps_five_rows() {
813 let mut state = Preview2dState {
814 elapsed_secs: 1.25,
815 ..Preview2dState::default()
816 };
817 assert_eq!(state.yaw_deg(), 0);
818 state.step_yaw(-1);
819 assert_eq!(state.yaw_deg(), 330);
820 assert_eq!(state.elapsed_secs, 1.25);
821 state.step_yaw(1);
822 assert_eq!(state.yaw_deg(), 0);
823 for _ in 0..12 {
824 state.step_yaw(1);
825 }
826 assert_eq!(state.yaw_deg(), 0);
827 assert_eq!(PAPERDOLL_YAW_COUNT, 12);
828
829 state.pitch_row = ZERO_PITCH_ROW;
830 state.step_pitch(1);
831 assert_eq!(state.pitch_deg(), 30);
832 state.step_pitch(1);
833 assert_eq!(state.pitch_deg(), 60);
834 state.step_pitch(1);
835 assert_eq!(state.pitch_deg(), 60);
836 state.step_pitch(-3);
837 assert_eq!(state.pitch_deg(), -30);
838 state.step_pitch(-1);
839 assert_eq!(state.pitch_deg(), -60);
840 state.step_pitch(-1);
841 assert_eq!(state.pitch_deg(), -60);
842 assert_eq!(state.elapsed_secs, 1.25);
843 }
844
845 #[test]
846 fn playback_accumulates_elapsed_and_loops_by_fps() {
847 let mut state = Preview2dState {
848 fps: 10,
849 clip_frames: 4,
850 clip_duration: 0.4,
851 playback_speed: 1.0,
852 playing: true,
853 ..Preview2dState::default()
854 };
855 assert_eq!(state.frame_index(), 0);
856 state.tick(0.15);
857 assert_eq!(state.frame_index(), 1);
858 state.tick(0.25);
859 assert_eq!(state.frame_index(), 0); // wrapped: 0.4s * 10fps = frame 0 again after loop
860 // 0.15+0.25=0.40 → wrap to 0 → frame 0
861 state.playing = false;
862 let frozen = state.elapsed_secs;
863 state.tick(1.0);
864 assert_eq!(state.elapsed_secs, frozen);
865 state.playing = true;
866 state.playback_speed = 2.0;
867 state.elapsed_secs = 0.0;
868 state.tick(0.05);
869 // elapsed += 0.05*2 = 0.10; samples = 0.10*10 = 1 → frame 1
870 assert_eq!(state.frame_index(), 1);
871 }
872
873 #[test]
874 fn light_hold_uses_dt_and_stops_on_release_or_cancel() {
875 let mut state = Preview2dState::default();
876 let start_az = state.light_azimuth_deg;
877 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
878 // Per-tick dt is capped at 0.25s (hitch guard).
879 state.tick(0.25);
880 state.tick(0.25);
881 let mid = state.light_azimuth_deg;
882 assert!((mid - start_az - 45.0).abs() < 1e-3);
883 state.release_light(LightAxis::Azimuth);
884 state.tick(0.25);
885 assert_eq!(state.light_azimuth_deg, mid);
886
887 state.hold_light(LightAxis::Elevation, HoldSign::Positive);
888 state.tick(0.25); // +15°
889 assert!(state.light_elevation_deg <= LIGHT_ELEVATION_MAX_DEG);
890 // Drive to clamp
891 state.light_elevation_deg = LIGHT_ELEVATION_MAX_DEG - 1.0;
892 state.tick(0.25);
893 assert_eq!(state.light_elevation_deg, LIGHT_ELEVATION_MAX_DEG);
894 state.on_interaction_cancel();
895 assert!(state.hold_azimuth.is_none() && state.hold_elevation.is_none());
896
897 state.light_azimuth_deg = 350.0;
898 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
899 state.tick(0.2); // +18 → 368 → 8
900 assert!((state.light_azimuth_deg - 8.0).abs() < 1e-3);
901 }
902
903 #[test]
904 fn light_direction_convention_front_and_up() {
905 let front = light_direction_view(0.0, 0.0);
906 assert!((front[0]).abs() < 1e-5);
907 assert!((front[1]).abs() < 1e-5);
908 assert!((front[2] - 1.0).abs() < 1e-5);
909 let up = light_direction_view(0.0, 90.0);
910 assert!((up[1] - 1.0).abs() < 1e-5);
911 let leftish = light_direction_view(90.0, 0.0);
912 assert!((leftish[0] - 1.0).abs() < 1e-5);
913 }
914
915 #[test]
916 fn missing_frame_is_diagnostic_not_silent_substitute() {
917 let project = Temp::new();
918 let dir = project.0.join("renders/male-body-atlas");
919 write_run(
920 &dir,
921 "demo",
922 "body",
923 None,
924 b"shared-body",
925 &[("walk", 2, 0.5)],
926 pixel(
927 [10, 20, 30, 255],
928 [128, 128, 255, 255],
929 [0, 0, 0, 255],
930 20000,
931 ),
932 );
933 let catalog = discover_completed_runs(&project.0);
934 let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
935 let req = ComposeRequest {
936 body: &body,
937 equipment: &[],
938 clip_token: "clip000-walk",
939 frame: 1,
940 pitch: 60,
941 yaw: 330,
942 light: Light::default(),
943 view_mode: ViewMode::Resultaat,
944 };
945 assert_eq!(compose_preview_frame(req.clone()).unwrap().width, 1);
946 let err = compose_preview_frame(ComposeRequest { frame: 2, ..req }).unwrap_err();
947 match err {
948 Preview2dError::Missing {
949 set, clip, frame, ..
950 } => {
951 assert_eq!(set, "body");
952 assert_eq!(clip, "clip000-walk");
953 assert_eq!(frame, 2);
954 }
955 other => panic!("expected Missing, got {other}"),
956 }
957 }
958
959 #[test]
960 fn catalog_compatibility_filters_equipment_by_export_contract() {
961 let project = Temp::new();
962 let renders = project.0.join("renders");
963 let clips = [("bind", 1u32, 0.0f32)];
964 let body_bytes = b"shared-body";
965 write_run(
966 &renders.join("male-body-atlas"),
967 "proj-a",
968 "body",
969 None,
970 body_bytes,
971 &clips,
972 pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),
973 );
974 write_run(
975 &renders.join("male-sword-atlas"),
976 "proj-a",
977 "sword",
978 Some(&digest::sha256_hex(b"sword")),
979 body_bytes,
980 &clips,
981 pixel(
982 [200, 10, 10, 255],
983 [255, 128, 128, 255],
984 [180, 180, 180, 255],
985 500,
986 ),
987 );
988 // Different body fingerprint → incompatible.
989 write_run(
990 &renders.join("male-helm-atlas"),
991 "proj-a",
992 "helm",
993 Some(&digest::sha256_hex(b"helm")),
994 b"other-body",
995 &clips,
996 pixel([9, 9, 9, 255], [128, 128, 255, 255], [0, 0, 0, 255], 800),
997 );
998
999 let catalog = discover_completed_runs(&project.0);
1000 assert_eq!(catalog.len(), 3);
1001 let allowed = compatible_equipment_sets(&catalog, Sex::Male);
1002 assert_eq!(allowed, vec!["sword".to_string()]);
1003 assert!(open_selection(&catalog, Sex::Male, &["helm".to_string()]).is_err());
1004
1005 let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();
1006 assert_eq!(body.ppda().settype, "body");
1007 assert_eq!(gear.len(), 1);
1008 let lit = compose_preview_frame(ComposeRequest {
1009 body: &body,
1010 equipment: &gear,
1011 clip_token: "clip000-bind",
1012 frame: 0,
1013 pitch: -60,
1014 yaw: 90,
1015 light: Light::default(),
1016 view_mode: ViewMode::Resultaat,
1017 })
1018 .unwrap();
1019 assert_eq!(lit.rgba[3], 255);
1020 // Foreground equipment (closer depth) wins.
1021 assert!(lit.rgba[0] > 100);
1022 }
1023
1024 #[test]
1025 fn playback_loops_on_frames_over_fps_not_source_duration() {
1026 // Non-integer duration: 1.1s at 4 fps → ceil = 5 frames.
1027 // Each frame must last a full 1/fps = 0.25s, so the loop wraps at 1.25s
1028 // (not at 1.1s, which would shorten the last frame to 0.1s).
1029 let mut state = Preview2dState {
1030 fps: 4,
1031 clip_frames: 5,
1032 clip_duration: 1.1,
1033 playback_speed: 1.0,
1034 playing: true,
1035 elapsed_secs: 0.0,
1036 ..Preview2dState::default()
1037 };
1038 // Frames 0..3 occupy [0.00,1.00); frame 4 occupies [1.00,1.25).
1039 state.elapsed_secs = 1.0;
1040 assert_eq!(state.frame_index(), 4, "last frame starts at 1.0s");
1041 state.tick(0.2); // 1.20 — still last frame
1042 assert!((state.elapsed_secs - 1.2).abs() < 1e-5);
1043 assert_eq!(state.frame_index(), 4, "last frame must last full 0.25s");
1044 state.tick(0.05); // 1.25 → wrap to 0
1045 assert!(
1046 state.elapsed_secs < 1e-5,
1047 "wrap at frames/fps=1.25, got {}",
1048 state.elapsed_secs
1049 );
1050 assert_eq!(state.frame_index(), 0);
1051 // Old bug: wrapping on clip_duration=1.1 would have wrapped at 1.1 → never a full last frame.
1052 state.elapsed_secs = 1.05;
1053 assert_eq!(state.frame_index(), 4);
1054 state.tick(0.1); // 1.15 — still within 1.25 loop (would have wrapped under the bug)
1055 assert!(
1056 (state.elapsed_secs - 1.15).abs() < 1e-5,
1057 "must NOT wrap on source duration 1.1; elapsed={}",
1058 state.elapsed_secs
1059 );
1060 assert_eq!(state.frame_index(), 4);
1061 }
1062
1063 #[test]
1064 fn azimuth_hold_wraps_full_circle_continuously() {
1065 let mut state = Preview2dState {
1066 light_azimuth_deg: 10.0,
1067 ..Preview2dState::default()
1068 };
1069 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
1070 // 90°/s * many ticks past 360.
1071 for _ in 0..20 {
1072 state.tick(0.25); // +22.5 each, capped dt
1073 }
1074 // 20 * 22.5 = 450 → 10+450 = 460 → 100
1075 assert!(
1076 (state.light_azimuth_deg - 100.0).abs() < 1e-2,
1077 "got {}",
1078 state.light_azimuth_deg
1079 );
1080 assert!(state.light_azimuth_deg >= 0.0 && state.light_azimuth_deg < 360.0);
1081 // Negative wrap
1082 state.light_azimuth_deg = 5.0;
1083 state.hold_light(LightAxis::Azimuth, HoldSign::Negative);
1084 state.tick(0.25); // -22.5 → -17.5 → 342.5
1085 assert!((state.light_azimuth_deg - 342.5).abs() < 1e-2);
1086 assert_eq!(wrap_azimuth_deg(-0.1), 359.9);
1087 assert_eq!(wrap_azimuth_deg(360.0), 0.0);
1088 assert_eq!(wrap_azimuth_deg(720.5), 0.5);
1089 }
1090
1091 #[test]
1092 fn view_modes_show_composited_maps_unlit_except_resultaat() {
1093 let project = Temp::new();
1094 // Distinct channels so modes are distinguishable; scalar specular.
1095 write_run(
1096 &project.0.join("renders/male-body-atlas"),
1097 "demo",
1098 "body",
1099 None,
1100 b"shared-body",
1101 &[("bind", 1, 0.0)],
1102 pixel(
1103 [10, 20, 30, 255],
1104 [40, 50, 60, 255],
1105 [70, 70, 70, 255],
1106 32768,
1107 ),
1108 );
1109 let catalog = discover_completed_runs(&project.0);
1110 let (run, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1111 let base = ComposeRequest {
1112 body: &run,
1113 equipment: &[],
1114 clip_token: "clip000-bind",
1115 frame: 0,
1116 pitch: 0,
1117 yaw: 0,
1118 light: Light::default(),
1119 view_mode: ViewMode::Resultaat,
1120 };
1121 let diffuse = compose_preview_frame(ComposeRequest {
1122 view_mode: ViewMode::DiffuseOnly,
1123 ..base.clone()
1124 })
1125 .unwrap();
1126 assert_eq!(&diffuse.rgba[0..4], &[10, 20, 30, 255]);
1127
1128 let normal = compose_preview_frame(ComposeRequest {
1129 view_mode: ViewMode::NormalOnly,
1130 ..base.clone()
1131 })
1132 .unwrap();
1133 assert_eq!(&normal.rgba[0..4], &[40, 50, 60, 255]);
1134
1135 let specular = compose_preview_frame(ComposeRequest {
1136 view_mode: ViewMode::SpecularOnly,
1137 ..base.clone()
1138 })
1139 .unwrap();
1140 assert_eq!(&specular.rgba[0..4], &[70, 70, 70, 255]);
1141
1142 let depth = compose_preview_frame(ComposeRequest {
1143 view_mode: ViewMode::DepthOnly,
1144 ..base.clone()
1145 })
1146 .unwrap();
1147 // 32768/65535 ≈ 0.5 → ~128
1148 assert_eq!(depth.rgba[0], depth.rgba[1]);
1149 assert_eq!(depth.rgba[1], depth.rgba[2]);
1150 assert!((depth.rgba[0] as i32 - 128).abs() <= 1);
1151 assert_eq!(depth.rgba[3], 255);
1152
1153 let result = compose_preview_frame(ComposeRequest {
1154 view_mode: ViewMode::Resultaat,
1155 ..base.clone()
1156 })
1157 .unwrap();
1158 // Relit differs from raw diffuse under default light.
1159 assert_ne!(&result.rgba[0..3], &diffuse.rgba[0..3]);
1160 assert_eq!(result.rgba[3], 255);
1161
1162 // Light must not change map modes.
1163 let dark = Light {
1164 ambient: 0.0,
1165 diffuse: 0.0,
1166 specular: 0.0,
1167 ..Light::default()
1168 };
1169 let diffuse_dark = compose_preview_frame(ComposeRequest {
1170 light: dark,
1171 view_mode: ViewMode::DiffuseOnly,
1172 ..base
1173 })
1174 .unwrap();
1175 assert_eq!(diffuse_dark.rgba, diffuse.rgba);
1176 }
1177
1178 #[test]
1179 fn discover_skips_incomplete_and_wrong_schema() {
1180 let project = Temp::new();
1181 let renders = project.0.join("renders");
1182 let incomplete = renders.join("male-body-atlas");
1183 fs::create_dir_all(&incomplete).unwrap();
1184 fs::write(incomplete.join("render.json"), b"{}").unwrap();
1185 // no .ppda at all
1186 fs::create_dir_all(renders.join("run-legacy")).unwrap();
1187 fs::create_dir_all(renders.join("inprogress-1-1-male-body")).unwrap();
1188 let (catalog, skipped) = discover_exports(&project.0);
1189 assert!(catalog.is_empty());
1190 assert_eq!(skipped.len(), 2, "{skipped:?}");
1191 assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);
1192 assert!(skipped[1].1.contains("oude run-map"), "{}", skipped[1].1);
1193 }
1194
1195 #[test]
1196 fn preview_reads_atlas_not_loose_pngs() {
1197 // Each view gets a distinct colour; the preview must return exactly the
1198 // atlas cell even after every loose PNG is deleted.
1199 let project = Temp::new();
1200 let dir = project.0.join("renders/male-body-atlas");
1201 let mut spec = SyntheticRun::body("demo", "male", b"b");
1202 spec.clips = vec![("walk".into(), 2, 0.5)];
1203 spec.target = [2, 3];
1204 spec.write(&dir, |_, f, p, y| {
1205 pixel(
1206 [(y / 30) as u8 * 20, (p + 60) as u8, f as u8 * 100 + 1, 255],
1207 [128, 128, 255, 255],
1208 [0, 0, 0, 255],
1209 1000,
1210 )
1211 })
1212 .unwrap();
1213 fs::remove_dir_all(dir.join("raw files")).unwrap();
1214 let catalog = discover_completed_runs(&project.0);
1215 assert_eq!(catalog.len(), 1);
1216 assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);
1217 let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1218 for (frame, pitch, yaw) in [(0u32, -60i32, 0u32), (1, 60, 330), (1, -30, 150)] {
1219 let img = compose_preview_frame(ComposeRequest {
1220 body: &body,
1221 equipment: &[],
1222 clip_token: "clip000-walk",
1223 frame,
1224 pitch,
1225 yaw,
1226 light: Light::default(),
1227 view_mode: ViewMode::DiffuseOnly,
1228 })
1229 .unwrap();
1230 assert_eq!((img.width, img.height), (2, 3));
1231 let want = [
1232 (yaw / 30) as u8 * 20,
1233 (pitch + 60) as u8,
1234 frame as u8 * 100 + 1,
1235 255,
1236 ];
1237 for px in img.rgba.chunks_exact(4) {
1238 assert_eq!(px, want);
1239 }
1240 }
1241 }
1242
1243 #[test]
1244 fn depth_view_shows_stored_pixels_as_is_resultaat_still_decodes() {
1245 use crate::camera::{DepthEncoding, DepthFormat};
1246 let depth = 40000u16;
1247 let mut gray16_result = None;
1248 for bits in [16u8, 8] {
1249 for encoding in DepthEncoding::ALL {
1250 let fmt = DepthFormat::new(bits, encoding).unwrap();
1251 let project = Temp::new();
1252 let dir = project.0.join("renders/male-body-atlas");
1253 let mut spec = SyntheticRun::body("demo", "male", b"b");
1254 spec.clips = vec![("walk".into(), 1, 0.5)];
1255 spec.target = [2, 2];
1256 spec.depth_format = fmt;
1257 spec.write(&dir, |_, _, _, _| {
1258 pixel(
1259 [200, 100, 50, 255],
1260 [128, 128, 255, 255],
1261 [0, 0, 0, 255],
1262 depth,
1263 )
1264 })
1265 .unwrap();
1266 let catalog = discover_completed_runs(&project.0);
1267 let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1268 let request = |view_mode| ComposeRequest {
1269 body: &body,
1270 equipment: &[],
1271 clip_token: "clip000-walk",
1272 frame: 0,
1273 pitch: 0,
1274 yaw: 0,
1275 light: Light::default(),
1276 view_mode,
1277 };
1278 let shown = compose_preview_frame(request(ViewMode::DepthOnly)).unwrap();
1279 // Expected: the stored channels, untranslated; 16-bit → 8-bit display.
1280 let raw = fmt.encode_value(f64::from(depth) / 65535.);
1281 let to8 = |v: u64| {
1282 if bits == 16 {
1283 ((v * 255 + 32767) / 65535) as u8
1284 } else {
1285 v as u8
1286 }
1287 };
1288 let want = match encoding {
1289 DepthEncoding::Gray => [to8(raw[0]), to8(raw[0]), to8(raw[0]), 255],
1290 _ => [to8(raw[0]), to8(raw[1]), to8(raw[2]), 255],
1291 };
1292 for px in shown.rgba.chunks_exact(4) {
1293 assert_eq!(px, want, "{fmt:?}");
1294 }
1295 // 16-bit rgb_packed of a 16-bit canonical value d stores d in every
1296 // channel (d/65535 repeats in base 65536), so only check the others.
1297 if encoding == DepthEncoding::RgbBands
1298 || (encoding == DepthEncoding::RgbPacked && bits == 8)
1299 {
1300 assert!(
1301 want[0] != want[1] || want[1] != want[2],
1302 "{fmt:?}: raw RGB must not look like decoded gray: {want:?}"
1303 );
1304 }
1305 // Resultaat decodes depth: same relit image as gray16 within 1 level.
1306 let lit = compose_preview_frame(request(ViewMode::Resultaat)).unwrap();
1307 if bits == 16 {
1308 match &gray16_result {
1309 None => gray16_result = Some(lit.rgba.clone()),
1310 Some(reference) => {
1311 for (a, b) in lit.rgba.iter().zip(reference) {
1312 assert!((*a as i32 - *b as i32).abs() <= 1, "{fmt:?}");
1313 }
1314 }
1315 }
1316 }
1317 }
1318 }
1319 }
1320
1321 #[test]
1322 fn five_pitch_rows_clamp_and_default_is_zero() {
1323 let mut state = Preview2dState::default();
1324 assert_eq!(state.pitch_deg(), 0);
1325 state.step_pitch(1);
1326 state.step_pitch(1);
1327 assert_eq!(state.pitch_deg(), 60);
1328 state.step_pitch(1);
1329 assert_eq!(state.pitch_deg(), 60);
1330 for _ in 0..10 {
1331 state.step_pitch(-1);
1332 }
1333 assert_eq!(state.pitch_deg(), -60);
1334 assert_eq!(pitch_row_from_deg(30), 3);
1335 assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);
1336 assert_eq!(state.fps, crate::project::DEFAULT_FPS);
1337 }
1338
1339 /// Regression (t964u): a light button held for several seconds keeps
1340 /// rotating (egui click sense alone stopped after ~1 s).
1341 #[test]
1342 fn light_hold_button_keeps_rotating_for_multi_second_hold() {
1343 use eframe::egui;
1344 let ctx = egui::Context::default();
1345 let rect = std::cell::Cell::new(egui::Rect::NOTHING);
1346 let mut state = Preview2dState::default();
1347 let start_az = state.light_azimuth_deg;
1348 let dt = 1.0 / 60.0;
1349 let frame = |events: Vec<egui::Event>, t: f64, state: &mut Preview2dState| {
1350 let input = egui::RawInput {
1351 screen_rect: Some(egui::Rect::from_min_size(
1352 egui::Pos2::ZERO,
1353 egui::vec2(400., 300.),
1354 )),
1355 time: Some(t),
1356 predicted_dt: dt,
1357 focused: true,
1358 events,
1359 ..Default::default()
1360 };
1361 let mut held = false;
1362 let _ = ctx.run(input, |ctx| {
1363 egui::CentralPanel::default().show(ctx, |ui| {
1364 let r = ui.button("Azimut +");
1365 rect.set(r.rect);
1366 held = ui.ctx().input(|i| raw_pointer_hold(r.rect, i));
1367 });
1368 });
1369 if held {
1370 state.hold_light(LightAxis::Azimuth, HoldSign::Positive);
1371 } else {
1372 state.release_light(LightAxis::Azimuth);
1373 }
1374 held
1375 };
1376 // Layout frame to learn the button rect.
1377 assert!(!frame(vec![], 0.0, &mut state));
1378 let at = rect.get().center();
1379 let press = vec![
1380 egui::Event::PointerMoved(at),
1381 egui::Event::PointerButton {
1382 pos: at,
1383 button: egui::PointerButton::Primary,
1384 pressed: true,
1385 modifiers: Default::default(),
1386 },
1387 ];
1388 assert!(frame(press, dt as f64, &mut state));
1389 // 3.5 s without any further input events, button still physically down.
1390 let frames = 210;
1391 for n in 0..frames {
1392 state.tick(dt);
1393 assert!(
1394 frame(vec![], (n + 2) as f64 * dt as f64, &mut state),
1395 "hold dropped after {:.2} s",
1396 n as f32 * dt
1397 );
1398 }
1399 let expected =
1400 wrap_azimuth_deg(start_az + LIGHT_AZIMUTH_RATE_DEG_PER_SEC * dt * frames as f32);
1401 let diff = (state.light_azimuth_deg - expected).abs();
1402 assert!(
1403 diff.min(360. - diff) < 0.5,
1404 "{} vs {expected}",
1405 state.light_azimuth_deg
1406 );
1407 // Release stops it.
1408 let release = vec![egui::Event::PointerButton {
1409 pos: at,
1410 button: egui::PointerButton::Primary,
1411 pressed: false,
1412 modifiers: Default::default(),
1413 }];
1414 assert!(!frame(release, 4.0, &mut state));
1415 assert!(state.hold_azimuth.is_none());
1416 // Pointer leaving the button / window stops a hold too.
1417 let press = vec![egui::Event::PointerButton {
1418 pos: at,
1419 button: egui::PointerButton::Primary,
1420 pressed: true,
1421 modifiers: Default::default(),
1422 }];
1423 assert!(frame(press, 4.1, &mut state));
1424 assert!(!frame(vec![egui::Event::PointerGone], 4.2, &mut state));
1425 // A press that started elsewhere does not count when dragged onto it.
1426 let away = egui::pos2(390., 290.);
1427 let drag = vec![
1428 egui::Event::PointerButton {
1429 pos: at,
1430 button: egui::PointerButton::Primary,
1431 pressed: false,
1432 modifiers: Default::default(),
1433 },
1434 egui::Event::PointerMoved(away),
1435 ];
1436 frame(drag, 4.3, &mut state);
1437 let press = vec![egui::Event::PointerButton {
1438 pos: away,
1439 button: egui::PointerButton::Primary,
1440 pressed: true,
1441 modifiers: Default::default(),
1442 }];
1443 assert!(!frame(press, 4.4, &mut state));
1444 assert!(!frame(vec![egui::Event::PointerMoved(at)], 4.5, &mut state));
1445 }
1446}