use super::geometry::*; use super::tile::*; pub use super::tile::TileEntry; use makepad_widgets::*; use std::collections::{HashMap, HashSet}; /// Counts of tiles in each state, for status display. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct TileStatusCounts { pub ready: usize, pub loading: usize, pub failed: usize, pub retrying: usize, pub exhausted: usize, pub features: usize, } /// Single owner of tile storage and lifecycle. /// /// Answers: who evicts? who marks stale? who retries? who guarantees uniqueness? /// All tile mutations go through `TileCache` methods. pub struct TileCache { pub(crate) tiles: HashMap, frame_counter: u32, style_epoch: u64, max_tiles: usize, stale_frame_threshold: u32, // Pending eviction to prevent use-after-free during rendering pending_eviction: Option<(HashSet, u32)>, } impl Default for TileCache { fn default() -> Self { Self { tiles: HashMap::new(), frame_counter: 0, style_epoch: 0, max_tiles: 640, stale_frame_threshold: 240, pending_eviction: None, } } } impl TileCache { pub fn new(max_tiles: usize) -> Self { Self { max_tiles, ..Default::default() } } // --- Query --- pub fn get(&self, key: TileKey) -> Option<&TileEntry> { self.tiles.get(&key) } pub fn get_mut(&mut self, key: TileKey) -> Option<&mut TileEntry> { self.tiles.get_mut(&key) } pub fn contains(&self, key: TileKey) -> bool { self.tiles.contains_key(&key) } pub fn len(&self) -> usize { self.tiles.len() } pub fn is_empty(&self) -> bool { self.tiles.is_empty() } pub fn is_ready(&self, key: TileKey) -> bool { self.tiles.get(&key).is_some_and(|entry| { if let TileLoadState::Ready { fill_geometry, stroke_geometry, feature_count, .. } = &entry.state { *feature_count > 0 || fill_geometry.is_some() || stroke_geometry.is_some() } else { false } }) } pub fn is_loading(&self, key: TileKey) -> bool { self.tiles .get(&key) .is_some_and(|e| matches!(e.state, TileLoadState::LoadingNetwork | TileLoadState::LoadingLocal)) } pub fn is_failed(&self, key: TileKey) -> bool { self.tiles .get(&key) .is_some_and(|e| matches!(e.state, TileLoadState::Failed { .. })) } pub fn find_ready_ancestor(&self, mut key: TileKey) -> Option { while key.z > 0 { key = TileKey { z: key.z - 1, x: key.x / 2, y: key.y / 2, }; if self.is_ready(key) { return Some(key); } } None } pub fn find_ready_descendants(&self, key: TileKey) -> Vec { self.tiles .iter() .filter(|(candidate, entry)| { matches!(entry.state, TileLoadState::Ready { .. }) && is_descendant_tile(**candidate, key) }) .map(|(k, _)| *k) .collect() } pub fn loading_count(&self) -> usize { self.tiles .values() .filter(|e| matches!(e.state, TileLoadState::LoadingNetwork | TileLoadState::LoadingLocal)) .count() } pub fn pending_loading(&self) -> usize { self.tiles .values() .filter(|e| matches!(e.state, TileLoadState::LoadingNetwork)) .count() } pub fn status_counts(&self, visible: &[TileKey]) -> TileStatusCounts { let mut counts = TileStatusCounts::default(); for key in visible { let Some(entry) = self.tiles.get(key) else { continue; }; match &entry.state { TileLoadState::LoadingNetwork | TileLoadState::LoadingLocal => counts.loading += 1, TileLoadState::Ready { feature_count, .. } => { counts.ready += 1; counts.features += feature_count; } TileLoadState::Failed { .. } => { counts.failed += 1; if entry.attempts >= MAX_TILE_RETRIES { counts.exhausted += 1; } else { counts.retrying += 1; } } } } counts } pub fn is_ready_or_has_descendants(&self, key: TileKey) -> bool { if self.is_ready(key) { return true; } self.find_ready_descendants(key).len() > 0 } // --- Mutation --- pub fn insert_loading(&mut self, key: TileKey, state: TileLoadState) { self.tiles.insert( key, TileEntry { state, last_used: self.frame_counter, attempts: 0, }, ); } pub fn insert_ready(&mut self, cx: &mut Cx, tile_key: TileKey, buffers: TileBuffers) { let fill_geometry = if !buffers.fill_indices.is_empty() && !buffers.fill_vertices.is_empty() { let geometry = Geometry::new(cx); geometry.update(cx, buffers.fill_indices, buffers.fill_vertices); Some(geometry) } else { None }; let stroke_geometry = if !buffers.stroke_indices.is_empty() && !buffers.stroke_vertices.is_empty() { let geometry = Geometry::new(cx); geometry.update(cx, buffers.stroke_indices, buffers.stroke_vertices); Some(geometry) } else { None }; self.tiles.insert( tile_key, TileEntry { state: TileLoadState::Ready { fill_geometry, stroke_geometry, feature_count: buffers.feature_count, labels: buffers.labels, pois: buffers.pois, }, last_used: self.frame_counter, attempts: 0, }, ); } pub fn mark_failed(&mut self, tile_key: TileKey, reason: &str) { let attempts = self .tiles .get(&tile_key) .map_or(1, |entry| entry.attempts.saturating_add(1)); let retry_delay = retry_delay_frames(attempts); let retry_after = self.frame_counter.saturating_add(retry_delay); self.tiles.insert( tile_key, TileEntry { state: TileLoadState::Failed { retry_after }, last_used: self.frame_counter, attempts, }, ); log!( "NigigMapView: tile z{} x{} y{} failed (attempt {}): {}", tile_key.z, tile_key.x, tile_key.y, attempts, reason ); } pub fn remove(&mut self, key: TileKey) -> Option { self.tiles.remove(&key) } pub fn mark_visible(&mut self, key: TileKey) { if let Some(entry) = self.tiles.get_mut(&key) { entry.last_used = self.frame_counter; } } pub fn tick(&mut self) { // Perform pending eviction from previous frame (prevents use-after-free) if let Some((visible, target_zoom)) = self.pending_eviction.take() { self.evict_internal(&visible, target_zoom); } // Use u32 counter to limit memory usage. When about to wrap, reset all // last_used values to 0 to prevent eviction logic from breaking. if self.frame_counter == u32::MAX - 1 { // Reset all last_used to 0 before wrap for entry in self.tiles.values_mut() { entry.last_used = 0; } self.frame_counter = 0; } else { self.frame_counter += 1; } } pub fn frame_counter(&self) -> u32 { self.frame_counter } pub fn should_retry(&self, key: TileKey, max_retries: u8) -> Option { self.tiles.get(&key).and_then(|entry| { if let TileLoadState::Failed { retry_after } = entry.state { if entry.attempts < max_retries && self.frame_counter >= retry_after { return Some(entry.attempts); } } None }) } // --- Eviction --- /// Set pending eviction to be performed at the start of the next frame. /// This prevents use-after-free by deferring eviction until after rendering. pub fn set_pending_eviction(&mut self, visible: HashSet, target_zoom: u32) { self.pending_eviction = Some((visible, target_zoom)); } /// Internal eviction method called from tick(). fn evict_internal(&mut self, visible: &HashSet, target_zoom: u32) { if self.tiles.len() <= self.max_tiles { return; } let min_keep_zoom = target_zoom.saturating_sub(2); let max_keep_zoom = target_zoom.saturating_add(1); let frame = self.frame_counter; let threshold = self.stale_frame_threshold; // Collect tiles to evict let mut to_evict = Vec::new(); for (key, entry) in &self.tiles { if visible.contains(key) || matches!( entry.state, TileLoadState::LoadingNetwork | TileLoadState::LoadingLocal ) { continue; } if key.z < min_keep_zoom || key.z > max_keep_zoom { to_evict.push(*key); continue; } if frame.saturating_sub(entry.last_used) > threshold { to_evict.push(*key); } } // Remove tiles (GPU resources already freed in evict()) for key in to_evict { self.tiles.remove(&key); } } pub fn evict(&mut self, visible: &HashSet, target_zoom: u32) { if self.tiles.len() <= self.max_tiles { return; } let min_keep_zoom = target_zoom.saturating_sub(2); let max_keep_zoom = target_zoom.saturating_add(1); let frame = self.frame_counter; let threshold = self.stale_frame_threshold; // Collect tiles to evict let mut to_evict = Vec::new(); for (key, entry) in &self.tiles { if visible.contains(key) || matches!( entry.state, TileLoadState::LoadingNetwork | TileLoadState::LoadingLocal ) { continue; } if key.z < min_keep_zoom || key.z > max_keep_zoom { to_evict.push(*key); continue; } if frame.saturating_sub(entry.last_used) > threshold { to_evict.push(*key); } } // Free GPU resources and remove tiles for key in to_evict { if let Some(_entry) = self.tiles.remove(&key) { // Geometry GPU resources are released when the entry is dropped } } } // --- Theme change --- pub fn clear_all(&mut self) { self.style_epoch = self.style_epoch.wrapping_add(1); if self.style_epoch == 0 { self.style_epoch = 1; } self.tiles.clear(); } pub fn style_epoch(&self) -> u64 { self.style_epoch } pub fn set_style_epoch(&mut self, epoch: u64) { self.style_epoch = epoch; } pub fn iter_all(&self) -> impl Iterator { self.tiles.iter() } pub fn iter_visible<'a>( &'a self, keys: &'a [TileKey], ) -> impl Iterator + 'a { keys.iter().filter_map(move |&key| { self.tiles.get(&key).map(|entry| (key, entry)) }) } } #[cfg(test)] mod tests { use super::*; use super::super::style::MapThemeStyle; fn key(z: u32, x: i32, y: i32) -> TileKey { TileKey { z, x, y } } fn make_cache() -> TileCache { TileCache::new(100) } #[test] fn insert_and_get() { let mut cache = make_cache(); let k = key(14, 9872, 8247); cache.insert_loading(k, TileLoadState::LoadingLocal); assert!(cache.get(k).is_some()); assert!(cache.is_loading(k)); } #[test] fn is_ready_with_empty_geometry() { let mut cache = make_cache(); let k = key(14, 0, 0); cache.tiles.insert( k, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 0, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); assert!(!cache.is_ready(k), "0 features and no geometry should not be ready"); } #[test] fn is_ready_with_features() { let mut cache = make_cache(); let k = key(14, 0, 0); cache.tiles.insert( k, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 42, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); assert!(cache.is_ready(k)); } #[test] fn mark_failed_increments_attempts() { let mut cache = make_cache(); let k = key(14, 0, 0); cache.insert_loading(k, TileLoadState::LoadingLocal); cache.mark_failed(k, "test error"); assert!(cache.is_failed(k)); let entry = cache.get(k).unwrap(); assert_eq!(entry.attempts, 1); cache.mark_failed(k, "test error 2"); let entry = cache.get(k).unwrap(); assert_eq!(entry.attempts, 2); } #[test] fn find_ready_ancestor_found() { let mut cache = make_cache(); // Insert a ready tile at z13 let parent = key(13, 4936, 4123); cache.tiles.insert( parent, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 10, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); // Child at z14 should find parent let child = key(14, 9872, 8247); assert_eq!(cache.find_ready_ancestor(child), Some(parent)); } #[test] fn find_ready_ancestor_not_found() { let cache = make_cache(); let k = key(14, 0, 0); assert_eq!(cache.find_ready_ancestor(k), None); } #[test] fn find_ready_descendants() { let mut cache = make_cache(); let parent = key(13, 100, 100); let child1 = key(14, 200, 200); let child2 = key(14, 201, 200); let unrelated = key(14, 0, 0); for k in [parent, child1, child2, unrelated] { cache.tiles.insert( k, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 5, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); } let descendants = cache.find_ready_descendants(parent); assert_eq!(descendants.len(), 2); assert!(descendants.contains(&child1)); assert!(descendants.contains(&child2)); assert!(!descendants.contains(&unrelated)); } #[test] fn loading_count() { let mut cache = make_cache(); cache.insert_loading(key(14, 0, 0), TileLoadState::LoadingLocal); cache.insert_loading(key(14, 1, 0), TileLoadState::LoadingNetwork); assert_eq!(cache.loading_count(), 2); } #[test] fn status_counts_ready() { let mut cache = make_cache(); let k = key(14, 0, 0); cache.tiles.insert( k, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 100, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); let counts = cache.status_counts(&[k]); assert_eq!(counts.ready, 1); assert_eq!(counts.features, 100); } #[test] fn evict_removes_old_tiles() { let mut cache = make_cache(); // Advance frame counter so old tiles become stale for _ in 0..300 { cache.tick(); } // Insert 150 tiles at zoom 14 with last_used=0 (stale) for i in 0..150 { let k = key(14, i, 0); cache.tiles.insert( k, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 1, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); } let visible: HashSet = (0..10).map(|i| key(14, i, 0)).collect(); cache.evict(&visible, 14); assert!(cache.len() <= 100 + 10, "should have evicted some tiles, got {}", cache.len()); } #[test] fn evict_preserves_loading() { let mut cache = make_cache(); for i in 0..150 { let k = key(14, i, 0); cache.tiles.insert( k, TileEntry { state: if i < 5 { TileLoadState::LoadingNetwork } else { TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 1, labels: vec![], pois: vec![], } }, last_used: 0, attempts: 0, }, ); } let visible = HashSet::new(); cache.evict(&visible, 14); // Loading tiles should be preserved for i in 0..5 { assert!(cache.contains(key(14, i, 0))); } } #[test] fn evict_preserves_visible() { let mut cache = make_cache(); for i in 0..150 { let k = key(14, i, 0); cache.tiles.insert( k, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 1, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); } let visible: HashSet = (0..10).map(|i| key(14, i, 0)).collect(); cache.evict(&visible, 14); for i in 0..10 { assert!(cache.contains(key(14, i, 0))); } } #[test] fn clear_all_resets() { let mut cache = make_cache(); cache.insert_loading(key(14, 0, 0), TileLoadState::LoadingLocal); let epoch = cache.style_epoch(); cache.clear_all(); assert!(cache.is_empty()); assert_eq!(cache.style_epoch(), epoch + 1); } #[test] fn style_epoch_monotonic() { let mut cache = make_cache(); let e0 = cache.style_epoch(); cache.clear_all(); let e1 = cache.style_epoch(); cache.clear_all(); let e2 = cache.style_epoch(); assert!(e1 > e0); assert!(e2 > e1); } #[test] fn tick_increments_frame() { let mut cache = make_cache(); let f0 = cache.frame_counter(); cache.tick(); assert_eq!(cache.frame_counter(), f0 + 1); } #[test] fn should_retry_after_delay() { let mut cache = make_cache(); let k = key(14, 0, 0); cache.mark_failed(k, "test"); // Should not retry immediately assert!(cache.should_retry(k, 6).is_none()); // Advance frames past the retry delay for _ in 0..31 { cache.tick(); } assert!(cache.should_retry(k, 6).is_some()); } #[test] fn should_retry_exhausted() { let mut cache = make_cache(); let k = key(14, 0, 0); // Fail 6 times (MAX_TILE_RETRIES) for _ in 0..6 { cache.mark_failed(k, "test"); } // Advance past retry delay for _ in 0..400 { cache.tick(); } assert!(cache.should_retry(k, 6).is_none(), "should not retry after max attempts"); } #[test] fn remove_returns_entry() { let mut cache = make_cache(); let k = key(14, 0, 0); cache.insert_loading(k, TileLoadState::LoadingLocal); assert!(cache.remove(k).is_some()); assert!(!cache.contains(k)); } #[test] fn mark_visible_updates_last_used() { let mut cache = make_cache(); let k = key(14, 0, 0); cache.insert_loading(k, TileLoadState::LoadingLocal); cache.tick(); cache.tick(); cache.mark_visible(k); assert_eq!(cache.get(k).unwrap().last_used, 2); } #[test] fn iter_visible_yields_ready() { let mut cache = make_cache(); let k1 = key(14, 0, 0); let k2 = key(14, 1, 0); cache.tiles.insert( k1, TileEntry { state: TileLoadState::Ready { fill_geometry: None, stroke_geometry: None, feature_count: 5, labels: vec![], pois: vec![], }, last_used: 0, attempts: 0, }, ); cache.insert_loading(k2, TileLoadState::LoadingLocal); let keys = vec![k1, k2]; let visible: Vec<_> = cache.iter_visible(&keys).collect(); assert_eq!(visible.len(), 2); } #[test] fn evict_noop_when_under_limit() { let mut cache = make_cache(); for i in 0..10 { cache.insert_loading(key(14, i, 0), TileLoadState::LoadingLocal); } let before = cache.len(); cache.evict(&HashSet::new(), 14); assert_eq!(cache.len(), before); } }