Skip to main content

rustial_engine/math/
tile.rs

1//! Tile addressing for slippy-map tile pyramids.
2//!
3//! # Tile grid convention
4//!
5//! This module uses the [OpenStreetMap / Slippy Map][osm] tile numbering
6//! scheme, which is shared by virtually all web map services (Mapbox, Google
7//! Maps, Leaflet, etc.):
8//!
9//! - **Origin** is the top-left (north-west) corner of the world.
10//! - **X** increases eastward (column index).
11//! - **Y** increases southward (row index).
12//! - At zoom level `z`, the world is divided into `2^z * 2^z` tiles.
13//!
14//! Internally the tile grid is backed by the Web Mercator projection
15//! (EPSG:3857), so latitudes beyond approximately +/-85.06 degrees are not
16//! representable in the tile grid.
17//!
18//! # Coordinate precision
19//!
20//! All conversions between geographic coordinates and tile coordinates are
21//! performed in f64.  The fractional [`TileCoord`] type preserves sub-tile
22//! precision, which is important for the renderer's camera-relative mesh
23//! placement (avoids f32 jitter when the camera is far from tile corners).
24//!
25//! [osm]: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
26
27use crate::bounds::WorldBounds;
28use crate::coord::{GeoCoord, WorldCoord};
29use crate::mercator::WebMercator;
30use glam::{DMat4, DVec3, DVec4};
31use std::f64::consts::PI;
32use std::fmt;
33
34/// Maximum zoom level for slippy-map tiles.
35///
36/// At zoom 22 the tile grid is 4 194 304 x 4 194 304, giving roughly 2 cm
37/// ground resolution at the equator.  Higher levels are impractical for
38/// raster tiles and would risk `u32` overflow in `axis_tiles()` at zoom 32.
39pub const MAX_ZOOM: u8 = 22;
40
41// ---------------------------------------------------------------------------
42// Flat-view tile selection
43// ---------------------------------------------------------------------------
44
45/// Policy knobs for footprint-aware flat raster tile selection.
46///
47/// These values control when the selector switches from conservative
48/// [`visible_tiles`] AABB coverage to the more precise sampled ground
49/// footprint, and how aggressively that footprint is sampled/clamped.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct FlatTileSelectionConfig {
52    /// Minimum pitch (radians) before footprint filtering is applied.
53    pub footprint_pitch_threshold_rad: f64,
54    /// Minimum raw tile count before footprint filtering is applied.
55    pub footprint_min_tiles: usize,
56    /// Number of sample segments per screen edge when building the
57    /// ground-footprint polygon.
58    pub footprint_edge_steps: usize,
59    /// Upper bound in meters for ground-intersection ray length.
60    pub max_ground_distance: f64,
61    /// Upper bound in meters for sky-pointing ray XY projection.
62    pub max_sky_distance: f64,
63}
64
65impl Default for FlatTileSelectionConfig {
66    fn default() -> Self {
67        Self {
68            footprint_pitch_threshold_rad: 0.3,
69            footprint_min_tiles: 64,
70            footprint_edge_steps: 24,
71            max_ground_distance: 500_000.0,
72            max_sky_distance: 400_000.0,
73        }
74    }
75}
76
77/// Camera parameters needed to select flat raster tiles for a pitched
78/// perspective map view.
79///
80/// The selection algorithm starts from the conservative axis-aligned
81/// [`visible_tiles`] result computed from the world-space viewport bounds,
82/// then filters that set against the sampled ground footprint of the actual
83/// screen frustum. This keeps coverage correct while avoiding severe tile
84/// over-selection at steep pitch angles where the visible ground forms a thin,
85/// rotated trapezoid rather than a broad rectangle.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct FlatTileView {
88    /// Camera target in Web Mercator world coordinates (meters).
89    pub target_world: WorldCoord,
90    /// Camera orbit distance in meters.
91    pub distance: f64,
92    /// Camera pitch in radians.
93    pub pitch: f64,
94    /// Camera yaw in radians.
95    pub yaw: f64,
96    /// Vertical field of view in radians.
97    pub fov_y: f64,
98    /// Viewport width in logical pixels.
99    pub viewport_width: u32,
100    /// Viewport height in logical pixels.
101    pub viewport_height: u32,
102}
103
104impl FlatTileView {
105    /// Construct flat-view selection parameters.
106    #[inline]
107    pub fn new(
108        target_world: WorldCoord,
109        distance: f64,
110        pitch: f64,
111        yaw: f64,
112        fov_y: f64,
113        viewport_width: u32,
114        viewport_height: u32,
115    ) -> Self {
116        Self {
117            target_world,
118            distance,
119            pitch,
120            yaw,
121            fov_y,
122            viewport_width,
123            viewport_height,
124        }
125    }
126}
127
128/// Return visible flat raster tiles for a pitched perspective view.
129///
130/// This is the production tile-selection path for **flat tile quads**.
131/// It preserves complete coverage while reducing the extreme overfetch that
132/// occurs when a steeply pitched view uses only an axis-aligned Mercator AABB.
133///
134/// # Algorithm
135///
136/// 1. Compute the conservative AABB tile set with [`visible_tiles`].
137/// 2. For sufficiently pitched views, sample the full screen border
138///    against the ground plane to build a world-space footprint polygon.
139/// 3. Keep only tiles whose bounds intersect that sampled footprint.
140///
141/// Low-pitch and small tile sets bypass the extra filtering step.
142pub fn visible_tiles_flat_view(bounds: &WorldBounds, zoom: u8, view: &FlatTileView) -> Vec<TileId> {
143    visible_tiles_flat_view_with_config(bounds, zoom, view, &FlatTileSelectionConfig::default())
144}
145
146/// Return visible flat raster tiles for a pitched perspective view using
147/// an explicit flat-tile selection policy.
148///
149/// This is the configurable form of [`visible_tiles_flat_view`].
150pub fn visible_tiles_flat_view_with_config(
151    bounds: &WorldBounds,
152    zoom: u8,
153    view: &FlatTileView,
154    config: &FlatTileSelectionConfig,
155) -> Vec<TileId> {
156    let mut tiles = visible_tiles(bounds, zoom);
157
158    if view.pitch <= config.footprint_pitch_threshold_rad
159        || tiles.len() <= config.footprint_min_tiles
160    {
161        return tiles;
162    }
163
164    let footprint = sampled_ground_footprint(view, config);
165    if footprint.len() < 3 {
166        return tiles;
167    }
168
169    tiles.retain(|tile| tile_intersects_ground_footprint(*tile, &footprint));
170    tiles
171}
172
173fn refine_nearby_flat_tiles(
174    tiles: Vec<TileId>,
175    zoom: u8,
176    view: &FlatTileView,
177    _footprint: &[(f64, f64)],
178) -> Vec<TileId> {
179    if zoom >= MAX_ZOOM || tiles.is_empty() {
180        return tiles;
181    }
182
183    let cam_x = view.target_world.position.x;
184    let cam_y = view.target_world.position.y;
185    let refine_radius = (view.distance * 2.5).max(60_000.0);
186    let refine_radius_sq = refine_radius * refine_radius;
187
188    let mut refined = Vec::with_capacity(tiles.len() * 3);
189    for tile in tiles {
190        let bounds = tile_bounds_world(&tile);
191        let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
192        let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
193        let dx = cx - cam_x;
194        let dy = cy - cam_y;
195        let dist_sq = dx * dx + dy * dy;
196
197        if dist_sq <= refine_radius_sq {
198            refined.extend_from_slice(&tile.children());
199        } else {
200            refined.push(tile);
201        }
202    }
203
204    refined.sort();
205    refined.dedup();
206    refined
207}
208
209fn sampled_ground_footprint(
210    view: &FlatTileView,
211    config: &FlatTileSelectionConfig,
212) -> Vec<(f64, f64)> {
213    let w = view.viewport_width as f64;
214    let h = view.viewport_height as f64;
215    if w <= 0.0 || h <= 0.0 {
216        return Vec::new();
217    }
218
219    let edge_steps = config.footprint_edge_steps.max(1);
220
221    let half_fov = view.fov_y / 2.0;
222    let max_angle = (view.pitch + half_fov).min(std::f64::consts::FRAC_PI_2 - 0.01);
223    let ground_far = view.distance * max_angle.tan().abs().max(1.0);
224
225    // Scale the distance caps with camera altitude so the footprint
226    // polygon covers the full viewport during high-altitude zoom-out.
227    // The config values act as a floor for close-up views; at high
228    // altitude the camera-derived limit takes over.  This matches
229    // MapLibre's behaviour where tile selection adapts to altitude.
230    let altitude_ground_cap = view.distance * 6.0;
231    let max_ground_dist =
232        (ground_far * 2.0).min(config.max_ground_distance.max(altitude_ground_cap));
233    let altitude_sky_cap = view.distance * 4.0;
234    let max_sky_dist = (view.distance * view.pitch.tan().abs().max(1.0) * 4.0)
235        .min(config.max_sky_distance.max(altitude_sky_cap));
236
237    let mut samples = Vec::with_capacity((edge_steps + 1) * 4);
238    let mut push_hit = |px: f64, py: f64| {
239        let (origin, dir) = screen_to_ray(view, px, py);
240
241        if dir.z.abs() < 1e-12 {
242            let xy_len = (dir.x * dir.x + dir.y * dir.y).sqrt();
243            if xy_len > 1e-12 {
244                samples.push((
245                    origin.x + (dir.x / xy_len) * max_sky_dist,
246                    origin.y + (dir.y / xy_len) * max_sky_dist,
247                ));
248            }
249            return;
250        }
251
252        let t = -origin.z / dir.z;
253        if t < 0.0 {
254            let xy_len = (dir.x * dir.x + dir.y * dir.y).sqrt();
255            if xy_len > 1e-12 {
256                samples.push((
257                    origin.x + (dir.x / xy_len) * max_sky_dist,
258                    origin.y + (dir.y / xy_len) * max_sky_dist,
259                ));
260            }
261            return;
262        }
263
264        let t_clamped = t.min(max_ground_dist);
265        samples.push((origin.x + dir.x * t_clamped, origin.y + dir.y * t_clamped));
266    };
267
268    for i in 0..=edge_steps {
269        let t = i as f64 / edge_steps as f64;
270        push_hit(t * w, 0.0);
271    }
272    for i in 1..=edge_steps {
273        let t = i as f64 / edge_steps as f64;
274        push_hit(w, t * h);
275    }
276    for i in (0..edge_steps).rev() {
277        let t = i as f64 / edge_steps as f64;
278        push_hit(t * w, h);
279    }
280    for i in (1..edge_steps).rev() {
281        let t = i as f64 / edge_steps as f64;
282        push_hit(0.0, t * h);
283    }
284
285    dedupe_nearby_points(samples, 1.0)
286}
287
288fn screen_to_ray(view: &FlatTileView, px: f64, py: f64) -> (DVec3, DVec3) {
289    let w = view.viewport_width.max(1) as f64;
290    let h = view.viewport_height.max(1) as f64;
291
292    let target_world = DVec3::new(
293        view.target_world.position.x,
294        view.target_world.position.y,
295        view.target_world.position.z,
296    );
297    let view_m = view_matrix(view, target_world);
298    let proj_m = perspective_matrix(view);
299    let vp_inv = (proj_m * view_m).inverse();
300
301    let ndc_x = (2.0 * px / w) - 1.0;
302    let ndc_y = 1.0 - (2.0 * py / h);
303
304    let near_ndc = DVec4::new(ndc_x, ndc_y, -1.0, 1.0);
305    let far_ndc = DVec4::new(ndc_x, ndc_y, 1.0, 1.0);
306
307    let near_world = vp_inv * near_ndc;
308    let far_world = vp_inv * far_ndc;
309
310    if near_world.w.abs() < 1e-12 || far_world.w.abs() < 1e-12 {
311        return (DVec3::ZERO, -DVec3::Z);
312    }
313
314    let near = DVec3::new(
315        near_world.x / near_world.w,
316        near_world.y / near_world.w,
317        near_world.z / near_world.w,
318    );
319    let far = DVec3::new(
320        far_world.x / far_world.w,
321        far_world.y / far_world.w,
322        far_world.z / far_world.w,
323    );
324
325    let dir = (far - near).normalize();
326    if dir.is_nan() {
327        return (DVec3::ZERO, -DVec3::Z);
328    }
329    (near, dir)
330}
331
332fn eye_offset(view: &FlatTileView) -> DVec3 {
333    let (sp, cp) = view.pitch.sin_cos();
334    let (sy, cy) = view.yaw.sin_cos();
335    DVec3::new(
336        -view.distance * sp * sy,
337        -view.distance * sp * cy,
338        view.distance * cp,
339    )
340}
341
342fn view_matrix(view: &FlatTileView, target_world: DVec3) -> DMat4 {
343    let eye = target_world + eye_offset(view);
344    const BLEND_RAD: f64 = 0.15;
345
346    let (sy, cy) = view.yaw.sin_cos();
347    let yaw_up = DVec3::new(sy, cy, 0.0);
348    let right = DVec3::new(cy, -sy, 0.0);
349    let look = (target_world - eye).normalize_or_zero();
350    let pitched_up = right.cross(look).normalize_or_zero();
351
352    let t = (view.pitch / BLEND_RAD).clamp(0.0, 1.0);
353    let up = (pitched_up * t + yaw_up * (1.0 - t)).normalize_or_zero();
354    let up = if up.length_squared() < 0.5 {
355        DVec3::Z
356    } else {
357        up
358    };
359
360    DMat4::look_at_rh(eye, target_world, up)
361}
362
363fn perspective_matrix(view: &FlatTileView) -> DMat4 {
364    let aspect = view.viewport_width as f64 / view.viewport_height.max(1) as f64;
365    let near = (view.distance * 0.001).max(0.01);
366    let pitch_far_scale = if view.pitch > 0.01 {
367        (1.0 / view.pitch.cos().abs().max(0.05)).min(100.0)
368    } else {
369        1.0
370    };
371    let far = view.distance * 10.0 * pitch_far_scale;
372    DMat4::perspective_rh(view.fov_y, aspect, near, far)
373}
374
375fn dedupe_nearby_points(points: Vec<(f64, f64)>, epsilon: f64) -> Vec<(f64, f64)> {
376    let mut out = Vec::with_capacity(points.len());
377    let eps2 = epsilon * epsilon;
378    for p in points {
379        let keep = out
380            .last()
381            .map(|q: &(f64, f64)| {
382                let dx = p.0 - q.0;
383                let dy = p.1 - q.1;
384                dx * dx + dy * dy > eps2
385            })
386            .unwrap_or(true);
387        if keep {
388            out.push(p);
389        }
390    }
391    if out.len() >= 2 {
392        let first = out[0];
393        if let Some(&last) = out.last() {
394            let dx = first.0 - last.0;
395            let dy = first.1 - last.1;
396            if dx * dx + dy * dy <= eps2 {
397                out.pop();
398            }
399        }
400    }
401    out
402}
403
404fn tile_intersects_ground_footprint(tile: TileId, footprint: &[(f64, f64)]) -> bool {
405    let bounds = tile_bounds_world(&tile);
406    let min_x = bounds.min.position.x;
407    let min_y = bounds.min.position.y;
408    let max_x = bounds.max.position.x;
409    let max_y = bounds.max.position.y;
410
411    if footprint
412        .iter()
413        .any(|&(x, y)| x >= min_x && x <= max_x && y >= min_y && y <= max_y)
414    {
415        return true;
416    }
417
418    let corners = [
419        (min_x, min_y),
420        (max_x, min_y),
421        (max_x, max_y),
422        (min_x, max_y),
423    ];
424    if corners.iter().any(|&p| point_in_polygon(p, footprint)) {
425        return true;
426    }
427
428    for i in 0..footprint.len() {
429        let a = footprint[i];
430        let b = footprint[(i + 1) % footprint.len()];
431        if segment_intersects_aabb(a, b, min_x, min_y, max_x, max_y) {
432            return true;
433        }
434    }
435
436    false
437}
438
439fn point_in_polygon(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
440    let (px, py) = point;
441    let mut inside = false;
442    let mut j = polygon.len() - 1;
443    for i in 0..polygon.len() {
444        let (xi, yi) = polygon[i];
445        let (xj, yj) = polygon[j];
446        if (yi > py) != (yj > py) {
447            let denom = yj - yi;
448            if denom.abs() > 1e-12 {
449                let x_at_py = (xj - xi) * (py - yi) / denom + xi;
450                if px < x_at_py {
451                    inside = !inside;
452                }
453            }
454        }
455        j = i;
456    }
457    inside
458}
459
460fn segment_intersects_aabb(
461    a: (f64, f64),
462    b: (f64, f64),
463    min_x: f64,
464    min_y: f64,
465    max_x: f64,
466    max_y: f64,
467) -> bool {
468    if (a.0 >= min_x && a.0 <= max_x && a.1 >= min_y && a.1 <= max_y)
469        || (b.0 >= min_x && b.0 <= max_x && b.1 >= min_y && b.1 <= max_y)
470    {
471        return true;
472    }
473
474    let rect = [
475        ((min_x, min_y), (max_x, min_y)),
476        ((max_x, min_y), (max_x, max_y)),
477        ((max_x, max_y), (min_x, max_y)),
478        ((min_x, max_y), (min_x, min_y)),
479    ];
480
481    rect.iter()
482        .any(|&(r0, r1)| segments_intersect(a, b, r0, r1))
483}
484
485fn segments_intersect(a1: (f64, f64), a2: (f64, f64), b1: (f64, f64), b2: (f64, f64)) -> bool {
486    fn orient(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
487        (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
488    }
489    fn on_segment(a: (f64, f64), b: (f64, f64), p: (f64, f64)) -> bool {
490        p.0 >= a.0.min(b.0) - 1e-9
491            && p.0 <= a.0.max(b.0) + 1e-9
492            && p.1 >= a.1.min(b.1) - 1e-9
493            && p.1 <= a.1.max(b.1) + 1e-9
494    }
495
496    let o1 = orient(a1, a2, b1);
497    let o2 = orient(a1, a2, b2);
498    let o3 = orient(b1, b2, a1);
499    let o4 = orient(b1, b2, a2);
500
501    if (o1 > 0.0 && o2 < 0.0 || o1 < 0.0 && o2 > 0.0)
502        && (o3 > 0.0 && o4 < 0.0 || o3 < 0.0 && o4 > 0.0)
503    {
504        return true;
505    }
506
507    (o1.abs() <= 1e-9 && on_segment(a1, a2, b1))
508        || (o2.abs() <= 1e-9 && on_segment(a1, a2, b2))
509        || (o3.abs() <= 1e-9 && on_segment(b1, b2, a1))
510        || (o4.abs() <= 1e-9 && on_segment(b1, b2, a2))
511}
512
513// ---------------------------------------------------------------------------
514// TileId
515// ---------------------------------------------------------------------------
516
517/// A tile identifier in a slippy-map tile grid (zoom / x / y).
518///
519/// Zoom level 0 has a single tile covering the world.  At zoom `z` there
520/// are `2^z` tiles along each axis, giving `4^z` tiles total.
521///
522/// `TileId` is `Copy`, `Hash`, and `Ord`, so it can be used directly as a
523/// key in `HashMap`, `BTreeMap`, or sorted `Vec`.
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
525#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
526pub struct TileId {
527    /// Zoom level (0-22).
528    pub zoom: u8,
529    /// Column index (0 is the western-most column).
530    pub x: u32,
531    /// Row index (0 is the northern-most row).
532    pub y: u32,
533}
534
535impl TileId {
536    /// Create a new tile identifier.
537    ///
538    /// # Panics (debug only)
539    ///
540    /// Debug-asserts that `zoom <= 22` and that `x`, `y` are within range
541    /// for the zoom level.  In release builds the values are unchecked --
542    /// use [`new_checked`](Self::new_checked) when the inputs come from
543    /// untrusted sources.
544    #[inline]
545    pub fn new(zoom: u8, x: u32, y: u32) -> Self {
546        debug_assert!(zoom <= MAX_ZOOM, "zoom {zoom} exceeds maximum {MAX_ZOOM}");
547        debug_assert!(
548            x < Self::axis_tiles(zoom),
549            "x={x} out of range for zoom {zoom}"
550        );
551        debug_assert!(
552            y < Self::axis_tiles(zoom),
553            "y={y} out of range for zoom {zoom}"
554        );
555        Self { zoom, x, y }
556    }
557
558    /// Checked constructor that returns `None` if parameters are out of range.
559    ///
560    /// Validates `zoom <= 22` and `x, y < 2^zoom`.
561    #[inline]
562    pub fn new_checked(zoom: u8, x: u32, y: u32) -> Option<Self> {
563        if zoom > MAX_ZOOM {
564            return None;
565        }
566        let n = Self::axis_tiles(zoom);
567        if x >= n || y >= n {
568            return None;
569        }
570        Some(Self { zoom, x, y })
571    }
572
573    /// Total number of tiles along one axis at this zoom level (`2^zoom`).
574    ///
575    /// # Contract
576    ///
577    /// Callers should pass zooms in `0..=MAX_ZOOM`. Higher zooms are not
578    /// meaningful for this crate and may overflow shift semantics on some
579    /// targets/toolchains.
580    #[inline]
581    pub fn axis_tiles(zoom: u8) -> u32 {
582        1u32 << zoom
583    }
584
585    /// Return the parent tile (one zoom level up).  Returns `None` at zoom 0.
586    ///
587    /// The parent is the tile that fully contains this tile at the next
588    /// coarser zoom level.  Used by `TileManager` for fallback rendering
589    /// while a tile is still loading.
590    #[inline]
591    pub fn parent(&self) -> Option<TileId> {
592        if self.zoom == 0 {
593            None
594        } else {
595            Some(TileId {
596                zoom: self.zoom - 1,
597                x: self.x / 2,
598                y: self.y / 2,
599            })
600        }
601    }
602
603    /// Return the four children (one zoom level down).
604    ///
605    /// The children are ordered: top-left, top-right, bottom-left,
606    /// bottom-right.
607    ///
608    /// # Note
609    ///
610    /// Calling this at `zoom == MAX_ZOOM` produces tiles at zoom 23,
611    /// which is beyond the validated range.  The engine never does this,
612    /// but callers at the boundary should be aware.
613    #[inline]
614    pub fn children(&self) -> [TileId; 4] {
615        let z = self.zoom + 1;
616        let x = self.x * 2;
617        let y = self.y * 2;
618        [
619            TileId { zoom: z, x, y },
620            TileId {
621                zoom: z,
622                x: x + 1,
623                y,
624            },
625            TileId {
626                zoom: z,
627                x,
628                y: y + 1,
629            },
630            TileId {
631                zoom: z,
632                x: x + 1,
633                y: y + 1,
634            },
635        ]
636    }
637
638    /// Encode this tile as a [Bing Maps-style quadkey][qk] string.
639    ///
640    /// Returns an empty string at zoom 0.  Each character is `'0'`-`'3'`,
641    /// encoding two bits per level: bit 0 from X, bit 1 from Y.
642    ///
643    /// [qk]: https://learn.microsoft.com/en-us/bingmaps/articles/bing-maps-tile-system
644    pub fn quadkey(&self) -> String {
645        let mut key = String::with_capacity(self.zoom as usize);
646        for i in (1..=self.zoom).rev() {
647            let mut digit: u8 = b'0';
648            let mask = 1u32 << (i - 1);
649            if (self.x & mask) != 0 {
650                digit += 1;
651            }
652            if (self.y & mask) != 0 {
653                digit += 2;
654            }
655            key.push(digit as char);
656        }
657        key
658    }
659
660    /// Decode a [Bing Maps-style quadkey][qk] string into a tile ID.
661    ///
662    /// Returns `None` if the string contains invalid characters or if the
663    /// resulting zoom exceeds [`MAX_ZOOM`].
664    ///
665    /// An empty quadkey decodes to the root tile `0/0/0`.
666    ///
667    /// [qk]: https://learn.microsoft.com/en-us/bingmaps/articles/bing-maps-tile-system
668    pub fn from_quadkey(key: &str) -> Option<Self> {
669        let zoom = key.len() as u8;
670        if zoom > MAX_ZOOM {
671            return None;
672        }
673
674        let mut x = 0u32;
675        let mut y = 0u32;
676
677        for (i, ch) in key.bytes().enumerate() {
678            let bit = 1u32 << (zoom as usize - 1 - i);
679            match ch {
680                b'0' => {}
681                b'1' => x |= bit,
682                b'2' => y |= bit,
683                b'3' => {
684                    x |= bit;
685                    y |= bit;
686                }
687                _ => return None,
688            }
689        }
690
691        Self::new_checked(zoom, x, y)
692    }
693
694    /// Return the neighbouring tile in a cardinal direction at the same
695    /// zoom level.
696    ///
697    /// `dx` and `dy` are offsets: `(-1, 0)` = west, `(1, 0)` = east,
698    /// `(0, -1)` = north, `(0, 1)` = south.  Diagonal offsets such as
699    /// `(-1, -1)` (north-west) are also supported.
700    ///
701    /// The X axis wraps around (longitude wrap): moving west from column 0
702    /// yields column `2^zoom - 1`, and vice versa.  The Y axis does **not**
703    /// wrap: moving north from row 0 or south from the last row returns
704    /// `None` (there is no tile beyond the poles).
705    ///
706    /// Returns `None` if the resulting tile would be out of the valid
707    /// Y range.
708    #[inline]
709    pub fn neighbor(&self, dx: i32, dy: i32) -> Option<TileId> {
710        let n = Self::axis_tiles(self.zoom) as i64;
711        // X wraps around (longitude wrapping).
712        let nx = ((self.x as i64 + dx as i64) % n + n) % n;
713        // Y does not wrap (no tiles beyond the poles).
714        let ny = self.y as i64 + dy as i64;
715        if ny < 0 || ny >= n {
716            return None;
717        }
718        Some(TileId {
719            zoom: self.zoom,
720            x: nx as u32,
721            y: ny as u32,
722        })
723    }
724}
725
726impl fmt::Display for TileId {
727    /// Formats as `"zoom/x/y"` (the standard OSM URL path component).
728    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
729        write!(f, "{}/{}/{}", self.zoom, self.x, self.y)
730    }
731}
732
733// ---------------------------------------------------------------------------
734// TileCoord
735// ---------------------------------------------------------------------------
736
737/// A fractional position within the tile grid at a given zoom level.
738///
739/// Where [`TileId`] addresses an integer tile, `TileCoord` addresses an
740/// exact point within the grid -- for example `(zoom=10, x=560.7, y=342.3)`
741/// means "70% across tile column 560, 30% down tile row 342".
742///
743/// Useful for sub-tile precision when mapping world coordinates to tiles.
744#[derive(Debug, Clone, Copy, PartialEq)]
745#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
746pub struct TileCoord {
747    /// Zoom level.
748    pub zoom: u8,
749    /// Fractional column position (0.0 = west edge, `2^zoom` = east edge).
750    pub x: f64,
751    /// Fractional row position (0.0 = north edge, `2^zoom` = south edge).
752    pub y: f64,
753}
754
755impl TileCoord {
756    /// Create a new fractional tile coordinate.
757    #[inline]
758    pub fn new(zoom: u8, x: f64, y: f64) -> Self {
759        Self { zoom, x, y }
760    }
761
762    /// Snap to the integer tile that contains this coordinate.
763    ///
764    /// Values are clamped to the valid tile range `[0, 2^zoom - 1]`, so
765    /// this always returns a valid `TileId` -- even if `x` or `y` are
766    /// slightly out of range due to floating-point rounding.
767    #[inline]
768    pub fn tile_id(&self) -> TileId {
769        let n = TileId::axis_tiles(self.zoom);
770        // Clamp to [0, n-1].  The lower clamp guards against negative
771        // values (which `as u32` would saturate to 0 on stable Rust,
772        // but explicitly clamping makes intent clear and safe).
773        let x = (self.x.max(0.0) as u32).min(n.saturating_sub(1));
774        let y = (self.y.max(0.0) as u32).min(n.saturating_sub(1));
775        TileId {
776            zoom: self.zoom,
777            x,
778            y,
779        }
780    }
781}
782
783impl fmt::Display for TileCoord {
784    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
785        write!(f, "{}/{:.3}/{:.3}", self.zoom, self.x, self.y)
786    }
787}
788
789// ---------------------------------------------------------------------------
790// Conversion functions
791// ---------------------------------------------------------------------------
792
793/// Convert a geographic coordinate to a fractional tile coordinate at the
794/// given zoom.
795///
796/// Uses the standard [Mercator tile formula][osm]:
797///
798/// ```text
799/// x = (lon + 180) / 360 * 2^zoom
800/// y = (1 - ln(tan(lat) + sec(lat)) / pi) / 2 * 2^zoom
801/// ```
802///
803/// The input must be within the Web Mercator valid range (latitude
804/// approximately +/-85.06 degrees).  Latitudes at or beyond the poles
805/// produce infinite or NaN `y` values.
806///
807/// [osm]: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Mathematics
808pub fn geo_to_tile(geo: &GeoCoord, zoom: u8) -> TileCoord {
809    let n = TileId::axis_tiles(zoom) as f64;
810    let lat_rad = geo.lat.to_radians();
811    let x = (geo.lon + 180.0) / 360.0 * n;
812    let y = (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / PI) / 2.0 * n;
813    TileCoord::new(zoom, x, y)
814}
815
816/// Checked variant of [`geo_to_tile`].
817///
818/// Returns `None` if `zoom > MAX_ZOOM` or if `geo` is outside Web Mercator
819/// latitude range.
820pub fn geo_to_tile_checked(geo: &GeoCoord, zoom: u8) -> Option<TileCoord> {
821    if zoom > MAX_ZOOM || !geo.is_web_mercator_valid() {
822        return None;
823    }
824    Some(geo_to_tile(geo, zoom))
825}
826
827/// Convert a tile ID (top-left corner) to the geographic coordinate of its
828/// north-west corner.
829///
830/// This is the inverse of snapping `geo_to_tile(...).tile_id()` at integer
831/// boundaries.
832pub fn tile_to_geo(tile: &TileId) -> GeoCoord {
833    tile_xy_to_geo(tile.zoom, tile.x as f64, tile.y as f64)
834}
835
836/// Convert fractional tile coordinates to geographic.
837///
838/// Accepts fractional `(x, y)` values that may sit on or beyond the tile
839/// grid boundary (for example `x = n` to get the east edge of the last
840/// column). This is useful for deriving geographic tile corners before
841/// reprojecting them into other planar world spaces.
842pub fn tile_xy_to_geo(zoom: u8, x: f64, y: f64) -> GeoCoord {
843    let n = TileId::axis_tiles(zoom) as f64;
844    let lon = x / n * 360.0 - 180.0;
845    let lat_rad = (PI * (1.0 - 2.0 * y / n)).sinh().atan();
846    GeoCoord::from_lat_lon(lat_rad.to_degrees(), lon)
847}
848
849/// Return the world-space bounding box (Web Mercator meters) for a tile.
850///
851/// The returned `WorldBounds` has `min` at the south-west corner and `max`
852/// at the north-east corner, matching the convention used by therenderer's
853/// quad mesh builder.
854pub fn tile_bounds_world(tile: &TileId) -> WorldBounds {
855    // NW corner of this tile, SE corner is the NW of the next tile (+1,+1).
856    let nw = tile_to_geo(tile);
857    let se = tile_xy_to_geo(tile.zoom, tile.x as f64 + 1.0, tile.y as f64 + 1.0);
858    let min_world = WebMercator::project(&GeoCoord::from_lat_lon(se.lat, nw.lon));
859    let max_world = WebMercator::project(&GeoCoord::from_lat_lon(nw.lat, se.lon));
860    WorldBounds::new(min_world, max_world)
861}
862
863/// Return all tiles that intersect the given world-space bounding box at a
864/// zoom level.
865///
866/// # Antimeridian handling
867///
868/// When the bounding box spans the antimeridian (min.x > max.x in world
869/// space after unprojection), the X iteration wraps around, producing tiles
870/// on both sides of the dateline.  The Y range never wraps because latitude
871/// is bounded by the Mercator limit.
872///
873/// # Ordering and duplicates
874///
875/// Output is deterministic and row-major: `y` increases outermost, `x`
876/// advances left-to-right within each row (with wrap when needed).
877/// Each `(z, x, y)` appears at most once.
878///
879/// # Allocation
880///
881/// Returns an owned `Vec` with capacity pre-allocated from the tile count.
882/// At zoom 14 a typical viewport might produce ~200 tiles; at zoom 18 with
883/// a 4K display, up to ~4000.
884pub fn visible_tiles(bounds: &WorldBounds, zoom: u8) -> Vec<TileId> {
885    let extent = WebMercator::max_extent();
886    let world_size = WebMercator::world_size();
887    let n = TileId::axis_tiles(zoom);
888
889    // If the viewport spans the full Mercator world width (or more), all
890    // columns at this zoom are visible regardless of wrapped longitude.
891    let spans_full_world_x = (bounds.max.position.x - bounds.min.position.x) >= world_size - 1.0;
892
893    let x_min_raw: i64;
894    let x_count: u32;
895    if spans_full_world_x {
896        x_min_raw = 0;
897        x_count = n;
898    } else {
899        let tile_world_width = world_size / n as f64;
900        x_min_raw = ((bounds.min.position.x + extent) / tile_world_width).floor() as i64;
901        let x_max_raw = ((bounds.max.position.x + extent) / tile_world_width).floor() as i64;
902        x_count = (x_max_raw - x_min_raw + 1).clamp(0, n as i64) as u32;
903    }
904
905    let world_y_to_tile_y = |world_y: f64| {
906        (((extent - world_y.clamp(-extent, extent)) / world_size) * n as f64)
907            .floor()
908            .clamp(0.0, n.saturating_sub(1) as f64) as u32
909    };
910
911    let y_min = world_y_to_tile_y(bounds.max.position.y);
912    let y_max = world_y_to_tile_y(bounds.min.position.y);
913
914    let mut tiles = Vec::with_capacity((x_count * (y_max - y_min + 1)) as usize);
915    for y in y_min..=y_max {
916        for i in 0..x_count {
917            let x = if spans_full_world_x {
918                i
919            } else {
920                (x_min_raw + i as i64).rem_euclid(n as i64) as u32
921            };
922            tiles.push(TileId { zoom, x, y });
923        }
924    }
925    tiles
926}
927
928/// Checked variant of [`visible_tiles`].
929pub fn visible_tiles_checked(bounds: &WorldBounds, zoom: u8) -> Option<Vec<TileId>> {
930    if zoom > MAX_ZOOM {
931        return None;
932    }
933    Some(visible_tiles(bounds, zoom))
934}
935
936/// Return tiles at multiple zoom levels based on distance from the camera.
937pub fn visible_tiles_lod(
938    bounds: &WorldBounds,
939    base_zoom: u8,
940    camera_world: (f64, f64),
941    near_threshold: f64,
942    mid_threshold: f64,
943    max_tiles: usize,
944) -> Vec<TileId> {
945    use std::collections::HashSet;
946
947    let near_zoom = (base_zoom + 1).min(MAX_ZOOM);
948    let far_zoom = base_zoom.saturating_sub(1);
949
950    let near_tiles = visible_tiles(bounds, near_zoom);
951    let mid_tiles = visible_tiles(bounds, base_zoom);
952    let far_tiles = visible_tiles(bounds, far_zoom);
953
954    let near_sq = near_threshold * near_threshold;
955    let mid_sq = mid_threshold * mid_threshold;
956
957    fn tile_dist_sq(tile: &TileId, cam: (f64, f64)) -> f64 {
958        let b = tile_bounds_world(tile);
959        let cx = (b.min.position.x + b.max.position.x) * 0.5;
960        let cy = (b.min.position.y + b.max.position.y) * 0.5;
961        let dx = cx - cam.0;
962        let dy = cy - cam.1;
963        dx * dx + dy * dy
964    }
965
966    let mut result: Vec<(TileId, f64)> = Vec::new();
967    let mut seen = HashSet::new();
968
969    for tile in &near_tiles {
970        let d2 = tile_dist_sq(tile, camera_world);
971        if d2 <= near_sq && seen.insert(*tile) {
972            result.push((*tile, d2));
973        }
974    }
975
976    let near_parent_set: HashSet<TileId> = if near_zoom > base_zoom {
977        result.iter().filter_map(|(t, _)| t.parent()).collect()
978    } else {
979        HashSet::new()
980    };
981
982    for tile in &mid_tiles {
983        let d2 = tile_dist_sq(tile, camera_world);
984        if d2 <= mid_sq && seen.insert(*tile) {
985            if near_parent_set.contains(tile) {
986                let children = tile.children();
987                if children.iter().all(|c| seen.contains(c)) {
988                    continue;
989                }
990            }
991            result.push((*tile, d2));
992        }
993    }
994
995    let mid_parent_set: HashSet<TileId> = if base_zoom > far_zoom {
996        result
997            .iter()
998            .filter(|(t, _)| t.zoom == base_zoom)
999            .filter_map(|(t, _)| t.parent())
1000            .collect()
1001    } else {
1002        HashSet::new()
1003    };
1004
1005    for tile in &far_tiles {
1006        let d2 = tile_dist_sq(tile, camera_world);
1007        if d2 > mid_sq && seen.insert(*tile) {
1008            if mid_parent_set.contains(tile) {
1009                let children = tile.children();
1010                if children.iter().all(|c| seen.contains(c)) {
1011                    continue;
1012                }
1013            }
1014            result.push((*tile, d2));
1015        }
1016    }
1017
1018    result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1019    result.truncate(max_tiles);
1020
1021    result.into_iter().map(|(t, _)| t).collect()
1022}
1023
1024/// Return visible tiles by depth-first quadtree traversal with frustum culling.
1025pub fn visible_tiles_frustum(
1026    frustum: &crate::frustum::Frustum,
1027    target_zoom: u8,
1028    max_tiles: usize,
1029    camera_world: (f64, f64),
1030) -> Vec<TileId> {
1031    let target_zoom = target_zoom.min(MAX_ZOOM);
1032
1033    struct StackEntry {
1034        tile: TileId,
1035        fully_visible: bool,
1036    }
1037
1038    let mut stack = Vec::with_capacity(64);
1039    stack.push(StackEntry {
1040        tile: TileId::new(0, 0, 0),
1041        fully_visible: false,
1042    });
1043
1044    let mut result: Vec<(TileId, f64)> = Vec::new();
1045
1046    while let Some(entry) = stack.pop() {
1047        let tile = entry.tile;
1048        let bounds = tile_bounds_world(&tile);
1049
1050        if !entry.fully_visible {
1051            let mut all_inside = true;
1052            let mut any_outside = false;
1053            let min = bounds.min.position;
1054            let max = bounds.max.position;
1055
1056            for plane in frustum.planes() {
1057                let px = if plane.normal()[0] >= 0.0 {
1058                    max.x
1059                } else {
1060                    min.x
1061                };
1062                let py = if plane.normal()[1] >= 0.0 {
1063                    max.y
1064                } else {
1065                    min.y
1066                };
1067                let pz = if plane.normal()[2] >= 0.0 {
1068                    max.z
1069                } else {
1070                    min.z
1071                };
1072
1073                if plane.distance_to_point(px, py, pz) < 0.0 {
1074                    any_outside = true;
1075                    break;
1076                }
1077
1078                let nx = if plane.normal()[0] >= 0.0 {
1079                    min.x
1080                } else {
1081                    max.x
1082                };
1083                let ny = if plane.normal()[1] >= 0.0 {
1084                    min.y
1085                } else {
1086                    max.y
1087                };
1088                let nz = if plane.normal()[2] >= 0.0 {
1089                    min.z
1090                } else {
1091                    max.z
1092                };
1093
1094                if plane.distance_to_point(nx, ny, nz) < 0.0 {
1095                    all_inside = false;
1096                }
1097            }
1098
1099            if any_outside {
1100                continue;
1101            }
1102
1103            if tile.zoom >= target_zoom {
1104                let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1105                let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1106                let dx = cx - camera_world.0;
1107                let dy = cy - camera_world.1;
1108                result.push((tile, dx * dx + dy * dy));
1109                continue;
1110            }
1111
1112            for child in tile.children().iter().rev() {
1113                stack.push(StackEntry {
1114                    tile: *child,
1115                    fully_visible: all_inside,
1116                });
1117            }
1118        } else {
1119            if tile.zoom >= target_zoom {
1120                let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1121                let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1122                let dx = cx - camera_world.0;
1123                let dy = cy - camera_world.1;
1124                result.push((tile, dx * dx + dy * dy));
1125                continue;
1126            }
1127
1128            for child in tile.children().iter().rev() {
1129                stack.push(StackEntry {
1130                    tile: *child,
1131                    fully_visible: true,
1132                });
1133            }
1134        }
1135    }
1136
1137    result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1138    result.truncate(max_tiles);
1139    result.into_iter().map(|(t, _)| t).collect()
1140}
1141
1142/// Return visible flat raster tiles for a pitched perspective view,
1143/// capped to a maximum count and prioritised by distance to the camera target.
1144pub fn visible_tiles_flat_view_capped(
1145    bounds: &WorldBounds,
1146    zoom: u8,
1147    view: &FlatTileView,
1148    max_tiles: usize,
1149) -> Vec<TileId> {
1150    visible_tiles_flat_view_capped_with_config(
1151        bounds,
1152        zoom,
1153        view,
1154        &FlatTileSelectionConfig::default(),
1155        max_tiles,
1156    )
1157}
1158
1159/// Return visible flat raster tiles for a pitched perspective view,
1160/// capped to a maximum count and prioritised by distance to the camera target,
1161/// using an explicit flat-tile selection policy.
1162pub fn visible_tiles_flat_view_capped_with_config(
1163    bounds: &WorldBounds,
1164    zoom: u8,
1165    view: &FlatTileView,
1166    config: &FlatTileSelectionConfig,
1167    max_tiles: usize,
1168) -> Vec<TileId> {
1169    let mut tiles = visible_tiles_flat_view_with_config(bounds, zoom, view, config);
1170    if tiles.len() <= max_tiles {
1171        return tiles;
1172    }
1173
1174    let cam_x = view.target_world.position.x;
1175    let cam_y = view.target_world.position.y;
1176    tiles.sort_by(|a, b| {
1177        let ad = tile_distance_sq(*a, cam_x, cam_y);
1178        let bd = tile_distance_sq(*b, cam_x, cam_y);
1179        ad.partial_cmp(&bd)
1180            .unwrap_or(std::cmp::Ordering::Equal)
1181            .then_with(|| a.zoom.cmp(&b.zoom))
1182            .then_with(|| a.y.cmp(&b.y))
1183            .then_with(|| a.x.cmp(&b.x))
1184    });
1185    tiles.truncate(max_tiles);
1186    tiles
1187}
1188
1189fn tile_distance_sq(tile: TileId, cam_x: f64, cam_y: f64) -> f64 {
1190    let bounds = tile_bounds_world(&tile);
1191    let cx = (bounds.min.position.x + bounds.max.position.x) * 0.5;
1192    let cy = (bounds.min.position.y + bounds.max.position.y) * 0.5;
1193    let dx = cx - cam_x;
1194    let dy = cy - cam_y;
1195    dx * dx + dy * dy
1196}
1197
1198/// Return visible flat raster tiles for a pitched perspective view with
1199/// an extra near-camera refinement pass, capped to a maximum count.
1200pub fn visible_tiles_flat_view_refined_capped(
1201    bounds: &WorldBounds,
1202    zoom: u8,
1203    view: &FlatTileView,
1204    max_tiles: usize,
1205) -> Vec<TileId> {
1206    visible_tiles_flat_view_refined_capped_with_config(
1207        bounds,
1208        zoom,
1209        view,
1210        &FlatTileSelectionConfig::default(),
1211        max_tiles,
1212    )
1213}
1214
1215/// Configurable form of [`visible_tiles_flat_view_refined_capped`].
1216pub fn visible_tiles_flat_view_refined_capped_with_config(
1217    bounds: &WorldBounds,
1218    zoom: u8,
1219    view: &FlatTileView,
1220    config: &FlatTileSelectionConfig,
1221    max_tiles: usize,
1222) -> Vec<TileId> {
1223    let footprint_filtered = visible_tiles_flat_view_with_config(bounds, zoom, view, config);
1224    if footprint_filtered.is_empty() {
1225        return footprint_filtered;
1226    }
1227
1228    let mut refined = footprint_filtered;
1229    if refined.len() < max_tiles
1230        && zoom < MAX_ZOOM
1231        && view.pitch > config.footprint_pitch_threshold_rad
1232    {
1233        let footprint = sampled_ground_footprint(view, config);
1234        if footprint.len() >= 3 {
1235            refined = refine_nearby_flat_tiles(refined, zoom, view, &footprint);
1236        }
1237    }
1238
1239    if refined.len() <= max_tiles {
1240        return refined;
1241    }
1242
1243    let cam_x = view.target_world.position.x;
1244    let cam_y = view.target_world.position.y;
1245    refined.sort_by(|a, b| {
1246        let ad = tile_distance_sq(*a, cam_x, cam_y);
1247        let bd = tile_distance_sq(*b, cam_x, cam_y);
1248        ad.partial_cmp(&bd)
1249            .unwrap_or(std::cmp::Ordering::Equal)
1250            .then_with(|| a.zoom.cmp(&b.zoom))
1251            .then_with(|| a.y.cmp(&b.y))
1252            .then_with(|| a.x.cmp(&b.x))
1253    });
1254    refined.truncate(max_tiles);
1255    refined
1256}
1257
1258// ---------------------------------------------------------------------------
1259// Covering-tiles: MapLibre-equivalent quadtree traversal with variable zoom
1260// ---------------------------------------------------------------------------
1261
1262/// Options controlling the covering-tiles quadtree traversal.
1263///
1264/// This is the Rustial equivalent of MapLibre's `CoveringTilesOptionsInternal`.
1265/// It couples frustum culling, per-tile variable zoom heuristics, wrap
1266/// handling, and tile-budget enforcement into a single covering-selection
1267/// call.
1268#[derive(Debug, Clone)]
1269pub struct CoveringTilesOptions {
1270    /// Smallest allowed tile zoom (inclusive).  Tiles at zooms below this
1271    /// are never emitted but may still be traversed.
1272    pub min_zoom: u8,
1273    /// Largest allowed tile zoom (inclusive).  The traversal never descends
1274    /// past this zoom regardless of the per-tile heuristic.
1275    pub max_zoom: u8,
1276    /// Whether to round (`true`) or floor (`false`) the computed tile zoom.
1277    pub round_zoom: bool,
1278    /// Source tile size in screen pixels (typically 256 or 512).
1279    pub tile_size: u32,
1280    /// Maximum number of result tiles.
1281    pub max_tiles: usize,
1282    /// Whether to allow per-tile variable zoom heuristics at steep pitch.
1283    /// When `false` all tiles use the single nominal zoom level.
1284    pub allow_variable_zoom: bool,
1285    /// Whether to emit world-copy tiles across the antimeridian.
1286    pub render_world_copies: bool,
1287}
1288
1289impl Default for CoveringTilesOptions {
1290    fn default() -> Self {
1291        Self {
1292            min_zoom: 0,
1293            max_zoom: MAX_ZOOM,
1294            round_zoom: false,
1295            tile_size: 256,
1296            max_tiles: 512,
1297            allow_variable_zoom: true,
1298            render_world_copies: true,
1299        }
1300    }
1301}
1302
1303/// Camera state needed by the covering-tiles traversal.
1304///
1305/// All distances / positions are in **Mercator normalised coordinates**
1306/// (range 0..1), matching the MapLibre convention where `worldSize` = 1.
1307/// The caller is responsible for converting meter-based engine coordinates
1308/// before calling [`visible_tiles_covering`].
1309#[derive(Debug, Clone, Copy)]
1310pub struct CoveringCamera {
1311    /// Camera position X in Mercator normalised coords.
1312    pub camera_x: f64,
1313    /// Camera position Y in Mercator normalised coords.
1314    pub camera_y: f64,
1315    /// Absolute vertical distance from camera to map center, in Mercator
1316    /// normalised coords.
1317    pub camera_to_center_z: f64,
1318    /// Center point X in Mercator normalised coords.
1319    pub center_x: f64,
1320    /// Center point Y in Mercator normalised coords.
1321    pub center_y: f64,
1322    /// Camera pitch in radians.
1323    pub pitch_rad: f64,
1324    /// Vertical field-of-view in **degrees** (matching MapLibre convention).
1325    pub fov_deg: f64,
1326    /// The logical zoom level requested at the center of the viewport
1327    /// (before tile-size adjustment).
1328    pub zoom: f64,
1329    /// Display tile size in screen pixels (typically 256).
1330    pub display_tile_size: u32,
1331}
1332
1333/// Compute the per-tile desired zoom using a pitch-aware heuristic.
1334///
1335/// This is a direct Rust port of MapLibre's `createCalculateTileZoomFunction`
1336/// with `maxZoomLevelsOnScreen = 9.314` and `tileCountMaxMinRatio = 3.0`.
1337///
1338/// # Arguments
1339///
1340/// * `requested_center_zoom` - nominal zoom at the center.
1341/// * `dist_2d` - 2D distance from camera to tile center (Mercator units).
1342/// * `dist_z` - vertical distance from camera to center (Mercator units).
1343/// * `dist_center_3d` - 3D distance from camera to center (Mercator units).
1344/// * `fov_deg` - camera vertical FOV in degrees.
1345fn default_calculate_tile_zoom(
1346    requested_center_zoom: f64,
1347    dist_2d: f64,
1348    dist_z: f64,
1349    dist_center_3d: f64,
1350    fov_deg: f64,
1351) -> f64 {
1352    const MAX_ZOOM_LEVELS_ON_SCREEN: f64 = 9.314;
1353    const TILE_COUNT_MAX_MIN_RATIO: f64 = 3.0;
1354    const MAX_MERCATOR_HORIZON_ANGLE: f64 = 85.051129; // degrees
1355
1356    fn scale_zoom(s: f64) -> f64 {
1357        s.log2()
1358    }
1359
1360    fn integral_cos_x_by_p(p: f64, x1: f64, x2: f64) -> f64 {
1361        let num_points = 10usize;
1362        let dx = (x2 - x1) / num_points as f64;
1363        let mut sum = 0.0;
1364        for i in 0..num_points {
1365            let x = x1 + (i as f64 + 0.5) / num_points as f64 * (x2 - x1);
1366            sum += dx * x.cos().powf(p);
1367        }
1368        sum
1369    }
1370
1371    let horizon_rad = (MAX_MERCATOR_HORIZON_ANGLE).to_radians();
1372    let fov_rad = fov_deg.to_radians();
1373
1374    let pitch_tile_loading_behavior = 2.0
1375        * ((MAX_ZOOM_LEVELS_ON_SCREEN - 1.0)
1376            / scale_zoom((horizon_rad - fov_rad).cos() / horizon_rad.cos())
1377            - 1.0);
1378
1379    let center_pitch = if dist_center_3d > 1e-15 {
1380        (dist_z / dist_center_3d).clamp(-1.0, 1.0).acos()
1381    } else {
1382        0.0
1383    };
1384
1385    let half_fov_rad = fov_rad / 2.0;
1386    let _tile_count_pitch0 =
1387        2.0 * integral_cos_x_by_p(pitch_tile_loading_behavior - 1.0, 0.0, half_fov_rad);
1388    let highest_pitch = horizon_rad.min(center_pitch + half_fov_rad);
1389    let lowest_pitch = highest_pitch.min(center_pitch - half_fov_rad);
1390    let tile_count = integral_cos_x_by_p(
1391        pitch_tile_loading_behavior - 1.0,
1392        lowest_pitch,
1393        highest_pitch,
1394    );
1395    let tile_count_pitch0 =
1396        2.0 * integral_cos_x_by_p(pitch_tile_loading_behavior - 1.0, 0.0, half_fov_rad);
1397
1398    let this_tile_pitch = if dist_z.abs() > 1e-15 {
1399        (dist_2d / dist_z).atan()
1400    } else {
1401        std::f64::consts::FRAC_PI_2
1402    };
1403    let dist_tile_3d = (dist_2d * dist_2d + dist_z * dist_z).sqrt();
1404
1405    let mut desired_z = requested_center_zoom;
1406    if dist_tile_3d > 1e-15 {
1407        desired_z += scale_zoom(dist_center_3d / dist_tile_3d / half_fov_rad.cos().max(0.5));
1408    }
1409    desired_z += pitch_tile_loading_behavior * scale_zoom(this_tile_pitch.cos()) / 2.0;
1410    desired_z -=
1411        scale_zoom((tile_count / tile_count_pitch0 / TILE_COUNT_MAX_MIN_RATIO).max(1.0)) / 2.0;
1412
1413    desired_z
1414}
1415
1416/// Return the nominal covering zoom level for a source tile size.
1417///
1418/// Equivalent to MapLibre's `coveringZoomLevel`.
1419fn covering_zoom_level(cam: &CoveringCamera, opts: &CoveringTilesOptions, round: bool) -> f64 {
1420    fn scale_zoom(s: f64) -> f64 {
1421        s.log2()
1422    }
1423    let raw = cam.zoom + scale_zoom(cam.display_tile_size as f64 / opts.tile_size as f64);
1424    let z = if round { raw.round() } else { raw.floor() };
1425    z.max(0.0)
1426}
1427
1428/// 2D distance from a point to the nearest point on a tile (Mercator normalised).
1429fn dist_to_tile_2d(cx: f64, cy: f64, tx: u32, ty: u32, z: u8) -> f64 {
1430    let n = (1u64 << z as u64) as f64;
1431    let inv = 1.0 / n;
1432    let tile_min_x = tx as f64 * inv;
1433    let tile_min_y = ty as f64 * inv;
1434    let tile_max_x = (tx + 1) as f64 * inv;
1435    let tile_max_y = (ty + 1) as f64 * inv;
1436
1437    let dx = if cx < tile_min_x {
1438        tile_min_x - cx
1439    } else if cx > tile_max_x {
1440        cx - tile_max_x
1441    } else {
1442        0.0
1443    };
1444    let dy = if cy < tile_min_y {
1445        tile_min_y - cy
1446    } else if cy > tile_max_y {
1447        cy - tile_max_y
1448    } else {
1449        0.0
1450    };
1451    (dx * dx + dy * dy).sqrt()
1452}
1453
1454/// Return visible tiles using MapLibre-equivalent depth-first quadtree
1455/// traversal with per-tile variable zoom heuristics and frustum culling.
1456///
1457/// This is the production covering-tiles path that matches MapLibre's
1458/// `coveringTiles()` algorithm:
1459///
1460/// 1. Derive a nominal target zoom from the camera zoom and source tile size.
1461/// 2. Perform a depth-first quadtree traversal from zoom 0.
1462/// 3. At each node, test visibility against the camera frustum.
1463/// 4. Optionally compute a per-tile desired zoom based on the tile's 3D
1464///    distance and pitch angle (variable zoom).
1465/// 5. When a tile reaches its desired zoom, emit it as a result.
1466/// 6. Optionally traverse world copies for antimeridian wrap.
1467///
1468/// Results are sorted by ascending distance from the center point and
1469/// capped to `opts.max_tiles`.
1470pub fn visible_tiles_covering(
1471    frustum: &crate::frustum::Frustum,
1472    cam: &CoveringCamera,
1473    opts: &CoveringTilesOptions,
1474) -> Vec<TileId> {
1475    let desired_z = covering_zoom_level(cam, opts, opts.round_zoom);
1476    let nominal_z = (desired_z as u8).min(opts.max_zoom);
1477    let num_tiles_f = (1u64 << nominal_z as u64) as f64;
1478
1479    let center_tx = num_tiles_f * cam.center_x;
1480    let center_ty = num_tiles_f * cam.center_y;
1481
1482    let dist_center_2d =
1483        ((cam.center_x - cam.camera_x).powi(2) + (cam.center_y - cam.camera_y).powi(2)).sqrt();
1484    let dist_z = cam.camera_to_center_z;
1485    let dist_center_3d = (dist_center_2d * dist_center_2d + dist_z * dist_z).sqrt();
1486
1487    let requested_center_zoom = cam.zoom
1488        + if cam.display_tile_size > 0 && opts.tile_size > 0 {
1489            (cam.display_tile_size as f64 / opts.tile_size as f64).log2()
1490        } else {
1491            0.0
1492        };
1493
1494    struct StackEntry {
1495        zoom: u8,
1496        x: u32,
1497        y: u32,
1498        wrap: i32,
1499        fully_visible: bool,
1500    }
1501
1502    let mut stack: Vec<StackEntry> = Vec::with_capacity(64);
1503
1504    // World copies for antimeridian wrap handling
1505    if opts.render_world_copies {
1506        for i in 1..=3i32 {
1507            stack.push(StackEntry {
1508                zoom: 0,
1509                x: 0,
1510                y: 0,
1511                wrap: -i,
1512                fully_visible: false,
1513            });
1514            stack.push(StackEntry {
1515                zoom: 0,
1516                x: 0,
1517                y: 0,
1518                wrap: i,
1519                fully_visible: false,
1520            });
1521        }
1522    }
1523    stack.push(StackEntry {
1524        zoom: 0,
1525        x: 0,
1526        y: 0,
1527        wrap: 0,
1528        fully_visible: false,
1529    });
1530
1531    struct ResultEntry {
1532        tile: TileId,
1533        wrap: i32,
1534        dist_sq: f64,
1535    }
1536
1537    let mut result: Vec<ResultEntry> = Vec::new();
1538    let world_size = WebMercator::world_size();
1539
1540    while let Some(entry) = stack.pop() {
1541        let z = entry.zoom;
1542        let x = entry.x;
1543        let y = entry.y;
1544        let mut fully_visible = entry.fully_visible;
1545
1546        // Compute the world-space AABB for this tile (with wrap offset)
1547        let tile_for_bounds = TileId {
1548            zoom: z,
1549            x: x % TileId::axis_tiles(z).max(1),
1550            y: y.min(TileId::axis_tiles(z).saturating_sub(1)),
1551        };
1552        let base_bounds = tile_bounds_world(&tile_for_bounds);
1553        let wrap_offset = entry.wrap as f64 * world_size;
1554        let bounds = WorldBounds::new(
1555            WorldCoord::new(
1556                base_bounds.min.position.x + wrap_offset,
1557                base_bounds.min.position.y,
1558                0.0,
1559            ),
1560            WorldCoord::new(
1561                base_bounds.max.position.x + wrap_offset,
1562                base_bounds.max.position.y,
1563                0.0,
1564            ),
1565        );
1566
1567        if !fully_visible {
1568            if !frustum.intersects_aabb(&bounds) {
1569                continue;
1570            }
1571            // Check if fully inside (all corners inside all planes)
1572            let min = bounds.min.position;
1573            let max = bounds.max.position;
1574            let mut all_inside = true;
1575            for plane in frustum.planes() {
1576                let nx = if plane.normal()[0] >= 0.0 {
1577                    min.x
1578                } else {
1579                    max.x
1580                };
1581                let ny = if plane.normal()[1] >= 0.0 {
1582                    min.y
1583                } else {
1584                    max.y
1585                };
1586                let nz = if plane.normal()[2] >= 0.0 {
1587                    min.z
1588                } else {
1589                    max.z
1590                };
1591                if plane.distance_to_point(nx, ny, nz) < 0.0 {
1592                    all_inside = false;
1593                    break;
1594                }
1595            }
1596            fully_visible = all_inside;
1597        }
1598
1599        // Compute the per-tile desired zoom
1600        let this_tile_desired_z = if opts.allow_variable_zoom && cam.pitch_rad > 0.05 {
1601            let d2d = dist_to_tile_2d(cam.camera_x, cam.camera_y, x, y, z);
1602            let z_val = default_calculate_tile_zoom(
1603                requested_center_zoom,
1604                d2d,
1605                dist_z,
1606                dist_center_3d,
1607                cam.fov_deg,
1608            );
1609            let z_rounded = if opts.round_zoom {
1610                z_val.round()
1611            } else {
1612                z_val.floor()
1613            };
1614            (z_rounded.max(0.0) as u8).min(opts.max_zoom)
1615        } else {
1616            nominal_z
1617        };
1618
1619        // Have we reached the target depth for this tile?
1620        if z >= this_tile_desired_z {
1621            if z < opts.min_zoom {
1622                continue;
1623            }
1624            // Distance from center for sorting
1625            let dz_shift = nominal_z.saturating_sub(z);
1626            let tile_center_x = (x as f64 + 0.5) * (1u64 << dz_shift) as f64;
1627            let tile_center_y = (y as f64 + 0.5) * (1u64 << dz_shift) as f64;
1628            let dx = center_tx - tile_center_x;
1629            let dy = center_ty - tile_center_y;
1630
1631            // Wrap x into valid tile range
1632            let n = TileId::axis_tiles(z);
1633            let wrapped_x = if n > 0 {
1634                ((x as i64).rem_euclid(n as i64)) as u32
1635            } else {
1636                0
1637            };
1638
1639            result.push(ResultEntry {
1640                tile: TileId {
1641                    zoom: z,
1642                    x: wrapped_x,
1643                    y: y.min(n.saturating_sub(1)),
1644                },
1645                wrap: entry.wrap,
1646                dist_sq: dx * dx + dy * dy,
1647            });
1648            continue;
1649        }
1650
1651        // Subdivide into 4 children
1652        let child_z = z + 1;
1653        if child_z > MAX_ZOOM {
1654            continue;
1655        }
1656        for i in (0..4u32).rev() {
1657            let cx = (x << 1) + (i % 2);
1658            let cy = (y << 1) + (i >> 1);
1659            stack.push(StackEntry {
1660                zoom: child_z,
1661                x: cx,
1662                y: cy,
1663                wrap: entry.wrap,
1664                fully_visible,
1665            });
1666        }
1667    }
1668
1669    // Sort by distance and cap.
1670    //
1671    // Important: when `render_world_copies` is enabled, the same canonical
1672    // (z/x/y) can be visible in multiple wrapped worlds at once. Collapsing
1673    // them into a single entry (dedup by z/x/y) can cause tiles to disappear
1674    // as the camera crosses world boundaries, because the renderer needs
1675    // both wrapped copies to stay concurrently visible.
1676    result.sort_by(|a, b| {
1677        a.dist_sq
1678            .partial_cmp(&b.dist_sq)
1679            .unwrap_or(std::cmp::Ordering::Equal)
1680    });
1681
1682    // Deduplicate exact duplicates (same wrap + canonical id) only.
1683    let mut seen = std::collections::HashSet::new();
1684    let mut final_tiles = Vec::with_capacity(result.len().min(opts.max_tiles));
1685    for entry in result {
1686        if seen.insert((entry.wrap, entry.tile.zoom, entry.tile.x, entry.tile.y)) {
1687            final_tiles.push(entry.tile);
1688            if final_tiles.len() >= opts.max_tiles {
1689                break;
1690            }
1691        }
1692    }
1693
1694    final_tiles
1695}
1696
1697#[cfg(test)]
1698mod tests {
1699    use super::*;
1700    use crate::mercator::WebMercator;
1701
1702    #[test]
1703    fn visible_tiles_flat_view_preserves_coverage() {
1704        let bounds = WorldBounds::new(
1705            WebMercator::project(&GeoCoord::from_lat_lon(37.7749, -122.4194)),
1706            WebMercator::project(&GeoCoord::from_lat_lon(37.8049, -122.3894)),
1707        );
1708        let zoom = 12;
1709        let view = FlatTileView::new(
1710            bounds.center(),
1711            1000.0,
1712            std::f64::consts::FRAC_PI_4,
1713            0.0,
1714            std::f64::consts::FRAC_PI_4,
1715            800,
1716            600,
1717        );
1718
1719        let tiles = visible_tiles_flat_view(&bounds, zoom, &view);
1720
1721        // Basic checks
1722        assert!(!tiles.is_empty());
1723        assert!(tiles.iter().all(|t| t.zoom == zoom));
1724    }
1725
1726    #[test]
1727    fn visible_tiles_flat_view_capped_does_not_exceed_limit() {
1728        let bounds = WorldBounds::new(
1729            WebMercator::project(&GeoCoord::from_lat_lon(37.7749, -122.4194)),
1730            WebMercator::project(&GeoCoord::from_lat_lon(37.8049, -122.3894)),
1731        );
1732        let zoom = 12;
1733        let view = FlatTileView::new(
1734            bounds.center(),
1735            1000.0,
1736            std::f64::consts::FRAC_PI_4,
1737            0.0,
1738            std::f64::consts::FRAC_PI_4,
1739            800,
1740            600,
1741        );
1742
1743        let max_tiles = 10;
1744        let tiles = visible_tiles_flat_view_capped(&bounds, zoom, &view, max_tiles);
1745
1746        // Check that the number of tiles does not exceed the cap
1747        assert!(tiles.len() <= max_tiles);
1748    }
1749
1750    #[test]
1751    fn visible_tiles_covering_top_down_returns_uniform_zoom() {
1752        use crate::frustum::Frustum;
1753        use glam::DMat4;
1754
1755        // Top-down camera (no pitch) should give all tiles at the same zoom.
1756        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1757        let eye = glam::DVec3::new(0.0, 0.0, 50_000.0);
1758        let target = glam::DVec3::ZERO;
1759        let view = DMat4::look_at_rh(eye, target, glam::DVec3::Y);
1760        let frustum = Frustum::from_view_projection(&(proj * view));
1761
1762        let world_size = crate::mercator::WebMercator::world_size();
1763        let cam = CoveringCamera {
1764            camera_x: 0.5,
1765            camera_y: 0.5,
1766            camera_to_center_z: 50_000.0 / world_size,
1767            center_x: 0.5,
1768            center_y: 0.5,
1769            pitch_rad: 0.0,
1770            fov_deg: 45.0,
1771            zoom: 4.0,
1772            display_tile_size: 256,
1773        };
1774        let opts = CoveringTilesOptions {
1775            min_zoom: 0,
1776            max_zoom: MAX_ZOOM,
1777            round_zoom: false,
1778            tile_size: 256,
1779            max_tiles: 512,
1780            allow_variable_zoom: true,
1781            render_world_copies: false,
1782        };
1783
1784        let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1785        assert!(!tiles.is_empty());
1786        let first_zoom = tiles[0].zoom;
1787        // All tiles at same zoom for a top-down view
1788        assert!(tiles.iter().all(|t| t.zoom == first_zoom));
1789    }
1790
1791    #[test]
1792    fn visible_tiles_covering_pitched_produces_variable_zoom() {
1793        use crate::frustum::Frustum;
1794        use glam::DMat4;
1795
1796        // Steeply pitched camera should produce tiles at different zoom levels.
1797        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1798        let eye = glam::DVec3::new(0.0, -40_000.0, 20_000.0);
1799        let target = glam::DVec3::ZERO;
1800        let view = DMat4::look_at_rh(eye, target, glam::DVec3::Z);
1801        let frustum = Frustum::from_view_projection(&(proj * view));
1802
1803        let world_size = crate::mercator::WebMercator::world_size();
1804        let pitch_rad = 1.1; // ~63 degrees
1805        let cam = CoveringCamera {
1806            camera_x: 0.5,
1807            camera_y: 0.5 - 40_000.0 / world_size,
1808            camera_to_center_z: 20_000.0 / world_size,
1809            center_x: 0.5,
1810            center_y: 0.5,
1811            pitch_rad,
1812            fov_deg: 45.0,
1813            zoom: 8.0,
1814            display_tile_size: 256,
1815        };
1816        let opts = CoveringTilesOptions {
1817            min_zoom: 0,
1818            max_zoom: MAX_ZOOM,
1819            round_zoom: false,
1820            tile_size: 256,
1821            max_tiles: 512,
1822            allow_variable_zoom: true,
1823            render_world_copies: false,
1824        };
1825
1826        let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1827        assert!(!tiles.is_empty());
1828        let zooms: std::collections::HashSet<u8> = tiles.iter().map(|t| t.zoom).collect();
1829        // At steep pitch with variable zoom enabled, we expect multiple zoom levels
1830        assert!(
1831            zooms.len() > 1,
1832            "Expected multiple zoom levels at steep pitch, got: {:?}",
1833            zooms
1834        );
1835    }
1836
1837    #[test]
1838    fn visible_tiles_covering_respects_max_tiles() {
1839        use crate::frustum::Frustum;
1840        use glam::DMat4;
1841
1842        let vp = DMat4::orthographic_rh(
1843            -500_000.0, 500_000.0, -500_000.0, 500_000.0, -500_000.0, 500_000.0,
1844        );
1845        let frustum = Frustum::from_view_projection(&vp);
1846
1847        let cam = CoveringCamera {
1848            camera_x: 0.5,
1849            camera_y: 0.5,
1850            camera_to_center_z: 0.01,
1851            center_x: 0.5,
1852            center_y: 0.5,
1853            pitch_rad: 0.0,
1854            fov_deg: 45.0,
1855            zoom: 10.0,
1856            display_tile_size: 256,
1857        };
1858        let opts = CoveringTilesOptions {
1859            min_zoom: 0,
1860            max_zoom: MAX_ZOOM,
1861            round_zoom: false,
1862            tile_size: 256,
1863            max_tiles: 5,
1864            allow_variable_zoom: false,
1865            render_world_copies: false,
1866        };
1867
1868        let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1869        assert!(tiles.len() <= 5);
1870    }
1871
1872    #[test]
1873    fn visible_tiles_covering_no_duplicates() {
1874        use crate::frustum::Frustum;
1875        use glam::DMat4;
1876
1877        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1878        let eye = glam::DVec3::new(0.0, 0.0, 50_000.0);
1879        let view = DMat4::look_at_rh(eye, glam::DVec3::ZERO, glam::DVec3::Y);
1880        let frustum = Frustum::from_view_projection(&(proj * view));
1881
1882        let cam = CoveringCamera {
1883            camera_x: 0.5,
1884            camera_y: 0.5,
1885            camera_to_center_z: 0.001,
1886            center_x: 0.5,
1887            center_y: 0.5,
1888            pitch_rad: 0.0,
1889            fov_deg: 45.0,
1890            zoom: 4.0,
1891            display_tile_size: 256,
1892        };
1893        let opts = CoveringTilesOptions {
1894            render_world_copies: true,
1895            ..CoveringTilesOptions::default()
1896        };
1897
1898        let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1899        let unique: std::collections::HashSet<_> = tiles.iter().collect();
1900        assert_eq!(
1901            tiles.len(),
1902            unique.len(),
1903            "Covering tiles should have no duplicates"
1904        );
1905    }
1906
1907    #[test]
1908    fn high_altitude_pitched_flat_view_selects_enough_tiles() {
1909        // Reproduce the fly_to zoom-out scenario: zoom 7, distance ~1.4 M m,
1910        // pitch ~48 degrees.  Before the fix the footprint was clamped to
1911        // 500 km which left most of the viewport uncovered.
1912        let center = WebMercator::project(&GeoCoord::from_lat_lon(40.839, -72.9));
1913        let distance = 1_406_424.0; // ~zoom 7 altitude
1914        let pitch = 48.4_f64.to_radians();
1915        let yaw = -20.3_f64.to_radians();
1916        let fov_y = 45.0_f64.to_radians();
1917        let view = FlatTileView::new(center, distance, pitch, yaw, fov_y, 1280, 720);
1918
1919        // Build viewport bounds that match the camera altitude.
1920        let half = distance * 4.0;
1921        let bounds = WorldBounds::new(
1922            WorldCoord::new(center.position.x - half, center.position.y - half, 0.0),
1923            WorldCoord::new(center.position.x + half, center.position.y + half, 0.0),
1924        );
1925
1926        let tiles = visible_tiles_flat_view_with_config(
1927            &bounds,
1928            7,
1929            &view,
1930            &FlatTileSelectionConfig::default(),
1931        );
1932
1933        // At zoom 7 over the Atlantic/US the viewport should contain many
1934        // tiles -- certainly more than the 5 that were produced when the
1935        // footprint was clamped to 500 km.
1936        assert!(
1937            tiles.len() >= 10,
1938            "Expected >= 10 tiles at zoom 7 high altitude, got {}",
1939            tiles.len()
1940        );
1941    }
1942
1943    #[test]
1944    fn visible_tiles_covering_variable_zoom_disabled_gives_uniform_zoom() {
1945        use crate::frustum::Frustum;
1946        use glam::DMat4;
1947
1948        let proj = DMat4::perspective_rh(std::f64::consts::FRAC_PI_4, 1.5, 1.0, 500_000.0);
1949        let eye = glam::DVec3::new(0.0, -40_000.0, 20_000.0);
1950        let target = glam::DVec3::ZERO;
1951        let view = DMat4::look_at_rh(eye, target, glam::DVec3::Z);
1952        let frustum = Frustum::from_view_projection(&(proj * view));
1953
1954        let world_size = crate::mercator::WebMercator::world_size();
1955        let cam = CoveringCamera {
1956            camera_x: 0.5,
1957            camera_y: 0.5 - 40_000.0 / world_size,
1958            camera_to_center_z: 20_000.0 / world_size,
1959            center_x: 0.5,
1960            center_y: 0.5,
1961            pitch_rad: 1.1,
1962            fov_deg: 45.0,
1963            zoom: 6.0,
1964            display_tile_size: 256,
1965        };
1966        let opts = CoveringTilesOptions {
1967            allow_variable_zoom: false,
1968            render_world_copies: false,
1969            ..CoveringTilesOptions::default()
1970        };
1971
1972        let tiles = visible_tiles_covering(&frustum, &cam, &opts);
1973        assert!(!tiles.is_empty());
1974        let first_zoom = tiles[0].zoom;
1975        assert!(
1976            tiles.iter().all(|t| t.zoom == first_zoom),
1977            "With variable zoom disabled, all tiles should be at the same zoom"
1978        );
1979    }
1980}