Skip to main content

rustial_engine/
tile_manager.rs

1// ---------------------------------------------------------------------------
2//! # Tile manager -- fetch, cache, and fallback orchestration
3//!
4//! [`TileManager`] is the framework-agnostic tile pipeline.  It sits
5//! between the abstract [`TileSource`] (which knows how to fetch tile
6//! bytes) and the renderer (which needs GPU-ready pixel data).  Every
7//! frame the owning [`TileLayer`] calls [`TileManager::update`],
8#![allow(clippy::too_many_lines)]
9//! which performs five steps:
10//!
11//! 1. **Poll** the source for tiles that have completed since the
12//!    previous frame.
13//! 2. **Compute** the desired tile set from `viewport_bounds` and
14//!    `zoom`, optionally refining flat perspective views with the
15//!    shared footprint-aware selection path.
16//! 3. **Cancel** pending requests that have scrolled out of the desired set.
17//! 4. **Build** the [`VisibleTileSet`] with parent-tile fallbacks for
18//!    tiles that are still loading or failed.
19//! 5. **Request** missing tiles in a deterministic priority order.
20//!
21//! ## Payload validation
22//!
23//! All completed tile payloads are validated before entering the cache.
24//! Malformed raster data (for example, invalid RGBA8 buffer length) is
25//! downgraded to a decode failure and recorded as a failed cache entry
26//! instead of poisoning the renderer with corrupt image metadata.
27//!
28//! Late responses that arrive after their pending cache entries were
29//! evicted are still accepted for successfully decoded tiles, but failed
30//! responses for already-evicted entries are ignored.
31//!
32//! ## Parent-tile fallback
33//!
34//! When a tile is not yet loaded, the manager walks up the zoom
35//! pyramid via [`TileId::parent`](rustial_math::TileId::parent) to
36//! find the nearest ancestor whose data is cached.  The renderer can
37//! then display that coarser tile until the full-resolution tile arrives.
38//!
39//! ## Data cloning
40//!
41//! [`TileData`] contains an `Arc<Vec<u8>>` of decoded RGBA8 pixels.
42//! The [`VisibleTileSet`] returned by `update()` clones tile data so that
43//! the caller owns it independently of the cache.  Because pixel buffers
44//! are wrapped in `Arc`, cloning is cheap.
45//!
46//! ## Request prioritisation
47//!
48//! Missing tiles are requested in a deterministic order:
49//!
50//! 1. **Coarser zooms first**
51//! 2. **Nearer tiles first**
52//!
53//! ## Active-use retention
54//!
55//! Tiles that are currently visible -- including loaded ancestors used
56//! as fallbacks -- are explicitly marked as recently used in the LRU
57//! cache.
58// ---------------------------------------------------------------------------
59
60use crate::tile_cache::{TileCache, TileCacheStats};
61use crate::tile_lifecycle::{TileLifecycleDiagnostics, TileLifecycleTracker};
62use crate::tile_source::{TileData, TileSource, TileSourceDiagnostics};
63use rustial_math::{
64    geo_to_tile, tile_bounds_world, FlatTileSelectionConfig, FlatTileView, GeoCoord, TileId,
65    WebMercator, WorldBounds,
66};
67use std::cmp::Ordering;
68use std::collections::HashSet;
69use std::time::SystemTime;
70
71/// Default visible-tile budget used by [`TileSelectionConfig`].
72const DEFAULT_VISIBLE_TILE_BUDGET: usize = 512;
73
74/// Maximum number of zoom levels to walk upward when searching for a
75/// loaded ancestor or bootstrapping parent-chain requests.  Matches
76/// MapLibre's practical parent-fallback depth.
77const MAX_ANCESTOR_DEPTH: u8 = 8;
78
79// ---------------------------------------------------------------------------
80// Tile selection policy
81// ---------------------------------------------------------------------------
82
83/// Engine-side policy controlling visible tile selection.
84#[derive(Debug, Clone, PartialEq)]
85pub struct TileSelectionConfig {
86    /// Maximum number of visible tiles the selector should emit.
87    pub visible_tile_budget: usize,
88    /// Footprint-aware flat-view tile selection policy.
89    pub flat_view: FlatTileSelectionConfig,
90    /// Minimum zoom level supported by the tile source.
91    ///
92    /// Tiles at zoom levels below this are never requested.  When the
93    /// camera zoom is below `source_min_zoom`, the manager does not
94    /// emit any visible tiles.  Defaults to 0.
95    pub source_min_zoom: u8,
96    /// Maximum zoom level supported by the tile source.
97    ///
98    /// When the camera zoom exceeds this value, tile requests are
99    /// clamped to `source_max_zoom` and the resulting visible tiles
100    /// are treated as overzoomed -- the `VisibleTile::target` records
101    /// the display-zoom tile ID while `VisibleTile::actual` records
102    /// the source-zoom tile ID.  Texture region mapping correctly
103    /// extracts the sub-tile rectangle for rendering.
104    ///
105    /// Defaults to 22 (the common Web Mercator maximum).
106    pub source_max_zoom: u8,
107    /// Duration in seconds over which a newly loaded tile fades from
108    /// fully transparent to fully opaque.
109    ///
110    /// Set to `0.0` to disable fade-in (tiles appear at full opacity
111    /// immediately).  Matches MapLibre's `rasterFadeDuration` concept.
112    /// Defaults to 0.0 (disabled); set to e.g. 0.3 for 300 ms fade.
113    pub raster_fade_duration: f32,
114    /// Maximum number of ancestor zoom-level tiles that may participate
115    /// in a cross-fade overlay while a child tile is fading in.
116    ///
117    /// Prevents excessive overlapping translucent tiles when many zoom
118    /// levels are simultaneously loading.  Defaults to 3.
119    pub max_fading_ancestor_levels: u8,
120    /// Maximum number of zoom levels to descend when searching for cached
121    /// child tiles to use as underzoom fallback.
122    ///
123    /// When the camera zooms out and a target tile is not yet loaded, the
124    /// manager checks whether higher-zoom children that are already cached
125    /// can cover the target's extent.  This is the inverse of the parent
126    /// fallback: instead of showing a blurry parent, we compose sharper
127    /// children.  Set to 0 to disable child fallback.  Defaults to 0
128    /// (disabled); set to e.g. 2 to enable.
129    pub max_child_depth: u8,
130    /// Maximum number of new tile requests the manager may issue in a
131    /// single `update` pass.
132    ///
133    /// This is set by the [`TileRequestCoordinator`](crate::TileRequestCoordinator)
134    /// to enforce a global cross-source request budget.  When `usize::MAX`,
135    /// the manager issues requests without limit (the default for
136    /// backwards compatibility and when coordination is disabled).
137    pub max_requests_per_frame: usize,
138}
139
140/// Speculative prefetch direction derived from camera zoom motion.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum ZoomPrefetchDirection {
143    /// Zooming in: prefetch children of centre-most current tiles.
144    In,
145    /// Zooming out: prefetch parents of the current tile set.
146    Out,
147}
148
149impl TileSelectionConfig {
150    /// Return the effective per-frame visible tile budget for a given cache.
151    #[inline]
152    pub fn effective_visible_tile_budget(&self, cache_capacity: usize) -> usize {
153        let policy_budget = self.visible_tile_budget.max(1);
154        let cache_budget = cache_capacity.saturating_sub(10).max(1);
155        policy_budget.min(cache_budget)
156    }
157}
158
159impl Default for TileSelectionConfig {
160    fn default() -> Self {
161        Self {
162            visible_tile_budget: DEFAULT_VISIBLE_TILE_BUDGET,
163            flat_view: FlatTileSelectionConfig::default(),
164            source_min_zoom: 0,
165            source_max_zoom: 22,
166            // Fade is off by default so that tests and headless pipelines
167            // get deterministic visible-tile counts.  Consumers should
168            // set this to e.g. 0.3 (300 ms) for smooth tile transitions.
169            raster_fade_duration: 0.0,
170            max_fading_ancestor_levels: 3,
171            // Child fallback enabled by default so cached higher-zoom
172            // tiles can cover lower-zoom targets during zoom-out, avoiding
173            // gaps while new tiles load.  Set to 0 to disable.
174            max_child_depth: 2,
175            // Unlimited by default -- the TileRequestCoordinator sets
176            // this field when cross-source coordination is active.
177            max_requests_per_frame: usize::MAX,
178        }
179    }
180}
181
182// ---------------------------------------------------------------------------
183// Texture mapping helpers
184// ---------------------------------------------------------------------------
185
186/// Normalized texture-space region within an `actual` tile image that maps to
187/// a `target` tile.
188#[derive(Debug, Clone, Copy, PartialEq)]
189pub struct TileTextureRegion {
190    /// Minimum U in normalized texture coordinates.
191    pub u_min: f32,
192    /// Minimum V in normalized texture coordinates.
193    pub v_min: f32,
194    /// Maximum U in normalized texture coordinates.
195    pub u_max: f32,
196    /// Maximum V in normalized texture coordinates.
197    pub v_max: f32,
198}
199
200impl TileTextureRegion {
201    /// Full texture coverage.
202    pub const FULL: Self = Self {
203        u_min: 0.0,
204        v_min: 0.0,
205        u_max: 1.0,
206        v_max: 1.0,
207    };
208
209    /// Compute the normalized texture-space region of `actual` needed to draw
210    /// `target`.
211    pub fn from_tiles(target: &TileId, actual: &TileId) -> Self {
212        if target.zoom <= actual.zoom || *target == *actual {
213            return Self::FULL;
214        }
215
216        let dz = target.zoom - actual.zoom;
217        let scale = 1u32 << dz;
218
219        if target.x / scale != actual.x || target.y / scale != actual.y {
220            return Self::FULL;
221        }
222
223        let offset_x = target.x - actual.x * scale;
224        let offset_y = target.y - actual.y * scale;
225        let inv = 1.0 / scale as f32;
226
227        Self {
228            u_min: offset_x as f32 * inv,
229            v_min: offset_y as f32 * inv,
230            u_max: (offset_x + 1) as f32 * inv,
231            v_max: (offset_y + 1) as f32 * inv,
232        }
233    }
234
235    /// Whether this region covers the full source texture.
236    #[inline]
237    pub fn is_full(&self) -> bool {
238        *self == Self::FULL
239    }
240
241    /// Compute the texture region for rendering a **child** tile (at a
242    /// higher zoom) into the grid cell of a **target** tile (at a lower
243    /// zoom).
244    ///
245    /// This is the inverse of [`from_tiles`](Self::from_tiles) which
246    /// handles the parent-fallback (overzoom) case.  For child-fallback
247    /// (underzoom), the child's full texture maps into a sub-region of the
248    /// target's grid cell.
249    ///
250    /// # Returns
251    ///
252    /// `None` if `child` is not a descendant of `target`.
253    pub fn from_child_tile(target: &TileId, child: &TileId) -> Option<Self> {
254        if child.zoom <= target.zoom {
255            return None;
256        }
257
258        let dz = child.zoom - target.zoom;
259        let scale = 1u32 << dz;
260
261        // Verify that the child is actually a descendant of the target.
262        if child.x / scale != target.x || child.y / scale != target.y {
263            return None;
264        }
265
266        let offset_x = child.x - target.x * scale;
267        let offset_y = child.y - target.y * scale;
268        let inv = 1.0 / scale as f32;
269
270        Some(Self {
271            u_min: offset_x as f32 * inv,
272            v_min: offset_y as f32 * inv,
273            u_max: (offset_x + 1) as f32 * inv,
274            v_max: (offset_y + 1) as f32 * inv,
275        })
276    }
277}
278
279/// Integer pixel-space crop rectangle derived from a fallback texture region.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct TilePixelRect {
282    /// Left pixel coordinate.
283    pub x: u32,
284    /// Top pixel coordinate.
285    pub y: u32,
286    /// Rectangle width in pixels.
287    pub width: u32,
288    /// Rectangle height in pixels.
289    pub height: u32,
290}
291
292impl TilePixelRect {
293    /// Full image coverage.
294    #[inline]
295    pub fn full(width: u32, height: u32) -> Self {
296        Self {
297            x: 0,
298            y: 0,
299            width,
300            height,
301        }
302    }
303
304    /// Compute the pixel crop rectangle of `actual` needed to draw `target`.
305    pub fn from_tiles(target: &TileId, actual: &TileId, width: u32, height: u32) -> Option<Self> {
306        if width == 0 || height == 0 {
307            return None;
308        }
309        if target.zoom <= actual.zoom || *target == *actual {
310            return Some(Self::full(width, height));
311        }
312
313        let dz = (target.zoom - actual.zoom) as u32;
314        let scale = 1u32.checked_shl(dz)?;
315
316        if target.x / scale != actual.x || target.y / scale != actual.y {
317            return None;
318        }
319
320        let crop_w = width / scale;
321        let crop_h = height / scale;
322        if crop_w == 0 || crop_h == 0 {
323            return None;
324        }
325
326        let offset_x = target.x.checked_sub(actual.x.checked_mul(scale)?)?;
327        let offset_y = target.y.checked_sub(actual.y.checked_mul(scale)?)?;
328        let x = offset_x.checked_mul(crop_w)?;
329        let y = offset_y.checked_mul(crop_h)?;
330
331        if x.checked_add(crop_w)? > width || y.checked_add(crop_h)? > height {
332            return None;
333        }
334
335        Some(Self {
336            x,
337            y,
338            width: crop_w,
339            height: crop_h,
340        })
341    }
342}
343
344// ---------------------------------------------------------------------------
345// Request priority helpers
346// ---------------------------------------------------------------------------
347
348#[derive(Debug, Clone, Copy)]
349enum RequestUrgency {
350    Coverage,
351    FallbackRefine,
352    Refresh,
353}
354
355impl RequestUrgency {
356    #[inline]
357    fn rank(self) -> u8 {
358        match self {
359            Self::Coverage => 0,
360            Self::FallbackRefine => 1,
361            Self::Refresh => 2,
362        }
363    }
364}
365
366#[derive(Debug, Clone, Copy)]
367struct RequestCandidate {
368    tile: TileId,
369    distance_sq: f64,
370    urgency: RequestUrgency,
371}
372
373impl RequestCandidate {
374    fn new(tile: TileId, camera_world: (f64, f64), urgency: RequestUrgency) -> Self {
375        let bounds = tile_bounds_world(&tile);
376        let center_x = (bounds.min.position.x + bounds.max.position.x) * 0.5;
377        let center_y = (bounds.min.position.y + bounds.max.position.y) * 0.5;
378        let dx = center_x - camera_world.0;
379        let dy = center_y - camera_world.1;
380        Self {
381            tile,
382            distance_sq: dx * dx + dy * dy,
383            urgency,
384        }
385    }
386}
387
388fn sort_request_candidates(candidates: &mut [RequestCandidate]) {
389    candidates.sort_by(|a, b| {
390        a.urgency
391            .rank()
392            .cmp(&b.urgency.rank())
393            .then_with(|| a.tile.zoom.cmp(&b.tile.zoom))
394            .then_with(|| {
395                a.distance_sq
396                    .partial_cmp(&b.distance_sq)
397                    .unwrap_or(Ordering::Equal)
398            })
399            .then_with(|| a.tile.y.cmp(&b.tile.y))
400            .then_with(|| a.tile.x.cmp(&b.tile.x))
401    });
402}
403
404fn desired_with_ancestor_retention<'a>(
405    desired: impl IntoIterator<Item = &'a TileId>,
406) -> HashSet<TileId> {
407    let tiles: Vec<TileId> = desired.into_iter().copied().collect();
408    let mut retained = HashSet::with_capacity(tiles.len() * 2);
409    for tile in tiles {
410        retained.insert(tile);
411        let mut current = tile;
412        let mut depth = 0u8;
413        while depth < MAX_ANCESTOR_DEPTH {
414            if let Some(parent) = current.parent() {
415                retained.insert(parent);
416                current = parent;
417                depth += 1;
418            } else {
419                break;
420            }
421        }
422    }
423    retained
424}
425
426fn desired_with_temporal_retention(
427    current_desired: &[TileId],
428    previous_desired: &HashSet<TileId>,
429) -> HashSet<TileId> {
430    desired_with_ancestor_retention(current_desired.iter().chain(previous_desired.iter()))
431}
432
433fn tile_contains(ancestor: TileId, tile: TileId) -> bool {
434    if tile.zoom < ancestor.zoom {
435        return false;
436    }
437
438    let dz = tile.zoom - ancestor.zoom;
439    if dz == 0 {
440        return tile == ancestor;
441    }
442
443    (tile.x >> dz) == ancestor.x && (tile.y >> dz) == ancestor.y
444}
445
446fn tile_at_zoom(tile: TileId, zoom: u8) -> TileId {
447    if zoom >= tile.zoom {
448        return tile;
449    }
450
451    let dz = tile.zoom - zoom;
452    TileId::new(zoom, tile.x >> dz, tile.y >> dz)
453}
454
455fn tiles_within_horizon(a: TileId, b: TileId, radius: u32) -> bool {
456    a.x.abs_diff(b.x) <= radius && a.y.abs_diff(b.y) <= radius
457}
458
459fn pending_tile_relevant_to_desired(tile: TileId, desired: &HashSet<TileId>) -> bool {
460    const DESCENDANT_RETENTION_DEPTH: u8 = 2;
461    const NEIGHBOR_RETENTION_RADIUS: u32 = 1;
462
463    desired.iter().copied().any(|desired_tile| {
464        if tile == desired_tile || tile_contains(tile, desired_tile) {
465            return true;
466        }
467
468        if tile.zoom > desired_tile.zoom
469            && tile.zoom - desired_tile.zoom <= DESCENDANT_RETENTION_DEPTH
470            && tile_contains(desired_tile, tile)
471        {
472            return true;
473        }
474
475        let common_zoom = tile.zoom.min(desired_tile.zoom);
476        let tile_common = tile_at_zoom(tile, common_zoom);
477        let desired_common = tile_at_zoom(desired_tile, common_zoom);
478        if !tiles_within_horizon(tile_common, desired_common, NEIGHBOR_RETENTION_RADIUS) {
479            return false;
480        }
481
482        tile.zoom <= desired_tile.zoom
483            || tile.zoom - desired_tile.zoom <= DESCENDANT_RETENTION_DEPTH
484    })
485}
486
487// ---------------------------------------------------------------------------
488// Route-aware prefetch helpers
489// ---------------------------------------------------------------------------
490
491/// Walk a geographic polyline and collect unique tile IDs at the given zoom
492/// level, ordered along the route starting from the segment closest to
493/// `camera_world`.
494///
495/// The route is sampled at roughly tile-width intervals so that every tile
496/// the polyline passes through is captured without excessive redundancy.
497/// Only the portion of the route **ahead** of the camera (further along
498/// the polyline from the nearest point) is returned.
499fn tiles_along_route(route: &[GeoCoord], zoom: u8, camera_world: (f64, f64)) -> Vec<TileId> {
500    if route.len() < 2 {
501        return Vec::new();
502    }
503
504    // --- 1. Find the route vertex closest to the camera ---
505    let mut best_seg = 0usize;
506    let mut best_dist_sq = f64::MAX;
507    for (i, coord) in route.iter().enumerate() {
508        let w = WebMercator::project_clamped(coord);
509        let dx = w.position.x - camera_world.0;
510        let dy = w.position.y - camera_world.1;
511        let d2 = dx * dx + dy * dy;
512        if d2 < best_dist_sq {
513            best_dist_sq = d2;
514            best_seg = i;
515        }
516    }
517
518    // --- 2. Walk forward from that vertex, sampling at tile-width steps ---
519    // Tile width in world-space meters at this zoom:
520    //   full_extent = 2 * WebMercator::max_extent()
521    //   tile_width  = full_extent / 2^zoom
522    let full_extent = 2.0 * WebMercator::max_extent();
523    let n_tiles = (1u64 << zoom) as f64;
524    let tile_width = full_extent / n_tiles;
525    // Sample at half-tile intervals to avoid missing narrow diagonal crossings.
526    let step = tile_width * 0.5;
527
528    let mut seen = HashSet::new();
529    let mut tiles = Vec::new();
530
531    // Walk segments starting from the closest vertex.
532    let start = best_seg.min(route.len().saturating_sub(2));
533    for seg in start..route.len().saturating_sub(1) {
534        let a = WebMercator::project_clamped(&route[seg]);
535        let b = WebMercator::project_clamped(&route[seg + 1]);
536        let dx = b.position.x - a.position.x;
537        let dy = b.position.y - a.position.y;
538        let seg_len = (dx * dx + dy * dy).sqrt();
539        if seg_len < 1e-9 {
540            continue;
541        }
542        let steps = (seg_len / step).ceil() as usize;
543        for s in 0..=steps {
544            let t = if steps == 0 {
545                0.0
546            } else {
547                (s as f64) / (steps as f64)
548            };
549            let px = a.position.x + dx * t;
550            let py = a.position.y + dy * t;
551            let geo = WebMercator::unproject(&rustial_math::WorldCoord::new(px, py, 0.0));
552            let tile = geo_to_tile(&geo, zoom).tile_id();
553            if seen.insert(tile) {
554                tiles.push(tile);
555            }
556        }
557    }
558
559    tiles
560}
561
562// ---------------------------------------------------------------------------
563// Overzoom helpers
564// ---------------------------------------------------------------------------
565
566/// Given a source tile at a clamped zoom and a higher display zoom, compute
567/// the set of display-zoom tile IDs that fall within the source tile's
568/// geographic extent.
569///
570/// For example, if `source_tile` is at zoom 14 and `display_zoom` is 16,
571/// this returns the 4 (= 2^(16-14) x 2^(16-14)) children at zoom 16.
572fn overzoomed_display_targets(source_tile: &TileId, display_zoom: u8) -> Vec<TileId> {
573    if display_zoom <= source_tile.zoom {
574        return vec![*source_tile];
575    }
576    let dz = display_zoom - source_tile.zoom;
577    let scale = 1u32 << dz;
578    let base_x = source_tile.x * scale;
579    let base_y = source_tile.y * scale;
580    let mut targets = Vec::with_capacity((scale * scale) as usize);
581    for dy in 0..scale {
582        for dx in 0..scale {
583            targets.push(TileId::new(display_zoom, base_x + dx, base_y + dy));
584        }
585    }
586    targets
587}
588
589// ---------------------------------------------------------------------------
590// Fade-in helpers
591// ---------------------------------------------------------------------------
592
593/// Compute the fade-in opacity for a tile that was loaded at `loaded_at`.
594///
595/// Returns `1.0` when fade is disabled (`fade_duration <= 0`) or the tile
596/// has been loaded long enough to be fully opaque.
597fn compute_fade_opacity(now: SystemTime, loaded_at: Option<SystemTime>, fade_duration: f32) -> f32 {
598    if fade_duration <= 0.0 {
599        return 1.0;
600    }
601    let Some(loaded) = loaded_at else {
602        return 1.0;
603    };
604    let elapsed = now.duration_since(loaded).unwrap_or_default().as_secs_f32();
605    (elapsed / fade_duration).clamp(0.0, 1.0)
606}
607
608/// Emit a cross-fade parent fallback tile into the visible set.
609///
610/// Walks up the tile ancestry (up to `max_levels` zoom levels) looking for
611/// a loaded ancestor in the cache.  If found, a `VisibleTile` is pushed
612/// with `fade_opacity` set to the complementary opacity of the fading
613/// child so that the blend is seamless.
614fn emit_crossfade_parent(
615    visible: &mut VisibleTileSet,
616    child_target: TileId,
617    parent_opacity: f32,
618    max_levels: u8,
619    cache: &mut TileCache,
620) {
621    let mut current = child_target;
622    let mut depth = 0u8;
623    while depth < max_levels {
624        if let Some(parent) = current.parent() {
625            let loaded = cache.get(&parent).and_then(|entry| entry.data()).cloned();
626            if let Some(data) = loaded {
627                cache.touch(&parent);
628                visible.tiles.push(VisibleTile {
629                    target: child_target,
630                    actual: parent,
631                    data: Some(data),
632                    fade_opacity: parent_opacity,
633                });
634                return;
635            }
636            current = parent;
637            depth += 1;
638        } else {
639            break;
640        }
641    }
642}
643
644// ---------------------------------------------------------------------------
645// Observability
646// ---------------------------------------------------------------------------
647
648/// Per-frame diagnostics for the most recent tile-selection/update pass.
649#[derive(Debug, Clone, Default, PartialEq, Eq)]
650pub struct TileSelectionStats {
651    /// Number of candidate tiles before budget capping.
652    pub raw_candidate_tiles: usize,
653    /// Number of visible tiles emitted for the frame.
654    pub visible_tiles: usize,
655    /// Number of visible tiles with exact imagery.
656    pub exact_visible_tiles: usize,
657    /// Number of visible tiles using fallback imagery.
658    pub fallback_visible_tiles: usize,
659    /// Number of visible tiles with no imagery available yet.
660    pub missing_visible_tiles: usize,
661    /// Number of visible tiles rendered as overzoomed (display zoom > source max zoom).
662    pub overzoomed_visible_tiles: usize,
663    /// Number of candidate tiles dropped due to the visible tile budget.
664    pub dropped_by_budget: usize,
665    /// Whether the visible tile budget was hit this frame.
666    pub budget_hit: bool,
667    /// Number of stale pending requests cancelled this frame.
668    pub cancelled_stale_pending: usize,
669    /// Number of new tile requests issued this frame.
670    pub requested_tiles: usize,
671    /// Number of speculative prefetch tile requests issued this frame.
672    pub speculative_requested_tiles: usize,
673    /// Number of cache hits on exact target tiles.
674    pub exact_cache_hits: usize,
675    /// Number of visible tiles satisfied by ancestor fallback.
676    pub fallback_hits: usize,
677    /// Number of desired tiles covered by cached child tiles (underzoom fallback).
678    pub child_fallback_hits: usize,
679    /// Number of individual child-fallback visible tiles emitted.
680    pub child_fallback_visible_tiles: usize,
681    /// Number of desired tiles that missed both exact and ancestor imagery.
682    pub cache_misses: usize,
683}
684
685/// Cumulative counters for long-running tile-manager diagnostics.
686#[derive(Debug, Clone, Default, PartialEq, Eq)]
687pub struct TileManagerCounters {
688    /// Total number of frames processed by the manager.
689    pub frames: u64,
690    /// Total number of times the visible tile budget was hit.
691    pub budget_hit_frames: u64,
692    /// Total number of candidate tiles dropped by the budget.
693    pub dropped_by_budget: u64,
694    /// Total number of exact visible-tile cache hits.
695    pub exact_cache_hits: u64,
696    /// Total number of visible fallback hits.
697    pub fallback_hits: u64,
698    /// Total number of visible child-fallback hits.
699    pub child_fallback_hits: u64,
700    /// Total number of visible cache misses.
701    pub cache_misses: u64,
702    /// Total number of requested tiles.
703    pub requested_tiles: u64,
704    /// Total number of speculative prefetch tile requests.
705    pub speculative_requested_tiles: u64,
706    /// Total number of stale pending requests cancelled.
707    pub cancelled_stale_pending: u64,
708    /// Total number of pending requests cancelled because their entries were evicted.
709    pub cancelled_evicted_pending: u64,
710}
711
712// ---------------------------------------------------------------------------
713// VisibleTileSet
714// ---------------------------------------------------------------------------
715
716/// The complete set of tiles that should be rendered for the current frame.
717#[derive(Debug, Default)]
718pub struct VisibleTileSet {
719    /// Tiles that should be displayed (loaded or fallback).
720    pub tiles: Vec<VisibleTile>,
721}
722
723impl VisibleTileSet {
724    /// Number of tiles in the set.
725    #[inline]
726    pub fn len(&self) -> usize {
727        self.tiles.len()
728    }
729
730    /// Whether the set is empty.
731    #[inline]
732    pub fn is_empty(&self) -> bool {
733        self.tiles.is_empty()
734    }
735
736    /// Number of tiles that currently have imagery available.
737    #[inline]
738    pub fn loaded_count(&self) -> usize {
739        self.tiles.iter().filter(|t| t.data.is_some()).count()
740    }
741
742    /// Iterate over the visible tiles.
743    #[inline]
744    pub fn iter(&self) -> std::slice::Iter<'_, VisibleTile> {
745        self.tiles.iter()
746    }
747}
748
749impl<'a> IntoIterator for &'a VisibleTileSet {
750    type Item = &'a VisibleTile;
751    type IntoIter = std::slice::Iter<'a, VisibleTile>;
752
753    fn into_iter(self) -> Self::IntoIter {
754        self.tiles.iter()
755    }
756}
757
758// ---------------------------------------------------------------------------
759// VisibleTile
760// ---------------------------------------------------------------------------
761
762/// A single visible tile, either the exact tile, a parent fallback, or a
763/// child fallback (underzoom).
764#[derive(Debug, Clone)]
765pub struct VisibleTile {
766    /// The tile ID that should be displayed at this position.
767    pub target: TileId,
768    /// The tile ID whose data is actually available.
769    pub actual: TileId,
770    /// Decoded pixel data for `actual`.
771    pub data: Option<TileData>,
772    /// Per-tile opacity for fade-in transitions (0.0 = fully transparent,
773    /// 1.0 = fully opaque).
774    ///
775    /// Renderers should multiply the tile fragment alpha by this value.
776    /// During cross-fade, parent fallback tiles are emitted with a
777    /// complementary opacity so the overall blending is seamless.
778    pub fade_opacity: f32,
779}
780
781impl VisibleTile {
782    /// Whether this tile has data available for rendering.
783    #[inline]
784    pub fn is_loaded(&self) -> bool {
785        self.data.is_some()
786    }
787
788    /// Whether this tile is using fallback imagery instead of the ideal tile.
789    #[inline]
790    pub fn is_fallback(&self) -> bool {
791        self.target != self.actual
792    }
793
794    /// Whether this tile is overzoomed -- i.e. the display zoom exceeds
795    /// the source's maximum zoom and the tile is being rendered from a
796    /// lower-zoom source tile.
797    #[inline]
798    pub fn is_overzoomed(&self) -> bool {
799        self.target.zoom > self.actual.zoom && self.data.is_some()
800    }
801
802    /// Whether this tile is using a higher-zoom child as underzoom fallback.
803    ///
804    /// True when the `actual` tile's zoom is greater than `target`'s zoom,
805    /// meaning a cached child tile is composited into a sub-region of the
806    /// target's grid cell while the target itself is still loading.
807    #[inline]
808    pub fn is_child_fallback(&self) -> bool {
809        self.actual.zoom > self.target.zoom && self.data.is_some()
810    }
811
812    /// Normalized texture-space region within `actual` that should be used
813    /// when rendering this visible tile.
814    ///
815    /// For parent fallback (overzoom), the returned region extracts the
816    /// relevant sub-tile from the lower-zoom parent texture.  For child
817    /// fallback (underzoom), the full child texture is used — the renderer
818    /// is responsible for placing the geometry at the child's grid-cell
819    /// bounds rather than the target's.
820    #[inline]
821    pub fn texture_region(&self) -> TileTextureRegion {
822        if self.actual.zoom > self.target.zoom {
823            // Child fallback: the child's entire texture is rendered into
824            // the child's own grid-cell bounds (a sub-region of the target).
825            TileTextureRegion::FULL
826        } else {
827            // Exact or parent fallback (overzoom).
828            TileTextureRegion::from_tiles(&self.target, &self.actual)
829        }
830    }
831
832    /// Pixel crop rectangle within an `actual` image of the given size that
833    /// should be used when rendering this visible tile.
834    #[inline]
835    pub fn pixel_crop_rect(&self, width: u32, height: u32) -> Option<TilePixelRect> {
836        TilePixelRect::from_tiles(&self.target, &self.actual, width, height)
837    }
838}
839
840// ---------------------------------------------------------------------------
841// TileManager
842// ---------------------------------------------------------------------------
843
844/// Orchestrates tile fetching, caching, and visible-set computation.
845pub struct TileManager {
846    source: Box<dyn TileSource>,
847    cache: TileCache,
848    lifecycle: TileLifecycleTracker,
849    selection_config: TileSelectionConfig,
850    last_selection_stats: TileSelectionStats,
851    counters: TileManagerCounters,
852    last_desired_tiles: HashSet<TileId>,
853}
854
855impl std::fmt::Debug for TileManager {
856    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
857        f.debug_struct("TileManager")
858            .field("cache_len", &self.cache.len())
859            .field("cache_capacity", &self.cache.capacity())
860            .finish_non_exhaustive()
861    }
862}
863
864impl TileManager {
865    /// Create a new tile manager with the given source and cache capacity.
866    pub fn new(source: Box<dyn TileSource>, cache_capacity: usize) -> Self {
867        Self::new_with_config(source, cache_capacity, TileSelectionConfig::default())
868    }
869
870    /// Create a new tile manager with an explicit tile-selection policy.
871    pub fn new_with_config(
872        source: Box<dyn TileSource>,
873        cache_capacity: usize,
874        selection_config: TileSelectionConfig,
875    ) -> Self {
876        Self {
877            source,
878            cache: TileCache::new(cache_capacity),
879            lifecycle: TileLifecycleTracker::default(),
880            selection_config,
881            last_selection_stats: TileSelectionStats::default(),
882            counters: TileManagerCounters::default(),
883            last_desired_tiles: HashSet::new(),
884        }
885    }
886
887    /// Update the tile manager for the current frame.
888    pub fn update(
889        &mut self,
890        viewport_bounds: &WorldBounds,
891        zoom: u8,
892        camera_world: (f64, f64),
893        camera_distance: f64,
894    ) -> VisibleTileSet {
895        self.update_with_view(viewport_bounds, zoom, camera_world, camera_distance, None)
896    }
897
898    /// Update the tile manager using frustum-based quadtree traversal.
899    ///
900    /// This is the MapLibre-equivalent covering-tiles path. Instead of
901    /// enumerating a rectangular AABB tile range and then filtering against
902    /// a sampled ground footprint, this performs a depth-first quadtree
903    /// descent from zoom 0, pruning any subtree whose world-space AABB
904    /// does not intersect the camera frustum.
905    pub fn update_with_frustum(
906        &mut self,
907        frustum: &rustial_math::Frustum,
908        zoom: u8,
909        camera_world: (f64, f64),
910    ) -> VisibleTileSet {
911        self.begin_lifecycle_frame();
912        self.poll_completed();
913
914        let mut stats = TileSelectionStats::default();
915        let max_tiles = self
916            .selection_config
917            .effective_visible_tile_budget(self.cache.capacity());
918
919        let desired = rustial_math::visible_tiles_frustum(frustum, zoom, max_tiles, camera_world);
920        self.last_desired_tiles = desired.iter().copied().collect();
921
922        stats.raw_candidate_tiles = desired.len();
923
924        let desired_set = desired_with_ancestor_retention(&desired);
925        stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
926
927        let now = SystemTime::now();
928        for id in self.cache.expired_ids_at(now) {
929            let _ = self.cache.mark_expired(id);
930        }
931
932        let mut visible = VisibleTileSet {
933            tiles: Vec::with_capacity(desired.len()),
934        };
935        let mut missing = Vec::new();
936        let mut refresh = Vec::new();
937        let mut bootstrap = Vec::new();
938        let fade_duration = self.selection_config.raster_fade_duration;
939        let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
940        let max_child_depth = self.selection_config.max_child_depth;
941
942        for &target in &desired {
943            self.lifecycle.record_selected(target);
944            let cached = self.cache.get(&target).map(|entry| {
945                (
946                    entry.data().cloned(),
947                    entry
948                        .freshness()
949                        .is_some_and(|freshness| freshness.is_expired_at(now)),
950                    entry.is_reloading(),
951                    entry.loaded_at(),
952                    entry.is_pending(),
953                )
954            });
955
956            match cached {
957                Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
958                    self.cache.touch(&target);
959                    if is_expired && !is_reloading && self.cache.start_reload(target) {
960                        refresh.push(RequestCandidate::new(
961                            target,
962                            camera_world,
963                            RequestUrgency::Refresh,
964                        ));
965                    }
966                    stats.exact_cache_hits += 1;
967                    stats.exact_visible_tiles += 1;
968
969                    let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
970                    if fade_opacity < 1.0 {
971                        emit_crossfade_parent(
972                            &mut visible,
973                            target,
974                            1.0 - fade_opacity,
975                            max_ancestor_fade,
976                            &mut self.cache,
977                        );
978                    }
979
980                    visible.tiles.push(VisibleTile {
981                        target,
982                        actual: target,
983                        data: Some(data),
984                        fade_opacity,
985                    });
986                    self.record_visible_tile_use(target, target, true);
987                }
988                Some((None, _, _, _, is_pending)) => {
989                    self.cache.touch(&target);
990                    // Try child fallback first (sharper), then ancestor.
991                    let children = self.find_loaded_children(&target, max_child_depth);
992                    if !children.is_empty() {
993                        stats.child_fallback_hits += 1;
994                        for (child_id, child_data) in children {
995                            stats.child_fallback_visible_tiles += 1;
996                            visible.tiles.push(VisibleTile {
997                                target,
998                                actual: child_id,
999                                data: Some(child_data),
1000                                fade_opacity: 1.0,
1001                            });
1002                            self.record_visible_tile_use(target, child_id, true);
1003                        }
1004                    } else {
1005                        let (actual, data) = self.find_loaded_ancestor(&target);
1006                        if data.is_some() && actual != target {
1007                            stats.fallback_hits += 1;
1008                            stats.fallback_visible_tiles += 1;
1009                        } else if data.is_none() {
1010                            stats.cache_misses += 1;
1011                            stats.missing_visible_tiles += 1;
1012                            bootstrap.push(target);
1013                        }
1014                        visible.tiles.push(VisibleTile {
1015                            target,
1016                            actual,
1017                            data,
1018                            fade_opacity: 1.0,
1019                        });
1020                        self.record_visible_tile_use(
1021                            target,
1022                            actual,
1023                            visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1024                        );
1025                    }
1026                    // Failed tiles with no retained payload must be
1027                    // re-requested; genuinely pending tiles are already
1028                    // in-flight and just need to wait.
1029                    if !is_pending {
1030                        let urgency =
1031                            if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1032                                RequestUrgency::Coverage
1033                            } else {
1034                                RequestUrgency::FallbackRefine
1035                            };
1036                        self.cache.remove(&target);
1037                        missing.push(RequestCandidate::new(target, camera_world, urgency));
1038                    }
1039                }
1040                None => {
1041                    // Try child fallback first, then ancestor.
1042                    let children = self.find_loaded_children(&target, max_child_depth);
1043                    if !children.is_empty() {
1044                        stats.child_fallback_hits += 1;
1045                        for (child_id, child_data) in children {
1046                            stats.child_fallback_visible_tiles += 1;
1047                            visible.tiles.push(VisibleTile {
1048                                target,
1049                                actual: child_id,
1050                                data: Some(child_data),
1051                                fade_opacity: 1.0,
1052                            });
1053                            self.record_visible_tile_use(target, child_id, true);
1054                        }
1055                    } else {
1056                        let (actual, data) = self.find_loaded_ancestor(&target);
1057                        if data.is_some() && actual != target {
1058                            stats.fallback_hits += 1;
1059                            stats.fallback_visible_tiles += 1;
1060                        } else if data.is_none() {
1061                            stats.cache_misses += 1;
1062                            stats.missing_visible_tiles += 1;
1063                            bootstrap.push(target);
1064                        }
1065                        visible.tiles.push(VisibleTile {
1066                            target,
1067                            actual,
1068                            data,
1069                            fade_opacity: 1.0,
1070                        });
1071                        self.record_visible_tile_use(
1072                            target,
1073                            actual,
1074                            visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1075                        );
1076                    }
1077                    let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1078                        RequestUrgency::Coverage
1079                    } else {
1080                        RequestUrgency::FallbackRefine
1081                    };
1082                    missing.push(RequestCandidate::new(target, camera_world, urgency));
1083                }
1084            }
1085        }
1086
1087        let (requested, cancelled_evicted_pending) =
1088            self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
1089
1090        stats.requested_tiles = requested.len();
1091        stats.visible_tiles = visible.tiles.len();
1092
1093        self.counters.frames += 1;
1094        if stats.budget_hit {
1095            self.counters.budget_hit_frames += 1;
1096        }
1097        self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
1098        self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
1099        self.counters.fallback_hits += stats.fallback_hits as u64;
1100        self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
1101        self.counters.cache_misses += stats.cache_misses as u64;
1102        self.counters.requested_tiles += stats.requested_tiles as u64;
1103        self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
1104        self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
1105        self.last_selection_stats = stats;
1106
1107        visible
1108    }
1109
1110    /// Update the tile manager using MapLibre-equivalent covering-tiles
1111    /// quadtree traversal with per-tile variable zoom heuristics.
1112    ///
1113    /// This is the preferred path for perspective cameras at steep pitch.
1114    /// It performs frustum-culled depth-first traversal where distant tiles
1115    /// may use lower zoom levels than near tiles, matching MapLibre's
1116    /// `coveringTiles()` behaviour.
1117    pub fn update_with_covering(
1118        &mut self,
1119        frustum: &rustial_math::Frustum,
1120        cam: &rustial_math::CoveringCamera,
1121        opts: &rustial_math::CoveringTilesOptions,
1122        camera_world: (f64, f64),
1123    ) -> VisibleTileSet {
1124        self.begin_lifecycle_frame();
1125        self.poll_completed();
1126
1127        let mut stats = TileSelectionStats::default();
1128        let max_tiles = self
1129            .selection_config
1130            .effective_visible_tile_budget(self.cache.capacity())
1131            .min(opts.max_tiles);
1132
1133        let effective_opts = rustial_math::CoveringTilesOptions {
1134            max_tiles,
1135            ..opts.clone()
1136        };
1137        let desired = rustial_math::visible_tiles_covering(frustum, cam, &effective_opts);
1138        let previous_desired = self.last_desired_tiles.clone();
1139
1140        stats.raw_candidate_tiles = desired.len();
1141
1142        let desired_set = desired_with_temporal_retention(&desired, &previous_desired);
1143        stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
1144        self.last_desired_tiles = desired.iter().copied().collect();
1145
1146        let now = SystemTime::now();
1147        for id in self.cache.expired_ids_at(now) {
1148            let _ = self.cache.mark_expired(id);
1149        }
1150
1151        let mut visible = VisibleTileSet {
1152            tiles: Vec::with_capacity(desired.len()),
1153        };
1154        let mut missing = Vec::new();
1155        let mut refresh = Vec::new();
1156        let fade_duration = self.selection_config.raster_fade_duration;
1157        let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
1158        let max_child_depth = self.selection_config.max_child_depth;
1159
1160        let mut bootstrap = Vec::new();
1161
1162        for &target in &desired {
1163            self.lifecycle.record_selected(target);
1164            let cached = self.cache.get(&target).map(|entry| {
1165                (
1166                    entry.data().cloned(),
1167                    entry
1168                        .freshness()
1169                        .is_some_and(|freshness| freshness.is_expired_at(now)),
1170                    entry.is_reloading(),
1171                    entry.loaded_at(),
1172                    entry.is_pending(),
1173                )
1174            });
1175
1176            match cached {
1177                Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
1178                    self.cache.touch(&target);
1179                    if is_expired && !is_reloading && self.cache.start_reload(target) {
1180                        refresh.push(RequestCandidate::new(
1181                            target,
1182                            camera_world,
1183                            RequestUrgency::Refresh,
1184                        ));
1185                    }
1186                    stats.exact_cache_hits += 1;
1187                    stats.exact_visible_tiles += 1;
1188
1189                    let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
1190                    if fade_opacity < 1.0 {
1191                        emit_crossfade_parent(
1192                            &mut visible,
1193                            target,
1194                            1.0 - fade_opacity,
1195                            max_ancestor_fade,
1196                            &mut self.cache,
1197                        );
1198                    }
1199
1200                    visible.tiles.push(VisibleTile {
1201                        target,
1202                        actual: target,
1203                        data: Some(data),
1204                        fade_opacity,
1205                    });
1206                    self.record_visible_tile_use(target, target, true);
1207                }
1208                Some((None, _, _, _, is_pending)) => {
1209                    self.cache.touch(&target);
1210                    // Try child fallback first (sharper), then ancestor.
1211                    let children = self.find_loaded_children(&target, max_child_depth);
1212                    if !children.is_empty() {
1213                        stats.child_fallback_hits += 1;
1214                        for (child_id, child_data) in children {
1215                            stats.child_fallback_visible_tiles += 1;
1216                            visible.tiles.push(VisibleTile {
1217                                target,
1218                                actual: child_id,
1219                                data: Some(child_data),
1220                                fade_opacity: 1.0,
1221                            });
1222                            self.record_visible_tile_use(target, child_id, true);
1223                        }
1224                    } else {
1225                        let (actual, data) = self.find_loaded_ancestor(&target);
1226                        if data.is_some() && actual != target {
1227                            stats.fallback_hits += 1;
1228                            stats.fallback_visible_tiles += 1;
1229                        } else if data.is_none() {
1230                            stats.cache_misses += 1;
1231                            stats.missing_visible_tiles += 1;
1232                            bootstrap.push(target);
1233                        }
1234                        visible.tiles.push(VisibleTile {
1235                            target,
1236                            actual,
1237                            data,
1238                            fade_opacity: 1.0,
1239                        });
1240                        self.record_visible_tile_use(
1241                            target,
1242                            actual,
1243                            visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1244                        );
1245                    }
1246                    // Failed tiles with no retained payload must be
1247                    // re-requested; genuinely pending tiles are already
1248                    // in-flight and just need to wait.
1249                    if !is_pending {
1250                        let urgency =
1251                            if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1252                                RequestUrgency::Coverage
1253                            } else {
1254                                RequestUrgency::FallbackRefine
1255                            };
1256                        self.cache.remove(&target);
1257                        missing.push(RequestCandidate::new(target, camera_world, urgency));
1258                    }
1259                }
1260                None => {
1261                    // Try child fallback first, then ancestor.
1262                    let children = self.find_loaded_children(&target, max_child_depth);
1263                    if !children.is_empty() {
1264                        stats.child_fallback_hits += 1;
1265                        for (child_id, child_data) in children {
1266                            stats.child_fallback_visible_tiles += 1;
1267                            visible.tiles.push(VisibleTile {
1268                                target,
1269                                actual: child_id,
1270                                data: Some(child_data),
1271                                fade_opacity: 1.0,
1272                            });
1273                            self.record_visible_tile_use(target, child_id, true);
1274                        }
1275                    } else {
1276                        let (actual, data) = self.find_loaded_ancestor(&target);
1277                        if data.is_some() && actual != target {
1278                            stats.fallback_hits += 1;
1279                            stats.fallback_visible_tiles += 1;
1280                        } else if data.is_none() {
1281                            stats.cache_misses += 1;
1282                            stats.missing_visible_tiles += 1;
1283                            bootstrap.push(target);
1284                        }
1285                        visible.tiles.push(VisibleTile {
1286                            target,
1287                            actual,
1288                            data,
1289                            fade_opacity: 1.0,
1290                        });
1291                        self.record_visible_tile_use(
1292                            target,
1293                            actual,
1294                            visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1295                        );
1296                    }
1297                    let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1298                        RequestUrgency::Coverage
1299                    } else {
1300                        RequestUrgency::FallbackRefine
1301                    };
1302                    missing.push(RequestCandidate::new(target, camera_world, urgency));
1303                }
1304            }
1305        }
1306
1307        let (requested, cancelled_evicted_pending) =
1308            self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
1309
1310        stats.requested_tiles = requested.len();
1311        stats.visible_tiles = visible.tiles.len();
1312
1313        self.counters.frames += 1;
1314        if stats.budget_hit {
1315            self.counters.budget_hit_frames += 1;
1316        }
1317        self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
1318        self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
1319        self.counters.fallback_hits += stats.fallback_hits as u64;
1320        self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
1321        self.counters.cache_misses += stats.cache_misses as u64;
1322        self.counters.requested_tiles += stats.requested_tiles as u64;
1323        self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
1324        self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
1325        self.last_selection_stats = stats;
1326
1327        visible
1328    }
1329
1330    /// Update the tile manager with optional shared flat-view selection
1331    /// parameters for pitched perspective raster rendering.
1332    pub fn update_with_view(
1333        &mut self,
1334        viewport_bounds: &WorldBounds,
1335        zoom: u8,
1336        camera_world: (f64, f64),
1337        _camera_distance: f64,
1338        flat_view: Option<&FlatTileView>,
1339    ) -> VisibleTileSet {
1340        self.begin_lifecycle_frame();
1341        self.poll_completed();
1342
1343        // Source zoom range enforcement (MapLibre OverscaledTileID equivalent).
1344        if zoom < self.selection_config.source_min_zoom {
1345            self.last_desired_tiles.clear();
1346            self.last_selection_stats = TileSelectionStats::default();
1347            return VisibleTileSet::default();
1348        }
1349
1350        let source_zoom = zoom.min(self.selection_config.source_max_zoom);
1351        let is_overzoomed = zoom > source_zoom;
1352
1353        let mut stats = TileSelectionStats::default();
1354        let max_tiles = self
1355            .selection_config
1356            .effective_visible_tile_budget(self.cache.capacity());
1357
1358        // Select tiles at the source zoom (clamped), not the display zoom.
1359        let mut source_tiles = if let Some(view) = flat_view {
1360            rustial_math::visible_tiles_flat_view_capped_with_config(
1361                viewport_bounds,
1362                source_zoom,
1363                view,
1364                &self.selection_config.flat_view,
1365                max_tiles,
1366            )
1367        } else {
1368            rustial_math::visible_tiles(viewport_bounds, source_zoom)
1369        };
1370
1371        stats.raw_candidate_tiles = source_tiles.len();
1372        if source_tiles.len() > max_tiles {
1373            stats.budget_hit = true;
1374            stats.dropped_by_budget = stats.raw_candidate_tiles - max_tiles;
1375            source_tiles.truncate(max_tiles);
1376        }
1377        let previous_desired = self.last_desired_tiles.clone();
1378
1379        // Build the desired set at source zoom for cache/pending tracking.
1380        // Include ancestors so that parent-chain bootstrap requests are not
1381        // immediately cancelled on the next frame before they can complete.
1382        // Also retain the immediately previous desired set so small pans,
1383        // yaw changes, and zoom transitions do not discard still-useful
1384        // in-flight work before the new frame has a chance to converge.
1385        let desired_set = desired_with_temporal_retention(&source_tiles, &previous_desired);
1386        stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
1387        self.last_desired_tiles = source_tiles.iter().copied().collect();
1388
1389        let now = SystemTime::now();
1390        for id in self.cache.expired_ids_at(now) {
1391            let _ = self.cache.mark_expired(id);
1392        }
1393
1394        let mut visible = VisibleTileSet {
1395            tiles: Vec::with_capacity(source_tiles.len()),
1396        };
1397        let mut missing = Vec::new();
1398        let mut refresh = Vec::new();
1399        let mut bootstrap = Vec::new();
1400        let fade_duration = self.selection_config.raster_fade_duration;
1401        let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
1402        let max_child_depth = self.selection_config.max_child_depth;
1403
1404        for &source_tile in &source_tiles {
1405            self.lifecycle.record_selected(source_tile);
1406            // When overzoomed, derive display-zoom target tiles from the
1407            // source tile so `VisibleTile::target` records the display
1408            // position and `VisibleTile::actual` records the fetched tile.
1409            let display_targets = if is_overzoomed {
1410                overzoomed_display_targets(&source_tile, zoom)
1411            } else {
1412                vec![source_tile]
1413            };
1414
1415            let cached = self.cache.get(&source_tile).map(|entry| {
1416                (
1417                    entry.data().cloned(),
1418                    entry
1419                        .freshness()
1420                        .is_some_and(|freshness| freshness.is_expired_at(now)),
1421                    entry.is_reloading(),
1422                    entry.loaded_at(),
1423                    entry.is_pending(),
1424                )
1425            });
1426
1427            match cached {
1428                Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
1429                    self.cache.touch(&source_tile);
1430                    if is_expired && !is_reloading && self.cache.start_reload(source_tile) {
1431                        refresh.push(RequestCandidate::new(
1432                            source_tile,
1433                            camera_world,
1434                            RequestUrgency::Refresh,
1435                        ));
1436                    }
1437                    stats.exact_cache_hits += 1;
1438
1439                    let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
1440
1441                    for target in display_targets {
1442                        if is_overzoomed {
1443                            stats.overzoomed_visible_tiles += 1;
1444                        } else {
1445                            stats.exact_visible_tiles += 1;
1446                        }
1447                        if fade_opacity < 1.0 {
1448                            emit_crossfade_parent(
1449                                &mut visible,
1450                                target,
1451                                1.0 - fade_opacity,
1452                                max_ancestor_fade,
1453                                &mut self.cache,
1454                            );
1455                        }
1456                        visible.tiles.push(VisibleTile {
1457                            target,
1458                            actual: source_tile,
1459                            data: Some(data.clone()),
1460                            fade_opacity,
1461                        });
1462                        self.record_visible_tile_use(target, source_tile, true);
1463                    }
1464                }
1465                Some((None, _, _, _, is_pending)) => {
1466                    self.cache.touch(&source_tile);
1467                    // Try child fallback when not overzoomed (children
1468                    // beyond source_max_zoom don't exist in the source).
1469                    let children = if !is_overzoomed {
1470                        self.find_loaded_children(&source_tile, max_child_depth)
1471                    } else {
1472                        Vec::new()
1473                    };
1474                    if !children.is_empty() {
1475                        stats.child_fallback_hits += 1;
1476                        for (child_id, child_data) in children {
1477                            for target in &display_targets {
1478                                stats.child_fallback_visible_tiles += 1;
1479                                visible.tiles.push(VisibleTile {
1480                                    target: *target,
1481                                    actual: child_id,
1482                                    data: Some(child_data.clone()),
1483                                    fade_opacity: 1.0,
1484                                });
1485                                self.record_visible_tile_use(*target, child_id, true);
1486                            }
1487                        }
1488                    } else {
1489                        let (actual, data) = self.find_loaded_ancestor(&source_tile);
1490                        if data.is_some() && actual != source_tile {
1491                            stats.fallback_hits += 1;
1492                        } else if data.is_none() {
1493                            stats.cache_misses += 1;
1494                            bootstrap.push(source_tile);
1495                        }
1496                        for target in display_targets {
1497                            if data.is_some() && actual != source_tile {
1498                                stats.fallback_visible_tiles += 1;
1499                            } else if data.is_none() {
1500                                stats.missing_visible_tiles += 1;
1501                            }
1502                            visible.tiles.push(VisibleTile {
1503                                target,
1504                                actual,
1505                                data: data.clone(),
1506                                fade_opacity: 1.0,
1507                            });
1508                            self.record_visible_tile_use(
1509                                target,
1510                                actual,
1511                                visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1512                            );
1513                        }
1514                    }
1515                    // Failed tiles with no retained payload must be
1516                    // re-requested; genuinely pending tiles are already
1517                    // in-flight and just need to wait.
1518                    if !is_pending {
1519                        let urgency =
1520                            if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1521                                RequestUrgency::Coverage
1522                            } else {
1523                                RequestUrgency::FallbackRefine
1524                            };
1525                        self.cache.remove(&source_tile);
1526                        missing.push(RequestCandidate::new(source_tile, camera_world, urgency));
1527                    }
1528                }
1529                None => {
1530                    // Try child fallback when not overzoomed, then ancestor.
1531                    let children = if !is_overzoomed {
1532                        self.find_loaded_children(&source_tile, max_child_depth)
1533                    } else {
1534                        Vec::new()
1535                    };
1536                    if !children.is_empty() {
1537                        stats.child_fallback_hits += 1;
1538                        for (child_id, child_data) in children {
1539                            for target in &display_targets {
1540                                stats.child_fallback_visible_tiles += 1;
1541                                visible.tiles.push(VisibleTile {
1542                                    target: *target,
1543                                    actual: child_id,
1544                                    data: Some(child_data.clone()),
1545                                    fade_opacity: 1.0,
1546                                });
1547                                self.record_visible_tile_use(*target, child_id, true);
1548                            }
1549                        }
1550                    } else {
1551                        let (actual, data) = self.find_loaded_ancestor(&source_tile);
1552                        if data.is_some() && actual != source_tile {
1553                            stats.fallback_hits += 1;
1554                        } else if data.is_none() {
1555                            stats.cache_misses += 1;
1556                            bootstrap.push(source_tile);
1557                        }
1558                        for target in display_targets {
1559                            if data.is_some() && actual != source_tile {
1560                                stats.fallback_visible_tiles += 1;
1561                            } else if data.is_none() {
1562                                stats.missing_visible_tiles += 1;
1563                            }
1564                            visible.tiles.push(VisibleTile {
1565                                target,
1566                                actual,
1567                                data: data.clone(),
1568                                fade_opacity: 1.0,
1569                            });
1570                            self.record_visible_tile_use(
1571                                target,
1572                                actual,
1573                                visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1574                            );
1575                        }
1576                    }
1577                    let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1578                        RequestUrgency::Coverage
1579                    } else {
1580                        RequestUrgency::FallbackRefine
1581                    };
1582                    missing.push(RequestCandidate::new(source_tile, camera_world, urgency));
1583                }
1584            }
1585        }
1586
1587        let (requested, cancelled_evicted_pending) =
1588            self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
1589
1590        stats.requested_tiles = requested.len();
1591        stats.visible_tiles = visible.tiles.len();
1592
1593        self.counters.frames += 1;
1594        if stats.budget_hit {
1595            self.counters.budget_hit_frames += 1;
1596        }
1597        self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
1598        self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
1599        self.counters.fallback_hits += stats.fallback_hits as u64;
1600        self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
1601        self.counters.cache_misses += stats.cache_misses as u64;
1602        self.counters.requested_tiles += stats.requested_tiles as u64;
1603        self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
1604        self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
1605        self.last_selection_stats = stats;
1606
1607        visible
1608    }
1609
1610    #[inline]
1611    /// Read-only access to the most recent tile-selection/update stats.
1612    pub fn last_selection_stats(&self) -> &TileSelectionStats {
1613        &self.last_selection_stats
1614    }
1615
1616    #[inline]
1617    /// Read-only access to the last desired source-tile set used for request selection.
1618    pub fn desired_tiles(&self) -> &HashSet<TileId> {
1619        &self.last_desired_tiles
1620    }
1621
1622    #[inline]
1623    /// Read-only access to cumulative tile-manager counters.
1624    pub fn counters(&self) -> &TileManagerCounters {
1625        &self.counters
1626    }
1627
1628    #[inline]
1629    /// Read-only access to the current tile-selection policy.
1630    pub fn selection_config(&self) -> &TileSelectionConfig {
1631        &self.selection_config
1632    }
1633
1634    #[inline]
1635    /// Replace the tile-selection policy used by future updates.
1636    pub fn set_selection_config(&mut self, config: TileSelectionConfig) {
1637        self.selection_config = config;
1638    }
1639
1640    #[inline]
1641    /// Read-only access to the cache.
1642    pub fn cache(&self) -> &TileCache {
1643        &self.cache
1644    }
1645
1646    #[inline]
1647    /// Snapshot counts of the current tile-cache state.
1648    pub fn cache_stats(&self) -> TileCacheStats {
1649        self.cache.stats()
1650    }
1651
1652    #[inline]
1653    /// Optional runtime diagnostics from the underlying tile source.
1654    pub fn source_diagnostics(&self) -> Option<TileSourceDiagnostics> {
1655        self.source.diagnostics()
1656    }
1657
1658    #[inline]
1659    /// Snapshot of recent tile lifecycle diagnostics.
1660    pub fn lifecycle_diagnostics(&self) -> TileLifecycleDiagnostics {
1661        self.lifecycle.diagnostics()
1662    }
1663
1664    #[inline]
1665    /// The number of tiles currently cached in any state.
1666    pub fn cached_count(&self) -> usize {
1667        self.cache.len()
1668    }
1669
1670    /// Speculatively prefetch tiles for a predicted viewport without affecting
1671    /// the visible tile set.
1672    ///
1673    /// Only tiles outside the current frame's `last_desired_tiles` are
1674    /// requested, and existing cached or pending entries are skipped.
1675    pub fn prefetch_with_view(
1676        &mut self,
1677        viewport_bounds: &WorldBounds,
1678        zoom: u8,
1679        camera_world: (f64, f64),
1680        flat_view: Option<&FlatTileView>,
1681        max_requests: usize,
1682    ) -> usize {
1683        if max_requests == 0 || zoom < self.selection_config.source_min_zoom {
1684            return 0;
1685        }
1686
1687        let source_zoom = zoom.min(self.selection_config.source_max_zoom);
1688        let max_tiles = self
1689            .selection_config
1690            .effective_visible_tile_budget(self.cache.capacity());
1691
1692        let mut predicted_tiles = if let Some(view) = flat_view {
1693            rustial_math::visible_tiles_flat_view_capped_with_config(
1694                viewport_bounds,
1695                source_zoom,
1696                view,
1697                &self.selection_config.flat_view,
1698                max_tiles,
1699            )
1700        } else {
1701            rustial_math::visible_tiles(viewport_bounds, source_zoom)
1702        };
1703        if predicted_tiles.len() > max_tiles {
1704            predicted_tiles.truncate(max_tiles);
1705        }
1706
1707        self.prefetch_tiles(predicted_tiles, camera_world, max_requests)
1708    }
1709
1710    /// Speculatively prefetch tiles implied by the current desired set and a
1711    /// zoom direction.
1712    pub fn prefetch_zoom_direction(
1713        &mut self,
1714        camera_world: (f64, f64),
1715        direction: ZoomPrefetchDirection,
1716        max_requests: usize,
1717    ) -> usize {
1718        if max_requests == 0 || self.last_desired_tiles.is_empty() {
1719            return 0;
1720        }
1721
1722        let mut anchors: Vec<RequestCandidate> = self
1723            .last_desired_tiles
1724            .iter()
1725            .copied()
1726            .map(|tile| RequestCandidate::new(tile, camera_world, RequestUrgency::Refresh))
1727            .collect();
1728        sort_request_candidates(&mut anchors);
1729
1730        let mut tiles = Vec::new();
1731        let mut seen = HashSet::new();
1732
1733        for anchor in anchors {
1734            match direction {
1735                ZoomPrefetchDirection::In => {
1736                    if anchor.tile.zoom >= self.selection_config.source_max_zoom {
1737                        continue;
1738                    }
1739                    for child in anchor.tile.children() {
1740                        if seen.insert(child) {
1741                            tiles.push(child);
1742                        }
1743                    }
1744                }
1745                ZoomPrefetchDirection::Out => {
1746                    let Some(parent) = anchor.tile.parent() else {
1747                        continue;
1748                    };
1749                    if parent.zoom < self.selection_config.source_min_zoom {
1750                        continue;
1751                    }
1752                    if seen.insert(parent) {
1753                        tiles.push(parent);
1754                    }
1755                }
1756            }
1757        }
1758
1759        self.prefetch_tiles(tiles, camera_world, max_requests)
1760    }
1761
1762    /// Speculatively prefetch tiles along a geographic route polyline.
1763    ///
1764    /// The route is walked from the segment nearest to the camera position
1765    /// forward, sampling tile boundaries at the given zoom level.  Only
1766    /// tiles that are **ahead** of the camera (further along the route) and
1767    /// not already in the current desired set or cache are requested.
1768    ///
1769    /// Returns the number of new tile requests issued.
1770    pub fn prefetch_route(
1771        &mut self,
1772        route: &[GeoCoord],
1773        zoom: u8,
1774        camera_world: (f64, f64),
1775        max_requests: usize,
1776    ) -> usize {
1777        if max_requests == 0 || route.len() < 2 || zoom < self.selection_config.source_min_zoom {
1778            return 0;
1779        }
1780
1781        let source_zoom = zoom.min(self.selection_config.source_max_zoom);
1782        let tiles = tiles_along_route(route, source_zoom, camera_world);
1783        self.prefetch_tiles(tiles, camera_world, max_requests)
1784    }
1785
1786    fn prefetch_tiles<I>(
1787        &mut self,
1788        tiles: I,
1789        camera_world: (f64, f64),
1790        max_requests: usize,
1791    ) -> usize
1792    where
1793        I: IntoIterator<Item = TileId>,
1794    {
1795        if max_requests == 0 {
1796            return 0;
1797        }
1798
1799        let mut candidates: Vec<RequestCandidate> = tiles
1800            .into_iter()
1801            .filter(|tile| !self.last_desired_tiles.contains(tile))
1802            .filter(|tile| self.cache.get(tile).is_none())
1803            .map(|tile| RequestCandidate::new(tile, camera_world, RequestUrgency::Refresh))
1804            .collect();
1805
1806        if candidates.is_empty() {
1807            return 0;
1808        }
1809
1810        sort_request_candidates(&mut candidates);
1811        let mut requested = Vec::new();
1812        for candidate in candidates.into_iter().take(max_requests) {
1813            let insert = self.cache.insert_pending_with_eviction(candidate.tile);
1814            self.record_evicted_tiles(&insert.evicted);
1815            self.counters.cancelled_evicted_pending +=
1816                self.cancel_evicted_pending(&insert.evicted) as u64;
1817            if insert.inserted {
1818                self.lifecycle.record_queued(candidate.tile);
1819                requested.push(candidate.tile);
1820            }
1821        }
1822
1823        if !requested.is_empty() {
1824            for &tile in &requested {
1825                self.lifecycle.record_dispatched(tile);
1826            }
1827            self.source.request_many(&requested);
1828        }
1829
1830        let requested_count = requested.len();
1831        self.last_selection_stats.speculative_requested_tiles += requested_count;
1832        self.counters.speculative_requested_tiles += requested_count as u64;
1833        requested_count
1834    }
1835
1836    #[inline]
1837    fn begin_lifecycle_frame(&mut self) {
1838        self.lifecycle.begin_frame(self.counters.frames + 1);
1839    }
1840
1841    #[inline]
1842    fn record_visible_tile_use(&mut self, target: TileId, actual: TileId, has_data: bool) {
1843        if !has_data {
1844            return;
1845        }
1846
1847        if target == actual {
1848            self.lifecycle.record_used_as_exact(actual);
1849        } else {
1850            self.lifecycle.record_used_as_fallback(actual);
1851        }
1852    }
1853
1854    fn record_evicted_tiles(&mut self, evicted: &[crate::tile_cache::EvictedTile]) {
1855        for tile in evicted {
1856            if tile.was_pending() {
1857                self.lifecycle.record_evicted_while_pending(tile.id);
1858            } else if tile.entry.is_renderable() {
1859                self.lifecycle.record_evicted_after_renderable_use(tile.id);
1860            }
1861        }
1862    }
1863
1864    /// Promote externally decoded tiles into the cache.
1865    ///
1866    /// This is the integration point for the background MVT decode
1867    /// pipeline: after `poll()` returns a `TileData::RawVector` entry
1868    /// and the async pipeline decodes it, the decoded `TileResponse`
1869    /// is fed back here to replace the raw entry with a fully decoded
1870    /// `TileData::Vector` entry.
1871    pub fn promote_decoded(&mut self, decoded: Vec<(TileId, crate::tile_source::TileResponse)>) {
1872        for (id, response) in decoded {
1873            match response.data.validate() {
1874                Ok(()) => {
1875                    self.lifecycle.record_decoded(id);
1876                    let evicted = self.cache.promote_with_eviction(id, response);
1877                    self.lifecycle.record_promoted_to_cache(id);
1878                    self.record_evicted_tiles(&evicted);
1879                    let cancelled = self.cancel_evicted_pending(&evicted);
1880                    self.counters.cancelled_evicted_pending += cancelled as u64;
1881                }
1882                Err(err) => {
1883                    self.lifecycle.record_failed(id);
1884                    self.cache.mark_failed(id, &err)
1885                }
1886            }
1887        }
1888    }
1889
1890    fn poll_completed(&mut self) {
1891        let completed = self.source.poll();
1892        for (id, result) in completed {
1893            match result {
1894                Ok(response) if response.not_modified => {
1895                    self.lifecycle.record_completed(id);
1896                    // 304 Not Modified: refresh the TTL without replacing data.
1897                    self.cache.refresh_ttl(id, response.freshness);
1898                }
1899                Ok(response) => match response.data.validate() {
1900                    Ok(()) => {
1901                        self.lifecycle.record_completed(id);
1902                        self.lifecycle.record_decoded(id);
1903                        let evicted = self.cache.promote_with_eviction(id, response);
1904                        self.lifecycle.record_promoted_to_cache(id);
1905                        self.record_evicted_tiles(&evicted);
1906                        let cancelled = self.cancel_evicted_pending(&evicted);
1907                        self.counters.cancelled_evicted_pending += cancelled as u64;
1908                    }
1909                    Err(err) => {
1910                        self.lifecycle.record_completed(id);
1911                        self.lifecycle.record_failed(id);
1912                        self.cache.mark_failed(id, &err)
1913                    }
1914                },
1915                Err(err) => {
1916                    self.lifecycle.record_completed(id);
1917                    self.lifecycle.record_failed(id);
1918                    self.cache.mark_failed(id, &err)
1919                }
1920            }
1921        }
1922    }
1923
1924    /// Cancel pending requests that were evicted by the cache.
1925    fn cancel_evicted_pending(&self, evicted: &[crate::tile_cache::EvictedTile]) -> usize {
1926        let pending_ids: Vec<TileId> = evicted
1927            .iter()
1928            .filter(|tile| tile.was_pending())
1929            .map(|tile| tile.id)
1930            .collect();
1931        self.source.cancel_many(&pending_ids);
1932        pending_ids.len()
1933    }
1934
1935    fn prune_stale_pending(&mut self, desired: &HashSet<TileId>) -> usize {
1936        let stale_pending: Vec<TileId> = self
1937            .cache
1938            .inflight_ids()
1939            .into_iter()
1940            .filter(|id| !pending_tile_relevant_to_desired(*id, desired))
1941            .collect();
1942
1943        self.source.cancel_many(&stale_pending);
1944        for id in &stale_pending {
1945            self.lifecycle.record_cancelled_as_stale(*id);
1946            // Reloading entries carry renderable stale payload that may
1947            // still serve as fallback.  Demote them to Expired instead
1948            // of destroying the data.  Pure Pending entries have no
1949            // renderable payload and can be fully removed.
1950            if !self.cache.cancel_reload(id) {
1951                self.cache.remove(id);
1952            }
1953        }
1954        stale_pending.len()
1955    }
1956
1957    fn find_loaded_ancestor(&mut self, tile: &TileId) -> (TileId, Option<TileData>) {
1958        let mut current = *tile;
1959        let mut depth = 0u8;
1960        while depth < MAX_ANCESTOR_DEPTH {
1961            if let Some(parent) = current.parent() {
1962                let loaded = self
1963                    .cache
1964                    .get(&parent)
1965                    .and_then(|entry| entry.data())
1966                    .cloned();
1967                if let Some(data) = loaded {
1968                    self.cache.touch(&parent);
1969                    return (parent, Some(data));
1970                }
1971                current = parent;
1972                depth += 1;
1973            } else {
1974                break;
1975            }
1976        }
1977        (*tile, None)
1978    }
1979
1980    /// Search for cached child tiles that completely cover the given target
1981    /// tile's geographic extent.
1982    ///
1983    /// Walks one zoom level down at a time (up to `max_depth` levels) and
1984    /// checks whether **all** children at that level are loaded.  Returns
1985    /// the first complete set found — i.e. z+1 (4 children) is preferred
1986    /// over z+2 (16 grandchildren).
1987    ///
1988    /// Returns an empty vec when no complete child coverage exists.
1989    fn find_loaded_children(&mut self, target: &TileId, max_depth: u8) -> Vec<(TileId, TileData)> {
1990        if max_depth == 0 {
1991            return Vec::new();
1992        }
1993
1994        // BFS: at each depth level, expand all current frontier tiles into
1995        // their 4 children and check whether every child is loaded.
1996        let mut frontier = vec![*target];
1997
1998        for _depth in 0..max_depth {
1999            let mut next_frontier = Vec::with_capacity(frontier.len() * 4);
2000            let mut all_loaded = true;
2001            let mut children_data = Vec::with_capacity(frontier.len() * 4);
2002
2003            for tile in &frontier {
2004                for child in tile.children() {
2005                    let loaded = self
2006                        .cache
2007                        .get(&child)
2008                        .and_then(|entry| entry.data())
2009                        .cloned();
2010                    if let Some(data) = loaded {
2011                        children_data.push((child, data));
2012                        next_frontier.push(child);
2013                    } else {
2014                        all_loaded = false;
2015                        break;
2016                    }
2017                }
2018                if !all_loaded {
2019                    break;
2020                }
2021            }
2022
2023            if all_loaded && !children_data.is_empty() {
2024                // Touch all children so they are retained in the LRU.
2025                for (child_id, _) in &children_data {
2026                    self.cache.touch(child_id);
2027                }
2028                return children_data;
2029            }
2030
2031            frontier = next_frontier;
2032        }
2033
2034        Vec::new()
2035    }
2036
2037    fn request_parent_chain(
2038        &mut self,
2039        tile: TileId,
2040        _camera_world: (f64, f64),
2041        requested: &mut Vec<TileId>,
2042        requested_set: &mut HashSet<TileId>,
2043        cancelled_evicted_pending: &mut usize,
2044    ) {
2045        let mut chain = Vec::new();
2046        let mut current = tile;
2047        let mut depth = 0u8;
2048        while depth < MAX_ANCESTOR_DEPTH {
2049            let Some(parent) = current.parent() else {
2050                break;
2051            };
2052            match self.cache.get(&parent) {
2053                Some(entry)
2054                    if entry.is_renderable() || entry.is_pending() || entry.is_reloading() =>
2055                {
2056                    break
2057                }
2058                Some(_) => {
2059                    current = parent;
2060                    depth += 1;
2061                }
2062                None => {
2063                    chain.push(parent);
2064                    current = parent;
2065                    depth += 1;
2066                }
2067            }
2068        }
2069
2070        chain.reverse();
2071        for ancestor in chain {
2072            if !requested_set.insert(ancestor) {
2073                continue;
2074            }
2075            let insert = self.cache.insert_pending_with_eviction(ancestor);
2076            self.record_evicted_tiles(&insert.evicted);
2077            *cancelled_evicted_pending += self.cancel_evicted_pending(&insert.evicted);
2078            if insert.inserted {
2079                self.lifecycle.record_queued(ancestor);
2080                requested.push(ancestor);
2081            }
2082        }
2083    }
2084
2085    fn request_tiles_with_bootstrap(
2086        &mut self,
2087        refresh: &mut Vec<RequestCandidate>,
2088        missing: &mut Vec<RequestCandidate>,
2089        bootstrap: &[TileId],
2090        camera_world: (f64, f64),
2091    ) -> (Vec<TileId>, usize) {
2092        sort_request_candidates(refresh);
2093        sort_request_candidates(missing);
2094
2095        // Per-frame request budget from the cross-source coordinator.
2096        // When coordination is disabled this is usize::MAX (unlimited).
2097        let budget = self.selection_config.max_requests_per_frame;
2098
2099        let mut requested = Vec::with_capacity(refresh.len() + missing.len() + bootstrap.len() * 2);
2100        let mut requested_set = HashSet::with_capacity(requested.capacity().max(1));
2101        let mut cancelled_evicted_pending = 0usize;
2102
2103        // Collect revalidation pairs for expired tiles.
2104        // Revalidation requests are lightweight (conditional 304) and do
2105        // not count against the coordinator budget.
2106        let mut revalidate_pairs: Vec<(TileId, crate::tile_source::RevalidationHint)> =
2107            Vec::with_capacity(refresh.len());
2108        for candidate in refresh.drain(..) {
2109            if requested_set.insert(candidate.tile) {
2110                let hint = self
2111                    .cache
2112                    .revalidation_hint(&candidate.tile)
2113                    .unwrap_or_default();
2114                revalidate_pairs.push((candidate.tile, hint));
2115                requested.push(candidate.tile);
2116            }
2117        }
2118
2119        // Track the count before bootstrap so we can slice the bootstrap
2120        // tiles out of `requested` later for plain request dispatch.
2121        let pre_bootstrap_len = requested.len();
2122
2123        for &tile in bootstrap {
2124            self.request_parent_chain(
2125                tile,
2126                camera_world,
2127                &mut requested,
2128                &mut requested_set,
2129                &mut cancelled_evicted_pending,
2130            );
2131        }
2132
2133        // Collect new (non-refresh) tile requests, respecting the
2134        // per-frame budget assigned by the TileRequestCoordinator.
2135        let mut new_request_ids: Vec<TileId> = requested[pre_bootstrap_len..].to_vec();
2136        for candidate in missing.drain(..) {
2137            // Stop issuing new requests once the coordinator budget is
2138            // exhausted.  Already-queued bootstrap parents are exempt
2139            // because they are critical for fallback rendering.
2140            if new_request_ids.len() >= budget {
2141                break;
2142            }
2143            if !requested_set.insert(candidate.tile) {
2144                continue;
2145            }
2146            let insert = self.cache.insert_pending_with_eviction(candidate.tile);
2147            self.record_evicted_tiles(&insert.evicted);
2148            cancelled_evicted_pending += self.cancel_evicted_pending(&insert.evicted);
2149            if insert.inserted {
2150                self.lifecycle.record_queued(candidate.tile);
2151                requested.push(candidate.tile);
2152                new_request_ids.push(candidate.tile);
2153            }
2154        }
2155
2156        // Dispatch visible/bootstrap requests before background refresh
2157        // work so exact coverage and fallback promotion improve first.
2158        if !new_request_ids.is_empty() {
2159            for &tile in &new_request_ids {
2160                self.lifecycle.record_dispatched(tile);
2161            }
2162            self.source.request_many(&new_request_ids);
2163        }
2164        // Issue conditional revalidation requests (with If-None-Match /
2165        // If-Modified-Since headers) for expired tiles afterward.
2166        if !revalidate_pairs.is_empty() {
2167            for (tile, _) in &revalidate_pairs {
2168                self.lifecycle.record_dispatched(*tile);
2169            }
2170            self.source.request_revalidate_many(&revalidate_pairs);
2171        }
2172        (requested, cancelled_evicted_pending)
2173    }
2174}
2175
2176#[cfg(test)]
2177mod tests {
2178    use super::*;
2179    use crate::tile_cache::TileCacheEntry;
2180    use crate::tile_lifecycle::TileLifecycleEventKind;
2181    use crate::tile_source::{DecodedImage, TileData, TileError, TileResponse, TileSource};
2182    use rustial_math::tile_bounds_world;
2183    use std::sync::{Arc, Mutex};
2184
2185    struct MockSource {
2186        ready: Mutex<Vec<(TileId, Result<TileResponse, TileError>)>>,
2187    }
2188
2189    impl MockSource {
2190        fn new() -> Self {
2191            Self {
2192                ready: Mutex::new(Vec::new()),
2193            }
2194        }
2195    }
2196
2197    impl TileSource for MockSource {
2198        fn request(&self, id: TileId) {
2199            let data = TileData::Raster(DecodedImage {
2200                width: 256,
2201                height: 256,
2202                data: vec![128u8; 256 * 256 * 4].into(),
2203            });
2204            self.ready
2205                .lock()
2206                .unwrap()
2207                .push((id, Ok(TileResponse::from_data(data))));
2208        }
2209
2210        fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2211            let mut ready = self.ready.lock().unwrap();
2212            std::mem::take(&mut *ready)
2213        }
2214    }
2215
2216    struct FailingSource;
2217
2218    impl TileSource for FailingSource {
2219        fn request(&self, _id: TileId) {}
2220
2221        fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2222            Vec::new()
2223        }
2224    }
2225
2226    struct DelayedFailSource {
2227        pending: Mutex<Vec<TileId>>,
2228    }
2229
2230    impl DelayedFailSource {
2231        fn new() -> Self {
2232            Self {
2233                pending: Mutex::new(Vec::new()),
2234            }
2235        }
2236    }
2237
2238    impl TileSource for DelayedFailSource {
2239        fn request(&self, id: TileId) {
2240            self.pending.lock().unwrap().push(id);
2241        }
2242
2243        fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2244            let ids: Vec<TileId> = std::mem::take(&mut *self.pending.lock().unwrap());
2245            ids.into_iter()
2246                .map(|id| (id, Err(TileError::Network("timeout".into()))))
2247                .collect()
2248        }
2249    }
2250
2251    #[derive(Clone, Default)]
2252    struct RecordingSource {
2253        requested: Arc<Mutex<Vec<TileId>>>,
2254        cancelled: Arc<Mutex<Vec<TileId>>>,
2255    }
2256
2257    impl RecordingSource {
2258        fn requested_ids(&self) -> Vec<TileId> {
2259            self.requested.lock().unwrap().clone()
2260        }
2261
2262        fn cancelled_ids(&self) -> Vec<TileId> {
2263            self.cancelled.lock().unwrap().clone()
2264        }
2265    }
2266
2267    impl TileSource for RecordingSource {
2268        fn request(&self, id: TileId) {
2269            self.requested.lock().unwrap().push(id);
2270        }
2271
2272        fn request_many(&self, ids: &[TileId]) {
2273            self.requested.lock().unwrap().extend_from_slice(ids);
2274        }
2275
2276        fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2277            Vec::new()
2278        }
2279
2280        fn cancel(&self, id: TileId) {
2281            self.cancelled.lock().unwrap().push(id);
2282        }
2283
2284        fn cancel_many(&self, ids: &[TileId]) {
2285            self.cancelled.lock().unwrap().extend_from_slice(ids);
2286        }
2287    }
2288
2289    struct InvalidImageSource {
2290        ready: Mutex<Vec<(TileId, Result<TileResponse, TileError>)>>,
2291    }
2292
2293    impl InvalidImageSource {
2294        fn new() -> Self {
2295            Self {
2296                ready: Mutex::new(Vec::new()),
2297            }
2298        }
2299    }
2300
2301    impl TileSource for InvalidImageSource {
2302        fn request(&self, id: TileId) {
2303            self.ready.lock().unwrap().push((
2304                id,
2305                Ok(TileResponse::from_data(TileData::Raster(DecodedImage {
2306                    width: 2,
2307                    height: 2,
2308                    data: vec![255u8; 15].into(),
2309                }))),
2310            ));
2311        }
2312
2313        fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2314            std::mem::take(&mut *self.ready.lock().unwrap())
2315        }
2316    }
2317
2318    fn full_world_bounds() -> WorldBounds {
2319        let extent = rustial_math::WebMercator::max_extent();
2320        WorldBounds::new(
2321            rustial_math::WorldCoord::new(-extent, -extent, 0.0),
2322            rustial_math::WorldCoord::new(extent, extent, 0.0),
2323        )
2324    }
2325
2326    fn dummy_tile_data() -> TileData {
2327        TileData::Raster(DecodedImage {
2328            width: 256,
2329            height: 256,
2330            data: vec![0u8; 256 * 256 * 4].into(),
2331        })
2332    }
2333
2334    fn dummy_tile_response() -> TileResponse {
2335        TileResponse::from_data(dummy_tile_data())
2336    }
2337
2338    fn tile_center(tile: TileId) -> (f64, f64) {
2339        let bounds = tile_bounds_world(&tile);
2340        (
2341            (bounds.min.position.x + bounds.max.position.x) * 0.5,
2342            (bounds.min.position.y + bounds.max.position.y) * 0.5,
2343        )
2344    }
2345
2346    fn inset_bounds(bounds: WorldBounds, inset: f64) -> WorldBounds {
2347        WorldBounds::new(
2348            rustial_math::WorldCoord::new(
2349                bounds.min.position.x + inset,
2350                bounds.min.position.y + inset,
2351                0.0,
2352            ),
2353            rustial_math::WorldCoord::new(
2354                bounds.max.position.x - inset,
2355                bounds.max.position.y - inset,
2356                0.0,
2357            ),
2358        )
2359    }
2360
2361    #[test]
2362    fn zoom_0_one_tile() {
2363        let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2364
2365        let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2366        assert_eq!(vis.len(), 1);
2367        assert!(vis.tiles[0].data.is_none());
2368
2369        let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2370        assert_eq!(vis.len(), 1);
2371        assert!(vis.tiles[0].is_loaded());
2372        assert!(!vis.tiles[0].is_fallback());
2373    }
2374
2375    #[test]
2376    fn zoom_1_four_tiles() {
2377        let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2378
2379        let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2380        let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2381        assert_eq!(vis.len(), 4);
2382        for tile in &vis {
2383            assert!(tile.is_loaded());
2384        }
2385    }
2386
2387    #[test]
2388    fn parent_fallback_when_pending() {
2389        let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2390
2391        let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2392        let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2393        assert!(vis.tiles[0].is_loaded());
2394
2395        let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2396        assert_eq!(vis.len(), 4);
2397        for tile in &vis {
2398            assert_eq!(tile.target.zoom, 1);
2399            assert_eq!(tile.actual.zoom, 0);
2400            assert!(tile.is_loaded());
2401            assert!(tile.is_fallback());
2402        }
2403    }
2404
2405    #[test]
2406    fn no_fallback_when_no_ancestor_loaded() {
2407        let mut mgr = TileManager::new(Box::new(FailingSource), 100);
2408
2409        let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2410        assert_eq!(vis.len(), 1);
2411        assert!(!vis.tiles[0].is_loaded());
2412        assert!(!vis.tiles[0].is_fallback());
2413    }
2414
2415    #[test]
2416    fn failed_tile_uses_parent_fallback() {
2417        let mut mgr = TileManager::new(Box::new(DelayedFailSource::new()), 100);
2418
2419        let z0 = TileId::new(0, 0, 0);
2420        mgr.cache.insert_pending(z0);
2421        mgr.cache.promote(z0, dummy_tile_response());
2422
2423        let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2424        let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2425        assert_eq!(vis.len(), 4);
2426        for tile in &vis {
2427            assert_eq!(tile.target.zoom, 1);
2428            assert_eq!(tile.actual.zoom, 0);
2429            assert!(tile.is_loaded());
2430            assert!(tile.is_fallback());
2431        }
2432    }
2433
2434    #[test]
2435    fn cache_accessor() {
2436        let mgr = TileManager::new(Box::new(MockSource::new()), 50);
2437        assert!(mgr.cache().is_empty());
2438        assert_eq!(mgr.cache().capacity(), 50);
2439        assert_eq!(mgr.cached_count(), 0);
2440    }
2441
2442    #[test]
2443    fn no_duplicate_requests() {
2444        let mut mgr = TileManager::new(Box::new(FailingSource), 100);
2445
2446        let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2447        let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2448        assert_eq!(mgr.cached_count(), 1);
2449    }
2450
2451    #[test]
2452    fn visible_tile_set_helpers() {
2453        let set = VisibleTileSet::default();
2454        assert!(set.is_empty());
2455        assert_eq!(set.len(), 0);
2456        assert_eq!(set.loaded_count(), 0);
2457
2458        let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2459        let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2460        let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2461        assert_eq!(vis.len(), 1);
2462        assert_eq!(vis.loaded_count(), 1);
2463        assert_eq!(vis.iter().count(), 1);
2464    }
2465
2466    #[test]
2467    fn visible_tile_is_fallback() {
2468        let tile_exact = VisibleTile {
2469            target: TileId::new(1, 0, 0),
2470            actual: TileId::new(1, 0, 0),
2471            data: None,
2472            fade_opacity: 1.0,
2473        };
2474        assert!(!tile_exact.is_fallback());
2475
2476        let tile_fallback = VisibleTile {
2477            target: TileId::new(1, 0, 0),
2478            actual: TileId::new(0, 0, 0),
2479            data: None,
2480            fade_opacity: 1.0,
2481        };
2482        assert!(tile_fallback.is_fallback());
2483    }
2484
2485    #[test]
2486    fn visible_tile_texture_region_matches_parent_subrect() {
2487        let tile = VisibleTile {
2488            target: TileId::new(3, 4, 2),
2489            actual: TileId::new(1, 1, 0),
2490            data: None,
2491            fade_opacity: 1.0,
2492        };
2493
2494        let region = tile.texture_region();
2495        assert!((region.u_min - 0.0).abs() < 1e-6);
2496        assert!((region.v_min - 0.5).abs() < 1e-6);
2497        assert!((region.u_max - 0.25).abs() < 1e-6);
2498        assert!((region.v_max - 0.75).abs() < 1e-6);
2499    }
2500
2501    #[test]
2502    fn visible_tile_pixel_crop_rect_matches_parent_subrect() {
2503        let tile = VisibleTile {
2504            target: TileId::new(3, 4, 2),
2505            actual: TileId::new(1, 1, 0),
2506            data: None,
2507            fade_opacity: 1.0,
2508        };
2509
2510        let crop = tile.pixel_crop_rect(256, 256).unwrap();
2511        assert_eq!(crop.x, 0);
2512        assert_eq!(crop.y, 128);
2513        assert_eq!(crop.width, 64);
2514        assert_eq!(crop.height, 64);
2515    }
2516
2517    #[test]
2518    fn debug_impl() {
2519        let mgr = TileManager::new(Box::new(MockSource::new()), 100);
2520        let dbg = format!("{mgr:?}");
2521        assert!(dbg.contains("TileManager"));
2522        assert!(dbg.contains("cache_len"));
2523    }
2524
2525    #[test]
2526    fn requests_missing_tiles_nearest_first_within_same_zoom() {
2527        let source = RecordingSource::default();
2528        let mut mgr = TileManager::new(Box::new(source.clone()), 100);
2529        let focus = TileId::new(1, 0, 0);
2530
2531        let _ = mgr.update(&full_world_bounds(), 1, tile_center(focus), 0.0);
2532
2533        let requested = source.requested_ids();
2534        assert_eq!(requested.len(), 5);
2535        assert_eq!(requested[0], TileId::new(0, 0, 0));
2536        assert_eq!(requested[1], focus);
2537    }
2538
2539    #[test]
2540    fn coverage_requests_sort_ahead_of_refresh_requests() {
2541        let coverage = TileId::new(4, 8, 8);
2542        let refresh = TileId::new(4, 7, 7);
2543        let camera_world = tile_center(refresh);
2544
2545        let mut candidates = vec![
2546            RequestCandidate::new(refresh, camera_world, RequestUrgency::Refresh),
2547            RequestCandidate::new(coverage, camera_world, RequestUrgency::Coverage),
2548        ];
2549
2550        sort_request_candidates(&mut candidates);
2551
2552        assert_eq!(candidates[0].tile, coverage);
2553        assert_eq!(candidates[1].tile, refresh);
2554    }
2555
2556    #[test]
2557    fn coverage_requests_sort_ahead_of_fallback_refine_requests() {
2558        let coverage = TileId::new(4, 8, 8);
2559        let fallback_refine = TileId::new(4, 7, 7);
2560        let camera_world = tile_center(fallback_refine);
2561
2562        let mut candidates = vec![
2563            RequestCandidate::new(
2564                fallback_refine,
2565                camera_world,
2566                RequestUrgency::FallbackRefine,
2567            ),
2568            RequestCandidate::new(coverage, camera_world, RequestUrgency::Coverage),
2569        ];
2570
2571        sort_request_candidates(&mut candidates);
2572
2573        assert_eq!(candidates[0].tile, coverage);
2574        assert_eq!(candidates[1].tile, fallback_refine);
2575    }
2576
2577    #[test]
2578    fn fallback_refine_requests_sort_ahead_of_refresh_requests() {
2579        let fallback_refine = TileId::new(4, 8, 8);
2580        let refresh = TileId::new(4, 7, 7);
2581        let camera_world = tile_center(refresh);
2582
2583        let mut candidates = vec![
2584            RequestCandidate::new(refresh, camera_world, RequestUrgency::Refresh),
2585            RequestCandidate::new(
2586                fallback_refine,
2587                camera_world,
2588                RequestUrgency::FallbackRefine,
2589            ),
2590        ];
2591
2592        sort_request_candidates(&mut candidates);
2593
2594        assert_eq!(candidates[0].tile, fallback_refine);
2595        assert_eq!(candidates[1].tile, refresh);
2596    }
2597
2598    #[test]
2599    fn coarse_tiles_sort_first_within_same_priority_tier() {
2600        let coarse = TileId::new(3, 4, 4);
2601        let fine = TileId::new(5, 16, 16);
2602        let camera_world = tile_center(fine);
2603
2604        let mut candidates = vec![
2605            RequestCandidate::new(fine, camera_world, RequestUrgency::FallbackRefine),
2606            RequestCandidate::new(coarse, camera_world, RequestUrgency::FallbackRefine),
2607        ];
2608
2609        sort_request_candidates(&mut candidates);
2610
2611        assert_eq!(candidates[0].tile, coarse);
2612        assert_eq!(candidates[1].tile, fine);
2613    }
2614
2615    #[test]
2616    fn visible_requests_dispatch_before_refresh_revalidations() {
2617        let source = RecordingSource::default();
2618        let mut mgr = TileManager::new(Box::new(source.clone()), 64);
2619        let camera_world = (0.0, 0.0);
2620        let refresh_tile = TileId::new(4, 7, 7);
2621        let fallback_tile = TileId::new(4, 8, 8);
2622
2623        let mut refresh = vec![RequestCandidate::new(
2624            refresh_tile,
2625            camera_world,
2626            RequestUrgency::Refresh,
2627        )];
2628        let mut missing = vec![RequestCandidate::new(
2629            fallback_tile,
2630            camera_world,
2631            RequestUrgency::FallbackRefine,
2632        )];
2633
2634        let (requested, _) =
2635            mgr.request_tiles_with_bootstrap(&mut refresh, &mut missing, &[], camera_world);
2636
2637        assert_eq!(requested, vec![refresh_tile, fallback_tile]);
2638        assert_eq!(source.requested_ids(), vec![fallback_tile, refresh_tile]);
2639    }
2640
2641    #[test]
2642    fn cancels_pending_requests_that_scroll_out_of_view() {
2643        let source = RecordingSource::default();
2644        let mut mgr = TileManager::new(Box::new(source.clone()), 100);
2645        let keep = TileId::new(1, 0, 0);
2646
2647        let _ = mgr.update(&full_world_bounds(), 1, tile_center(keep), 0.0);
2648        assert_eq!(mgr.cached_count(), 5);
2649
2650        let narrow_bounds = inset_bounds(tile_bounds_world(&keep), 1.0);
2651        let vis = mgr.update(&narrow_bounds, 1, tile_center(keep), 0.0);
2652
2653        assert_eq!(vis.len(), 1);
2654        assert_eq!(mgr.cached_count(), 5);
2655
2656        let cancelled = source.cancelled_ids();
2657        assert!(cancelled.is_empty());
2658        assert!(!cancelled.contains(&keep));
2659    }
2660
2661    #[test]
2662    fn invalid_completed_tile_is_retried_after_failure() {
2663        let mut mgr = TileManager::new(Box::new(InvalidImageSource::new()), 100);
2664
2665        // Frame 1: tile requested.
2666        let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2667        // Frame 2: poll returns invalid data -> mark_failed -> retry clears
2668        // the failed entry and re-requests.
2669        let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2670
2671        assert_eq!(vis.len(), 1);
2672        assert!(!vis.tiles[0].is_loaded());
2673        // The failed entry was removed and re-requested, so the cache
2674        // now holds a fresh Pending entry from the retry.
2675        assert!(matches!(
2676            mgr.cache.get(&TileId::new(0, 0, 0)),
2677            Some(TileCacheEntry::Pending)
2678        ));
2679    }
2680
2681    #[test]
2682    fn tiny_cache_caps_requests_to_avoid_thrashing() {
2683        let source = RecordingSource::default();
2684        let mut mgr = TileManager::new_with_config(
2685            Box::new(source.clone()),
2686            1,
2687            TileSelectionConfig {
2688                visible_tile_budget: 1,
2689                ..TileSelectionConfig::default()
2690            },
2691        );
2692
2693        let _ = mgr.update(
2694            &full_world_bounds(),
2695            1,
2696            tile_center(TileId::new(1, 0, 0)),
2697            0.0,
2698        );
2699
2700        let requested = source.requested_ids();
2701        let cancelled = source.cancelled_ids();
2702
2703        assert_eq!(requested.len(), 2);
2704        assert_eq!(requested[0], TileId::new(0, 0, 0));
2705        assert_eq!(cancelled.len(), 1);
2706        assert_eq!(cancelled[0], TileId::new(0, 0, 0));
2707        assert_eq!(mgr.cached_count(), 1);
2708    }
2709
2710    #[test]
2711    fn explicit_visible_tile_budget_is_respected() {
2712        let mut mgr = TileManager::new_with_config(
2713            Box::new(FailingSource),
2714            512,
2715            TileSelectionConfig {
2716                visible_tile_budget: 1,
2717                ..TileSelectionConfig::default()
2718            },
2719        );
2720
2721        let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2722        assert_eq!(vis.len(), 1);
2723    }
2724
2725    #[test]
2726    fn tiny_cache_still_caps_effective_budget_below_policy_budget() {
2727        let source = RecordingSource::default();
2728        let mut mgr = TileManager::new_with_config(
2729            Box::new(source.clone()),
2730            1,
2731            TileSelectionConfig {
2732                visible_tile_budget: 512,
2733                ..TileSelectionConfig::default()
2734            },
2735        );
2736
2737        let _ = mgr.update(
2738            &full_world_bounds(),
2739            1,
2740            tile_center(TileId::new(1, 0, 0)),
2741            0.0,
2742        );
2743
2744        let requested = source.requested_ids();
2745        assert_eq!(requested.len(), 2);
2746        assert_eq!(requested[0], TileId::new(0, 0, 0));
2747        assert_eq!(mgr.cached_count(), 1);
2748    }
2749
2750    #[test]
2751    fn update_with_view_uses_shared_flat_tile_selection() {
2752        let source = RecordingSource::default();
2753        let mut mgr = TileManager::new(Box::new(source), 512);
2754
2755        let center = rustial_math::GeoCoord::from_lat_lon(39.8180, 2.6514);
2756        let center_world = rustial_math::WebMercator::project(&center);
2757        let bounds = WorldBounds::new(
2758            rustial_math::WorldCoord::new(
2759                center_world.position.x - 220_000.0,
2760                center_world.position.y - 220_000.0,
2761                0.0,
2762            ),
2763            rustial_math::WorldCoord::new(
2764                center_world.position.x + 220_000.0,
2765                center_world.position.y + 220_000.0,
2766                0.0,
2767            ),
2768        );
2769
2770        let view = rustial_math::FlatTileView::new(
2771            rustial_math::WorldCoord::new(
2772                center_world.position.x,
2773                center_world.position.y,
2774                center_world.position.z,
2775            ),
2776            26_001.0,
2777            76.5_f64.to_radians(),
2778            79.9_f64.to_radians(),
2779            std::f64::consts::FRAC_PI_4,
2780            1280,
2781            720,
2782        );
2783
2784        let raw = rustial_math::visible_tiles(&bounds, 12);
2785        let vis = mgr.update_with_view(
2786            &bounds,
2787            12,
2788            (center_world.position.x, center_world.position.y),
2789            26_001.0,
2790            Some(&view),
2791        );
2792
2793        assert!(raw.len() > vis.len());
2794        assert!(!vis.is_empty());
2795        assert!(vis.iter().all(|tile| tile.target.zoom == 12));
2796        assert!(vis.iter().all(|tile| tile.actual.zoom <= 12));
2797    }
2798
2799    #[test]
2800    fn selection_stats_report_budget_hits_and_dropped_tiles() {
2801        let mut mgr = TileManager::new_with_config(
2802            Box::new(FailingSource),
2803            512,
2804            TileSelectionConfig {
2805                visible_tile_budget: 1,
2806                ..TileSelectionConfig::default()
2807            },
2808        );
2809
2810        let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2811        let stats = mgr.last_selection_stats();
2812
2813        assert_eq!(vis.len(), 1);
2814        assert!(stats.budget_hit);
2815        assert_eq!(stats.raw_candidate_tiles, 4);
2816        assert_eq!(stats.visible_tiles, 1);
2817        assert_eq!(stats.dropped_by_budget, 3);
2818        assert_eq!(mgr.counters().budget_hit_frames, 1);
2819        assert_eq!(mgr.counters().dropped_by_budget, 3);
2820    }
2821
2822    #[test]
2823    fn selection_stats_report_exact_hits_and_requests() {
2824        let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2825
2826        let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2827        let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2828
2829        let stats = mgr.last_selection_stats();
2830        assert_eq!(stats.visible_tiles, 1);
2831        assert_eq!(stats.exact_visible_tiles, 1);
2832        assert_eq!(stats.fallback_visible_tiles, 0);
2833        assert_eq!(stats.missing_visible_tiles, 0);
2834        assert_eq!(stats.exact_cache_hits, 1);
2835        assert_eq!(stats.requested_tiles, 0);
2836        assert_eq!(mgr.counters().exact_cache_hits, 1);
2837    }
2838
2839    #[test]
2840    fn selection_stats_report_fallback_hits_and_stale_cancellations() {
2841        let source = RecordingSource::default();
2842        let mut mgr = TileManager::new(Box::new(source.clone()), 100);
2843
2844        let z0 = TileId::new(0, 0, 0);
2845        mgr.cache.insert_pending(z0);
2846        mgr.cache.promote(z0, dummy_tile_response());
2847
2848        let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2849        let keep = TileId::new(1, 0, 0);
2850        let narrow_bounds = inset_bounds(tile_bounds_world(&keep), 1.0);
2851        let _ = mgr.update(&narrow_bounds, 1, tile_center(keep), 0.0);
2852
2853        let stats = mgr.last_selection_stats();
2854        assert_eq!(stats.visible_tiles, 1);
2855        assert_eq!(stats.fallback_visible_tiles, 1);
2856        assert_eq!(stats.missing_visible_tiles, 0);
2857        assert_eq!(stats.cancelled_stale_pending, 0);
2858        assert_eq!(mgr.counters().fallback_hits, 5);
2859        assert_eq!(mgr.counters().cancelled_stale_pending, 0);
2860        assert!(source.cancelled_ids().is_empty());
2861    }
2862
2863    // -- Source zoom range (overzoom / underzoom) -------------------------
2864
2865    #[test]
2866    fn zoom_below_source_min_returns_empty() {
2867        let source = MockSource::new();
2868        let config = TileSelectionConfig {
2869            source_min_zoom: 2,
2870            source_max_zoom: 14,
2871            ..TileSelectionConfig::default()
2872        };
2873        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
2874
2875        let result = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2876        assert!(
2877            result.is_empty(),
2878            "zoom 1 < source_min_zoom 2 should return empty"
2879        );
2880        assert_eq!(mgr.last_selection_stats().visible_tiles, 0);
2881    }
2882
2883    #[test]
2884    fn zoom_at_source_min_returns_tiles() {
2885        let source = MockSource::new();
2886        let config = TileSelectionConfig {
2887            source_min_zoom: 2,
2888            source_max_zoom: 14,
2889            ..TileSelectionConfig::default()
2890        };
2891        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
2892
2893        let result = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
2894        assert!(
2895            !result.is_empty(),
2896            "zoom == source_min_zoom should return tiles"
2897        );
2898    }
2899
2900    #[test]
2901    fn overzoom_clamps_requests_to_source_max_zoom() {
2902        let source = MockSource::new();
2903        let config = TileSelectionConfig {
2904            source_min_zoom: 0,
2905            source_max_zoom: 1,
2906            ..TileSelectionConfig::default()
2907        };
2908        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
2909
2910        // First update requests tiles; second update polls the results.
2911        let _ = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
2912        let result = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
2913
2914        // The visible tiles should be at display zoom 2 but fetched from
2915        // source zoom 1.
2916        assert!(!result.is_empty());
2917        for tile in result.iter() {
2918            // target is at display zoom
2919            assert_eq!(tile.target.zoom, 2);
2920            // actual is at source zoom
2921            assert_eq!(tile.actual.zoom, 1);
2922            // texture region should be a sub-tile rectangle
2923            let region = tile.texture_region();
2924            assert!(!region.is_full());
2925        }
2926
2927        // Stats should report overzoomed tiles
2928        let stats = mgr.last_selection_stats();
2929        assert!(stats.overzoomed_visible_tiles > 0);
2930    }
2931
2932    #[test]
2933    fn overzoom_texture_region_maps_correctly() {
2934        // When source_max_zoom=0 and display_zoom=1, we get 4 display tiles
2935        // all backed by the single zoom-0 tile.
2936        let source = MockSource::new();
2937        let config = TileSelectionConfig {
2938            source_min_zoom: 0,
2939            source_max_zoom: 0,
2940            ..TileSelectionConfig::default()
2941        };
2942        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
2943
2944        // First update requests; second polls.
2945        let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2946        let result = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2947        assert_eq!(
2948            result.len(),
2949            4,
2950            "4 display tiles at zoom 1 from 1 source tile at zoom 0"
2951        );
2952
2953        // Each display tile should map to a different quadrant of the source tile.
2954        let mut regions: Vec<_> = result.iter().map(|t| t.texture_region()).collect();
2955        regions.sort_by(|a, b| {
2956            a.u_min
2957                .partial_cmp(&b.u_min)
2958                .unwrap()
2959                .then(a.v_min.partial_cmp(&b.v_min).unwrap())
2960        });
2961
2962        // All regions should be non-full (sub-tile)
2963        for region in &regions {
2964            assert!(!region.is_full());
2965            let u_size = region.u_max - region.u_min;
2966            let v_size = region.v_max - region.v_min;
2967            assert!(
2968                (u_size - 0.5).abs() < 1e-5,
2969                "each quadrant is half the tile"
2970            );
2971            assert!(
2972                (v_size - 0.5).abs() < 1e-5,
2973                "each quadrant is half the tile"
2974            );
2975        }
2976    }
2977
2978    #[test]
2979    fn overzoomed_display_targets_computes_children() {
2980        let parent = TileId::new(1, 0, 0);
2981        let children = overzoomed_display_targets(&parent, 2);
2982        assert_eq!(children.len(), 4);
2983        assert!(children.contains(&TileId::new(2, 0, 0)));
2984        assert!(children.contains(&TileId::new(2, 1, 0)));
2985        assert!(children.contains(&TileId::new(2, 0, 1)));
2986        assert!(children.contains(&TileId::new(2, 1, 1)));
2987    }
2988
2989    #[test]
2990    fn overzoomed_display_targets_same_zoom_returns_self() {
2991        let tile = TileId::new(3, 2, 1);
2992        let targets = overzoomed_display_targets(&tile, 3);
2993        assert_eq!(targets, vec![tile]);
2994    }
2995
2996    #[test]
2997    fn missing_tile_requests_parent_chain_before_exact_tile() {
2998        let source = RecordingSource::default();
2999        let mut mgr = TileManager::new_with_config(
3000            Box::new(source.clone()),
3001            16,
3002            TileSelectionConfig {
3003                visible_tile_budget: 1,
3004                ..TileSelectionConfig::default()
3005            },
3006        );
3007
3008        let target = TileId::new(2, 1, 1);
3009        let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3010        let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
3011
3012        let requested = source.requested_ids();
3013        assert_eq!(
3014            requested,
3015            vec![TileId::new(0, 0, 0), TileId::new(1, 0, 0), target]
3016        );
3017    }
3018
3019    #[test]
3020    fn desired_ancestor_retention_avoids_cancelling_bootstrap_parents() {
3021        let source = RecordingSource::default();
3022        let mut mgr = TileManager::new_with_config(
3023            Box::new(source.clone()),
3024            16,
3025            TileSelectionConfig {
3026                visible_tile_budget: 1,
3027                ..TileSelectionConfig::default()
3028            },
3029        );
3030
3031        let target = TileId::new(2, 1, 1);
3032        let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3033
3034        let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
3035        let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
3036
3037        assert!(source.cancelled_ids().is_empty());
3038        assert_eq!(mgr.cached_count(), 3);
3039    }
3040
3041    #[test]
3042    fn previous_desired_tile_gets_one_frame_retention_before_stale_cancel() {
3043        let source = RecordingSource::default();
3044        let mut mgr = TileManager::new(Box::new(source.clone()), 32);
3045
3046        let first = TileId::new(14, 0, 0);
3047        let second = TileId::new(14, 4_096, 4_096);
3048        mgr.cache.insert_pending(first);
3049        mgr.cache.insert_pending(second);
3050
3051        let previous_desired = HashSet::from([first]);
3052        let retained = desired_with_temporal_retention(&[second], &previous_desired);
3053        assert_eq!(mgr.prune_stale_pending(&retained), 0);
3054
3055        assert!(
3056            !source.cancelled_ids().contains(&first),
3057            "the previous desired tile should survive one extra frame of camera motion"
3058        );
3059
3060        let current_only = desired_with_temporal_retention(&[second], &HashSet::from([second]));
3061        assert_eq!(mgr.prune_stale_pending(&current_only), 1);
3062
3063        assert!(
3064            source.cancelled_ids().contains(&first),
3065            "once the tile is outside both the current and immediately previous desired sets it should be cancelled"
3066        );
3067    }
3068
3069    #[test]
3070    fn adjacent_same_zoom_pending_tile_survives_small_pan_horizon() {
3071        let source = RecordingSource::default();
3072        let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3073
3074        let adjacent = TileId::new(10, 100, 100);
3075        let desired = TileId::new(10, 101, 100);
3076        mgr.cache.insert_pending(adjacent);
3077
3078        let desired_set = desired_with_ancestor_retention(&[desired]);
3079        let cancelled = mgr.prune_stale_pending(&desired_set);
3080
3081        assert_eq!(cancelled, 0);
3082        assert!(source.cancelled_ids().is_empty());
3083        assert!(mgr.cache.get(&adjacent).is_some());
3084    }
3085
3086    #[test]
3087    fn nearby_descendant_pending_tile_survives_zoom_in_horizon() {
3088        let source = RecordingSource::default();
3089        let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3090
3091        let pending_child = TileId::new(11, 204, 200);
3092        let desired = TileId::new(10, 101, 100);
3093        mgr.cache.insert_pending(pending_child);
3094
3095        let desired_set = desired_with_ancestor_retention(&[desired]);
3096        let cancelled = mgr.prune_stale_pending(&desired_set);
3097
3098        assert_eq!(cancelled, 0);
3099        assert!(source.cancelled_ids().is_empty());
3100        assert!(mgr.cache.get(&pending_child).is_some());
3101    }
3102
3103    #[test]
3104    fn nearby_ancestor_pending_tile_survives_zoom_out_horizon() {
3105        let source = RecordingSource::default();
3106        let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3107
3108        let pending_parent = TileId::new(9, 51, 50);
3109        let desired = TileId::new(10, 101, 100);
3110        mgr.cache.insert_pending(pending_parent);
3111
3112        let desired_set = desired_with_ancestor_retention(&[desired]);
3113        let cancelled = mgr.prune_stale_pending(&desired_set);
3114
3115        assert_eq!(cancelled, 0);
3116        assert!(source.cancelled_ids().is_empty());
3117        assert!(mgr.cache.get(&pending_parent).is_some());
3118    }
3119
3120    #[test]
3121    fn stale_prune_preserves_reloading_renderable_payload() {
3122        let source = RecordingSource::default();
3123        let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3124
3125        let reloading = TileId::new(10, 500, 500);
3126        let desired = TileId::new(10, 0, 0);
3127
3128        mgr.cache.promote(reloading, dummy_tile_response());
3129        assert!(mgr.cache.start_reload(reloading));
3130        assert!(mgr.cache.get(&reloading).unwrap().is_reloading());
3131
3132        let desired_set = desired_with_ancestor_retention(&[desired]);
3133        let cancelled = mgr.prune_stale_pending(&desired_set);
3134
3135        assert_eq!(cancelled, 1);
3136        assert!(source.cancelled_ids().contains(&reloading));
3137        // The entry should be demoted to Expired, not removed.
3138        let entry = mgr
3139            .cache
3140            .get(&reloading)
3141            .expect("reloading entry should survive as expired");
3142        assert!(entry.is_expired());
3143        assert!(entry.is_renderable());
3144    }
3145
3146    #[test]
3147    fn stale_prune_removes_pure_pending_entry() {
3148        let source = RecordingSource::default();
3149        let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3150
3151        let pending = TileId::new(10, 500, 500);
3152        let desired = TileId::new(10, 0, 0);
3153        mgr.cache.insert_pending(pending);
3154
3155        let desired_set = desired_with_ancestor_retention(&[desired]);
3156        let cancelled = mgr.prune_stale_pending(&desired_set);
3157
3158        assert_eq!(cancelled, 1);
3159        assert!(source.cancelled_ids().contains(&pending));
3160        assert!(!mgr.cache.contains(&pending));
3161    }
3162
3163    #[test]
3164    fn speculative_prefetch_requests_only_tiles_outside_current_desired_set() {
3165        let source = RecordingSource::default();
3166        let mut mgr = TileManager::new_with_config(
3167            Box::new(source.clone()),
3168            32,
3169            TileSelectionConfig::default(),
3170        );
3171
3172        let current = TileId::new(2, 1, 1);
3173        let predicted = TileId::new(2, 2, 1);
3174        let current_bounds = inset_bounds(tile_bounds_world(&current), 1.0);
3175        let predicted_bounds = inset_bounds(tile_bounds_world(&predicted), 1.0);
3176
3177        let _ = mgr.update(&current_bounds, 2, tile_center(current), 0.0);
3178        let before = source.requested_ids().len();
3179
3180        let prefetched =
3181            mgr.prefetch_with_view(&predicted_bounds, 2, tile_center(predicted), None, 2);
3182        let requested = source.requested_ids();
3183
3184        assert_eq!(prefetched, 1);
3185        assert_eq!(requested.len(), before + 1);
3186        assert_eq!(requested.last().copied(), Some(predicted));
3187        assert_eq!(mgr.last_selection_stats().speculative_requested_tiles, 1);
3188        assert_eq!(mgr.counters().speculative_requested_tiles, 1);
3189    }
3190
3191    #[test]
3192    fn speculative_prefetch_skips_when_prediction_matches_current_desired_tiles() {
3193        let source = RecordingSource::default();
3194        let mut mgr = TileManager::new_with_config(
3195            Box::new(source.clone()),
3196            32,
3197            TileSelectionConfig::default(),
3198        );
3199
3200        let current = TileId::new(2, 1, 1);
3201        let current_bounds = inset_bounds(tile_bounds_world(&current), 1.0);
3202
3203        let _ = mgr.update(&current_bounds, 2, tile_center(current), 0.0);
3204        let before = source.requested_ids().len();
3205
3206        let prefetched = mgr.prefetch_with_view(&current_bounds, 2, tile_center(current), None, 2);
3207
3208        assert_eq!(prefetched, 0);
3209        assert_eq!(source.requested_ids().len(), before);
3210    }
3211
3212    #[test]
3213    fn zoom_in_prefetch_requests_children_of_centre_tiles() {
3214        let source = RecordingSource::default();
3215        let mut mgr = TileManager::new_with_config(
3216            Box::new(source.clone()),
3217            32,
3218            TileSelectionConfig::default(),
3219        );
3220
3221        let current = TileId::new(2, 1, 1);
3222        let current_bounds = inset_bounds(tile_bounds_world(&current), 1.0);
3223        let _ = mgr.update(&current_bounds, 2, tile_center(current), 0.0);
3224        let before = source.requested_ids().len();
3225
3226        let prefetched =
3227            mgr.prefetch_zoom_direction(tile_center(current), ZoomPrefetchDirection::In, 4);
3228        let requested = source.requested_ids();
3229
3230        assert_eq!(prefetched, 4);
3231        assert_eq!(requested.len(), before + 4);
3232        assert!(requested[before..].contains(&TileId::new(3, 2, 2)));
3233        assert!(requested[before..].contains(&TileId::new(3, 3, 2)));
3234        assert!(requested[before..].contains(&TileId::new(3, 2, 3)));
3235        assert!(requested[before..].contains(&TileId::new(3, 3, 3)));
3236    }
3237
3238    #[test]
3239    fn zoom_out_prefetch_requests_parent_tiles() {
3240        let source = RecordingSource::default();
3241        let mut mgr = TileManager::new_with_config(
3242            Box::new(source.clone()),
3243            32,
3244            TileSelectionConfig::default(),
3245        );
3246
3247        let current = TileId::new(2, 1, 1);
3248        mgr.cache.insert_pending(current);
3249        mgr.cache.promote(current, dummy_tile_response());
3250        let current_bounds = inset_bounds(tile_bounds_world(&current), 1.0);
3251        let _ = mgr.update(&current_bounds, 2, tile_center(current), 0.0);
3252        let before = source.requested_ids().len();
3253
3254        let prefetched =
3255            mgr.prefetch_zoom_direction(tile_center(current), ZoomPrefetchDirection::Out, 2);
3256        let requested = source.requested_ids();
3257
3258        assert_eq!(prefetched, 1);
3259        assert_eq!(requested.len(), before + 1);
3260        assert_eq!(requested.last().copied(), Some(TileId::new(1, 0, 0)));
3261    }
3262
3263    // -- Route-aware prefetch tests -----------------------------------
3264
3265    #[test]
3266    fn route_prefetch_requests_tiles_along_polyline_ahead_of_camera() {
3267        let source = RecordingSource::default();
3268        let mut mgr = TileManager::new_with_config(
3269            Box::new(source.clone()),
3270            64,
3271            TileSelectionConfig::default(),
3272        );
3273
3274        // Build a short east-west route at zoom 4.
3275        let route = vec![
3276            GeoCoord::from_lat_lon(0.0, 0.0),
3277            GeoCoord::from_lat_lon(0.0, 30.0),
3278            GeoCoord::from_lat_lon(0.0, 60.0),
3279        ];
3280
3281        // Place the camera at the route start with a tight view.
3282        let cam = WebMercator::project_clamped(&route[0]);
3283        let camera_world = (cam.position.x, cam.position.y);
3284
3285        // Use a single-tile tight bound so the desired set is small.
3286        let current = geo_to_tile(&route[0], 4).tile_id();
3287        let current_bounds = inset_bounds(tile_bounds_world(&current), 1.0);
3288        let _ = mgr.update(&current_bounds, 4, camera_world, 0.0);
3289        let before = source.requested_ids().len();
3290
3291        let prefetched = mgr.prefetch_route(&route, 4, camera_world, 8);
3292        let requested = source.requested_ids();
3293
3294        // Should have prefetched at least one tile beyond the camera's current view.
3295        assert!(prefetched > 0, "route prefetch should request tiles ahead");
3296        assert_eq!(requested.len(), before + prefetched);
3297    }
3298
3299    #[test]
3300    fn route_prefetch_budget_is_respected() {
3301        let source = RecordingSource::default();
3302        let mut mgr = TileManager::new_with_config(
3303            Box::new(source.clone()),
3304            64,
3305            TileSelectionConfig::default(),
3306        );
3307
3308        let route = vec![
3309            GeoCoord::from_lat_lon(0.0, 0.0),
3310            GeoCoord::from_lat_lon(0.0, 30.0),
3311            GeoCoord::from_lat_lon(0.0, 60.0),
3312        ];
3313
3314        let cam = WebMercator::project_clamped(&route[0]);
3315        let camera_world = (cam.position.x, cam.position.y);
3316
3317        let current = geo_to_tile(&route[0], 4).tile_id();
3318        let current_bounds = inset_bounds(tile_bounds_world(&current), 1.0);
3319        let _ = mgr.update(&current_bounds, 4, camera_world, 0.0);
3320
3321        let prefetched = mgr.prefetch_route(&route, 4, camera_world, 2);
3322        assert!(
3323            prefetched <= 2,
3324            "route prefetch must respect max_requests budget"
3325        );
3326    }
3327
3328    #[test]
3329    fn route_prefetch_empty_route_returns_zero() {
3330        let source = RecordingSource::default();
3331        let mut mgr = TileManager::new(Box::new(source.clone()), 32);
3332
3333        assert_eq!(mgr.prefetch_route(&[], 4, (0.0, 0.0), 8), 0);
3334        assert_eq!(
3335            mgr.prefetch_route(&[GeoCoord::from_lat_lon(0.0, 0.0)], 4, (0.0, 0.0), 8,),
3336            0
3337        );
3338    }
3339
3340    #[test]
3341    fn tiles_along_route_produces_ordered_unique_tiles() {
3342        let route = vec![
3343            GeoCoord::from_lat_lon(0.0, 0.0),
3344            GeoCoord::from_lat_lon(0.0, 10.0),
3345            GeoCoord::from_lat_lon(0.0, 20.0),
3346        ];
3347
3348        let cam = WebMercator::project_clamped(&route[0]);
3349        let camera_world = (cam.position.x, cam.position.y);
3350
3351        let tiles = tiles_along_route(&route, 4, camera_world);
3352        assert!(!tiles.is_empty());
3353
3354        // All unique
3355        let unique: HashSet<_> = tiles.iter().copied().collect();
3356        assert_eq!(
3357            unique.len(),
3358            tiles.len(),
3359            "tiles_along_route must not produce duplicates"
3360        );
3361
3362        // All at the correct zoom
3363        assert!(tiles.iter().all(|t| t.zoom == 4));
3364    }
3365
3366    // -- Fade-in tests ------------------------------------------------
3367
3368    #[test]
3369    fn compute_fade_opacity_disabled_returns_one() {
3370        let now = SystemTime::now();
3371        assert_eq!(compute_fade_opacity(now, Some(now), 0.0), 1.0);
3372    }
3373
3374    #[test]
3375    fn compute_fade_opacity_no_loaded_at_returns_one() {
3376        let now = SystemTime::now();
3377        assert_eq!(compute_fade_opacity(now, None, 0.3), 1.0);
3378    }
3379
3380    #[test]
3381    fn compute_fade_opacity_ramps_from_zero_to_one() {
3382        use std::time::Duration;
3383
3384        let loaded = SystemTime::now();
3385        let half = loaded + Duration::from_millis(150);
3386        let full = loaded + Duration::from_millis(300);
3387        let over = loaded + Duration::from_millis(600);
3388
3389        let at_zero = compute_fade_opacity(loaded, Some(loaded), 0.3);
3390        assert!((at_zero - 0.0).abs() < 0.01, "at load time: {at_zero}");
3391
3392        let at_half = compute_fade_opacity(half, Some(loaded), 0.3);
3393        assert!((at_half - 0.5).abs() < 0.05, "at 150ms: {at_half}");
3394
3395        let at_full = compute_fade_opacity(full, Some(loaded), 0.3);
3396        assert!((at_full - 1.0).abs() < 0.01, "at 300ms: {at_full}");
3397
3398        let at_over = compute_fade_opacity(over, Some(loaded), 0.3);
3399        assert_eq!(at_over, 1.0, "past duration should clamp to 1.0");
3400    }
3401
3402    #[test]
3403    fn crossfade_emits_parent_while_child_fading() {
3404        let source = MockSource::new();
3405
3406        let config = TileSelectionConfig {
3407            raster_fade_duration: 10.0, // long duration to ensure fade < 1.0
3408            ..TileSelectionConfig::default()
3409        };
3410        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3411
3412        let parent = TileId::new(0, 0, 0);
3413        let child = TileId::new(1, 0, 0);
3414
3415        // Pre-load the parent as "old" (fully faded in).
3416        mgr.cache.insert_pending(parent);
3417        mgr.cache.promote(parent, dummy_tile_response());
3418
3419        // Now load the child -- it will be freshly loaded, so fade_opacity < 1.0.
3420        mgr.cache.insert_pending(child);
3421        mgr.cache.promote(child, dummy_tile_response());
3422
3423        let bounds = inset_bounds(tile_bounds_world(&child), 1.0);
3424        let vis = mgr.update(&bounds, 1, tile_center(child), 0.0);
3425
3426        // With a 10-second fade, the child should have a very small
3427        // fade_opacity (practically 0).  The visible set should contain
3428        // both the fading child and a cross-fade parent entry.
3429        let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == child).collect();
3430        let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == parent).collect();
3431
3432        assert!(!child_tiles.is_empty(), "child tile should be present");
3433        let child_fade = child_tiles[0].fade_opacity;
3434        assert!(
3435            child_fade < 1.0,
3436            "child should be fading (got {child_fade})"
3437        );
3438
3439        assert!(
3440            !parent_tiles.is_empty(),
3441            "cross-fade parent should be emitted while child fades"
3442        );
3443        let parent_fade = parent_tiles[0].fade_opacity;
3444        let sum = child_fade + parent_fade;
3445        assert!(
3446            (sum - 1.0).abs() < 0.05,
3447            "child + parent opacities should sum to ~1.0 (got {sum})"
3448        );
3449    }
3450
3451    #[test]
3452    fn max_fading_ancestor_cap_respected() {
3453        let mut cache = TileCache::new(100);
3454        let mut visible = VisibleTileSet { tiles: Vec::new() };
3455
3456        // Load ancestors at zoom 0..5
3457        for z in 0..5 {
3458            let id = TileId::new(z, 0, 0);
3459            cache.insert_pending(id);
3460            cache.promote(id, dummy_tile_response());
3461        }
3462
3463        // max_fading_ancestor_levels = 2 means we walk at most 2 levels up.
3464        let target = TileId::new(4, 0, 0);
3465        emit_crossfade_parent(&mut visible, target, 0.5, 2, &mut cache);
3466
3467        // Should find the parent at zoom 3 (1 level up).
3468        assert_eq!(visible.tiles.len(), 1);
3469        assert_eq!(visible.tiles[0].actual.zoom, 3);
3470    }
3471
3472    // -- Child fallback (underzoom) tests ------------------------------
3473
3474    #[test]
3475    fn from_child_tile_returns_correct_sub_region() {
3476        let parent = TileId::new(1, 0, 0);
3477        // Top-left child at zoom 2.
3478        let child_tl = TileId::new(2, 0, 0);
3479        let region = TileTextureRegion::from_child_tile(&parent, &child_tl).unwrap();
3480        assert!((region.u_min - 0.0).abs() < 1e-6);
3481        assert!((region.v_min - 0.0).abs() < 1e-6);
3482        assert!((region.u_max - 0.5).abs() < 1e-6);
3483        assert!((region.v_max - 0.5).abs() < 1e-6);
3484
3485        // Bottom-right child at zoom 2.
3486        let child_br = TileId::new(2, 1, 1);
3487        let region = TileTextureRegion::from_child_tile(&parent, &child_br).unwrap();
3488        assert!((region.u_min - 0.5).abs() < 1e-6);
3489        assert!((region.v_min - 0.5).abs() < 1e-6);
3490        assert!((region.u_max - 1.0).abs() < 1e-6);
3491        assert!((region.v_max - 1.0).abs() < 1e-6);
3492    }
3493
3494    #[test]
3495    fn from_child_tile_returns_none_for_non_descendant() {
3496        let parent = TileId::new(1, 0, 0);
3497        // This child belongs to a different parent.
3498        let wrong_child = TileId::new(2, 3, 3);
3499        assert!(TileTextureRegion::from_child_tile(&parent, &wrong_child).is_none());
3500    }
3501
3502    #[test]
3503    fn from_child_tile_returns_none_when_child_zoom_lte_target() {
3504        let parent = TileId::new(2, 0, 0);
3505        let same = TileId::new(2, 0, 0);
3506        assert!(TileTextureRegion::from_child_tile(&parent, &same).is_none());
3507
3508        let higher = TileId::new(1, 0, 0);
3509        assert!(TileTextureRegion::from_child_tile(&parent, &higher).is_none());
3510    }
3511
3512    #[test]
3513    fn child_fallback_uses_cached_children() {
3514        // Set up: parent at z1 is not loaded, but all 4 children at z2 are.
3515        let source = MockSource::new();
3516        let config = TileSelectionConfig {
3517            max_child_depth: 2,
3518            ..TileSelectionConfig::default()
3519        };
3520        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3521
3522        let parent = TileId::new(1, 0, 0);
3523        let children = parent.children();
3524        for child in &children {
3525            mgr.cache.insert_pending(*child);
3526            mgr.cache.promote(*child, dummy_tile_response());
3527        }
3528
3529        // Request at zoom 1 — parent is missing, children are loaded.
3530        let bounds = inset_bounds(tile_bounds_world(&parent), 1.0);
3531        let vis = mgr.update(&bounds, 1, tile_center(parent), 0.0);
3532
3533        // Should get 4 child-fallback visible tiles (one per child).
3534        let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3535        assert_eq!(child_tiles.len(), 4, "expected 4 child-fallback tiles");
3536
3537        // All should reference the parent as target.
3538        for ct in &child_tiles {
3539            assert_eq!(ct.target, parent);
3540            assert!(ct.data.is_some());
3541            assert_eq!(ct.actual.zoom, 2);
3542        }
3543
3544        // Stats should reflect child fallback.
3545        let stats = mgr.last_selection_stats();
3546        assert_eq!(stats.child_fallback_hits, 1);
3547        assert_eq!(stats.child_fallback_visible_tiles, 4);
3548    }
3549
3550    #[test]
3551    fn child_fallback_prefers_children_over_parent() {
3552        // Both parent (z0) and all children (z2) of z1 tile are loaded.
3553        let source = MockSource::new();
3554        let config = TileSelectionConfig {
3555            max_child_depth: 2,
3556            ..TileSelectionConfig::default()
3557        };
3558        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3559
3560        let z0 = TileId::new(0, 0, 0);
3561        mgr.cache.insert_pending(z0);
3562        mgr.cache.promote(z0, dummy_tile_response());
3563
3564        let target = TileId::new(1, 0, 0);
3565        let children = target.children();
3566        for child in &children {
3567            mgr.cache.insert_pending(*child);
3568            mgr.cache.promote(*child, dummy_tile_response());
3569        }
3570
3571        let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3572        let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
3573
3574        // Child fallback should be preferred: 4 tiles from children, not
3575        // 1 from the parent.
3576        let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3577        let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == z0).collect();
3578        assert_eq!(child_tiles.len(), 4, "expected child fallback tiles");
3579        assert_eq!(
3580            parent_tiles.len(),
3581            0,
3582            "should not use parent when children available"
3583        );
3584    }
3585
3586    #[test]
3587    fn child_fallback_incomplete_children_falls_through_to_parent() {
3588        // Only 3 of 4 children loaded — should fall through to parent.
3589        let source = MockSource::new();
3590        let config = TileSelectionConfig {
3591            max_child_depth: 2,
3592            ..TileSelectionConfig::default()
3593        };
3594        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3595
3596        let z0 = TileId::new(0, 0, 0);
3597        mgr.cache.insert_pending(z0);
3598        mgr.cache.promote(z0, dummy_tile_response());
3599
3600        let target = TileId::new(1, 0, 0);
3601        let children = target.children();
3602        // Load only 3 of 4 children.
3603        for child in &children[..3] {
3604            mgr.cache.insert_pending(*child);
3605            mgr.cache.promote(*child, dummy_tile_response());
3606        }
3607
3608        let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3609        let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
3610
3611        // Should fall back to parent (z0), not emit partial children.
3612        let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == z0).collect();
3613        assert!(!parent_tiles.is_empty(), "should fall back to z0 parent");
3614        let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3615        assert!(
3616            child_fb.is_empty(),
3617            "incomplete children should not be used"
3618        );
3619    }
3620
3621    #[test]
3622    fn child_fallback_max_depth_cap_respected() {
3623        // Children at z+1 are not loaded, grandchildren at z+2 are.
3624        // max_child_depth=1 should NOT reach z+2.
3625        let source = MockSource::new();
3626        let config = TileSelectionConfig {
3627            max_child_depth: 1,
3628            ..TileSelectionConfig::default()
3629        };
3630        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3631
3632        let target = TileId::new(1, 0, 0);
3633        // Load all 16 grandchildren (z+2).
3634        for child in target.children() {
3635            for grandchild in child.children() {
3636                mgr.cache.insert_pending(grandchild);
3637                mgr.cache.promote(grandchild, dummy_tile_response());
3638            }
3639        }
3640
3641        let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3642        let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
3643
3644        // max_child_depth=1 means only z+1 is checked, which has no loaded
3645        // tiles, so no child fallback should occur.
3646        let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3647        assert!(
3648            child_fb.is_empty(),
3649            "depth=1 should not reach grandchildren"
3650        );
3651    }
3652
3653    #[test]
3654    fn child_fallback_disabled_when_max_child_depth_zero() {
3655        let source = MockSource::new();
3656        let config = TileSelectionConfig {
3657            max_child_depth: 0,
3658            ..TileSelectionConfig::default()
3659        };
3660        let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3661
3662        let target = TileId::new(1, 0, 0);
3663        for child in target.children() {
3664            mgr.cache.insert_pending(child);
3665            mgr.cache.promote(child, dummy_tile_response());
3666        }
3667
3668        let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3669        let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
3670
3671        let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3672        assert!(
3673            child_fb.is_empty(),
3674            "max_child_depth=0 should disable child fallback"
3675        );
3676    }
3677
3678    #[test]
3679    fn stale_pending_same_zoom_tiles_are_pruned_when_unrelated_to_desired_view() {
3680        let source = RecordingSource::default();
3681        let mut mgr = TileManager::new(Box::new(source.clone()), 128);
3682
3683        let stale = TileId::new(4, 0, 0);
3684        let desired = TileId::new(4, 8, 8);
3685
3686        mgr.cache.insert_pending(stale);
3687
3688        let desired_set = desired_with_ancestor_retention(&[desired]);
3689        let cancelled = mgr.prune_stale_pending(&desired_set);
3690
3691        assert_eq!(cancelled, 1);
3692        assert_eq!(source.cancelled_ids(), vec![stale]);
3693        assert!(mgr.cache.get(&stale).is_none());
3694    }
3695
3696    #[test]
3697    fn is_child_fallback_returns_true_for_child_actual() {
3698        let tile = VisibleTile {
3699            target: TileId::new(1, 0, 0),
3700            actual: TileId::new(2, 0, 0),
3701            data: Some(dummy_tile_response().data),
3702            fade_opacity: 1.0,
3703        };
3704        assert!(tile.is_child_fallback());
3705        assert!(!tile.is_overzoomed());
3706    }
3707
3708    #[test]
3709    fn lifecycle_diagnostics_track_request_completion_and_exact_use() {
3710        let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
3711
3712        let bounds = full_world_bounds();
3713        let _ = mgr.update(&bounds, 0, (0.0, 0.0), 0.0);
3714        let _ = mgr.update(&bounds, 0, (0.0, 0.0), 0.0);
3715
3716        let diagnostics = mgr.lifecycle_diagnostics();
3717        let record = diagnostics
3718            .active_records
3719            .iter()
3720            .find(|record| record.tile == TileId::new(0, 0, 0))
3721            .expect("expected z0 lifecycle record");
3722
3723        assert_eq!(record.first_selected_frame, Some(1));
3724        assert_eq!(record.first_queued_frame, Some(1));
3725        assert_eq!(record.first_dispatched_frame, Some(1));
3726        assert_eq!(record.first_completed_frame, Some(2));
3727        assert_eq!(record.first_decoded_frame, Some(2));
3728        assert_eq!(record.first_promoted_frame, Some(2));
3729        assert_eq!(record.first_renderable_frame, Some(2));
3730        assert_eq!(record.first_exact_frame, Some(2));
3731        assert_eq!(record.queued_frames_to_dispatch, Some(0));
3732        assert_eq!(record.in_flight_frames_to_complete, Some(1));
3733        assert_eq!(record.completion_to_visible_use_frames, Some(0));
3734
3735        assert!(diagnostics
3736            .recent_events
3737            .iter()
3738            .any(|event| event.tile == TileId::new(0, 0, 0)
3739                && event.kind == TileLifecycleEventKind::Queued));
3740        assert!(diagnostics
3741            .recent_events
3742            .iter()
3743            .any(|event| event.tile == TileId::new(0, 0, 0)
3744                && event.kind == TileLifecycleEventKind::UsedAsExact));
3745    }
3746
3747    #[test]
3748    fn lifecycle_diagnostics_track_stale_cancellation() {
3749        let source = RecordingSource::default();
3750        let mut mgr = TileManager::new(Box::new(source.clone()), 128);
3751
3752        let first = TileId::new(4, 0, 0);
3753        let desired = TileId::new(4, 8, 8);
3754
3755        mgr.begin_lifecycle_frame();
3756        mgr.cache.insert_pending(first);
3757        mgr.lifecycle.record_queued(first);
3758        let desired_set = desired_with_ancestor_retention(&[desired]);
3759        let cancelled_count = mgr.prune_stale_pending(&desired_set);
3760
3761        let diagnostics = mgr.lifecycle_diagnostics();
3762        let cancelled = diagnostics
3763            .recent_terminal_records
3764            .iter()
3765            .find(|record| record.tile == first)
3766            .expect("expected stale-cancelled tile lifecycle record");
3767
3768        assert_eq!(
3769            cancelled.terminal_event,
3770            Some(TileLifecycleEventKind::CancelledAsStale)
3771        );
3772        assert_eq!(cancelled.tile, first);
3773        assert_eq!(cancelled_count, 1);
3774        assert_eq!(source.cancelled_ids(), vec![first]);
3775        assert!(diagnostics
3776            .recent_events
3777            .iter()
3778            .any(|event| event.tile == first
3779                && event.kind == TileLifecycleEventKind::CancelledAsStale));
3780    }
3781}