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::{
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/// 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.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
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 { 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            // Horizon fill: add coarser tiles to cover distant ground
337            // beyond the base-zoom LOD set.  Only tiles that do NOT
338            // overlap (are not an ancestor of) any existing tile are
339            // added, preventing duplicate terrain geometry at different
340            // zoom levels.
341            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    /// Update terrain using an externally-provided tile set.
387    ///
388    /// This is used when the tile layer has already computed the desired
389    /// visible tile set (e.g. via covering-tiles traversal) and the
390    /// terrain manager should use the same tiles to ensure texture
391    /// availability for every terrain mesh entity.
392    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        // Poll completed elevation fetches.  New grids are immediately
408        // expanded with a clamped 1-sample border before insertion into
409        // the cache (see `expand_with_clamped_border`).
410        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                // Expand to (W+2)x(H+2) with clamped border -- one
416                // allocation per tile, at load time only.
417                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        // Incrementally patch borders of changed tiles and their
425        // neighbours.  Only the affected edge strips are overwritten
426        // in-place -- no full-grid copies.
427        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            // 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> =
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        // Prune stale queued DEM requests that are no longer relevant to the
464        // current desired source-tile set. In-flight requests remain tracked
465        // until completion, but unsent queued requests should not accumulate
466        // across aggressive fly-to transitions.
467        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        // Retain both desired display tiles and their source ancestors.
480        let mut retain_set = desired_set.clone();
481        retain_set.extend(source_tiles.iter().copied());
482        self.evict_outside(&retain_set);
483
484        // Request elevation at the source zoom (clamped), not the
485        // display zoom.  This ensures tiles beyond the source's max
486        // zoom reuse parent elevation data instead of generating
487        // failed/flat requests.
488        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        // The cache already holds border-expanded grids.  No per-frame
520        // allocation is needed -- just read directly from the cache.
521        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            // Use the cached (border-expanded) grid by reference, or fall
527            // back to a flat placeholder if not yet loaded.  Avoiding
528            // `.cloned()` here eliminates a ~260 KB deep copy per tile.
529            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    /// Lightweight source-only update: poll elevation fetches, determine
576    /// visible tiles, request missing elevation data, and return the desired
577    /// tile set with their cached elevation grids.
578    ///
579    /// This does **not** build terrain meshes or hillshade rasters.  It is
580    /// the first half of the split pipeline used by the async data path,
581    /// where mesh building is dispatched to background tasks.
582    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        // Poll completed elevation fetches -- expand on insertion.
595        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        // Cache already holds border-expanded grids -- read directly.
680        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    /// Lightweight source-only update using an externally-provided tile set.
695    ///
696    /// This mirrors [`update_with_tiles`](Self::update_with_tiles) for the
697    /// async terrain path: poll elevation fetches, request missing source tiles,
698    /// and return the desired tile set paired with cached (or flat fallback)
699    /// elevation grids and per-tile generations.
700    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        // 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
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    /// Query elevation at a geographic coordinate.
792    ///
793    /// Uses a targeted tile lookup: computes which tile contains the
794    /// coordinate at the most recent zoom level, then falls back to
795    /// parent zoom levels if the exact tile is not cached.  This is
796    /// O(zoom) instead of O(cache_size).
797    ///
798    /// Returns `None` if terrain is disabled or no cached tile covers
799    /// the coordinate.
800    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    /// Access the configuration.
824    pub fn config(&self) -> &TerrainConfig {
825        &self.config
826    }
827
828    /// Access the configuration mutably.
829    pub fn config_mut(&mut self) -> &mut TerrainConfig {
830        self.last_frame_key = None;
831        &mut self.config
832    }
833
834    /// Prepared hillshade rasters for the most recently visible terrain set.
835    pub fn visible_hillshade_rasters(&self) -> &[PreparedHillshadeRaster] {
836        &self.last_hillshade_rasters
837    }
838
839    /// The source max zoom for elevation data.
840    #[inline]
841    pub fn source_max_zoom(&self) -> u8 {
842        self.config.source_max_zoom
843    }
844
845    /// Return the DEM source tile used to back a visible terrain tile.
846    #[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    /// Return the DEM sub-region sampled by a visible terrain tile.
852    #[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        // After DEM backfilling the elevation grid is expanded by a
1015        // 1-sample border on each edge, so the hillshade raster
1016        // dimensions are (original + 2) x (original + 2).
1017        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        // First frame requests data and emits placeholder terrain.
1034        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        // Second frame receives the flat DEM and reports it as loaded.
1056        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        // First frame requests the clamped parent DEM tile.
1091        let _ = mgr.update_with_tiles(&[child], 16, CameraProjection::WebMercator);
1092        // Second frame receives it and builds the visible mesh.
1093        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}