Skip to main content

rustial_engine/terrain/
terrain_manager.rs

1//! Terrain manager: fetches, caches, and generates terrain meshes.
2
3use 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/// Snapshot diagnostics for the terrain pipeline.
16#[derive(Debug, Clone, Default, PartialEq)]
17pub struct TerrainDiagnostics {
18    /// Whether terrain rendering is enabled.
19    pub enabled: bool,
20    /// Number of cached elevation tiles currently retained.
21    pub cache_entries: usize,
22    /// Number of elevation fetches currently pending.
23    pub pending_tiles: usize,
24    /// Number of terrain mesh descriptors visible in the most recent frame.
25    pub visible_mesh_tiles: usize,
26    /// Number of visible tiles whose elevation source data is cached.
27    pub visible_loaded_tiles: usize,
28    /// Number of visible tiles whose elevation source data is still pending.
29    pub visible_pending_tiles: usize,
30    /// Number of visible tiles currently using flat placeholder elevation.
31    pub visible_placeholder_tiles: usize,
32    /// Number of prepared hillshade rasters for the most recent visible terrain set.
33    pub visible_hillshade_tiles: usize,
34    /// Elevation source max zoom currently configured.
35    pub source_max_zoom: u8,
36    /// Most recent desired terrain zoom requested by the terrain manager.
37    pub last_desired_zoom: u8,
38    /// Terrain mesh resolution configured on the manager.
39    pub mesh_resolution: u16,
40    /// Terrain vertical exaggeration currently in effect.
41    pub vertical_exaggeration: f64,
42    /// Terrain skirt depth in meters.
43    pub skirt_depth_m: f64,
44    /// Minimum visible terrain elevation in rendered meters, if any visible mesh has elevation data.
45    pub visible_min_elevation_m: Option<f64>,
46    /// Maximum visible terrain elevation in rendered meters, if any visible mesh has elevation data.
47    pub visible_max_elevation_m: Option<f64>,
48    /// Number of visible terrain descriptors carrying an elevation texture payload.
49    pub elevation_texture_tiles: usize,
50    /// Total materialized terrain vertices currently present in the visible mesh set.
51    pub materialized_vertex_count: usize,
52    /// Total materialized terrain indices currently present in the visible mesh set.
53    pub materialized_index_count: usize,
54    /// Optional transport/failure diagnostics from the active elevation source.
55    pub source_diagnostics: Option<ElevationSourceDiagnostics>,
56}
57
58/// Clamp a tile ID to a maximum zoom level by walking up to the
59/// nearest ancestor at or below `max_zoom`.
60///
61/// If the tile is already at or below `max_zoom`, it is returned
62/// unchanged.
63fn 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
84/// Manages terrain elevation data and mesh generation.
85pub struct TerrainManager {
86    config: TerrainConfig,
87    /// Cached elevation grids keyed by tile ID.
88    ///
89    /// Grids are stored in **expanded** form: `(W+2) x (H+2)` with a
90    /// 1-sample border that is initially clamped to the nearest interior
91    /// sample and incrementally patched from neighbour data as it becomes
92    /// available (see [`BackfillState`]).
93    cache: HashMap<TileId, ElevationGrid>,
94    /// Tiles with pending fetch requests.
95    pending: std::collections::HashSet<TileId>,
96    /// Maximum cache entries.
97    max_cache: usize,
98    /// Monotonic access stamp used for cache eviction ordering.
99    access_clock: u64,
100    /// Last access stamp for each cached source tile.
101    last_touched: HashMap<TileId, u64>,
102    /// Last requested zoom level for elevation_at queries.
103    last_desired_zoom: u8,
104    /// Monotonic counter used to generate unique per-tile generations.
105    next_generation: u64,
106    /// Per-tile generation: tracks the generation of the elevation data
107    /// that was used to build each tile's mesh.  Updated only when a
108    /// specific tile's elevation data actually changes.
109    tile_generations: HashMap<TileId, u64>,
110    /// Per-tile backfill state tracking which neighbour borders have
111    /// been patched.  Prevents redundant copies when nothing changes.
112    backfill_states: HashMap<TileId, BackfillState>,
113    /// Cached prepared hillshade rasters keyed by tile id and generation.
114    hillshade_cache: HashMap<TileId, PreparedHillshadeRaster>,
115    /// Prepared hillshade rasters for the most recent visible terrain set.
116    last_hillshade_rasters: Vec<PreparedHillshadeRaster>,
117    /// Cached mesh set for the most recent visible terrain frame.
118    last_meshes: Vec<TerrainMeshData>,
119    /// Cache key for the most recent visible terrain frame.
120    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    /// Create a new terrain manager.
162    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    /// Whether terrain is enabled.
182    pub fn enabled(&self) -> bool {
183        self.config.enabled
184    }
185
186    /// Set terrain enabled/disabled.
187    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    /// Get the vertical exaggeration.
197    pub fn vertical_exaggeration(&self) -> f64 {
198        self.config.vertical_exaggeration
199    }
200
201    /// Set vertical exaggeration.
202    pub fn set_vertical_exaggeration(&mut self, exaggeration: f64) {
203        self.config.vertical_exaggeration = exaggeration;
204        self.last_frame_key = None;
205    }
206
207    /// Mesh resolution (vertices per tile edge) from the current configuration.
208    #[inline]
209    pub fn mesh_resolution(&self) -> u16 {
210        self.config.mesh_resolution
211    }
212
213    /// Number of tile elevation requests currently pending (sent but
214    /// not yet received).
215    ///
216    /// Used by the [`TileRequestCoordinator`](crate::TileRequestCoordinator)
217    /// to estimate terrain demand for global budget allocation.
218    #[inline]
219    pub fn pending_count(&self) -> usize {
220        self.pending.len()
221    }
222
223    /// Number of cached elevation tiles currently retained.
224    #[inline]
225    pub fn cache_entries(&self) -> usize {
226        self.cache.len()
227    }
228
229    /// Most recent desired terrain zoom requested by the manager.
230    #[inline]
231    pub fn last_desired_zoom(&self) -> u8 {
232        self.last_desired_zoom
233    }
234
235    /// Snapshot diagnostics for the current terrain state.
236    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                // GPU-displacement path: report theoretical grid size.
275                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    /// Update terrain data for the current frame.
297    ///
298    /// Returns terrain meshes for all visible tiles that have elevation data.
299    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            // Remove coarser tiles that overlap with finer tiles to
323            // prevent z-fighting from duplicate terrain geometry at
324            // different zoom levels.
325            {
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            // Horizon fill: add coarser tiles to cover distant ground
339            // beyond the base-zoom LOD set.  Only tiles that do NOT
340            // overlap (are not an ancestor of) any existing tile are
341            // added, preventing duplicate terrain geometry at different
342            // zoom levels.
343            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    /// Update terrain using an externally-provided tile set.
390    ///
391    /// This is used when the tile layer has already computed the desired
392    /// visible tile set (e.g. via covering-tiles traversal) and the
393    /// terrain manager should use the same tiles to ensure texture
394    /// availability for every terrain mesh entity.
395    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        // Poll completed elevation fetches.  New grids are immediately
411        // expanded with a clamped 1-sample border before insertion into
412        // the cache (see `expand_with_clamped_border`).
413        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                // Expand to (W+2)x(H+2) with clamped border -- one
419                // allocation per tile, at load time only.
420                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        // Incrementally patch borders of changed tiles and their
428        // neighbours.  Only the affected edge strips are overwritten
429        // in-place -- no full-grid copies.
430        if !changed_tiles.is_empty() {
431            let backfill_modified =
432                patch_changed_tiles(&mut self.cache, &mut self.backfill_states, &changed_tiles);
433
434            // Bump generation for tiles whose elevation data changed
435            // (either from new source data or from border patching).
436            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        // Build the set of *source* tiles we actually need to fetch
446        // (clamped to source_max_zoom) plus keep track of the desired
447        // tiles for eviction decisions.
448        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        // Prune stale queued DEM requests that are no longer relevant to the
463        // current desired source-tile set. In-flight requests remain tracked
464        // until completion, but unsent queued requests should not accumulate
465        // across aggressive fly-to transitions.
466        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        // Retain both desired display tiles and their source ancestors.
479        let mut retain_set = desired_set.clone();
480        retain_set.extend(source_tiles.iter().copied());
481        self.evict_outside(&retain_set);
482
483        // Request elevation at the source zoom (clamped), not the
484        // display zoom.  This ensures tiles beyond the source's max
485        // zoom reuse parent elevation data instead of generating
486        // failed/flat requests.
487        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        // The cache already holds border-expanded grids.  No per-frame
522        // allocation is needed -- just read directly from the cache.
523        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            // Use the cached (border-expanded) grid by reference, or fall
529            // back to a flat placeholder if not yet loaded.  Avoiding
530            // `.cloned()` here eliminates a ~260 KB deep copy per tile.
531            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    /// Lightweight source-only update: poll elevation fetches, determine
581    /// visible tiles, request missing elevation data, and return the desired
582    /// tile set with their cached elevation grids.
583    ///
584    /// This does **not** build terrain meshes or hillshade rasters.  It is
585    /// the first half of the split pipeline used by the async data path,
586    /// where mesh building is dispatched to background tasks.
587    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        // Poll completed elevation fetches -- expand on insertion.
600        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        // Cache already holds border-expanded grids -- read directly.
681        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    /// Lightweight source-only update using an externally-provided tile set.
698    ///
699    /// This mirrors [`update_with_tiles`](Self::update_with_tiles) for the
700    /// async terrain path: poll elevation fetches, request missing source tiles,
701    /// and return the desired tile set paired with cached (or flat fallback)
702    /// elevation grids and per-tile generations.
703    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        // Cache already holds border-expanded grids -- read directly.
776        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    /// Query elevation at a geographic coordinate.
794    ///
795    /// Uses a targeted tile lookup: computes which tile contains the
796    /// coordinate at the most recent zoom level, then falls back to
797    /// parent zoom levels if the exact tile is not cached.  This is
798    /// O(zoom) instead of O(cache_size).
799    ///
800    /// Returns `None` if terrain is disabled or no cached tile covers
801    /// the coordinate.
802    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    /// Access the configuration.
826    pub fn config(&self) -> &TerrainConfig {
827        &self.config
828    }
829
830    /// Access the configuration mutably.
831    pub fn config_mut(&mut self) -> &mut TerrainConfig {
832        self.last_frame_key = None;
833        &mut self.config
834    }
835
836    /// Prepared hillshade rasters for the most recently visible terrain set.
837    pub fn visible_hillshade_rasters(&self) -> &[PreparedHillshadeRaster] {
838        &self.last_hillshade_rasters
839    }
840
841    /// The source max zoom for elevation data.
842    #[inline]
843    pub fn source_max_zoom(&self) -> u8 {
844        self.config.source_max_zoom
845    }
846
847    /// Return the DEM source tile used to back a visible terrain tile.
848    #[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    /// Return the DEM sub-region sampled by a visible terrain tile.
854    #[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        // After DEM backfilling the elevation grid is expanded by a
1114        // 1-sample border on each edge, so the hillshade raster
1115        // dimensions are (original + 2) x (original + 2).
1116        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        // First frame requests data and emits placeholder terrain.
1133        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        // Second frame receives the flat DEM and reports it as loaded.
1155        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        // First frame requests the clamped parent DEM tile.
1190        let _ = mgr.update_with_tiles(&[child], 16, CameraProjection::WebMercator);
1191        // Second frame receives it and builds the visible mesh.
1192        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}