BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

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