1use crate::MapError;
14use crate::cache::{CacheStats, TileCache};
15use crate::model::{CrsIdentity, NodataPolicy, QueryConvention};
16use serde::Serialize;
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::{Arc, Mutex};
19use std::time::Instant;
20use terrio::{LazyRaster, Window, WindowOptions};
21
22pub struct NativeTerrain {
23 pub path: String,
24 pub gt: [f64; 6],
25 pub width: usize,
26 pub height: usize,
27 pub block: (usize, usize),
28 pub nodata: Option<f64>,
29 pub epsg: u32,
30 pub crs_identity: &'static str,
31 pub crs_wkt_fnv1a64: Option<String>,
32 pub query: QueryConvention,
33 pub policy: NodataPolicy,
34 block_bytes: u64,
36 readers: Mutex<Vec<LazyRaster>>,
37 readers_opened: AtomicU64,
38 decode_nanos: AtomicU64,
39 pub cache: TileCache,
40}
41
42#[derive(Serialize, Debug, Clone)]
43pub struct TerrainInfo {
44 pub path: String,
45 pub geo_transform: [f64; 6],
46 pub width: usize,
47 pub height: usize,
48 pub block: (usize, usize),
49 pub blocks_x: usize,
50 pub blocks_y: usize,
51 pub nodata: Option<f64>,
52 pub epsg: Option<u32>,
53 pub data_type: String,
54 pub band_count: usize,
55 pub crs: terrio::crs::SpatialRef,
56 pub crs_wkt_fnv1a64: Option<String>,
57}
58
59#[derive(Serialize, Debug, Clone)]
60pub struct TerrainStats {
61 pub cache: CacheStats,
63 pub readers_opened: u64,
64 pub decode_seconds: f64,
65 pub decoder_transient_bytes_per_load_estimate: u64,
68 pub decoder_transient_peak_estimate_bytes: u64,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq)]
73pub enum Hit {
74 Outside,
75 Pixel {
76 col: usize,
77 row: usize,
78 near_boundary: bool,
79 },
80}
81
82pub fn inspect(path: &str) -> Result<TerrainInfo, MapError> {
83 let r = open_lazy(path)?;
84 let block = r.block_size().unwrap_or((0, 0));
85 Ok(TerrainInfo {
86 path: path.to_string(),
87 geo_transform: r.geo_transform(),
88 width: r.width(),
89 height: r.height(),
90 block,
91 blocks_x: if block.0 > 0 {
92 r.width().div_ceil(block.0)
93 } else {
94 0
95 },
96 blocks_y: if block.1 > 0 {
97 r.height().div_ceil(block.1)
98 } else {
99 0
100 },
101 nodata: r.nodata(),
102 epsg: r.crs().epsg,
103 data_type: format!("{:?}", r.data_type()),
104 band_count: r.band_count(),
105 crs: r.crs(),
106 crs_wkt_fnv1a64: wkt_fnv(&r.crs()),
107 })
108}
109
110pub fn wkt_fnv(crs: &terrio::crs::SpatialRef) -> Option<String> {
112 crs.to_wkt()
113 .map(|w| format!("{:016x}", crate::sink::fnv1a64(w.as_bytes())))
114}
115
116fn open_lazy(path: &str) -> Result<LazyRaster, MapError> {
117 let r = LazyRaster::open_tiff(path)
118 .map_err(|e| MapError::Terrain(format!("{path}: {e}")))?
119 .ok_or_else(|| {
120 MapError::Unsupported(format!(
121 "{path}: not a TIFF (VRT/HDF/other terrain unsupported)"
122 ))
123 })?;
124 if !r.is_lazy() {
125 return Err(MapError::Unsupported(format!(
126 "{path}: terrio did not open a lazy GeoTIFF"
127 )));
128 }
129 Ok(r)
130}
131
132impl NativeTerrain {
133 pub fn open(
134 path: &str,
135 expected_epsg: u32,
136 identity: &CrsIdentity,
137 query: QueryConvention,
138 policy: NodataPolicy,
139 cache_budget: u64,
140 ) -> Result<Self, MapError> {
141 let r = open_lazy(path)?;
142 if r.band_count() != 1 {
143 return Err(MapError::Unsupported(format!(
144 "{path}: {} bands; single-band required",
145 r.band_count()
146 )));
147 }
148 if r.data_type() != terrio::DataType::F32 {
149 return Err(MapError::Unsupported(format!(
150 "{path}: data type {:?}; float32 required",
151 r.data_type()
152 )));
153 }
154 let gt = r.geo_transform();
155 if !gt.iter().all(|v| v.is_finite())
156 || gt[2] != 0.0
157 || gt[4] != 0.0
158 || gt[1] <= 0.0
159 || gt[5] >= 0.0
160 {
161 return Err(MapError::Unsupported(format!(
162 "{path}: geotransform {gt:?} is not north-up axis-aligned (rotated/south-up unsupported)"
163 )));
164 }
165 let crs = r.crs();
166 let crs_identity = match (crs.epsg, identity) {
167 (Some(e), _) if e != expected_epsg => {
168 return Err(MapError::Unsupported(format!(
169 "{path}: terrain EPSG:{e} differs from session EPSG:{expected_epsg}; no reprojection is performed"
170 )));
171 }
172 (Some(_), CrsIdentity::Epsg) => "terrio_identified_epsg",
173 (Some(_), CrsIdentity::CallerVerifiedWkt { fnv1a64 }) => {
175 check_wkt_pin(path, &crs, fnv1a64)?;
176 "terrio_identified_epsg_and_caller_verified_wkt_fnv1a64"
177 }
178 (None, CrsIdentity::Epsg) => {
179 return Err(MapError::Unsupported(format!(
180 "{path}: terrio does not identify an EPSG code for this terrain; use crs_identity caller_verified_wkt after independent verification"
181 )));
182 }
183 (None, CrsIdentity::CallerVerifiedWkt { fnv1a64 }) => {
184 check_wkt_pin(path, &crs, fnv1a64)?;
185 "caller_verified_wkt_fnv1a64"
186 }
187 };
188 let block = r
189 .block_size()
190 .filter(|b| b.0 > 0 && b.1 > 0)
191 .ok_or_else(|| MapError::Unsupported(format!("{path}: no block layout")))?;
192 let (width, height) = (r.width(), r.height());
193 if width == 0 || height == 0 || width > u32::MAX as usize || height > u32::MAX as usize {
194 return Err(MapError::Unsupported(format!(
195 "{path}: unsupported size {width}x{height}"
196 )));
197 }
198 let block_bytes = checked_block_bytes(block)
199 .filter(|b| *b <= MAX_BLOCK_BYTES)
200 .ok_or_else(|| {
201 MapError::Unsupported(format!(
202 "{path}: block {}x{} float32 exceeds {MAX_BLOCK_BYTES} bytes or overflows",
203 block.0, block.1
204 ))
205 })?;
206 let nodata = r.nodata();
207 Ok(Self {
208 path: path.to_string(),
209 gt,
210 width,
211 height,
212 block,
213 nodata,
214 epsg: expected_epsg,
215 crs_identity,
216 crs_wkt_fnv1a64: wkt_fnv(&crs),
217 query,
218 policy,
219 block_bytes,
220 readers: Mutex::new(vec![r]),
221 readers_opened: AtomicU64::new(1),
222 decode_nanos: AtomicU64::new(0),
223 cache: TileCache::new(cache_budget),
224 })
225 }
226
227 pub fn blocks(&self) -> (usize, usize) {
228 (
229 self.width.div_ceil(self.block.0),
230 self.height.div_ceil(self.block.1),
231 )
232 }
233
234 pub fn block_window(&self, bx: usize, by: usize) -> Window {
236 let (bw, bh) = self.block;
237 let x = bx * bw;
238 let y = by * bh;
239 let w = bw.min(self.width - x);
240 let h = bh.min(self.height - y);
241 debug_assert!(x / bw == (x + w - 1) / bw && y / bh == (y + h - 1) / bh);
242 Window::new(x, y, w, h)
243 }
244
245 pub fn max_block_bytes(&self) -> u64 {
246 self.block_bytes
247 }
248
249 pub fn decoder_transient_bytes_per_load(&self) -> u64 {
254 let (bx, by) = self.blocks();
255 let tables = (bx as u64).saturating_mul(by as u64).saturating_mul(16);
256 self.block_bytes.saturating_mul(4).saturating_add(tables)
257 }
258
259 pub fn get_block(&self, bx: usize, by: usize) -> Result<Arc<[f32]>, MapError> {
261 let w = self.block_window(bx, by);
262 let bytes = (w.width * w.height * 4) as u64;
264 self.cache
265 .get_or_load((bx as u32, by as u32), bytes, || self.decode(w))
266 }
267
268 fn decode(&self, w: Window) -> Result<Vec<f32>, MapError> {
269 let reader = self.readers.lock().unwrap_or_else(|p| p.into_inner()).pop();
270 let mut reader = match reader {
271 Some(r) => r,
272 None => {
273 self.readers_opened.fetch_add(1, Ordering::Relaxed);
274 open_lazy(&self.path)?
275 }
276 };
277 let start = Instant::now();
278 let options = WindowOptions {
279 bands: vec![1],
280 overview: None,
281 };
282 let ds = reader.read_window(w, &options).map_err(|e| {
283 MapError::Terrain(format!("{}: block read {:?}: {e}", self.path, (w.x, w.y)))
284 })?;
285 self.decode_nanos
286 .fetch_add(start.elapsed().as_nanos() as u64, Ordering::Relaxed);
287 self.readers
289 .lock()
290 .unwrap_or_else(|p| p.into_inner())
291 .push(reader);
292 let terrio::raster::RasterData::F32(values) = ds.raster.data else {
293 return Err(MapError::Unsupported(
294 "terrio returned non-float32 block".into(),
295 ));
296 };
297 if values.len() != w.width * w.height {
298 return Err(MapError::Terrain(format!(
299 "block has {} values, expected {}",
300 values.len(),
301 w.width * w.height
302 )));
303 }
304 Ok(values)
305 }
306
307 #[inline]
309 pub fn hit(&self, xs: f64, ys: f64) -> Hit {
310 let fc = (xs - self.gt[0]) / self.gt[1];
311 let fr = (ys - self.gt[3]) / self.gt[5];
312 if !(fc.is_finite() && fr.is_finite()) {
313 return Hit::Outside;
314 }
315 let (c, r) = (fc.floor(), fr.floor());
316 if c < 0.0 || r < 0.0 || c >= self.width as f64 || r >= self.height as f64 {
317 return Hit::Outside;
318 }
319 let tol = 1e-9;
320 let near = (fc - c) < tol || (c + 1.0 - fc) < tol || (fr - r) < tol || (r + 1.0 - fr) < tol;
321 Hit::Pixel {
322 col: c as usize,
323 row: r as usize,
324 near_boundary: near,
325 }
326 }
327
328 pub fn floor_index(&self, xs: f64, ys: f64) -> Option<(f64, f64)> {
330 let fc = ((xs - self.gt[0]) / self.gt[1]).floor();
331 let fr = ((ys - self.gt[3]) / self.gt[5]).floor();
332 (fc.is_finite() && fr.is_finite()).then_some((fc, fr))
333 }
334
335 #[inline]
339 pub fn classify(&self, v: f32) -> Option<f64> {
340 if v.is_nan() {
341 return None;
342 }
343 let sentinel = self.nodata.is_some_and(|nd| v == nd as f32);
344 match (sentinel, self.policy) {
345 (true, NodataPolicy::Dry) => None,
346 _ => Some(v as f64),
347 }
348 }
349
350 pub fn is_sentinel(&self, v: f32) -> bool {
351 self.nodata
352 .is_some_and(|nd| v == nd as f32 || (nd.is_nan() && v.is_nan()))
353 }
354
355 pub fn stats(&self) -> TerrainStats {
356 let cache = self.cache.stats();
357 let per = self.decoder_transient_bytes_per_load();
358 TerrainStats {
359 decoder_transient_peak_estimate_bytes: per.saturating_mul(cache.peak_concurrent_loads),
360 decoder_transient_bytes_per_load_estimate: per,
361 cache,
362 readers_opened: self.readers_opened.load(Ordering::Relaxed),
363 decode_seconds: self.decode_nanos.load(Ordering::Relaxed) as f64 * 1e-9,
364 }
365 }
366}
367
368pub const MAX_BLOCK_BYTES: u64 = 1 << 30;
370
371pub fn checked_block_bytes(block: (usize, usize)) -> Option<u64> {
373 (block.0 as u64)
374 .checked_mul(block.1 as u64)
375 .and_then(|v| v.checked_mul(4))
376}
377
378fn check_wkt_pin(path: &str, crs: &terrio::crs::SpatialRef, fnv1a64: &str) -> Result<(), MapError> {
379 let got =
380 wkt_fnv(crs).ok_or_else(|| MapError::Unsupported(format!("{path}: terrain has no CRS")))?;
381 if got.eq_ignore_ascii_case(fnv1a64) {
382 Ok(())
383 } else {
384 Err(MapError::Unsupported(format!(
385 "{path}: terrain WKT hash {got} differs from caller-verified {fnv1a64}"
386 )))
387 }
388}
389
390pub fn nodata_f32_exact(nodata: Option<f64>) -> Option<bool> {
393 nodata.map(|nd| nd.is_nan() || (nd as f32) as f64 == nd)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn absurd_block_sizes_are_detected_not_panics() {
402 assert_eq!(checked_block_bytes((256, 256)), Some(262_144));
403 assert_eq!(checked_block_bytes((usize::MAX, 2)), None);
404 assert_eq!(
405 checked_block_bytes((u32::MAX as usize, u32::MAX as usize)),
406 None
407 );
408 assert!(checked_block_bytes((65_536, 65_536)).unwrap() > MAX_BLOCK_BYTES);
409 }
410
411 #[test]
412 fn nodata_cast_exactness_is_reported() {
413 assert_eq!(nodata_f32_exact(None), None);
414 assert_eq!(nodata_f32_exact(Some(-9999.0)), Some(true));
415 assert_eq!(nodata_f32_exact(Some(f64::NAN)), Some(true));
416 assert_eq!(nodata_f32_exact(Some(f64::NEG_INFINITY)), Some(true));
417 assert_eq!(nodata_f32_exact(Some(-3.4028234663852886e38)), Some(true));
418 assert_eq!(nodata_f32_exact(Some(-3.40282e38)), Some(false));
419 }
420}