1#![allow(clippy::too_many_lines)]
9use crate::tile_cache::{TileCache, TileCacheStats};
61use crate::tile_lifecycle::{TileLifecycleDiagnostics, TileLifecycleTracker};
62use crate::tile_source::{TileData, TileSource, TileSourceDiagnostics};
63use rustial_math::{
64 geo_to_tile, tile_bounds_world, FlatTileSelectionConfig, FlatTileView, GeoCoord, TileId,
65 WebMercator, WorldBounds,
66};
67use std::cmp::Ordering;
68use std::collections::HashSet;
69use std::time::SystemTime;
70
71const DEFAULT_VISIBLE_TILE_BUDGET: usize = 512;
73
74const MAX_ANCESTOR_DEPTH: u8 = 8;
78
79#[derive(Debug, Clone, PartialEq)]
85pub struct TileSelectionConfig {
86 pub visible_tile_budget: usize,
88 pub flat_view: FlatTileSelectionConfig,
90 pub source_min_zoom: u8,
96 pub source_max_zoom: u8,
107 pub raster_fade_duration: f32,
114 pub max_fading_ancestor_levels: u8,
120 pub max_child_depth: u8,
130 pub max_requests_per_frame: usize,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum ZoomPrefetchDirection {
143 In,
145 Out,
147}
148
149impl TileSelectionConfig {
150 #[inline]
152 pub fn effective_visible_tile_budget(&self, cache_capacity: usize) -> usize {
153 let policy_budget = self.visible_tile_budget.max(1);
154 let cache_budget = cache_capacity.saturating_sub(10).max(1);
155 policy_budget.min(cache_budget)
156 }
157}
158
159impl Default for TileSelectionConfig {
160 fn default() -> Self {
161 Self {
162 visible_tile_budget: DEFAULT_VISIBLE_TILE_BUDGET,
163 flat_view: FlatTileSelectionConfig::default(),
164 source_min_zoom: 0,
165 source_max_zoom: 22,
166 raster_fade_duration: 0.0,
170 max_fading_ancestor_levels: 3,
171 max_child_depth: 2,
175 max_requests_per_frame: usize::MAX,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq)]
189pub struct TileTextureRegion {
190 pub u_min: f32,
192 pub v_min: f32,
194 pub u_max: f32,
196 pub v_max: f32,
198}
199
200impl TileTextureRegion {
201 pub const FULL: Self = Self {
203 u_min: 0.0,
204 v_min: 0.0,
205 u_max: 1.0,
206 v_max: 1.0,
207 };
208
209 pub fn from_tiles(target: &TileId, actual: &TileId) -> Self {
212 if target.zoom <= actual.zoom || *target == *actual {
213 return Self::FULL;
214 }
215
216 let dz = target.zoom - actual.zoom;
217 let scale = 1u32 << dz;
218
219 if target.x / scale != actual.x || target.y / scale != actual.y {
220 return Self::FULL;
221 }
222
223 let offset_x = target.x - actual.x * scale;
224 let offset_y = target.y - actual.y * scale;
225 let inv = 1.0 / scale as f32;
226
227 Self {
228 u_min: offset_x as f32 * inv,
229 v_min: offset_y as f32 * inv,
230 u_max: (offset_x + 1) as f32 * inv,
231 v_max: (offset_y + 1) as f32 * inv,
232 }
233 }
234
235 #[inline]
237 pub fn is_full(&self) -> bool {
238 *self == Self::FULL
239 }
240
241 pub fn from_child_tile(target: &TileId, child: &TileId) -> Option<Self> {
254 if child.zoom <= target.zoom {
255 return None;
256 }
257
258 let dz = child.zoom - target.zoom;
259 let scale = 1u32 << dz;
260
261 if child.x / scale != target.x || child.y / scale != target.y {
263 return None;
264 }
265
266 let offset_x = child.x - target.x * scale;
267 let offset_y = child.y - target.y * scale;
268 let inv = 1.0 / scale as f32;
269
270 Some(Self {
271 u_min: offset_x as f32 * inv,
272 v_min: offset_y as f32 * inv,
273 u_max: (offset_x + 1) as f32 * inv,
274 v_max: (offset_y + 1) as f32 * inv,
275 })
276 }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct TilePixelRect {
282 pub x: u32,
284 pub y: u32,
286 pub width: u32,
288 pub height: u32,
290}
291
292impl TilePixelRect {
293 #[inline]
295 pub fn full(width: u32, height: u32) -> Self {
296 Self {
297 x: 0,
298 y: 0,
299 width,
300 height,
301 }
302 }
303
304 pub fn from_tiles(target: &TileId, actual: &TileId, width: u32, height: u32) -> Option<Self> {
306 if width == 0 || height == 0 {
307 return None;
308 }
309 if target.zoom <= actual.zoom || *target == *actual {
310 return Some(Self::full(width, height));
311 }
312
313 let dz = (target.zoom - actual.zoom) as u32;
314 let scale = 1u32.checked_shl(dz)?;
315
316 if target.x / scale != actual.x || target.y / scale != actual.y {
317 return None;
318 }
319
320 let crop_w = width / scale;
321 let crop_h = height / scale;
322 if crop_w == 0 || crop_h == 0 {
323 return None;
324 }
325
326 let offset_x = target.x.checked_sub(actual.x.checked_mul(scale)?)?;
327 let offset_y = target.y.checked_sub(actual.y.checked_mul(scale)?)?;
328 let x = offset_x.checked_mul(crop_w)?;
329 let y = offset_y.checked_mul(crop_h)?;
330
331 if x.checked_add(crop_w)? > width || y.checked_add(crop_h)? > height {
332 return None;
333 }
334
335 Some(Self {
336 x,
337 y,
338 width: crop_w,
339 height: crop_h,
340 })
341 }
342}
343
344#[derive(Debug, Clone, Copy)]
349enum RequestUrgency {
350 Coverage,
351 FallbackRefine,
352 Refresh,
353}
354
355impl RequestUrgency {
356 #[inline]
357 fn rank(self) -> u8 {
358 match self {
359 Self::Coverage => 0,
360 Self::FallbackRefine => 1,
361 Self::Refresh => 2,
362 }
363 }
364}
365
366#[derive(Debug, Clone, Copy)]
367struct RequestCandidate {
368 tile: TileId,
369 distance_sq: f64,
370 urgency: RequestUrgency,
371}
372
373impl RequestCandidate {
374 fn new(tile: TileId, camera_world: (f64, f64), urgency: RequestUrgency) -> Self {
375 let bounds = tile_bounds_world(&tile);
376 let center_x = (bounds.min.position.x + bounds.max.position.x) * 0.5;
377 let center_y = (bounds.min.position.y + bounds.max.position.y) * 0.5;
378 let dx = center_x - camera_world.0;
379 let dy = center_y - camera_world.1;
380 Self {
381 tile,
382 distance_sq: dx * dx + dy * dy,
383 urgency,
384 }
385 }
386}
387
388fn sort_request_candidates(candidates: &mut [RequestCandidate]) {
389 candidates.sort_by(|a, b| {
390 a.urgency
391 .rank()
392 .cmp(&b.urgency.rank())
393 .then_with(|| a.tile.zoom.cmp(&b.tile.zoom))
394 .then_with(|| {
395 a.distance_sq
396 .partial_cmp(&b.distance_sq)
397 .unwrap_or(Ordering::Equal)
398 })
399 .then_with(|| a.tile.y.cmp(&b.tile.y))
400 .then_with(|| a.tile.x.cmp(&b.tile.x))
401 });
402}
403
404fn desired_with_ancestor_retention<'a>(
405 desired: impl IntoIterator<Item = &'a TileId>,
406) -> HashSet<TileId> {
407 let tiles: Vec<TileId> = desired.into_iter().copied().collect();
408 let mut retained = HashSet::with_capacity(tiles.len() * 2);
409 for tile in tiles {
410 retained.insert(tile);
411 let mut current = tile;
412 let mut depth = 0u8;
413 while depth < MAX_ANCESTOR_DEPTH {
414 if let Some(parent) = current.parent() {
415 retained.insert(parent);
416 current = parent;
417 depth += 1;
418 } else {
419 break;
420 }
421 }
422 }
423 retained
424}
425
426fn desired_with_temporal_retention(
427 current_desired: &[TileId],
428 previous_desired: &HashSet<TileId>,
429) -> HashSet<TileId> {
430 desired_with_ancestor_retention(current_desired.iter().chain(previous_desired.iter()))
431}
432
433fn tile_contains(ancestor: TileId, tile: TileId) -> bool {
434 if tile.zoom < ancestor.zoom {
435 return false;
436 }
437
438 let dz = tile.zoom - ancestor.zoom;
439 if dz == 0 {
440 return tile == ancestor;
441 }
442
443 (tile.x >> dz) == ancestor.x && (tile.y >> dz) == ancestor.y
444}
445
446fn tile_at_zoom(tile: TileId, zoom: u8) -> TileId {
447 if zoom >= tile.zoom {
448 return tile;
449 }
450
451 let dz = tile.zoom - zoom;
452 TileId::new(zoom, tile.x >> dz, tile.y >> dz)
453}
454
455fn tiles_within_horizon(a: TileId, b: TileId, radius: u32) -> bool {
456 a.x.abs_diff(b.x) <= radius && a.y.abs_diff(b.y) <= radius
457}
458
459fn pending_tile_relevant_to_desired(tile: TileId, desired: &HashSet<TileId>) -> bool {
460 const DESCENDANT_RETENTION_DEPTH: u8 = 2;
461 const NEIGHBOR_RETENTION_RADIUS: u32 = 1;
462
463 desired.iter().copied().any(|desired_tile| {
464 if tile == desired_tile || tile_contains(tile, desired_tile) {
465 return true;
466 }
467
468 if tile.zoom > desired_tile.zoom
469 && tile.zoom - desired_tile.zoom <= DESCENDANT_RETENTION_DEPTH
470 && tile_contains(desired_tile, tile)
471 {
472 return true;
473 }
474
475 let common_zoom = tile.zoom.min(desired_tile.zoom);
476 let tile_common = tile_at_zoom(tile, common_zoom);
477 let desired_common = tile_at_zoom(desired_tile, common_zoom);
478 if !tiles_within_horizon(tile_common, desired_common, NEIGHBOR_RETENTION_RADIUS) {
479 return false;
480 }
481
482 tile.zoom <= desired_tile.zoom
483 || tile.zoom - desired_tile.zoom <= DESCENDANT_RETENTION_DEPTH
484 })
485}
486
487fn tiles_along_route(route: &[GeoCoord], zoom: u8, camera_world: (f64, f64)) -> Vec<TileId> {
500 if route.len() < 2 {
501 return Vec::new();
502 }
503
504 let mut best_seg = 0usize;
506 let mut best_dist_sq = f64::MAX;
507 for (i, coord) in route.iter().enumerate() {
508 let w = WebMercator::project_clamped(coord);
509 let dx = w.position.x - camera_world.0;
510 let dy = w.position.y - camera_world.1;
511 let d2 = dx * dx + dy * dy;
512 if d2 < best_dist_sq {
513 best_dist_sq = d2;
514 best_seg = i;
515 }
516 }
517
518 let full_extent = 2.0 * WebMercator::max_extent();
523 let n_tiles = (1u64 << zoom) as f64;
524 let tile_width = full_extent / n_tiles;
525 let step = tile_width * 0.5;
527
528 let mut seen = HashSet::new();
529 let mut tiles = Vec::new();
530
531 let start = best_seg.min(route.len().saturating_sub(2));
533 for seg in start..route.len().saturating_sub(1) {
534 let a = WebMercator::project_clamped(&route[seg]);
535 let b = WebMercator::project_clamped(&route[seg + 1]);
536 let dx = b.position.x - a.position.x;
537 let dy = b.position.y - a.position.y;
538 let seg_len = (dx * dx + dy * dy).sqrt();
539 if seg_len < 1e-9 {
540 continue;
541 }
542 let steps = (seg_len / step).ceil() as usize;
543 for s in 0..=steps {
544 let t = if steps == 0 {
545 0.0
546 } else {
547 (s as f64) / (steps as f64)
548 };
549 let px = a.position.x + dx * t;
550 let py = a.position.y + dy * t;
551 let geo = WebMercator::unproject(&rustial_math::WorldCoord::new(px, py, 0.0));
552 let tile = geo_to_tile(&geo, zoom).tile_id();
553 if seen.insert(tile) {
554 tiles.push(tile);
555 }
556 }
557 }
558
559 tiles
560}
561
562fn overzoomed_display_targets(source_tile: &TileId, display_zoom: u8) -> Vec<TileId> {
573 if display_zoom <= source_tile.zoom {
574 return vec![*source_tile];
575 }
576 let dz = display_zoom - source_tile.zoom;
577 let scale = 1u32 << dz;
578 let base_x = source_tile.x * scale;
579 let base_y = source_tile.y * scale;
580 let mut targets = Vec::with_capacity((scale * scale) as usize);
581 for dy in 0..scale {
582 for dx in 0..scale {
583 targets.push(TileId::new(display_zoom, base_x + dx, base_y + dy));
584 }
585 }
586 targets
587}
588
589fn compute_fade_opacity(now: SystemTime, loaded_at: Option<SystemTime>, fade_duration: f32) -> f32 {
598 if fade_duration <= 0.0 {
599 return 1.0;
600 }
601 let Some(loaded) = loaded_at else {
602 return 1.0;
603 };
604 let elapsed = now.duration_since(loaded).unwrap_or_default().as_secs_f32();
605 (elapsed / fade_duration).clamp(0.0, 1.0)
606}
607
608fn emit_crossfade_parent(
615 visible: &mut VisibleTileSet,
616 child_target: TileId,
617 parent_opacity: f32,
618 max_levels: u8,
619 cache: &mut TileCache,
620) {
621 let mut current = child_target;
622 let mut depth = 0u8;
623 while depth < max_levels {
624 if let Some(parent) = current.parent() {
625 let loaded = cache.get(&parent).and_then(|entry| entry.data()).cloned();
626 if let Some(data) = loaded {
627 cache.touch(&parent);
628 visible.tiles.push(VisibleTile {
629 target: child_target,
630 actual: parent,
631 data: Some(data),
632 fade_opacity: parent_opacity,
633 });
634 return;
635 }
636 current = parent;
637 depth += 1;
638 } else {
639 break;
640 }
641 }
642}
643
644#[derive(Debug, Clone, Default, PartialEq, Eq)]
650pub struct TileSelectionStats {
651 pub raw_candidate_tiles: usize,
653 pub visible_tiles: usize,
655 pub exact_visible_tiles: usize,
657 pub fallback_visible_tiles: usize,
659 pub missing_visible_tiles: usize,
661 pub overzoomed_visible_tiles: usize,
663 pub dropped_by_budget: usize,
665 pub budget_hit: bool,
667 pub cancelled_stale_pending: usize,
669 pub requested_tiles: usize,
671 pub speculative_requested_tiles: usize,
673 pub exact_cache_hits: usize,
675 pub fallback_hits: usize,
677 pub child_fallback_hits: usize,
679 pub child_fallback_visible_tiles: usize,
681 pub cache_misses: usize,
683}
684
685#[derive(Debug, Clone, Default, PartialEq, Eq)]
687pub struct TileManagerCounters {
688 pub frames: u64,
690 pub budget_hit_frames: u64,
692 pub dropped_by_budget: u64,
694 pub exact_cache_hits: u64,
696 pub fallback_hits: u64,
698 pub child_fallback_hits: u64,
700 pub cache_misses: u64,
702 pub requested_tiles: u64,
704 pub speculative_requested_tiles: u64,
706 pub cancelled_stale_pending: u64,
708 pub cancelled_evicted_pending: u64,
710}
711
712#[derive(Debug, Default)]
718pub struct VisibleTileSet {
719 pub tiles: Vec<VisibleTile>,
721}
722
723impl VisibleTileSet {
724 #[inline]
726 pub fn len(&self) -> usize {
727 self.tiles.len()
728 }
729
730 #[inline]
732 pub fn is_empty(&self) -> bool {
733 self.tiles.is_empty()
734 }
735
736 #[inline]
738 pub fn loaded_count(&self) -> usize {
739 self.tiles.iter().filter(|t| t.data.is_some()).count()
740 }
741
742 #[inline]
744 pub fn iter(&self) -> std::slice::Iter<'_, VisibleTile> {
745 self.tiles.iter()
746 }
747}
748
749impl<'a> IntoIterator for &'a VisibleTileSet {
750 type Item = &'a VisibleTile;
751 type IntoIter = std::slice::Iter<'a, VisibleTile>;
752
753 fn into_iter(self) -> Self::IntoIter {
754 self.tiles.iter()
755 }
756}
757
758#[derive(Debug, Clone)]
765pub struct VisibleTile {
766 pub target: TileId,
768 pub actual: TileId,
770 pub data: Option<TileData>,
772 pub fade_opacity: f32,
779}
780
781impl VisibleTile {
782 #[inline]
784 pub fn is_loaded(&self) -> bool {
785 self.data.is_some()
786 }
787
788 #[inline]
790 pub fn is_fallback(&self) -> bool {
791 self.target != self.actual
792 }
793
794 #[inline]
798 pub fn is_overzoomed(&self) -> bool {
799 self.target.zoom > self.actual.zoom && self.data.is_some()
800 }
801
802 #[inline]
808 pub fn is_child_fallback(&self) -> bool {
809 self.actual.zoom > self.target.zoom && self.data.is_some()
810 }
811
812 #[inline]
821 pub fn texture_region(&self) -> TileTextureRegion {
822 if self.actual.zoom > self.target.zoom {
823 TileTextureRegion::FULL
826 } else {
827 TileTextureRegion::from_tiles(&self.target, &self.actual)
829 }
830 }
831
832 #[inline]
835 pub fn pixel_crop_rect(&self, width: u32, height: u32) -> Option<TilePixelRect> {
836 TilePixelRect::from_tiles(&self.target, &self.actual, width, height)
837 }
838}
839
840pub struct TileManager {
846 source: Box<dyn TileSource>,
847 cache: TileCache,
848 lifecycle: TileLifecycleTracker,
849 selection_config: TileSelectionConfig,
850 last_selection_stats: TileSelectionStats,
851 counters: TileManagerCounters,
852 last_desired_tiles: HashSet<TileId>,
853}
854
855impl std::fmt::Debug for TileManager {
856 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
857 f.debug_struct("TileManager")
858 .field("cache_len", &self.cache.len())
859 .field("cache_capacity", &self.cache.capacity())
860 .finish_non_exhaustive()
861 }
862}
863
864impl TileManager {
865 pub fn new(source: Box<dyn TileSource>, cache_capacity: usize) -> Self {
867 Self::new_with_config(source, cache_capacity, TileSelectionConfig::default())
868 }
869
870 pub fn new_with_config(
872 source: Box<dyn TileSource>,
873 cache_capacity: usize,
874 selection_config: TileSelectionConfig,
875 ) -> Self {
876 Self {
877 source,
878 cache: TileCache::new(cache_capacity),
879 lifecycle: TileLifecycleTracker::default(),
880 selection_config,
881 last_selection_stats: TileSelectionStats::default(),
882 counters: TileManagerCounters::default(),
883 last_desired_tiles: HashSet::new(),
884 }
885 }
886
887 pub fn update(
889 &mut self,
890 viewport_bounds: &WorldBounds,
891 zoom: u8,
892 camera_world: (f64, f64),
893 camera_distance: f64,
894 ) -> VisibleTileSet {
895 self.update_with_view(viewport_bounds, zoom, camera_world, camera_distance, None)
896 }
897
898 pub fn update_with_frustum(
906 &mut self,
907 frustum: &rustial_math::Frustum,
908 zoom: u8,
909 camera_world: (f64, f64),
910 ) -> VisibleTileSet {
911 self.begin_lifecycle_frame();
912 self.poll_completed();
913
914 let mut stats = TileSelectionStats::default();
915 let max_tiles = self
916 .selection_config
917 .effective_visible_tile_budget(self.cache.capacity());
918
919 let desired = rustial_math::visible_tiles_frustum(frustum, zoom, max_tiles, camera_world);
920 self.last_desired_tiles = desired.iter().copied().collect();
921
922 stats.raw_candidate_tiles = desired.len();
923
924 let desired_set = desired_with_ancestor_retention(&desired);
925 stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
926
927 let now = SystemTime::now();
928 for id in self.cache.expired_ids_at(now) {
929 let _ = self.cache.mark_expired(id);
930 }
931
932 let mut visible = VisibleTileSet {
933 tiles: Vec::with_capacity(desired.len()),
934 };
935 let mut missing = Vec::new();
936 let mut refresh = Vec::new();
937 let mut bootstrap = Vec::new();
938 let fade_duration = self.selection_config.raster_fade_duration;
939 let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
940 let max_child_depth = self.selection_config.max_child_depth;
941
942 for &target in &desired {
943 self.lifecycle.record_selected(target);
944 let cached = self.cache.get(&target).map(|entry| {
945 (
946 entry.data().cloned(),
947 entry
948 .freshness()
949 .is_some_and(|freshness| freshness.is_expired_at(now)),
950 entry.is_reloading(),
951 entry.loaded_at(),
952 entry.is_pending(),
953 )
954 });
955
956 match cached {
957 Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
958 self.cache.touch(&target);
959 if is_expired && !is_reloading && self.cache.start_reload(target) {
960 refresh.push(RequestCandidate::new(
961 target,
962 camera_world,
963 RequestUrgency::Refresh,
964 ));
965 }
966 stats.exact_cache_hits += 1;
967 stats.exact_visible_tiles += 1;
968
969 let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
970 if fade_opacity < 1.0 {
971 emit_crossfade_parent(
972 &mut visible,
973 target,
974 1.0 - fade_opacity,
975 max_ancestor_fade,
976 &mut self.cache,
977 );
978 }
979
980 visible.tiles.push(VisibleTile {
981 target,
982 actual: target,
983 data: Some(data),
984 fade_opacity,
985 });
986 self.record_visible_tile_use(target, target, true);
987 }
988 Some((None, _, _, _, is_pending)) => {
989 self.cache.touch(&target);
990 let children = self.find_loaded_children(&target, max_child_depth);
992 if !children.is_empty() {
993 stats.child_fallback_hits += 1;
994 for (child_id, child_data) in children {
995 stats.child_fallback_visible_tiles += 1;
996 visible.tiles.push(VisibleTile {
997 target,
998 actual: child_id,
999 data: Some(child_data),
1000 fade_opacity: 1.0,
1001 });
1002 self.record_visible_tile_use(target, child_id, true);
1003 }
1004 } else {
1005 let (actual, data) = self.find_loaded_ancestor(&target);
1006 if data.is_some() && actual != target {
1007 stats.fallback_hits += 1;
1008 stats.fallback_visible_tiles += 1;
1009 } else if data.is_none() {
1010 stats.cache_misses += 1;
1011 stats.missing_visible_tiles += 1;
1012 bootstrap.push(target);
1013 }
1014 visible.tiles.push(VisibleTile {
1015 target,
1016 actual,
1017 data,
1018 fade_opacity: 1.0,
1019 });
1020 self.record_visible_tile_use(
1021 target,
1022 actual,
1023 visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1024 );
1025 }
1026 if !is_pending {
1030 let urgency =
1031 if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1032 RequestUrgency::Coverage
1033 } else {
1034 RequestUrgency::FallbackRefine
1035 };
1036 self.cache.remove(&target);
1037 missing.push(RequestCandidate::new(target, camera_world, urgency));
1038 }
1039 }
1040 None => {
1041 let children = self.find_loaded_children(&target, max_child_depth);
1043 if !children.is_empty() {
1044 stats.child_fallback_hits += 1;
1045 for (child_id, child_data) in children {
1046 stats.child_fallback_visible_tiles += 1;
1047 visible.tiles.push(VisibleTile {
1048 target,
1049 actual: child_id,
1050 data: Some(child_data),
1051 fade_opacity: 1.0,
1052 });
1053 self.record_visible_tile_use(target, child_id, true);
1054 }
1055 } else {
1056 let (actual, data) = self.find_loaded_ancestor(&target);
1057 if data.is_some() && actual != target {
1058 stats.fallback_hits += 1;
1059 stats.fallback_visible_tiles += 1;
1060 } else if data.is_none() {
1061 stats.cache_misses += 1;
1062 stats.missing_visible_tiles += 1;
1063 bootstrap.push(target);
1064 }
1065 visible.tiles.push(VisibleTile {
1066 target,
1067 actual,
1068 data,
1069 fade_opacity: 1.0,
1070 });
1071 self.record_visible_tile_use(
1072 target,
1073 actual,
1074 visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1075 );
1076 }
1077 let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1078 RequestUrgency::Coverage
1079 } else {
1080 RequestUrgency::FallbackRefine
1081 };
1082 missing.push(RequestCandidate::new(target, camera_world, urgency));
1083 }
1084 }
1085 }
1086
1087 let (requested, cancelled_evicted_pending) =
1088 self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
1089
1090 stats.requested_tiles = requested.len();
1091 stats.visible_tiles = visible.tiles.len();
1092
1093 self.counters.frames += 1;
1094 if stats.budget_hit {
1095 self.counters.budget_hit_frames += 1;
1096 }
1097 self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
1098 self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
1099 self.counters.fallback_hits += stats.fallback_hits as u64;
1100 self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
1101 self.counters.cache_misses += stats.cache_misses as u64;
1102 self.counters.requested_tiles += stats.requested_tiles as u64;
1103 self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
1104 self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
1105 self.last_selection_stats = stats;
1106
1107 visible
1108 }
1109
1110 pub fn update_with_covering(
1118 &mut self,
1119 frustum: &rustial_math::Frustum,
1120 cam: &rustial_math::CoveringCamera,
1121 opts: &rustial_math::CoveringTilesOptions,
1122 camera_world: (f64, f64),
1123 ) -> VisibleTileSet {
1124 self.begin_lifecycle_frame();
1125 self.poll_completed();
1126
1127 let mut stats = TileSelectionStats::default();
1128 let max_tiles = self
1129 .selection_config
1130 .effective_visible_tile_budget(self.cache.capacity())
1131 .min(opts.max_tiles);
1132
1133 let effective_opts = rustial_math::CoveringTilesOptions {
1134 max_tiles,
1135 ..opts.clone()
1136 };
1137 let desired = rustial_math::visible_tiles_covering(frustum, cam, &effective_opts);
1138 let previous_desired = self.last_desired_tiles.clone();
1139
1140 stats.raw_candidate_tiles = desired.len();
1141
1142 let desired_set = desired_with_temporal_retention(&desired, &previous_desired);
1143 stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
1144 self.last_desired_tiles = desired.iter().copied().collect();
1145
1146 let now = SystemTime::now();
1147 for id in self.cache.expired_ids_at(now) {
1148 let _ = self.cache.mark_expired(id);
1149 }
1150
1151 let mut visible = VisibleTileSet {
1152 tiles: Vec::with_capacity(desired.len()),
1153 };
1154 let mut missing = Vec::new();
1155 let mut refresh = Vec::new();
1156 let fade_duration = self.selection_config.raster_fade_duration;
1157 let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
1158 let max_child_depth = self.selection_config.max_child_depth;
1159
1160 let mut bootstrap = Vec::new();
1161
1162 for &target in &desired {
1163 self.lifecycle.record_selected(target);
1164 let cached = self.cache.get(&target).map(|entry| {
1165 (
1166 entry.data().cloned(),
1167 entry
1168 .freshness()
1169 .is_some_and(|freshness| freshness.is_expired_at(now)),
1170 entry.is_reloading(),
1171 entry.loaded_at(),
1172 entry.is_pending(),
1173 )
1174 });
1175
1176 match cached {
1177 Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
1178 self.cache.touch(&target);
1179 if is_expired && !is_reloading && self.cache.start_reload(target) {
1180 refresh.push(RequestCandidate::new(
1181 target,
1182 camera_world,
1183 RequestUrgency::Refresh,
1184 ));
1185 }
1186 stats.exact_cache_hits += 1;
1187 stats.exact_visible_tiles += 1;
1188
1189 let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
1190 if fade_opacity < 1.0 {
1191 emit_crossfade_parent(
1192 &mut visible,
1193 target,
1194 1.0 - fade_opacity,
1195 max_ancestor_fade,
1196 &mut self.cache,
1197 );
1198 }
1199
1200 visible.tiles.push(VisibleTile {
1201 target,
1202 actual: target,
1203 data: Some(data),
1204 fade_opacity,
1205 });
1206 self.record_visible_tile_use(target, target, true);
1207 }
1208 Some((None, _, _, _, is_pending)) => {
1209 self.cache.touch(&target);
1210 let children = self.find_loaded_children(&target, max_child_depth);
1212 if !children.is_empty() {
1213 stats.child_fallback_hits += 1;
1214 for (child_id, child_data) in children {
1215 stats.child_fallback_visible_tiles += 1;
1216 visible.tiles.push(VisibleTile {
1217 target,
1218 actual: child_id,
1219 data: Some(child_data),
1220 fade_opacity: 1.0,
1221 });
1222 self.record_visible_tile_use(target, child_id, true);
1223 }
1224 } else {
1225 let (actual, data) = self.find_loaded_ancestor(&target);
1226 if data.is_some() && actual != target {
1227 stats.fallback_hits += 1;
1228 stats.fallback_visible_tiles += 1;
1229 } else if data.is_none() {
1230 stats.cache_misses += 1;
1231 stats.missing_visible_tiles += 1;
1232 bootstrap.push(target);
1233 }
1234 visible.tiles.push(VisibleTile {
1235 target,
1236 actual,
1237 data,
1238 fade_opacity: 1.0,
1239 });
1240 self.record_visible_tile_use(
1241 target,
1242 actual,
1243 visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1244 );
1245 }
1246 if !is_pending {
1250 let urgency =
1251 if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1252 RequestUrgency::Coverage
1253 } else {
1254 RequestUrgency::FallbackRefine
1255 };
1256 self.cache.remove(&target);
1257 missing.push(RequestCandidate::new(target, camera_world, urgency));
1258 }
1259 }
1260 None => {
1261 let children = self.find_loaded_children(&target, max_child_depth);
1263 if !children.is_empty() {
1264 stats.child_fallback_hits += 1;
1265 for (child_id, child_data) in children {
1266 stats.child_fallback_visible_tiles += 1;
1267 visible.tiles.push(VisibleTile {
1268 target,
1269 actual: child_id,
1270 data: Some(child_data),
1271 fade_opacity: 1.0,
1272 });
1273 self.record_visible_tile_use(target, child_id, true);
1274 }
1275 } else {
1276 let (actual, data) = self.find_loaded_ancestor(&target);
1277 if data.is_some() && actual != target {
1278 stats.fallback_hits += 1;
1279 stats.fallback_visible_tiles += 1;
1280 } else if data.is_none() {
1281 stats.cache_misses += 1;
1282 stats.missing_visible_tiles += 1;
1283 bootstrap.push(target);
1284 }
1285 visible.tiles.push(VisibleTile {
1286 target,
1287 actual,
1288 data,
1289 fade_opacity: 1.0,
1290 });
1291 self.record_visible_tile_use(
1292 target,
1293 actual,
1294 visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1295 );
1296 }
1297 let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1298 RequestUrgency::Coverage
1299 } else {
1300 RequestUrgency::FallbackRefine
1301 };
1302 missing.push(RequestCandidate::new(target, camera_world, urgency));
1303 }
1304 }
1305 }
1306
1307 let (requested, cancelled_evicted_pending) =
1308 self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
1309
1310 stats.requested_tiles = requested.len();
1311 stats.visible_tiles = visible.tiles.len();
1312
1313 self.counters.frames += 1;
1314 if stats.budget_hit {
1315 self.counters.budget_hit_frames += 1;
1316 }
1317 self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
1318 self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
1319 self.counters.fallback_hits += stats.fallback_hits as u64;
1320 self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
1321 self.counters.cache_misses += stats.cache_misses as u64;
1322 self.counters.requested_tiles += stats.requested_tiles as u64;
1323 self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
1324 self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
1325 self.last_selection_stats = stats;
1326
1327 visible
1328 }
1329
1330 pub fn update_with_view(
1333 &mut self,
1334 viewport_bounds: &WorldBounds,
1335 zoom: u8,
1336 camera_world: (f64, f64),
1337 _camera_distance: f64,
1338 flat_view: Option<&FlatTileView>,
1339 ) -> VisibleTileSet {
1340 self.begin_lifecycle_frame();
1341 self.poll_completed();
1342
1343 if zoom < self.selection_config.source_min_zoom {
1345 self.last_desired_tiles.clear();
1346 self.last_selection_stats = TileSelectionStats::default();
1347 return VisibleTileSet::default();
1348 }
1349
1350 let source_zoom = zoom.min(self.selection_config.source_max_zoom);
1351 let is_overzoomed = zoom > source_zoom;
1352
1353 let mut stats = TileSelectionStats::default();
1354 let max_tiles = self
1355 .selection_config
1356 .effective_visible_tile_budget(self.cache.capacity());
1357
1358 let mut source_tiles = if let Some(view) = flat_view {
1360 rustial_math::visible_tiles_flat_view_capped_with_config(
1361 viewport_bounds,
1362 source_zoom,
1363 view,
1364 &self.selection_config.flat_view,
1365 max_tiles,
1366 )
1367 } else {
1368 rustial_math::visible_tiles(viewport_bounds, source_zoom)
1369 };
1370
1371 stats.raw_candidate_tiles = source_tiles.len();
1372 if source_tiles.len() > max_tiles {
1373 stats.budget_hit = true;
1374 stats.dropped_by_budget = stats.raw_candidate_tiles - max_tiles;
1375 source_tiles.truncate(max_tiles);
1376 }
1377 let previous_desired = self.last_desired_tiles.clone();
1378
1379 let desired_set = desired_with_temporal_retention(&source_tiles, &previous_desired);
1386 stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
1387 self.last_desired_tiles = source_tiles.iter().copied().collect();
1388
1389 let now = SystemTime::now();
1390 for id in self.cache.expired_ids_at(now) {
1391 let _ = self.cache.mark_expired(id);
1392 }
1393
1394 let mut visible = VisibleTileSet {
1395 tiles: Vec::with_capacity(source_tiles.len()),
1396 };
1397 let mut missing = Vec::new();
1398 let mut refresh = Vec::new();
1399 let mut bootstrap = Vec::new();
1400 let fade_duration = self.selection_config.raster_fade_duration;
1401 let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
1402 let max_child_depth = self.selection_config.max_child_depth;
1403
1404 for &source_tile in &source_tiles {
1405 self.lifecycle.record_selected(source_tile);
1406 let display_targets = if is_overzoomed {
1410 overzoomed_display_targets(&source_tile, zoom)
1411 } else {
1412 vec![source_tile]
1413 };
1414
1415 let cached = self.cache.get(&source_tile).map(|entry| {
1416 (
1417 entry.data().cloned(),
1418 entry
1419 .freshness()
1420 .is_some_and(|freshness| freshness.is_expired_at(now)),
1421 entry.is_reloading(),
1422 entry.loaded_at(),
1423 entry.is_pending(),
1424 )
1425 });
1426
1427 match cached {
1428 Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
1429 self.cache.touch(&source_tile);
1430 if is_expired && !is_reloading && self.cache.start_reload(source_tile) {
1431 refresh.push(RequestCandidate::new(
1432 source_tile,
1433 camera_world,
1434 RequestUrgency::Refresh,
1435 ));
1436 }
1437 stats.exact_cache_hits += 1;
1438
1439 let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
1440
1441 for target in display_targets {
1442 if is_overzoomed {
1443 stats.overzoomed_visible_tiles += 1;
1444 } else {
1445 stats.exact_visible_tiles += 1;
1446 }
1447 if fade_opacity < 1.0 {
1448 emit_crossfade_parent(
1449 &mut visible,
1450 target,
1451 1.0 - fade_opacity,
1452 max_ancestor_fade,
1453 &mut self.cache,
1454 );
1455 }
1456 visible.tiles.push(VisibleTile {
1457 target,
1458 actual: source_tile,
1459 data: Some(data.clone()),
1460 fade_opacity,
1461 });
1462 self.record_visible_tile_use(target, source_tile, true);
1463 }
1464 }
1465 Some((None, _, _, _, is_pending)) => {
1466 self.cache.touch(&source_tile);
1467 let children = if !is_overzoomed {
1470 self.find_loaded_children(&source_tile, max_child_depth)
1471 } else {
1472 Vec::new()
1473 };
1474 if !children.is_empty() {
1475 stats.child_fallback_hits += 1;
1476 for (child_id, child_data) in children {
1477 for target in &display_targets {
1478 stats.child_fallback_visible_tiles += 1;
1479 visible.tiles.push(VisibleTile {
1480 target: *target,
1481 actual: child_id,
1482 data: Some(child_data.clone()),
1483 fade_opacity: 1.0,
1484 });
1485 self.record_visible_tile_use(*target, child_id, true);
1486 }
1487 }
1488 } else {
1489 let (actual, data) = self.find_loaded_ancestor(&source_tile);
1490 if data.is_some() && actual != source_tile {
1491 stats.fallback_hits += 1;
1492 } else if data.is_none() {
1493 stats.cache_misses += 1;
1494 bootstrap.push(source_tile);
1495 }
1496 for target in display_targets {
1497 if data.is_some() && actual != source_tile {
1498 stats.fallback_visible_tiles += 1;
1499 } else if data.is_none() {
1500 stats.missing_visible_tiles += 1;
1501 }
1502 visible.tiles.push(VisibleTile {
1503 target,
1504 actual,
1505 data: data.clone(),
1506 fade_opacity: 1.0,
1507 });
1508 self.record_visible_tile_use(
1509 target,
1510 actual,
1511 visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1512 );
1513 }
1514 }
1515 if !is_pending {
1519 let urgency =
1520 if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1521 RequestUrgency::Coverage
1522 } else {
1523 RequestUrgency::FallbackRefine
1524 };
1525 self.cache.remove(&source_tile);
1526 missing.push(RequestCandidate::new(source_tile, camera_world, urgency));
1527 }
1528 }
1529 None => {
1530 let children = if !is_overzoomed {
1532 self.find_loaded_children(&source_tile, max_child_depth)
1533 } else {
1534 Vec::new()
1535 };
1536 if !children.is_empty() {
1537 stats.child_fallback_hits += 1;
1538 for (child_id, child_data) in children {
1539 for target in &display_targets {
1540 stats.child_fallback_visible_tiles += 1;
1541 visible.tiles.push(VisibleTile {
1542 target: *target,
1543 actual: child_id,
1544 data: Some(child_data.clone()),
1545 fade_opacity: 1.0,
1546 });
1547 self.record_visible_tile_use(*target, child_id, true);
1548 }
1549 }
1550 } else {
1551 let (actual, data) = self.find_loaded_ancestor(&source_tile);
1552 if data.is_some() && actual != source_tile {
1553 stats.fallback_hits += 1;
1554 } else if data.is_none() {
1555 stats.cache_misses += 1;
1556 bootstrap.push(source_tile);
1557 }
1558 for target in display_targets {
1559 if data.is_some() && actual != source_tile {
1560 stats.fallback_visible_tiles += 1;
1561 } else if data.is_none() {
1562 stats.missing_visible_tiles += 1;
1563 }
1564 visible.tiles.push(VisibleTile {
1565 target,
1566 actual,
1567 data: data.clone(),
1568 fade_opacity: 1.0,
1569 });
1570 self.record_visible_tile_use(
1571 target,
1572 actual,
1573 visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
1574 );
1575 }
1576 }
1577 let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
1578 RequestUrgency::Coverage
1579 } else {
1580 RequestUrgency::FallbackRefine
1581 };
1582 missing.push(RequestCandidate::new(source_tile, camera_world, urgency));
1583 }
1584 }
1585 }
1586
1587 let (requested, cancelled_evicted_pending) =
1588 self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
1589
1590 stats.requested_tiles = requested.len();
1591 stats.visible_tiles = visible.tiles.len();
1592
1593 self.counters.frames += 1;
1594 if stats.budget_hit {
1595 self.counters.budget_hit_frames += 1;
1596 }
1597 self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
1598 self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
1599 self.counters.fallback_hits += stats.fallback_hits as u64;
1600 self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
1601 self.counters.cache_misses += stats.cache_misses as u64;
1602 self.counters.requested_tiles += stats.requested_tiles as u64;
1603 self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
1604 self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
1605 self.last_selection_stats = stats;
1606
1607 visible
1608 }
1609
1610 #[inline]
1611 pub fn last_selection_stats(&self) -> &TileSelectionStats {
1613 &self.last_selection_stats
1614 }
1615
1616 #[inline]
1617 pub fn desired_tiles(&self) -> &HashSet<TileId> {
1619 &self.last_desired_tiles
1620 }
1621
1622 #[inline]
1623 pub fn counters(&self) -> &TileManagerCounters {
1625 &self.counters
1626 }
1627
1628 #[inline]
1629 pub fn selection_config(&self) -> &TileSelectionConfig {
1631 &self.selection_config
1632 }
1633
1634 #[inline]
1635 pub fn set_selection_config(&mut self, config: TileSelectionConfig) {
1637 self.selection_config = config;
1638 }
1639
1640 #[inline]
1641 pub fn cache(&self) -> &TileCache {
1643 &self.cache
1644 }
1645
1646 #[inline]
1647 pub fn cache_stats(&self) -> TileCacheStats {
1649 self.cache.stats()
1650 }
1651
1652 #[inline]
1653 pub fn source_diagnostics(&self) -> Option<TileSourceDiagnostics> {
1655 self.source.diagnostics()
1656 }
1657
1658 #[inline]
1659 pub fn lifecycle_diagnostics(&self) -> TileLifecycleDiagnostics {
1661 self.lifecycle.diagnostics()
1662 }
1663
1664 #[inline]
1665 pub fn cached_count(&self) -> usize {
1667 self.cache.len()
1668 }
1669
1670 pub fn prefetch_with_view(
1676 &mut self,
1677 viewport_bounds: &WorldBounds,
1678 zoom: u8,
1679 camera_world: (f64, f64),
1680 flat_view: Option<&FlatTileView>,
1681 max_requests: usize,
1682 ) -> usize {
1683 if max_requests == 0 || zoom < self.selection_config.source_min_zoom {
1684 return 0;
1685 }
1686
1687 let source_zoom = zoom.min(self.selection_config.source_max_zoom);
1688 let max_tiles = self
1689 .selection_config
1690 .effective_visible_tile_budget(self.cache.capacity());
1691
1692 let mut predicted_tiles = if let Some(view) = flat_view {
1693 rustial_math::visible_tiles_flat_view_capped_with_config(
1694 viewport_bounds,
1695 source_zoom,
1696 view,
1697 &self.selection_config.flat_view,
1698 max_tiles,
1699 )
1700 } else {
1701 rustial_math::visible_tiles(viewport_bounds, source_zoom)
1702 };
1703 if predicted_tiles.len() > max_tiles {
1704 predicted_tiles.truncate(max_tiles);
1705 }
1706
1707 self.prefetch_tiles(predicted_tiles, camera_world, max_requests)
1708 }
1709
1710 pub fn prefetch_zoom_direction(
1713 &mut self,
1714 camera_world: (f64, f64),
1715 direction: ZoomPrefetchDirection,
1716 max_requests: usize,
1717 ) -> usize {
1718 if max_requests == 0 || self.last_desired_tiles.is_empty() {
1719 return 0;
1720 }
1721
1722 let mut anchors: Vec<RequestCandidate> = self
1723 .last_desired_tiles
1724 .iter()
1725 .copied()
1726 .map(|tile| RequestCandidate::new(tile, camera_world, RequestUrgency::Refresh))
1727 .collect();
1728 sort_request_candidates(&mut anchors);
1729
1730 let mut tiles = Vec::new();
1731 let mut seen = HashSet::new();
1732
1733 for anchor in anchors {
1734 match direction {
1735 ZoomPrefetchDirection::In => {
1736 if anchor.tile.zoom >= self.selection_config.source_max_zoom {
1737 continue;
1738 }
1739 for child in anchor.tile.children() {
1740 if seen.insert(child) {
1741 tiles.push(child);
1742 }
1743 }
1744 }
1745 ZoomPrefetchDirection::Out => {
1746 let Some(parent) = anchor.tile.parent() else {
1747 continue;
1748 };
1749 if parent.zoom < self.selection_config.source_min_zoom {
1750 continue;
1751 }
1752 if seen.insert(parent) {
1753 tiles.push(parent);
1754 }
1755 }
1756 }
1757 }
1758
1759 self.prefetch_tiles(tiles, camera_world, max_requests)
1760 }
1761
1762 pub fn prefetch_route(
1771 &mut self,
1772 route: &[GeoCoord],
1773 zoom: u8,
1774 camera_world: (f64, f64),
1775 max_requests: usize,
1776 ) -> usize {
1777 if max_requests == 0 || route.len() < 2 || zoom < self.selection_config.source_min_zoom {
1778 return 0;
1779 }
1780
1781 let source_zoom = zoom.min(self.selection_config.source_max_zoom);
1782 let tiles = tiles_along_route(route, source_zoom, camera_world);
1783 self.prefetch_tiles(tiles, camera_world, max_requests)
1784 }
1785
1786 fn prefetch_tiles<I>(
1787 &mut self,
1788 tiles: I,
1789 camera_world: (f64, f64),
1790 max_requests: usize,
1791 ) -> usize
1792 where
1793 I: IntoIterator<Item = TileId>,
1794 {
1795 if max_requests == 0 {
1796 return 0;
1797 }
1798
1799 let mut candidates: Vec<RequestCandidate> = tiles
1800 .into_iter()
1801 .filter(|tile| !self.last_desired_tiles.contains(tile))
1802 .filter(|tile| self.cache.get(tile).is_none())
1803 .map(|tile| RequestCandidate::new(tile, camera_world, RequestUrgency::Refresh))
1804 .collect();
1805
1806 if candidates.is_empty() {
1807 return 0;
1808 }
1809
1810 sort_request_candidates(&mut candidates);
1811 let mut requested = Vec::new();
1812 for candidate in candidates.into_iter().take(max_requests) {
1813 let insert = self.cache.insert_pending_with_eviction(candidate.tile);
1814 self.record_evicted_tiles(&insert.evicted);
1815 self.counters.cancelled_evicted_pending +=
1816 self.cancel_evicted_pending(&insert.evicted) as u64;
1817 if insert.inserted {
1818 self.lifecycle.record_queued(candidate.tile);
1819 requested.push(candidate.tile);
1820 }
1821 }
1822
1823 if !requested.is_empty() {
1824 for &tile in &requested {
1825 self.lifecycle.record_dispatched(tile);
1826 }
1827 self.source.request_many(&requested);
1828 }
1829
1830 let requested_count = requested.len();
1831 self.last_selection_stats.speculative_requested_tiles += requested_count;
1832 self.counters.speculative_requested_tiles += requested_count as u64;
1833 requested_count
1834 }
1835
1836 #[inline]
1837 fn begin_lifecycle_frame(&mut self) {
1838 self.lifecycle.begin_frame(self.counters.frames + 1);
1839 }
1840
1841 #[inline]
1842 fn record_visible_tile_use(&mut self, target: TileId, actual: TileId, has_data: bool) {
1843 if !has_data {
1844 return;
1845 }
1846
1847 if target == actual {
1848 self.lifecycle.record_used_as_exact(actual);
1849 } else {
1850 self.lifecycle.record_used_as_fallback(actual);
1851 }
1852 }
1853
1854 fn record_evicted_tiles(&mut self, evicted: &[crate::tile_cache::EvictedTile]) {
1855 for tile in evicted {
1856 if tile.was_pending() {
1857 self.lifecycle.record_evicted_while_pending(tile.id);
1858 } else if tile.entry.is_renderable() {
1859 self.lifecycle.record_evicted_after_renderable_use(tile.id);
1860 }
1861 }
1862 }
1863
1864 pub fn promote_decoded(&mut self, decoded: Vec<(TileId, crate::tile_source::TileResponse)>) {
1872 for (id, response) in decoded {
1873 match response.data.validate() {
1874 Ok(()) => {
1875 self.lifecycle.record_decoded(id);
1876 let evicted = self.cache.promote_with_eviction(id, response);
1877 self.lifecycle.record_promoted_to_cache(id);
1878 self.record_evicted_tiles(&evicted);
1879 let cancelled = self.cancel_evicted_pending(&evicted);
1880 self.counters.cancelled_evicted_pending += cancelled as u64;
1881 }
1882 Err(err) => {
1883 self.lifecycle.record_failed(id);
1884 self.cache.mark_failed(id, &err)
1885 }
1886 }
1887 }
1888 }
1889
1890 fn poll_completed(&mut self) {
1891 let completed = self.source.poll();
1892 for (id, result) in completed {
1893 match result {
1894 Ok(response) if response.not_modified => {
1895 self.lifecycle.record_completed(id);
1896 self.cache.refresh_ttl(id, response.freshness);
1898 }
1899 Ok(response) => match response.data.validate() {
1900 Ok(()) => {
1901 self.lifecycle.record_completed(id);
1902 self.lifecycle.record_decoded(id);
1903 let evicted = self.cache.promote_with_eviction(id, response);
1904 self.lifecycle.record_promoted_to_cache(id);
1905 self.record_evicted_tiles(&evicted);
1906 let cancelled = self.cancel_evicted_pending(&evicted);
1907 self.counters.cancelled_evicted_pending += cancelled as u64;
1908 }
1909 Err(err) => {
1910 self.lifecycle.record_completed(id);
1911 self.lifecycle.record_failed(id);
1912 self.cache.mark_failed(id, &err)
1913 }
1914 },
1915 Err(err) => {
1916 self.lifecycle.record_completed(id);
1917 self.lifecycle.record_failed(id);
1918 self.cache.mark_failed(id, &err)
1919 }
1920 }
1921 }
1922 }
1923
1924 fn cancel_evicted_pending(&self, evicted: &[crate::tile_cache::EvictedTile]) -> usize {
1926 let pending_ids: Vec<TileId> = evicted
1927 .iter()
1928 .filter(|tile| tile.was_pending())
1929 .map(|tile| tile.id)
1930 .collect();
1931 self.source.cancel_many(&pending_ids);
1932 pending_ids.len()
1933 }
1934
1935 fn prune_stale_pending(&mut self, desired: &HashSet<TileId>) -> usize {
1936 let stale_pending: Vec<TileId> = self
1937 .cache
1938 .inflight_ids()
1939 .into_iter()
1940 .filter(|id| !pending_tile_relevant_to_desired(*id, desired))
1941 .collect();
1942
1943 self.source.cancel_many(&stale_pending);
1944 for id in &stale_pending {
1945 self.lifecycle.record_cancelled_as_stale(*id);
1946 if !self.cache.cancel_reload(id) {
1951 self.cache.remove(id);
1952 }
1953 }
1954 stale_pending.len()
1955 }
1956
1957 fn find_loaded_ancestor(&mut self, tile: &TileId) -> (TileId, Option<TileData>) {
1958 let mut current = *tile;
1959 let mut depth = 0u8;
1960 while depth < MAX_ANCESTOR_DEPTH {
1961 if let Some(parent) = current.parent() {
1962 let loaded = self
1963 .cache
1964 .get(&parent)
1965 .and_then(|entry| entry.data())
1966 .cloned();
1967 if let Some(data) = loaded {
1968 self.cache.touch(&parent);
1969 return (parent, Some(data));
1970 }
1971 current = parent;
1972 depth += 1;
1973 } else {
1974 break;
1975 }
1976 }
1977 (*tile, None)
1978 }
1979
1980 fn find_loaded_children(&mut self, target: &TileId, max_depth: u8) -> Vec<(TileId, TileData)> {
1990 if max_depth == 0 {
1991 return Vec::new();
1992 }
1993
1994 let mut frontier = vec![*target];
1997
1998 for _depth in 0..max_depth {
1999 let mut next_frontier = Vec::with_capacity(frontier.len() * 4);
2000 let mut all_loaded = true;
2001 let mut children_data = Vec::with_capacity(frontier.len() * 4);
2002
2003 for tile in &frontier {
2004 for child in tile.children() {
2005 let loaded = self
2006 .cache
2007 .get(&child)
2008 .and_then(|entry| entry.data())
2009 .cloned();
2010 if let Some(data) = loaded {
2011 children_data.push((child, data));
2012 next_frontier.push(child);
2013 } else {
2014 all_loaded = false;
2015 break;
2016 }
2017 }
2018 if !all_loaded {
2019 break;
2020 }
2021 }
2022
2023 if all_loaded && !children_data.is_empty() {
2024 for (child_id, _) in &children_data {
2026 self.cache.touch(child_id);
2027 }
2028 return children_data;
2029 }
2030
2031 frontier = next_frontier;
2032 }
2033
2034 Vec::new()
2035 }
2036
2037 fn request_parent_chain(
2038 &mut self,
2039 tile: TileId,
2040 _camera_world: (f64, f64),
2041 requested: &mut Vec<TileId>,
2042 requested_set: &mut HashSet<TileId>,
2043 cancelled_evicted_pending: &mut usize,
2044 ) {
2045 let mut chain = Vec::new();
2046 let mut current = tile;
2047 let mut depth = 0u8;
2048 while depth < MAX_ANCESTOR_DEPTH {
2049 let Some(parent) = current.parent() else {
2050 break;
2051 };
2052 match self.cache.get(&parent) {
2053 Some(entry)
2054 if entry.is_renderable() || entry.is_pending() || entry.is_reloading() =>
2055 {
2056 break
2057 }
2058 Some(_) => {
2059 current = parent;
2060 depth += 1;
2061 }
2062 None => {
2063 chain.push(parent);
2064 current = parent;
2065 depth += 1;
2066 }
2067 }
2068 }
2069
2070 chain.reverse();
2071 for ancestor in chain {
2072 if !requested_set.insert(ancestor) {
2073 continue;
2074 }
2075 let insert = self.cache.insert_pending_with_eviction(ancestor);
2076 self.record_evicted_tiles(&insert.evicted);
2077 *cancelled_evicted_pending += self.cancel_evicted_pending(&insert.evicted);
2078 if insert.inserted {
2079 self.lifecycle.record_queued(ancestor);
2080 requested.push(ancestor);
2081 }
2082 }
2083 }
2084
2085 fn request_tiles_with_bootstrap(
2086 &mut self,
2087 refresh: &mut Vec<RequestCandidate>,
2088 missing: &mut Vec<RequestCandidate>,
2089 bootstrap: &[TileId],
2090 camera_world: (f64, f64),
2091 ) -> (Vec<TileId>, usize) {
2092 sort_request_candidates(refresh);
2093 sort_request_candidates(missing);
2094
2095 let budget = self.selection_config.max_requests_per_frame;
2098
2099 let mut requested = Vec::with_capacity(refresh.len() + missing.len() + bootstrap.len() * 2);
2100 let mut requested_set = HashSet::with_capacity(requested.capacity().max(1));
2101 let mut cancelled_evicted_pending = 0usize;
2102
2103 let mut revalidate_pairs: Vec<(TileId, crate::tile_source::RevalidationHint)> =
2107 Vec::with_capacity(refresh.len());
2108 for candidate in refresh.drain(..) {
2109 if requested_set.insert(candidate.tile) {
2110 let hint = self
2111 .cache
2112 .revalidation_hint(&candidate.tile)
2113 .unwrap_or_default();
2114 revalidate_pairs.push((candidate.tile, hint));
2115 requested.push(candidate.tile);
2116 }
2117 }
2118
2119 let pre_bootstrap_len = requested.len();
2122
2123 for &tile in bootstrap {
2124 self.request_parent_chain(
2125 tile,
2126 camera_world,
2127 &mut requested,
2128 &mut requested_set,
2129 &mut cancelled_evicted_pending,
2130 );
2131 }
2132
2133 let mut new_request_ids: Vec<TileId> = requested[pre_bootstrap_len..].to_vec();
2136 for candidate in missing.drain(..) {
2137 if new_request_ids.len() >= budget {
2141 break;
2142 }
2143 if !requested_set.insert(candidate.tile) {
2144 continue;
2145 }
2146 let insert = self.cache.insert_pending_with_eviction(candidate.tile);
2147 self.record_evicted_tiles(&insert.evicted);
2148 cancelled_evicted_pending += self.cancel_evicted_pending(&insert.evicted);
2149 if insert.inserted {
2150 self.lifecycle.record_queued(candidate.tile);
2151 requested.push(candidate.tile);
2152 new_request_ids.push(candidate.tile);
2153 }
2154 }
2155
2156 if !new_request_ids.is_empty() {
2159 for &tile in &new_request_ids {
2160 self.lifecycle.record_dispatched(tile);
2161 }
2162 self.source.request_many(&new_request_ids);
2163 }
2164 if !revalidate_pairs.is_empty() {
2167 for (tile, _) in &revalidate_pairs {
2168 self.lifecycle.record_dispatched(*tile);
2169 }
2170 self.source.request_revalidate_many(&revalidate_pairs);
2171 }
2172 (requested, cancelled_evicted_pending)
2173 }
2174}
2175
2176#[cfg(test)]
2177mod tests {
2178 use super::*;
2179 use crate::tile_cache::TileCacheEntry;
2180 use crate::tile_lifecycle::TileLifecycleEventKind;
2181 use crate::tile_source::{DecodedImage, TileData, TileError, TileResponse, TileSource};
2182 use rustial_math::tile_bounds_world;
2183 use std::sync::{Arc, Mutex};
2184
2185 struct MockSource {
2186 ready: Mutex<Vec<(TileId, Result<TileResponse, TileError>)>>,
2187 }
2188
2189 impl MockSource {
2190 fn new() -> Self {
2191 Self {
2192 ready: Mutex::new(Vec::new()),
2193 }
2194 }
2195 }
2196
2197 impl TileSource for MockSource {
2198 fn request(&self, id: TileId) {
2199 let data = TileData::Raster(DecodedImage {
2200 width: 256,
2201 height: 256,
2202 data: vec![128u8; 256 * 256 * 4].into(),
2203 });
2204 self.ready
2205 .lock()
2206 .unwrap()
2207 .push((id, Ok(TileResponse::from_data(data))));
2208 }
2209
2210 fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2211 let mut ready = self.ready.lock().unwrap();
2212 std::mem::take(&mut *ready)
2213 }
2214 }
2215
2216 struct FailingSource;
2217
2218 impl TileSource for FailingSource {
2219 fn request(&self, _id: TileId) {}
2220
2221 fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2222 Vec::new()
2223 }
2224 }
2225
2226 struct DelayedFailSource {
2227 pending: Mutex<Vec<TileId>>,
2228 }
2229
2230 impl DelayedFailSource {
2231 fn new() -> Self {
2232 Self {
2233 pending: Mutex::new(Vec::new()),
2234 }
2235 }
2236 }
2237
2238 impl TileSource for DelayedFailSource {
2239 fn request(&self, id: TileId) {
2240 self.pending.lock().unwrap().push(id);
2241 }
2242
2243 fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2244 let ids: Vec<TileId> = std::mem::take(&mut *self.pending.lock().unwrap());
2245 ids.into_iter()
2246 .map(|id| (id, Err(TileError::Network("timeout".into()))))
2247 .collect()
2248 }
2249 }
2250
2251 #[derive(Clone, Default)]
2252 struct RecordingSource {
2253 requested: Arc<Mutex<Vec<TileId>>>,
2254 cancelled: Arc<Mutex<Vec<TileId>>>,
2255 }
2256
2257 impl RecordingSource {
2258 fn requested_ids(&self) -> Vec<TileId> {
2259 self.requested.lock().unwrap().clone()
2260 }
2261
2262 fn cancelled_ids(&self) -> Vec<TileId> {
2263 self.cancelled.lock().unwrap().clone()
2264 }
2265 }
2266
2267 impl TileSource for RecordingSource {
2268 fn request(&self, id: TileId) {
2269 self.requested.lock().unwrap().push(id);
2270 }
2271
2272 fn request_many(&self, ids: &[TileId]) {
2273 self.requested.lock().unwrap().extend_from_slice(ids);
2274 }
2275
2276 fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2277 Vec::new()
2278 }
2279
2280 fn cancel(&self, id: TileId) {
2281 self.cancelled.lock().unwrap().push(id);
2282 }
2283
2284 fn cancel_many(&self, ids: &[TileId]) {
2285 self.cancelled.lock().unwrap().extend_from_slice(ids);
2286 }
2287 }
2288
2289 struct InvalidImageSource {
2290 ready: Mutex<Vec<(TileId, Result<TileResponse, TileError>)>>,
2291 }
2292
2293 impl InvalidImageSource {
2294 fn new() -> Self {
2295 Self {
2296 ready: Mutex::new(Vec::new()),
2297 }
2298 }
2299 }
2300
2301 impl TileSource for InvalidImageSource {
2302 fn request(&self, id: TileId) {
2303 self.ready.lock().unwrap().push((
2304 id,
2305 Ok(TileResponse::from_data(TileData::Raster(DecodedImage {
2306 width: 2,
2307 height: 2,
2308 data: vec![255u8; 15].into(),
2309 }))),
2310 ));
2311 }
2312
2313 fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
2314 std::mem::take(&mut *self.ready.lock().unwrap())
2315 }
2316 }
2317
2318 fn full_world_bounds() -> WorldBounds {
2319 let extent = rustial_math::WebMercator::max_extent();
2320 WorldBounds::new(
2321 rustial_math::WorldCoord::new(-extent, -extent, 0.0),
2322 rustial_math::WorldCoord::new(extent, extent, 0.0),
2323 )
2324 }
2325
2326 fn dummy_tile_data() -> TileData {
2327 TileData::Raster(DecodedImage {
2328 width: 256,
2329 height: 256,
2330 data: vec![0u8; 256 * 256 * 4].into(),
2331 })
2332 }
2333
2334 fn dummy_tile_response() -> TileResponse {
2335 TileResponse::from_data(dummy_tile_data())
2336 }
2337
2338 fn tile_center(tile: TileId) -> (f64, f64) {
2339 let bounds = tile_bounds_world(&tile);
2340 (
2341 (bounds.min.position.x + bounds.max.position.x) * 0.5,
2342 (bounds.min.position.y + bounds.max.position.y) * 0.5,
2343 )
2344 }
2345
2346 fn inset_bounds(bounds: WorldBounds, inset: f64) -> WorldBounds {
2347 WorldBounds::new(
2348 rustial_math::WorldCoord::new(
2349 bounds.min.position.x + inset,
2350 bounds.min.position.y + inset,
2351 0.0,
2352 ),
2353 rustial_math::WorldCoord::new(
2354 bounds.max.position.x - inset,
2355 bounds.max.position.y - inset,
2356 0.0,
2357 ),
2358 )
2359 }
2360
2361 #[test]
2362 fn zoom_0_one_tile() {
2363 let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2364
2365 let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2366 assert_eq!(vis.len(), 1);
2367 assert!(vis.tiles[0].data.is_none());
2368
2369 let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2370 assert_eq!(vis.len(), 1);
2371 assert!(vis.tiles[0].is_loaded());
2372 assert!(!vis.tiles[0].is_fallback());
2373 }
2374
2375 #[test]
2376 fn zoom_1_four_tiles() {
2377 let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2378
2379 let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2380 let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2381 assert_eq!(vis.len(), 4);
2382 for tile in &vis {
2383 assert!(tile.is_loaded());
2384 }
2385 }
2386
2387 #[test]
2388 fn parent_fallback_when_pending() {
2389 let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2390
2391 let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2392 let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2393 assert!(vis.tiles[0].is_loaded());
2394
2395 let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2396 assert_eq!(vis.len(), 4);
2397 for tile in &vis {
2398 assert_eq!(tile.target.zoom, 1);
2399 assert_eq!(tile.actual.zoom, 0);
2400 assert!(tile.is_loaded());
2401 assert!(tile.is_fallback());
2402 }
2403 }
2404
2405 #[test]
2406 fn no_fallback_when_no_ancestor_loaded() {
2407 let mut mgr = TileManager::new(Box::new(FailingSource), 100);
2408
2409 let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2410 assert_eq!(vis.len(), 1);
2411 assert!(!vis.tiles[0].is_loaded());
2412 assert!(!vis.tiles[0].is_fallback());
2413 }
2414
2415 #[test]
2416 fn failed_tile_uses_parent_fallback() {
2417 let mut mgr = TileManager::new(Box::new(DelayedFailSource::new()), 100);
2418
2419 let z0 = TileId::new(0, 0, 0);
2420 mgr.cache.insert_pending(z0);
2421 mgr.cache.promote(z0, dummy_tile_response());
2422
2423 let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2424 let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2425 assert_eq!(vis.len(), 4);
2426 for tile in &vis {
2427 assert_eq!(tile.target.zoom, 1);
2428 assert_eq!(tile.actual.zoom, 0);
2429 assert!(tile.is_loaded());
2430 assert!(tile.is_fallback());
2431 }
2432 }
2433
2434 #[test]
2435 fn cache_accessor() {
2436 let mgr = TileManager::new(Box::new(MockSource::new()), 50);
2437 assert!(mgr.cache().is_empty());
2438 assert_eq!(mgr.cache().capacity(), 50);
2439 assert_eq!(mgr.cached_count(), 0);
2440 }
2441
2442 #[test]
2443 fn no_duplicate_requests() {
2444 let mut mgr = TileManager::new(Box::new(FailingSource), 100);
2445
2446 let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2447 let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2448 assert_eq!(mgr.cached_count(), 1);
2449 }
2450
2451 #[test]
2452 fn visible_tile_set_helpers() {
2453 let set = VisibleTileSet::default();
2454 assert!(set.is_empty());
2455 assert_eq!(set.len(), 0);
2456 assert_eq!(set.loaded_count(), 0);
2457
2458 let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2459 let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2460 let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2461 assert_eq!(vis.len(), 1);
2462 assert_eq!(vis.loaded_count(), 1);
2463 assert_eq!(vis.iter().count(), 1);
2464 }
2465
2466 #[test]
2467 fn visible_tile_is_fallback() {
2468 let tile_exact = VisibleTile {
2469 target: TileId::new(1, 0, 0),
2470 actual: TileId::new(1, 0, 0),
2471 data: None,
2472 fade_opacity: 1.0,
2473 };
2474 assert!(!tile_exact.is_fallback());
2475
2476 let tile_fallback = VisibleTile {
2477 target: TileId::new(1, 0, 0),
2478 actual: TileId::new(0, 0, 0),
2479 data: None,
2480 fade_opacity: 1.0,
2481 };
2482 assert!(tile_fallback.is_fallback());
2483 }
2484
2485 #[test]
2486 fn visible_tile_texture_region_matches_parent_subrect() {
2487 let tile = VisibleTile {
2488 target: TileId::new(3, 4, 2),
2489 actual: TileId::new(1, 1, 0),
2490 data: None,
2491 fade_opacity: 1.0,
2492 };
2493
2494 let region = tile.texture_region();
2495 assert!((region.u_min - 0.0).abs() < 1e-6);
2496 assert!((region.v_min - 0.5).abs() < 1e-6);
2497 assert!((region.u_max - 0.25).abs() < 1e-6);
2498 assert!((region.v_max - 0.75).abs() < 1e-6);
2499 }
2500
2501 #[test]
2502 fn visible_tile_pixel_crop_rect_matches_parent_subrect() {
2503 let tile = VisibleTile {
2504 target: TileId::new(3, 4, 2),
2505 actual: TileId::new(1, 1, 0),
2506 data: None,
2507 fade_opacity: 1.0,
2508 };
2509
2510 let crop = tile.pixel_crop_rect(256, 256).unwrap();
2511 assert_eq!(crop.x, 0);
2512 assert_eq!(crop.y, 128);
2513 assert_eq!(crop.width, 64);
2514 assert_eq!(crop.height, 64);
2515 }
2516
2517 #[test]
2518 fn debug_impl() {
2519 let mgr = TileManager::new(Box::new(MockSource::new()), 100);
2520 let dbg = format!("{mgr:?}");
2521 assert!(dbg.contains("TileManager"));
2522 assert!(dbg.contains("cache_len"));
2523 }
2524
2525 #[test]
2526 fn requests_missing_tiles_nearest_first_within_same_zoom() {
2527 let source = RecordingSource::default();
2528 let mut mgr = TileManager::new(Box::new(source.clone()), 100);
2529 let focus = TileId::new(1, 0, 0);
2530
2531 let _ = mgr.update(&full_world_bounds(), 1, tile_center(focus), 0.0);
2532
2533 let requested = source.requested_ids();
2534 assert_eq!(requested.len(), 5);
2535 assert_eq!(requested[0], TileId::new(0, 0, 0));
2536 assert_eq!(requested[1], focus);
2537 }
2538
2539 #[test]
2540 fn coverage_requests_sort_ahead_of_refresh_requests() {
2541 let coverage = TileId::new(4, 8, 8);
2542 let refresh = TileId::new(4, 7, 7);
2543 let camera_world = tile_center(refresh);
2544
2545 let mut candidates = vec![
2546 RequestCandidate::new(refresh, camera_world, RequestUrgency::Refresh),
2547 RequestCandidate::new(coverage, camera_world, RequestUrgency::Coverage),
2548 ];
2549
2550 sort_request_candidates(&mut candidates);
2551
2552 assert_eq!(candidates[0].tile, coverage);
2553 assert_eq!(candidates[1].tile, refresh);
2554 }
2555
2556 #[test]
2557 fn coverage_requests_sort_ahead_of_fallback_refine_requests() {
2558 let coverage = TileId::new(4, 8, 8);
2559 let fallback_refine = TileId::new(4, 7, 7);
2560 let camera_world = tile_center(fallback_refine);
2561
2562 let mut candidates = vec![
2563 RequestCandidate::new(
2564 fallback_refine,
2565 camera_world,
2566 RequestUrgency::FallbackRefine,
2567 ),
2568 RequestCandidate::new(coverage, camera_world, RequestUrgency::Coverage),
2569 ];
2570
2571 sort_request_candidates(&mut candidates);
2572
2573 assert_eq!(candidates[0].tile, coverage);
2574 assert_eq!(candidates[1].tile, fallback_refine);
2575 }
2576
2577 #[test]
2578 fn fallback_refine_requests_sort_ahead_of_refresh_requests() {
2579 let fallback_refine = TileId::new(4, 8, 8);
2580 let refresh = TileId::new(4, 7, 7);
2581 let camera_world = tile_center(refresh);
2582
2583 let mut candidates = vec![
2584 RequestCandidate::new(refresh, camera_world, RequestUrgency::Refresh),
2585 RequestCandidate::new(
2586 fallback_refine,
2587 camera_world,
2588 RequestUrgency::FallbackRefine,
2589 ),
2590 ];
2591
2592 sort_request_candidates(&mut candidates);
2593
2594 assert_eq!(candidates[0].tile, fallback_refine);
2595 assert_eq!(candidates[1].tile, refresh);
2596 }
2597
2598 #[test]
2599 fn coarse_tiles_sort_first_within_same_priority_tier() {
2600 let coarse = TileId::new(3, 4, 4);
2601 let fine = TileId::new(5, 16, 16);
2602 let camera_world = tile_center(fine);
2603
2604 let mut candidates = vec![
2605 RequestCandidate::new(fine, camera_world, RequestUrgency::FallbackRefine),
2606 RequestCandidate::new(coarse, camera_world, RequestUrgency::FallbackRefine),
2607 ];
2608
2609 sort_request_candidates(&mut candidates);
2610
2611 assert_eq!(candidates[0].tile, coarse);
2612 assert_eq!(candidates[1].tile, fine);
2613 }
2614
2615 #[test]
2616 fn visible_requests_dispatch_before_refresh_revalidations() {
2617 let source = RecordingSource::default();
2618 let mut mgr = TileManager::new(Box::new(source.clone()), 64);
2619 let camera_world = (0.0, 0.0);
2620 let refresh_tile = TileId::new(4, 7, 7);
2621 let fallback_tile = TileId::new(4, 8, 8);
2622
2623 let mut refresh = vec![RequestCandidate::new(
2624 refresh_tile,
2625 camera_world,
2626 RequestUrgency::Refresh,
2627 )];
2628 let mut missing = vec![RequestCandidate::new(
2629 fallback_tile,
2630 camera_world,
2631 RequestUrgency::FallbackRefine,
2632 )];
2633
2634 let (requested, _) =
2635 mgr.request_tiles_with_bootstrap(&mut refresh, &mut missing, &[], camera_world);
2636
2637 assert_eq!(requested, vec![refresh_tile, fallback_tile]);
2638 assert_eq!(source.requested_ids(), vec![fallback_tile, refresh_tile]);
2639 }
2640
2641 #[test]
2642 fn cancels_pending_requests_that_scroll_out_of_view() {
2643 let source = RecordingSource::default();
2644 let mut mgr = TileManager::new(Box::new(source.clone()), 100);
2645 let keep = TileId::new(1, 0, 0);
2646
2647 let _ = mgr.update(&full_world_bounds(), 1, tile_center(keep), 0.0);
2648 assert_eq!(mgr.cached_count(), 5);
2649
2650 let narrow_bounds = inset_bounds(tile_bounds_world(&keep), 1.0);
2651 let vis = mgr.update(&narrow_bounds, 1, tile_center(keep), 0.0);
2652
2653 assert_eq!(vis.len(), 1);
2654 assert_eq!(mgr.cached_count(), 5);
2655
2656 let cancelled = source.cancelled_ids();
2657 assert!(cancelled.is_empty());
2658 assert!(!cancelled.contains(&keep));
2659 }
2660
2661 #[test]
2662 fn invalid_completed_tile_is_retried_after_failure() {
2663 let mut mgr = TileManager::new(Box::new(InvalidImageSource::new()), 100);
2664
2665 let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2667 let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2670
2671 assert_eq!(vis.len(), 1);
2672 assert!(!vis.tiles[0].is_loaded());
2673 assert!(matches!(
2676 mgr.cache.get(&TileId::new(0, 0, 0)),
2677 Some(TileCacheEntry::Pending)
2678 ));
2679 }
2680
2681 #[test]
2682 fn tiny_cache_caps_requests_to_avoid_thrashing() {
2683 let source = RecordingSource::default();
2684 let mut mgr = TileManager::new_with_config(
2685 Box::new(source.clone()),
2686 1,
2687 TileSelectionConfig {
2688 visible_tile_budget: 1,
2689 ..TileSelectionConfig::default()
2690 },
2691 );
2692
2693 let _ = mgr.update(
2694 &full_world_bounds(),
2695 1,
2696 tile_center(TileId::new(1, 0, 0)),
2697 0.0,
2698 );
2699
2700 let requested = source.requested_ids();
2701 let cancelled = source.cancelled_ids();
2702
2703 assert_eq!(requested.len(), 2);
2704 assert_eq!(requested[0], TileId::new(0, 0, 0));
2705 assert_eq!(cancelled.len(), 1);
2706 assert_eq!(cancelled[0], TileId::new(0, 0, 0));
2707 assert_eq!(mgr.cached_count(), 1);
2708 }
2709
2710 #[test]
2711 fn explicit_visible_tile_budget_is_respected() {
2712 let mut mgr = TileManager::new_with_config(
2713 Box::new(FailingSource),
2714 512,
2715 TileSelectionConfig {
2716 visible_tile_budget: 1,
2717 ..TileSelectionConfig::default()
2718 },
2719 );
2720
2721 let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2722 assert_eq!(vis.len(), 1);
2723 }
2724
2725 #[test]
2726 fn tiny_cache_still_caps_effective_budget_below_policy_budget() {
2727 let source = RecordingSource::default();
2728 let mut mgr = TileManager::new_with_config(
2729 Box::new(source.clone()),
2730 1,
2731 TileSelectionConfig {
2732 visible_tile_budget: 512,
2733 ..TileSelectionConfig::default()
2734 },
2735 );
2736
2737 let _ = mgr.update(
2738 &full_world_bounds(),
2739 1,
2740 tile_center(TileId::new(1, 0, 0)),
2741 0.0,
2742 );
2743
2744 let requested = source.requested_ids();
2745 assert_eq!(requested.len(), 2);
2746 assert_eq!(requested[0], TileId::new(0, 0, 0));
2747 assert_eq!(mgr.cached_count(), 1);
2748 }
2749
2750 #[test]
2751 fn update_with_view_uses_shared_flat_tile_selection() {
2752 let source = RecordingSource::default();
2753 let mut mgr = TileManager::new(Box::new(source), 512);
2754
2755 let center = rustial_math::GeoCoord::from_lat_lon(39.8180, 2.6514);
2756 let center_world = rustial_math::WebMercator::project(¢er);
2757 let bounds = WorldBounds::new(
2758 rustial_math::WorldCoord::new(
2759 center_world.position.x - 220_000.0,
2760 center_world.position.y - 220_000.0,
2761 0.0,
2762 ),
2763 rustial_math::WorldCoord::new(
2764 center_world.position.x + 220_000.0,
2765 center_world.position.y + 220_000.0,
2766 0.0,
2767 ),
2768 );
2769
2770 let view = rustial_math::FlatTileView::new(
2771 rustial_math::WorldCoord::new(
2772 center_world.position.x,
2773 center_world.position.y,
2774 center_world.position.z,
2775 ),
2776 26_001.0,
2777 76.5_f64.to_radians(),
2778 79.9_f64.to_radians(),
2779 std::f64::consts::FRAC_PI_4,
2780 1280,
2781 720,
2782 );
2783
2784 let raw = rustial_math::visible_tiles(&bounds, 12);
2785 let vis = mgr.update_with_view(
2786 &bounds,
2787 12,
2788 (center_world.position.x, center_world.position.y),
2789 26_001.0,
2790 Some(&view),
2791 );
2792
2793 assert!(raw.len() > vis.len());
2794 assert!(!vis.is_empty());
2795 assert!(vis.iter().all(|tile| tile.target.zoom == 12));
2796 assert!(vis.iter().all(|tile| tile.actual.zoom <= 12));
2797 }
2798
2799 #[test]
2800 fn selection_stats_report_budget_hits_and_dropped_tiles() {
2801 let mut mgr = TileManager::new_with_config(
2802 Box::new(FailingSource),
2803 512,
2804 TileSelectionConfig {
2805 visible_tile_budget: 1,
2806 ..TileSelectionConfig::default()
2807 },
2808 );
2809
2810 let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2811 let stats = mgr.last_selection_stats();
2812
2813 assert_eq!(vis.len(), 1);
2814 assert!(stats.budget_hit);
2815 assert_eq!(stats.raw_candidate_tiles, 4);
2816 assert_eq!(stats.visible_tiles, 1);
2817 assert_eq!(stats.dropped_by_budget, 3);
2818 assert_eq!(mgr.counters().budget_hit_frames, 1);
2819 assert_eq!(mgr.counters().dropped_by_budget, 3);
2820 }
2821
2822 #[test]
2823 fn selection_stats_report_exact_hits_and_requests() {
2824 let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
2825
2826 let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2827 let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
2828
2829 let stats = mgr.last_selection_stats();
2830 assert_eq!(stats.visible_tiles, 1);
2831 assert_eq!(stats.exact_visible_tiles, 1);
2832 assert_eq!(stats.fallback_visible_tiles, 0);
2833 assert_eq!(stats.missing_visible_tiles, 0);
2834 assert_eq!(stats.exact_cache_hits, 1);
2835 assert_eq!(stats.requested_tiles, 0);
2836 assert_eq!(mgr.counters().exact_cache_hits, 1);
2837 }
2838
2839 #[test]
2840 fn selection_stats_report_fallback_hits_and_stale_cancellations() {
2841 let source = RecordingSource::default();
2842 let mut mgr = TileManager::new(Box::new(source.clone()), 100);
2843
2844 let z0 = TileId::new(0, 0, 0);
2845 mgr.cache.insert_pending(z0);
2846 mgr.cache.promote(z0, dummy_tile_response());
2847
2848 let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2849 let keep = TileId::new(1, 0, 0);
2850 let narrow_bounds = inset_bounds(tile_bounds_world(&keep), 1.0);
2851 let _ = mgr.update(&narrow_bounds, 1, tile_center(keep), 0.0);
2852
2853 let stats = mgr.last_selection_stats();
2854 assert_eq!(stats.visible_tiles, 1);
2855 assert_eq!(stats.fallback_visible_tiles, 1);
2856 assert_eq!(stats.missing_visible_tiles, 0);
2857 assert_eq!(stats.cancelled_stale_pending, 0);
2858 assert_eq!(mgr.counters().fallback_hits, 5);
2859 assert_eq!(mgr.counters().cancelled_stale_pending, 0);
2860 assert!(source.cancelled_ids().is_empty());
2861 }
2862
2863 #[test]
2866 fn zoom_below_source_min_returns_empty() {
2867 let source = MockSource::new();
2868 let config = TileSelectionConfig {
2869 source_min_zoom: 2,
2870 source_max_zoom: 14,
2871 ..TileSelectionConfig::default()
2872 };
2873 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
2874
2875 let result = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2876 assert!(
2877 result.is_empty(),
2878 "zoom 1 < source_min_zoom 2 should return empty"
2879 );
2880 assert_eq!(mgr.last_selection_stats().visible_tiles, 0);
2881 }
2882
2883 #[test]
2884 fn zoom_at_source_min_returns_tiles() {
2885 let source = MockSource::new();
2886 let config = TileSelectionConfig {
2887 source_min_zoom: 2,
2888 source_max_zoom: 14,
2889 ..TileSelectionConfig::default()
2890 };
2891 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
2892
2893 let result = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
2894 assert!(
2895 !result.is_empty(),
2896 "zoom == source_min_zoom should return tiles"
2897 );
2898 }
2899
2900 #[test]
2901 fn overzoom_clamps_requests_to_source_max_zoom() {
2902 let source = MockSource::new();
2903 let config = TileSelectionConfig {
2904 source_min_zoom: 0,
2905 source_max_zoom: 1,
2906 ..TileSelectionConfig::default()
2907 };
2908 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
2909
2910 let _ = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
2912 let result = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
2913
2914 assert!(!result.is_empty());
2917 for tile in result.iter() {
2918 assert_eq!(tile.target.zoom, 2);
2920 assert_eq!(tile.actual.zoom, 1);
2922 let region = tile.texture_region();
2924 assert!(!region.is_full());
2925 }
2926
2927 let stats = mgr.last_selection_stats();
2929 assert!(stats.overzoomed_visible_tiles > 0);
2930 }
2931
2932 #[test]
2933 fn overzoom_texture_region_maps_correctly() {
2934 let source = MockSource::new();
2937 let config = TileSelectionConfig {
2938 source_min_zoom: 0,
2939 source_max_zoom: 0,
2940 ..TileSelectionConfig::default()
2941 };
2942 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
2943
2944 let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2946 let result = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
2947 assert_eq!(
2948 result.len(),
2949 4,
2950 "4 display tiles at zoom 1 from 1 source tile at zoom 0"
2951 );
2952
2953 let mut regions: Vec<_> = result.iter().map(|t| t.texture_region()).collect();
2955 regions.sort_by(|a, b| {
2956 a.u_min
2957 .partial_cmp(&b.u_min)
2958 .unwrap()
2959 .then(a.v_min.partial_cmp(&b.v_min).unwrap())
2960 });
2961
2962 for region in ®ions {
2964 assert!(!region.is_full());
2965 let u_size = region.u_max - region.u_min;
2966 let v_size = region.v_max - region.v_min;
2967 assert!(
2968 (u_size - 0.5).abs() < 1e-5,
2969 "each quadrant is half the tile"
2970 );
2971 assert!(
2972 (v_size - 0.5).abs() < 1e-5,
2973 "each quadrant is half the tile"
2974 );
2975 }
2976 }
2977
2978 #[test]
2979 fn overzoomed_display_targets_computes_children() {
2980 let parent = TileId::new(1, 0, 0);
2981 let children = overzoomed_display_targets(&parent, 2);
2982 assert_eq!(children.len(), 4);
2983 assert!(children.contains(&TileId::new(2, 0, 0)));
2984 assert!(children.contains(&TileId::new(2, 1, 0)));
2985 assert!(children.contains(&TileId::new(2, 0, 1)));
2986 assert!(children.contains(&TileId::new(2, 1, 1)));
2987 }
2988
2989 #[test]
2990 fn overzoomed_display_targets_same_zoom_returns_self() {
2991 let tile = TileId::new(3, 2, 1);
2992 let targets = overzoomed_display_targets(&tile, 3);
2993 assert_eq!(targets, vec![tile]);
2994 }
2995
2996 #[test]
2997 fn missing_tile_requests_parent_chain_before_exact_tile() {
2998 let source = RecordingSource::default();
2999 let mut mgr = TileManager::new_with_config(
3000 Box::new(source.clone()),
3001 16,
3002 TileSelectionConfig {
3003 visible_tile_budget: 1,
3004 ..TileSelectionConfig::default()
3005 },
3006 );
3007
3008 let target = TileId::new(2, 1, 1);
3009 let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3010 let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
3011
3012 let requested = source.requested_ids();
3013 assert_eq!(
3014 requested,
3015 vec![TileId::new(0, 0, 0), TileId::new(1, 0, 0), target]
3016 );
3017 }
3018
3019 #[test]
3020 fn desired_ancestor_retention_avoids_cancelling_bootstrap_parents() {
3021 let source = RecordingSource::default();
3022 let mut mgr = TileManager::new_with_config(
3023 Box::new(source.clone()),
3024 16,
3025 TileSelectionConfig {
3026 visible_tile_budget: 1,
3027 ..TileSelectionConfig::default()
3028 },
3029 );
3030
3031 let target = TileId::new(2, 1, 1);
3032 let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3033
3034 let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
3035 let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
3036
3037 assert!(source.cancelled_ids().is_empty());
3038 assert_eq!(mgr.cached_count(), 3);
3039 }
3040
3041 #[test]
3042 fn previous_desired_tile_gets_one_frame_retention_before_stale_cancel() {
3043 let source = RecordingSource::default();
3044 let mut mgr = TileManager::new(Box::new(source.clone()), 32);
3045
3046 let first = TileId::new(14, 0, 0);
3047 let second = TileId::new(14, 4_096, 4_096);
3048 mgr.cache.insert_pending(first);
3049 mgr.cache.insert_pending(second);
3050
3051 let previous_desired = HashSet::from([first]);
3052 let retained = desired_with_temporal_retention(&[second], &previous_desired);
3053 assert_eq!(mgr.prune_stale_pending(&retained), 0);
3054
3055 assert!(
3056 !source.cancelled_ids().contains(&first),
3057 "the previous desired tile should survive one extra frame of camera motion"
3058 );
3059
3060 let current_only = desired_with_temporal_retention(&[second], &HashSet::from([second]));
3061 assert_eq!(mgr.prune_stale_pending(¤t_only), 1);
3062
3063 assert!(
3064 source.cancelled_ids().contains(&first),
3065 "once the tile is outside both the current and immediately previous desired sets it should be cancelled"
3066 );
3067 }
3068
3069 #[test]
3070 fn adjacent_same_zoom_pending_tile_survives_small_pan_horizon() {
3071 let source = RecordingSource::default();
3072 let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3073
3074 let adjacent = TileId::new(10, 100, 100);
3075 let desired = TileId::new(10, 101, 100);
3076 mgr.cache.insert_pending(adjacent);
3077
3078 let desired_set = desired_with_ancestor_retention(&[desired]);
3079 let cancelled = mgr.prune_stale_pending(&desired_set);
3080
3081 assert_eq!(cancelled, 0);
3082 assert!(source.cancelled_ids().is_empty());
3083 assert!(mgr.cache.get(&adjacent).is_some());
3084 }
3085
3086 #[test]
3087 fn nearby_descendant_pending_tile_survives_zoom_in_horizon() {
3088 let source = RecordingSource::default();
3089 let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3090
3091 let pending_child = TileId::new(11, 204, 200);
3092 let desired = TileId::new(10, 101, 100);
3093 mgr.cache.insert_pending(pending_child);
3094
3095 let desired_set = desired_with_ancestor_retention(&[desired]);
3096 let cancelled = mgr.prune_stale_pending(&desired_set);
3097
3098 assert_eq!(cancelled, 0);
3099 assert!(source.cancelled_ids().is_empty());
3100 assert!(mgr.cache.get(&pending_child).is_some());
3101 }
3102
3103 #[test]
3104 fn nearby_ancestor_pending_tile_survives_zoom_out_horizon() {
3105 let source = RecordingSource::default();
3106 let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3107
3108 let pending_parent = TileId::new(9, 51, 50);
3109 let desired = TileId::new(10, 101, 100);
3110 mgr.cache.insert_pending(pending_parent);
3111
3112 let desired_set = desired_with_ancestor_retention(&[desired]);
3113 let cancelled = mgr.prune_stale_pending(&desired_set);
3114
3115 assert_eq!(cancelled, 0);
3116 assert!(source.cancelled_ids().is_empty());
3117 assert!(mgr.cache.get(&pending_parent).is_some());
3118 }
3119
3120 #[test]
3121 fn stale_prune_preserves_reloading_renderable_payload() {
3122 let source = RecordingSource::default();
3123 let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3124
3125 let reloading = TileId::new(10, 500, 500);
3126 let desired = TileId::new(10, 0, 0);
3127
3128 mgr.cache.promote(reloading, dummy_tile_response());
3129 assert!(mgr.cache.start_reload(reloading));
3130 assert!(mgr.cache.get(&reloading).unwrap().is_reloading());
3131
3132 let desired_set = desired_with_ancestor_retention(&[desired]);
3133 let cancelled = mgr.prune_stale_pending(&desired_set);
3134
3135 assert_eq!(cancelled, 1);
3136 assert!(source.cancelled_ids().contains(&reloading));
3137 let entry = mgr
3139 .cache
3140 .get(&reloading)
3141 .expect("reloading entry should survive as expired");
3142 assert!(entry.is_expired());
3143 assert!(entry.is_renderable());
3144 }
3145
3146 #[test]
3147 fn stale_prune_removes_pure_pending_entry() {
3148 let source = RecordingSource::default();
3149 let mut mgr = TileManager::new(Box::new(source.clone()), 64);
3150
3151 let pending = TileId::new(10, 500, 500);
3152 let desired = TileId::new(10, 0, 0);
3153 mgr.cache.insert_pending(pending);
3154
3155 let desired_set = desired_with_ancestor_retention(&[desired]);
3156 let cancelled = mgr.prune_stale_pending(&desired_set);
3157
3158 assert_eq!(cancelled, 1);
3159 assert!(source.cancelled_ids().contains(&pending));
3160 assert!(!mgr.cache.contains(&pending));
3161 }
3162
3163 #[test]
3164 fn speculative_prefetch_requests_only_tiles_outside_current_desired_set() {
3165 let source = RecordingSource::default();
3166 let mut mgr = TileManager::new_with_config(
3167 Box::new(source.clone()),
3168 32,
3169 TileSelectionConfig::default(),
3170 );
3171
3172 let current = TileId::new(2, 1, 1);
3173 let predicted = TileId::new(2, 2, 1);
3174 let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
3175 let predicted_bounds = inset_bounds(tile_bounds_world(&predicted), 1.0);
3176
3177 let _ = mgr.update(¤t_bounds, 2, tile_center(current), 0.0);
3178 let before = source.requested_ids().len();
3179
3180 let prefetched =
3181 mgr.prefetch_with_view(&predicted_bounds, 2, tile_center(predicted), None, 2);
3182 let requested = source.requested_ids();
3183
3184 assert_eq!(prefetched, 1);
3185 assert_eq!(requested.len(), before + 1);
3186 assert_eq!(requested.last().copied(), Some(predicted));
3187 assert_eq!(mgr.last_selection_stats().speculative_requested_tiles, 1);
3188 assert_eq!(mgr.counters().speculative_requested_tiles, 1);
3189 }
3190
3191 #[test]
3192 fn speculative_prefetch_skips_when_prediction_matches_current_desired_tiles() {
3193 let source = RecordingSource::default();
3194 let mut mgr = TileManager::new_with_config(
3195 Box::new(source.clone()),
3196 32,
3197 TileSelectionConfig::default(),
3198 );
3199
3200 let current = TileId::new(2, 1, 1);
3201 let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
3202
3203 let _ = mgr.update(¤t_bounds, 2, tile_center(current), 0.0);
3204 let before = source.requested_ids().len();
3205
3206 let prefetched = mgr.prefetch_with_view(¤t_bounds, 2, tile_center(current), None, 2);
3207
3208 assert_eq!(prefetched, 0);
3209 assert_eq!(source.requested_ids().len(), before);
3210 }
3211
3212 #[test]
3213 fn zoom_in_prefetch_requests_children_of_centre_tiles() {
3214 let source = RecordingSource::default();
3215 let mut mgr = TileManager::new_with_config(
3216 Box::new(source.clone()),
3217 32,
3218 TileSelectionConfig::default(),
3219 );
3220
3221 let current = TileId::new(2, 1, 1);
3222 let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
3223 let _ = mgr.update(¤t_bounds, 2, tile_center(current), 0.0);
3224 let before = source.requested_ids().len();
3225
3226 let prefetched =
3227 mgr.prefetch_zoom_direction(tile_center(current), ZoomPrefetchDirection::In, 4);
3228 let requested = source.requested_ids();
3229
3230 assert_eq!(prefetched, 4);
3231 assert_eq!(requested.len(), before + 4);
3232 assert!(requested[before..].contains(&TileId::new(3, 2, 2)));
3233 assert!(requested[before..].contains(&TileId::new(3, 3, 2)));
3234 assert!(requested[before..].contains(&TileId::new(3, 2, 3)));
3235 assert!(requested[before..].contains(&TileId::new(3, 3, 3)));
3236 }
3237
3238 #[test]
3239 fn zoom_out_prefetch_requests_parent_tiles() {
3240 let source = RecordingSource::default();
3241 let mut mgr = TileManager::new_with_config(
3242 Box::new(source.clone()),
3243 32,
3244 TileSelectionConfig::default(),
3245 );
3246
3247 let current = TileId::new(2, 1, 1);
3248 mgr.cache.insert_pending(current);
3249 mgr.cache.promote(current, dummy_tile_response());
3250 let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
3251 let _ = mgr.update(¤t_bounds, 2, tile_center(current), 0.0);
3252 let before = source.requested_ids().len();
3253
3254 let prefetched =
3255 mgr.prefetch_zoom_direction(tile_center(current), ZoomPrefetchDirection::Out, 2);
3256 let requested = source.requested_ids();
3257
3258 assert_eq!(prefetched, 1);
3259 assert_eq!(requested.len(), before + 1);
3260 assert_eq!(requested.last().copied(), Some(TileId::new(1, 0, 0)));
3261 }
3262
3263 #[test]
3266 fn route_prefetch_requests_tiles_along_polyline_ahead_of_camera() {
3267 let source = RecordingSource::default();
3268 let mut mgr = TileManager::new_with_config(
3269 Box::new(source.clone()),
3270 64,
3271 TileSelectionConfig::default(),
3272 );
3273
3274 let route = vec![
3276 GeoCoord::from_lat_lon(0.0, 0.0),
3277 GeoCoord::from_lat_lon(0.0, 30.0),
3278 GeoCoord::from_lat_lon(0.0, 60.0),
3279 ];
3280
3281 let cam = WebMercator::project_clamped(&route[0]);
3283 let camera_world = (cam.position.x, cam.position.y);
3284
3285 let current = geo_to_tile(&route[0], 4).tile_id();
3287 let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
3288 let _ = mgr.update(¤t_bounds, 4, camera_world, 0.0);
3289 let before = source.requested_ids().len();
3290
3291 let prefetched = mgr.prefetch_route(&route, 4, camera_world, 8);
3292 let requested = source.requested_ids();
3293
3294 assert!(prefetched > 0, "route prefetch should request tiles ahead");
3296 assert_eq!(requested.len(), before + prefetched);
3297 }
3298
3299 #[test]
3300 fn route_prefetch_budget_is_respected() {
3301 let source = RecordingSource::default();
3302 let mut mgr = TileManager::new_with_config(
3303 Box::new(source.clone()),
3304 64,
3305 TileSelectionConfig::default(),
3306 );
3307
3308 let route = vec![
3309 GeoCoord::from_lat_lon(0.0, 0.0),
3310 GeoCoord::from_lat_lon(0.0, 30.0),
3311 GeoCoord::from_lat_lon(0.0, 60.0),
3312 ];
3313
3314 let cam = WebMercator::project_clamped(&route[0]);
3315 let camera_world = (cam.position.x, cam.position.y);
3316
3317 let current = geo_to_tile(&route[0], 4).tile_id();
3318 let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
3319 let _ = mgr.update(¤t_bounds, 4, camera_world, 0.0);
3320
3321 let prefetched = mgr.prefetch_route(&route, 4, camera_world, 2);
3322 assert!(
3323 prefetched <= 2,
3324 "route prefetch must respect max_requests budget"
3325 );
3326 }
3327
3328 #[test]
3329 fn route_prefetch_empty_route_returns_zero() {
3330 let source = RecordingSource::default();
3331 let mut mgr = TileManager::new(Box::new(source.clone()), 32);
3332
3333 assert_eq!(mgr.prefetch_route(&[], 4, (0.0, 0.0), 8), 0);
3334 assert_eq!(
3335 mgr.prefetch_route(&[GeoCoord::from_lat_lon(0.0, 0.0)], 4, (0.0, 0.0), 8,),
3336 0
3337 );
3338 }
3339
3340 #[test]
3341 fn tiles_along_route_produces_ordered_unique_tiles() {
3342 let route = vec![
3343 GeoCoord::from_lat_lon(0.0, 0.0),
3344 GeoCoord::from_lat_lon(0.0, 10.0),
3345 GeoCoord::from_lat_lon(0.0, 20.0),
3346 ];
3347
3348 let cam = WebMercator::project_clamped(&route[0]);
3349 let camera_world = (cam.position.x, cam.position.y);
3350
3351 let tiles = tiles_along_route(&route, 4, camera_world);
3352 assert!(!tiles.is_empty());
3353
3354 let unique: HashSet<_> = tiles.iter().copied().collect();
3356 assert_eq!(
3357 unique.len(),
3358 tiles.len(),
3359 "tiles_along_route must not produce duplicates"
3360 );
3361
3362 assert!(tiles.iter().all(|t| t.zoom == 4));
3364 }
3365
3366 #[test]
3369 fn compute_fade_opacity_disabled_returns_one() {
3370 let now = SystemTime::now();
3371 assert_eq!(compute_fade_opacity(now, Some(now), 0.0), 1.0);
3372 }
3373
3374 #[test]
3375 fn compute_fade_opacity_no_loaded_at_returns_one() {
3376 let now = SystemTime::now();
3377 assert_eq!(compute_fade_opacity(now, None, 0.3), 1.0);
3378 }
3379
3380 #[test]
3381 fn compute_fade_opacity_ramps_from_zero_to_one() {
3382 use std::time::Duration;
3383
3384 let loaded = SystemTime::now();
3385 let half = loaded + Duration::from_millis(150);
3386 let full = loaded + Duration::from_millis(300);
3387 let over = loaded + Duration::from_millis(600);
3388
3389 let at_zero = compute_fade_opacity(loaded, Some(loaded), 0.3);
3390 assert!((at_zero - 0.0).abs() < 0.01, "at load time: {at_zero}");
3391
3392 let at_half = compute_fade_opacity(half, Some(loaded), 0.3);
3393 assert!((at_half - 0.5).abs() < 0.05, "at 150ms: {at_half}");
3394
3395 let at_full = compute_fade_opacity(full, Some(loaded), 0.3);
3396 assert!((at_full - 1.0).abs() < 0.01, "at 300ms: {at_full}");
3397
3398 let at_over = compute_fade_opacity(over, Some(loaded), 0.3);
3399 assert_eq!(at_over, 1.0, "past duration should clamp to 1.0");
3400 }
3401
3402 #[test]
3403 fn crossfade_emits_parent_while_child_fading() {
3404 let source = MockSource::new();
3405
3406 let config = TileSelectionConfig {
3407 raster_fade_duration: 10.0, ..TileSelectionConfig::default()
3409 };
3410 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3411
3412 let parent = TileId::new(0, 0, 0);
3413 let child = TileId::new(1, 0, 0);
3414
3415 mgr.cache.insert_pending(parent);
3417 mgr.cache.promote(parent, dummy_tile_response());
3418
3419 mgr.cache.insert_pending(child);
3421 mgr.cache.promote(child, dummy_tile_response());
3422
3423 let bounds = inset_bounds(tile_bounds_world(&child), 1.0);
3424 let vis = mgr.update(&bounds, 1, tile_center(child), 0.0);
3425
3426 let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == child).collect();
3430 let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == parent).collect();
3431
3432 assert!(!child_tiles.is_empty(), "child tile should be present");
3433 let child_fade = child_tiles[0].fade_opacity;
3434 assert!(
3435 child_fade < 1.0,
3436 "child should be fading (got {child_fade})"
3437 );
3438
3439 assert!(
3440 !parent_tiles.is_empty(),
3441 "cross-fade parent should be emitted while child fades"
3442 );
3443 let parent_fade = parent_tiles[0].fade_opacity;
3444 let sum = child_fade + parent_fade;
3445 assert!(
3446 (sum - 1.0).abs() < 0.05,
3447 "child + parent opacities should sum to ~1.0 (got {sum})"
3448 );
3449 }
3450
3451 #[test]
3452 fn max_fading_ancestor_cap_respected() {
3453 let mut cache = TileCache::new(100);
3454 let mut visible = VisibleTileSet { tiles: Vec::new() };
3455
3456 for z in 0..5 {
3458 let id = TileId::new(z, 0, 0);
3459 cache.insert_pending(id);
3460 cache.promote(id, dummy_tile_response());
3461 }
3462
3463 let target = TileId::new(4, 0, 0);
3465 emit_crossfade_parent(&mut visible, target, 0.5, 2, &mut cache);
3466
3467 assert_eq!(visible.tiles.len(), 1);
3469 assert_eq!(visible.tiles[0].actual.zoom, 3);
3470 }
3471
3472 #[test]
3475 fn from_child_tile_returns_correct_sub_region() {
3476 let parent = TileId::new(1, 0, 0);
3477 let child_tl = TileId::new(2, 0, 0);
3479 let region = TileTextureRegion::from_child_tile(&parent, &child_tl).unwrap();
3480 assert!((region.u_min - 0.0).abs() < 1e-6);
3481 assert!((region.v_min - 0.0).abs() < 1e-6);
3482 assert!((region.u_max - 0.5).abs() < 1e-6);
3483 assert!((region.v_max - 0.5).abs() < 1e-6);
3484
3485 let child_br = TileId::new(2, 1, 1);
3487 let region = TileTextureRegion::from_child_tile(&parent, &child_br).unwrap();
3488 assert!((region.u_min - 0.5).abs() < 1e-6);
3489 assert!((region.v_min - 0.5).abs() < 1e-6);
3490 assert!((region.u_max - 1.0).abs() < 1e-6);
3491 assert!((region.v_max - 1.0).abs() < 1e-6);
3492 }
3493
3494 #[test]
3495 fn from_child_tile_returns_none_for_non_descendant() {
3496 let parent = TileId::new(1, 0, 0);
3497 let wrong_child = TileId::new(2, 3, 3);
3499 assert!(TileTextureRegion::from_child_tile(&parent, &wrong_child).is_none());
3500 }
3501
3502 #[test]
3503 fn from_child_tile_returns_none_when_child_zoom_lte_target() {
3504 let parent = TileId::new(2, 0, 0);
3505 let same = TileId::new(2, 0, 0);
3506 assert!(TileTextureRegion::from_child_tile(&parent, &same).is_none());
3507
3508 let higher = TileId::new(1, 0, 0);
3509 assert!(TileTextureRegion::from_child_tile(&parent, &higher).is_none());
3510 }
3511
3512 #[test]
3513 fn child_fallback_uses_cached_children() {
3514 let source = MockSource::new();
3516 let config = TileSelectionConfig {
3517 max_child_depth: 2,
3518 ..TileSelectionConfig::default()
3519 };
3520 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3521
3522 let parent = TileId::new(1, 0, 0);
3523 let children = parent.children();
3524 for child in &children {
3525 mgr.cache.insert_pending(*child);
3526 mgr.cache.promote(*child, dummy_tile_response());
3527 }
3528
3529 let bounds = inset_bounds(tile_bounds_world(&parent), 1.0);
3531 let vis = mgr.update(&bounds, 1, tile_center(parent), 0.0);
3532
3533 let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3535 assert_eq!(child_tiles.len(), 4, "expected 4 child-fallback tiles");
3536
3537 for ct in &child_tiles {
3539 assert_eq!(ct.target, parent);
3540 assert!(ct.data.is_some());
3541 assert_eq!(ct.actual.zoom, 2);
3542 }
3543
3544 let stats = mgr.last_selection_stats();
3546 assert_eq!(stats.child_fallback_hits, 1);
3547 assert_eq!(stats.child_fallback_visible_tiles, 4);
3548 }
3549
3550 #[test]
3551 fn child_fallback_prefers_children_over_parent() {
3552 let source = MockSource::new();
3554 let config = TileSelectionConfig {
3555 max_child_depth: 2,
3556 ..TileSelectionConfig::default()
3557 };
3558 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3559
3560 let z0 = TileId::new(0, 0, 0);
3561 mgr.cache.insert_pending(z0);
3562 mgr.cache.promote(z0, dummy_tile_response());
3563
3564 let target = TileId::new(1, 0, 0);
3565 let children = target.children();
3566 for child in &children {
3567 mgr.cache.insert_pending(*child);
3568 mgr.cache.promote(*child, dummy_tile_response());
3569 }
3570
3571 let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3572 let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
3573
3574 let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3577 let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == z0).collect();
3578 assert_eq!(child_tiles.len(), 4, "expected child fallback tiles");
3579 assert_eq!(
3580 parent_tiles.len(),
3581 0,
3582 "should not use parent when children available"
3583 );
3584 }
3585
3586 #[test]
3587 fn child_fallback_incomplete_children_falls_through_to_parent() {
3588 let source = MockSource::new();
3590 let config = TileSelectionConfig {
3591 max_child_depth: 2,
3592 ..TileSelectionConfig::default()
3593 };
3594 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3595
3596 let z0 = TileId::new(0, 0, 0);
3597 mgr.cache.insert_pending(z0);
3598 mgr.cache.promote(z0, dummy_tile_response());
3599
3600 let target = TileId::new(1, 0, 0);
3601 let children = target.children();
3602 for child in &children[..3] {
3604 mgr.cache.insert_pending(*child);
3605 mgr.cache.promote(*child, dummy_tile_response());
3606 }
3607
3608 let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3609 let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
3610
3611 let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == z0).collect();
3613 assert!(!parent_tiles.is_empty(), "should fall back to z0 parent");
3614 let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3615 assert!(
3616 child_fb.is_empty(),
3617 "incomplete children should not be used"
3618 );
3619 }
3620
3621 #[test]
3622 fn child_fallback_max_depth_cap_respected() {
3623 let source = MockSource::new();
3626 let config = TileSelectionConfig {
3627 max_child_depth: 1,
3628 ..TileSelectionConfig::default()
3629 };
3630 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3631
3632 let target = TileId::new(1, 0, 0);
3633 for child in target.children() {
3635 for grandchild in child.children() {
3636 mgr.cache.insert_pending(grandchild);
3637 mgr.cache.promote(grandchild, dummy_tile_response());
3638 }
3639 }
3640
3641 let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3642 let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
3643
3644 let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3647 assert!(
3648 child_fb.is_empty(),
3649 "depth=1 should not reach grandchildren"
3650 );
3651 }
3652
3653 #[test]
3654 fn child_fallback_disabled_when_max_child_depth_zero() {
3655 let source = MockSource::new();
3656 let config = TileSelectionConfig {
3657 max_child_depth: 0,
3658 ..TileSelectionConfig::default()
3659 };
3660 let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
3661
3662 let target = TileId::new(1, 0, 0);
3663 for child in target.children() {
3664 mgr.cache.insert_pending(child);
3665 mgr.cache.promote(child, dummy_tile_response());
3666 }
3667
3668 let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
3669 let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
3670
3671 let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
3672 assert!(
3673 child_fb.is_empty(),
3674 "max_child_depth=0 should disable child fallback"
3675 );
3676 }
3677
3678 #[test]
3679 fn stale_pending_same_zoom_tiles_are_pruned_when_unrelated_to_desired_view() {
3680 let source = RecordingSource::default();
3681 let mut mgr = TileManager::new(Box::new(source.clone()), 128);
3682
3683 let stale = TileId::new(4, 0, 0);
3684 let desired = TileId::new(4, 8, 8);
3685
3686 mgr.cache.insert_pending(stale);
3687
3688 let desired_set = desired_with_ancestor_retention(&[desired]);
3689 let cancelled = mgr.prune_stale_pending(&desired_set);
3690
3691 assert_eq!(cancelled, 1);
3692 assert_eq!(source.cancelled_ids(), vec![stale]);
3693 assert!(mgr.cache.get(&stale).is_none());
3694 }
3695
3696 #[test]
3697 fn is_child_fallback_returns_true_for_child_actual() {
3698 let tile = VisibleTile {
3699 target: TileId::new(1, 0, 0),
3700 actual: TileId::new(2, 0, 0),
3701 data: Some(dummy_tile_response().data),
3702 fade_opacity: 1.0,
3703 };
3704 assert!(tile.is_child_fallback());
3705 assert!(!tile.is_overzoomed());
3706 }
3707
3708 #[test]
3709 fn lifecycle_diagnostics_track_request_completion_and_exact_use() {
3710 let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
3711
3712 let bounds = full_world_bounds();
3713 let _ = mgr.update(&bounds, 0, (0.0, 0.0), 0.0);
3714 let _ = mgr.update(&bounds, 0, (0.0, 0.0), 0.0);
3715
3716 let diagnostics = mgr.lifecycle_diagnostics();
3717 let record = diagnostics
3718 .active_records
3719 .iter()
3720 .find(|record| record.tile == TileId::new(0, 0, 0))
3721 .expect("expected z0 lifecycle record");
3722
3723 assert_eq!(record.first_selected_frame, Some(1));
3724 assert_eq!(record.first_queued_frame, Some(1));
3725 assert_eq!(record.first_dispatched_frame, Some(1));
3726 assert_eq!(record.first_completed_frame, Some(2));
3727 assert_eq!(record.first_decoded_frame, Some(2));
3728 assert_eq!(record.first_promoted_frame, Some(2));
3729 assert_eq!(record.first_renderable_frame, Some(2));
3730 assert_eq!(record.first_exact_frame, Some(2));
3731 assert_eq!(record.queued_frames_to_dispatch, Some(0));
3732 assert_eq!(record.in_flight_frames_to_complete, Some(1));
3733 assert_eq!(record.completion_to_visible_use_frames, Some(0));
3734
3735 assert!(diagnostics
3736 .recent_events
3737 .iter()
3738 .any(|event| event.tile == TileId::new(0, 0, 0)
3739 && event.kind == TileLifecycleEventKind::Queued));
3740 assert!(diagnostics
3741 .recent_events
3742 .iter()
3743 .any(|event| event.tile == TileId::new(0, 0, 0)
3744 && event.kind == TileLifecycleEventKind::UsedAsExact));
3745 }
3746
3747 #[test]
3748 fn lifecycle_diagnostics_track_stale_cancellation() {
3749 let source = RecordingSource::default();
3750 let mut mgr = TileManager::new(Box::new(source.clone()), 128);
3751
3752 let first = TileId::new(4, 0, 0);
3753 let desired = TileId::new(4, 8, 8);
3754
3755 mgr.begin_lifecycle_frame();
3756 mgr.cache.insert_pending(first);
3757 mgr.lifecycle.record_queued(first);
3758 let desired_set = desired_with_ancestor_retention(&[desired]);
3759 let cancelled_count = mgr.prune_stale_pending(&desired_set);
3760
3761 let diagnostics = mgr.lifecycle_diagnostics();
3762 let cancelled = diagnostics
3763 .recent_terminal_records
3764 .iter()
3765 .find(|record| record.tile == first)
3766 .expect("expected stale-cancelled tile lifecycle record");
3767
3768 assert_eq!(
3769 cancelled.terminal_event,
3770 Some(TileLifecycleEventKind::CancelledAsStale)
3771 );
3772 assert_eq!(cancelled.tile, first);
3773 assert_eq!(cancelled_count, 1);
3774 assert_eq!(source.cancelled_ids(), vec![first]);
3775 assert!(diagnostics
3776 .recent_events
3777 .iter()
3778 .any(|event| event.tile == first
3779 && event.kind == TileLifecycleEventKind::CancelledAsStale));
3780 }
3781}