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::{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 side = self.side;
183        let scale = (side - 1) as f64;
184        let (lat0, lon0) = (lat_lo as f64, lon_lo as f64);
185        // A north-up `side x side` grid of the smooth terrain, row-major: `lat`
186        // is constant along a row, `lon` varies per column.
187        let data: Vec<i16> = (0..side)
188            .flat_map(|row| {
189                let lat = lat0 + 1.0 - row as f64 / scale;
190                (0..side).map(move |col| Self::value(lat, lon0 + col as f64 / scale).round() as i16)
191            })
192            .collect();
193        Some(Arc::new(Tile {
194            lat_lo: lat0,
195            lon_lo: lon0,
196            side,
197            data,
198        }))
199    }
200}
201
202/// Remote mirror + on-disk cache, replicating `SRTM.py`'s default behavior.
203///
204/// The mirror lists `.hgt.zip` files under region subdirectories; we scrape
205/// the whole index once (8-ish requests) and memoize it. Tiles land in
206/// `cache_dir` as plain `.hgt` files, so a `DirSource` pointed at the same
207/// directory shares the cache.
208pub struct RemoteSource {
209    base_urls: Vec<String>,
210    cache_dir: PathBuf,
211    agent: ureq::Agent,
212    index: RwLock<Option<HashMap<String, String>>>,
213    tiles: Mutex<TileMap>,
214}
215
216impl RemoteSource {
217    pub fn new(base_urls: &[&str], cache_dir: impl Into<PathBuf>) -> Result<Self> {
218        let cache_dir = cache_dir.into();
219        std::fs::create_dir_all(&cache_dir)?;
220        Ok(Self {
221            base_urls: base_urls
222                .iter()
223                .map(|u| u.trim_end_matches('/').to_string())
224                .collect(),
225            cache_dir,
226            agent: ureq::AgentBuilder::new()
227                .timeout_connect(std::time::Duration::from_secs(20))
228                .timeout(std::time::Duration::from_secs(180))
229                .user_agent("ridge-redux/0.1")
230                .build(),
231            index: RwLock::new(None),
232            tiles: Mutex::new(HashMap::new()),
233        })
234    }
235
236    /// Default mirror and [`default_cache_dir`].
237    pub fn default_paths() -> Result<Self> {
238        let cache = default_cache_dir();
239        Self::new(
240            &[
241                "https://srtm.kurviger.de/SRTM1/",
242                "https://srtm.kurviger.de/SRTM3/",
243            ],
244            cache,
245        )
246    }
247
248    fn fetch_url(&self, url: &str) -> Result<Vec<u8>> {
249        let resp = self
250            .agent
251            .get(url)
252            .call()
253            .map_err(|e| Error::Srtm(format!("GET {url}: {e}")))?;
254        let mut buf = Vec::new();
255        resp.into_reader()
256            .take(512 * 1024 * 1024)
257            .read_to_end(&mut buf)
258            .map_err(|e| Error::Srtm(format!("reading {url}: {e}")))?;
259        Ok(buf)
260    }
261
262    /// Scrape each mirror's subdirectories for `NAME.hgt.zip -> url`.
263    /// Earlier bases win, so pass higher-resolution mirrors first.
264    fn build_index(&self) -> Result<HashMap<String, String>> {
265        let mut index = HashMap::new();
266        for base in &self.base_urls {
267            let page = self.fetch_url(&format!("{base}/"))?;
268            let text = String::from_utf8_lossy(&page);
269            // Subdirectories are linked as `Region_01/index.html` (SRTM1) or
270            // `Eurasia/index.html` (SRTM3) — or with a trailing slash.
271            let dirs = scan_hrefs(&text)
272                .into_iter()
273                .filter_map(|h| {
274                    if h.starts_with("..") {
275                        return None;
276                    }
277                    if let Some(dir) = h.strip_suffix("/index.html") {
278                        return Some(dir.to_string());
279                    }
280                    if h.ends_with('/') {
281                        return Some(h.trim_end_matches('/').to_string());
282                    }
283                    None
284                })
285                .collect::<Vec<_>>();
286            for dir in dirs {
287                let dir_url = format!("{base}/{}", dir.trim_end_matches('/'));
288                let sub = match self.fetch_url(&dir_url) {
289                    Ok(sub) => sub,
290                    Err(_) => continue,
291                };
292                let sub_text = String::from_utf8_lossy(&sub);
293                for href in scan_hrefs(&sub_text) {
294                    if let Some(name) = href.rsplit('/').next() {
295                        if name.ends_with(".hgt.zip") {
296                            let file = name.trim_end_matches(".zip");
297                            index.entry(file.to_string()).or_insert_with(|| {
298                                if href.starts_with("http") {
299                                    href.clone()
300                                } else {
301                                    format!("{dir_url}/{href}")
302                                }
303                            });
304                        }
305                    }
306                }
307            }
308        }
309        if index.is_empty() {
310            return Err(Error::Srtm("mirror index came back empty".into()));
311        }
312        Ok(index)
313    }
314
315    pub fn index(&self) -> Result<HashMap<String, String>> {
316        if let Some(idx) = self.index.read().unwrap().as_ref() {
317            return Ok(idx.clone());
318        }
319        let idx = self.build_index()?;
320        *self.index.write().unwrap() = Some(idx.clone());
321        Ok(idx)
322    }
323
324    pub fn load_or_download(&self, lat_lo: i32, lon_lo: i32) -> Result<Arc<Tile>> {
325        let name = tile_name(lat_lo as f64, lon_lo as f64);
326        let path = self.cache_dir.join(&name);
327        let bytes = match std::fs::read(&path) {
328            Ok(bytes) => bytes,
329            Err(_) => {
330                let index = self.index()?;
331                let url = index.get(&name).ok_or_else(|| {
332                    Error::Srtm(format!(
333                        "no tile {name} on the mirror (ocean or out of range?)"
334                    ))
335                })?;
336                let zipped = self.fetch_url(url)?;
337                let raw = unzip_single(&zipped, &name)
338                    .ok_or_else(|| Error::Srtm(format!("could not unzip tile {name}")))?;
339                std::fs::write(&path, &raw)?;
340                raw
341            }
342        };
343        Ok(Arc::new(Tile::parse(&name, &bytes)?))
344    }
345}
346
347impl TileSource for RemoteSource {
348    fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>> {
349        if let Some(cached) = self.tiles.lock().unwrap().get(&(lat_lo, lon_lo)) {
350            return cached.clone();
351        }
352        let loaded = self.load_or_download(lat_lo, lon_lo).ok();
353        self.tiles
354            .lock()
355            .unwrap()
356            .insert((lat_lo, lon_lo), loaded.clone());
357        loaded
358    }
359}
360
361/// Extract every `href="..."` from an HTML page.
362fn scan_hrefs(page: &str) -> Vec<String> {
363    let mut out = Vec::new();
364    let mut rest = page;
365    while let Some(pos) = rest.find("href=\"") {
366        rest = &rest[pos + 6..];
367        if let Some(end) = rest.find('"') {
368            out.push(rest[..end].to_string());
369            rest = &rest[end..];
370        } else {
371            break;
372        }
373    }
374    out
375}
376
377/// Where downloaded tiles are kept by default: `ridge-redux/srtm` in the
378/// platform's cache directory. That's `$XDG_CACHE_HOME` or `~/.cache` on
379/// Linux, `~/Library/Caches` on macOS and `%LOCALAPPDATA%` on Windows; the
380/// system temp directory if there's none.
381pub fn default_cache_dir() -> PathBuf {
382    dirs::cache_dir()
383        .unwrap_or_else(std::env::temp_dir)
384        .join("ridge-redux")
385        .join("srtm")
386}
387
388/// The default cache before 0.1.1, on every platform: `$XDG_CACHE_HOME`,
389/// else `$HOME/.cache`, else `/tmp/.cache`. Only Linux matches
390/// [`default_cache_dir`]; [`migrate_legacy_cache`] moves the others.
391pub fn legacy_cache_dir() -> PathBuf {
392    std::env::var("XDG_CACHE_HOME")
393        .map(PathBuf::from)
394        .unwrap_or_else(|_| {
395            std::env::var("HOME")
396                .map(PathBuf::from)
397                .unwrap_or_else(|_| PathBuf::from("/tmp"))
398                .join(".cache")
399        })
400        .join("ridge-redux")
401        .join("srtm")
402}
403
404/// Move tiles from [`legacy_cache_dir`] into `to`, then remove the legacy
405/// directory if that empties it. Tiles `to` already has are dropped, not
406/// copied over. Returns how many tiles moved; 0 when there's nothing to do.
407pub fn migrate_legacy_cache(to: &Path) -> std::io::Result<usize> {
408    migrate_cache(&legacy_cache_dir(), to)
409}
410
411fn migrate_cache(from: &Path, to: &Path) -> std::io::Result<usize> {
412    if !from.is_dir() || same_dir(from, to) {
413        return Ok(0);
414    }
415    std::fs::create_dir_all(to)?;
416    let mut moved = 0;
417    for entry in std::fs::read_dir(from)? {
418        let entry = entry?;
419        if !entry.file_type()?.is_file() {
420            continue;
421        }
422        let src = entry.path();
423        let dest = to.join(entry.file_name());
424        if dest.exists() {
425            std::fs::remove_file(&src)?;
426            continue;
427        }
428        // A rename can't cross filesystems (a different drive, say).
429        if std::fs::rename(&src, &dest).is_err() {
430            std::fs::copy(&src, &dest)?;
431            std::fs::remove_file(&src)?;
432        }
433        moved += 1;
434    }
435    remove_cache_dirs(from);
436    Ok(moved)
437}
438
439/// Delete a tile cache directory, and its `ridge-redux` parent if that's
440/// left empty. Returns the number of files and bytes freed.
441pub fn remove_cache(dir: &Path) -> std::io::Result<(usize, u64)> {
442    let (files, bytes) = cache_size(dir)?;
443    std::fs::remove_dir_all(dir)?;
444    remove_cache_dirs(dir);
445    Ok((files, bytes))
446}
447
448/// Files and bytes in a tile cache directory (not recursive: tiles are flat).
449pub fn cache_size(dir: &Path) -> std::io::Result<(usize, u64)> {
450    let mut files = 0;
451    let mut bytes = 0;
452    for entry in std::fs::read_dir(dir)? {
453        let meta = entry?.metadata()?;
454        if meta.is_file() {
455            files += 1;
456            bytes += meta.len();
457        }
458    }
459    Ok((files, bytes))
460}
461
462/// Remove `dir` and then its `ridge-redux` parent, each only if empty.
463fn remove_cache_dirs(dir: &Path) {
464    let _ = std::fs::remove_dir(dir);
465    if let Some(parent) = dir.parent().filter(|p| p.ends_with("ridge-redux")) {
466        let _ = std::fs::remove_dir(parent);
467    }
468}
469
470fn same_dir(a: &Path, b: &Path) -> bool {
471    match (a.canonicalize(), b.canonicalize()) {
472        (Ok(a), Ok(b)) => a == b,
473        _ => a == b,
474    }
475}
476
477/// Minimal zip extractor: find the entry whose name ends with `want_suffix`
478/// (or the first entry) and decompress it. Handles stored + deflate entries.
479pub fn unzip_single(zip: &[u8], want_suffix: &str) -> Option<Vec<u8>> {
480    // End of central directory record.
481    let eocd = zip
482        .windows(22)
483        .rev()
484        .find(|w| w[..4] == [0x50, 0x4b, 0x05, 0x06])?;
485    let eocd_pos = zip.len() - eocd.len();
486    let entries = u16::from_le_bytes([eocd[10], eocd[11]]) as usize;
487    let cd_size = u32::from_le_bytes([eocd[12], eocd[13], eocd[14], eocd[15]]) as usize;
488    let cd_offset = u32::from_le_bytes([eocd[16], eocd[17], eocd[18], eocd[19]]) as usize;
489    if cd_offset == 0xFFFF_FFFF {
490        return None; // zip64: not needed for 25 MB tiles
491    }
492    let _ = eocd_pos;
493    let mut cd = &zip[cd_offset.min(zip.len())..(cd_offset + cd_size).min(zip.len())];
494
495    for _ in 0..entries {
496        if cd.len() < 46 || cd[..4] != [0x50, 0x4b, 0x01, 0x02] {
497            return None;
498        }
499        let method = u16::from_le_bytes([cd[10], cd[11]]);
500        let comp_size = u32::from_le_bytes([cd[20], cd[21], cd[22], cd[23]]) as usize;
501        let name_len = u16::from_le_bytes([cd[28], cd[29]]) as usize;
502        let extra_len = u16::from_le_bytes([cd[30], cd[31]]) as usize;
503        let comment_len = u16::from_le_bytes([cd[32], cd[33]]) as usize;
504        let local_off = u32::from_le_bytes([cd[42], cd[43], cd[44], cd[45]]) as usize;
505        let name = String::from_utf8_lossy(&cd[46..46 + name_len]).to_string();
506        cd = &cd[46 + name_len + extra_len + comment_len..];
507
508        if !want_suffix.is_empty() && !name.ends_with(want_suffix) {
509            continue;
510        }
511        // Local file header: skip name + extra to find data.
512        if local_off + 30 > zip.len() || zip[local_off..local_off + 4] != [0x50, 0x4b, 0x03, 0x04] {
513            return None;
514        }
515        let l_name = u16::from_le_bytes([zip[local_off + 26], zip[local_off + 27]]) as usize;
516        let l_extra = u16::from_le_bytes([zip[local_off + 28], zip[local_off + 29]]) as usize;
517        let data_start = local_off + 30 + l_name + l_extra;
518        let data_end = (data_start + comp_size).min(zip.len());
519        let data = &zip[data_start..data_end];
520        return match method {
521            0 => Some(data.to_vec()),
522            8 => {
523                let mut out = Vec::with_capacity(comp_size * 4);
524                let mut decoder = flate2::read::DeflateDecoder::new(data);
525                decoder.read_to_end(&mut out).ok()?;
526                Some(out)
527            }
528            _ => None,
529        };
530    }
531    None
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    fn scratch(name: &str) -> PathBuf {
539        let dir = std::env::temp_dir()
540            .join(format!("ridge-srtm-test-{}", std::process::id()))
541            .join(name);
542        let _ = std::fs::remove_dir_all(&dir);
543        dir
544    }
545
546    #[test]
547    fn migrate_moves_new_tiles_and_drops_duplicates() {
548        let root = scratch("migrate");
549        let from = root.join("old").join("ridge-redux").join("srtm");
550        let to = root.join("new").join("ridge-redux").join("srtm");
551        std::fs::create_dir_all(&from).unwrap();
552        std::fs::create_dir_all(&to).unwrap();
553        std::fs::write(from.join("N44W072.hgt"), b"old").unwrap();
554        std::fs::write(from.join("N43W071.hgt"), b"old").unwrap();
555        std::fs::write(to.join("N43W071.hgt"), b"new").unwrap();
556
557        assert_eq!(migrate_cache(&from, &to).unwrap(), 1);
558        assert_eq!(std::fs::read(to.join("N44W072.hgt")).unwrap(), b"old");
559        assert_eq!(std::fs::read(to.join("N43W071.hgt")).unwrap(), b"new");
560        assert!(!from.parent().unwrap().exists(), "emptied legacy dirs go");
561        // Nothing left to move the second time.
562        assert_eq!(migrate_cache(&from, &to).unwrap(), 0);
563        std::fs::remove_dir_all(&root).unwrap();
564    }
565
566    #[test]
567    fn migrate_into_itself_is_a_no_op() {
568        let dir = scratch("same").join("ridge-redux").join("srtm");
569        std::fs::create_dir_all(&dir).unwrap();
570        std::fs::write(dir.join("N44W072.hgt"), b"tile").unwrap();
571        assert_eq!(migrate_cache(&dir, &dir).unwrap(), 0);
572        assert!(dir.join("N44W072.hgt").exists());
573        std::fs::remove_dir_all(dir.parent().unwrap().parent().unwrap()).unwrap();
574    }
575
576    #[test]
577    fn remove_cache_reports_and_tidies() {
578        let root = scratch("remove");
579        let dir = root.join("ridge-redux").join("srtm");
580        std::fs::create_dir_all(&dir).unwrap();
581        std::fs::write(dir.join("N44W072.hgt"), [0u8; 10]).unwrap();
582        std::fs::write(dir.join("N43W071.hgt"), [0u8; 5]).unwrap();
583        assert_eq!(remove_cache(&dir).unwrap(), (2, 15));
584        assert!(!root.join("ridge-redux").exists());
585        std::fs::remove_dir_all(&root).unwrap();
586    }
587
588    #[test]
589    fn tile_names_roundtrip() {
590        assert_eq!(tile_name(44.0, -72.0), "N44W072.hgt");
591        assert_eq!(tile_name(-33.5, 151.2), "S33E151.hgt");
592        assert_eq!(parse_tile_name("N44W072.hgt"), Some((44.0, -72.0)));
593        assert_eq!(parse_tile_name("S33E151.hgt"), Some((-33.0, 151.0)));
594        assert_eq!(parse_tile_name("junk.hgt"), None);
595    }
596
597    #[test]
598    fn synthetic_tile_lookup() {
599        let src = SyntheticSource { side: 1201 };
600        let t = src.tile(44, -72).unwrap();
601        let v = t.elevation(44.5, -71.5);
602        let expected = SyntheticSource::value(44.5, -71.5).round();
603        assert!((v - expected).abs() < 1e-9);
604        assert!(t.elevation(45.5, -71.5).is_nan()); // out of tile
605    }
606
607    #[test]
608    fn zip_roundtrip() {
609        // Build a stored (method 0) zip by hand: header + data + central dir + eocd.
610        let name = b"N44W072.hgt";
611        let payload = b"hello-tile-data".to_vec();
612        let mut zip = Vec::new();
613        // local header
614        zip.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
615        zip.extend_from_slice(&0u32.to_le_bytes()); // crc
616        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
617        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
618        zip.extend_from_slice(&(name.len() as u16).to_le_bytes());
619        zip.extend_from_slice(&0u16.to_le_bytes());
620        zip.extend_from_slice(name);
621        zip.extend_from_slice(&payload);
622        // central directory
623        let cd_start = zip.len();
624        // sig(4), ver_made(2), ver_needed(2), flags(2), method(2), time(2), date(2) = 16 bytes
625        zip.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02, 20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
626        zip.extend_from_slice(&0u32.to_le_bytes());
627        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
628        zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
629        zip.extend_from_slice(&(name.len() as u16).to_le_bytes());
630        zip.extend_from_slice(&0u16.to_le_bytes()); // extra len
631        zip.extend_from_slice(&0u16.to_le_bytes()); // comment len
632        zip.extend_from_slice(&0u16.to_le_bytes()); // disk number start
633        zip.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
634        zip.extend_from_slice(&0u32.to_le_bytes()); // external attrs
635        zip.extend_from_slice(&0u32.to_le_bytes()); // local header offset (header starts at 0)
636        zip.extend_from_slice(name);
637        let cd_end = zip.len();
638        // eocd
639        zip.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 1, 0, 1, 0]);
640        zip.extend_from_slice(&((cd_end - cd_start) as u32).to_le_bytes());
641        zip.extend_from_slice(&(cd_start as u32).to_le_bytes());
642        zip.extend_from_slice(&0u16.to_le_bytes());
643
644        let out = unzip_single(&zip, ".hgt").expect("extract");
645        assert_eq!(out, payload);
646    }
647}