use crate::camera::Camera;
use crate::camera_projection::CameraProjection;
use crate::geo_wrap::{shortest_lon_target, wrap_lon_180};
use rustial_math::{GeoCoord, WorldCoord};
const METERS_PER_DEGREE: f64 = 111_319.49;
const MERCATOR_MAX_LAT: f64 = 85.06;
const MOMENTUM_EPS: f64 = 0.01;
const DISTANCE_EPS: f64 = 0.1;
const ANGLE_EPS: f64 = 1e-4;
const WGS84_CIRCUMFERENCE: f64 = 2.0 * std::f64::consts::PI * 6_378_137.0;
const TILE_PX: f64 = 256.0;
fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, t: f64) -> f64 {
let mut s = t;
for _ in 0..8 {
let x = bezier_component(x1, x2, s) - t;
let dx = bezier_derivative(x1, x2, s);
if dx.abs() < 1e-12 {
break;
}
s -= x / dx;
s = s.clamp(0.0, 1.0);
}
bezier_component(y1, y2, s)
}
fn bezier_component(p1: f64, p2: f64, t: f64) -> f64 {
let t2 = t * t;
let t3 = t2 * t;
3.0 * p1 * t * (1.0 - t) * (1.0 - t) + 3.0 * p2 * t2 * (1.0 - t) + t3
}
fn bezier_derivative(p1: f64, p2: f64, t: f64) -> f64 {
let t2 = t * t;
3.0 * p1 * (1.0 - t) * (1.0 - t) + 6.0 * (p2 - p1) * t * (1.0 - t) + 3.0 * (1.0 - p2) * t2
}
fn default_easing(t: f64) -> f64 {
cubic_bezier(0.25, 0.1, 0.25, 1.0, t)
}
fn zoom_to_distance(zoom: f64, fov_y: f64, viewport_height: u32, is_perspective: bool) -> f64 {
let mpp = WGS84_CIRCUMFERENCE / (2.0_f64.powf(zoom) * TILE_PX);
let visible_height = mpp * viewport_height.max(1) as f64;
if is_perspective {
visible_height / (2.0 * (fov_y / 2.0).tan())
} else {
visible_height / 2.0
}
}
fn distance_to_zoom(distance: f64, fov_y: f64, viewport_height: u32, is_perspective: bool) -> f64 {
let visible_height = if is_perspective {
2.0 * distance * (fov_y / 2.0).tan()
} else {
2.0 * distance
};
let mpp = visible_height / viewport_height.max(1) as f64;
if mpp <= 0.0 || !mpp.is_finite() {
return 22.0;
}
(WGS84_CIRCUMFERENCE / (mpp * TILE_PX))
.log2()
.clamp(0.0, 22.0)
}
#[derive(Debug, Clone)]
pub struct FlyToOptions {
pub center: Option<GeoCoord>,
pub zoom: Option<f64>,
pub bearing: Option<f64>,
pub pitch: Option<f64>,
pub curve: f64,
pub speed: f64,
pub screen_speed: Option<f64>,
pub duration: Option<f64>,
pub min_zoom: Option<f64>,
pub max_duration: Option<f64>,
pub easing: Option<fn(f64) -> f64>,
}
impl Default for FlyToOptions {
fn default() -> Self {
Self {
center: None,
zoom: None,
bearing: None,
pitch: None,
curve: 1.42,
speed: 1.2,
screen_speed: None,
duration: None,
min_zoom: None,
max_duration: None,
easing: None,
}
}
}
#[derive(Debug, Clone)]
pub struct EaseToOptions {
pub center: Option<GeoCoord>,
pub zoom: Option<f64>,
pub bearing: Option<f64>,
pub pitch: Option<f64>,
pub duration: f64,
pub easing: Option<fn(f64) -> f64>,
}
impl Default for EaseToOptions {
fn default() -> Self {
Self {
center: None,
zoom: None,
bearing: None,
pitch: None,
duration: 0.5,
easing: None,
}
}
}
struct FlyToState {
end_center: GeoCoord,
end_zoom: f64,
end_bearing: f64,
end_pitch: f64,
start_zoom: f64,
start_bearing: f64,
start_pitch: f64,
from_x: f64,
from_y: f64,
delta_x: f64,
delta_y: f64,
w0: f64,
u1: f64,
rho: f64,
r0: f64,
path_length: f64,
zoom_only: bool,
zoom_only_sign: f64,
duration: f64,
elapsed: f64,
easing: fn(f64) -> f64,
projection: CameraProjection,
is_perspective: bool,
fov_y: f64,
viewport_height: u32,
}
impl FlyToState {
fn new(camera: &Camera, opts: &FlyToOptions) -> Option<Self> {
let projection = camera.projection();
let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
let fov_y = camera.fov_y();
let viewport_height = camera.viewport_height();
let viewport_width = camera.viewport_width();
let start_center = *camera.target();
let start_zoom =
distance_to_zoom(camera.distance(), fov_y, viewport_height, is_perspective);
let start_bearing = camera.yaw();
let start_pitch = camera.pitch();
let end_center = opts.center.unwrap_or(start_center);
let end_zoom = opts.zoom.unwrap_or(start_zoom);
let end_bearing = match opts.bearing {
Some(b) => shortest_bearing_target(start_bearing, b),
None => start_bearing,
};
let end_pitch = opts.pitch.unwrap_or(start_pitch);
let from_world = projection.project(&start_center);
let to_world = projection.project(&end_center);
let pixels_per_meter = 2.0_f64.powf(start_zoom) * TILE_PX / WGS84_CIRCUMFERENCE;
let from_px_x = from_world.position.x * pixels_per_meter;
let from_px_y = from_world.position.y * pixels_per_meter;
let to_px_x = to_world.position.x * pixels_per_meter;
let to_px_y = to_world.position.y * pixels_per_meter;
let delta_x = to_px_x - from_px_x;
let delta_y = to_px_y - from_px_y;
let u1 = (delta_x * delta_x + delta_y * delta_y).sqrt();
let w0 = viewport_width.max(viewport_height).max(1) as f64;
let scale_of_zoom = 2.0_f64.powf(end_zoom - start_zoom);
let w1 = w0 / scale_of_zoom;
let mut rho = opts.curve;
if let Some(min_z) = opts.min_zoom {
let min_z = min_z.min(start_zoom).min(end_zoom);
let scale_of_min_zoom = 2.0_f64.powf(min_z - start_zoom);
let w_max = w0 / scale_of_min_zoom;
if u1 > 0.0 {
rho = (w_max / u1 * 2.0).sqrt();
}
}
let rho2 = rho * rho;
let zoom_out_factor = |descent: bool| -> f64 {
let (w, sign) = if descent { (w1, -1.0) } else { (w0, 1.0) };
let b = (w1 * w1 - w0 * w0 + sign * rho2 * rho2 * u1 * u1) / (2.0 * w * rho2 * u1);
((b * b + 1.0).sqrt() - b).ln()
};
let r0;
let path_length;
let zoom_only;
let zoom_only_sign;
let is_degenerate = if u1.abs() < 0.000001 {
true
} else {
let trial_r0 = zoom_out_factor(false);
let trial_r1 = zoom_out_factor(true);
let trial_s = (trial_r1 - trial_r0) / rho;
!trial_s.is_finite()
};
if is_degenerate {
if (w0 - w1).abs() < 0.000001 {
if (end_bearing - start_bearing).abs() < ANGLE_EPS
&& (end_pitch - start_pitch).abs() < ANGLE_EPS
{
return None;
}
}
zoom_only = true;
zoom_only_sign = if w1 < w0 { -1.0 } else { 1.0 };
r0 = 0.0;
path_length = if w1 > 0.0 && w0 > 0.0 {
(w1 / w0).ln().abs() / rho
} else {
0.0
};
} else {
zoom_only = false;
zoom_only_sign = 0.0;
r0 = zoom_out_factor(false);
let r1 = zoom_out_factor(true);
path_length = (r1 - r0) / rho;
}
let duration = if let Some(d) = opts.duration {
d
} else {
let v = if let Some(ss) = opts.screen_speed {
ss / rho
} else {
opts.speed
};
if v > 0.0 {
path_length / v
} else {
1.0
}
};
if let Some(max_dur) = opts.max_duration {
if duration > max_dur {
return None; }
}
let easing = opts.easing.unwrap_or(default_easing);
Some(FlyToState {
start_zoom,
start_bearing,
start_pitch,
end_center,
end_zoom,
end_bearing,
end_pitch,
from_x: from_px_x,
from_y: from_px_y,
delta_x,
delta_y,
w0,
u1,
rho,
r0,
path_length,
zoom_only,
zoom_only_sign,
duration,
elapsed: 0.0,
easing,
projection,
is_perspective,
fov_y,
viewport_height,
})
}
fn w(&self, s: f64) -> f64 {
if self.zoom_only {
(self.zoom_only_sign * self.rho * s).exp()
} else {
self.r0.cosh() / (self.r0 + self.rho * s).cosh()
}
}
fn u(&self, s: f64) -> f64 {
if self.zoom_only {
0.0
} else {
let rho2 = self.rho * self.rho;
self.w0 * ((self.r0.cosh() * (self.r0 + self.rho * s).tanh() - self.r0.sinh()) / rho2)
/ self.u1
}
}
fn tick(&mut self, camera: &mut Camera, dt: f64) -> bool {
self.elapsed += dt;
let t = (self.elapsed / self.duration.max(1e-9)).clamp(0.0, 1.0);
let k = (self.easing)(t);
let done = t >= 1.0;
if done {
camera.set_target(self.end_center);
camera.set_distance(zoom_to_distance(
self.end_zoom,
self.fov_y,
self.viewport_height,
self.is_perspective,
));
camera.set_yaw(self.end_bearing);
camera.set_pitch(self.end_pitch);
return true;
}
let s = k * self.path_length;
let scale = 1.0 / self.w(s);
let center_factor = self.u(s);
let zoom = self.start_zoom + scale.log2();
camera.set_distance(zoom_to_distance(
zoom,
self.fov_y,
self.viewport_height,
self.is_perspective,
));
let pixels_per_meter = 2.0_f64.powf(self.start_zoom) * TILE_PX / WGS84_CIRCUMFERENCE;
let cx_px = self.from_x + self.delta_x * center_factor;
let cy_px = self.from_y + self.delta_y * center_factor;
let cx_m = cx_px / pixels_per_meter;
let cy_m = cy_px / pixels_per_meter;
let new_center = self.projection.unproject(&WorldCoord::new(cx_m, cy_m, 0.0));
camera.set_target(new_center);
let bearing = self.start_bearing + (self.end_bearing - self.start_bearing) * k;
camera.set_yaw(bearing);
let pitch = self.start_pitch + (self.end_pitch - self.start_pitch) * k;
camera.set_pitch(pitch);
false
}
}
struct EaseToState {
start_center: GeoCoord,
start_zoom: f64,
start_bearing: f64,
start_pitch: f64,
end_center: GeoCoord,
end_zoom: f64,
end_bearing: f64,
end_pitch: f64,
duration: f64,
elapsed: f64,
easing: fn(f64) -> f64,
is_perspective: bool,
fov_y: f64,
viewport_height: u32,
}
impl EaseToState {
fn new(camera: &Camera, opts: &EaseToOptions) -> Self {
let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
let start_zoom = distance_to_zoom(
camera.distance(),
camera.fov_y(),
camera.viewport_height(),
is_perspective,
);
let start_bearing = camera.yaw();
let end_center = opts.center.unwrap_or(*camera.target());
let wrapped_end_center = GeoCoord::from_lat_lon(
end_center.lat,
shortest_lon_target(camera.target().lon, end_center.lon),
);
Self {
start_center: *camera.target(),
start_zoom,
start_bearing,
start_pitch: camera.pitch(),
end_center: wrapped_end_center,
end_zoom: opts.zoom.unwrap_or(start_zoom),
end_bearing: match opts.bearing {
Some(b) => shortest_bearing_target(start_bearing, b),
None => start_bearing,
},
end_pitch: opts.pitch.unwrap_or(camera.pitch()),
duration: opts.duration,
elapsed: 0.0,
easing: opts.easing.unwrap_or(default_easing),
is_perspective,
fov_y: camera.fov_y(),
viewport_height: camera.viewport_height(),
}
}
fn tick(&mut self, camera: &mut Camera, dt: f64) -> bool {
self.elapsed += dt;
let t = (self.elapsed / self.duration.max(1e-9)).clamp(0.0, 1.0);
let k = (self.easing)(t);
let done = t >= 1.0;
if done {
camera.set_target(self.end_center);
camera.set_distance(zoom_to_distance(
self.end_zoom,
self.fov_y,
self.viewport_height,
self.is_perspective,
));
camera.set_yaw(self.end_bearing);
camera.set_pitch(self.end_pitch);
return true;
}
let lat = self.start_center.lat + (self.end_center.lat - self.start_center.lat) * k;
let lon = self.start_center.lon + (self.end_center.lon - self.start_center.lon) * k;
camera.set_target(GeoCoord::from_lat_lon(lat, wrap_lon_180(lon)));
let zoom = self.start_zoom + (self.end_zoom - self.start_zoom) * k;
camera.set_distance(zoom_to_distance(
zoom,
self.fov_y,
self.viewport_height,
self.is_perspective,
));
camera.set_yaw(self.start_bearing + (self.end_bearing - self.start_bearing) * k);
camera.set_pitch(self.start_pitch + (self.end_pitch - self.start_pitch) * k);
false
}
}
pub struct CameraAnimator {
fly_to: Option<FlyToState>,
ease_to: Option<EaseToState>,
target_distance: Option<f64>,
target_yaw: Option<f64>,
target_pitch: Option<f64>,
momentum: (f64, f64),
pub momentum_decay: f64,
pub smoothing: f64,
}
impl Default for CameraAnimator {
fn default() -> Self {
Self {
fly_to: None,
ease_to: None,
target_distance: None,
target_yaw: None,
target_pitch: None,
momentum: (0.0, 0.0),
momentum_decay: 0.05,
smoothing: 8.0,
}
}
}
impl CameraAnimator {
pub fn new() -> Self {
Self::default()
}
pub fn start_fly_to(&mut self, camera: &mut Camera, options: &FlyToOptions) {
self.cancel();
match FlyToState::new(camera, options) {
Some(state) => {
self.fly_to = Some(state);
}
None => {
Self::apply_jump(
camera,
options.center,
options.zoom,
options.bearing,
options.pitch,
);
}
}
}
pub fn start_ease_to(&mut self, camera: &mut Camera, options: &EaseToOptions) {
self.cancel();
if options.duration <= 0.0 {
Self::apply_jump(
camera,
options.center,
options.zoom,
options.bearing,
options.pitch,
);
return;
}
self.ease_to = Some(EaseToState::new(camera, options));
}
fn apply_jump(
camera: &mut Camera,
center: Option<GeoCoord>,
zoom: Option<f64>,
bearing: Option<f64>,
pitch: Option<f64>,
) {
if let Some(c) = center {
camera.set_target(c);
}
if let Some(z) = zoom {
let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
camera.set_distance(zoom_to_distance(
z,
camera.fov_y(),
camera.viewport_height(),
is_perspective,
));
}
if let Some(b) = bearing {
camera.set_yaw(b);
}
if let Some(p) = pitch {
camera.set_pitch(p);
}
}
pub fn animate_zoom(&mut self, target_distance: f64) {
if target_distance.is_finite() && target_distance > 0.0 {
self.target_distance = Some(target_distance);
}
}
pub fn animate_rotate(&mut self, target_yaw: f64, target_pitch: f64) {
if target_yaw.is_finite() {
self.target_yaw = Some(target_yaw);
}
if target_pitch.is_finite() {
self.target_pitch = Some(target_pitch);
}
}
pub fn apply_momentum(&mut self, vx: f64, vy: f64) {
if vx.is_finite() && vy.is_finite() {
self.momentum = (vx, vy);
}
}
pub fn cancel(&mut self) {
self.fly_to = None;
self.ease_to = None;
self.target_distance = None;
self.target_yaw = None;
self.target_pitch = None;
self.momentum = (0.0, 0.0);
}
pub fn is_active(&self) -> bool {
self.fly_to.is_some()
|| self.ease_to.is_some()
|| self.target_distance.is_some()
|| self.target_yaw.is_some()
|| self.target_pitch.is_some()
|| self.momentum.0.abs() > MOMENTUM_EPS
|| self.momentum.1.abs() > MOMENTUM_EPS
}
pub fn is_flying(&self) -> bool {
self.fly_to.is_some()
}
pub fn is_easing(&self) -> bool {
self.ease_to.is_some()
}
pub fn tick(&mut self, camera: &mut Camera, dt: f64) {
if !dt.is_finite() || dt <= 0.0 {
return;
}
if let Some(ref mut state) = self.fly_to {
if state.tick(camera, dt) {
self.fly_to = None;
}
return;
}
if let Some(ref mut state) = self.ease_to {
if state.tick(camera, dt) {
self.ease_to = None;
}
return;
}
let smoothing = self.smoothing.max(0.0);
let t = 1.0 - (-smoothing * dt).exp();
if let Some(target) = self.target_distance {
let d = camera.distance() + (target - camera.distance()) * t;
camera.set_distance(d);
if (camera.distance() - target).abs() < DISTANCE_EPS {
camera.set_distance(target);
self.target_distance = None;
}
}
if let Some(target) = self.target_yaw {
let delta = shortest_angle_delta(camera.yaw(), target);
camera.set_yaw(camera.yaw() + delta * t);
if delta.abs() < ANGLE_EPS {
camera.set_yaw(target);
self.target_yaw = None;
}
}
if let Some(target) = self.target_pitch {
let p = camera.pitch() + (target - camera.pitch()) * t;
camera.set_pitch(p);
if (camera.pitch() - target).abs() < ANGLE_EPS {
camera.set_pitch(target);
self.target_pitch = None;
}
}
if self.momentum.0.abs() > MOMENTUM_EPS || self.momentum.1.abs() > MOMENTUM_EPS {
let dx_deg = self.momentum.0 * dt
/ (METERS_PER_DEGREE * camera.target().lat.to_radians().cos().max(0.001));
let dy_deg = self.momentum.1 * dt / METERS_PER_DEGREE;
let mut target = *camera.target();
target.lon += dx_deg;
target.lat += dy_deg;
target.lat = target.lat.clamp(-MERCATOR_MAX_LAT, MERCATOR_MAX_LAT);
if target.lon > 180.0 {
target.lon -= 360.0;
}
if target.lon < -180.0 {
target.lon += 360.0;
}
camera.set_target(target);
let decay = self.momentum_decay.clamp(0.0, 1.0).powf(dt);
self.momentum.0 *= decay;
self.momentum.1 *= decay;
}
}
}
fn shortest_angle_delta(from: f64, to: f64) -> f64 {
let two_pi = std::f64::consts::TAU;
let mut d = (to - from) % two_pi;
if d > std::f64::consts::PI {
d -= two_pi;
}
if d < -std::f64::consts::PI {
d += two_pi;
}
d
}
fn shortest_bearing_target(from: f64, to: f64) -> f64 {
from + shortest_angle_delta(from, to)
}
#[cfg(test)]
mod tests {
use super::*;
use rustial_math::GeoCoord;
#[test]
fn zoom_converges() {
let mut cam = Camera::default();
cam.set_distance(10_000_000.0);
let mut anim = CameraAnimator::new();
anim.animate_zoom(1_000_000.0);
for _ in 0..60 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!((cam.distance() - 1_000_000.0).abs() < 100_000.0);
for _ in 0..240 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!((cam.distance() - 1_000_000.0).abs() < 1.0);
assert!(!anim.is_active());
}
#[test]
fn momentum_decays() {
let mut cam = Camera::default();
let mut anim = CameraAnimator::new();
anim.apply_momentum(100_000.0, 0.0);
assert!(anim.is_active());
for _ in 0..600 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!(!anim.is_active());
}
#[test]
fn cancel_stops_all() {
let mut anim = CameraAnimator::new();
anim.animate_zoom(100.0);
anim.apply_momentum(10.0, 10.0);
assert!(anim.is_active());
anim.cancel();
assert!(!anim.is_active());
}
#[test]
fn shortest_angle_delta_wraps() {
let from = std::f64::consts::PI - 0.0174533;
let to = -std::f64::consts::PI + 0.0174533;
let d = shortest_angle_delta(from, to);
assert!(d.abs() < 0.1);
}
#[test]
fn rotate_uses_shortest_path() {
let mut cam = Camera::default();
cam.set_yaw(std::f64::consts::PI - 0.01);
let mut anim = CameraAnimator::new();
anim.animate_rotate(-std::f64::consts::PI + 0.01, cam.pitch());
let before = cam.yaw();
anim.tick(&mut cam, 1.0 / 60.0);
let moved = (cam.yaw() - before).abs();
assert!(
moved < 0.2,
"yaw moved too far; likely long-path interpolation"
);
}
#[test]
fn tick_ignores_invalid_dt() {
let mut cam = Camera::default();
let mut anim = CameraAnimator::new();
anim.animate_zoom(1000.0);
let before = cam.distance();
anim.tick(&mut cam, f64::NAN);
assert_eq!(cam.distance(), before);
anim.tick(&mut cam, 0.0);
assert_eq!(cam.distance(), before);
anim.tick(&mut cam, -1.0);
assert_eq!(cam.distance(), before);
}
#[test]
fn animate_zoom_rejects_invalid_values() {
let mut anim = CameraAnimator::new();
anim.animate_zoom(f64::NAN);
assert!(!anim.is_active());
anim.animate_zoom(-10.0);
assert!(!anim.is_active());
anim.animate_zoom(0.0);
assert!(!anim.is_active());
}
#[test]
fn animate_rotate_rejects_non_finite_independently() {
let mut anim = CameraAnimator::new();
anim.animate_rotate(f64::NAN, 0.1);
assert!(anim.is_active());
anim.cancel();
anim.animate_rotate(0.2, f64::NAN);
assert!(anim.is_active());
}
#[test]
fn momentum_clamps_and_wraps_geo() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(85.0, 179.9));
let mut anim = CameraAnimator::new();
anim.apply_momentum(100_000.0, 100_000.0);
anim.tick(&mut cam, 1.0);
assert!(cam.target().lat <= MERCATOR_MAX_LAT);
assert!(cam.target().lon >= -180.0 && cam.target().lon <= 180.0);
}
#[test]
fn decay_is_clamped_to_valid_range() {
let mut cam = Camera::default();
let mut anim = CameraAnimator::new();
anim.apply_momentum(100.0, 0.0);
anim.momentum_decay = 2.0;
let before = cam.target().lon;
anim.tick(&mut cam, 1.0);
let after = cam.target().lon;
assert!(after != before);
let after2_before = cam.target().lon;
anim.tick(&mut cam, 1.0);
assert!((cam.target().lon - after2_before).abs() < 1.0);
}
#[test]
fn fly_to_changes_center_and_zoom() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(0.0, 0.0));
cam.set_distance(10_000_000.0);
cam.set_viewport(800, 600);
let mut anim = CameraAnimator::new();
anim.start_fly_to(
&mut cam,
&FlyToOptions {
center: Some(GeoCoord::from_lat_lon(48.8566, 2.3522)), zoom: Some(12.0),
..Default::default()
},
);
assert!(anim.is_active());
assert!(anim.is_flying());
for _ in 0..6000 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!(!anim.is_active());
assert!(
(cam.target().lat - 48.8566).abs() < 0.01,
"lat={}",
cam.target().lat
);
assert!(
(cam.target().lon - 2.3522).abs() < 0.01,
"lon={}",
cam.target().lon
);
}
#[test]
fn fly_to_animates_bearing_shortest_path() {
let mut cam = Camera::default();
cam.set_distance(1_000_000.0);
cam.set_viewport(800, 600);
cam.set_yaw(std::f64::consts::PI - 0.1);
let target_yaw = -std::f64::consts::PI + 0.1;
let mut anim = CameraAnimator::new();
anim.start_fly_to(
&mut cam,
&FlyToOptions {
bearing: Some(target_yaw),
zoom: Some(5.0),
..Default::default()
},
);
let before = cam.yaw();
anim.tick(&mut cam, 1.0 / 60.0);
let step = (cam.yaw() - before).abs();
assert!(step < 0.5, "yaw step too large: {step}");
}
#[test]
fn fly_to_same_location_different_zoom_works() {
let mut cam = Camera::default();
cam.set_distance(10_000_000.0);
cam.set_viewport(800, 600);
let start = *cam.target();
let mut anim = CameraAnimator::new();
anim.start_fly_to(
&mut cam,
&FlyToOptions {
zoom: Some(14.0),
..Default::default()
},
);
assert!(anim.is_active());
for _ in 0..6000 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!(!anim.is_active());
assert!((cam.target().lat - start.lat).abs() < 0.01);
assert!((cam.target().lon - start.lon).abs() < 0.01);
}
#[test]
fn fly_to_cancel_stops_animation() {
let mut cam = Camera::default();
cam.set_distance(10_000_000.0);
cam.set_viewport(800, 600);
let mut anim = CameraAnimator::new();
anim.start_fly_to(
&mut cam,
&FlyToOptions {
center: Some(GeoCoord::from_lat_lon(51.5, -0.12)),
zoom: Some(10.0),
..Default::default()
},
);
anim.tick(&mut cam, 0.1);
assert!(anim.is_active());
anim.cancel();
assert!(!anim.is_active());
}
#[test]
fn fly_to_explicit_duration() {
let mut cam = Camera::default();
cam.set_distance(10_000_000.0);
cam.set_viewport(800, 600);
let mut anim = CameraAnimator::new();
anim.start_fly_to(
&mut cam,
&FlyToOptions {
center: Some(GeoCoord::from_lat_lon(35.6762, 139.6503)),
zoom: Some(10.0),
duration: Some(2.0),
..Default::default()
},
);
for _ in 0..130 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!(!anim.is_active());
assert!((cam.target().lat - 35.6762).abs() < 0.01);
}
#[test]
fn fly_to_max_duration_degrades_to_jump() {
let mut cam = Camera::default();
cam.set_distance(10_000_000.0);
cam.set_viewport(800, 600);
let mut anim = CameraAnimator::new();
anim.start_fly_to(
&mut cam,
&FlyToOptions {
center: Some(GeoCoord::from_lat_lon(35.6762, 139.6503)),
zoom: Some(10.0),
max_duration: Some(0.001), ..Default::default()
},
);
assert!(!anim.is_active());
assert!((cam.target().lat - 35.6762).abs() < 0.01);
}
#[test]
fn ease_to_basic() {
let mut cam = Camera::default();
cam.set_distance(10_000_000.0);
cam.set_viewport(800, 600);
let mut anim = CameraAnimator::new();
anim.start_ease_to(
&mut cam,
&EaseToOptions {
center: Some(GeoCoord::from_lat_lon(40.7128, -74.0060)),
zoom: Some(12.0),
duration: 0.5,
..Default::default()
},
);
assert!(anim.is_active());
assert!(anim.is_easing());
for _ in 0..60 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!(!anim.is_active());
assert!((cam.target().lat - 40.7128).abs() < 0.01);
}
#[test]
fn ease_to_zero_duration_is_instant() {
let mut cam = Camera::default();
cam.set_distance(10_000_000.0);
cam.set_viewport(800, 600);
let mut anim = CameraAnimator::new();
anim.start_ease_to(
&mut cam,
&EaseToOptions {
center: Some(GeoCoord::from_lat_lon(51.5, -0.12)),
duration: 0.0,
..Default::default()
},
);
assert!(!anim.is_active());
assert!((cam.target().lat - 51.5).abs() < 0.01);
}
#[test]
fn default_easing_endpoints() {
assert!((default_easing(0.0)).abs() < 1e-6);
assert!((default_easing(1.0) - 1.0).abs() < 1e-6);
}
#[test]
fn default_easing_monotonic() {
let mut prev = 0.0;
for i in 1..=100 {
let t = i as f64 / 100.0;
let v = default_easing(t);
assert!(v >= prev - 1e-9, "non-monotonic at t={t}: {v} < {prev}");
prev = v;
}
}
#[test]
fn fly_to_zooms_out_then_in() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(0.0, 0.0));
cam.set_distance(zoom_to_distance(
5.0,
cam.fov_y(),
cam.viewport_height(),
true,
));
cam.set_viewport(800, 600);
let start_dist = cam.distance();
let end_zoom = 5.0;
let mut anim = CameraAnimator::new();
anim.start_fly_to(
&mut cam,
&FlyToOptions {
center: Some(GeoCoord::from_lat_lon(40.0, 30.0)),
zoom: Some(end_zoom),
duration: Some(4.0),
easing: Some(|t| t), ..Default::default()
},
);
let mut max_dist: f64 = 0.0;
for _ in 0..240 {
anim.tick(&mut cam, 1.0 / 60.0);
max_dist = max_dist.max(cam.distance());
}
for _ in 0..300 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!(
max_dist > start_dist * 1.5,
"mid-flight distance ({max_dist:.0}) should be well above start ({start_dist:.0})"
);
let final_dist = cam.distance();
assert!(
(final_dist - start_dist).abs() < start_dist * 0.05,
"final distance ({final_dist:.0}) should be near start ({start_dist:.0})"
);
}
#[test]
fn fly_to_van_wijk_r_function_matches_maplibre() {
let w0: f64 = 800.0;
let w1: f64 = 400.0;
let u1: f64 = 1000.0;
let rho: f64 = 1.42;
let rho2 = rho * rho;
let r = |descent: bool| -> f64 {
let (w, sign) = if descent { (w1, -1.0) } else { (w0, 1.0) };
let b = (w1 * w1 - w0 * w0 + sign * rho2 * rho2 * u1 * u1) / (2.0 * w * rho2 * u1);
((b * b + 1.0).sqrt() - b).ln()
};
let r0 = r(false);
let r1 = r(true);
let s = (r1 - r0) / rho;
assert!(r0.is_finite(), "r0 should be finite");
assert!(r1.is_finite(), "r1 should be finite");
assert!(s > 0.0, "path length S should be positive, got {s}");
assert!(s.is_finite(), "path length S should be finite");
let w_at_0 = r0.cosh() / (r0 + rho * 0.0).cosh();
assert!(
(w_at_0 - 1.0).abs() < 1e-10,
"w(0) should be 1.0, got {w_at_0}"
);
let w_at_s = r0.cosh() / (r0 + rho * s).cosh();
let expected_w_at_s = w1 / w0; assert!(
(w_at_s - expected_w_at_s).abs() < 0.01,
"w(S) should be {expected_w_at_s}, got {w_at_s}"
);
}
#[test]
fn fly_to_degenerate_same_center_uses_w1_not_zoom() {
let mut cam = Camera::default();
cam.set_target(GeoCoord::from_lat_lon(10.0, 20.0));
cam.set_distance(zoom_to_distance(
3.0,
cam.fov_y(),
cam.viewport_height(),
true,
));
cam.set_viewport(800, 600);
let start_dist = cam.distance();
let mut anim = CameraAnimator::new();
anim.start_fly_to(
&mut cam,
&FlyToOptions {
zoom: Some(10.0),
duration: Some(1.0),
..Default::default()
},
);
assert!(
anim.is_active(),
"animation should be active for zoom-only flight"
);
for _ in 0..70 {
anim.tick(&mut cam, 1.0 / 60.0);
}
assert!(!anim.is_active());
assert!(
cam.distance() < start_dist * 0.01,
"should have zoomed in: start={start_dist:.0}, end={:.0}",
cam.distance()
);
}
}