Skip to main content

ridge_core/
srtm.rs

1//! SRTM elevation tiles: parsing, downloading, caching.
2//!
3//! Replicates the pieces of `SRTM.py` that upstream ridge_map relies on:
4//!
5//! * `.hgt` files are big-endian int16 grids, north-up, with `side` = 3601
6//!   (SRTM 1-arc-second) or 1201 (SRTM 3-arc-second).
7//! * Tiles are named `{N|S}{lat:02}{E|W}{lon:03}.hgt`, e.g. `N44W072.hgt`,
8//!   and cover exactly one degree. `row = floor((lat_lo + 1 - lat) * (side-1))`,
9//!   `col = floor((lon - lon_lo) * (side-1))`.
10//! * Void / invalid samples (outside `[-1000, 10000]`) become NaN.
11//! * Downloads come from a directory mirror (`srtm.kurviger.de` by default)
12//!   as `.hgt.zip`, split across region subdirectories; the region index is
13//!   scraped once and cached. Downloaded tiles are cached on disk unzipped.
14
15use std::collections::HashMap;
16use std::io::Read;
17use std::path::PathBuf;
18use std::sync::{Arc, Mutex, RwLock};
19
20use crate::{Error, Result};
21
22/// Memoized tile map shared by the cached sources.
23type TileMap = HashMap<(i32, i32), Option<Arc<Tile>>>;
24
25/// A parsed single-degree SRTM tile.
26#[derive(Debug, Clone)]
27pub struct Tile {
28    /// Lower-left latitude of the tile, e.g. `44.0` for N44.
29    pub lat_lo: f64,
30    /// Lower-left longitude, e.g. `-72.0` for W072.
31    pub lon_lo: f64,
32    /// Grid side length (3601 or 1201).
33    pub side: usize,
34    /// Row-major, north-up elevation samples in meters.
35    pub data: Vec<i16>,
36}
37
38impl Tile {
39    /// Parse raw `.hgt` contents. `file_name` supplies the tile origin, e.g.
40    /// `N44W072.hgt`.
41    pub fn parse(file_name: &str, data: &[u8]) -> Result<Tile> {
42        let (lat_lo, lon_lo) = parse_tile_name(file_name)
43            .ok_or_else(|| Error::Srtm(format!("bad tile name {file_name:?}")))?;
44        if !data.len().is_multiple_of(2) {
45            return Err(Error::Srtm(format!("tile {file_name:?} has odd length")));
46        }
47        let n = data.len() / 2;
48        let side = (n as f64).sqrt();
49        if side.fract() != 0.0 {
50            return Err(Error::Srtm(format!(
51                "tile {file_name:?} has non-square size {n}"
52            )));
53        }
54        let side = side as usize;
55        let values: Vec<i16> = data
56            .as_chunks::<2>()
57            .0
58            .iter()
59            .map(|&pair| i16::from_be_bytes(pair))
60            .collect();
61        Ok(Tile {
62            lat_lo,
63            lon_lo,
64            side,
65            data: values,
66        })
67    }
68
69    pub fn resolution(&self) -> f64 {
70        1.0 / (self.side - 1) as f64
71    }
72
73    /// Nearest-neighbor elevation at `(lat, lon)` in meters, or NaN for
74    /// voids / out-of-tile points (mirrors `SRTM.py`'s `get_elevation`).
75    pub fn elevation(&self, lat: f64, lon: f64) -> f64 {
76        let s = (self.side - 1) as f64;
77        let row = ((self.lat_lo + 1.0 - lat) * s).floor();
78        let col = ((lon - self.lon_lo) * s).floor();
79        if row < 0.0 || col < 0.0 || row as usize >= self.side || col as usize >= self.side {
80            return f64::NAN;
81        }
82        let v = self.data[row as usize * self.side + col as usize] as f64;
83        if !(-1000.0..=10000.0).contains(&v) {
84            f64::NAN
85        } else {
86            v
87        }
88    }
89}
90
91/// `N44W072.hgt` -> `(44.0, -72.0)`.
92pub fn parse_tile_name(name: &str) -> Option<(f64, f64)> {
93    let base = name.rsplit('/').next()?;
94    let stem = base.strip_suffix(".hgt")?;
95    let bytes = stem.as_bytes();
96    if bytes.len() != 7 {
97        return None;
98    }
99    let ns = bytes[0];
100    let lat: i32 = stem[1..3].parse().ok()?;
101    let ew = bytes[3];
102    let lon: i32 = stem[4..7].parse().ok()?;
103    let lat_lo = match ns {
104        b'N' => lat as f64,
105        b'S' => -(lat as f64),
106        _ => return None,
107    };
108    let lon_lo = match ew {
109        b'E' => lon as f64,
110        b'W' => -(lon as f64),
111        _ => return None,
112    };
113    Some((lat_lo, lon_lo))
114}
115
116/// `lat=44.3, lon=-71.9` -> `"N44W072.hgt"` (mirrors `SRTM.py` `get_file_name`).
117pub fn tile_name(lat: f64, lon: f64) -> String {
118    let (ns, lat) = if lat >= 0.0 { ('N', lat) } else { ('S', -lat) };
119    let (ew, lon) = if lon >= 0.0 { ('E', lon) } else { ('W', -lon) };
120    // srtm.py: str(int(abs(floor(x)))).zfill(...)
121    format!(
122        "{ns}{:02}{ew}{:03}.hgt",
123        lat.floor() as i32,
124        lon.floor() as i32
125    )
126}
127
128/// Where tiles come from.
129pub trait TileSource: Send + Sync {
130    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>>;
131}
132
133/// Tiles read from a local directory (tests, offline demos, pre-seeded caches).
134/// Parsed tiles are memoized, so repeated lookups don't re-read the file.
135pub struct DirSource {
136    dir: PathBuf,
137    tiles: Mutex<TileMap>,
138}
139
140impl DirSource {
141    pub fn new(dir: impl Into<PathBuf>) -> Self {
142        Self {
143            dir: dir.into(),
144            tiles: Mutex::new(HashMap::new()),
145        }
146    }
147}
148
149impl TileSource for DirSource {
150    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>> {
151        if let Some(hit) = self.tiles.lock().unwrap().get(&(lat_lo, lon_lo)) {
152            return hit.clone();
153        }
154        let name = tile_name(lat_lo as f64, lon_lo as f64);
155        let loaded = std::fs::read(self.dir.join(&name))
156            .ok()
157            .and_then(|bytes| Tile::parse(&name, &bytes).ok())
158            .map(Arc::new);
159        self.tiles
160            .lock()
161            .unwrap()
162            .insert((lat_lo, lon_lo), loaded.clone());
163        loaded
164    }
165}
166
167/// In-memory source for unit tests: synthesizes tiles on demand.
168pub struct SyntheticSource {
169    pub side: usize,
170}
171
172impl SyntheticSource {
173    /// A smooth valley-and-ridge terrain, in meters, over global lat/lon.
174    pub fn value(lat: f64, lon: f64) -> f64 {
175        let r = ((lat - 44.0).powi(2) + 1.5 * (lon + 71.5).powi(2)).sqrt();
176        900.0 * (-0.15 * r).exp() + 300.0 * (0.05 * lon).sin() * (0.07 * lat).cos() + 400.0
177    }
178}
179
180impl TileSource for SyntheticSource {
181    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>> {
182        let n = self.side * self.side;
183        let mut data = Vec::with_capacity(n);
184        for row in 0..self.side {
185            let lat = lat_lo as f64 + 1.0 - row as f64 / (self.side - 1) as f64;
186            for col in 0..self.side {
187                let lon = lon_lo as f64 + col as f64 / (self.side - 1) as f64;
188                data.push(Self::value(lat, lon).round() as i16);
189            }
190        }
191        Some(Arc::new(Tile {
192            lat_lo: lat_lo as f64,
193            lon_lo: lon_lo as f64,
194            side: self.side,
195            data,
196        }))
197    }
198}
199
200/// Remote mirror + on-disk cache, replicating `SRTM.py`'s default behavior.
201///
202/// The mirror lists `.hgt.zip` files under region subdirectories; we scrape
203/// the whole index once (8-ish requests) and memoize it. Tiles land in
204/// `cache_dir` as plain `.hgt` files, so a `DirSource` pointed at the same
205/// directory shares the cache.
206pub struct RemoteSource {
207    base_urls: Vec<String>,
208    cache_dir: PathBuf,
209    agent: ureq::Agent,
210    index: RwLock<Option<HashMap<String, String>>>,
211    tiles: Mutex<TileMap>,
212}
213
214impl RemoteSource {
215    pub fn new(base_urls: &[&str], cache_dir: impl Into<PathBuf>) -> Result<Self> {
216        let cache_dir = cache_dir.into();
217        std::fs::create_dir_all(&cache_dir)?;
218        Ok(Self {
219            base_urls: base_urls
220                .iter()
221                .map(|u| u.trim_end_matches('/').to_string())
222                .collect(),
223            cache_dir,
224            agent: ureq::AgentBuilder::new()
225                .timeout_connect(std::time::Duration::from_secs(20))
226                .timeout(std::time::Duration::from_secs(180))
227                .user_agent("ridge-redux/0.1")
228                .build(),
229            index: RwLock::new(None),
230            tiles: Mutex::new(HashMap::new()),
231        })
232    }
233
234    /// Default mirror and `~/.cache/ridge-redux/srtm`.
235    pub fn default_paths() -> Result<Self> {
236        let cache = std::env::var("XDG_CACHE_HOME")
237            .map(std::path::PathBuf::from)
238            .unwrap_or_else(|_| dirs_home().join(".cache"))
239            .join("ridge-redux")
240            .join("srtm");
241        Self::new(
242            &[
243                "https://srtm.kurviger.de/SRTM1/",
244                "https://srtm.kurviger.de/SRTM3/",
245            ],
246            cache,
247        )
248    }
249
250    fn fetch_url(&self, url: &str) -> Result<Vec<u8>> {
251        let resp = self
252            .agent
253            .get(url)
254            .call()
255            .map_err(|e| Error::Srtm(format!("GET {url}: {e}")))?;
256        let mut buf = Vec::new();
257        resp.into_reader()
258            .take(512 * 1024 * 1024)
259            .read_to_end(&mut buf)
260            .map_err(|e| Error::Srtm(format!("reading {url}: {e}")))?;
261        Ok(buf)
262    }
263
264    /// Scrape each mirror's subdirectories for `NAME.hgt.zip -> url`.
265    /// Earlier bases win, so pass higher-resolution mirrors first.
266    fn build_index(&self) -> Result<HashMap<String, String>> {
267        let mut index = HashMap::new();
268        for base in &self.base_urls {
269            let page = self.fetch_url(&format!("{base}/"))?;
270            let text = String::from_utf8_lossy(&page);
271            // Subdirectories are linked as `Region_01/index.html` (SRTM1) or
272            // `Eurasia/index.html` (SRTM3) — or with a trailing slash.
273            let dirs = scan_hrefs(&text)
274                .into_iter()
275                .filter_map(|h| {
276                    if h.starts_with("..") {
277                        return None;
278                    }
279                    if let Some(dir) = h.strip_suffix("/index.html") {
280                        return Some(dir.to_string());
281                    }
282                    if h.ends_with('/') {
283                        return Some(h.trim_end_matches('/').to_string());
284                    }
285                    None
286                })
287                .collect::<Vec<_>>();
288            for dir in dirs {
289                let dir_url = format!("{base}/{}", dir.trim_end_matches('/'));
290                let sub = match self.fetch_url(&dir_url) {
291                    Ok(sub) => sub,
292                    Err(_) => continue,
293                };
294                let sub_text = String::from_utf8_lossy(&sub);
295                for href in scan_hrefs(&sub_text) {
296                    if let Some(name) = href.rsplit('/').next() {
297                        if name.ends_with(".hgt.zip") {
298                            let file = name.trim_end_matches(".zip");
299                            index.entry(file.to_string()).or_insert_with(|| {
300                                if href.starts_with("http") {
301                                    href.clone()
302                                } else {
303                                    format!("{dir_url}/{href}")
304                                }
305                            });
306                        }
307                    }
308                }
309            }
310        }
311        if index.is_empty() {
312            return Err(Error::Srtm("mirror index came back empty".into()));
313        }
314        Ok(index)
315    }
316
317    pub fn index(&self) -> Result<HashMap<String, String>> {
318        if let Some(idx) = self.index.read().unwrap().as_ref() {
319            return Ok(idx.clone());
320        }
321        let idx = self.build_index()?;
322        *self.index.write().unwrap() = Some(idx.clone());
323        Ok(idx)
324    }
325
326    pub fn load_or_download(&self, lat_lo: i32, lon_lo: i32) -> Result<Arc<Tile>> {
327        let name = tile_name(lat_lo as f64, lon_lo as f64);
328        let path = self.cache_dir.join(&name);
329        let bytes = match std::fs::read(&path) {
330            Ok(bytes) => bytes,
331            Err(_) => {
332                let index = self.index()?;
333                let url = index.get(&name).ok_or_else(|| {
334                    Error::Srtm(format!(
335                        "no tile {name} on the mirror (ocean or out of range?)"
336                    ))
337                })?;
338                let zipped = self.fetch_url(url)?;
339                let raw = unzip_single(&zipped, &name)
340                    .ok_or_else(|| Error::Srtm(format!("could not unzip tile {name}")))?;
341                std::fs::write(&path, &raw)?;
342                raw
343            }
344        };
345        Ok(Arc::new(Tile::parse(&name, &bytes)?))
346    }
347}
348
349impl TileSource for RemoteSource {
350    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>> {
351        if let Some(cached) = self.tiles.lock().unwrap().get(&(lat_lo, lon_lo)) {
352            return cached.clone();
353        }
354        let loaded = self.load_or_download(lat_lo, lon_lo).ok();
355        self.tiles
356            .lock()
357            .unwrap()
358            .insert((lat_lo, lon_lo), loaded.clone());
359        loaded
360    }
361}
362
363/// Extract every `href="..."` from an HTML page.
364fn scan_hrefs(page: &str) -> Vec<String> {
365    let mut out = Vec::new();
366    let mut rest = page;
367    while let Some(pos) = rest.find("href=\"") {
368        rest = &rest[pos + 6..];
369        if let Some(end) = rest.find('"') {
370            out.push(rest[..end].to_string());
371            rest = &rest[end..];
372        } else {
373            break;
374        }
375    }
376    out
377}
378
379fn dirs_home() -> PathBuf {
380    std::env::var("HOME")
381        .map(PathBuf::from)
382        .unwrap_or_else(|_| PathBuf::from("/tmp"))
383}
384
385/// Minimal zip extractor: find the entry whose name ends with `want_suffix`
386/// (or the first entry) and decompress it. Handles stored + deflate entries.
387pub fn unzip_single(zip: &[u8], want_suffix: &str) -> Option<Vec<u8>> {
388    // End of central directory record.
389    let eocd = zip
390        .windows(22)
391        .rev()
392        .find(|w| w[..4] == [0x50, 0x4b, 0x05, 0x06])?;
393    let eocd_pos = zip.len() - eocd.len();
394    let entries = u16::from_le_bytes([eocd[10], eocd[11]]) as usize;
395    let cd_size = u32::from_le_bytes([eocd[12], eocd[13], eocd[14], eocd[15]]) as usize;
396    let cd_offset = u32::from_le_bytes([eocd[16], eocd[17], eocd[18], eocd[19]]) as usize;
397    if cd_offset == 0xFFFF_FFFF {
398        return None; // zip64: not needed for 25 MB tiles
399    }
400    let _ = eocd_pos;
401    let mut cd = &zip[cd_offset.min(zip.len())..(cd_offset + cd_size).min(zip.len())];
402
403    for _ in 0..entries {
404        if cd.len() < 46 || cd[..4] != [0x50, 0x4b, 0x01, 0x02] {
405            return None;
406        }
407        let method = u16::from_le_bytes([cd[10], cd[11]]);
408        let comp_size = u32::from_le_bytes([cd[20], cd[21], cd[22], cd[23]]) as usize;
409        let name_len = u16::from_le_bytes([cd[28], cd[29]]) as usize;
410        let extra_len = u16::from_le_bytes([cd[30], cd[31]]) as usize;
411        let comment_len = u16::from_le_bytes([cd[32], cd[33]]) as usize;
412        let local_off = u32::from_le_bytes([cd[42], cd[43], cd[44], cd[45]]) as usize;
413        let name = String::from_utf8_lossy(&cd[46..46 + name_len]).to_string();
414        cd = &cd[46 + name_len + extra_len + comment_len..];
415
416        if !want_suffix.is_empty() && !name.ends_with(want_suffix) {
417            continue;
418        }
419        // Local file header: skip name + extra to find data.
420        if local_off + 30 > zip.len() || zip[local_off..local_off + 4] != [0x50, 0x4b, 0x03, 0x04] {
421            return None;
422        }
423        let l_name = u16::from_le_bytes([zip[local_off + 26], zip[local_off + 27]]) as usize;
424        let l_extra = u16::from_le_bytes([zip[local_off + 28], zip[local_off + 29]]) as usize;
425        let data_start = local_off + 30 + l_name + l_extra;
426        let data_end = (data_start + comp_size).min(zip.len());
427        let data = &zip[data_start..data_end];
428        return match method {
429            0 => Some(data.to_vec()),
430            8 => {
431                let mut out = Vec::with_capacity(comp_size * 4);
432                let mut decoder = flate2::read::DeflateDecoder::new(data);
433                decoder.read_to_end(&mut out).ok()?;
434                Some(out)
435            }
436            _ => None,
437        };
438    }
439    None
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn tile_names_roundtrip() {
448        assert_eq!(tile_name(44.0, -72.0), "N44W072.hgt");
449        assert_eq!(tile_name(-33.5, 151.2), "S33E151.hgt");
450        assert_eq!(parse_tile_name("N44W072.hgt"), Some((44.0, -72.0)));
451        assert_eq!(parse_tile_name("S33E151.hgt"), Some((-33.0, 151.0)));
452        assert_eq!(parse_tile_name("junk.hgt"), None);
453    }
454
455    #[test]
456    fn synthetic_tile_lookup() {
457        let src = SyntheticSource { side: 1201 };
458        let t = src.tile(44, -72).unwrap();
459        let v = t.elevation(44.5, -71.5);
460        let expected = SyntheticSource::value(44.5, -71.5).round();
461        assert!((v - expected).abs() < 1e-9);
462        assert!(t.elevation(45.5, -71.5).is_nan()); // out of tile
463    }
464
465    #[test]
466    fn zip_roundtrip() {
467        // Build a stored (method 0) zip by hand: header + data + central dir + eocd.
468        let name = b"N44W072.hgt";
469        let payload = b"hello-tile-data".to_vec();
470        let mut zip = Vec::new();
471        // local header
472        zip.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
473        zip.extend_from_slice(&0u32.to_le_bytes()); // crc
474        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
475        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
476        zip.extend_from_slice(&(name.len() as u16).to_le_bytes());
477        zip.extend_from_slice(&0u16.to_le_bytes());
478        zip.extend_from_slice(name);
479        zip.extend_from_slice(&payload);
480        // central directory
481        let cd_start = zip.len();
482        // sig(4), ver_made(2), ver_needed(2), flags(2), method(2), time(2), date(2) = 16 bytes
483        zip.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02, 20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
484        zip.extend_from_slice(&0u32.to_le_bytes());
485        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
486        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
487        zip.extend_from_slice(&(name.len() as u16).to_le_bytes());
488        zip.extend_from_slice(&0u16.to_le_bytes()); // extra len
489        zip.extend_from_slice(&0u16.to_le_bytes()); // comment len
490        zip.extend_from_slice(&0u16.to_le_bytes()); // disk number start
491        zip.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
492        zip.extend_from_slice(&0u32.to_le_bytes()); // external attrs
493        zip.extend_from_slice(&0u32.to_le_bytes()); // local header offset (header starts at 0)
494        zip.extend_from_slice(name);
495        let cd_end = zip.len();
496        // eocd
497        zip.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 1, 0, 1, 0]);
498        zip.extend_from_slice(&((cd_end - cd_start) as u32).to_le_bytes());
499        zip.extend_from_slice(&(cd_start as u32).to_le_bytes());
500        zip.extend_from_slice(&0u16.to_le_bytes());
501
502        let out = unzip_single(&zip, ".hgt").expect("extract");
503        assert_eq!(out, payload);
504    }
505}