1use std::collections::HashMap;
16use std::io::Read;
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex, RwLock};
19
20use crate::{Error, Result};
21
22type TileMap = HashMap<(i32, i32), Option<Arc<Tile>>>;
24
25#[derive(Debug, Clone)]
27pub struct Tile {
28 pub lat_lo: f64,
30 pub lon_lo: f64,
32 pub side: usize,
34 pub data: Vec<i16>,
36}
37
38impl Tile {
39 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 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
91pub 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
116pub 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 format!(
122 "{ns}{:02}{ew}{:03}.hgt",
123 lat.floor() as i32,
124 lon.floor() as i32
125 )
126}
127
128pub trait TileSource: Send + Sync {
130 fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>>;
131}
132
133pub 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
167pub struct SyntheticSource {
169 pub side: usize,
170}
171
172impl SyntheticSource {
173 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 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
202pub 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 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 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 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
361fn 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
377pub 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
388pub 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
404pub 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 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
439pub 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
448pub 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
462fn 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
477pub fn unzip_single(zip: &[u8], want_suffix: &str) -> Option<Vec<u8>> {
480 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; }
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 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 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()); }
606
607 #[test]
608 fn zip_roundtrip() {
609 let name = b"N44W072.hgt";
611 let payload = b"hello-tile-data".to_vec();
612 let mut zip = Vec::new();
613 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()); 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 let cd_start = zip.len();
624 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()); zip.extend_from_slice(&0u16.to_le_bytes()); zip.extend_from_slice(&0u16.to_le_bytes()); zip.extend_from_slice(&0u16.to_le_bytes()); zip.extend_from_slice(&0u32.to_le_bytes()); zip.extend_from_slice(&0u32.to_le_bytes()); zip.extend_from_slice(name);
637 let cd_end = zip.len();
638 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}