BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / preview2d.rs

preview2d.rs

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