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