1use crate::camera_projection::CameraProjection;
4use crate::terrain::backfill::{expand_with_clamped_border, patch_changed_tiles, BackfillState};
5use crate::terrain::config::TerrainConfig;
6use crate::terrain::elevation_source::ElevationSourceDiagnostics;
7use crate::terrain::hillshade::{prepare_hillshade_raster, PreparedHillshadeRaster};
8use crate::terrain::mesh::{build_terrain_descriptor_with_source, skirt_height, TerrainMeshData};
9use crate::tile_manager::TileTextureRegion;
10use rustial_math::{
11 visible_tiles, visible_tiles_lod, ElevationGrid, GeoCoord, TileId, WorldBounds,
12};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, Default, PartialEq)]
17pub struct TerrainDiagnostics {
18 pub enabled: bool,
20 pub cache_entries: usize,
22 pub pending_tiles: usize,
24 pub visible_mesh_tiles: usize,
26 pub visible_loaded_tiles: usize,
28 pub visible_pending_tiles: usize,
30 pub visible_placeholder_tiles: usize,
32 pub visible_hillshade_tiles: usize,
34 pub source_max_zoom: u8,
36 pub last_desired_zoom: u8,
38 pub mesh_resolution: u16,
40 pub vertical_exaggeration: f64,
42 pub skirt_depth_m: f64,
44 pub visible_min_elevation_m: Option<f64>,
46 pub visible_max_elevation_m: Option<f64>,
48 pub elevation_texture_tiles: usize,
50 pub materialized_vertex_count: usize,
52 pub materialized_index_count: usize,
54 pub source_diagnostics: Option<ElevationSourceDiagnostics>,
56}
57
58fn clamp_tile_to_zoom(tile: TileId, max_zoom: u8) -> TileId {
64 if tile.zoom <= max_zoom {
65 return tile;
66 }
67 let dz = tile.zoom - max_zoom;
68 let scale = 1u32 << dz;
69 TileId::new(max_zoom, tile.x / scale, tile.y / scale)
70}
71
72fn terrain_base_tile_budget(required_tiles: usize) -> usize {
73 required_tiles.clamp(80, 256)
74}
75
76fn terrain_horizon_tile_budget(base_budget: usize, pitch: f64) -> usize {
77 if pitch <= 0.5 {
78 0
79 } else {
80 (base_budget / 3).clamp(24, 96)
81 }
82}
83
84pub struct TerrainManager {
86 config: TerrainConfig,
87 cache: HashMap<TileId, ElevationGrid>,
94 pending: std::collections::HashSet<TileId>,
96 max_cache: usize,
98 access_clock: u64,
100 last_touched: HashMap<TileId, u64>,
102 last_desired_zoom: u8,
104 next_generation: u64,
106 tile_generations: HashMap<TileId, u64>,
110 backfill_states: HashMap<TileId, BackfillState>,
113 hillshade_cache: HashMap<TileId, PreparedHillshadeRaster>,
115 last_hillshade_rasters: Vec<PreparedHillshadeRaster>,
117 last_meshes: Vec<TerrainMeshData>,
119 last_frame_key: Option<TerrainFrameKey>,
121}
122
123#[derive(Debug, Clone, PartialEq)]
124struct TerrainFrameKey {
125 desired_tiles: Vec<TileId>,
126 tile_generations: Vec<u64>,
127 projection: CameraProjection,
128 resolution: u16,
129 vertical_exaggeration: f64,
130 effective_skirt: f64,
131}
132
133impl TerrainFrameKey {
134 fn new(
135 desired_tiles: &[TileId],
136 tile_generations: Vec<u64>,
137 projection: CameraProjection,
138 resolution: u16,
139 vertical_exaggeration: f64,
140 effective_skirt: f64,
141 ) -> Self {
142 Self {
143 desired_tiles: desired_tiles.to_vec(),
144 tile_generations,
145 projection,
146 resolution,
147 vertical_exaggeration,
148 effective_skirt,
149 }
150 }
151}
152
153impl TerrainManager {
154 #[inline]
155 fn touch_tile(&mut self, tile: TileId) {
156 let stamp = self.access_clock;
157 self.access_clock = self.access_clock.saturating_add(1);
158 self.last_touched.insert(tile, stamp);
159 }
160
161 pub fn new(config: TerrainConfig, max_cache: usize) -> Self {
163 Self {
164 config,
165 cache: HashMap::new(),
166 pending: std::collections::HashSet::new(),
167 max_cache,
168 access_clock: 1,
169 last_touched: HashMap::new(),
170 last_desired_zoom: 0,
171 next_generation: 1,
172 tile_generations: HashMap::new(),
173 backfill_states: HashMap::new(),
174 hillshade_cache: HashMap::new(),
175 last_hillshade_rasters: Vec::new(),
176 last_meshes: Vec::new(),
177 last_frame_key: None,
178 }
179 }
180
181 pub fn enabled(&self) -> bool {
183 self.config.enabled
184 }
185
186 pub fn set_enabled(&mut self, enabled: bool) {
188 self.config.enabled = enabled;
189 if !enabled {
190 self.last_meshes.clear();
191 self.last_hillshade_rasters.clear();
192 self.last_frame_key = None;
193 }
194 }
195
196 pub fn vertical_exaggeration(&self) -> f64 {
198 self.config.vertical_exaggeration
199 }
200
201 pub fn set_vertical_exaggeration(&mut self, exaggeration: f64) {
203 self.config.vertical_exaggeration = exaggeration;
204 self.last_frame_key = None;
205 }
206
207 #[inline]
209 pub fn mesh_resolution(&self) -> u16 {
210 self.config.mesh_resolution
211 }
212
213 #[inline]
219 pub fn pending_count(&self) -> usize {
220 self.pending.len()
221 }
222
223 #[inline]
225 pub fn cache_entries(&self) -> usize {
226 self.cache.len()
227 }
228
229 #[inline]
231 pub fn last_desired_zoom(&self) -> u8 {
232 self.last_desired_zoom
233 }
234
235 pub fn diagnostics(&self) -> TerrainDiagnostics {
237 let mut diagnostics = TerrainDiagnostics {
238 enabled: self.config.enabled,
239 cache_entries: self.cache.len(),
240 pending_tiles: self.pending.len(),
241 visible_mesh_tiles: self.last_meshes.len(),
242 visible_hillshade_tiles: self.last_hillshade_rasters.len(),
243 source_max_zoom: self.config.source_max_zoom,
244 last_desired_zoom: self.last_desired_zoom,
245 mesh_resolution: self.config.mesh_resolution,
246 vertical_exaggeration: self.config.vertical_exaggeration,
247 skirt_depth_m: skirt_height(self.last_desired_zoom, self.config.vertical_exaggeration),
248 source_diagnostics: self.config.source.diagnostics(),
249 ..TerrainDiagnostics::default()
250 };
251
252 let mut min_elev = f64::INFINITY;
253 let mut max_elev = f64::NEG_INFINITY;
254
255 for mesh in &self.last_meshes {
256 let source_tile = clamp_tile_to_zoom(mesh.tile, self.config.source_max_zoom);
257 if self.cache.contains_key(&source_tile) {
258 diagnostics.visible_loaded_tiles += 1;
259 } else if self.pending.contains(&source_tile) {
260 diagnostics.visible_pending_tiles += 1;
261 } else {
262 diagnostics.visible_placeholder_tiles += 1;
263 }
264
265 if let Some(elevation) = mesh.elevation_texture.as_ref() {
266 diagnostics.elevation_texture_tiles += 1;
267 let lo = elevation.min_elev as f64 * self.config.vertical_exaggeration;
268 let hi = elevation.max_elev as f64 * self.config.vertical_exaggeration;
269 min_elev = min_elev.min(lo);
270 max_elev = max_elev.max(hi);
271 }
272
273 if mesh.positions.is_empty() {
274 let res = mesh.grid_resolution as usize;
276 let grid_verts = res * res;
277 let skirt_verts = 4 * 2 * (res - 1);
278 diagnostics.materialized_vertex_count += grid_verts + skirt_verts;
279 let grid_idx = (res - 1) * (res - 1) * 6;
280 let skirt_idx = 4 * (res - 1) * 6;
281 diagnostics.materialized_index_count += grid_idx + skirt_idx;
282 } else {
283 diagnostics.materialized_vertex_count += mesh.positions.len();
284 diagnostics.materialized_index_count += mesh.indices.len();
285 }
286 }
287
288 if min_elev.is_finite() && max_elev.is_finite() {
289 diagnostics.visible_min_elevation_m = Some(min_elev);
290 diagnostics.visible_max_elevation_m = Some(max_elev);
291 }
292
293 diagnostics
294 }
295
296 pub fn update(
300 &mut self,
301 viewport_bounds: &WorldBounds,
302 zoom: u8,
303 camera_world: (f64, f64),
304 projection: CameraProjection,
305 camera_distance: f64,
306 camera_pitch: f64,
307 ) -> Vec<TerrainMeshData> {
308 let desired = if camera_pitch > 0.3 {
309 let near_threshold = camera_distance * 1.5;
310 let mid_threshold = camera_distance * 4.0;
311 let max_tiles = 80;
312
313 let mut tiles = visible_tiles_lod(
314 viewport_bounds,
315 zoom,
316 camera_world,
317 near_threshold,
318 mid_threshold,
319 max_tiles,
320 );
321
322 {
326 let snapshot: Vec<TileId> = tiles.clone();
327 tiles.retain(|t| {
328 !snapshot.iter().any(|other| {
329 if other.zoom <= t.zoom {
330 return false;
331 }
332 let dz = other.zoom - t.zoom;
333 (other.x >> dz) == t.x && (other.y >> dz) == t.y
334 })
335 });
336 }
337
338 if camera_pitch > 0.5 && zoom > 2 {
344 use std::collections::HashSet;
345 let seen: HashSet<TileId> = tiles.iter().copied().collect();
346 let base_tiles: Vec<TileId> = tiles.clone();
347 let is_ancestor_of_existing = |candidate: &TileId| -> bool {
348 base_tiles.iter().any(|t| {
349 if t.zoom <= candidate.zoom {
350 return false;
351 }
352 let dz = t.zoom - candidate.zoom;
353 (t.x >> dz) == candidate.x && (t.y >> dz) == candidate.y
354 })
355 };
356 let mut budget = terrain_horizon_tile_budget(max_tiles, camera_pitch);
357 let mut hz = zoom.saturating_sub(2);
358 while hz > 0 && budget > 0 {
359 let coarse = visible_tiles(viewport_bounds, hz);
360 let mut extras: Vec<_> = coarse
361 .into_iter()
362 .filter(|t| !seen.contains(t) && !is_ancestor_of_existing(t))
363 .map(|t| {
364 let b = rustial_math::tile_bounds_world(&t);
365 let cx = (b.min.position.x + b.max.position.x) * 0.5;
366 let cy = (b.min.position.y + b.max.position.y) * 0.5;
367 let dx = cx - camera_world.0;
368 let dy = cy - camera_world.1;
369 (t, dx * dx + dy * dy)
370 })
371 .collect();
372 extras
373 .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
374 let take = extras.len().min(budget);
375 tiles.extend(extras.into_iter().take(take).map(|(t, _)| t));
376 budget = budget.saturating_sub(take);
377 hz = hz.saturating_sub(2);
378 }
379 }
380
381 tiles
382 } else {
383 visible_tiles(viewport_bounds, zoom)
384 };
385
386 self.update_with_tiles(&desired, zoom, projection)
387 }
388
389 pub fn update_with_tiles(
396 &mut self,
397 desired: &[TileId],
398 zoom: u8,
399 projection: CameraProjection,
400 ) -> Vec<TerrainMeshData> {
401 if !self.config.enabled {
402 self.last_meshes.clear();
403 self.last_hillshade_rasters.clear();
404 self.last_frame_key = None;
405 return Vec::new();
406 }
407
408 let source_max_zoom = self.config.source_max_zoom;
409
410 let completed = self.config.source.poll();
414 let mut changed_tiles = std::collections::HashSet::new();
415 for (id, result) in completed {
416 self.pending.remove(&id);
417 if let Ok(grid) = result {
418 let expanded = expand_with_clamped_border(&grid);
421 self.cache.insert(id, expanded);
422 self.touch_tile(id);
423 changed_tiles.insert(id);
424 }
425 }
426
427 if !changed_tiles.is_empty() {
431 let backfill_modified =
432 patch_changed_tiles(&mut self.cache, &mut self.backfill_states, &changed_tiles);
433
434 let gen = self.next_generation;
437 self.next_generation += 1;
438 for tile_id in changed_tiles.iter().chain(backfill_modified.iter()) {
439 self.tile_generations.insert(*tile_id, gen);
440 }
441 }
442
443 self.last_desired_zoom = zoom;
444
445 let desired_set: std::collections::HashSet<TileId> = desired.iter().copied().collect();
449 let source_tiles: std::collections::HashSet<TileId> = desired
450 .iter()
451 .map(|t| clamp_tile_to_zoom(*t, source_max_zoom))
452 .collect();
453 let hot_cached_tiles: Vec<_> = source_tiles
454 .iter()
455 .filter(|tile| self.cache.contains_key(tile))
456 .copied()
457 .collect();
458 for tile in hot_cached_tiles {
459 self.touch_tile(tile);
460 }
461
462 let stale_pending: Vec<_> = self
467 .pending
468 .iter()
469 .copied()
470 .filter(|tile| !source_tiles.contains(tile))
471 .collect();
472 for tile in stale_pending {
473 if self.config.source.cancel(tile) {
474 self.pending.remove(&tile);
475 }
476 }
477
478 let mut retain_set = desired_set.clone();
480 retain_set.extend(source_tiles.iter().copied());
481 self.evict_outside(&retain_set);
482
483 for source_tile in &source_tiles {
488 if !self.cache.contains_key(source_tile) && !self.pending.contains(source_tile) {
489 self.config.source.request(*source_tile);
490 self.pending.insert(*source_tile);
491 }
492 }
493
494 let resolution = self.config.mesh_resolution;
495
496 let effective_skirt = skirt_height(zoom, self.config.vertical_exaggeration);
497
498 let tile_generations: Vec<u64> = desired
499 .iter()
500 .map(|tile| {
501 let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
502 self.tile_generations
503 .get(&source_tile)
504 .copied()
505 .unwrap_or(0)
506 })
507 .collect();
508 let frame_key = TerrainFrameKey::new(
509 desired,
510 tile_generations,
511 projection,
512 resolution,
513 self.config.vertical_exaggeration,
514 effective_skirt,
515 );
516
517 if self.last_frame_key.as_ref() == Some(&frame_key) {
518 return self.last_meshes.clone();
519 }
520
521 let mut meshes = Vec::with_capacity(desired.len());
524 let mut hillshade_rasters = Vec::with_capacity(desired.len());
525 for tile in desired {
526 let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
527
528 let fallback;
532 let elevation = match self.cache.get(&source_tile) {
533 Some(cached) => cached,
534 None => {
535 fallback = ElevationGrid::flat(*tile, resolution as u32, resolution as u32);
536 &fallback
537 }
538 };
539
540 let tile_gen = self
541 .tile_generations
542 .get(&source_tile)
543 .copied()
544 .unwrap_or(0);
545 let elevation_region = TileTextureRegion::from_tiles(tile, &source_tile);
546
547 let mesh = build_terrain_descriptor_with_source(
548 tile,
549 source_tile,
550 elevation_region,
551 elevation,
552 resolution,
553 self.config.vertical_exaggeration,
554 tile_gen,
555 );
556 meshes.push(mesh);
557
558 let raster = match self.hillshade_cache.get(tile) {
559 Some(cached) if cached.generation == tile_gen => cached.clone(),
560 _ => {
561 let prepared = prepare_hillshade_raster(
562 elevation,
563 self.config.vertical_exaggeration,
564 tile_gen,
565 );
566 self.hillshade_cache.insert(*tile, prepared.clone());
567 prepared
568 }
569 };
570 hillshade_rasters.push(raster);
571 }
572
573 self.last_hillshade_rasters = hillshade_rasters;
574 self.last_meshes = meshes.clone();
575 self.last_frame_key = Some(frame_key);
576
577 meshes
578 }
579
580 pub fn update_sources(
588 &mut self,
589 viewport_bounds: &WorldBounds,
590 zoom: u8,
591 camera_world: (f64, f64),
592 camera_distance: f64,
593 camera_pitch: f64,
594 ) -> Vec<(TileId, ElevationGrid, u64)> {
595 if !self.config.enabled {
596 return Vec::new();
597 }
598
599 let completed = self.config.source.poll();
601 let mut changed_tiles = std::collections::HashSet::new();
602 for (id, result) in completed {
603 self.pending.remove(&id);
604 if let Ok(grid) = result {
605 let expanded = expand_with_clamped_border(&grid);
606 self.cache.insert(id, expanded);
607 self.touch_tile(id);
608 changed_tiles.insert(id);
609 }
610 }
611 if !changed_tiles.is_empty() {
612 let backfill_modified =
613 patch_changed_tiles(&mut self.cache, &mut self.backfill_states, &changed_tiles);
614 let gen = self.next_generation;
615 self.next_generation += 1;
616 for tile_id in changed_tiles.iter().chain(backfill_modified.iter()) {
617 self.tile_generations.insert(*tile_id, gen);
618 }
619 }
620
621 let desired = if camera_pitch > 0.3 {
622 let near_threshold = camera_distance * 1.5;
623 let mid_threshold = camera_distance * 4.0;
624 let strict_tiles = visible_tiles(viewport_bounds, zoom);
625 let max_tiles = terrain_base_tile_budget(strict_tiles.len());
626 visible_tiles_lod(
627 viewport_bounds,
628 zoom,
629 camera_world,
630 near_threshold,
631 mid_threshold,
632 max_tiles,
633 )
634 } else {
635 visible_tiles(viewport_bounds, zoom)
636 };
637
638 let source_max_zoom = self.config.source_max_zoom;
639 self.last_desired_zoom = zoom;
640
641 let desired_set: std::collections::HashSet<TileId> = desired.iter().copied().collect();
642 let source_tiles: std::collections::HashSet<TileId> = desired
643 .iter()
644 .map(|t| clamp_tile_to_zoom(*t, source_max_zoom))
645 .collect();
646 let hot_cached_tiles: Vec<_> = source_tiles
647 .iter()
648 .filter(|tile| self.cache.contains_key(tile))
649 .copied()
650 .collect();
651 for tile in hot_cached_tiles {
652 self.touch_tile(tile);
653 }
654
655 let stale_pending: Vec<_> = self
656 .pending
657 .iter()
658 .copied()
659 .filter(|tile| !source_tiles.contains(tile))
660 .collect();
661 for tile in stale_pending {
662 if self.config.source.cancel(tile) {
663 self.pending.remove(&tile);
664 }
665 }
666
667 let mut retain_set = desired_set.clone();
668 retain_set.extend(source_tiles.iter().copied());
669 self.evict_outside(&retain_set);
670
671 for source_tile in &source_tiles {
672 if !self.cache.contains_key(source_tile) && !self.pending.contains(source_tile) {
673 self.config.source.request(*source_tile);
674 self.pending.insert(*source_tile);
675 }
676 }
677
678 let resolution = self.config.mesh_resolution;
679
680 let mut result = Vec::with_capacity(desired.len());
682 for tile in &desired {
683 let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
684 let elevation = self.cache.get(&source_tile).cloned().unwrap_or_else(|| {
685 ElevationGrid::flat(*tile, resolution as u32, resolution as u32)
686 });
687 let tile_gen = self
688 .tile_generations
689 .get(&source_tile)
690 .copied()
691 .unwrap_or(0);
692 result.push((*tile, elevation, tile_gen));
693 }
694 result
695 }
696
697 pub fn update_sources_with_tiles(
704 &mut self,
705 desired: &[TileId],
706 zoom: u8,
707 ) -> Vec<(TileId, ElevationGrid, u64)> {
708 if !self.config.enabled {
709 return Vec::new();
710 }
711
712 let completed = self.config.source.poll();
713 let mut changed_tiles = std::collections::HashSet::new();
714 for (id, result) in completed {
715 self.pending.remove(&id);
716 if let Ok(grid) = result {
717 let expanded = expand_with_clamped_border(&grid);
718 self.cache.insert(id, expanded);
719 self.touch_tile(id);
720 changed_tiles.insert(id);
721 }
722 }
723 if !changed_tiles.is_empty() {
724 let backfill_modified =
725 patch_changed_tiles(&mut self.cache, &mut self.backfill_states, &changed_tiles);
726 let generation = self.next_generation;
727 self.next_generation += 1;
728 for tile_id in changed_tiles.iter().chain(backfill_modified.iter()) {
729 self.tile_generations.insert(*tile_id, generation);
730 }
731 }
732
733 self.last_desired_zoom = zoom;
734
735 let source_max_zoom = self.config.source_max_zoom;
736 let desired_set: std::collections::HashSet<TileId> = desired.iter().copied().collect();
737 let source_tiles: std::collections::HashSet<TileId> = desired
738 .iter()
739 .map(|tile| clamp_tile_to_zoom(*tile, source_max_zoom))
740 .collect();
741 let hot_cached_tiles: Vec<_> = source_tiles
742 .iter()
743 .filter(|tile| self.cache.contains_key(tile))
744 .copied()
745 .collect();
746 for tile in hot_cached_tiles {
747 self.touch_tile(tile);
748 }
749
750 let stale_pending: Vec<_> = self
751 .pending
752 .iter()
753 .copied()
754 .filter(|tile| !source_tiles.contains(tile))
755 .collect();
756 for tile in stale_pending {
757 if self.config.source.cancel(tile) {
758 self.pending.remove(&tile);
759 }
760 }
761
762 let mut retain_set = desired_set.clone();
763 retain_set.extend(source_tiles.iter().copied());
764 self.evict_outside(&retain_set);
765
766 for source_tile in &source_tiles {
767 if !self.cache.contains_key(source_tile) && !self.pending.contains(source_tile) {
768 self.config.source.request(*source_tile);
769 self.pending.insert(*source_tile);
770 }
771 }
772
773 let resolution = self.config.mesh_resolution;
774
775 let mut result = Vec::with_capacity(desired.len());
777 for tile in desired {
778 let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
779 let elevation = self.cache.get(&source_tile).cloned().unwrap_or_else(|| {
780 ElevationGrid::flat(*tile, resolution as u32, resolution as u32)
781 });
782 let tile_gen = self
783 .tile_generations
784 .get(&source_tile)
785 .copied()
786 .unwrap_or(0);
787 result.push((*tile, elevation, tile_gen));
788 }
789
790 result
791 }
792
793 pub fn elevation_at(&self, coord: &GeoCoord) -> Option<f64> {
803 if !self.config.enabled {
804 return None;
805 }
806
807 let max_z = self.last_desired_zoom.min(self.config.source_max_zoom);
808 let mut z = max_z;
809 loop {
810 let tile = rustial_math::geo_to_tile(coord, z).tile_id();
811 if let Some(grid) = self.cache.get(&tile) {
812 if let Some(elev) = grid.sample_geo(coord) {
813 return Some(elev as f64 * self.config.vertical_exaggeration);
814 }
815 }
816 if z == 0 {
817 break;
818 }
819 z -= 1;
820 }
821
822 None
823 }
824
825 pub fn config(&self) -> &TerrainConfig {
827 &self.config
828 }
829
830 pub fn config_mut(&mut self) -> &mut TerrainConfig {
832 self.last_frame_key = None;
833 &mut self.config
834 }
835
836 pub fn visible_hillshade_rasters(&self) -> &[PreparedHillshadeRaster] {
838 &self.last_hillshade_rasters
839 }
840
841 #[inline]
843 pub fn source_max_zoom(&self) -> u8 {
844 self.config.source_max_zoom
845 }
846
847 #[inline]
849 pub fn elevation_source_tile_for(&self, tile: TileId) -> TileId {
850 clamp_tile_to_zoom(tile, self.config.source_max_zoom)
851 }
852
853 #[inline]
855 pub fn elevation_region_for(&self, tile: TileId) -> TileTextureRegion {
856 let source_tile = self.elevation_source_tile_for(tile);
857 TileTextureRegion::from_tiles(&tile, &source_tile)
858 }
859
860 fn evict_outside(&mut self, desired: &std::collections::HashSet<TileId>) {
861 while self.cache.len() > self.max_cache {
862 let stale = self
863 .cache
864 .keys()
865 .filter(|id| !desired.contains(id) && id.zoom != self.last_desired_zoom)
866 .min_by_key(|id| self.last_touched.get(id).copied().unwrap_or(0))
867 .copied();
868 if let Some(key) = stale {
869 self.cache.remove(&key);
870 self.last_touched.remove(&key);
871 self.tile_generations.remove(&key);
872 self.hillshade_cache.remove(&key);
873 self.backfill_states.remove(&key);
874 self.last_frame_key = None;
875 continue;
876 }
877 let expendable = self
878 .cache
879 .keys()
880 .filter(|id| !desired.contains(id))
881 .min_by_key(|id| self.last_touched.get(id).copied().unwrap_or(0))
882 .copied();
883 if let Some(key) = expendable {
884 self.cache.remove(&key);
885 self.last_touched.remove(&key);
886 self.tile_generations.remove(&key);
887 self.hillshade_cache.remove(&key);
888 self.backfill_states.remove(&key);
889 self.last_frame_key = None;
890 continue;
891 }
892 break;
893 }
894 }
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::camera_projection::CameraProjection;
901 use crate::terrain::elevation_source::FlatElevationSource;
902 use rustial_math::{WebMercator, WorldCoord};
903
904 fn full_world_bounds() -> WorldBounds {
905 let extent = WebMercator::max_extent();
906 WorldBounds::new(
907 WorldCoord::new(-extent, -extent, 0.0),
908 WorldCoord::new(extent, extent, 0.0),
909 )
910 }
911
912 #[test]
913 fn disabled_returns_empty() {
914 let config = TerrainConfig::default();
915 let mut mgr = TerrainManager::new(config, 100);
916 let meshes = mgr.update(
917 &full_world_bounds(),
918 0,
919 (0.0, 0.0),
920 CameraProjection::WebMercator,
921 10_000_000.0,
922 0.0,
923 );
924 assert!(meshes.is_empty());
925 }
926
927 #[test]
928 fn enabled_with_flat_source() {
929 let config = TerrainConfig {
930 enabled: true,
931 mesh_resolution: 4,
932 source: Box::new(FlatElevationSource::new(4, 4)),
933 ..TerrainConfig::default()
934 };
935 let mut mgr = TerrainManager::new(config, 100);
936
937 let meshes = mgr.update(
938 &full_world_bounds(),
939 0,
940 (0.0, 0.0),
941 CameraProjection::WebMercator,
942 10_000_000.0,
943 0.0,
944 );
945 assert_eq!(meshes.len(), 1);
946 assert_eq!(meshes[0].tile, TileId::new(0, 0, 0));
947
948 let meshes = mgr.update(
949 &full_world_bounds(),
950 0,
951 (0.0, 0.0),
952 CameraProjection::WebMercator,
953 10_000_000.0,
954 0.0,
955 );
956 assert_eq!(meshes.len(), 1);
957 assert_eq!(meshes[0].tile, TileId::new(0, 0, 0));
958 }
959
960 #[test]
961 fn steady_state_reuses_cached_meshes() {
962 let config = TerrainConfig {
963 enabled: true,
964 mesh_resolution: 8,
965 source: Box::new(FlatElevationSource::new(8, 8)),
966 ..TerrainConfig::default()
967 };
968 let mut mgr = TerrainManager::new(config, 100);
969
970 mgr.update(
971 &full_world_bounds(),
972 0,
973 (0.0, 0.0),
974 CameraProjection::WebMercator,
975 10_000_000.0,
976 0.0,
977 );
978 let first = mgr.update(
979 &full_world_bounds(),
980 0,
981 (0.0, 0.0),
982 CameraProjection::WebMercator,
983 10_000_000.0,
984 0.0,
985 );
986 let second = mgr.update(
987 &full_world_bounds(),
988 0,
989 (0.0, 0.0),
990 CameraProjection::WebMercator,
991 10_000_000.0,
992 0.0,
993 );
994
995 assert_eq!(first.len(), second.len());
996 assert_eq!(first[0].tile, second[0].tile);
997 assert_eq!(first[0].grid_resolution, second[0].grid_resolution);
998 assert_eq!(
999 first[0]
1000 .elevation_texture
1001 .as_ref()
1002 .map(|t| (t.width, t.height)),
1003 second[0]
1004 .elevation_texture
1005 .as_ref()
1006 .map(|t| (t.width, t.height)),
1007 );
1008 assert!(first[0].positions.is_empty());
1009 assert!(second[0].positions.is_empty());
1010 }
1011
1012 #[test]
1013 fn changing_projection_invalidates_cached_meshes() {
1014 let config = TerrainConfig {
1015 enabled: true,
1016 mesh_resolution: 8,
1017 source: Box::new(FlatElevationSource::new(8, 8)),
1018 ..TerrainConfig::default()
1019 };
1020 let mut mgr = TerrainManager::new(config, 100);
1021
1022 mgr.update(
1023 &full_world_bounds(),
1024 0,
1025 (0.0, 0.0),
1026 CameraProjection::WebMercator,
1027 10_000_000.0,
1028 0.0,
1029 );
1030 let merc = mgr.update(
1031 &full_world_bounds(),
1032 0,
1033 (0.0, 0.0),
1034 CameraProjection::WebMercator,
1035 10_000_000.0,
1036 0.0,
1037 );
1038 let eq = mgr.update(
1039 &full_world_bounds(),
1040 0,
1041 (0.0, 0.0),
1042 CameraProjection::Equirectangular,
1043 10_000_000.0,
1044 0.0,
1045 );
1046
1047 assert_eq!(merc.len(), eq.len());
1048 assert_eq!(merc[0].tile, eq[0].tile);
1049 assert_eq!(merc[0].grid_resolution, eq[0].grid_resolution);
1050 assert!(merc[0].positions.is_empty());
1051 assert!(eq[0].positions.is_empty());
1052 }
1053
1054 #[test]
1055 fn elevation_at_flat() {
1056 let config = TerrainConfig {
1057 enabled: true,
1058 mesh_resolution: 4,
1059 source: Box::new(FlatElevationSource::new(4, 4)),
1060 ..TerrainConfig::default()
1061 };
1062 let mut mgr = TerrainManager::new(config, 100);
1063 mgr.update(
1064 &full_world_bounds(),
1065 0,
1066 (0.0, 0.0),
1067 CameraProjection::WebMercator,
1068 10_000_000.0,
1069 0.0,
1070 );
1071 mgr.update(
1072 &full_world_bounds(),
1073 0,
1074 (0.0, 0.0),
1075 CameraProjection::WebMercator,
1076 10_000_000.0,
1077 0.0,
1078 );
1079
1080 let elev = mgr.elevation_at(&GeoCoord::from_lat_lon(0.0, 0.0));
1081 assert_eq!(elev, Some(0.0));
1082 }
1083
1084 #[test]
1085 fn prepared_hillshade_is_emitted_for_visible_tiles() {
1086 let config = TerrainConfig {
1087 enabled: true,
1088 mesh_resolution: 4,
1089 source: Box::new(FlatElevationSource::new(4, 4)),
1090 ..TerrainConfig::default()
1091 };
1092 let mut mgr = TerrainManager::new(config, 100);
1093 mgr.update(
1094 &full_world_bounds(),
1095 0,
1096 (0.0, 0.0),
1097 CameraProjection::WebMercator,
1098 10_000_000.0,
1099 0.0,
1100 );
1101 mgr.update(
1102 &full_world_bounds(),
1103 0,
1104 (0.0, 0.0),
1105 CameraProjection::WebMercator,
1106 10_000_000.0,
1107 0.0,
1108 );
1109
1110 let rasters = mgr.visible_hillshade_rasters();
1111 assert_eq!(rasters.len(), 1);
1112 assert_eq!(rasters[0].tile, TileId::new(0, 0, 0));
1113 assert_eq!(rasters[0].image.width, 6);
1117 assert_eq!(rasters[0].image.height, 6);
1118 }
1119
1120 #[test]
1121 fn diagnostics_report_visible_and_cache_state() {
1122 let config = TerrainConfig {
1123 enabled: true,
1124 mesh_resolution: 4,
1125 vertical_exaggeration: 1.5,
1126 skirt_depth: 120.0,
1127 source: Box::new(FlatElevationSource::new(4, 4)),
1128 ..TerrainConfig::default()
1129 };
1130 let mut mgr = TerrainManager::new(config, 100);
1131
1132 mgr.update(
1134 &full_world_bounds(),
1135 0,
1136 (0.0, 0.0),
1137 CameraProjection::WebMercator,
1138 10_000_000.0,
1139 0.0,
1140 );
1141 let first = mgr.diagnostics();
1142 assert!(first.enabled);
1143 assert_eq!(first.visible_mesh_tiles, 1);
1144 assert_eq!(first.visible_pending_tiles, 1);
1145 assert_eq!(first.visible_loaded_tiles, 0);
1146 assert_eq!(first.cache_entries, 0);
1147 assert_eq!(first.pending_tiles, 1);
1148 assert_eq!(first.visible_hillshade_tiles, 1);
1149 assert_eq!(first.elevation_texture_tiles, 1);
1150 assert_eq!(first.mesh_resolution, 4);
1151 assert_eq!(first.vertical_exaggeration, 1.5);
1152 assert_eq!(first.skirt_depth_m, skirt_height(0, 1.5));
1153
1154 mgr.update(
1156 &full_world_bounds(),
1157 0,
1158 (0.0, 0.0),
1159 CameraProjection::WebMercator,
1160 10_000_000.0,
1161 0.0,
1162 );
1163 let second = mgr.diagnostics();
1164 assert_eq!(second.visible_mesh_tiles, 1);
1165 assert_eq!(second.visible_loaded_tiles, 1);
1166 assert_eq!(second.visible_pending_tiles, 0);
1167 assert_eq!(second.visible_placeholder_tiles, 0);
1168 assert_eq!(second.cache_entries, 1);
1169 assert_eq!(second.pending_tiles, 0);
1170 assert_eq!(second.visible_hillshade_tiles, 1);
1171 assert_eq!(second.visible_min_elevation_m, Some(0.0));
1172 assert_eq!(second.visible_max_elevation_m, Some(0.0));
1173 assert_eq!(second.last_desired_zoom, 0);
1174 assert_eq!(second.source_max_zoom, 15);
1175 }
1176
1177 #[test]
1178 fn overzoomed_child_mesh_uses_parent_dem_subregion() {
1179 let config = TerrainConfig {
1180 enabled: true,
1181 mesh_resolution: 4,
1182 source_max_zoom: 15,
1183 source: Box::new(FlatElevationSource::new(4, 4)),
1184 ..TerrainConfig::default()
1185 };
1186 let mut mgr = TerrainManager::new(config, 100);
1187 let child = TileId::new(16, 1000, 2000);
1188
1189 let _ = mgr.update_with_tiles(&[child], 16, CameraProjection::WebMercator);
1191 let meshes = mgr.update_with_tiles(&[child], 16, CameraProjection::WebMercator);
1193
1194 assert_eq!(meshes.len(), 1);
1195 let mesh = &meshes[0];
1196 assert_eq!(mesh.tile, child);
1197 assert_eq!(mesh.elevation_source_tile, TileId::new(15, 500, 1000));
1198 assert_eq!(mesh.elevation_region.u_min, 0.0);
1199 assert_eq!(mesh.elevation_region.v_min, 0.0);
1200 assert_eq!(mesh.elevation_region.u_max, 0.5);
1201 assert_eq!(mesh.elevation_region.v_max, 0.5);
1202 }
1203
1204 #[test]
1205 fn evict_outside_prefers_least_recently_used_non_retained_tile() {
1206 let config = TerrainConfig {
1207 enabled: true,
1208 source: Box::new(FlatElevationSource::new(4, 4)),
1209 ..TerrainConfig::default()
1210 };
1211 let mut mgr = TerrainManager::new(config, 2);
1212 let a = TileId::new(3, 0, 0);
1213 let b = TileId::new(3, 1, 0);
1214 let c = TileId::new(3, 2, 0);
1215
1216 mgr.cache.insert(a, ElevationGrid::flat(a, 4, 4));
1217 mgr.touch_tile(a);
1218 mgr.cache.insert(b, ElevationGrid::flat(b, 4, 4));
1219 mgr.touch_tile(b);
1220 mgr.cache.insert(c, ElevationGrid::flat(c, 4, 4));
1221 mgr.touch_tile(c);
1222
1223 let retain = std::collections::HashSet::from([c]);
1224 mgr.evict_outside(&retain);
1225
1226 assert!(!mgr.cache.contains_key(&a));
1227 assert!(mgr.cache.contains_key(&b));
1228 assert!(mgr.cache.contains_key(&c));
1229 assert!(!mgr.last_touched.contains_key(&a));
1230 }
1231}