1use crate::camera::Camera;
33use crate::camera_projection::CameraProjection;
34use crate::geo_wrap::{wrap_lon_180, shortest_lon_target};
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)).log2().clamp(0.0, 22.0)
133}
134
135#[derive(Debug, Clone)]
146pub struct FlyToOptions {
147 pub center: Option<GeoCoord>,
149 pub zoom: Option<f64>,
151 pub bearing: Option<f64>,
155 pub pitch: Option<f64>,
157 pub curve: f64,
162 pub speed: f64,
166 pub screen_speed: Option<f64>,
170 pub duration: Option<f64>,
175 pub min_zoom: Option<f64>,
180 pub max_duration: Option<f64>,
185 pub easing: Option<fn(f64) -> f64>,
189}
190
191impl Default for FlyToOptions {
192 fn default() -> Self {
193 Self {
194 center: None,
195 zoom: None,
196 bearing: None,
197 pitch: None,
198 curve: 1.42,
199 speed: 1.2,
200 screen_speed: None,
201 duration: None,
202 min_zoom: None,
203 max_duration: None,
204 easing: None,
205 }
206 }
207}
208
209#[derive(Debug, Clone)]
218pub struct EaseToOptions {
219 pub center: Option<GeoCoord>,
221 pub zoom: Option<f64>,
223 pub bearing: Option<f64>,
225 pub pitch: Option<f64>,
227 pub duration: f64,
229 pub easing: Option<fn(f64) -> f64>,
231}
232
233impl Default for EaseToOptions {
234 fn default() -> Self {
235 Self {
236 center: None,
237 zoom: None,
238 bearing: None,
239 pitch: None,
240 duration: 0.5,
241 easing: None,
242 }
243 }
244}
245
246struct FlyToState {
252 end_center: GeoCoord,
254 end_zoom: f64,
255 end_bearing: f64,
256 end_pitch: f64,
257
258 start_zoom: f64,
260 start_bearing: f64,
261 start_pitch: f64,
262
263 from_x: f64,
266 from_y: f64,
267 delta_x: f64,
269 delta_y: f64,
270 w0: f64,
272 u1: f64,
274 rho: f64,
276 r0: f64,
278 path_length: f64,
280 zoom_only: bool,
282 zoom_only_sign: f64,
284
285 duration: f64,
287 elapsed: f64,
288 easing: fn(f64) -> f64,
289
290 projection: CameraProjection,
292 is_perspective: bool,
293 fov_y: f64,
294 viewport_height: u32,
295}
296
297impl FlyToState {
298 fn new(camera: &Camera, opts: &FlyToOptions) -> Option<Self> {
299 let projection = camera.projection();
300 let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
301 let fov_y = camera.fov_y();
302 let viewport_height = camera.viewport_height();
303 let viewport_width = camera.viewport_width();
304
305 let start_center = *camera.target();
306 let start_zoom = distance_to_zoom(camera.distance(), fov_y, viewport_height, is_perspective);
307 let start_bearing = camera.yaw();
308 let start_pitch = camera.pitch();
309
310 let end_center = opts.center.unwrap_or(start_center);
311 let end_zoom = opts.zoom.unwrap_or(start_zoom);
312 let end_bearing = match opts.bearing {
313 Some(b) => shortest_bearing_target(start_bearing, b),
314 None => start_bearing,
315 };
316 let end_pitch = opts.pitch.unwrap_or(start_pitch);
317
318 let from_world = projection.project(&start_center);
320 let to_world = projection.project(&end_center);
321
322 let pixels_per_meter = 2.0_f64.powf(start_zoom) * TILE_PX / WGS84_CIRCUMFERENCE;
327
328 let from_px_x = from_world.position.x * pixels_per_meter;
329 let from_px_y = from_world.position.y * pixels_per_meter;
330 let to_px_x = to_world.position.x * pixels_per_meter;
331 let to_px_y = to_world.position.y * pixels_per_meter;
332
333 let delta_x = to_px_x - from_px_x;
334 let delta_y = to_px_y - from_px_y;
335
336 let u1 = (delta_x * delta_x + delta_y * delta_y).sqrt();
338
339 let w0 = viewport_width.max(viewport_height).max(1) as f64;
341
342 let scale_of_zoom = 2.0_f64.powf(end_zoom - start_zoom);
344
345 let w1 = w0 / scale_of_zoom;
348
349 let mut rho = opts.curve;
351
352 if let Some(min_z) = opts.min_zoom {
355 let min_z = min_z.min(start_zoom).min(end_zoom);
356 let scale_of_min_zoom = 2.0_f64.powf(min_z - start_zoom);
357 let w_max = w0 / scale_of_min_zoom;
358 if u1 > 0.0 {
359 rho = (w_max / u1 * 2.0).sqrt();
360 }
361 }
362
363 let rho2 = rho * rho;
364
365 let zoom_out_factor = |descent: bool| -> f64 {
374 let (w, sign) = if descent { (w1, -1.0) } else { (w0, 1.0) };
375 let b = (w1 * w1 - w0 * w0 + sign * rho2 * rho2 * u1 * u1)
376 / (2.0 * w * rho2 * u1);
377 ((b * b + 1.0).sqrt() - b).ln()
378 };
379
380 let r0;
381 let path_length;
382 let zoom_only;
383 let zoom_only_sign;
384
385 let is_degenerate = if u1.abs() < 0.000001 {
389 true
390 } else {
391 let trial_r0 = zoom_out_factor(false);
392 let trial_r1 = zoom_out_factor(true);
393 let trial_s = (trial_r1 - trial_r0) / rho;
394 !trial_s.is_finite()
395 };
396
397 if is_degenerate {
398 if (w0 - w1).abs() < 0.000001 {
401 if (end_bearing - start_bearing).abs() < ANGLE_EPS
404 && (end_pitch - start_pitch).abs() < ANGLE_EPS
405 {
406 return None;
407 }
408 }
409
410 zoom_only = true;
414 zoom_only_sign = if w1 < w0 { -1.0 } else { 1.0 };
415 r0 = 0.0;
416 path_length = if w1 > 0.0 && w0 > 0.0 {
417 (w1 / w0).ln().abs() / rho
418 } else {
419 0.0
420 };
421 } else {
422 zoom_only = false;
423 zoom_only_sign = 0.0;
424 r0 = zoom_out_factor(false);
426 let r1 = zoom_out_factor(true);
427 path_length = (r1 - r0) / rho;
428 }
429
430 let duration = if let Some(d) = opts.duration {
434 d
435 } else {
436 let v = if let Some(ss) = opts.screen_speed {
437 ss / rho
438 } else {
439 opts.speed
440 };
441 if v > 0.0 { path_length / v } else { 1.0 }
442 };
443
444 if let Some(max_dur) = opts.max_duration {
448 if duration > max_dur {
449 return None; }
451 }
452
453 let easing = opts.easing.unwrap_or(default_easing);
454
455 Some(FlyToState {
456 start_zoom,
457 start_bearing,
458 start_pitch,
459 end_center,
460 end_zoom,
461 end_bearing,
462 end_pitch,
463 from_x: from_px_x,
464 from_y: from_px_y,
465 delta_x,
466 delta_y,
467 w0,
468 u1,
469 rho,
470 r0,
471 path_length,
472 zoom_only,
473 zoom_only_sign,
474 duration,
475 elapsed: 0.0,
476 easing,
477 projection,
478 is_perspective,
479 fov_y,
480 viewport_height,
481 })
482 }
483
484 fn w(&self, s: f64) -> f64 {
486 if self.zoom_only {
487 (self.zoom_only_sign * self.rho * s).exp()
488 } else {
489 self.r0.cosh() / (self.r0 + self.rho * s).cosh()
490 }
491 }
492
493 fn u(&self, s: f64) -> f64 {
495 if self.zoom_only {
496 0.0
497 } else {
498 let rho2 = self.rho * self.rho;
499 self.w0 * ((self.r0.cosh() * (self.r0 + self.rho * s).tanh() - self.r0.sinh()) / rho2)
500 / self.u1
501 }
502 }
503
504 fn tick(&mut self, camera: &mut Camera, dt: f64) -> bool {
506 self.elapsed += dt;
507 let t = (self.elapsed / self.duration.max(1e-9)).clamp(0.0, 1.0);
508 let k = (self.easing)(t);
509
510 let done = t >= 1.0;
511
512 if done {
513 camera.set_target(self.end_center);
515 camera.set_distance(zoom_to_distance(
516 self.end_zoom,
517 self.fov_y,
518 self.viewport_height,
519 self.is_perspective,
520 ));
521 camera.set_yaw(self.end_bearing);
522 camera.set_pitch(self.end_pitch);
523 return true;
524 }
525
526 let s = k * self.path_length;
528
529 let scale = 1.0 / self.w(s);
531
532 let center_factor = self.u(s);
534
535 let zoom = self.start_zoom + scale.log2();
537 camera.set_distance(zoom_to_distance(
538 zoom,
539 self.fov_y,
540 self.viewport_height,
541 self.is_perspective,
542 ));
543
544 let pixels_per_meter = 2.0_f64.powf(self.start_zoom) * TILE_PX / WGS84_CIRCUMFERENCE;
546 let cx_px = self.from_x + self.delta_x * center_factor;
547 let cy_px = self.from_y + self.delta_y * center_factor;
548 let cx_m = cx_px / pixels_per_meter;
560 let cy_m = cy_px / pixels_per_meter;
561 let new_center = self.projection.unproject(&WorldCoord::new(cx_m, cy_m, 0.0));
562 camera.set_target(new_center);
563
564 let bearing = self.start_bearing + (self.end_bearing - self.start_bearing) * k;
566 camera.set_yaw(bearing);
567
568 let pitch = self.start_pitch + (self.end_pitch - self.start_pitch) * k;
570 camera.set_pitch(pitch);
571
572 false
573 }
574}
575
576struct EaseToState {
582 start_center: GeoCoord,
583 start_zoom: f64,
584 start_bearing: f64,
585 start_pitch: f64,
586
587 end_center: GeoCoord,
588 end_zoom: f64,
589 end_bearing: f64,
590 end_pitch: f64,
591
592 duration: f64,
593 elapsed: f64,
594 easing: fn(f64) -> f64,
595
596 is_perspective: bool,
597 fov_y: f64,
598 viewport_height: u32,
599}
600
601impl EaseToState {
602 fn new(camera: &Camera, opts: &EaseToOptions) -> Self {
603 let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
604 let start_zoom = distance_to_zoom(camera.distance(), camera.fov_y(), camera.viewport_height(), is_perspective);
605 let start_bearing = camera.yaw();
606
607 let end_center = opts.center.unwrap_or(*camera.target());
608 let wrapped_end_center = GeoCoord::from_lat_lon(
609 end_center.lat,
610 shortest_lon_target((*camera.target()).lon, end_center.lon),
611 );
612
613 Self {
614 start_center: *camera.target(),
615 start_zoom,
616 start_bearing,
617 start_pitch: camera.pitch(),
618 end_center: wrapped_end_center,
619 end_zoom: opts.zoom.unwrap_or(start_zoom),
620 end_bearing: match opts.bearing {
621 Some(b) => shortest_bearing_target(start_bearing, b),
622 None => start_bearing,
623 },
624 end_pitch: opts.pitch.unwrap_or(camera.pitch()),
625 duration: opts.duration,
626 elapsed: 0.0,
627 easing: opts.easing.unwrap_or(default_easing),
628 is_perspective,
629 fov_y: camera.fov_y(),
630 viewport_height: camera.viewport_height(),
631 }
632 }
633
634 fn tick(&mut self, camera: &mut Camera, dt: f64) -> bool {
635 self.elapsed += dt;
636 let t = (self.elapsed / self.duration.max(1e-9)).clamp(0.0, 1.0);
637 let k = (self.easing)(t);
638 let done = t >= 1.0;
639
640 if done {
641 camera.set_target(self.end_center);
642 camera.set_distance(zoom_to_distance(
643 self.end_zoom,
644 self.fov_y,
645 self.viewport_height,
646 self.is_perspective,
647 ));
648 camera.set_yaw(self.end_bearing);
649 camera.set_pitch(self.end_pitch);
650 return true;
651 }
652
653 let lat = self.start_center.lat + (self.end_center.lat - self.start_center.lat) * k;
655 let lon = self.start_center.lon + (self.end_center.lon - self.start_center.lon) * k;
656 camera.set_target(GeoCoord::from_lat_lon(lat, wrap_lon_180(lon)));
657
658 let zoom = self.start_zoom + (self.end_zoom - self.start_zoom) * k;
660 camera.set_distance(zoom_to_distance(
661 zoom,
662 self.fov_y,
663 self.viewport_height,
664 self.is_perspective,
665 ));
666
667 camera.set_yaw(self.start_bearing + (self.end_bearing - self.start_bearing) * k);
669
670 camera.set_pitch(self.start_pitch + (self.end_pitch - self.start_pitch) * k);
672
673 false
674 }
675}
676
677pub struct CameraAnimator {
693 fly_to: Option<FlyToState>,
695 ease_to: Option<EaseToState>,
696
697 target_distance: Option<f64>,
699 target_yaw: Option<f64>,
700 target_pitch: Option<f64>,
701
702 momentum: (f64, f64),
704
705 pub momentum_decay: f64,
707 pub smoothing: f64,
710}
711
712impl Default for CameraAnimator {
713 fn default() -> Self {
714 Self {
715 fly_to: None,
716 ease_to: None,
717 target_distance: None,
718 target_yaw: None,
719 target_pitch: None,
720 momentum: (0.0, 0.0),
721 momentum_decay: 0.05,
722 smoothing: 8.0,
723 }
724 }
725}
726
727impl CameraAnimator {
728 pub fn new() -> Self {
730 Self::default()
731 }
732
733 pub fn start_fly_to(&mut self, camera: &mut Camera, options: &FlyToOptions) {
747 self.cancel();
748
749 match FlyToState::new(camera, options) {
750 Some(state) => {
751 self.fly_to = Some(state);
752 }
753 None => {
754 Self::apply_jump(camera, options.center, options.zoom, options.bearing, options.pitch);
756 }
757 }
758 }
759
760 pub fn start_ease_to(&mut self, camera: &mut Camera, options: &EaseToOptions) {
767 self.cancel();
768
769 if options.duration <= 0.0 {
770 Self::apply_jump(camera, options.center, options.zoom, options.bearing, options.pitch);
771 return;
772 }
773
774 self.ease_to = Some(EaseToState::new(camera, options));
775 }
776
777 fn apply_jump(
779 camera: &mut Camera,
780 center: Option<GeoCoord>,
781 zoom: Option<f64>,
782 bearing: Option<f64>,
783 pitch: Option<f64>,
784 ) {
785 if let Some(c) = center {
786 camera.set_target(c);
787 }
788 if let Some(z) = zoom {
789 let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
790 camera.set_distance(zoom_to_distance(
791 z,
792 camera.fov_y(),
793 camera.viewport_height(),
794 is_perspective,
795 ));
796 }
797 if let Some(b) = bearing {
798 camera.set_yaw(b);
799 }
800 if let Some(p) = pitch {
801 camera.set_pitch(p);
802 }
803 }
804
805 pub fn animate_zoom(&mut self, target_distance: f64) {
811 if target_distance.is_finite() && target_distance > 0.0 {
812 self.target_distance = Some(target_distance);
813 }
814 }
815
816 pub fn animate_rotate(&mut self, target_yaw: f64, target_pitch: f64) {
820 if target_yaw.is_finite() {
821 self.target_yaw = Some(target_yaw);
822 }
823 if target_pitch.is_finite() {
824 self.target_pitch = Some(target_pitch);
825 }
826 }
827
828 pub fn apply_momentum(&mut self, vx: f64, vy: f64) {
832 if vx.is_finite() && vy.is_finite() {
833 self.momentum = (vx, vy);
834 }
835 }
836
837 pub fn cancel(&mut self) {
839 self.fly_to = None;
840 self.ease_to = None;
841 self.target_distance = None;
842 self.target_yaw = None;
843 self.target_pitch = None;
844 self.momentum = (0.0, 0.0);
845 }
846
847 pub fn is_active(&self) -> bool {
849 self.fly_to.is_some()
850 || self.ease_to.is_some()
851 || self.target_distance.is_some()
852 || self.target_yaw.is_some()
853 || self.target_pitch.is_some()
854 || self.momentum.0.abs() > MOMENTUM_EPS
855 || self.momentum.1.abs() > MOMENTUM_EPS
856 }
857
858 pub fn is_flying(&self) -> bool {
860 self.fly_to.is_some()
861 }
862
863 pub fn is_easing(&self) -> bool {
865 self.ease_to.is_some()
866 }
867
868 pub fn tick(&mut self, camera: &mut Camera, dt: f64) {
872 if !dt.is_finite() || dt <= 0.0 {
873 return;
874 }
875
876 if let Some(ref mut state) = self.fly_to {
878 if state.tick(camera, dt) {
879 self.fly_to = None;
880 }
881 return;
882 }
883
884 if let Some(ref mut state) = self.ease_to {
886 if state.tick(camera, dt) {
887 self.ease_to = None;
888 }
889 return;
890 }
891
892 let smoothing = self.smoothing.max(0.0);
894 let t = 1.0 - (-smoothing * dt).exp();
895
896 if let Some(target) = self.target_distance {
897 let d = camera.distance() + (target - camera.distance()) * t;
898 camera.set_distance(d);
899 if (camera.distance() - target).abs() < DISTANCE_EPS {
900 camera.set_distance(target);
901 self.target_distance = None;
902 }
903 }
904
905 if let Some(target) = self.target_yaw {
906 let delta = shortest_angle_delta(camera.yaw(), target);
907 camera.set_yaw(camera.yaw() + delta * t);
908 if delta.abs() < ANGLE_EPS {
909 camera.set_yaw(target);
910 self.target_yaw = None;
911 }
912 }
913
914 if let Some(target) = self.target_pitch {
915 let p = camera.pitch() + (target - camera.pitch()) * t;
916 camera.set_pitch(p);
917 if (camera.pitch() - target).abs() < ANGLE_EPS {
918 camera.set_pitch(target);
919 self.target_pitch = None;
920 }
921 }
922
923 if self.momentum.0.abs() > MOMENTUM_EPS || self.momentum.1.abs() > MOMENTUM_EPS {
925 let dx_deg = self.momentum.0 * dt
926 / (METERS_PER_DEGREE * camera.target().lat.to_radians().cos().max(0.001));
927 let dy_deg = self.momentum.1 * dt / METERS_PER_DEGREE;
928
929 let mut target = *camera.target();
930 target.lon += dx_deg;
931 target.lat += dy_deg;
932
933 target.lat = target.lat.clamp(-MERCATOR_MAX_LAT, MERCATOR_MAX_LAT);
934 if target.lon > 180.0 {
935 target.lon -= 360.0;
936 }
937 if target.lon < -180.0 {
938 target.lon += 360.0;
939 }
940 camera.set_target(target);
941
942 let decay = self.momentum_decay.clamp(0.0, 1.0).powf(dt);
943 self.momentum.0 *= decay;
944 self.momentum.1 *= decay;
945 }
946 }
947}
948
949fn shortest_angle_delta(from: f64, to: f64) -> f64 {
957 let two_pi = std::f64::consts::TAU;
958 let mut d = (to - from) % two_pi;
959 if d > std::f64::consts::PI {
960 d -= two_pi;
961 }
962 if d < -std::f64::consts::PI {
963 d += two_pi;
964 }
965 d
966}
967
968fn shortest_bearing_target(from: f64, to: f64) -> f64 {
973 from + shortest_angle_delta(from, to)
974}
975
976#[cfg(test)]
981mod tests {
982 use super::*;
983 use rustial_math::GeoCoord;
984
985 #[test]
988 fn zoom_converges() {
989 let mut cam = Camera::default();
990 cam.set_distance(10_000_000.0);
991 let mut anim = CameraAnimator::new();
992 anim.animate_zoom(1_000_000.0);
993
994 for _ in 0..60 {
995 anim.tick(&mut cam, 1.0 / 60.0);
996 }
997 assert!((cam.distance() - 1_000_000.0).abs() < 100_000.0);
998
999 for _ in 0..240 {
1000 anim.tick(&mut cam, 1.0 / 60.0);
1001 }
1002 assert!((cam.distance() - 1_000_000.0).abs() < 1.0);
1003 assert!(!anim.is_active());
1004 }
1005
1006 #[test]
1007 fn momentum_decays() {
1008 let mut cam = Camera::default();
1009 let mut anim = CameraAnimator::new();
1010 anim.apply_momentum(100_000.0, 0.0);
1011
1012 assert!(anim.is_active());
1013
1014 for _ in 0..600 {
1015 anim.tick(&mut cam, 1.0 / 60.0);
1016 }
1017
1018 assert!(!anim.is_active());
1019 }
1020
1021 #[test]
1022 fn cancel_stops_all() {
1023 let mut anim = CameraAnimator::new();
1024 anim.animate_zoom(100.0);
1025 anim.apply_momentum(10.0, 10.0);
1026 assert!(anim.is_active());
1027 anim.cancel();
1028 assert!(!anim.is_active());
1029 }
1030
1031 #[test]
1032 fn shortest_angle_delta_wraps() {
1033 let from = std::f64::consts::PI - 0.0174533;
1034 let to = -std::f64::consts::PI + 0.0174533;
1035 let d = shortest_angle_delta(from, to);
1036 assert!(d.abs() < 0.1);
1037 }
1038
1039 #[test]
1040 fn rotate_uses_shortest_path() {
1041 let mut cam = Camera::default();
1042 cam.set_yaw(std::f64::consts::PI - 0.01);
1043 let mut anim = CameraAnimator::new();
1044 anim.animate_rotate(-std::f64::consts::PI + 0.01, cam.pitch());
1045
1046 let before = cam.yaw();
1047 anim.tick(&mut cam, 1.0 / 60.0);
1048 let moved = (cam.yaw() - before).abs();
1049 assert!(moved < 0.2, "yaw moved too far; likely long-path interpolation");
1050 }
1051
1052 #[test]
1053 fn tick_ignores_invalid_dt() {
1054 let mut cam = Camera::default();
1055 let mut anim = CameraAnimator::new();
1056 anim.animate_zoom(1000.0);
1057 let before = cam.distance();
1058
1059 anim.tick(&mut cam, f64::NAN);
1060 assert_eq!(cam.distance(), before);
1061
1062 anim.tick(&mut cam, 0.0);
1063 assert_eq!(cam.distance(), before);
1064
1065 anim.tick(&mut cam, -1.0);
1066 assert_eq!(cam.distance(), before);
1067 }
1068
1069 #[test]
1070 fn animate_zoom_rejects_invalid_values() {
1071 let mut anim = CameraAnimator::new();
1072 anim.animate_zoom(f64::NAN);
1073 assert!(!anim.is_active());
1074 anim.animate_zoom(-10.0);
1075 assert!(!anim.is_active());
1076 anim.animate_zoom(0.0);
1077 assert!(!anim.is_active());
1078 }
1079
1080 #[test]
1081 fn animate_rotate_rejects_non_finite_independently() {
1082 let mut anim = CameraAnimator::new();
1083 anim.animate_rotate(f64::NAN, 0.1);
1084 assert!(anim.is_active());
1085 anim.cancel();
1086
1087 anim.animate_rotate(0.2, f64::NAN);
1088 assert!(anim.is_active());
1089 }
1090
1091 #[test]
1092 fn momentum_clamps_and_wraps_geo() {
1093 let mut cam = Camera::default();
1094 cam.set_target(GeoCoord::from_lat_lon(85.0, 179.9));
1095 let mut anim = CameraAnimator::new();
1096 anim.apply_momentum(100_000.0, 100_000.0);
1097
1098 anim.tick(&mut cam, 1.0);
1099
1100 assert!(cam.target().lat <= MERCATOR_MAX_LAT);
1101 assert!(cam.target().lon >= -180.0 && cam.target().lon <= 180.0);
1102 }
1103
1104 #[test]
1105 fn decay_is_clamped_to_valid_range() {
1106 let mut cam = Camera::default();
1107 let mut anim = CameraAnimator::new();
1108 anim.apply_momentum(100.0, 0.0);
1109 anim.momentum_decay = 2.0;
1110
1111 let before = cam.target().lon;
1112 anim.tick(&mut cam, 1.0);
1113 let after = cam.target().lon;
1114 assert!(after != before);
1115 let after2_before = cam.target().lon;
1116 anim.tick(&mut cam, 1.0);
1117 assert!((cam.target().lon - after2_before).abs() < 1.0);
1118 }
1119
1120 #[test]
1123 fn fly_to_changes_center_and_zoom() {
1124 let mut cam = Camera::default();
1125 cam.set_target(GeoCoord::from_lat_lon(0.0, 0.0));
1126 cam.set_distance(10_000_000.0);
1127 cam.set_viewport(800, 600);
1128
1129 let mut anim = CameraAnimator::new();
1130 anim.start_fly_to(
1131 &mut cam,
1132 &FlyToOptions {
1133 center: Some(GeoCoord::from_lat_lon(48.8566, 2.3522)), zoom: Some(12.0),
1135 ..Default::default()
1136 },
1137 );
1138
1139 assert!(anim.is_active());
1140 assert!(anim.is_flying());
1141
1142 for _ in 0..6000 {
1144 anim.tick(&mut cam, 1.0 / 60.0);
1145 }
1146
1147 assert!(!anim.is_active());
1148 assert!((cam.target().lat - 48.8566).abs() < 0.01, "lat={}", cam.target().lat);
1149 assert!((cam.target().lon - 2.3522).abs() < 0.01, "lon={}", cam.target().lon);
1150 }
1151
1152 #[test]
1153 fn fly_to_animates_bearing_shortest_path() {
1154 let mut cam = Camera::default();
1155 cam.set_distance(1_000_000.0);
1156 cam.set_viewport(800, 600);
1157 cam.set_yaw(std::f64::consts::PI - 0.1);
1158
1159 let target_yaw = -std::f64::consts::PI + 0.1;
1160 let mut anim = CameraAnimator::new();
1161 anim.start_fly_to(
1162 &mut cam,
1163 &FlyToOptions {
1164 bearing: Some(target_yaw),
1165 zoom: Some(5.0),
1166 ..Default::default()
1167 },
1168 );
1169
1170 let before = cam.yaw();
1173 anim.tick(&mut cam, 1.0 / 60.0);
1174 let step = (cam.yaw() - before).abs();
1175 assert!(step < 0.5, "yaw step too large: {step}");
1176 }
1177
1178 #[test]
1179 fn fly_to_same_location_different_zoom_works() {
1180 let mut cam = Camera::default();
1181 cam.set_distance(10_000_000.0);
1182 cam.set_viewport(800, 600);
1183 let start = *cam.target();
1184
1185 let mut anim = CameraAnimator::new();
1186 anim.start_fly_to(
1187 &mut cam,
1188 &FlyToOptions {
1189 zoom: Some(14.0),
1190 ..Default::default()
1191 },
1192 );
1193
1194 assert!(anim.is_active());
1195
1196 for _ in 0..6000 {
1197 anim.tick(&mut cam, 1.0 / 60.0);
1198 }
1199
1200 assert!(!anim.is_active());
1201 assert!((cam.target().lat - start.lat).abs() < 0.01);
1203 assert!((cam.target().lon - start.lon).abs() < 0.01);
1204 }
1205
1206 #[test]
1207 fn fly_to_cancel_stops_animation() {
1208 let mut cam = Camera::default();
1209 cam.set_distance(10_000_000.0);
1210 cam.set_viewport(800, 600);
1211
1212 let mut anim = CameraAnimator::new();
1213 anim.start_fly_to(
1214 &mut cam,
1215 &FlyToOptions {
1216 center: Some(GeoCoord::from_lat_lon(51.5, -0.12)),
1217 zoom: Some(10.0),
1218 ..Default::default()
1219 },
1220 );
1221
1222 anim.tick(&mut cam, 0.1);
1223 assert!(anim.is_active());
1224 anim.cancel();
1225 assert!(!anim.is_active());
1226 }
1227
1228 #[test]
1229 fn fly_to_explicit_duration() {
1230 let mut cam = Camera::default();
1231 cam.set_distance(10_000_000.0);
1232 cam.set_viewport(800, 600);
1233
1234 let mut anim = CameraAnimator::new();
1235 anim.start_fly_to(
1236 &mut cam,
1237 &FlyToOptions {
1238 center: Some(GeoCoord::from_lat_lon(35.6762, 139.6503)),
1239 zoom: Some(10.0),
1240 duration: Some(2.0),
1241 ..Default::default()
1242 },
1243 );
1244
1245 for _ in 0..130 {
1247 anim.tick(&mut cam, 1.0 / 60.0);
1248 }
1249 assert!(!anim.is_active());
1250 assert!((cam.target().lat - 35.6762).abs() < 0.01);
1251 }
1252
1253 #[test]
1254 fn fly_to_max_duration_degrades_to_jump() {
1255 let mut cam = Camera::default();
1256 cam.set_distance(10_000_000.0);
1257 cam.set_viewport(800, 600);
1258
1259 let mut anim = CameraAnimator::new();
1260 anim.start_fly_to(
1261 &mut cam,
1262 &FlyToOptions {
1263 center: Some(GeoCoord::from_lat_lon(35.6762, 139.6503)),
1264 zoom: Some(10.0),
1265 max_duration: Some(0.001), ..Default::default()
1267 },
1268 );
1269
1270 assert!(!anim.is_active());
1272 assert!((cam.target().lat - 35.6762).abs() < 0.01);
1273 }
1274
1275 #[test]
1278 fn ease_to_basic() {
1279 let mut cam = Camera::default();
1280 cam.set_distance(10_000_000.0);
1281 cam.set_viewport(800, 600);
1282
1283 let mut anim = CameraAnimator::new();
1284 anim.start_ease_to(
1285 &mut cam,
1286 &EaseToOptions {
1287 center: Some(GeoCoord::from_lat_lon(40.7128, -74.0060)),
1288 zoom: Some(12.0),
1289 duration: 0.5,
1290 ..Default::default()
1291 },
1292 );
1293
1294 assert!(anim.is_active());
1295 assert!(anim.is_easing());
1296
1297 for _ in 0..60 {
1298 anim.tick(&mut cam, 1.0 / 60.0);
1299 }
1300
1301 assert!(!anim.is_active());
1302 assert!((cam.target().lat - 40.7128).abs() < 0.01);
1303 }
1304
1305 #[test]
1306 fn ease_to_zero_duration_is_instant() {
1307 let mut cam = Camera::default();
1308 cam.set_distance(10_000_000.0);
1309 cam.set_viewport(800, 600);
1310
1311 let mut anim = CameraAnimator::new();
1312 anim.start_ease_to(
1313 &mut cam,
1314 &EaseToOptions {
1315 center: Some(GeoCoord::from_lat_lon(51.5, -0.12)),
1316 duration: 0.0,
1317 ..Default::default()
1318 },
1319 );
1320
1321 assert!(!anim.is_active());
1323 assert!((cam.target().lat - 51.5).abs() < 0.01);
1324 }
1325
1326 #[test]
1329 fn default_easing_endpoints() {
1330 assert!((default_easing(0.0)).abs() < 1e-6);
1331 assert!((default_easing(1.0) - 1.0).abs() < 1e-6);
1332 }
1333
1334 #[test]
1335 fn default_easing_monotonic() {
1336 let mut prev = 0.0;
1337 for i in 1..=100 {
1338 let t = i as f64 / 100.0;
1339 let v = default_easing(t);
1340 assert!(v >= prev - 1e-9, "non-monotonic at t={t}: {v} < {prev}");
1341 prev = v;
1342 }
1343 }
1344
1345 #[test]
1348 fn fly_to_zooms_out_then_in() {
1349 let mut cam = Camera::default();
1352 cam.set_target(GeoCoord::from_lat_lon(0.0, 0.0));
1353 cam.set_distance(zoom_to_distance(5.0, cam.fov_y(), cam.viewport_height(), true));
1354 cam.set_viewport(800, 600);
1355
1356 let start_dist = cam.distance();
1357 let end_zoom = 5.0; let mut anim = CameraAnimator::new();
1360 anim.start_fly_to(
1361 &mut cam,
1362 &FlyToOptions {
1363 center: Some(GeoCoord::from_lat_lon(40.0, 30.0)),
1364 zoom: Some(end_zoom),
1365 duration: Some(4.0),
1366 easing: Some(|t| t), ..Default::default()
1368 },
1369 );
1370
1371 let mut max_dist: f64 = 0.0;
1375 for _ in 0..240 {
1376 anim.tick(&mut cam, 1.0 / 60.0);
1377 max_dist = max_dist.max(cam.distance());
1378 }
1379 for _ in 0..300 {
1381 anim.tick(&mut cam, 1.0 / 60.0);
1382 }
1383
1384 assert!(
1385 max_dist > start_dist * 1.5,
1386 "mid-flight distance ({max_dist:.0}) should be well above start ({start_dist:.0})"
1387 );
1388 let final_dist = cam.distance();
1390 assert!(
1391 (final_dist - start_dist).abs() < start_dist * 0.05,
1392 "final distance ({final_dist:.0}) should be near start ({start_dist:.0})"
1393 );
1394 }
1395
1396 #[test]
1397 fn fly_to_van_wijk_r_function_matches_maplibre() {
1398 let w0: f64 = 800.0;
1406 let w1: f64 = 400.0;
1407 let u1: f64 = 1000.0;
1408 let rho: f64 = 1.42;
1409 let rho2 = rho * rho;
1410
1411 let r = |descent: bool| -> f64 {
1412 let (w, sign) = if descent { (w1, -1.0) } else { (w0, 1.0) };
1413 let b = (w1 * w1 - w0 * w0 + sign * rho2 * rho2 * u1 * u1)
1414 / (2.0 * w * rho2 * u1);
1415 ((b * b + 1.0).sqrt() - b).ln()
1416 };
1417
1418 let r0 = r(false);
1419 let r1 = r(true);
1420 let s = (r1 - r0) / rho;
1421
1422 assert!(r0.is_finite(), "r0 should be finite");
1423 assert!(r1.is_finite(), "r1 should be finite");
1424 assert!(s > 0.0, "path length S should be positive, got {s}");
1425 assert!(s.is_finite(), "path length S should be finite");
1426
1427 let w_at_0 = r0.cosh() / (r0 + rho * 0.0).cosh();
1429 assert!((w_at_0 - 1.0).abs() < 1e-10, "w(0) should be 1.0, got {w_at_0}");
1430
1431 let w_at_s = r0.cosh() / (r0 + rho * s).cosh();
1433 let expected_w_at_s = w1 / w0; assert!(
1435 (w_at_s - expected_w_at_s).abs() < 0.01,
1436 "w(S) should be {expected_w_at_s}, got {w_at_s}"
1437 );
1438 }
1439
1440 #[test]
1441 fn fly_to_degenerate_same_center_uses_w1_not_zoom() {
1442 let mut cam = Camera::default();
1445 cam.set_target(GeoCoord::from_lat_lon(10.0, 20.0));
1446 cam.set_distance(zoom_to_distance(3.0, cam.fov_y(), cam.viewport_height(), true));
1447 cam.set_viewport(800, 600);
1448
1449 let start_dist = cam.distance();
1450
1451 let mut anim = CameraAnimator::new();
1452 anim.start_fly_to(
1453 &mut cam,
1454 &FlyToOptions {
1455 zoom: Some(10.0),
1457 duration: Some(1.0),
1458 ..Default::default()
1459 },
1460 );
1461
1462 assert!(anim.is_active(), "animation should be active for zoom-only flight");
1463
1464 for _ in 0..70 {
1466 anim.tick(&mut cam, 1.0 / 60.0);
1467 }
1468
1469 assert!(!anim.is_active());
1470 assert!(
1472 cam.distance() < start_dist * 0.01,
1473 "should have zoomed in: start={start_dist:.0}, end={:.0}",
1474 cam.distance()
1475 );
1476 }
1477}