1use crate::camera::Camera;
33use crate::camera_projection::CameraProjection;
34use crate::geo_wrap::{shortest_lon_target, wrap_lon_180};
35use rustial_math::{GeoCoord, WorldCoord};
36
37const METERS_PER_DEGREE: f64 = 111_319.49;
43
44const MERCATOR_MAX_LAT: f64 = 85.06;
46
47const MOMENTUM_EPS: f64 = 0.01;
49
50const DISTANCE_EPS: f64 = 0.1;
52
53const ANGLE_EPS: f64 = 1e-4;
55
56const WGS84_CIRCUMFERENCE: f64 = 2.0 * std::f64::consts::PI * 6_378_137.0;
58
59const TILE_PX: f64 = 256.0;
61
62fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, t: f64) -> f64 {
71 let mut s = t;
73 for _ in 0..8 {
74 let x = bezier_component(x1, x2, s) - t;
75 let dx = bezier_derivative(x1, x2, s);
76 if dx.abs() < 1e-12 {
77 break;
78 }
79 s -= x / dx;
80 s = s.clamp(0.0, 1.0);
81 }
82 bezier_component(y1, y2, s)
83}
84
85fn bezier_component(p1: f64, p2: f64, t: f64) -> f64 {
86 let t2 = t * t;
87 let t3 = t2 * t;
88 3.0 * p1 * t * (1.0 - t) * (1.0 - t) + 3.0 * p2 * t2 * (1.0 - t) + t3
89}
90
91fn bezier_derivative(p1: f64, p2: f64, t: f64) -> f64 {
92 let t2 = t * t;
93 3.0 * p1 * (1.0 - t) * (1.0 - t) + 6.0 * (p2 - p1) * t * (1.0 - t) + 3.0 * (1.0 - p2) * t2
94}
95
96fn default_easing(t: f64) -> f64 {
98 cubic_bezier(0.25, 0.1, 0.25, 1.0, t)
99}
100
101fn zoom_to_distance(zoom: f64, fov_y: f64, viewport_height: u32, is_perspective: bool) -> f64 {
112 let mpp = WGS84_CIRCUMFERENCE / (2.0_f64.powf(zoom) * TILE_PX);
113 let visible_height = mpp * viewport_height.max(1) as f64;
114 if is_perspective {
115 visible_height / (2.0 * (fov_y / 2.0).tan())
116 } else {
117 visible_height / 2.0
118 }
119}
120
121fn distance_to_zoom(distance: f64, fov_y: f64, viewport_height: u32, is_perspective: bool) -> f64 {
123 let visible_height = if is_perspective {
124 2.0 * distance * (fov_y / 2.0).tan()
125 } else {
126 2.0 * distance
127 };
128 let mpp = visible_height / viewport_height.max(1) as f64;
129 if mpp <= 0.0 || !mpp.is_finite() {
130 return 22.0;
131 }
132 (WGS84_CIRCUMFERENCE / (mpp * TILE_PX))
133 .log2()
134 .clamp(0.0, 22.0)
135}
136
137#[derive(Debug, Clone)]
148pub struct FlyToOptions {
149 pub center: Option<GeoCoord>,
151 pub zoom: Option<f64>,
153 pub bearing: Option<f64>,
157 pub pitch: Option<f64>,
159 pub curve: f64,
164 pub speed: f64,
168 pub screen_speed: Option<f64>,
172 pub duration: Option<f64>,
177 pub min_zoom: Option<f64>,
182 pub max_duration: Option<f64>,
187 pub easing: Option<fn(f64) -> f64>,
191}
192
193impl Default for FlyToOptions {
194 fn default() -> Self {
195 Self {
196 center: None,
197 zoom: None,
198 bearing: None,
199 pitch: None,
200 curve: 1.42,
201 speed: 1.2,
202 screen_speed: None,
203 duration: None,
204 min_zoom: None,
205 max_duration: None,
206 easing: None,
207 }
208 }
209}
210
211#[derive(Debug, Clone)]
220pub struct EaseToOptions {
221 pub center: Option<GeoCoord>,
223 pub zoom: Option<f64>,
225 pub bearing: Option<f64>,
227 pub pitch: Option<f64>,
229 pub duration: f64,
231 pub easing: Option<fn(f64) -> f64>,
233}
234
235impl Default for EaseToOptions {
236 fn default() -> Self {
237 Self {
238 center: None,
239 zoom: None,
240 bearing: None,
241 pitch: None,
242 duration: 0.5,
243 easing: None,
244 }
245 }
246}
247
248struct FlyToState {
254 end_center: GeoCoord,
256 end_zoom: f64,
257 end_bearing: f64,
258 end_pitch: f64,
259
260 start_zoom: f64,
262 start_bearing: f64,
263 start_pitch: f64,
264
265 from_x: f64,
268 from_y: f64,
269 delta_x: f64,
271 delta_y: f64,
272 w0: f64,
274 u1: f64,
276 rho: f64,
278 r0: f64,
280 path_length: f64,
282 zoom_only: bool,
284 zoom_only_sign: f64,
286
287 duration: f64,
289 elapsed: f64,
290 easing: fn(f64) -> f64,
291
292 projection: CameraProjection,
294 is_perspective: bool,
295 fov_y: f64,
296 viewport_height: u32,
297}
298
299impl FlyToState {
300 fn new(camera: &Camera, opts: &FlyToOptions) -> Option<Self> {
301 let projection = camera.projection();
302 let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
303 let fov_y = camera.fov_y();
304 let viewport_height = camera.viewport_height();
305 let viewport_width = camera.viewport_width();
306
307 let start_center = *camera.target();
308 let start_zoom =
309 distance_to_zoom(camera.distance(), fov_y, viewport_height, is_perspective);
310 let start_bearing = camera.yaw();
311 let start_pitch = camera.pitch();
312
313 let end_center = opts.center.unwrap_or(start_center);
314 let end_zoom = opts.zoom.unwrap_or(start_zoom);
315 let end_bearing = match opts.bearing {
316 Some(b) => shortest_bearing_target(start_bearing, b),
317 None => start_bearing,
318 };
319 let end_pitch = opts.pitch.unwrap_or(start_pitch);
320
321 let from_world = projection.project(&start_center);
323 let to_world = projection.project(&end_center);
324
325 let pixels_per_meter = 2.0_f64.powf(start_zoom) * TILE_PX / WGS84_CIRCUMFERENCE;
330
331 let from_px_x = from_world.position.x * pixels_per_meter;
332 let from_px_y = from_world.position.y * pixels_per_meter;
333 let to_px_x = to_world.position.x * pixels_per_meter;
334 let to_px_y = to_world.position.y * pixels_per_meter;
335
336 let delta_x = to_px_x - from_px_x;
337 let delta_y = to_px_y - from_px_y;
338
339 let u1 = (delta_x * delta_x + delta_y * delta_y).sqrt();
341
342 let w0 = viewport_width.max(viewport_height).max(1) as f64;
344
345 let scale_of_zoom = 2.0_f64.powf(end_zoom - start_zoom);
347
348 let w1 = w0 / scale_of_zoom;
351
352 let mut rho = opts.curve;
354
355 if let Some(min_z) = opts.min_zoom {
358 let min_z = min_z.min(start_zoom).min(end_zoom);
359 let scale_of_min_zoom = 2.0_f64.powf(min_z - start_zoom);
360 let w_max = w0 / scale_of_min_zoom;
361 if u1 > 0.0 {
362 rho = (w_max / u1 * 2.0).sqrt();
363 }
364 }
365
366 let rho2 = rho * rho;
367
368 let zoom_out_factor = |descent: bool| -> f64 {
377 let (w, sign) = if descent { (w1, -1.0) } else { (w0, 1.0) };
378 let b = (w1 * w1 - w0 * w0 + sign * rho2 * rho2 * u1 * u1) / (2.0 * w * rho2 * u1);
379 ((b * b + 1.0).sqrt() - b).ln()
380 };
381
382 let r0;
383 let path_length;
384 let zoom_only;
385 let zoom_only_sign;
386
387 let is_degenerate = if u1.abs() < 0.000001 {
391 true
392 } else {
393 let trial_r0 = zoom_out_factor(false);
394 let trial_r1 = zoom_out_factor(true);
395 let trial_s = (trial_r1 - trial_r0) / rho;
396 !trial_s.is_finite()
397 };
398
399 if is_degenerate {
400 if (w0 - w1).abs() < 0.000001 {
403 if (end_bearing - start_bearing).abs() < ANGLE_EPS
406 && (end_pitch - start_pitch).abs() < ANGLE_EPS
407 {
408 return None;
409 }
410 }
411
412 zoom_only = true;
416 zoom_only_sign = if w1 < w0 { -1.0 } else { 1.0 };
417 r0 = 0.0;
418 path_length = if w1 > 0.0 && w0 > 0.0 {
419 (w1 / w0).ln().abs() / rho
420 } else {
421 0.0
422 };
423 } else {
424 zoom_only = false;
425 zoom_only_sign = 0.0;
426 r0 = zoom_out_factor(false);
428 let r1 = zoom_out_factor(true);
429 path_length = (r1 - r0) / rho;
430 }
431
432 let duration = if let Some(d) = opts.duration {
436 d
437 } else {
438 let v = if let Some(ss) = opts.screen_speed {
439 ss / rho
440 } else {
441 opts.speed
442 };
443 if v > 0.0 {
444 path_length / v
445 } else {
446 1.0
447 }
448 };
449
450 if let Some(max_dur) = opts.max_duration {
454 if duration > max_dur {
455 return None; }
457 }
458
459 let easing = opts.easing.unwrap_or(default_easing);
460
461 Some(FlyToState {
462 start_zoom,
463 start_bearing,
464 start_pitch,
465 end_center,
466 end_zoom,
467 end_bearing,
468 end_pitch,
469 from_x: from_px_x,
470 from_y: from_px_y,
471 delta_x,
472 delta_y,
473 w0,
474 u1,
475 rho,
476 r0,
477 path_length,
478 zoom_only,
479 zoom_only_sign,
480 duration,
481 elapsed: 0.0,
482 easing,
483 projection,
484 is_perspective,
485 fov_y,
486 viewport_height,
487 })
488 }
489
490 fn w(&self, s: f64) -> f64 {
492 if self.zoom_only {
493 (self.zoom_only_sign * self.rho * s).exp()
494 } else {
495 self.r0.cosh() / (self.r0 + self.rho * s).cosh()
496 }
497 }
498
499 fn u(&self, s: f64) -> f64 {
501 if self.zoom_only {
502 0.0
503 } else {
504 let rho2 = self.rho * self.rho;
505 self.w0 * ((self.r0.cosh() * (self.r0 + self.rho * s).tanh() - self.r0.sinh()) / rho2)
506 / self.u1
507 }
508 }
509
510 fn tick(&mut self, camera: &mut Camera, dt: f64) -> bool {
512 self.elapsed += dt;
513 let t = (self.elapsed / self.duration.max(1e-9)).clamp(0.0, 1.0);
514 let k = (self.easing)(t);
515
516 let done = t >= 1.0;
517
518 if done {
519 camera.set_target(self.end_center);
521 camera.set_distance(zoom_to_distance(
522 self.end_zoom,
523 self.fov_y,
524 self.viewport_height,
525 self.is_perspective,
526 ));
527 camera.set_yaw(self.end_bearing);
528 camera.set_pitch(self.end_pitch);
529 return true;
530 }
531
532 let s = k * self.path_length;
534
535 let scale = 1.0 / self.w(s);
537
538 let center_factor = self.u(s);
540
541 let zoom = self.start_zoom + scale.log2();
543 camera.set_distance(zoom_to_distance(
544 zoom,
545 self.fov_y,
546 self.viewport_height,
547 self.is_perspective,
548 ));
549
550 let pixels_per_meter = 2.0_f64.powf(self.start_zoom) * TILE_PX / WGS84_CIRCUMFERENCE;
552 let cx_px = self.from_x + self.delta_x * center_factor;
553 let cy_px = self.from_y + self.delta_y * center_factor;
554 let cx_m = cx_px / pixels_per_meter;
566 let cy_m = cy_px / pixels_per_meter;
567 let new_center = self.projection.unproject(&WorldCoord::new(cx_m, cy_m, 0.0));
568 camera.set_target(new_center);
569
570 let bearing = self.start_bearing + (self.end_bearing - self.start_bearing) * k;
572 camera.set_yaw(bearing);
573
574 let pitch = self.start_pitch + (self.end_pitch - self.start_pitch) * k;
576 camera.set_pitch(pitch);
577
578 false
579 }
580}
581
582struct EaseToState {
588 start_center: GeoCoord,
589 start_zoom: f64,
590 start_bearing: f64,
591 start_pitch: f64,
592
593 end_center: GeoCoord,
594 end_zoom: f64,
595 end_bearing: f64,
596 end_pitch: f64,
597
598 duration: f64,
599 elapsed: f64,
600 easing: fn(f64) -> f64,
601
602 is_perspective: bool,
603 fov_y: f64,
604 viewport_height: u32,
605}
606
607impl EaseToState {
608 fn new(camera: &Camera, opts: &EaseToOptions) -> Self {
609 let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
610 let start_zoom = distance_to_zoom(
611 camera.distance(),
612 camera.fov_y(),
613 camera.viewport_height(),
614 is_perspective,
615 );
616 let start_bearing = camera.yaw();
617
618 let end_center = opts.center.unwrap_or(*camera.target());
619 let wrapped_end_center = GeoCoord::from_lat_lon(
620 end_center.lat,
621 shortest_lon_target(camera.target().lon, end_center.lon),
622 );
623
624 Self {
625 start_center: *camera.target(),
626 start_zoom,
627 start_bearing,
628 start_pitch: camera.pitch(),
629 end_center: wrapped_end_center,
630 end_zoom: opts.zoom.unwrap_or(start_zoom),
631 end_bearing: match opts.bearing {
632 Some(b) => shortest_bearing_target(start_bearing, b),
633 None => start_bearing,
634 },
635 end_pitch: opts.pitch.unwrap_or(camera.pitch()),
636 duration: opts.duration,
637 elapsed: 0.0,
638 easing: opts.easing.unwrap_or(default_easing),
639 is_perspective,
640 fov_y: camera.fov_y(),
641 viewport_height: camera.viewport_height(),
642 }
643 }
644
645 fn tick(&mut self, camera: &mut Camera, dt: f64) -> bool {
646 self.elapsed += dt;
647 let t = (self.elapsed / self.duration.max(1e-9)).clamp(0.0, 1.0);
648 let k = (self.easing)(t);
649 let done = t >= 1.0;
650
651 if done {
652 camera.set_target(self.end_center);
653 camera.set_distance(zoom_to_distance(
654 self.end_zoom,
655 self.fov_y,
656 self.viewport_height,
657 self.is_perspective,
658 ));
659 camera.set_yaw(self.end_bearing);
660 camera.set_pitch(self.end_pitch);
661 return true;
662 }
663
664 let lat = self.start_center.lat + (self.end_center.lat - self.start_center.lat) * k;
666 let lon = self.start_center.lon + (self.end_center.lon - self.start_center.lon) * k;
667 camera.set_target(GeoCoord::from_lat_lon(lat, wrap_lon_180(lon)));
668
669 let zoom = self.start_zoom + (self.end_zoom - self.start_zoom) * k;
671 camera.set_distance(zoom_to_distance(
672 zoom,
673 self.fov_y,
674 self.viewport_height,
675 self.is_perspective,
676 ));
677
678 camera.set_yaw(self.start_bearing + (self.end_bearing - self.start_bearing) * k);
680
681 camera.set_pitch(self.start_pitch + (self.end_pitch - self.start_pitch) * k);
683
684 false
685 }
686}
687
688pub struct CameraAnimator {
704 fly_to: Option<FlyToState>,
706 ease_to: Option<EaseToState>,
707
708 target_distance: Option<f64>,
710 target_yaw: Option<f64>,
711 target_pitch: Option<f64>,
712
713 momentum: (f64, f64),
715
716 pub momentum_decay: f64,
718 pub smoothing: f64,
721}
722
723impl Default for CameraAnimator {
724 fn default() -> Self {
725 Self {
726 fly_to: None,
727 ease_to: None,
728 target_distance: None,
729 target_yaw: None,
730 target_pitch: None,
731 momentum: (0.0, 0.0),
732 momentum_decay: 0.05,
733 smoothing: 8.0,
734 }
735 }
736}
737
738impl CameraAnimator {
739 pub fn new() -> Self {
741 Self::default()
742 }
743
744 pub fn start_fly_to(&mut self, camera: &mut Camera, options: &FlyToOptions) {
758 self.cancel();
759
760 match FlyToState::new(camera, options) {
761 Some(state) => {
762 self.fly_to = Some(state);
763 }
764 None => {
765 Self::apply_jump(
767 camera,
768 options.center,
769 options.zoom,
770 options.bearing,
771 options.pitch,
772 );
773 }
774 }
775 }
776
777 pub fn start_ease_to(&mut self, camera: &mut Camera, options: &EaseToOptions) {
784 self.cancel();
785
786 if options.duration <= 0.0 {
787 Self::apply_jump(
788 camera,
789 options.center,
790 options.zoom,
791 options.bearing,
792 options.pitch,
793 );
794 return;
795 }
796
797 self.ease_to = Some(EaseToState::new(camera, options));
798 }
799
800 fn apply_jump(
802 camera: &mut Camera,
803 center: Option<GeoCoord>,
804 zoom: Option<f64>,
805 bearing: Option<f64>,
806 pitch: Option<f64>,
807 ) {
808 if let Some(c) = center {
809 camera.set_target(c);
810 }
811 if let Some(z) = zoom {
812 let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
813 camera.set_distance(zoom_to_distance(
814 z,
815 camera.fov_y(),
816 camera.viewport_height(),
817 is_perspective,
818 ));
819 }
820 if let Some(b) = bearing {
821 camera.set_yaw(b);
822 }
823 if let Some(p) = pitch {
824 camera.set_pitch(p);
825 }
826 }
827
828 pub fn animate_zoom(&mut self, target_distance: f64) {
834 if target_distance.is_finite() && target_distance > 0.0 {
835 self.target_distance = Some(target_distance);
836 }
837 }
838
839 pub fn animate_rotate(&mut self, target_yaw: f64, target_pitch: f64) {
843 if target_yaw.is_finite() {
844 self.target_yaw = Some(target_yaw);
845 }
846 if target_pitch.is_finite() {
847 self.target_pitch = Some(target_pitch);
848 }
849 }
850
851 pub fn apply_momentum(&mut self, vx: f64, vy: f64) {
855 if vx.is_finite() && vy.is_finite() {
856 self.momentum = (vx, vy);
857 }
858 }
859
860 pub fn cancel(&mut self) {
862 self.fly_to = None;
863 self.ease_to = None;
864 self.target_distance = None;
865 self.target_yaw = None;
866 self.target_pitch = None;
867 self.momentum = (0.0, 0.0);
868 }
869
870 pub fn is_active(&self) -> bool {
872 self.fly_to.is_some()
873 || self.ease_to.is_some()
874 || self.target_distance.is_some()
875 || self.target_yaw.is_some()
876 || self.target_pitch.is_some()
877 || self.momentum.0.abs() > MOMENTUM_EPS
878 || self.momentum.1.abs() > MOMENTUM_EPS
879 }
880
881 pub fn is_flying(&self) -> bool {
883 self.fly_to.is_some()
884 }
885
886 pub fn is_easing(&self) -> bool {
888 self.ease_to.is_some()
889 }
890
891 pub fn tick(&mut self, camera: &mut Camera, dt: f64) {
895 if !dt.is_finite() || dt <= 0.0 {
896 return;
897 }
898
899 if let Some(ref mut state) = self.fly_to {
901 if state.tick(camera, dt) {
902 self.fly_to = None;
903 }
904 return;
905 }
906
907 if let Some(ref mut state) = self.ease_to {
909 if state.tick(camera, dt) {
910 self.ease_to = None;
911 }
912 return;
913 }
914
915 let smoothing = self.smoothing.max(0.0);
917 let t = 1.0 - (-smoothing * dt).exp();
918
919 if let Some(target) = self.target_distance {
920 let d = camera.distance() + (target - camera.distance()) * t;
921 camera.set_distance(d);
922 if (camera.distance() - target).abs() < DISTANCE_EPS {
923 camera.set_distance(target);
924 self.target_distance = None;
925 }
926 }
927
928 if let Some(target) = self.target_yaw {
929 let delta = shortest_angle_delta(camera.yaw(), target);
930 camera.set_yaw(camera.yaw() + delta * t);
931 if delta.abs() < ANGLE_EPS {
932 camera.set_yaw(target);
933 self.target_yaw = None;
934 }
935 }
936
937 if let Some(target) = self.target_pitch {
938 let p = camera.pitch() + (target - camera.pitch()) * t;
939 camera.set_pitch(p);
940 if (camera.pitch() - target).abs() < ANGLE_EPS {
941 camera.set_pitch(target);
942 self.target_pitch = None;
943 }
944 }
945
946 if self.momentum.0.abs() > MOMENTUM_EPS || self.momentum.1.abs() > MOMENTUM_EPS {
948 let dx_deg = self.momentum.0 * dt
949 / (METERS_PER_DEGREE * camera.target().lat.to_radians().cos().max(0.001));
950 let dy_deg = self.momentum.1 * dt / METERS_PER_DEGREE;
951
952 let mut target = *camera.target();
953 target.lon += dx_deg;
954 target.lat += dy_deg;
955
956 target.lat = target.lat.clamp(-MERCATOR_MAX_LAT, MERCATOR_MAX_LAT);
957 if target.lon > 180.0 {
958 target.lon -= 360.0;
959 }
960 if target.lon < -180.0 {
961 target.lon += 360.0;
962 }
963 camera.set_target(target);
964
965 let decay = self.momentum_decay.clamp(0.0, 1.0).powf(dt);
966 self.momentum.0 *= decay;
967 self.momentum.1 *= decay;
968 }
969 }
970}
971
972fn shortest_angle_delta(from: f64, to: f64) -> f64 {
980 let two_pi = std::f64::consts::TAU;
981 let mut d = (to - from) % two_pi;
982 if d > std::f64::consts::PI {
983 d -= two_pi;
984 }
985 if d < -std::f64::consts::PI {
986 d += two_pi;
987 }
988 d
989}
990
991fn shortest_bearing_target(from: f64, to: f64) -> f64 {
996 from + shortest_angle_delta(from, to)
997}
998
999#[cfg(test)]
1004mod tests {
1005 use super::*;
1006 use rustial_math::GeoCoord;
1007
1008 #[test]
1011 fn zoom_converges() {
1012 let mut cam = Camera::default();
1013 cam.set_distance(10_000_000.0);
1014 let mut anim = CameraAnimator::new();
1015 anim.animate_zoom(1_000_000.0);
1016
1017 for _ in 0..60 {
1018 anim.tick(&mut cam, 1.0 / 60.0);
1019 }
1020 assert!((cam.distance() - 1_000_000.0).abs() < 100_000.0);
1021
1022 for _ in 0..240 {
1023 anim.tick(&mut cam, 1.0 / 60.0);
1024 }
1025 assert!((cam.distance() - 1_000_000.0).abs() < 1.0);
1026 assert!(!anim.is_active());
1027 }
1028
1029 #[test]
1030 fn momentum_decays() {
1031 let mut cam = Camera::default();
1032 let mut anim = CameraAnimator::new();
1033 anim.apply_momentum(100_000.0, 0.0);
1034
1035 assert!(anim.is_active());
1036
1037 for _ in 0..600 {
1038 anim.tick(&mut cam, 1.0 / 60.0);
1039 }
1040
1041 assert!(!anim.is_active());
1042 }
1043
1044 #[test]
1045 fn cancel_stops_all() {
1046 let mut anim = CameraAnimator::new();
1047 anim.animate_zoom(100.0);
1048 anim.apply_momentum(10.0, 10.0);
1049 assert!(anim.is_active());
1050 anim.cancel();
1051 assert!(!anim.is_active());
1052 }
1053
1054 #[test]
1055 fn shortest_angle_delta_wraps() {
1056 let from = std::f64::consts::PI - 0.0174533;
1057 let to = -std::f64::consts::PI + 0.0174533;
1058 let d = shortest_angle_delta(from, to);
1059 assert!(d.abs() < 0.1);
1060 }
1061
1062 #[test]
1063 fn rotate_uses_shortest_path() {
1064 let mut cam = Camera::default();
1065 cam.set_yaw(std::f64::consts::PI - 0.01);
1066 let mut anim = CameraAnimator::new();
1067 anim.animate_rotate(-std::f64::consts::PI + 0.01, cam.pitch());
1068
1069 let before = cam.yaw();
1070 anim.tick(&mut cam, 1.0 / 60.0);
1071 let moved = (cam.yaw() - before).abs();
1072 assert!(
1073 moved < 0.2,
1074 "yaw moved too far; likely long-path interpolation"
1075 );
1076 }
1077
1078 #[test]
1079 fn tick_ignores_invalid_dt() {
1080 let mut cam = Camera::default();
1081 let mut anim = CameraAnimator::new();
1082 anim.animate_zoom(1000.0);
1083 let before = cam.distance();
1084
1085 anim.tick(&mut cam, f64::NAN);
1086 assert_eq!(cam.distance(), before);
1087
1088 anim.tick(&mut cam, 0.0);
1089 assert_eq!(cam.distance(), before);
1090
1091 anim.tick(&mut cam, -1.0);
1092 assert_eq!(cam.distance(), before);
1093 }
1094
1095 #[test]
1096 fn animate_zoom_rejects_invalid_values() {
1097 let mut anim = CameraAnimator::new();
1098 anim.animate_zoom(f64::NAN);
1099 assert!(!anim.is_active());
1100 anim.animate_zoom(-10.0);
1101 assert!(!anim.is_active());
1102 anim.animate_zoom(0.0);
1103 assert!(!anim.is_active());
1104 }
1105
1106 #[test]
1107 fn animate_rotate_rejects_non_finite_independently() {
1108 let mut anim = CameraAnimator::new();
1109 anim.animate_rotate(f64::NAN, 0.1);
1110 assert!(anim.is_active());
1111 anim.cancel();
1112
1113 anim.animate_rotate(0.2, f64::NAN);
1114 assert!(anim.is_active());
1115 }
1116
1117 #[test]
1118 fn momentum_clamps_and_wraps_geo() {
1119 let mut cam = Camera::default();
1120 cam.set_target(GeoCoord::from_lat_lon(85.0, 179.9));
1121 let mut anim = CameraAnimator::new();
1122 anim.apply_momentum(100_000.0, 100_000.0);
1123
1124 anim.tick(&mut cam, 1.0);
1125
1126 assert!(cam.target().lat <= MERCATOR_MAX_LAT);
1127 assert!(cam.target().lon >= -180.0 && cam.target().lon <= 180.0);
1128 }
1129
1130 #[test]
1131 fn decay_is_clamped_to_valid_range() {
1132 let mut cam = Camera::default();
1133 let mut anim = CameraAnimator::new();
1134 anim.apply_momentum(100.0, 0.0);
1135 anim.momentum_decay = 2.0;
1136
1137 let before = cam.target().lon;
1138 anim.tick(&mut cam, 1.0);
1139 let after = cam.target().lon;
1140 assert!(after != before);
1141 let after2_before = cam.target().lon;
1142 anim.tick(&mut cam, 1.0);
1143 assert!((cam.target().lon - after2_before).abs() < 1.0);
1144 }
1145
1146 #[test]
1149 fn fly_to_changes_center_and_zoom() {
1150 let mut cam = Camera::default();
1151 cam.set_target(GeoCoord::from_lat_lon(0.0, 0.0));
1152 cam.set_distance(10_000_000.0);
1153 cam.set_viewport(800, 600);
1154
1155 let mut anim = CameraAnimator::new();
1156 anim.start_fly_to(
1157 &mut cam,
1158 &FlyToOptions {
1159 center: Some(GeoCoord::from_lat_lon(48.8566, 2.3522)), zoom: Some(12.0),
1161 ..Default::default()
1162 },
1163 );
1164
1165 assert!(anim.is_active());
1166 assert!(anim.is_flying());
1167
1168 for _ in 0..6000 {
1170 anim.tick(&mut cam, 1.0 / 60.0);
1171 }
1172
1173 assert!(!anim.is_active());
1174 assert!(
1175 (cam.target().lat - 48.8566).abs() < 0.01,
1176 "lat={}",
1177 cam.target().lat
1178 );
1179 assert!(
1180 (cam.target().lon - 2.3522).abs() < 0.01,
1181 "lon={}",
1182 cam.target().lon
1183 );
1184 }
1185
1186 #[test]
1187 fn fly_to_animates_bearing_shortest_path() {
1188 let mut cam = Camera::default();
1189 cam.set_distance(1_000_000.0);
1190 cam.set_viewport(800, 600);
1191 cam.set_yaw(std::f64::consts::PI - 0.1);
1192
1193 let target_yaw = -std::f64::consts::PI + 0.1;
1194 let mut anim = CameraAnimator::new();
1195 anim.start_fly_to(
1196 &mut cam,
1197 &FlyToOptions {
1198 bearing: Some(target_yaw),
1199 zoom: Some(5.0),
1200 ..Default::default()
1201 },
1202 );
1203
1204 let before = cam.yaw();
1207 anim.tick(&mut cam, 1.0 / 60.0);
1208 let step = (cam.yaw() - before).abs();
1209 assert!(step < 0.5, "yaw step too large: {step}");
1210 }
1211
1212 #[test]
1213 fn fly_to_same_location_different_zoom_works() {
1214 let mut cam = Camera::default();
1215 cam.set_distance(10_000_000.0);
1216 cam.set_viewport(800, 600);
1217 let start = *cam.target();
1218
1219 let mut anim = CameraAnimator::new();
1220 anim.start_fly_to(
1221 &mut cam,
1222 &FlyToOptions {
1223 zoom: Some(14.0),
1224 ..Default::default()
1225 },
1226 );
1227
1228 assert!(anim.is_active());
1229
1230 for _ in 0..6000 {
1231 anim.tick(&mut cam, 1.0 / 60.0);
1232 }
1233
1234 assert!(!anim.is_active());
1235 assert!((cam.target().lat - start.lat).abs() < 0.01);
1237 assert!((cam.target().lon - start.lon).abs() < 0.01);
1238 }
1239
1240 #[test]
1241 fn fly_to_cancel_stops_animation() {
1242 let mut cam = Camera::default();
1243 cam.set_distance(10_000_000.0);
1244 cam.set_viewport(800, 600);
1245
1246 let mut anim = CameraAnimator::new();
1247 anim.start_fly_to(
1248 &mut cam,
1249 &FlyToOptions {
1250 center: Some(GeoCoord::from_lat_lon(51.5, -0.12)),
1251 zoom: Some(10.0),
1252 ..Default::default()
1253 },
1254 );
1255
1256 anim.tick(&mut cam, 0.1);
1257 assert!(anim.is_active());
1258 anim.cancel();
1259 assert!(!anim.is_active());
1260 }
1261
1262 #[test]
1263 fn fly_to_explicit_duration() {
1264 let mut cam = Camera::default();
1265 cam.set_distance(10_000_000.0);
1266 cam.set_viewport(800, 600);
1267
1268 let mut anim = CameraAnimator::new();
1269 anim.start_fly_to(
1270 &mut cam,
1271 &FlyToOptions {
1272 center: Some(GeoCoord::from_lat_lon(35.6762, 139.6503)),
1273 zoom: Some(10.0),
1274 duration: Some(2.0),
1275 ..Default::default()
1276 },
1277 );
1278
1279 for _ in 0..130 {
1281 anim.tick(&mut cam, 1.0 / 60.0);
1282 }
1283 assert!(!anim.is_active());
1284 assert!((cam.target().lat - 35.6762).abs() < 0.01);
1285 }
1286
1287 #[test]
1288 fn fly_to_max_duration_degrades_to_jump() {
1289 let mut cam = Camera::default();
1290 cam.set_distance(10_000_000.0);
1291 cam.set_viewport(800, 600);
1292
1293 let mut anim = CameraAnimator::new();
1294 anim.start_fly_to(
1295 &mut cam,
1296 &FlyToOptions {
1297 center: Some(GeoCoord::from_lat_lon(35.6762, 139.6503)),
1298 zoom: Some(10.0),
1299 max_duration: Some(0.001), ..Default::default()
1301 },
1302 );
1303
1304 assert!(!anim.is_active());
1306 assert!((cam.target().lat - 35.6762).abs() < 0.01);
1307 }
1308
1309 #[test]
1312 fn ease_to_basic() {
1313 let mut cam = Camera::default();
1314 cam.set_distance(10_000_000.0);
1315 cam.set_viewport(800, 600);
1316
1317 let mut anim = CameraAnimator::new();
1318 anim.start_ease_to(
1319 &mut cam,
1320 &EaseToOptions {
1321 center: Some(GeoCoord::from_lat_lon(40.7128, -74.0060)),
1322 zoom: Some(12.0),
1323 duration: 0.5,
1324 ..Default::default()
1325 },
1326 );
1327
1328 assert!(anim.is_active());
1329 assert!(anim.is_easing());
1330
1331 for _ in 0..60 {
1332 anim.tick(&mut cam, 1.0 / 60.0);
1333 }
1334
1335 assert!(!anim.is_active());
1336 assert!((cam.target().lat - 40.7128).abs() < 0.01);
1337 }
1338
1339 #[test]
1340 fn ease_to_zero_duration_is_instant() {
1341 let mut cam = Camera::default();
1342 cam.set_distance(10_000_000.0);
1343 cam.set_viewport(800, 600);
1344
1345 let mut anim = CameraAnimator::new();
1346 anim.start_ease_to(
1347 &mut cam,
1348 &EaseToOptions {
1349 center: Some(GeoCoord::from_lat_lon(51.5, -0.12)),
1350 duration: 0.0,
1351 ..Default::default()
1352 },
1353 );
1354
1355 assert!(!anim.is_active());
1357 assert!((cam.target().lat - 51.5).abs() < 0.01);
1358 }
1359
1360 #[test]
1363 fn default_easing_endpoints() {
1364 assert!((default_easing(0.0)).abs() < 1e-6);
1365 assert!((default_easing(1.0) - 1.0).abs() < 1e-6);
1366 }
1367
1368 #[test]
1369 fn default_easing_monotonic() {
1370 let mut prev = 0.0;
1371 for i in 1..=100 {
1372 let t = i as f64 / 100.0;
1373 let v = default_easing(t);
1374 assert!(v >= prev - 1e-9, "non-monotonic at t={t}: {v} < {prev}");
1375 prev = v;
1376 }
1377 }
1378
1379 #[test]
1382 fn fly_to_zooms_out_then_in() {
1383 let mut cam = Camera::default();
1386 cam.set_target(GeoCoord::from_lat_lon(0.0, 0.0));
1387 cam.set_distance(zoom_to_distance(
1388 5.0,
1389 cam.fov_y(),
1390 cam.viewport_height(),
1391 true,
1392 ));
1393 cam.set_viewport(800, 600);
1394
1395 let start_dist = cam.distance();
1396 let end_zoom = 5.0; let mut anim = CameraAnimator::new();
1399 anim.start_fly_to(
1400 &mut cam,
1401 &FlyToOptions {
1402 center: Some(GeoCoord::from_lat_lon(40.0, 30.0)),
1403 zoom: Some(end_zoom),
1404 duration: Some(4.0),
1405 easing: Some(|t| t), ..Default::default()
1407 },
1408 );
1409
1410 let mut max_dist: f64 = 0.0;
1414 for _ in 0..240 {
1415 anim.tick(&mut cam, 1.0 / 60.0);
1416 max_dist = max_dist.max(cam.distance());
1417 }
1418 for _ in 0..300 {
1420 anim.tick(&mut cam, 1.0 / 60.0);
1421 }
1422
1423 assert!(
1424 max_dist > start_dist * 1.5,
1425 "mid-flight distance ({max_dist:.0}) should be well above start ({start_dist:.0})"
1426 );
1427 let final_dist = cam.distance();
1429 assert!(
1430 (final_dist - start_dist).abs() < start_dist * 0.05,
1431 "final distance ({final_dist:.0}) should be near start ({start_dist:.0})"
1432 );
1433 }
1434
1435 #[test]
1436 fn fly_to_van_wijk_r_function_matches_maplibre() {
1437 let w0: f64 = 800.0;
1445 let w1: f64 = 400.0;
1446 let u1: f64 = 1000.0;
1447 let rho: f64 = 1.42;
1448 let rho2 = rho * rho;
1449
1450 let r = |descent: bool| -> f64 {
1451 let (w, sign) = if descent { (w1, -1.0) } else { (w0, 1.0) };
1452 let b = (w1 * w1 - w0 * w0 + sign * rho2 * rho2 * u1 * u1) / (2.0 * w * rho2 * u1);
1453 ((b * b + 1.0).sqrt() - b).ln()
1454 };
1455
1456 let r0 = r(false);
1457 let r1 = r(true);
1458 let s = (r1 - r0) / rho;
1459
1460 assert!(r0.is_finite(), "r0 should be finite");
1461 assert!(r1.is_finite(), "r1 should be finite");
1462 assert!(s > 0.0, "path length S should be positive, got {s}");
1463 assert!(s.is_finite(), "path length S should be finite");
1464
1465 let w_at_0 = r0.cosh() / (r0 + rho * 0.0).cosh();
1467 assert!(
1468 (w_at_0 - 1.0).abs() < 1e-10,
1469 "w(0) should be 1.0, got {w_at_0}"
1470 );
1471
1472 let w_at_s = r0.cosh() / (r0 + rho * s).cosh();
1474 let expected_w_at_s = w1 / w0; assert!(
1476 (w_at_s - expected_w_at_s).abs() < 0.01,
1477 "w(S) should be {expected_w_at_s}, got {w_at_s}"
1478 );
1479 }
1480
1481 #[test]
1482 fn fly_to_degenerate_same_center_uses_w1_not_zoom() {
1483 let mut cam = Camera::default();
1486 cam.set_target(GeoCoord::from_lat_lon(10.0, 20.0));
1487 cam.set_distance(zoom_to_distance(
1488 3.0,
1489 cam.fov_y(),
1490 cam.viewport_height(),
1491 true,
1492 ));
1493 cam.set_viewport(800, 600);
1494
1495 let start_dist = cam.distance();
1496
1497 let mut anim = CameraAnimator::new();
1498 anim.start_fly_to(
1499 &mut cam,
1500 &FlyToOptions {
1501 zoom: Some(10.0),
1503 duration: Some(1.0),
1504 ..Default::default()
1505 },
1506 );
1507
1508 assert!(
1509 anim.is_active(),
1510 "animation should be active for zoom-only flight"
1511 );
1512
1513 for _ in 0..70 {
1515 anim.tick(&mut cam, 1.0 / 60.0);
1516 }
1517
1518 assert!(!anim.is_active());
1519 assert!(
1521 cam.distance() < start_dist * 0.01,
1522 "should have zoomed in: start={start_dist:.0}, end={:.0}",
1523 cam.distance()
1524 );
1525 }
1526}