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::{shortest_lon_target, wrap_lon_180};
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))
133        .log2()
134        .clamp(0.0, 22.0)
135}
136
137// ---------------------------------------------------------------------------
138// FlyToOptions
139// ---------------------------------------------------------------------------
140
141/// Options for the [`fly_to`](CameraAnimator::start_fly_to) animation.
142///
143/// Mirrors the MapLibre/Mapbox `FlyToOptions` API.
144///
145/// All fields are optional.  When omitted, the animation retains the
146/// camera's current value for that property.
147#[derive(Debug, Clone)]
148pub struct FlyToOptions {
149    /// Target geographic center.  If `None`, the center does not change.
150    pub center: Option<GeoCoord>,
151    /// Target zoom level.  If `None`, the zoom does not change.
152    pub zoom: Option<f64>,
153    /// Target bearing (yaw) in **radians**.  If `None`, bearing does not change.
154    ///
155    /// The animation always takes the shortest angular path.
156    pub bearing: Option<f64>,
157    /// Target pitch in **radians**.  If `None`, pitch does not change.
158    pub pitch: Option<f64>,
159    /// The zooming "curve" (? in the van Wijk paper).
160    ///
161    /// Higher values produce more exaggerated zoom-out.
162    /// Default: `1.42` (the user-study average from van Wijk 2003).
163    pub curve: f64,
164    /// Average speed in ?-screenfulls per second.  Default: `1.2`.
165    ///
166    /// Ignored when `duration` is set explicitly.
167    pub speed: f64,
168    /// Average speed in screenfulls per second (overrides `speed`).
169    ///
170    /// Ignored when `duration` is set explicitly.
171    pub screen_speed: Option<f64>,
172    /// Explicit animation duration in **seconds**.
173    ///
174    /// When set, `speed` / `screen_speed` are ignored and the animation
175    /// runs for exactly this duration.
176    pub duration: Option<f64>,
177    /// The zoom level at the peak of the flight path.
178    ///
179    /// When set, `curve` is overridden to produce a zoom-out that reaches
180    /// this zoom level.
181    pub min_zoom: Option<f64>,
182    /// Maximum allowed duration in **seconds**.
183    ///
184    /// If the auto-computed duration exceeds this, the animation
185    /// degrades to an instant `jump_to`.
186    pub max_duration: Option<f64>,
187    /// Easing function `f(t) -> k` where `t` and `k` are both in `[0, 1]`.
188    ///
189    /// Default: `cubic-bezier(0.25, 0.1, 0.25, 1.0)` (MapLibre default).
190    pub easing: Option<fn(f64) -> f64>,
191}
192
193impl Default for FlyToOptions {
194    fn default() -> Self {
195        Self {
196            center: None,
197            zoom: None,
198            bearing: None,
199            pitch: None,
200            curve: 1.42,
201            speed: 1.2,
202            screen_speed: None,
203            duration: None,
204            min_zoom: None,
205            max_duration: None,
206            easing: None,
207        }
208    }
209}
210
211// ---------------------------------------------------------------------------
212// EaseToOptions
213// ---------------------------------------------------------------------------
214
215/// Options for the [`ease_to`](CameraAnimator::start_ease_to) animation.
216///
217/// Simple linear interpolation of all camera properties over a fixed
218/// duration, matching MapLibre's `easeTo`.
219#[derive(Debug, Clone)]
220pub struct EaseToOptions {
221    /// Target geographic center.
222    pub center: Option<GeoCoord>,
223    /// Target zoom level.
224    pub zoom: Option<f64>,
225    /// Target bearing (yaw) in radians.
226    pub bearing: Option<f64>,
227    /// Target pitch in radians.
228    pub pitch: Option<f64>,
229    /// Duration in seconds.  Default: `0.5`.
230    pub duration: f64,
231    /// Easing function.  Default: `cubic-bezier(0.25, 0.1, 0.25, 1.0)`.
232    pub easing: Option<fn(f64) -> f64>,
233}
234
235impl Default for EaseToOptions {
236    fn default() -> Self {
237        Self {
238            center: None,
239            zoom: None,
240            bearing: None,
241            pitch: None,
242            duration: 0.5,
243            easing: None,
244        }
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Internal flight state
250// ---------------------------------------------------------------------------
251
252/// Active van Wijk fly-to animation state.
253struct FlyToState {
254    // -- End state --
255    end_center: GeoCoord,
256    end_zoom: f64,
257    end_bearing: f64,
258    end_pitch: f64,
259
260    // -- Start state (needed for per-frame interpolation) --
261    start_zoom: f64,
262    start_bearing: f64,
263    start_pitch: f64,
264
265    // -- Van Wijk parameters --
266    /// Projected start position (world coords at initial scale).
267    from_x: f64,
268    from_y: f64,
269    /// Projected delta (end - start in world coords at initial scale).
270    delta_x: f64,
271    delta_y: f64,
272    /// w0: initial visible span in pixels.
273    w0: f64,
274    /// u1: ground-plane path length in pixels at initial scale.
275    u1: f64,
276    /// ?: zoom curve parameter.
277    rho: f64,
278    /// r?: zoom-out factor during ascent.
279    r0: f64,
280    /// S: total path length in ?-screenfulls.
281    path_length: f64,
282    /// Whether u? ? 0 (same-location zoom-only path).
283    zoom_only: bool,
284    /// k = w1 < w0 ? -1 : 1  (used in zoom-only fallback).
285    zoom_only_sign: f64,
286
287    // -- Timing --
288    duration: f64,
289    elapsed: f64,
290    easing: fn(f64) -> f64,
291
292    // -- Projection used for world-space interpolation --
293    projection: CameraProjection,
294    is_perspective: bool,
295    fov_y: f64,
296    viewport_height: u32,
297}
298
299impl FlyToState {
300    fn new(camera: &Camera, opts: &FlyToOptions) -> Option<Self> {
301        let projection = camera.projection();
302        let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
303        let fov_y = camera.fov_y();
304        let viewport_height = camera.viewport_height();
305        let viewport_width = camera.viewport_width();
306
307        let start_center = *camera.target();
308        let start_zoom =
309            distance_to_zoom(camera.distance(), fov_y, viewport_height, is_perspective);
310        let start_bearing = camera.yaw();
311        let start_pitch = camera.pitch();
312
313        let end_center = opts.center.unwrap_or(start_center);
314        let end_zoom = opts.zoom.unwrap_or(start_zoom);
315        let end_bearing = match opts.bearing {
316            Some(b) => shortest_bearing_target(start_bearing, b),
317            None => start_bearing,
318        };
319        let end_pitch = opts.pitch.unwrap_or(start_pitch);
320
321        // Project start and end centers to world coordinates.
322        let from_world = projection.project(&start_center);
323        let to_world = projection.project(&end_center);
324
325        // World-size at the *initial* zoom level, used to convert between
326        // "world pixels" (MapLibre's coordinate space) and meters.
327        // At zoom z, world_size = tile_px * 2^z  (in pixels).
328        // We need a pixel-space path length: divide meters by mpp.
329        let pixels_per_meter = 2.0_f64.powf(start_zoom) * TILE_PX / WGS84_CIRCUMFERENCE;
330
331        let from_px_x = from_world.position.x * pixels_per_meter;
332        let from_px_y = from_world.position.y * pixels_per_meter;
333        let to_px_x = to_world.position.x * pixels_per_meter;
334        let to_px_y = to_world.position.y * pixels_per_meter;
335
336        let delta_x = to_px_x - from_px_x;
337        let delta_y = to_px_y - from_px_y;
338
339        // u1: ground-plane path length in pixels at initial scale.
340        let u1 = (delta_x * delta_x + delta_y * delta_y).sqrt();
341
342        // w0: initial visible span (max of width, height) in pixels.
343        let w0 = viewport_width.max(viewport_height).max(1) as f64;
344
345        // scale = 2^(endZoom - startZoom)  (MapLibre: zoomScale(zoom - startZoom))
346        let scale_of_zoom = 2.0_f64.powf(end_zoom - start_zoom);
347
348        // w1: final visible span measured in pixels at the *initial* scale.
349        // MapLibre: w1 = w0 / scale
350        let w1 = w0 / scale_of_zoom;
351
352        // rho: curve parameter.
353        let mut rho = opts.curve;
354
355        // If minZoom is specified, override rho.
356        // MapLibre: const minZoom = clamp(Math.min(options.minZoom, startZoom, zoom), ...)
357        if let Some(min_z) = opts.min_zoom {
358            let min_z = min_z.min(start_zoom).min(end_zoom);
359            let scale_of_min_zoom = 2.0_f64.powf(min_z - start_zoom);
360            let w_max = w0 / scale_of_min_zoom;
361            if u1 > 0.0 {
362                rho = (w_max / u1 * 2.0).sqrt();
363            }
364        }
365
366        let rho2 = rho * rho;
367
368        // r(i): zoom-out factor at one end of the animation.
369        // MapLibre/Mapbox:
370        //   function r(i) {
371        //     const b = (w1*w1 - w0*w0 + (i ? -1 : 1) * rho2*rho2 * u1*u1)
372        //               / (2 * (i ? w1 : w0) * rho2 * u1);
373        //     return Math.log(Math.sqrt(b*b + 1) - b);
374        //   }
375        // descent=true corresponds to i=1 (descent), descent=false to i=0 (ascent).
376        let zoom_out_factor = |descent: bool| -> f64 {
377            let (w, sign) = if descent { (w1, -1.0) } else { (w0, 1.0) };
378            let b = (w1 * w1 - w0 * w0 + sign * rho2 * rho2 * u1 * u1) / (2.0 * w * rho2 * u1);
379            ((b * b + 1.0).sqrt() - b).ln()
380        };
381
382        let r0;
383        let path_length;
384        let zoom_only;
385        let zoom_only_sign;
386
387        // Check if the ground-plane path is effectively zero or if the
388        // full van Wijk formula produces a non-finite result.
389        // MapLibre/Mapbox: if (Math.abs(u1) < 0.000001 || !isFinite(S))
390        let is_degenerate = if u1.abs() < 0.000001 {
391            true
392        } else {
393            let trial_r0 = zoom_out_factor(false);
394            let trial_r1 = zoom_out_factor(true);
395            let trial_s = (trial_r1 - trial_r0) / rho;
396            !trial_s.is_finite()
397        };
398
399        if is_degenerate {
400            // Same-location or degenerate path.
401            // MapLibre/Mapbox: if (Math.abs(w0 - w1) < 0.000001) return this.easeTo(...)
402            if (w0 - w1).abs() < 0.000001 {
403                // No zoom change and no center change -- nothing to animate
404                // unless bearing or pitch changed.
405                if (end_bearing - start_bearing).abs() < ANGLE_EPS
406                    && (end_pitch - start_pitch).abs() < ANGLE_EPS
407                {
408                    return None;
409                }
410            }
411
412            // MapLibre/Mapbox: const k = w1 < w0 ? -1 : 1;
413            //                  S = Math.abs(Math.log(w1 / w0)) / rho;
414            //                  w = function(s) { return Math.exp(k * rho * s); };
415            zoom_only = true;
416            zoom_only_sign = if w1 < w0 { -1.0 } else { 1.0 };
417            r0 = 0.0;
418            path_length = if w1 > 0.0 && w0 > 0.0 {
419                (w1 / w0).ln().abs() / rho
420            } else {
421                0.0
422            };
423        } else {
424            zoom_only = false;
425            zoom_only_sign = 0.0;
426            // MapLibre/Mapbox: r0 = r(0);  S = (r(1) - r0) / rho;
427            r0 = zoom_out_factor(false);
428            let r1 = zoom_out_factor(true);
429            path_length = (r1 - r0) / rho;
430        }
431
432        // Compute duration.
433        // MapLibre/Mapbox: duration = 1000 * S / V  (milliseconds)
434        // We store duration in seconds.
435        let duration = if let Some(d) = opts.duration {
436            d
437        } else {
438            let v = if let Some(ss) = opts.screen_speed {
439                ss / rho
440            } else {
441                opts.speed
442            };
443            if v > 0.0 {
444                path_length / v
445            } else {
446                1.0
447            }
448        };
449
450        // Check max_duration.
451        // MapLibre/Mapbox: if (options.maxDuration && options.duration > options.maxDuration)
452        //                      options.duration = 0;
453        if let Some(max_dur) = opts.max_duration {
454            if duration > max_dur {
455                return None; // Degrade to instant jump.
456            }
457        }
458
459        let easing = opts.easing.unwrap_or(default_easing);
460
461        Some(FlyToState {
462            start_zoom,
463            start_bearing,
464            start_pitch,
465            end_center,
466            end_zoom,
467            end_bearing,
468            end_pitch,
469            from_x: from_px_x,
470            from_y: from_px_y,
471            delta_x,
472            delta_y,
473            w0,
474            u1,
475            rho,
476            r0,
477            path_length,
478            zoom_only,
479            zoom_only_sign,
480            duration,
481            elapsed: 0.0,
482            easing,
483            projection,
484            is_perspective,
485            fov_y,
486            viewport_height,
487        })
488    }
489
490    /// Evaluate the van Wijk w(s) function -- visible span at path distance s.
491    fn w(&self, s: f64) -> f64 {
492        if self.zoom_only {
493            (self.zoom_only_sign * self.rho * s).exp()
494        } else {
495            self.r0.cosh() / (self.r0 + self.rho * s).cosh()
496        }
497    }
498
499    /// Evaluate the van Wijk u(s) function -- center interpolation factor.
500    fn u(&self, s: f64) -> f64 {
501        if self.zoom_only {
502            0.0
503        } else {
504            let rho2 = self.rho * self.rho;
505            self.w0 * ((self.r0.cosh() * (self.r0 + self.rho * s).tanh() - self.r0.sinh()) / rho2)
506                / self.u1
507        }
508    }
509
510    /// Advance the flight by `dt` seconds.  Returns `true` when complete.
511    fn tick(&mut self, camera: &mut Camera, dt: f64) -> bool {
512        self.elapsed += dt;
513        let t = (self.elapsed / self.duration.max(1e-9)).clamp(0.0, 1.0);
514        let k = (self.easing)(t);
515
516        let done = t >= 1.0;
517
518        if done {
519            // Snap to final state.
520            camera.set_target(self.end_center);
521            camera.set_distance(zoom_to_distance(
522                self.end_zoom,
523                self.fov_y,
524                self.viewport_height,
525                self.is_perspective,
526            ));
527            camera.set_yaw(self.end_bearing);
528            camera.set_pitch(self.end_pitch);
529            return true;
530        }
531
532        // Path distance in ?-screenfulls.
533        let s = k * self.path_length;
534
535        // Scale factor: 1/w(s).
536        let scale = 1.0 / self.w(s);
537
538        // Center interpolation factor.
539        let center_factor = self.u(s);
540
541        // Interpolate zoom via scale.
542        let zoom = self.start_zoom + scale.log2();
543        camera.set_distance(zoom_to_distance(
544            zoom,
545            self.fov_y,
546            self.viewport_height,
547            self.is_perspective,
548        ));
549
550        // Interpolate center in projected pixel space, then unproject.
551        let pixels_per_meter = 2.0_f64.powf(self.start_zoom) * TILE_PX / WGS84_CIRCUMFERENCE;
552        let cx_px = self.from_x + self.delta_x * center_factor;
553        let cy_px = self.from_y + self.delta_y * center_factor;
554        // Convert from "initial-scale pixels" back to meters, then to the
555        // current-scale center (the scale multiplication here accounts for
556        // the zoom change and corresponds to MapLibre's
557        // `from.add(delta.mult(centerFactor)).mult(scale)` followed by
558        // `unprojectFromWorldCoordinates(tr.worldSize, ...)`).
559        //
560        // In MapLibre, `tr.worldSize` changes with zoom, which is why they
561        // multiply by `scale` before unprojecting.  We achieve the same
562        // result by converting from initial-scale pixels to meters (dividing
563        // by the initial pixels_per_meter) and then letting the projection
564        // unproject from meter-space.
565        let cx_m = cx_px / pixels_per_meter;
566        let cy_m = cy_px / pixels_per_meter;
567        let new_center = self.projection.unproject(&WorldCoord::new(cx_m, cy_m, 0.0));
568        camera.set_target(new_center);
569
570        // Interpolate bearing (shortest path).
571        let bearing = self.start_bearing + (self.end_bearing - self.start_bearing) * k;
572        camera.set_yaw(bearing);
573
574        // Interpolate pitch linearly.
575        let pitch = self.start_pitch + (self.end_pitch - self.start_pitch) * k;
576        camera.set_pitch(pitch);
577
578        false
579    }
580}
581
582// ---------------------------------------------------------------------------
583// Internal ease-to state
584// ---------------------------------------------------------------------------
585
586/// Active ease-to animation state.
587struct EaseToState {
588    start_center: GeoCoord,
589    start_zoom: f64,
590    start_bearing: f64,
591    start_pitch: f64,
592
593    end_center: GeoCoord,
594    end_zoom: f64,
595    end_bearing: f64,
596    end_pitch: f64,
597
598    duration: f64,
599    elapsed: f64,
600    easing: fn(f64) -> f64,
601
602    is_perspective: bool,
603    fov_y: f64,
604    viewport_height: u32,
605}
606
607impl EaseToState {
608    fn new(camera: &Camera, opts: &EaseToOptions) -> Self {
609        let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
610        let start_zoom = distance_to_zoom(
611            camera.distance(),
612            camera.fov_y(),
613            camera.viewport_height(),
614            is_perspective,
615        );
616        let start_bearing = camera.yaw();
617
618        let end_center = opts.center.unwrap_or(*camera.target());
619        let wrapped_end_center = GeoCoord::from_lat_lon(
620            end_center.lat,
621            shortest_lon_target(camera.target().lon, end_center.lon),
622        );
623
624        Self {
625            start_center: *camera.target(),
626            start_zoom,
627            start_bearing,
628            start_pitch: camera.pitch(),
629            end_center: wrapped_end_center,
630            end_zoom: opts.zoom.unwrap_or(start_zoom),
631            end_bearing: match opts.bearing {
632                Some(b) => shortest_bearing_target(start_bearing, b),
633                None => start_bearing,
634            },
635            end_pitch: opts.pitch.unwrap_or(camera.pitch()),
636            duration: opts.duration,
637            elapsed: 0.0,
638            easing: opts.easing.unwrap_or(default_easing),
639            is_perspective,
640            fov_y: camera.fov_y(),
641            viewport_height: camera.viewport_height(),
642        }
643    }
644
645    fn tick(&mut self, camera: &mut Camera, dt: f64) -> bool {
646        self.elapsed += dt;
647        let t = (self.elapsed / self.duration.max(1e-9)).clamp(0.0, 1.0);
648        let k = (self.easing)(t);
649        let done = t >= 1.0;
650
651        if done {
652            camera.set_target(self.end_center);
653            camera.set_distance(zoom_to_distance(
654                self.end_zoom,
655                self.fov_y,
656                self.viewport_height,
657                self.is_perspective,
658            ));
659            camera.set_yaw(self.end_bearing);
660            camera.set_pitch(self.end_pitch);
661            return true;
662        }
663
664        // Center: lerp in geographic space.
665        let lat = self.start_center.lat + (self.end_center.lat - self.start_center.lat) * k;
666        let lon = self.start_center.lon + (self.end_center.lon - self.start_center.lon) * k;
667        camera.set_target(GeoCoord::from_lat_lon(lat, wrap_lon_180(lon)));
668
669        // Zoom.
670        let zoom = self.start_zoom + (self.end_zoom - self.start_zoom) * k;
671        camera.set_distance(zoom_to_distance(
672            zoom,
673            self.fov_y,
674            self.viewport_height,
675            self.is_perspective,
676        ));
677
678        // Bearing (shortest path, already normalized).
679        camera.set_yaw(self.start_bearing + (self.end_bearing - self.start_bearing) * k);
680
681        // Pitch.
682        camera.set_pitch(self.start_pitch + (self.end_pitch - self.start_pitch) * k);
683
684        false
685    }
686}
687
688// ---------------------------------------------------------------------------
689// CameraAnimator
690// ---------------------------------------------------------------------------
691
692/// Drives smooth camera transitions.
693///
694/// Supports three animation modes that may be active simultaneously:
695///
696/// 1. **Fly-to / ease-to** -- a coordinated multi-property transition
697///    (only one may be active at a time; starting one cancels the other).
698/// 2. **Simple targets** -- independent exponential-smoothed zoom / yaw /
699///    pitch targets (useful for scroll-zoom, keyboard rotation).
700/// 3. **Momentum** -- inertial pan that decays over time.
701///
702/// Call [`tick`](Self::tick) every frame with the elapsed `dt`.
703pub struct CameraAnimator {
704    // -- Coordinated flight / ease state --
705    fly_to: Option<FlyToState>,
706    ease_to: Option<EaseToState>,
707
708    // -- Simple independent targets (legacy) --
709    target_distance: Option<f64>,
710    target_yaw: Option<f64>,
711    target_pitch: Option<f64>,
712
713    // -- Momentum --
714    momentum: (f64, f64),
715
716    /// Momentum decay factor per second (0..1, 0 = instant stop).
717    pub momentum_decay: f64,
718    /// Smoothing factor for simple zoom/rotate targets
719    /// (0 = instant, higher = smoother).
720    pub smoothing: f64,
721}
722
723impl Default for CameraAnimator {
724    fn default() -> Self {
725        Self {
726            fly_to: None,
727            ease_to: None,
728            target_distance: None,
729            target_yaw: None,
730            target_pitch: None,
731            momentum: (0.0, 0.0),
732            momentum_decay: 0.05,
733            smoothing: 8.0,
734        }
735    }
736}
737
738impl CameraAnimator {
739    /// Create a new camera animator with default settings.
740    pub fn new() -> Self {
741        Self::default()
742    }
743
744    // -- Coordinated animations -------------------------------------------
745
746    /// Start a van Wijk fly-to animation.
747    ///
748    /// This implements the "optimal path" algorithm from van Wijk & Nuij
749    /// (2003), matching MapLibre/Mapbox `flyTo` behavior.  The camera
750    /// simultaneously interpolates center, zoom, bearing, and pitch along
751    /// a zoom-out-then-zoom-in arc.
752    ///
753    /// Cancels any active fly-to, ease-to, simple targets, and momentum.
754    ///
755    /// If the requested transition is degenerate (no change or exceeds
756    /// `max_duration`), a `jump_to` is performed instead.
757    pub fn start_fly_to(&mut self, camera: &mut Camera, options: &FlyToOptions) {
758        self.cancel();
759
760        match FlyToState::new(camera, options) {
761            Some(state) => {
762                self.fly_to = Some(state);
763            }
764            None => {
765                // Degenerate: apply final state immediately.
766                Self::apply_jump(
767                    camera,
768                    options.center,
769                    options.zoom,
770                    options.bearing,
771                    options.pitch,
772                );
773            }
774        }
775    }
776
777    /// Start a simple ease-to animation.
778    ///
779    /// Linearly interpolates center, zoom, bearing, and pitch over the
780    /// specified duration with configurable easing.
781    ///
782    /// Cancels any active fly-to, ease-to, simple targets, and momentum.
783    pub fn start_ease_to(&mut self, camera: &mut Camera, options: &EaseToOptions) {
784        self.cancel();
785
786        if options.duration <= 0.0 {
787            Self::apply_jump(
788                camera,
789                options.center,
790                options.zoom,
791                options.bearing,
792                options.pitch,
793            );
794            return;
795        }
796
797        self.ease_to = Some(EaseToState::new(camera, options));
798    }
799
800    /// Apply final state immediately (jump_to helper).
801    fn apply_jump(
802        camera: &mut Camera,
803        center: Option<GeoCoord>,
804        zoom: Option<f64>,
805        bearing: Option<f64>,
806        pitch: Option<f64>,
807    ) {
808        if let Some(c) = center {
809            camera.set_target(c);
810        }
811        if let Some(z) = zoom {
812            let is_perspective = camera.mode() == crate::camera::CameraMode::Perspective;
813            camera.set_distance(zoom_to_distance(
814                z,
815                camera.fov_y(),
816                camera.viewport_height(),
817                is_perspective,
818            ));
819        }
820        if let Some(b) = bearing {
821            camera.set_yaw(b);
822        }
823        if let Some(p) = pitch {
824            camera.set_pitch(p);
825        }
826    }
827
828    // -- Simple independent targets (legacy) ------------------------------
829
830    /// Set a zoom animation target (distance in meters).
831    ///
832    /// Non-finite or non-positive distances are ignored.
833    pub fn animate_zoom(&mut self, target_distance: f64) {
834        if target_distance.is_finite() && target_distance > 0.0 {
835            self.target_distance = Some(target_distance);
836        }
837    }
838
839    /// Set rotation animation targets.
840    ///
841    /// Non-finite angles are ignored independently.
842    pub fn animate_rotate(&mut self, target_yaw: f64, target_pitch: f64) {
843        if target_yaw.is_finite() {
844            self.target_yaw = Some(target_yaw);
845        }
846        if target_pitch.is_finite() {
847            self.target_pitch = Some(target_pitch);
848        }
849    }
850
851    /// Apply pan momentum in world meters/second.
852    ///
853    /// Non-finite inputs are ignored.
854    pub fn apply_momentum(&mut self, vx: f64, vy: f64) {
855        if vx.is_finite() && vy.is_finite() {
856            self.momentum = (vx, vy);
857        }
858    }
859
860    /// Cancel all animations and momentum.
861    pub fn cancel(&mut self) {
862        self.fly_to = None;
863        self.ease_to = None;
864        self.target_distance = None;
865        self.target_yaw = None;
866        self.target_pitch = None;
867        self.momentum = (0.0, 0.0);
868    }
869
870    /// Whether any animation or momentum is active.
871    pub fn is_active(&self) -> bool {
872        self.fly_to.is_some()
873            || self.ease_to.is_some()
874            || self.target_distance.is_some()
875            || self.target_yaw.is_some()
876            || self.target_pitch.is_some()
877            || self.momentum.0.abs() > MOMENTUM_EPS
878            || self.momentum.1.abs() > MOMENTUM_EPS
879    }
880
881    /// Whether a coordinated fly-to or ease-to animation is active.
882    pub fn is_flying(&self) -> bool {
883        self.fly_to.is_some()
884    }
885
886    /// Whether a coordinated ease-to animation is active.
887    pub fn is_easing(&self) -> bool {
888        self.ease_to.is_some()
889    }
890
891    /// Advance the animation by `dt` seconds, mutating the camera.
892    ///
893    /// Non-finite or non-positive `dt` values are ignored.
894    pub fn tick(&mut self, camera: &mut Camera, dt: f64) {
895        if !dt.is_finite() || dt <= 0.0 {
896            return;
897        }
898
899        // Coordinated fly-to takes priority.
900        if let Some(ref mut state) = self.fly_to {
901            if state.tick(camera, dt) {
902                self.fly_to = None;
903            }
904            return;
905        }
906
907        // Coordinated ease-to.
908        if let Some(ref mut state) = self.ease_to {
909            if state.tick(camera, dt) {
910                self.ease_to = None;
911            }
912            return;
913        }
914
915        // Simple independent targets.
916        let smoothing = self.smoothing.max(0.0);
917        let t = 1.0 - (-smoothing * dt).exp();
918
919        if let Some(target) = self.target_distance {
920            let d = camera.distance() + (target - camera.distance()) * t;
921            camera.set_distance(d);
922            if (camera.distance() - target).abs() < DISTANCE_EPS {
923                camera.set_distance(target);
924                self.target_distance = None;
925            }
926        }
927
928        if let Some(target) = self.target_yaw {
929            let delta = shortest_angle_delta(camera.yaw(), target);
930            camera.set_yaw(camera.yaw() + delta * t);
931            if delta.abs() < ANGLE_EPS {
932                camera.set_yaw(target);
933                self.target_yaw = None;
934            }
935        }
936
937        if let Some(target) = self.target_pitch {
938            let p = camera.pitch() + (target - camera.pitch()) * t;
939            camera.set_pitch(p);
940            if (camera.pitch() - target).abs() < ANGLE_EPS {
941                camera.set_pitch(target);
942                self.target_pitch = None;
943            }
944        }
945
946        // Pan momentum.
947        if self.momentum.0.abs() > MOMENTUM_EPS || self.momentum.1.abs() > MOMENTUM_EPS {
948            let dx_deg = self.momentum.0 * dt
949                / (METERS_PER_DEGREE * camera.target().lat.to_radians().cos().max(0.001));
950            let dy_deg = self.momentum.1 * dt / METERS_PER_DEGREE;
951
952            let mut target = *camera.target();
953            target.lon += dx_deg;
954            target.lat += dy_deg;
955
956            target.lat = target.lat.clamp(-MERCATOR_MAX_LAT, MERCATOR_MAX_LAT);
957            if target.lon > 180.0 {
958                target.lon -= 360.0;
959            }
960            if target.lon < -180.0 {
961                target.lon += 360.0;
962            }
963            camera.set_target(target);
964
965            let decay = self.momentum_decay.clamp(0.0, 1.0).powf(dt);
966            self.momentum.0 *= decay;
967            self.momentum.1 *= decay;
968        }
969    }
970}
971
972// ---------------------------------------------------------------------------
973// Helpers
974// ---------------------------------------------------------------------------
975
976/// Smallest signed angular delta from `from` to `to` in radians.
977///
978/// Result is in `[-PI, PI]`.
979fn shortest_angle_delta(from: f64, to: f64) -> f64 {
980    let two_pi = std::f64::consts::TAU;
981    let mut d = (to - from) % two_pi;
982    if d > std::f64::consts::PI {
983        d -= two_pi;
984    }
985    if d < -std::f64::consts::PI {
986        d += two_pi;
987    }
988    d
989}
990
991/// Normalize target bearing so that interpolation takes the shortest path.
992///
993/// Returns a target value numerically close to `from` that yields the
994/// same final bearing modulo 2?.
995fn shortest_bearing_target(from: f64, to: f64) -> f64 {
996    from + shortest_angle_delta(from, to)
997}
998
999// ---------------------------------------------------------------------------
1000// Tests
1001// ---------------------------------------------------------------------------
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006    use rustial_math::GeoCoord;
1007
1008    // -- Legacy simple animation tests ------------------------------------
1009
1010    #[test]
1011    fn zoom_converges() {
1012        let mut cam = Camera::default();
1013        cam.set_distance(10_000_000.0);
1014        let mut anim = CameraAnimator::new();
1015        anim.animate_zoom(1_000_000.0);
1016
1017        for _ in 0..60 {
1018            anim.tick(&mut cam, 1.0 / 60.0);
1019        }
1020        assert!((cam.distance() - 1_000_000.0).abs() < 100_000.0);
1021
1022        for _ in 0..240 {
1023            anim.tick(&mut cam, 1.0 / 60.0);
1024        }
1025        assert!((cam.distance() - 1_000_000.0).abs() < 1.0);
1026        assert!(!anim.is_active());
1027    }
1028
1029    #[test]
1030    fn momentum_decays() {
1031        let mut cam = Camera::default();
1032        let mut anim = CameraAnimator::new();
1033        anim.apply_momentum(100_000.0, 0.0);
1034
1035        assert!(anim.is_active());
1036
1037        for _ in 0..600 {
1038            anim.tick(&mut cam, 1.0 / 60.0);
1039        }
1040
1041        assert!(!anim.is_active());
1042    }
1043
1044    #[test]
1045    fn cancel_stops_all() {
1046        let mut anim = CameraAnimator::new();
1047        anim.animate_zoom(100.0);
1048        anim.apply_momentum(10.0, 10.0);
1049        assert!(anim.is_active());
1050        anim.cancel();
1051        assert!(!anim.is_active());
1052    }
1053
1054    #[test]
1055    fn shortest_angle_delta_wraps() {
1056        let from = std::f64::consts::PI - 0.0174533;
1057        let to = -std::f64::consts::PI + 0.0174533;
1058        let d = shortest_angle_delta(from, to);
1059        assert!(d.abs() < 0.1);
1060    }
1061
1062    #[test]
1063    fn rotate_uses_shortest_path() {
1064        let mut cam = Camera::default();
1065        cam.set_yaw(std::f64::consts::PI - 0.01);
1066        let mut anim = CameraAnimator::new();
1067        anim.animate_rotate(-std::f64::consts::PI + 0.01, cam.pitch());
1068
1069        let before = cam.yaw();
1070        anim.tick(&mut cam, 1.0 / 60.0);
1071        let moved = (cam.yaw() - before).abs();
1072        assert!(
1073            moved < 0.2,
1074            "yaw moved too far; likely long-path interpolation"
1075        );
1076    }
1077
1078    #[test]
1079    fn tick_ignores_invalid_dt() {
1080        let mut cam = Camera::default();
1081        let mut anim = CameraAnimator::new();
1082        anim.animate_zoom(1000.0);
1083        let before = cam.distance();
1084
1085        anim.tick(&mut cam, f64::NAN);
1086        assert_eq!(cam.distance(), before);
1087
1088        anim.tick(&mut cam, 0.0);
1089        assert_eq!(cam.distance(), before);
1090
1091        anim.tick(&mut cam, -1.0);
1092        assert_eq!(cam.distance(), before);
1093    }
1094
1095    #[test]
1096    fn animate_zoom_rejects_invalid_values() {
1097        let mut anim = CameraAnimator::new();
1098        anim.animate_zoom(f64::NAN);
1099        assert!(!anim.is_active());
1100        anim.animate_zoom(-10.0);
1101        assert!(!anim.is_active());
1102        anim.animate_zoom(0.0);
1103        assert!(!anim.is_active());
1104    }
1105
1106    #[test]
1107    fn animate_rotate_rejects_non_finite_independently() {
1108        let mut anim = CameraAnimator::new();
1109        anim.animate_rotate(f64::NAN, 0.1);
1110        assert!(anim.is_active());
1111        anim.cancel();
1112
1113        anim.animate_rotate(0.2, f64::NAN);
1114        assert!(anim.is_active());
1115    }
1116
1117    #[test]
1118    fn momentum_clamps_and_wraps_geo() {
1119        let mut cam = Camera::default();
1120        cam.set_target(GeoCoord::from_lat_lon(85.0, 179.9));
1121        let mut anim = CameraAnimator::new();
1122        anim.apply_momentum(100_000.0, 100_000.0);
1123
1124        anim.tick(&mut cam, 1.0);
1125
1126        assert!(cam.target().lat <= MERCATOR_MAX_LAT);
1127        assert!(cam.target().lon >= -180.0 && cam.target().lon <= 180.0);
1128    }
1129
1130    #[test]
1131    fn decay_is_clamped_to_valid_range() {
1132        let mut cam = Camera::default();
1133        let mut anim = CameraAnimator::new();
1134        anim.apply_momentum(100.0, 0.0);
1135        anim.momentum_decay = 2.0;
1136
1137        let before = cam.target().lon;
1138        anim.tick(&mut cam, 1.0);
1139        let after = cam.target().lon;
1140        assert!(after != before);
1141        let after2_before = cam.target().lon;
1142        anim.tick(&mut cam, 1.0);
1143        assert!((cam.target().lon - after2_before).abs() < 1.0);
1144    }
1145
1146    // -- Fly-to tests -----------------------------------------------------
1147
1148    #[test]
1149    fn fly_to_changes_center_and_zoom() {
1150        let mut cam = Camera::default();
1151        cam.set_target(GeoCoord::from_lat_lon(0.0, 0.0));
1152        cam.set_distance(10_000_000.0);
1153        cam.set_viewport(800, 600);
1154
1155        let mut anim = CameraAnimator::new();
1156        anim.start_fly_to(
1157            &mut cam,
1158            &FlyToOptions {
1159                center: Some(GeoCoord::from_lat_lon(48.8566, 2.3522)), // Paris
1160                zoom: Some(12.0),
1161                ..Default::default()
1162            },
1163        );
1164
1165        assert!(anim.is_active());
1166        assert!(anim.is_flying());
1167
1168        // Run for a long time to ensure completion.
1169        for _ in 0..6000 {
1170            anim.tick(&mut cam, 1.0 / 60.0);
1171        }
1172
1173        assert!(!anim.is_active());
1174        assert!(
1175            (cam.target().lat - 48.8566).abs() < 0.01,
1176            "lat={}",
1177            cam.target().lat
1178        );
1179        assert!(
1180            (cam.target().lon - 2.3522).abs() < 0.01,
1181            "lon={}",
1182            cam.target().lon
1183        );
1184    }
1185
1186    #[test]
1187    fn fly_to_animates_bearing_shortest_path() {
1188        let mut cam = Camera::default();
1189        cam.set_distance(1_000_000.0);
1190        cam.set_viewport(800, 600);
1191        cam.set_yaw(std::f64::consts::PI - 0.1);
1192
1193        let target_yaw = -std::f64::consts::PI + 0.1;
1194        let mut anim = CameraAnimator::new();
1195        anim.start_fly_to(
1196            &mut cam,
1197            &FlyToOptions {
1198                bearing: Some(target_yaw),
1199                zoom: Some(5.0),
1200                ..Default::default()
1201            },
1202        );
1203
1204        // The first frame should take the short path (< 0.3 radians of
1205        // total delta, so any individual step should be small).
1206        let before = cam.yaw();
1207        anim.tick(&mut cam, 1.0 / 60.0);
1208        let step = (cam.yaw() - before).abs();
1209        assert!(step < 0.5, "yaw step too large: {step}");
1210    }
1211
1212    #[test]
1213    fn fly_to_same_location_different_zoom_works() {
1214        let mut cam = Camera::default();
1215        cam.set_distance(10_000_000.0);
1216        cam.set_viewport(800, 600);
1217        let start = *cam.target();
1218
1219        let mut anim = CameraAnimator::new();
1220        anim.start_fly_to(
1221            &mut cam,
1222            &FlyToOptions {
1223                zoom: Some(14.0),
1224                ..Default::default()
1225            },
1226        );
1227
1228        assert!(anim.is_active());
1229
1230        for _ in 0..6000 {
1231            anim.tick(&mut cam, 1.0 / 60.0);
1232        }
1233
1234        assert!(!anim.is_active());
1235        // Center should not have moved significantly.
1236        assert!((cam.target().lat - start.lat).abs() < 0.01);
1237        assert!((cam.target().lon - start.lon).abs() < 0.01);
1238    }
1239
1240    #[test]
1241    fn fly_to_cancel_stops_animation() {
1242        let mut cam = Camera::default();
1243        cam.set_distance(10_000_000.0);
1244        cam.set_viewport(800, 600);
1245
1246        let mut anim = CameraAnimator::new();
1247        anim.start_fly_to(
1248            &mut cam,
1249            &FlyToOptions {
1250                center: Some(GeoCoord::from_lat_lon(51.5, -0.12)),
1251                zoom: Some(10.0),
1252                ..Default::default()
1253            },
1254        );
1255
1256        anim.tick(&mut cam, 0.1);
1257        assert!(anim.is_active());
1258        anim.cancel();
1259        assert!(!anim.is_active());
1260    }
1261
1262    #[test]
1263    fn fly_to_explicit_duration() {
1264        let mut cam = Camera::default();
1265        cam.set_distance(10_000_000.0);
1266        cam.set_viewport(800, 600);
1267
1268        let mut anim = CameraAnimator::new();
1269        anim.start_fly_to(
1270            &mut cam,
1271            &FlyToOptions {
1272                center: Some(GeoCoord::from_lat_lon(35.6762, 139.6503)),
1273                zoom: Some(10.0),
1274                duration: Some(2.0),
1275                ..Default::default()
1276            },
1277        );
1278
1279        // Run for slightly more than 2 seconds to account for floating point.
1280        for _ in 0..130 {
1281            anim.tick(&mut cam, 1.0 / 60.0);
1282        }
1283        assert!(!anim.is_active());
1284        assert!((cam.target().lat - 35.6762).abs() < 0.01);
1285    }
1286
1287    #[test]
1288    fn fly_to_max_duration_degrades_to_jump() {
1289        let mut cam = Camera::default();
1290        cam.set_distance(10_000_000.0);
1291        cam.set_viewport(800, 600);
1292
1293        let mut anim = CameraAnimator::new();
1294        anim.start_fly_to(
1295            &mut cam,
1296            &FlyToOptions {
1297                center: Some(GeoCoord::from_lat_lon(35.6762, 139.6503)),
1298                zoom: Some(10.0),
1299                max_duration: Some(0.001), // Very short max => instant jump.
1300                ..Default::default()
1301            },
1302        );
1303
1304        // Should have jumped immediately.
1305        assert!(!anim.is_active());
1306        assert!((cam.target().lat - 35.6762).abs() < 0.01);
1307    }
1308
1309    // -- Ease-to tests ----------------------------------------------------
1310
1311    #[test]
1312    fn ease_to_basic() {
1313        let mut cam = Camera::default();
1314        cam.set_distance(10_000_000.0);
1315        cam.set_viewport(800, 600);
1316
1317        let mut anim = CameraAnimator::new();
1318        anim.start_ease_to(
1319            &mut cam,
1320            &EaseToOptions {
1321                center: Some(GeoCoord::from_lat_lon(40.7128, -74.0060)),
1322                zoom: Some(12.0),
1323                duration: 0.5,
1324                ..Default::default()
1325            },
1326        );
1327
1328        assert!(anim.is_active());
1329        assert!(anim.is_easing());
1330
1331        for _ in 0..60 {
1332            anim.tick(&mut cam, 1.0 / 60.0);
1333        }
1334
1335        assert!(!anim.is_active());
1336        assert!((cam.target().lat - 40.7128).abs() < 0.01);
1337    }
1338
1339    #[test]
1340    fn ease_to_zero_duration_is_instant() {
1341        let mut cam = Camera::default();
1342        cam.set_distance(10_000_000.0);
1343        cam.set_viewport(800, 600);
1344
1345        let mut anim = CameraAnimator::new();
1346        anim.start_ease_to(
1347            &mut cam,
1348            &EaseToOptions {
1349                center: Some(GeoCoord::from_lat_lon(51.5, -0.12)),
1350                duration: 0.0,
1351                ..Default::default()
1352            },
1353        );
1354
1355        // Should have jumped immediately.
1356        assert!(!anim.is_active());
1357        assert!((cam.target().lat - 51.5).abs() < 0.01);
1358    }
1359
1360    // -- Easing function test ---------------------------------------------
1361
1362    #[test]
1363    fn default_easing_endpoints() {
1364        assert!((default_easing(0.0)).abs() < 1e-6);
1365        assert!((default_easing(1.0) - 1.0).abs() < 1e-6);
1366    }
1367
1368    #[test]
1369    fn default_easing_monotonic() {
1370        let mut prev = 0.0;
1371        for i in 1..=100 {
1372            let t = i as f64 / 100.0;
1373            let v = default_easing(t);
1374            assert!(v >= prev - 1e-9, "non-monotonic at t={t}: {v} < {prev}");
1375            prev = v;
1376        }
1377    }
1378
1379    // -- Van Wijk algorithm correctness -----------------------------------
1380
1381    #[test]
1382    fn fly_to_zooms_out_then_in() {
1383        // The van Wijk algorithm should zoom out during the middle of the
1384        // flight (lower zoom = higher distance) and then zoom back in.
1385        let mut cam = Camera::default();
1386        cam.set_target(GeoCoord::from_lat_lon(0.0, 0.0));
1387        cam.set_distance(zoom_to_distance(
1388            5.0,
1389            cam.fov_y(),
1390            cam.viewport_height(),
1391            true,
1392        ));
1393        cam.set_viewport(800, 600);
1394
1395        let start_dist = cam.distance();
1396        let end_zoom = 5.0; // Same zoom, different center => pure zoom-out arc.
1397
1398        let mut anim = CameraAnimator::new();
1399        anim.start_fly_to(
1400            &mut cam,
1401            &FlyToOptions {
1402                center: Some(GeoCoord::from_lat_lon(40.0, 30.0)),
1403                zoom: Some(end_zoom),
1404                duration: Some(4.0),
1405                easing: Some(|t| t), // Linear easing for predictable sampling.
1406                ..Default::default()
1407            },
1408        );
1409
1410        // Sample distance at mid-flight.  With same start/end zoom and a
1411        // large center displacement, the camera should be farther away
1412        // (zoomed out) at the midpoint.
1413        let mut max_dist: f64 = 0.0;
1414        for _ in 0..240 {
1415            anim.tick(&mut cam, 1.0 / 60.0);
1416            max_dist = max_dist.max(cam.distance());
1417        }
1418        // Complete the rest.
1419        for _ in 0..300 {
1420            anim.tick(&mut cam, 1.0 / 60.0);
1421        }
1422
1423        assert!(
1424            max_dist > start_dist * 1.5,
1425            "mid-flight distance ({max_dist:.0}) should be well above start ({start_dist:.0})"
1426        );
1427        // Final distance should be back near the start (same zoom).
1428        let final_dist = cam.distance();
1429        assert!(
1430            (final_dist - start_dist).abs() < start_dist * 0.05,
1431            "final distance ({final_dist:.0}) should be near start ({start_dist:.0})"
1432        );
1433    }
1434
1435    #[test]
1436    fn fly_to_van_wijk_r_function_matches_maplibre() {
1437        // Direct unit test of the zoom_out_factor (r) function against
1438        // known MapLibre values.
1439        //
1440        // For w0=800, w1=400 (zoom in by 1 level), u1=1000, rho=1.42:
1441        //   rho2 = 2.0164
1442        //   r(0) = ln(sqrt(b0^2+1) - b0)  where b0 = (w1^2-w0^2+rho2^2*u1^2) / (2*w0*rho2*u1)
1443        //   r(1) = ln(sqrt(b1^2+1) - b1)  where b1 = (w1^2-w0^2-rho2^2*u1^2) / (2*w1*rho2*u1)
1444        let w0: f64 = 800.0;
1445        let w1: f64 = 400.0;
1446        let u1: f64 = 1000.0;
1447        let rho: f64 = 1.42;
1448        let rho2 = rho * rho;
1449
1450        let r = |descent: bool| -> f64 {
1451            let (w, sign) = if descent { (w1, -1.0) } else { (w0, 1.0) };
1452            let b = (w1 * w1 - w0 * w0 + sign * rho2 * rho2 * u1 * u1) / (2.0 * w * rho2 * u1);
1453            ((b * b + 1.0).sqrt() - b).ln()
1454        };
1455
1456        let r0 = r(false);
1457        let r1 = r(true);
1458        let s = (r1 - r0) / rho;
1459
1460        assert!(r0.is_finite(), "r0 should be finite");
1461        assert!(r1.is_finite(), "r1 should be finite");
1462        assert!(s > 0.0, "path length S should be positive, got {s}");
1463        assert!(s.is_finite(), "path length S should be finite");
1464
1465        // Verify that w(0) = cosh(r0)/cosh(r0) = 1.0 (unit visible span at start).
1466        let w_at_0 = r0.cosh() / (r0 + rho * 0.0).cosh();
1467        assert!(
1468            (w_at_0 - 1.0).abs() < 1e-10,
1469            "w(0) should be 1.0, got {w_at_0}"
1470        );
1471
1472        // Verify that w(S) = w1/w0 (MapLibre: scale = 1/w(S), so zoom = startZoom + log2(scale)).
1473        let w_at_s = r0.cosh() / (r0 + rho * s).cosh();
1474        let expected_w_at_s = w1 / w0; // 0.5
1475        assert!(
1476            (w_at_s - expected_w_at_s).abs() < 0.01,
1477            "w(S) should be {expected_w_at_s}, got {w_at_s}"
1478        );
1479    }
1480
1481    #[test]
1482    fn fly_to_degenerate_same_center_uses_w1_not_zoom() {
1483        // Regression: the degenerate path should compare w0 vs w1 (pixel
1484        // spans), not w0 vs end_zoom (a zoom level number).
1485        let mut cam = Camera::default();
1486        cam.set_target(GeoCoord::from_lat_lon(10.0, 20.0));
1487        cam.set_distance(zoom_to_distance(
1488            3.0,
1489            cam.fov_y(),
1490            cam.viewport_height(),
1491            true,
1492        ));
1493        cam.set_viewport(800, 600);
1494
1495        let start_dist = cam.distance();
1496
1497        let mut anim = CameraAnimator::new();
1498        anim.start_fly_to(
1499            &mut cam,
1500            &FlyToOptions {
1501                // Same center, different zoom => zoom-only degenerate path.
1502                zoom: Some(10.0),
1503                duration: Some(1.0),
1504                ..Default::default()
1505            },
1506        );
1507
1508        assert!(
1509            anim.is_active(),
1510            "animation should be active for zoom-only flight"
1511        );
1512
1513        // Run to completion.
1514        for _ in 0..70 {
1515            anim.tick(&mut cam, 1.0 / 60.0);
1516        }
1517
1518        assert!(!anim.is_active());
1519        // Should have zoomed in significantly.
1520        assert!(
1521            cam.distance() < start_dist * 0.01,
1522            "should have zoomed in: start={start_dist:.0}, end={:.0}",
1523            cam.distance()
1524        );
1525    }
1526}