Skip to main content

rustial_engine/
camera_animator.rs

1//! Smooth camera animation with easing and momentum.
2//!
3//! This module provides a stateful animator used by
4//! [`MapState`](crate::MapState) to drive camera transitions:
5//!
6//! - **`fly_to`** -- the van Wijk & Nuij (2003) "optimal path" animation
7//!   matching MapLibre/Mapbox `flyTo`.  Simultaneously interpolates center,
8//!   zoom, bearing, and pitch along a zoom-out-then-zoom-in arc.
9//! - **`ease_to`** -- simple interpolation of center, zoom, bearing, and
10//!   pitch with configurable duration and easing.
11//! - **Momentum** -- inertial pan that decays over time.
12//! - **Simple targets** -- independent exponential-smoothed zoom / rotation
13//!   targets (legacy, still useful for scroll-zoom and keyboard rotation).
14//!
15//! # Design
16//!
17//! - Time integration is explicit via [`tick`](CameraAnimator::tick)
18//!   and caller-provided `dt` (seconds).
19//! - The van Wijk flight uses an easing function applied to wall-clock
20//!   progress `t ? [0, 1]` to produce a path parameter `k`, then derives
21//!   zoom scale `w(s)` and center factor `u(s)` from the paper's
22//!   closed-form expressions.
23//! - Bearing and pitch are linearly interpolated in `k` (same as MapLibre).
24//! - Yaw interpolation follows the shortest angular path.
25//!
26//! # Reference
27//!
28//! Van Wijk, Jarke J.; Nuij, Wim A. A.  "Smooth and efficient zooming
29//! and panning."  INFOVIS '03. pp. 15-22.
30//! <https://www.win.tue.nl/~vanwijk/zoompan.pdf>
31
32use crate::camera::Camera;
33use crate::camera_projection::CameraProjection;
34use crate::geo_wrap::{wrap_lon_180, shortest_lon_target};
35use rustial_math::{GeoCoord, WorldCoord};
36
37// ---------------------------------------------------------------------------
38// Constants
39// ---------------------------------------------------------------------------
40
41/// Approximate meters per degree of latitude (WGS-84 at equator).
42const METERS_PER_DEGREE: f64 = 111_319.49;
43
44/// Maximum latitude for Web Mercator validity.
45const MERCATOR_MAX_LAT: f64 = 85.06;
46
47/// Activity threshold for momentum (m/s).
48const MOMENTUM_EPS: f64 = 0.01;
49
50/// Distance convergence threshold (meters).
51const DISTANCE_EPS: f64 = 0.1;
52
53/// Angle convergence threshold (radians).
54const ANGLE_EPS: f64 = 1e-4;
55
56/// Earth's equatorial circumference in meters (2*pi * WGS-84 semi-major axis).
57const WGS84_CIRCUMFERENCE: f64 = 2.0 * std::f64::consts::PI * 6_378_137.0;
58
59/// Standard tile size in pixels.
60const TILE_PX: f64 = 256.0;
61
62// ---------------------------------------------------------------------------
63// Easing
64// ---------------------------------------------------------------------------
65
66/// Evaluate a cubic bezier easing curve at parameter `t`.
67///
68/// Control points are `(0, 0)`, `(x1, y1)`, `(x2, y2)`, `(1, 1)`.
69/// Returns the `y` value for the given `t` (time) input.
70fn cubic_bezier(x1: f64, y1: f64, x2: f64, y2: f64, t: f64) -> f64 {
71    // Newton-Raphson to find the bezier parameter `s` such that `x(s) = t`.
72    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
96/// MapLibre default easing: `cubic-bezier(0.25, 0.1, 0.25, 1.0)`.
97fn default_easing(t: f64) -> f64 {
98    cubic_bezier(0.25, 0.1, 0.25, 1.0, t)
99}
100
101// ---------------------------------------------------------------------------
102// Zoom <-> distance helpers
103// ---------------------------------------------------------------------------
104
105/// Convert a fractional zoom level to camera distance (meters).
106///
107/// This inverts the `zoom = log2(circumference / (mpp * tile_px))` formula
108/// where `mpp = visible_height / viewport_height` and
109/// `visible_height = 2 * distance * tan(fov_y/2)` for perspective,
110/// or `visible_height = 2 * distance` for orthographic.
111fn 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
121/// Convert camera distance to fractional zoom level.
122fn 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// ---------------------------------------------------------------------------
136// FlyToOptions
137// ---------------------------------------------------------------------------
138
139/// Options for the [`fly_to`](CameraAnimator::start_fly_to) animation.
140///
141/// Mirrors the MapLibre/Mapbox `FlyToOptions` API.
142///
143/// All fields are optional.  When omitted, the animation retains the
144/// camera's current value for that property.
145#[derive(Debug, Clone)]
146pub struct FlyToOptions {
147    /// Target geographic center.  If `None`, the center does not change.
148    pub center: Option<GeoCoord>,
149    /// Target zoom level.  If `None`, the zoom does not change.
150    pub zoom: Option<f64>,
151    /// Target bearing (yaw) in **radians**.  If `None`, bearing does not change.
152    ///
153    /// The animation always takes the shortest angular path.
154    pub bearing: Option<f64>,
155    /// Target pitch in **radians**.  If `None`, pitch does not change.
156    pub pitch: Option<f64>,
157    /// The zooming "curve" (? in the van Wijk paper).
158    ///
159    /// Higher values produce more exaggerated zoom-out.
160    /// Default: `1.42` (the user-study average from van Wijk 2003).
161    pub curve: f64,
162    /// Average speed in ?-screenfulls per second.  Default: `1.2`.
163    ///
164    /// Ignored when `duration` is set explicitly.
165    pub speed: f64,
166    /// Average speed in screenfulls per second (overrides `speed`).
167    ///
168    /// Ignored when `duration` is set explicitly.
169    pub screen_speed: Option<f64>,
170    /// Explicit animation duration in **seconds**.
171    ///
172    /// When set, `speed` / `screen_speed` are ignored and the animation
173    /// runs for exactly this duration.
174    pub duration: Option<f64>,
175    /// The zoom level at the peak of the flight path.
176    ///
177    /// When set, `curve` is overridden to produce a zoom-out that reaches
178    /// this zoom level.
179    pub min_zoom: Option<f64>,
180    /// Maximum allowed duration in **seconds**.
181    ///
182    /// If the auto-computed duration exceeds this, the animation
183    /// degrades to an instant `jump_to`.
184    pub max_duration: Option<f64>,
185    /// Easing function `f(t) -> k` where `t` and `k` are both in `[0, 1]`.
186    ///
187    /// Default: `cubic-bezier(0.25, 0.1, 0.25, 1.0)` (MapLibre default).
188    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// ---------------------------------------------------------------------------
210// EaseToOptions
211// ---------------------------------------------------------------------------
212
213/// Options for the [`ease_to`](CameraAnimator::start_ease_to) animation.
214///
215/// Simple linear interpolation of all camera properties over a fixed
216/// duration, matching MapLibre's `easeTo`.
217#[derive(Debug, Clone)]
218pub struct EaseToOptions {
219    /// Target geographic center.
220    pub center: Option<GeoCoord>,
221    /// Target zoom level.
222    pub zoom: Option<f64>,
223    /// Target bearing (yaw) in radians.
224    pub bearing: Option<f64>,
225    /// Target pitch in radians.
226    pub pitch: Option<f64>,
227    /// Duration in seconds.  Default: `0.5`.
228    pub duration: f64,
229    /// Easing function.  Default: `cubic-bezier(0.25, 0.1, 0.25, 1.0)`.
230    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
246// ---------------------------------------------------------------------------
247// Internal flight state
248// ---------------------------------------------------------------------------
249
250/// Active van Wijk fly-to animation state.
251struct FlyToState {
252    // -- End state --
253    end_center: GeoCoord,
254    end_zoom: f64,
255    end_bearing: f64,
256    end_pitch: f64,
257
258    // -- Start state (needed for per-frame interpolation) --
259    start_zoom: f64,
260    start_bearing: f64,
261    start_pitch: f64,
262
263    // -- Van Wijk parameters --
264    /// Projected start position (world coords at initial scale).
265    from_x: f64,
266    from_y: f64,
267    /// Projected delta (end - start in world coords at initial scale).
268    delta_x: f64,
269    delta_y: f64,
270    /// w0: initial visible span in pixels.
271    w0: f64,
272    /// u1: ground-plane path length in pixels at initial scale.
273    u1: f64,
274    /// ?: zoom curve parameter.
275    rho: f64,
276    /// r?: zoom-out factor during ascent.
277    r0: f64,
278    /// S: total path length in ?-screenfulls.
279    path_length: f64,
280    /// Whether u? ? 0 (same-location zoom-only path).
281    zoom_only: bool,
282    /// k = w1 < w0 ? -1 : 1  (used in zoom-only fallback).
283    zoom_only_sign: f64,
284
285    // -- Timing --
286    duration: f64,
287    elapsed: f64,
288    easing: fn(f64) -> f64,
289
290    // -- Projection used for world-space interpolation --
291    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        // Project start and end centers to world coordinates.
319        let from_world = projection.project(&start_center);
320        let to_world = projection.project(&end_center);
321
322        // World-size at the *initial* zoom level, used to convert between
323        // "world pixels" (MapLibre's coordinate space) and meters.
324        // At zoom z, world_size = tile_px * 2^z  (in pixels).
325        // We need a pixel-space path length: divide meters by mpp.
326        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        // u1: ground-plane path length in pixels at initial scale.
337        let u1 = (delta_x * delta_x + delta_y * delta_y).sqrt();
338
339        // w0: initial visible span (max of width, height) in pixels.
340        let w0 = viewport_width.max(viewport_height).max(1) as f64;
341
342        // scale = 2^(endZoom - startZoom)  (MapLibre: zoomScale(zoom - startZoom))
343        let scale_of_zoom = 2.0_f64.powf(end_zoom - start_zoom);
344
345        // w1: final visible span measured in pixels at the *initial* scale.
346        // MapLibre: w1 = w0 / scale
347        let w1 = w0 / scale_of_zoom;
348
349        // rho: curve parameter.
350        let mut rho = opts.curve;
351
352        // If minZoom is specified, override rho.
353        // MapLibre: const minZoom = clamp(Math.min(options.minZoom, startZoom, zoom), ...)
354        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        // r(i): zoom-out factor at one end of the animation.
366        // MapLibre/Mapbox:
367        //   function r(i) {
368        //     const b = (w1*w1 - w0*w0 + (i ? -1 : 1) * rho2*rho2 * u1*u1)
369        //               / (2 * (i ? w1 : w0) * rho2 * u1);
370        //     return Math.log(Math.sqrt(b*b + 1) - b);
371        //   }
372        // descent=true corresponds to i=1 (descent), descent=false to i=0 (ascent).
373        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        // Check if the ground-plane path is effectively zero or if the
386        // full van Wijk formula produces a non-finite result.
387        // MapLibre/Mapbox: if (Math.abs(u1) < 0.000001 || !isFinite(S))
388        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            // Same-location or degenerate path.
399            // MapLibre/Mapbox: if (Math.abs(w0 - w1) < 0.000001) return this.easeTo(...)
400            if (w0 - w1).abs() < 0.000001 {
401                // No zoom change and no center change -- nothing to animate
402                // unless bearing or pitch changed.
403                if (end_bearing - start_bearing).abs() < ANGLE_EPS
404                    && (end_pitch - start_pitch).abs() < ANGLE_EPS
405                {
406                    return None;
407                }
408            }
409
410            // MapLibre/Mapbox: const k = w1 < w0 ? -1 : 1;
411            //                  S = Math.abs(Math.log(w1 / w0)) / rho;
412            //                  w = function(s) { return Math.exp(k * rho * s); };
413            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            // MapLibre/Mapbox: r0 = r(0);  S = (r(1) - r0) / rho;
425            r0 = zoom_out_factor(false);
426            let r1 = zoom_out_factor(true);
427            path_length = (r1 - r0) / rho;
428        }
429
430        // Compute duration.
431        // MapLibre/Mapbox: duration = 1000 * S / V  (milliseconds)
432        // We store duration in seconds.
433        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        // Check max_duration.
445        // MapLibre/Mapbox: if (options.maxDuration && options.duration > options.maxDuration)
446        //                      options.duration = 0;
447        if let Some(max_dur) = opts.max_duration {
448            if duration > max_dur {
449                return None; // Degrade to instant jump.
450            }
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    /// Evaluate the van Wijk w(s) function -- visible span at path distance s.
485    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    /// Evaluate the van Wijk u(s) function -- center interpolation factor.
494    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    /// Advance the flight by `dt` seconds.  Returns `true` when complete.
505    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            // Snap to final state.
514            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        // Path distance in ?-screenfulls.
527        let s = k * self.path_length;
528
529        // Scale factor: 1/w(s).
530        let scale = 1.0 / self.w(s);
531
532        // Center interpolation factor.
533        let center_factor = self.u(s);
534
535        // Interpolate zoom via scale.
536        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        // Interpolate center in projected pixel space, then unproject.
545        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        // Convert from "initial-scale pixels" back to meters, then to the
549        // current-scale center (the scale multiplication here accounts for
550        // the zoom change and corresponds to MapLibre's
551        // `from.add(delta.mult(centerFactor)).mult(scale)` followed by
552        // `unprojectFromWorldCoordinates(tr.worldSize, ...)`).
553        //
554        // In MapLibre, `tr.worldSize` changes with zoom, which is why they
555        // multiply by `scale` before unprojecting.  We achieve the same
556        // result by converting from initial-scale pixels to meters (dividing
557        // by the initial pixels_per_meter) and then letting the projection
558        // unproject from meter-space.
559        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        // Interpolate bearing (shortest path).
565        let bearing = self.start_bearing + (self.end_bearing - self.start_bearing) * k;
566        camera.set_yaw(bearing);
567
568        // Interpolate pitch linearly.
569        let pitch = self.start_pitch + (self.end_pitch - self.start_pitch) * k;
570        camera.set_pitch(pitch);
571
572        false
573    }
574}
575
576// ---------------------------------------------------------------------------
577// Internal ease-to state
578// ---------------------------------------------------------------------------
579
580/// Active ease-to animation state.
581struct 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        // Center: lerp in geographic space.
654        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        // Zoom.
659        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        // Bearing (shortest path, already normalized).
668        camera.set_yaw(self.start_bearing + (self.end_bearing - self.start_bearing) * k);
669
670        // Pitch.
671        camera.set_pitch(self.start_pitch + (self.end_pitch - self.start_pitch) * k);
672
673        false
674    }
675}
676
677// ---------------------------------------------------------------------------
678// CameraAnimator
679// ---------------------------------------------------------------------------
680
681/// Drives smooth camera transitions.
682///
683/// Supports three animation modes that may be active simultaneously:
684///
685/// 1. **Fly-to / ease-to** -- a coordinated multi-property transition
686///    (only one may be active at a time; starting one cancels the other).
687/// 2. **Simple targets** -- independent exponential-smoothed zoom / yaw /
688///    pitch targets (useful for scroll-zoom, keyboard rotation).
689/// 3. **Momentum** -- inertial pan that decays over time.
690///
691/// Call [`tick`](Self::tick) every frame with the elapsed `dt`.
692pub struct CameraAnimator {
693    // -- Coordinated flight / ease state --
694    fly_to: Option<FlyToState>,
695    ease_to: Option<EaseToState>,
696
697    // -- Simple independent targets (legacy) --
698    target_distance: Option<f64>,
699    target_yaw: Option<f64>,
700    target_pitch: Option<f64>,
701
702    // -- Momentum --
703    momentum: (f64, f64),
704
705    /// Momentum decay factor per second (0..1, 0 = instant stop).
706    pub momentum_decay: f64,
707    /// Smoothing factor for simple zoom/rotate targets
708    /// (0 = instant, higher = smoother).
709    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    /// Create a new camera animator with default settings.
729    pub fn new() -> Self {
730        Self::default()
731    }
732
733    // -- Coordinated animations -------------------------------------------
734
735    /// Start a van Wijk fly-to animation.
736    ///
737    /// This implements the "optimal path" algorithm from van Wijk & Nuij
738    /// (2003), matching MapLibre/Mapbox `flyTo` behavior.  The camera
739    /// simultaneously interpolates center, zoom, bearing, and pitch along
740    /// a zoom-out-then-zoom-in arc.
741    ///
742    /// Cancels any active fly-to, ease-to, simple targets, and momentum.
743    ///
744    /// If the requested transition is degenerate (no change or exceeds
745    /// `max_duration`), a `jump_to` is performed instead.
746    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                // Degenerate: apply final state immediately.
755                Self::apply_jump(camera, options.center, options.zoom, options.bearing, options.pitch);
756            }
757        }
758    }
759
760    /// Start a simple ease-to animation.
761    ///
762    /// Linearly interpolates center, zoom, bearing, and pitch over the
763    /// specified duration with configurable easing.
764    ///
765    /// Cancels any active fly-to, ease-to, simple targets, and momentum.
766    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    /// Apply final state immediately (jump_to helper).
778    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    // -- Simple independent targets (legacy) ------------------------------
806
807    /// Set a zoom animation target (distance in meters).
808    ///
809    /// Non-finite or non-positive distances are ignored.
810    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    /// Set rotation animation targets.
817    ///
818    /// Non-finite angles are ignored independently.
819    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    /// Apply pan momentum in world meters/second.
829    ///
830    /// Non-finite inputs are ignored.
831    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    /// Cancel all animations and momentum.
838    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    /// Whether any animation or momentum is active.
848    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    /// Whether a coordinated fly-to or ease-to animation is active.
859    pub fn is_flying(&self) -> bool {
860        self.fly_to.is_some()
861    }
862
863    /// Whether a coordinated ease-to animation is active.
864    pub fn is_easing(&self) -> bool {
865        self.ease_to.is_some()
866    }
867
868    /// Advance the animation by `dt` seconds, mutating the camera.
869    ///
870    /// Non-finite or non-positive `dt` values are ignored.
871    pub fn tick(&mut self, camera: &mut Camera, dt: f64) {
872        if !dt.is_finite() || dt <= 0.0 {
873            return;
874        }
875
876        // Coordinated fly-to takes priority.
877        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        // Coordinated ease-to.
885        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        // Simple independent targets.
893        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        // Pan momentum.
924        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
949// ---------------------------------------------------------------------------
950// Helpers
951// ---------------------------------------------------------------------------
952
953/// Smallest signed angular delta from `from` to `to` in radians.
954///
955/// Result is in `[-PI, PI]`.
956fn 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
968/// Normalize target bearing so that interpolation takes the shortest path.
969///
970/// Returns a target value numerically close to `from` that yields the
971/// same final bearing modulo 2?.
972fn shortest_bearing_target(from: f64, to: f64) -> f64 {
973    from + shortest_angle_delta(from, to)
974}
975
976// ---------------------------------------------------------------------------
977// Tests
978// ---------------------------------------------------------------------------
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983    use rustial_math::GeoCoord;
984
985    // -- Legacy simple animation tests ------------------------------------
986
987    #[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    // -- Fly-to tests -----------------------------------------------------
1121
1122    #[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)), // Paris
1134                zoom: Some(12.0),
1135                ..Default::default()
1136            },
1137        );
1138
1139        assert!(anim.is_active());
1140        assert!(anim.is_flying());
1141
1142        // Run for a long time to ensure completion.
1143        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        // The first frame should take the short path (< 0.3 radians of
1171        // total delta, so any individual step should be small).
1172        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        // Center should not have moved significantly.
1202        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        // Run for slightly more than 2 seconds to account for floating point.
1246        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), // Very short max => instant jump.
1266                ..Default::default()
1267            },
1268        );
1269
1270        // Should have jumped immediately.
1271        assert!(!anim.is_active());
1272        assert!((cam.target().lat - 35.6762).abs() < 0.01);
1273    }
1274
1275    // -- Ease-to tests ----------------------------------------------------
1276
1277    #[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        // Should have jumped immediately.
1322        assert!(!anim.is_active());
1323        assert!((cam.target().lat - 51.5).abs() < 0.01);
1324    }
1325
1326    // -- Easing function test ---------------------------------------------
1327
1328    #[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    // -- Van Wijk algorithm correctness -----------------------------------
1346
1347    #[test]
1348    fn fly_to_zooms_out_then_in() {
1349        // The van Wijk algorithm should zoom out during the middle of the
1350        // flight (lower zoom = higher distance) and then zoom back in.
1351        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; // Same zoom, different center => pure zoom-out arc.
1358
1359        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), // Linear easing for predictable sampling.
1367                ..Default::default()
1368            },
1369        );
1370
1371        // Sample distance at mid-flight.  With same start/end zoom and a
1372        // large center displacement, the camera should be farther away
1373        // (zoomed out) at the midpoint.
1374        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        // Complete the rest.
1380        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        // Final distance should be back near the start (same zoom).
1389        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        // Direct unit test of the zoom_out_factor (r) function against
1399        // known MapLibre values.
1400        //
1401        // For w0=800, w1=400 (zoom in by 1 level), u1=1000, rho=1.42:
1402        //   rho2 = 2.0164
1403        //   r(0) = ln(sqrt(b0^2+1) - b0)  where b0 = (w1^2-w0^2+rho2^2*u1^2) / (2*w0*rho2*u1)
1404        //   r(1) = ln(sqrt(b1^2+1) - b1)  where b1 = (w1^2-w0^2-rho2^2*u1^2) / (2*w1*rho2*u1)
1405        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        // Verify that w(0) = cosh(r0)/cosh(r0) = 1.0 (unit visible span at start).
1428        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        // Verify that w(S) = w1/w0 (MapLibre: scale = 1/w(S), so zoom = startZoom + log2(scale)).
1432        let w_at_s = r0.cosh() / (r0 + rho * s).cosh();
1433        let expected_w_at_s = w1 / w0; // 0.5
1434        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        // Regression: the degenerate path should compare w0 vs w1 (pixel
1443        // spans), not w0 vs end_zoom (a zoom level number).
1444        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                // Same center, different zoom => zoom-only degenerate path.
1456                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        // Run to completion.
1465        for _ in 0..70 {
1466            anim.tick(&mut cam, 1.0 / 60.0);
1467        }
1468
1469        assert!(!anim.is_active());
1470        // Should have zoomed in significantly.
1471        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}