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