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