1use super::objects::CometDirection;
41use egui::{
42 Color32, ColorImage, Context, Id, Mesh, Painter, Pos2, Shape, Stroke, TextureFilter,
43 TextureHandle, TextureOptions, TextureWrapMode, Vec2,
44 epaint::{CircleShape, PathShape, Vertex},
45 pos2,
46};
47use std::f32::consts::TAU;
48use std::time::Instant;
49
50pub const PULSE_DURATION: f32 = 3.5;
52pub const RIPPLE_DURATION: f32 = 3.5;
54pub const COUNTDOWN_DURATION: f32 = 5.0;
56pub const SCALE_IN_DURATION: f32 = 0.45;
58pub const CROSSHAIR_DURATION: f32 = 0.6;
60pub const FLASH_DECAY_DURATION: f32 = 1.0;
62pub const COMET_PERIOD: f32 = 1.6;
64pub const COMET_TRAVEL_DURATION: f32 = 1.2;
67pub const DASH_PERIOD_PX: f32 = 24.0;
72pub const DASH_SPEED: f32 = 0.6;
75pub const DASH_WIDTH: f32 = 3.0;
78pub const WIPE_DURATION: f32 = 0.9;
80pub const GLOW_BAND_PERIOD: f32 = 2.2;
84pub const GLOW_BAND_LENGTH_PX: f32 = 40.0;
88pub const GLOW_BAND_THICKNESS: f32 = 5.0;
91pub const CHEVRON_PERIOD_PX: f32 = 28.0;
95pub const CHEVRON_SPEED: f32 = 0.5;
98pub const CHEVRON_WIDTH: f32 = 10.0;
101
102fn with_alpha(color: Color32, alpha: f32) -> Color32 {
104 Color32::from_rgba_unmultiplied(
105 color.r(),
106 color.g(),
107 color.b(),
108 (255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
109 )
110}
111
112fn elapsed(initial_time: Instant) -> f32 {
114 Instant::now().duration_since(initial_time).as_secs_f32()
115}
116
117fn triangle_wave(time: f32, period: f32) -> f32 {
119 let phase = (time / period).rem_euclid(1.0);
120 1.0 - (2.0 * phase - 1.0).abs()
121}
122
123fn ease_out_back(x: f32) -> f32 {
125 const C1: f32 = 1.701_58;
126 const C3: f32 = C1 + 1.0;
127 let x1 = x - 1.0;
128 1.0 + C3 * x1 * x1 * x1 + C1 * x1 * x1
129}
130
131pub struct Animation {}
136
137impl Animation {
138 pub fn pulse(
145 painter: &Painter,
146 center: Pos2,
147 zoom: f32,
148 initial_time: Instant,
149 color: Color32,
150 ) -> bool {
151 let secs = elapsed(initial_time);
152 let radius = (4.00 + (40.00 * secs)) * zoom;
153 let transparency = (1.00 - (secs / PULSE_DURATION).abs()).max(0.0);
154 painter.add(Shape::Circle(CircleShape::filled(
155 center,
156 radius,
157 with_alpha(color, transparency),
158 )));
159 secs < PULSE_DURATION
160 }
161
162 pub fn ripple(
168 painter: &Painter,
169 center: Pos2,
170 zoom: f32,
171 initial_time: Instant,
172 color: Color32,
173 ) -> bool {
174 const RINGS: usize = 3;
175 let secs = elapsed(initial_time);
176 let stagger = RIPPLE_DURATION / RINGS as f32;
177
178 let mut shapes = Vec::with_capacity(RINGS);
179 for ring in 0..RINGS {
180 let local = secs - ring as f32 * stagger;
181 if !(0.0..RIPPLE_DURATION).contains(&local) {
182 continue;
183 }
184 let progress = local / RIPPLE_DURATION;
185 shapes.push(Shape::Circle(CircleShape::stroke(
186 center,
187 (4.0 + 36.0 * progress) * zoom,
188 Stroke::new(2.0 * zoom, with_alpha(color, 1.0 - progress)),
189 )));
190 }
191 painter.extend(shapes);
192 secs < RIPPLE_DURATION
193 }
194
195 pub fn countdown_arc(
201 painter: &Painter,
202 center: Pos2,
203 zoom: f32,
204 initial_time: Instant,
205 color: Color32,
206 ) -> bool {
207 const STEPS: usize = 48;
209 let secs = elapsed(initial_time);
210 let remaining = (1.0 - secs / COUNTDOWN_DURATION).clamp(0.0, 1.0);
211 let radius = 10.0 * zoom;
212
213 let count = (STEPS as f32 * remaining).round() as usize;
214 if count >= 1 {
215 let points = (0..=count)
216 .map(|i| {
217 let angle = TAU * (i as f32 / STEPS as f32) - TAU / 4.0;
220 Pos2::new(
221 center.x + radius * angle.cos(),
222 center.y + radius * angle.sin(),
223 )
224 })
225 .collect();
226 painter.add(Shape::Path(PathShape::line(
227 points,
228 Stroke::new(2.0 * zoom, with_alpha(color, 1.0)),
229 )));
230 }
231 secs < COUNTDOWN_DURATION
232 }
233
234 pub fn scale_in(
239 painter: &Painter,
240 center: Pos2,
241 zoom: f32,
242 initial_time: Instant,
243 color: Color32,
244 ) -> bool {
245 let secs = elapsed(initial_time);
246 let progress = (secs / SCALE_IN_DURATION).clamp(0.0, 1.0);
247 let radius = 8.0 * zoom * ease_out_back(progress).max(0.0);
248 painter.add(Shape::Circle(CircleShape::filled(
249 center,
250 radius,
251 with_alpha(color, 1.0 - progress),
252 )));
253 secs < SCALE_IN_DURATION
254 }
255
256 pub fn crosshair(
261 painter: &Painter,
262 center: Pos2,
263 zoom: f32,
264 initial_time: Instant,
265 color: Color32,
266 ) -> bool {
267 let secs = elapsed(initial_time);
268 let progress = (secs / CROSSHAIR_DURATION).clamp(0.0, 1.0);
269 let far = (30.0 - 18.0 * progress) * zoom;
272 let near = far - 8.0 * zoom;
273 let alpha = if progress < 0.66 {
274 1.0
275 } else {
276 1.0 - (progress - 0.66) / 0.34
277 };
278 let stroke = Stroke::new(2.0 * zoom, with_alpha(color, alpha));
279
280 let mut shapes = Vec::with_capacity(4);
281 for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
282 shapes.push(Shape::line_segment(
283 [
284 Pos2::new(center.x + dx * far, center.y + dy * far),
285 Pos2::new(center.x + dx * near, center.y + dy * near),
286 ],
287 stroke,
288 ));
289 }
290 painter.extend(shapes);
291 secs < CROSSHAIR_DURATION
292 }
293
294 pub fn flash_decay(
303 painter: &Painter,
304 a: Pos2,
305 b: Pos2,
306 zoom: f32,
307 initial_time: Instant,
308 color: Color32,
309 ) -> bool {
310 let secs = elapsed(initial_time);
311 let progress = (secs / FLASH_DECAY_DURATION).clamp(0.0, 1.0);
312 let width = (2.0 + 10.0 * (1.0 - progress)) * zoom;
313 painter.line_segment(
314 [a, b],
315 Stroke::new(width, with_alpha(color, 1.0 - progress)),
316 );
317 secs < FLASH_DECAY_DURATION
318 }
319
320 pub fn comet_once(
325 painter: &Painter,
326 a: Pos2,
327 b: Pos2,
328 zoom: f32,
329 initial_time: Instant,
330 color: Color32,
331 direction: CometDirection,
332 ) -> bool {
333 let secs = elapsed(initial_time);
334 let progress = (secs / COMET_TRAVEL_DURATION).clamp(0.0, 1.0);
335 let (from, to) = match direction {
336 CometDirection::Forward => (a, b),
337 CometDirection::Reverse => (b, a),
338 };
339 let pos = from + (to - from) * progress;
340 painter.add(Shape::Circle(CircleShape::filled(
341 pos,
342 (4.0 * zoom).max(2.5),
343 color,
344 )));
345 secs < COMET_TRAVEL_DURATION
346 }
347
348 pub fn wipe(
358 painter: &Painter,
359 a: Pos2,
360 b: Pos2,
361 zoom: f32,
362 initial_time: Instant,
363 color: Color32,
364 ) -> bool {
365 let secs = elapsed(initial_time);
366 let progress = (secs / WIPE_DURATION).clamp(0.0, 1.0);
367 let leading_edge = a + (b - a) * progress;
368 painter.line_segment([a, leading_edge], Stroke::new(2.5 * zoom, color));
369 secs < WIPE_DURATION
370 }
371
372 pub fn comet(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
379 let t = (time / COMET_PERIOD).rem_euclid(1.0);
380 let pos = a + (b - a) * t;
381 painter.add(Shape::Circle(CircleShape::filled(
382 pos,
383 (4.0 * zoom).max(2.5),
384 color,
385 )));
386 }
387
388 pub fn dash(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
398 let delta = b - a;
399 let len = delta.length();
400 if len <= f32::EPSILON {
401 return;
402 }
403 let dir = delta / len;
404 let normal = Vec2::new(-dir.y, dir.x) * (DASH_WIDTH * zoom * 0.5);
405 let phase = (time * DASH_SPEED).rem_euclid(1.0);
406 let u0 = phase;
407 let u1 = phase + len / DASH_PERIOD_PX;
408
409 let texture = Self::dash_texture(painter.ctx());
410 let mut mesh = Mesh::with_texture(texture.id());
411 mesh.vertices.extend([
412 Vertex {
413 pos: a + normal,
414 uv: pos2(u0, 0.5),
415 color,
416 },
417 Vertex {
418 pos: a - normal,
419 uv: pos2(u0, 0.5),
420 color,
421 },
422 Vertex {
423 pos: b + normal,
424 uv: pos2(u1, 0.5),
425 color,
426 },
427 Vertex {
428 pos: b - normal,
429 uv: pos2(u1, 0.5),
430 color,
431 },
432 ]);
433 mesh.indices.extend([0, 1, 2, 2, 1, 3]);
434 painter.add(mesh);
435 }
436
437 fn dash_texture(ctx: &Context) -> TextureHandle {
449 let id = Id::new("egui_map::dash_texture");
450 if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
451 return handle;
452 }
453
454 const WIDTH: usize = 32;
455 const FADE: usize = 3;
456 let half = WIDTH / 2;
457 let pixels = (0..WIDTH)
458 .map(|i| {
459 let alpha = if i < half - FADE {
460 255
461 } else if i < half + FADE {
462 let t = (i - (half - FADE)) as f32 / (2.0 * FADE as f32);
463 (255.0 * (1.0 - t)).round() as u8
464 } else {
465 0
466 };
467 Color32::from_white_alpha(alpha)
468 })
469 .collect();
470 let image = ColorImage::new([WIDTH, 1], pixels);
471 let handle = ctx.load_texture(
472 "egui_map::dash",
473 image,
474 TextureOptions {
475 magnification: TextureFilter::Linear,
476 minification: TextureFilter::Linear,
477 wrap_mode: TextureWrapMode::Repeat,
478 mipmap_mode: None,
479 },
480 );
481 ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
482 handle
483 }
484
485 pub fn glow_band(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
498 let delta = b - a;
499 let len = delta.length();
500 if len <= f32::EPSILON {
501 return;
502 }
503 let dir = delta / len;
504 let normal = Vec2::new(-dir.y, dir.x) * (GLOW_BAND_THICKNESS * zoom * 0.5);
505
506 let half_width_frac = (GLOW_BAND_LENGTH_PX * 0.5 / len).min(0.5);
510 let span = 1.0 + 2.0 * half_width_frac;
514 let t = (time / GLOW_BAND_PERIOD).rem_euclid(1.0);
515 let peak = -half_width_frac + t * span;
516 let texture_u = |frac: f32| 0.5 + (frac - peak) / (2.0 * half_width_frac);
517
518 let texture = Self::glow_band_texture(painter.ctx());
519 let mut mesh = Mesh::with_texture(texture.id());
520 let u_a = texture_u(0.0);
521 let u_b = texture_u(1.0);
522 mesh.vertices.extend([
523 Vertex {
524 pos: a + normal,
525 uv: pos2(u_a, 0.5),
526 color,
527 },
528 Vertex {
529 pos: a - normal,
530 uv: pos2(u_a, 0.5),
531 color,
532 },
533 Vertex {
534 pos: b + normal,
535 uv: pos2(u_b, 0.5),
536 color,
537 },
538 Vertex {
539 pos: b - normal,
540 uv: pos2(u_b, 0.5),
541 color,
542 },
543 ]);
544 mesh.indices.extend([0, 1, 2, 2, 1, 3]);
545 painter.add(mesh);
546 }
547
548 fn glow_band_texture(ctx: &Context) -> TextureHandle {
558 let id = Id::new("egui_map::glow_band_texture");
559 if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
560 return handle;
561 }
562
563 const WIDTH: usize = 64;
564 let pixels = (0..WIDTH)
565 .map(|i| {
566 let u = i as f32 / (WIDTH - 1) as f32;
567 let distance_from_center = (u - 0.5).abs() * 2.0;
568 let alpha = (1.0 - distance_from_center).clamp(0.0, 1.0);
569 let alpha = alpha * alpha * (3.0 - 2.0 * alpha); Color32::from_white_alpha((255.0 * alpha).round() as u8)
571 })
572 .collect();
573 let image = ColorImage::new([WIDTH, 1], pixels);
574 let handle = ctx.load_texture(
575 "egui_map::glow_band",
576 image,
577 TextureOptions {
578 magnification: TextureFilter::Linear,
579 minification: TextureFilter::Linear,
580 wrap_mode: TextureWrapMode::ClampToEdge,
581 mipmap_mode: None,
582 },
583 );
584 ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
585 handle
586 }
587
588 pub fn chevrons(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
602 let delta = b - a;
603 let len = delta.length();
604 if len <= f32::EPSILON {
605 return;
606 }
607 let dir = delta / len;
608 let normal = Vec2::new(-dir.y, dir.x) * (CHEVRON_WIDTH * zoom * 0.5);
609 let phase = (-(time * CHEVRON_SPEED)).rem_euclid(1.0);
619 let u0 = phase;
620 let u1 = phase + len / CHEVRON_PERIOD_PX;
621
622 let texture = Self::chevrons_texture(painter.ctx());
623 let mut mesh = Mesh::with_texture(texture.id());
624 mesh.vertices.extend([
625 Vertex {
626 pos: a + normal,
627 uv: pos2(u0, 0.0),
628 color,
629 },
630 Vertex {
631 pos: a - normal,
632 uv: pos2(u0, 1.0),
633 color,
634 },
635 Vertex {
636 pos: b + normal,
637 uv: pos2(u1, 0.0),
638 color,
639 },
640 Vertex {
641 pos: b - normal,
642 uv: pos2(u1, 1.0),
643 color,
644 },
645 ]);
646 mesh.indices.extend([0, 1, 2, 2, 1, 3]);
647 painter.add(mesh);
648 }
649
650 fn chevrons_texture(ctx: &Context) -> TextureHandle {
664 let id = Id::new("egui_map::chevrons_texture");
665 if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
666 return handle;
667 }
668
669 const WIDTH: usize = 32;
670 const HEIGHT: usize = 16;
671 const TIP_U: f32 = 0.75;
672 const LEG_SLOPE: f32 = 0.5;
673 const STROKE_THICKNESS: f32 = 0.12;
674
675 let mut pixels = Vec::with_capacity(WIDTH * HEIGHT);
676 for j in 0..HEIGHT {
677 let v = j as f32 / (HEIGHT - 1) as f32;
678 let ideal_u = TIP_U - LEG_SLOPE * (v - 0.5).abs();
679 for i in 0..WIDTH {
680 let u = i as f32 / WIDTH as f32;
681 let distance = (u - ideal_u).abs();
682 let alpha = (1.0 - distance / STROKE_THICKNESS).clamp(0.0, 1.0);
683 let alpha = alpha * alpha * (3.0 - 2.0 * alpha); pixels.push(Color32::from_white_alpha((255.0 * alpha).round() as u8));
685 }
686 }
687 let image = ColorImage::new([WIDTH, HEIGHT], pixels);
688 let handle = ctx.load_texture(
689 "egui_map::chevrons",
690 image,
691 TextureOptions {
692 magnification: TextureFilter::Linear,
693 minification: TextureFilter::Linear,
694 wrap_mode: TextureWrapMode::Repeat,
695 mipmap_mode: None,
696 },
697 );
698 ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
699 handle
700 }
701
702 pub fn halo(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
712 const PERIOD: f32 = 2.0;
713 let alpha = 0.30 + 0.45 * triangle_wave(time, PERIOD);
714 let radius = (9.0 * zoom).max(5.0);
715 painter.add(Shape::Circle(CircleShape::stroke(
716 center,
717 radius,
718 Stroke::new((2.0 * zoom).max(1.5), with_alpha(color, alpha)),
719 )));
720 }
721
722 pub fn blink(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
728 const PERIOD: f32 = 2.55;
729 painter.add(Shape::Circle(CircleShape::stroke(
730 center,
731 4.0 * zoom,
732 Stroke::new(9.0 * zoom, with_alpha(color, triangle_wave(time, PERIOD))),
733 )));
734 }
735
736 pub fn orbit(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
740 const PERIOD: f32 = 3.0;
741 let radius = (12.0 * zoom).max(7.0);
742 let angle = TAU * (time / PERIOD).rem_euclid(1.0);
743 let dot = Pos2::new(
744 center.x + radius * angle.cos(),
745 center.y + radius * angle.sin(),
746 );
747 painter.extend([
748 Shape::Circle(CircleShape::stroke(
749 center,
750 radius,
751 Stroke::new(1.0, with_alpha(color, 0.25)),
752 )),
753 Shape::Circle(CircleShape::filled(
754 dot,
755 (2.5 * zoom).max(2.0),
756 with_alpha(color, 1.0),
757 )),
758 ]);
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use egui::{Context, LayerId, Rect, Vec2};
766 use std::time::Duration;
767
768 fn headless_painter() -> Painter {
769 Painter::new(
770 Context::default(),
771 LayerId::background(),
772 Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 100.0)),
773 )
774 }
775
776 #[allow(clippy::type_complexity)]
778 fn event_effects() -> Vec<(
779 &'static str,
780 fn(&Painter, Pos2, f32, Instant, Color32) -> bool,
781 f32,
782 )> {
783 vec![
784 ("pulse", Animation::pulse, PULSE_DURATION),
785 ("ripple", Animation::ripple, RIPPLE_DURATION),
786 (
787 "countdown_arc",
788 Animation::countdown_arc,
789 COUNTDOWN_DURATION,
790 ),
791 ("scale_in", Animation::scale_in, SCALE_IN_DURATION),
792 ("crosshair", Animation::crosshair, CROSSHAIR_DURATION),
793 ]
794 }
795
796 #[test]
797 fn event_effects_report_running_then_finished() {
798 let painter = headless_painter();
799 for (name, effect, duration) in event_effects() {
800 assert!(
801 effect(&painter, Pos2::ZERO, 1.0, Instant::now(), Color32::RED),
802 "{name} must report it is still running when it just started"
803 );
804
805 let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
806 assert!(
807 !effect(&painter, Pos2::ZERO, 1.0, long_past, Color32::RED),
808 "{name} must report it is finished once its duration has passed"
809 );
810 }
811 }
812
813 #[test]
814 fn every_event_effect_stays_under_the_orphan_sweep() {
815 for (name, _, duration) in event_effects() {
818 assert!(
819 duration < 10.0,
820 "{name} lasts {duration}s, which the 10s orphan sweep would truncate"
821 );
822 }
823 }
824
825 #[test]
826 fn persistent_effects_run_at_any_time() {
827 let painter = headless_painter();
828 for time in [0.0, 0.7, 1.3, 60.0] {
829 Animation::halo(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
830 Animation::blink(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
831 Animation::orbit(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
832 }
833 }
834
835 #[allow(clippy::type_complexity)]
837 fn segment_event_effects() -> Vec<(
838 &'static str,
839 fn(&Painter, Pos2, Pos2, f32, Instant, Color32) -> bool,
840 f32,
841 )> {
842 vec![
843 ("flash_decay", Animation::flash_decay, FLASH_DECAY_DURATION),
844 ("wipe", Animation::wipe, WIPE_DURATION),
845 ]
846 }
847
848 #[test]
849 fn segment_event_effects_report_running_then_finished() {
850 let painter = headless_painter();
851 let a = Pos2::ZERO;
852 let b = Pos2::new(50.0, 0.0);
853 for (name, effect, duration) in segment_event_effects() {
854 assert!(
855 effect(&painter, a, b, 1.0, Instant::now(), Color32::RED),
856 "{name} must report it is still running when it just started"
857 );
858
859 let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
860 assert!(
861 !effect(&painter, a, b, 1.0, long_past, Color32::RED),
862 "{name} must report it is finished once its duration has passed"
863 );
864 }
865 }
866
867 #[test]
868 fn every_segment_event_effect_stays_under_the_orphan_sweep() {
869 for (name, _, duration) in segment_event_effects() {
870 assert!(
871 duration < 10.0,
872 "{name} lasts {duration}s, which the 10s orphan sweep would truncate"
873 );
874 }
875 }
876
877 #[test]
878 fn comet_runs_at_any_time_and_stays_on_the_segment() {
879 let painter = headless_painter();
880 let a = Pos2::ZERO;
881 let b = Pos2::new(50.0, 0.0);
882 for time in [0.0, 0.4, 0.8, 60.0] {
883 Animation::comet(&painter, a, b, 1.0, time, Color32::GREEN);
884 }
885 }
886
887 #[test]
888 fn comet_loops_back_to_the_start() {
889 let t0 = 0.2;
891 let t1 = t0 + COMET_PERIOD;
892 let at = |t: f32| {
893 let frac = (t / COMET_PERIOD).rem_euclid(1.0);
894 Pos2::ZERO + (Pos2::new(50.0, 0.0) - Pos2::ZERO) * frac
895 };
896 assert_eq!(at(t0), at(t1));
897 }
898
899 #[test]
900 fn comet_once_reports_running_then_finished() {
901 let painter = headless_painter();
902 let a = Pos2::ZERO;
903 let b = Pos2::new(50.0, 0.0);
904 assert!(
905 Animation::comet_once(
906 &painter,
907 a,
908 b,
909 1.0,
910 Instant::now(),
911 Color32::RED,
912 CometDirection::Forward,
913 ),
914 "comet_once must report it is still running when it just started"
915 );
916
917 let long_past = Instant::now() - Duration::from_secs_f32(COMET_TRAVEL_DURATION + 1.0);
918 assert!(
919 !Animation::comet_once(
920 &painter,
921 a,
922 b,
923 1.0,
924 long_past,
925 Color32::RED,
926 CometDirection::Forward,
927 ),
928 "comet_once must report it is finished once its duration has passed"
929 );
930 }
931
932 #[test]
933 #[allow(clippy::assertions_on_constants)]
936 fn comet_once_stays_under_the_orphan_sweep() {
937 assert!(
938 COMET_TRAVEL_DURATION < 10.0,
939 "comet_once lasts {COMET_TRAVEL_DURATION}s, which the 10s orphan sweep would truncate"
940 );
941 }
942
943 #[test]
944 fn comet_once_direction_picks_the_starting_endpoint() {
945 let a = Pos2::ZERO;
949 let b = Pos2::new(50.0, 0.0);
950 let start_pos = |direction: CometDirection| {
951 let secs = 0.0_f32;
952 let progress = (secs / COMET_TRAVEL_DURATION).clamp(0.0, 1.0);
953 let (from, to) = match direction {
954 CometDirection::Forward => (a, b),
955 CometDirection::Reverse => (b, a),
956 };
957 from + (to - from) * progress
958 };
959 assert_eq!(start_pos(CometDirection::Forward), a);
960 assert_eq!(start_pos(CometDirection::Reverse), b);
961 }
962
963 #[test]
964 fn wipe_progress_interpolates_toward_the_far_endpoint() {
965 let a = Pos2::ZERO;
971 let b = Pos2::new(50.0, 0.0);
972 let leading_edge = |progress: f32| a + (b - a) * progress;
973
974 assert_eq!(leading_edge(0.0), a, "must start exactly at `a`");
975 assert_eq!(leading_edge(1.0), b, "must finish exactly at `b`");
976 assert_eq!(leading_edge(0.5), Pos2::new(25.0, 0.0));
977 }
978
979 #[test]
980 fn dash_runs_at_any_time_and_skips_zero_length_segments() {
981 let painter = headless_painter();
982 let a = Pos2::ZERO;
983 let b = Pos2::new(50.0, 0.0);
984 for time in [0.0, 0.4, 0.8, 60.0] {
985 Animation::dash(&painter, a, b, 1.0, time, Color32::GREEN);
986 }
987 Animation::dash(&painter, a, a, 1.0, 0.0, Color32::GREEN);
990 }
991
992 #[test]
993 fn dash_texture_is_registered_once_per_context() {
994 let ctx = Context::default();
997 let first = Animation::dash_texture(&ctx);
998 let second = Animation::dash_texture(&ctx);
999 assert_eq!(first.id(), second.id());
1000 }
1001
1002 #[test]
1003 fn glow_band_runs_at_any_time_and_skips_zero_length_segments() {
1004 let painter = headless_painter();
1005 let a = Pos2::ZERO;
1006 let b = Pos2::new(50.0, 0.0);
1007 for time in [0.0, 0.4, 0.8, 60.0] {
1008 Animation::glow_band(&painter, a, b, 1.0, time, Color32::GREEN);
1009 }
1010 Animation::glow_band(&painter, a, a, 1.0, 0.0, Color32::GREEN);
1013 }
1014
1015 #[test]
1016 fn glow_band_texture_is_registered_once_per_context() {
1017 let ctx = Context::default();
1018 let first = Animation::glow_band_texture(&ctx);
1019 let second = Animation::glow_band_texture(&ctx);
1020 assert_eq!(first.id(), second.id());
1021 }
1022
1023 #[test]
1024 fn chevrons_runs_at_any_time_and_skips_zero_length_segments() {
1025 let painter = headless_painter();
1026 let a = Pos2::ZERO;
1027 let b = Pos2::new(50.0, 0.0);
1028 for time in [0.0, 0.4, 0.8, 60.0] {
1029 Animation::chevrons(&painter, a, b, 1.0, time, Color32::GREEN);
1030 }
1031 Animation::chevrons(&painter, a, a, 1.0, 0.0, Color32::GREEN);
1032 }
1033
1034 #[test]
1035 fn chevrons_texture_is_registered_once_per_context() {
1036 let ctx = Context::default();
1037 let first = Animation::chevrons_texture(&ctx);
1038 let second = Animation::chevrons_texture(&ctx);
1039 assert_eq!(first.id(), second.id());
1040 }
1041
1042 #[test]
1043 fn triangle_wave_goes_up_and_back_down() {
1044 assert_eq!(triangle_wave(0.0, 2.0), 0.0);
1045 assert_eq!(triangle_wave(1.0, 2.0), 1.0);
1046 assert!(triangle_wave(2.0, 2.0).abs() < 1e-6);
1047 assert!((triangle_wave(3.0, 2.0) - 1.0).abs() < 1e-6);
1048 for step in 0..200 {
1049 let v = triangle_wave(step as f32 * 0.05, 2.55);
1050 assert!((0.0..=1.0).contains(&v), "{v} out of range");
1051 }
1052 }
1053
1054 #[test]
1055 fn ease_out_back_overshoots_then_settles() {
1056 assert_eq!(ease_out_back(0.0), 0.0);
1057 assert!((ease_out_back(1.0) - 1.0).abs() < 1e-5);
1058 let peak = (0..=100)
1059 .map(|i| ease_out_back(i as f32 / 100.0))
1060 .fold(f32::MIN, f32::max);
1061 assert!(
1062 peak > 1.0,
1063 "ease_out_back should overshoot, peaked at {peak}"
1064 );
1065 }
1066
1067 #[test]
1068 fn with_alpha_clamps_out_of_range_values() {
1069 assert_eq!(with_alpha(Color32::RED, 2.0).a(), 255);
1070 assert_eq!(with_alpha(Color32::RED, -1.0).a(), 0);
1071 assert_eq!(with_alpha(Color32::RED, 1.0), Color32::RED);
1072 }
1073}