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