use crate::camera_projection::CameraProjection;
use crate::terrain::backfill::{expand_with_clamped_border, patch_changed_tiles, BackfillState};
use crate::terrain::config::TerrainConfig;
use crate::terrain::elevation_source::ElevationSourceDiagnostics;
use crate::terrain::hillshade::{prepare_hillshade_raster, PreparedHillshadeRaster};
use crate::terrain::mesh::{build_terrain_descriptor_with_source, skirt_height, TerrainMeshData};
use crate::tile_manager::TileTextureRegion;
use rustial_math::{
visible_tiles, visible_tiles_lod, ElevationGrid, GeoCoord, TileId, WorldBounds,
};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TerrainDiagnostics {
pub enabled: bool,
pub cache_entries: usize,
pub pending_tiles: usize,
pub visible_mesh_tiles: usize,
pub visible_loaded_tiles: usize,
pub visible_pending_tiles: usize,
pub visible_placeholder_tiles: usize,
pub visible_hillshade_tiles: usize,
pub source_max_zoom: u8,
pub last_desired_zoom: u8,
pub mesh_resolution: u16,
pub vertical_exaggeration: f64,
pub skirt_depth_m: f64,
pub visible_min_elevation_m: Option<f64>,
pub visible_max_elevation_m: Option<f64>,
pub elevation_texture_tiles: usize,
pub materialized_vertex_count: usize,
pub materialized_index_count: usize,
pub source_diagnostics: Option<ElevationSourceDiagnostics>,
}
fn clamp_tile_to_zoom(tile: TileId, max_zoom: u8) -> TileId {
if tile.zoom <= max_zoom {
return tile;
}
let dz = tile.zoom - max_zoom;
let scale = 1u32 << dz;
TileId::new(max_zoom, tile.x / scale, tile.y / scale)
}
fn terrain_base_tile_budget(required_tiles: usize) -> usize {
required_tiles.clamp(80, 256)
}
fn terrain_horizon_tile_budget(base_budget: usize, pitch: f64) -> usize {
if pitch <= 0.5 {
0
} else {
(base_budget / 3).clamp(24, 96)
}
}
pub struct TerrainManager {
config: TerrainConfig,
cache: HashMap<TileId, ElevationGrid>,
pending: std::collections::HashSet<TileId>,
max_cache: usize,
access_clock: u64,
last_touched: HashMap<TileId, u64>,
last_desired_zoom: u8,
next_generation: u64,
tile_generations: HashMap<TileId, u64>,
backfill_states: HashMap<TileId, BackfillState>,
hillshade_cache: HashMap<TileId, PreparedHillshadeRaster>,
last_hillshade_rasters: Vec<PreparedHillshadeRaster>,
last_meshes: Vec<TerrainMeshData>,
last_frame_key: Option<TerrainFrameKey>,
}
#[derive(Debug, Clone, PartialEq)]
struct TerrainFrameKey {
desired_tiles: Vec<TileId>,
tile_generations: Vec<u64>,
projection: CameraProjection,
resolution: u16,
vertical_exaggeration: f64,
effective_skirt: f64,
}
impl TerrainFrameKey {
fn new(
desired_tiles: &[TileId],
tile_generations: Vec<u64>,
projection: CameraProjection,
resolution: u16,
vertical_exaggeration: f64,
effective_skirt: f64,
) -> Self {
Self {
desired_tiles: desired_tiles.to_vec(),
tile_generations,
projection,
resolution,
vertical_exaggeration,
effective_skirt,
}
}
}
impl TerrainManager {
#[inline]
fn touch_tile(&mut self, tile: TileId) {
let stamp = self.access_clock;
self.access_clock = self.access_clock.saturating_add(1);
self.last_touched.insert(tile, stamp);
}
pub fn new(config: TerrainConfig, max_cache: usize) -> Self {
Self {
config,
cache: HashMap::new(),
pending: std::collections::HashSet::new(),
max_cache,
access_clock: 1,
last_touched: HashMap::new(),
last_desired_zoom: 0,
next_generation: 1,
tile_generations: HashMap::new(),
backfill_states: HashMap::new(),
hillshade_cache: HashMap::new(),
last_hillshade_rasters: Vec::new(),
last_meshes: Vec::new(),
last_frame_key: None,
}
}
pub fn enabled(&self) -> bool {
self.config.enabled
}
pub fn set_enabled(&mut self, enabled: bool) {
self.config.enabled = enabled;
if !enabled {
self.last_meshes.clear();
self.last_hillshade_rasters.clear();
self.last_frame_key = None;
}
}
pub fn vertical_exaggeration(&self) -> f64 {
self.config.vertical_exaggeration
}
pub fn set_vertical_exaggeration(&mut self, exaggeration: f64) {
self.config.vertical_exaggeration = exaggeration;
self.last_frame_key = None;
}
#[inline]
pub fn mesh_resolution(&self) -> u16 {
self.config.mesh_resolution
}
#[inline]
pub fn pending_count(&self) -> usize {
self.pending.len()
}
#[inline]
pub fn cache_entries(&self) -> usize {
self.cache.len()
}
#[inline]
pub fn last_desired_zoom(&self) -> u8 {
self.last_desired_zoom
}
pub fn diagnostics(&self) -> TerrainDiagnostics {
let mut diagnostics = TerrainDiagnostics {
enabled: self.config.enabled,
cache_entries: self.cache.len(),
pending_tiles: self.pending.len(),
visible_mesh_tiles: self.last_meshes.len(),
visible_hillshade_tiles: self.last_hillshade_rasters.len(),
source_max_zoom: self.config.source_max_zoom,
last_desired_zoom: self.last_desired_zoom,
mesh_resolution: self.config.mesh_resolution,
vertical_exaggeration: self.config.vertical_exaggeration,
skirt_depth_m: skirt_height(self.last_desired_zoom, self.config.vertical_exaggeration),
source_diagnostics: self.config.source.diagnostics(),
..TerrainDiagnostics::default()
};
let mut min_elev = f64::INFINITY;
let mut max_elev = f64::NEG_INFINITY;
for mesh in &self.last_meshes {
let source_tile = clamp_tile_to_zoom(mesh.tile, self.config.source_max_zoom);
if self.cache.contains_key(&source_tile) {
diagnostics.visible_loaded_tiles += 1;
} else if self.pending.contains(&source_tile) {
diagnostics.visible_pending_tiles += 1;
} else {
diagnostics.visible_placeholder_tiles += 1;
}
if let Some(elevation) = mesh.elevation_texture.as_ref() {
diagnostics.elevation_texture_tiles += 1;
let lo = elevation.min_elev as f64 * self.config.vertical_exaggeration;
let hi = elevation.max_elev as f64 * self.config.vertical_exaggeration;
min_elev = min_elev.min(lo);
max_elev = max_elev.max(hi);
}
if mesh.positions.is_empty() {
let res = mesh.grid_resolution as usize;
let grid_verts = res * res;
let skirt_verts = 4 * 2 * (res - 1);
diagnostics.materialized_vertex_count += grid_verts + skirt_verts;
let grid_idx = (res - 1) * (res - 1) * 6;
let skirt_idx = 4 * (res - 1) * 6;
diagnostics.materialized_index_count += grid_idx + skirt_idx;
} else {
diagnostics.materialized_vertex_count += mesh.positions.len();
diagnostics.materialized_index_count += mesh.indices.len();
}
}
if min_elev.is_finite() && max_elev.is_finite() {
diagnostics.visible_min_elevation_m = Some(min_elev);
diagnostics.visible_max_elevation_m = Some(max_elev);
}
diagnostics
}
pub fn update(
&mut self,
viewport_bounds: &WorldBounds,
zoom: u8,
camera_world: (f64, f64),
projection: CameraProjection,
camera_distance: f64,
camera_pitch: f64,
) -> Vec<TerrainMeshData> {
let desired = if camera_pitch > 0.3 {
let near_threshold = camera_distance * 1.5;
let mid_threshold = camera_distance * 4.0;
let max_tiles = 80;
let mut tiles = visible_tiles_lod(
viewport_bounds,
zoom,
camera_world,
near_threshold,
mid_threshold,
max_tiles,
);
{
let snapshot: Vec<TileId> = tiles.clone();
tiles.retain(|t| {
!snapshot.iter().any(|other| {
if other.zoom <= t.zoom {
return false;
}
let dz = other.zoom - t.zoom;
(other.x >> dz) == t.x && (other.y >> dz) == t.y
})
});
}
if camera_pitch > 0.5 && zoom > 2 {
use std::collections::HashSet;
let seen: HashSet<TileId> = tiles.iter().copied().collect();
let base_tiles: Vec<TileId> = tiles.clone();
let is_ancestor_of_existing = |candidate: &TileId| -> bool {
base_tiles.iter().any(|t| {
if t.zoom <= candidate.zoom {
return false;
}
let dz = t.zoom - candidate.zoom;
(t.x >> dz) == candidate.x && (t.y >> dz) == candidate.y
})
};
let mut budget = terrain_horizon_tile_budget(max_tiles, camera_pitch);
let mut hz = zoom.saturating_sub(2);
while hz > 0 && budget > 0 {
let coarse = visible_tiles(viewport_bounds, hz);
let mut extras: Vec<_> = coarse
.into_iter()
.filter(|t| !seen.contains(t) && !is_ancestor_of_existing(t))
.map(|t| {
let b = rustial_math::tile_bounds_world(&t);
let cx = (b.min.position.x + b.max.position.x) * 0.5;
let cy = (b.min.position.y + b.max.position.y) * 0.5;
let dx = cx - camera_world.0;
let dy = cy - camera_world.1;
(t, dx * dx + dy * dy)
})
.collect();
extras
.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let take = extras.len().min(budget);
tiles.extend(extras.into_iter().take(take).map(|(t, _)| t));
budget = budget.saturating_sub(take);
hz = hz.saturating_sub(2);
}
}
tiles
} else {
visible_tiles(viewport_bounds, zoom)
};
self.update_with_tiles(&desired, zoom, projection)
}
pub fn update_with_tiles(
&mut self,
desired: &[TileId],
zoom: u8,
projection: CameraProjection,
) -> Vec<TerrainMeshData> {
if !self.config.enabled {
self.last_meshes.clear();
self.last_hillshade_rasters.clear();
self.last_frame_key = None;
return Vec::new();
}
let source_max_zoom = self.config.source_max_zoom;
let completed = self.config.source.poll();
let mut changed_tiles = std::collections::HashSet::new();
for (id, result) in completed {
self.pending.remove(&id);
if let Ok(grid) = result {
let expanded = expand_with_clamped_border(&grid);
self.cache.insert(id, expanded);
self.touch_tile(id);
changed_tiles.insert(id);
}
}
if !changed_tiles.is_empty() {
let backfill_modified =
patch_changed_tiles(&mut self.cache, &mut self.backfill_states, &changed_tiles);
let gen = self.next_generation;
self.next_generation += 1;
for tile_id in changed_tiles.iter().chain(backfill_modified.iter()) {
self.tile_generations.insert(*tile_id, gen);
}
}
self.last_desired_zoom = zoom;
let desired_set: std::collections::HashSet<TileId> = desired.iter().copied().collect();
let source_tiles: std::collections::HashSet<TileId> = desired
.iter()
.map(|t| clamp_tile_to_zoom(*t, source_max_zoom))
.collect();
let hot_cached_tiles: Vec<_> = source_tiles
.iter()
.filter(|tile| self.cache.contains_key(tile))
.copied()
.collect();
for tile in hot_cached_tiles {
self.touch_tile(tile);
}
let stale_pending: Vec<_> = self
.pending
.iter()
.copied()
.filter(|tile| !source_tiles.contains(tile))
.collect();
for tile in stale_pending {
if self.config.source.cancel(tile) {
self.pending.remove(&tile);
}
}
let mut retain_set = desired_set.clone();
retain_set.extend(source_tiles.iter().copied());
self.evict_outside(&retain_set);
for source_tile in &source_tiles {
if !self.cache.contains_key(source_tile) && !self.pending.contains(source_tile) {
self.config.source.request(*source_tile);
self.pending.insert(*source_tile);
}
}
let resolution = self.config.mesh_resolution;
let effective_skirt = skirt_height(zoom, self.config.vertical_exaggeration);
let tile_generations: Vec<u64> = desired
.iter()
.map(|tile| {
let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
self.tile_generations
.get(&source_tile)
.copied()
.unwrap_or(0)
})
.collect();
let frame_key = TerrainFrameKey::new(
desired,
tile_generations,
projection,
resolution,
self.config.vertical_exaggeration,
effective_skirt,
);
if self.last_frame_key.as_ref() == Some(&frame_key) {
return self.last_meshes.clone();
}
let mut meshes = Vec::with_capacity(desired.len());
let mut hillshade_rasters = Vec::with_capacity(desired.len());
for tile in desired {
let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
let fallback;
let elevation = match self.cache.get(&source_tile) {
Some(cached) => cached,
None => {
fallback = ElevationGrid::flat(*tile, resolution as u32, resolution as u32);
&fallback
}
};
let tile_gen = self
.tile_generations
.get(&source_tile)
.copied()
.unwrap_or(0);
let elevation_region = TileTextureRegion::from_tiles(tile, &source_tile);
let mesh = build_terrain_descriptor_with_source(
tile,
source_tile,
elevation_region,
elevation,
resolution,
self.config.vertical_exaggeration,
tile_gen,
);
meshes.push(mesh);
let raster = match self.hillshade_cache.get(tile) {
Some(cached) if cached.generation == tile_gen => cached.clone(),
_ => {
let prepared = prepare_hillshade_raster(
elevation,
self.config.vertical_exaggeration,
tile_gen,
);
self.hillshade_cache.insert(*tile, prepared.clone());
prepared
}
};
hillshade_rasters.push(raster);
}
self.last_hillshade_rasters = hillshade_rasters;
self.last_meshes = meshes.clone();
self.last_frame_key = Some(frame_key);
meshes
}
pub fn update_sources(
&mut self,
viewport_bounds: &WorldBounds,
zoom: u8,
camera_world: (f64, f64),
camera_distance: f64,
camera_pitch: f64,
) -> Vec<(TileId, ElevationGrid, u64)> {
if !self.config.enabled {
return Vec::new();
}
let completed = self.config.source.poll();
let mut changed_tiles = std::collections::HashSet::new();
for (id, result) in completed {
self.pending.remove(&id);
if let Ok(grid) = result {
let expanded = expand_with_clamped_border(&grid);
self.cache.insert(id, expanded);
self.touch_tile(id);
changed_tiles.insert(id);
}
}
if !changed_tiles.is_empty() {
let backfill_modified =
patch_changed_tiles(&mut self.cache, &mut self.backfill_states, &changed_tiles);
let gen = self.next_generation;
self.next_generation += 1;
for tile_id in changed_tiles.iter().chain(backfill_modified.iter()) {
self.tile_generations.insert(*tile_id, gen);
}
}
let desired = if camera_pitch > 0.3 {
let near_threshold = camera_distance * 1.5;
let mid_threshold = camera_distance * 4.0;
let strict_tiles = visible_tiles(viewport_bounds, zoom);
let max_tiles = terrain_base_tile_budget(strict_tiles.len());
visible_tiles_lod(
viewport_bounds,
zoom,
camera_world,
near_threshold,
mid_threshold,
max_tiles,
)
} else {
visible_tiles(viewport_bounds, zoom)
};
let source_max_zoom = self.config.source_max_zoom;
self.last_desired_zoom = zoom;
let desired_set: std::collections::HashSet<TileId> = desired.iter().copied().collect();
let source_tiles: std::collections::HashSet<TileId> = desired
.iter()
.map(|t| clamp_tile_to_zoom(*t, source_max_zoom))
.collect();
let hot_cached_tiles: Vec<_> = source_tiles
.iter()
.filter(|tile| self.cache.contains_key(tile))
.copied()
.collect();
for tile in hot_cached_tiles {
self.touch_tile(tile);
}
let stale_pending: Vec<_> = self
.pending
.iter()
.copied()
.filter(|tile| !source_tiles.contains(tile))
.collect();
for tile in stale_pending {
if self.config.source.cancel(tile) {
self.pending.remove(&tile);
}
}
let mut retain_set = desired_set.clone();
retain_set.extend(source_tiles.iter().copied());
self.evict_outside(&retain_set);
for source_tile in &source_tiles {
if !self.cache.contains_key(source_tile) && !self.pending.contains(source_tile) {
self.config.source.request(*source_tile);
self.pending.insert(*source_tile);
}
}
let resolution = self.config.mesh_resolution;
let mut result = Vec::with_capacity(desired.len());
for tile in &desired {
let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
let elevation = self.cache.get(&source_tile).cloned().unwrap_or_else(|| {
ElevationGrid::flat(*tile, resolution as u32, resolution as u32)
});
let tile_gen = self
.tile_generations
.get(&source_tile)
.copied()
.unwrap_or(0);
result.push((*tile, elevation, tile_gen));
}
result
}
pub fn update_sources_with_tiles(
&mut self,
desired: &[TileId],
zoom: u8,
) -> Vec<(TileId, ElevationGrid, u64)> {
if !self.config.enabled {
return Vec::new();
}
let completed = self.config.source.poll();
let mut changed_tiles = std::collections::HashSet::new();
for (id, result) in completed {
self.pending.remove(&id);
if let Ok(grid) = result {
let expanded = expand_with_clamped_border(&grid);
self.cache.insert(id, expanded);
self.touch_tile(id);
changed_tiles.insert(id);
}
}
if !changed_tiles.is_empty() {
let backfill_modified =
patch_changed_tiles(&mut self.cache, &mut self.backfill_states, &changed_tiles);
let generation = self.next_generation;
self.next_generation += 1;
for tile_id in changed_tiles.iter().chain(backfill_modified.iter()) {
self.tile_generations.insert(*tile_id, generation);
}
}
self.last_desired_zoom = zoom;
let source_max_zoom = self.config.source_max_zoom;
let desired_set: std::collections::HashSet<TileId> = desired.iter().copied().collect();
let source_tiles: std::collections::HashSet<TileId> = desired
.iter()
.map(|tile| clamp_tile_to_zoom(*tile, source_max_zoom))
.collect();
let hot_cached_tiles: Vec<_> = source_tiles
.iter()
.filter(|tile| self.cache.contains_key(tile))
.copied()
.collect();
for tile in hot_cached_tiles {
self.touch_tile(tile);
}
let stale_pending: Vec<_> = self
.pending
.iter()
.copied()
.filter(|tile| !source_tiles.contains(tile))
.collect();
for tile in stale_pending {
if self.config.source.cancel(tile) {
self.pending.remove(&tile);
}
}
let mut retain_set = desired_set.clone();
retain_set.extend(source_tiles.iter().copied());
self.evict_outside(&retain_set);
for source_tile in &source_tiles {
if !self.cache.contains_key(source_tile) && !self.pending.contains(source_tile) {
self.config.source.request(*source_tile);
self.pending.insert(*source_tile);
}
}
let resolution = self.config.mesh_resolution;
let mut result = Vec::with_capacity(desired.len());
for tile in desired {
let source_tile = clamp_tile_to_zoom(*tile, source_max_zoom);
let elevation = self.cache.get(&source_tile).cloned().unwrap_or_else(|| {
ElevationGrid::flat(*tile, resolution as u32, resolution as u32)
});
let tile_gen = self
.tile_generations
.get(&source_tile)
.copied()
.unwrap_or(0);
result.push((*tile, elevation, tile_gen));
}
result
}
pub fn elevation_at(&self, coord: &GeoCoord) -> Option<f64> {
if !self.config.enabled {
return None;
}
let max_z = self.last_desired_zoom.min(self.config.source_max_zoom);
let mut z = max_z;
loop {
let tile = rustial_math::geo_to_tile(coord, z).tile_id();
if let Some(grid) = self.cache.get(&tile) {
if let Some(elev) = grid.sample_geo(coord) {
return Some(elev as f64 * self.config.vertical_exaggeration);
}
}
if z == 0 {
break;
}
z -= 1;
}
None
}
pub fn config(&self) -> &TerrainConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut TerrainConfig {
self.last_frame_key = None;
&mut self.config
}
pub fn visible_hillshade_rasters(&self) -> &[PreparedHillshadeRaster] {
&self.last_hillshade_rasters
}
#[inline]
pub fn source_max_zoom(&self) -> u8 {
self.config.source_max_zoom
}
#[inline]
pub fn elevation_source_tile_for(&self, tile: TileId) -> TileId {
clamp_tile_to_zoom(tile, self.config.source_max_zoom)
}
#[inline]
pub fn elevation_region_for(&self, tile: TileId) -> TileTextureRegion {
let source_tile = self.elevation_source_tile_for(tile);
TileTextureRegion::from_tiles(&tile, &source_tile)
}
fn evict_outside(&mut self, desired: &std::collections::HashSet<TileId>) {
while self.cache.len() > self.max_cache {
let stale = self
.cache
.keys()
.filter(|id| !desired.contains(id) && id.zoom != self.last_desired_zoom)
.min_by_key(|id| self.last_touched.get(id).copied().unwrap_or(0))
.copied();
if let Some(key) = stale {
self.cache.remove(&key);
self.last_touched.remove(&key);
self.tile_generations.remove(&key);
self.hillshade_cache.remove(&key);
self.backfill_states.remove(&key);
self.last_frame_key = None;
continue;
}
let expendable = self
.cache
.keys()
.filter(|id| !desired.contains(id))
.min_by_key(|id| self.last_touched.get(id).copied().unwrap_or(0))
.copied();
if let Some(key) = expendable {
self.cache.remove(&key);
self.last_touched.remove(&key);
self.tile_generations.remove(&key);
self.hillshade_cache.remove(&key);
self.backfill_states.remove(&key);
self.last_frame_key = None;
continue;
}
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::camera_projection::CameraProjection;
use crate::terrain::elevation_source::FlatElevationSource;
use rustial_math::{WebMercator, WorldCoord};
fn full_world_bounds() -> WorldBounds {
let extent = WebMercator::max_extent();
WorldBounds::new(
WorldCoord::new(-extent, -extent, 0.0),
WorldCoord::new(extent, extent, 0.0),
)
}
#[test]
fn disabled_returns_empty() {
let config = TerrainConfig::default();
let mut mgr = TerrainManager::new(config, 100);
let meshes = mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
assert!(meshes.is_empty());
}
#[test]
fn enabled_with_flat_source() {
let config = TerrainConfig {
enabled: true,
mesh_resolution: 4,
source: Box::new(FlatElevationSource::new(4, 4)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 100);
let meshes = mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
assert_eq!(meshes.len(), 1);
assert_eq!(meshes[0].tile, TileId::new(0, 0, 0));
let meshes = mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
assert_eq!(meshes.len(), 1);
assert_eq!(meshes[0].tile, TileId::new(0, 0, 0));
}
#[test]
fn steady_state_reuses_cached_meshes() {
let config = TerrainConfig {
enabled: true,
mesh_resolution: 8,
source: Box::new(FlatElevationSource::new(8, 8)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 100);
mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
let first = mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
let second = mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
assert_eq!(first.len(), second.len());
assert_eq!(first[0].tile, second[0].tile);
assert_eq!(first[0].grid_resolution, second[0].grid_resolution);
assert_eq!(
first[0]
.elevation_texture
.as_ref()
.map(|t| (t.width, t.height)),
second[0]
.elevation_texture
.as_ref()
.map(|t| (t.width, t.height)),
);
assert!(first[0].positions.is_empty());
assert!(second[0].positions.is_empty());
}
#[test]
fn changing_projection_invalidates_cached_meshes() {
let config = TerrainConfig {
enabled: true,
mesh_resolution: 8,
source: Box::new(FlatElevationSource::new(8, 8)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 100);
mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
let merc = mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
let eq = mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::Equirectangular,
10_000_000.0,
0.0,
);
assert_eq!(merc.len(), eq.len());
assert_eq!(merc[0].tile, eq[0].tile);
assert_eq!(merc[0].grid_resolution, eq[0].grid_resolution);
assert!(merc[0].positions.is_empty());
assert!(eq[0].positions.is_empty());
}
#[test]
fn elevation_at_flat() {
let config = TerrainConfig {
enabled: true,
mesh_resolution: 4,
source: Box::new(FlatElevationSource::new(4, 4)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 100);
mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
let elev = mgr.elevation_at(&GeoCoord::from_lat_lon(0.0, 0.0));
assert_eq!(elev, Some(0.0));
}
#[test]
fn prepared_hillshade_is_emitted_for_visible_tiles() {
let config = TerrainConfig {
enabled: true,
mesh_resolution: 4,
source: Box::new(FlatElevationSource::new(4, 4)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 100);
mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
let rasters = mgr.visible_hillshade_rasters();
assert_eq!(rasters.len(), 1);
assert_eq!(rasters[0].tile, TileId::new(0, 0, 0));
assert_eq!(rasters[0].image.width, 6);
assert_eq!(rasters[0].image.height, 6);
}
#[test]
fn diagnostics_report_visible_and_cache_state() {
let config = TerrainConfig {
enabled: true,
mesh_resolution: 4,
vertical_exaggeration: 1.5,
skirt_depth: 120.0,
source: Box::new(FlatElevationSource::new(4, 4)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 100);
mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
let first = mgr.diagnostics();
assert!(first.enabled);
assert_eq!(first.visible_mesh_tiles, 1);
assert_eq!(first.visible_pending_tiles, 1);
assert_eq!(first.visible_loaded_tiles, 0);
assert_eq!(first.cache_entries, 0);
assert_eq!(first.pending_tiles, 1);
assert_eq!(first.visible_hillshade_tiles, 1);
assert_eq!(first.elevation_texture_tiles, 1);
assert_eq!(first.mesh_resolution, 4);
assert_eq!(first.vertical_exaggeration, 1.5);
assert_eq!(first.skirt_depth_m, skirt_height(0, 1.5));
mgr.update(
&full_world_bounds(),
0,
(0.0, 0.0),
CameraProjection::WebMercator,
10_000_000.0,
0.0,
);
let second = mgr.diagnostics();
assert_eq!(second.visible_mesh_tiles, 1);
assert_eq!(second.visible_loaded_tiles, 1);
assert_eq!(second.visible_pending_tiles, 0);
assert_eq!(second.visible_placeholder_tiles, 0);
assert_eq!(second.cache_entries, 1);
assert_eq!(second.pending_tiles, 0);
assert_eq!(second.visible_hillshade_tiles, 1);
assert_eq!(second.visible_min_elevation_m, Some(0.0));
assert_eq!(second.visible_max_elevation_m, Some(0.0));
assert_eq!(second.last_desired_zoom, 0);
assert_eq!(second.source_max_zoom, 15);
}
#[test]
fn overzoomed_child_mesh_uses_parent_dem_subregion() {
let config = TerrainConfig {
enabled: true,
mesh_resolution: 4,
source_max_zoom: 15,
source: Box::new(FlatElevationSource::new(4, 4)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 100);
let child = TileId::new(16, 1000, 2000);
let _ = mgr.update_with_tiles(&[child], 16, CameraProjection::WebMercator);
let meshes = mgr.update_with_tiles(&[child], 16, CameraProjection::WebMercator);
assert_eq!(meshes.len(), 1);
let mesh = &meshes[0];
assert_eq!(mesh.tile, child);
assert_eq!(mesh.elevation_source_tile, TileId::new(15, 500, 1000));
assert_eq!(mesh.elevation_region.u_min, 0.0);
assert_eq!(mesh.elevation_region.v_min, 0.0);
assert_eq!(mesh.elevation_region.u_max, 0.5);
assert_eq!(mesh.elevation_region.v_max, 0.5);
}
#[test]
fn evict_outside_prefers_least_recently_used_non_retained_tile() {
let config = TerrainConfig {
enabled: true,
source: Box::new(FlatElevationSource::new(4, 4)),
..TerrainConfig::default()
};
let mut mgr = TerrainManager::new(config, 2);
let a = TileId::new(3, 0, 0);
let b = TileId::new(3, 1, 0);
let c = TileId::new(3, 2, 0);
mgr.cache.insert(a, ElevationGrid::flat(a, 4, 4));
mgr.touch_tile(a);
mgr.cache.insert(b, ElevationGrid::flat(b, 4, 4));
mgr.touch_tile(b);
mgr.cache.insert(c, ElevationGrid::flat(c, 4, 4));
mgr.touch_tile(c);
let retain = std::collections::HashSet::from([c]);
mgr.evict_outside(&retain);
assert!(!mgr.cache.contains_key(&a));
assert!(mgr.cache.contains_key(&b));
assert!(mgr.cache.contains_key(&c));
assert!(!mgr.last_touched.contains_key(&a));
}
}