1use crate::camera_projection::CameraProjection;
4use crate::terrain::backfill::{
5 expand_with_clamped_border, patch_changed_tiles, BackfillState,
6};
7use crate::terrain::config::TerrainConfig;
8use crate::terrain::elevation_source::ElevationSourceDiagnostics;
9use crate::terrain::hillshade::{prepare_hillshade_raster, PreparedHillshadeRaster};
10use crate::terrain::mesh::{build_terrain_descriptor_with_source, skirt_height, TerrainMeshData};
11use crate::tile_manager::TileTextureRegion;
12use rustial_math::{visible_tiles, visible_tiles_lod, ElevationGrid, GeoCoord, TileId, WorldBounds};
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.max(80).min(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).max(24).min(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 { return false; }
330 let dz = other.zoom - t.zoom;
331 (other.x >> dz) == t.x && (other.y >> dz) == t.y
332 })
333 });
334 }
335
336 if camera_pitch > 0.5 && zoom > 2 {
342 use std::collections::HashSet;
343 let seen: HashSet<TileId> = tiles.iter().copied().collect();
344 let base_tiles: Vec<TileId> = tiles.clone();
345 let is_ancestor_of_existing = |candidate: &TileId| -> bool {
346 base_tiles.iter().any(|t| {
347 if t.zoom <= candidate.zoom { return false; }
348 let dz = t.zoom - candidate.zoom;
349 (t.x >> dz) == candidate.x && (t.y >> dz) == candidate.y
350 })
351 };
352 let mut budget = terrain_horizon_tile_budget(max_tiles, camera_pitch);
353 let mut hz = zoom.saturating_sub(2);
354 while hz > 0 && budget > 0 {
355 let coarse = visible_tiles(viewport_bounds, hz);
356 let mut extras: Vec<_> = coarse
357 .into_iter()
358 .filter(|t| !seen.contains(t) && !is_ancestor_of_existing(t))
359 .map(|t| {
360 let b = rustial_math::tile_bounds_world(&t);
361 let cx = (b.min.position.x + b.max.position.x) * 0.5;
362 let cy = (b.min.position.y + b.max.position.y) * 0.5;
363 let dx = cx - camera_world.0;
364 let dy = cy - camera_world.1;
365 (t, dx * dx + dy * dy)
366 })
367 .collect();
368 extras.sort_by(|a, b| {
369 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
370 });
371 let take = extras.len().min(budget);
372 tiles.extend(extras.into_iter().take(take).map(|(t, _)| t));
373 budget = budget.saturating_sub(take);
374 hz = hz.saturating_sub(2);
375 }
376 }
377
378 tiles
379 } else {
380 visible_tiles(viewport_bounds, zoom)
381 };
382
383 self.update_with_tiles(&desired, zoom, projection)
384 }
385
386 pub fn update_with_tiles(
393 &mut self,
394 desired: &[TileId],
395 zoom: u8,
396 projection: CameraProjection,
397 ) -> Vec<TerrainMeshData> {
398 if !self.config.enabled {
399 self.last_meshes.clear();
400 self.last_hillshade_rasters.clear();
401 self.last_frame_key = None;
402 return Vec::new();
403 }
404
405 let source_max_zoom = self.config.source_max_zoom;
406
407 let completed = self.config.source.poll();
411 let mut changed_tiles = std::collections::HashSet::new();
412 for (id, result) in completed {
413 self.pending.remove(&id);
414 if let Ok(grid) = result {
415 let expanded = expand_with_clamped_border(&grid);
418 self.cache.insert(id, expanded);
419 self.touch_tile(id);
420 changed_tiles.insert(id);
421 }
422 }
423
424 if !changed_tiles.is_empty() {
428 let backfill_modified = patch_changed_tiles(
429 &mut self.cache,
430 &mut self.backfill_states,
431 &changed_tiles,
432 );
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> =
449 desired.iter().copied().collect();
450 let source_tiles: std::collections::HashSet<TileId> = desired
451 .iter()
452 .map(|t| clamp_tile_to_zoom(*t, source_max_zoom))
453 .collect();
454 let hot_cached_tiles: Vec<_> = source_tiles
455 .iter()
456 .filter(|tile| self.cache.contains_key(tile))
457 .copied()
458 .collect();
459 for tile in hot_cached_tiles {
460 self.touch_tile(tile);
461 }
462
463 let stale_pending: Vec<_> = self
468 .pending
469 .iter()
470 .copied()
471 .filter(|tile| !source_tiles.contains(tile))
472 .collect();
473 for tile in stale_pending {
474 if self.config.source.cancel(tile) {
475 self.pending.remove(&tile);
476 }
477 }
478
479 let mut retain_set = desired_set.clone();
481 retain_set.extend(source_tiles.iter().copied());
482 self.evict_outside(&retain_set);
483
484 for source_tile in &source_tiles {
489 if !self.cache.contains_key(source_tile) && !self.pending.contains(source_tile) {
490 self.config.source.request(*source_tile);
491 self.pending.insert(*source_tile);
492 }
493 }
494
495 let resolution = self.config.mesh_resolution;
496
497 let effective_skirt = skirt_height(zoom, self.config.vertical_exaggeration);
498
499 let tile_generations: Vec<u64> = desired
500 .iter()
501 .map(|tile| {
502 let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
503 self.tile_generations.get(&source_tile).copied().unwrap_or(0)
504 })
505 .collect();
506 let frame_key = TerrainFrameKey::new(
507 desired,
508 tile_generations,
509 projection,
510 resolution,
511 self.config.vertical_exaggeration,
512 effective_skirt,
513 );
514
515 if self.last_frame_key.as_ref() == Some(&frame_key) {
516 return self.last_meshes.clone();
517 }
518
519 let mut meshes = Vec::with_capacity(desired.len());
522 let mut hillshade_rasters = Vec::with_capacity(desired.len());
523 for tile in desired {
524 let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
525
526 let fallback;
530 let elevation = match self.cache.get(&source_tile) {
531 Some(cached) => cached,
532 None => {
533 fallback =
534 ElevationGrid::flat(*tile, resolution as u32, resolution as u32);
535 &fallback
536 }
537 };
538
539 let tile_gen = self.tile_generations.get(&source_tile).copied().unwrap_or(0);
540 let elevation_region = TileTextureRegion::from_tiles(tile, &source_tile);
541
542 let mesh = build_terrain_descriptor_with_source(
543 tile,
544 source_tile,
545 elevation_region,
546 &elevation,
547 resolution,
548 self.config.vertical_exaggeration,
549 tile_gen,
550 );
551 meshes.push(mesh);
552
553 let raster = match self.hillshade_cache.get(tile) {
554 Some(cached) if cached.generation == tile_gen => cached.clone(),
555 _ => {
556 let prepared = prepare_hillshade_raster(
557 &elevation,
558 self.config.vertical_exaggeration,
559 tile_gen,
560 );
561 self.hillshade_cache.insert(*tile, prepared.clone());
562 prepared
563 }
564 };
565 hillshade_rasters.push(raster);
566 }
567
568 self.last_hillshade_rasters = hillshade_rasters;
569 self.last_meshes = meshes.clone();
570 self.last_frame_key = Some(frame_key);
571
572 meshes
573 }
574
575 pub fn update_sources(
583 &mut self,
584 viewport_bounds: &WorldBounds,
585 zoom: u8,
586 camera_world: (f64, f64),
587 camera_distance: f64,
588 camera_pitch: f64,
589 ) -> Vec<(TileId, ElevationGrid, u64)> {
590 if !self.config.enabled {
591 return Vec::new();
592 }
593
594 let completed = self.config.source.poll();
596 let mut changed_tiles = std::collections::HashSet::new();
597 for (id, result) in completed {
598 self.pending.remove(&id);
599 if let Ok(grid) = result {
600 let expanded = expand_with_clamped_border(&grid);
601 self.cache.insert(id, expanded);
602 self.touch_tile(id);
603 changed_tiles.insert(id);
604 }
605 }
606 if !changed_tiles.is_empty() {
607 let backfill_modified = patch_changed_tiles(
608 &mut self.cache,
609 &mut self.backfill_states,
610 &changed_tiles,
611 );
612 let gen = self.next_generation;
613 self.next_generation += 1;
614 for tile_id in changed_tiles.iter().chain(backfill_modified.iter()) {
615 self.tile_generations.insert(*tile_id, gen);
616 }
617 }
618
619 let desired = if camera_pitch > 0.3 {
620 let near_threshold = camera_distance * 1.5;
621 let mid_threshold = camera_distance * 4.0;
622 let strict_tiles = visible_tiles(viewport_bounds, zoom);
623 let max_tiles = terrain_base_tile_budget(strict_tiles.len());
624 visible_tiles_lod(
625 viewport_bounds,
626 zoom,
627 camera_world,
628 near_threshold,
629 mid_threshold,
630 max_tiles,
631 )
632 } else {
633 visible_tiles(viewport_bounds, zoom)
634 };
635
636 let source_max_zoom = self.config.source_max_zoom;
637 self.last_desired_zoom = zoom;
638
639 let desired_set: std::collections::HashSet<TileId> =
640 desired.iter().copied().collect();
641 let source_tiles: std::collections::HashSet<TileId> = desired
642 .iter()
643 .map(|t| clamp_tile_to_zoom(*t, source_max_zoom))
644 .collect();
645 let hot_cached_tiles: Vec<_> = source_tiles
646 .iter()
647 .filter(|tile| self.cache.contains_key(tile))
648 .copied()
649 .collect();
650 for tile in hot_cached_tiles {
651 self.touch_tile(tile);
652 }
653
654 let stale_pending: Vec<_> = self
655 .pending
656 .iter()
657 .copied()
658 .filter(|tile| !source_tiles.contains(tile))
659 .collect();
660 for tile in stale_pending {
661 if self.config.source.cancel(tile) {
662 self.pending.remove(&tile);
663 }
664 }
665
666 let mut retain_set = desired_set.clone();
667 retain_set.extend(source_tiles.iter().copied());
668 self.evict_outside(&retain_set);
669
670 for source_tile in &source_tiles {
671 if !self.cache.contains_key(source_tile) && !self.pending.contains(source_tile) {
672 self.config.source.request(*source_tile);
673 self.pending.insert(*source_tile);
674 }
675 }
676
677 let resolution = self.config.mesh_resolution;
678
679 let mut result = Vec::with_capacity(desired.len());
681 for tile in &desired {
682 let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
683 let elevation = self
684 .cache
685 .get(&source_tile)
686 .cloned()
687 .unwrap_or_else(|| ElevationGrid::flat(*tile, resolution as u32, resolution as u32));
688 let tile_gen = self.tile_generations.get(&source_tile).copied().unwrap_or(0);
689 result.push((*tile, elevation, tile_gen));
690 }
691 result
692 }
693
694 pub fn update_sources_with_tiles(
701 &mut self,
702 desired: &[TileId],
703 zoom: u8,
704 ) -> Vec<(TileId, ElevationGrid, u64)> {
705 if !self.config.enabled {
706 return Vec::new();
707 }
708
709 let completed = self.config.source.poll();
710 let mut changed_tiles = std::collections::HashSet::new();
711 for (id, result) in completed {
712 self.pending.remove(&id);
713 if let Ok(grid) = result {
714 let expanded = expand_with_clamped_border(&grid);
715 self.cache.insert(id, expanded);
716 self.touch_tile(id);
717 changed_tiles.insert(id);
718 }
719 }
720 if !changed_tiles.is_empty() {
721 let backfill_modified = patch_changed_tiles(
722 &mut self.cache,
723 &mut self.backfill_states,
724 &changed_tiles,
725 );
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
780 .cache
781 .get(&source_tile)
782 .cloned()
783 .unwrap_or_else(|| ElevationGrid::flat(*tile, resolution as u32, resolution as u32));
784 let tile_gen = self.tile_generations.get(&source_tile).copied().unwrap_or(0);
785 result.push((*tile, elevation, tile_gen));
786 }
787
788 result
789 }
790
791 pub fn elevation_at(&self, coord: &GeoCoord) -> Option<f64> {
801 if !self.config.enabled {
802 return None;
803 }
804
805 let max_z = self.last_desired_zoom.min(self.config.source_max_zoom);
806 let mut z = max_z;
807 loop {
808 let tile = rustial_math::geo_to_tile(coord, z).tile_id();
809 if let Some(grid) = self.cache.get(&tile) {
810 if let Some(elev) = grid.sample_geo(coord) {
811 return Some(elev as f64 * self.config.vertical_exaggeration);
812 }
813 }
814 if z == 0 {
815 break;
816 }
817 z -= 1;
818 }
819
820 None
821 }
822
823 pub fn config(&self) -> &TerrainConfig {
825 &self.config
826 }
827
828 pub fn config_mut(&mut self) -> &mut TerrainConfig {
830 self.last_frame_key = None;
831 &mut self.config
832 }
833
834 pub fn visible_hillshade_rasters(&self) -> &[PreparedHillshadeRaster] {
836 &self.last_hillshade_rasters
837 }
838
839 #[inline]
841 pub fn source_max_zoom(&self) -> u8 {
842 self.config.source_max_zoom
843 }
844
845 #[inline]
847 pub fn elevation_source_tile_for(&self, tile: TileId) -> TileId {
848 clamp_tile_to_zoom(tile, self.config.source_max_zoom)
849 }
850
851 #[inline]
853 pub fn elevation_region_for(&self, tile: TileId) -> TileTextureRegion {
854 let source_tile = self.elevation_source_tile_for(tile);
855 TileTextureRegion::from_tiles(&tile, &source_tile)
856 }
857
858 fn evict_outside(&mut self, desired: &std::collections::HashSet<TileId>) {
859 while self.cache.len() > self.max_cache {
860 let stale = self
861 .cache
862 .keys()
863 .filter(|id| !desired.contains(id) && id.zoom != self.last_desired_zoom)
864 .min_by_key(|id| self.last_touched.get(id).copied().unwrap_or(0))
865 .copied();
866 if let Some(key) = stale {
867 self.cache.remove(&key);
868 self.last_touched.remove(&key);
869 self.tile_generations.remove(&key);
870 self.hillshade_cache.remove(&key);
871 self.backfill_states.remove(&key);
872 self.last_frame_key = None;
873 continue;
874 }
875 let expendable = self
876 .cache
877 .keys()
878 .filter(|id| !desired.contains(id))
879 .min_by_key(|id| self.last_touched.get(id).copied().unwrap_or(0))
880 .copied();
881 if let Some(key) = expendable {
882 self.cache.remove(&key);
883 self.last_touched.remove(&key);
884 self.tile_generations.remove(&key);
885 self.hillshade_cache.remove(&key);
886 self.backfill_states.remove(&key);
887 self.last_frame_key = None;
888 continue;
889 }
890 break;
891 }
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898 use crate::camera_projection::CameraProjection;
899 use crate::terrain::elevation_source::FlatElevationSource;
900 use rustial_math::{WebMercator, WorldCoord};
901
902 fn full_world_bounds() -> WorldBounds {
903 let extent = WebMercator::max_extent();
904 WorldBounds::new(
905 WorldCoord::new(-extent, -extent, 0.0),
906 WorldCoord::new(extent, extent, 0.0),
907 )
908 }
909
910 #[test]
911 fn disabled_returns_empty() {
912 let config = TerrainConfig::default();
913 let mut mgr = TerrainManager::new(config, 100);
914 let meshes = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
915 assert!(meshes.is_empty());
916 }
917
918 #[test]
919 fn enabled_with_flat_source() {
920 let config = TerrainConfig {
921 enabled: true,
922 mesh_resolution: 4,
923 source: Box::new(FlatElevationSource::new(4, 4)),
924 ..TerrainConfig::default()
925 };
926 let mut mgr = TerrainManager::new(config, 100);
927
928 let meshes = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
929 assert_eq!(meshes.len(), 1);
930 assert_eq!(meshes[0].tile, TileId::new(0, 0, 0));
931
932 let meshes = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
933 assert_eq!(meshes.len(), 1);
934 assert_eq!(meshes[0].tile, TileId::new(0, 0, 0));
935 }
936
937 #[test]
938 fn steady_state_reuses_cached_meshes() {
939 let config = TerrainConfig {
940 enabled: true,
941 mesh_resolution: 8,
942 source: Box::new(FlatElevationSource::new(8, 8)),
943 ..TerrainConfig::default()
944 };
945 let mut mgr = TerrainManager::new(config, 100);
946
947 mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
948 let first = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
949 let second = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
950
951 assert_eq!(first.len(), second.len());
952 assert_eq!(first[0].tile, second[0].tile);
953 assert_eq!(first[0].grid_resolution, second[0].grid_resolution);
954 assert_eq!(
955 first[0].elevation_texture.as_ref().map(|t| (t.width, t.height)),
956 second[0].elevation_texture.as_ref().map(|t| (t.width, t.height)),
957 );
958 assert!(first[0].positions.is_empty());
959 assert!(second[0].positions.is_empty());
960 }
961
962 #[test]
963 fn changing_projection_invalidates_cached_meshes() {
964 let config = TerrainConfig {
965 enabled: true,
966 mesh_resolution: 8,
967 source: Box::new(FlatElevationSource::new(8, 8)),
968 ..TerrainConfig::default()
969 };
970 let mut mgr = TerrainManager::new(config, 100);
971
972 mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
973 let merc = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
974 let eq = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::Equirectangular, 10_000_000.0, 0.0);
975
976 assert_eq!(merc.len(), eq.len());
977 assert_eq!(merc[0].tile, eq[0].tile);
978 assert_eq!(merc[0].grid_resolution, eq[0].grid_resolution);
979 assert!(merc[0].positions.is_empty());
980 assert!(eq[0].positions.is_empty());
981 }
982
983 #[test]
984 fn elevation_at_flat() {
985 let config = TerrainConfig {
986 enabled: true,
987 mesh_resolution: 4,
988 source: Box::new(FlatElevationSource::new(4, 4)),
989 ..TerrainConfig::default()
990 };
991 let mut mgr = TerrainManager::new(config, 100);
992 mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
993 mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
994
995 let elev = mgr.elevation_at(&GeoCoord::from_lat_lon(0.0, 0.0));
996 assert_eq!(elev, Some(0.0));
997 }
998
999 #[test]
1000 fn prepared_hillshade_is_emitted_for_visible_tiles() {
1001 let config = TerrainConfig {
1002 enabled: true,
1003 mesh_resolution: 4,
1004 source: Box::new(FlatElevationSource::new(4, 4)),
1005 ..TerrainConfig::default()
1006 };
1007 let mut mgr = TerrainManager::new(config, 100);
1008 mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
1009 mgr.update(&full_world_bounds(), 0, (0.0, 0.0), CameraProjection::WebMercator, 10_000_000.0, 0.0);
1010
1011 let rasters = mgr.visible_hillshade_rasters();
1012 assert_eq!(rasters.len(), 1);
1013 assert_eq!(rasters[0].tile, TileId::new(0, 0, 0));
1014 assert_eq!(rasters[0].image.width, 6);
1018 assert_eq!(rasters[0].image.height, 6);
1019 }
1020
1021 #[test]
1022 fn diagnostics_report_visible_and_cache_state() {
1023 let config = TerrainConfig {
1024 enabled: true,
1025 mesh_resolution: 4,
1026 vertical_exaggeration: 1.5,
1027 skirt_depth: 120.0,
1028 source: Box::new(FlatElevationSource::new(4, 4)),
1029 ..TerrainConfig::default()
1030 };
1031 let mut mgr = TerrainManager::new(config, 100);
1032
1033 mgr.update(
1035 &full_world_bounds(),
1036 0,
1037 (0.0, 0.0),
1038 CameraProjection::WebMercator,
1039 10_000_000.0,
1040 0.0,
1041 );
1042 let first = mgr.diagnostics();
1043 assert!(first.enabled);
1044 assert_eq!(first.visible_mesh_tiles, 1);
1045 assert_eq!(first.visible_pending_tiles, 1);
1046 assert_eq!(first.visible_loaded_tiles, 0);
1047 assert_eq!(first.cache_entries, 0);
1048 assert_eq!(first.pending_tiles, 1);
1049 assert_eq!(first.visible_hillshade_tiles, 1);
1050 assert_eq!(first.elevation_texture_tiles, 1);
1051 assert_eq!(first.mesh_resolution, 4);
1052 assert_eq!(first.vertical_exaggeration, 1.5);
1053 assert_eq!(first.skirt_depth_m, skirt_height(0, 1.5));
1054
1055 mgr.update(
1057 &full_world_bounds(),
1058 0,
1059 (0.0, 0.0),
1060 CameraProjection::WebMercator,
1061 10_000_000.0,
1062 0.0,
1063 );
1064 let second = mgr.diagnostics();
1065 assert_eq!(second.visible_mesh_tiles, 1);
1066 assert_eq!(second.visible_loaded_tiles, 1);
1067 assert_eq!(second.visible_pending_tiles, 0);
1068 assert_eq!(second.visible_placeholder_tiles, 0);
1069 assert_eq!(second.cache_entries, 1);
1070 assert_eq!(second.pending_tiles, 0);
1071 assert_eq!(second.visible_hillshade_tiles, 1);
1072 assert_eq!(second.visible_min_elevation_m, Some(0.0));
1073 assert_eq!(second.visible_max_elevation_m, Some(0.0));
1074 assert_eq!(second.last_desired_zoom, 0);
1075 assert_eq!(second.source_max_zoom, 15);
1076 }
1077
1078 #[test]
1079 fn overzoomed_child_mesh_uses_parent_dem_subregion() {
1080 let config = TerrainConfig {
1081 enabled: true,
1082 mesh_resolution: 4,
1083 source_max_zoom: 15,
1084 source: Box::new(FlatElevationSource::new(4, 4)),
1085 ..TerrainConfig::default()
1086 };
1087 let mut mgr = TerrainManager::new(config, 100);
1088 let child = TileId::new(16, 1000, 2000);
1089
1090 let _ = mgr.update_with_tiles(&[child], 16, CameraProjection::WebMercator);
1092 let meshes = mgr.update_with_tiles(&[child], 16, CameraProjection::WebMercator);
1094
1095 assert_eq!(meshes.len(), 1);
1096 let mesh = &meshes[0];
1097 assert_eq!(mesh.tile, child);
1098 assert_eq!(mesh.elevation_source_tile, TileId::new(15, 500, 1000));
1099 assert_eq!(mesh.elevation_region.u_min, 0.0);
1100 assert_eq!(mesh.elevation_region.v_min, 0.0);
1101 assert_eq!(mesh.elevation_region.u_max, 0.5);
1102 assert_eq!(mesh.elevation_region.v_max, 0.5);
1103 }
1104
1105 #[test]
1106 fn evict_outside_prefers_least_recently_used_non_retained_tile() {
1107 let config = TerrainConfig {
1108 enabled: true,
1109 source: Box::new(FlatElevationSource::new(4, 4)),
1110 ..TerrainConfig::default()
1111 };
1112 let mut mgr = TerrainManager::new(config, 2);
1113 let a = TileId::new(3, 0, 0);
1114 let b = TileId::new(3, 1, 0);
1115 let c = TileId::new(3, 2, 0);
1116
1117 mgr.cache.insert(a, ElevationGrid::flat(a, 4, 4));
1118 mgr.touch_tile(a);
1119 mgr.cache.insert(b, ElevationGrid::flat(b, 4, 4));
1120 mgr.touch_tile(b);
1121 mgr.cache.insert(c, ElevationGrid::flat(c, 4, 4));
1122 mgr.touch_tile(c);
1123
1124 let retain = std::collections::HashSet::from([c]);
1125 mgr.evict_outside(&retain);
1126
1127 assert!(!mgr.cache.contains_key(&a));
1128 assert!(mgr.cache.contains_key(&b));
1129 assert!(mgr.cache.contains_key(&c));
1130 assert!(!mgr.last_touched.contains_key(&a));
1131 }
1132}