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 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
200pub 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 pub fn default_paths() -> Result<Self> {
236 let cache = default_cache_dir();
237 Self::new(
238 &[
239 "https://srtm.kurviger.de/SRTM1/",
240 "https://srtm.kurviger.de/SRTM3/",
241 ],
242 cache,
243 )
244 }
245
246 fn fetch_url(&self, url: &str) -> Result<Vec<u8>> {
247 let resp = self
248 .agent
249 .get(url)
250 .call()
251 .map_err(|e| Error::Srtm(format!("GET {url}: {e}")))?;
252 let mut buf = Vec::new();
253 resp.into_reader()
254 .take(512 * 1024 * 1024)
255 .read_to_end(&mut buf)
256 .map_err(|e| Error::Srtm(format!("reading {url}: {e}")))?;
257 Ok(buf)
258 }
259
260 fn build_index(&self) -> Result<HashMap<String, String>> {
263 let mut index = HashMap::new();
264 for base in &self.base_urls {
265 let page = self.fetch_url(&format!("{base}/"))?;
266 let text = String::from_utf8_lossy(&page);
267 let dirs = scan_hrefs(&text)
270 .into_iter()
271 .filter_map(|h| {
272 if h.starts_with("..") {
273 return None;
274 }
275 if let Some(dir) = h.strip_suffix("/index.html") {
276 return Some(dir.to_string());
277 }
278 if h.ends_with('/') {
279 return Some(h.trim_end_matches('/').to_string());
280 }
281 None
282 })
283 .collect::<Vec<_>>();
284 for dir in dirs {
285 let dir_url = format!("{base}/{}", dir.trim_end_matches('/'));
286 let sub = match self.fetch_url(&dir_url) {
287 Ok(sub) => sub,
288 Err(_) => continue,
289 };
290 let sub_text = String::from_utf8_lossy(&sub);
291 for href in scan_hrefs(&sub_text) {
292 if let Some(name) = href.rsplit('/').next() {
293 if name.ends_with(".hgt.zip") {
294 let file = name.trim_end_matches(".zip");
295 index.entry(file.to_string()).or_insert_with(|| {
296 if href.starts_with("http") {
297 href.clone()
298 } else {
299 format!("{dir_url}/{href}")
300 }
301 });
302 }
303 }
304 }
305 }
306 }
307 if index.is_empty() {
308 return Err(Error::Srtm("mirror index came back empty".into()));
309 }
310 Ok(index)
311 }
312
313 pub fn index(&self) -> Result<HashMap<String, String>> {
314 if let Some(idx) = self.index.read().unwrap().as_ref() {
315 return Ok(idx.clone());
316 }
317 let idx = self.build_index()?;
318 *self.index.write().unwrap() = Some(idx.clone());
319 Ok(idx)
320 }
321
322 pub fn load_or_download(&self, lat_lo: i32, lon_lo: i32) -> Result<Arc<Tile>> {
323 let name = tile_name(lat_lo as f64, lon_lo as f64);
324 let path = self.cache_dir.join(&name);
325 let bytes = match std::fs::read(&path) {
326 Ok(bytes) => bytes,
327 Err(_) => {
328 let index = self.index()?;
329 let url = index.get(&name).ok_or_else(|| {
330 Error::Srtm(format!(
331 "no tile {name} on the mirror (ocean or out of range?)"
332 ))
333 })?;
334 let zipped = self.fetch_url(url)?;
335 let raw = unzip_single(&zipped, &name)
336 .ok_or_else(|| Error::Srtm(format!("could not unzip tile {name}")))?;
337 std::fs::write(&path, &raw)?;
338 raw
339 }
340 };
341 Ok(Arc::new(Tile::parse(&name, &bytes)?))
342 }
343}
344
345impl TileSource for RemoteSource {
346 fn tile(&self, lat_lo: i32, lon_lo: i32) -> Option<Arc<Tile>> {
347 if let Some(cached) = self.tiles.lock().unwrap().get(&(lat_lo, lon_lo)) {
348 return cached.clone();
349 }
350 let loaded = self.load_or_download(lat_lo, lon_lo).ok();
351 self.tiles
352 .lock()
353 .unwrap()
354 .insert((lat_lo, lon_lo), loaded.clone());
355 loaded
356 }
357}
358
359fn scan_hrefs(page: &str) -> Vec<String> {
361 let mut out = Vec::new();
362 let mut rest = page;
363 while let Some(pos) = rest.find("href=\"") {
364 rest = &rest[pos + 6..];
365 if let Some(end) = rest.find('"') {
366 out.push(rest[..end].to_string());
367 rest = &rest[end..];
368 } else {
369 break;
370 }
371 }
372 out
373}
374
375pub fn default_cache_dir() -> PathBuf {
380 dirs::cache_dir()
381 .unwrap_or_else(std::env::temp_dir)
382 .join("ridge-redux")
383 .join("srtm")
384}
385
386pub fn legacy_cache_dir() -> PathBuf {
390 std::env::var("XDG_CACHE_HOME")
391 .map(PathBuf::from)
392 .unwrap_or_else(|_| {
393 std::env::var("HOME")
394 .map(PathBuf::from)
395 .unwrap_or_else(|_| PathBuf::from("/tmp"))
396 .join(".cache")
397 })
398 .join("ridge-redux")
399 .join("srtm")
400}
401
402pub fn migrate_legacy_cache(to: &Path) -> std::io::Result<usize> {
406 migrate_cache(&legacy_cache_dir(), to)
407}
408
409fn migrate_cache(from: &Path, to: &Path) -> std::io::Result<usize> {
410 if !from.is_dir() || same_dir(from, to) {
411 return Ok(0);
412 }
413 std::fs::create_dir_all(to)?;
414 let mut moved = 0;
415 for entry in std::fs::read_dir(from)? {
416 let entry = entry?;
417 if !entry.file_type()?.is_file() {
418 continue;
419 }
420 let src = entry.path();
421 let dest = to.join(entry.file_name());
422 if dest.exists() {
423 std::fs::remove_file(&src)?;
424 continue;
425 }
426 if std::fs::rename(&src, &dest).is_err() {
428 std::fs::copy(&src, &dest)?;
429 std::fs::remove_file(&src)?;
430 }
431 moved += 1;
432 }
433 remove_cache_dirs(from);
434 Ok(moved)
435}
436
437pub fn remove_cache(dir: &Path) -> std::io::Result<(usize, u64)> {
440 let (files, bytes) = cache_size(dir)?;
441 std::fs::remove_dir_all(dir)?;
442 remove_cache_dirs(dir);
443 Ok((files, bytes))
444}
445
446pub fn cache_size(dir: &Path) -> std::io::Result<(usize, u64)> {
448 let mut files = 0;
449 let mut bytes = 0;
450 for entry in std::fs::read_dir(dir)? {
451 let meta = entry?.metadata()?;
452 if meta.is_file() {
453 files += 1;
454 bytes += meta.len();
455 }
456 }
457 Ok((files, bytes))
458}
459
460fn remove_cache_dirs(dir: &Path) {
462 let _ = std::fs::remove_dir(dir);
463 if let Some(parent) = dir.parent().filter(|p| p.ends_with("ridge-redux")) {
464 let _ = std::fs::remove_dir(parent);
465 }
466}
467
468fn same_dir(a: &Path, b: &Path) -> bool {
469 match (a.canonicalize(), b.canonicalize()) {
470 (Ok(a), Ok(b)) => a == b,
471 _ => a == b,
472 }
473}
474
475pub fn unzip_single(zip: &[u8], want_suffix: &str) -> Option<Vec<u8>> {
478 let eocd = zip
480 .windows(22)
481 .rev()
482 .find(|w| w[..4] == [0x50, 0x4b, 0x05, 0x06])?;
483 let eocd_pos = zip.len() - eocd.len();
484 let entries = u16::from_le_bytes([eocd[10], eocd[11]]) as usize;
485 let cd_size = u32::from_le_bytes([eocd[12], eocd[13], eocd[14], eocd[15]]) as usize;
486 let cd_offset = u32::from_le_bytes([eocd[16], eocd[17], eocd[18], eocd[19]]) as usize;
487 if cd_offset == 0xFFFF_FFFF {
488 return None; }
490 let _ = eocd_pos;
491 let mut cd = &zip[cd_offset.min(zip.len())..(cd_offset + cd_size).min(zip.len())];
492
493 for _ in 0..entries {
494 if cd.len() < 46 || cd[..4] != [0x50, 0x4b, 0x01, 0x02] {
495 return None;
496 }
497 let method = u16::from_le_bytes([cd[10], cd[11]]);
498 let comp_size = u32::from_le_bytes([cd[20], cd[21], cd[22], cd[23]]) as usize;
499 let name_len = u16::from_le_bytes([cd[28], cd[29]]) as usize;
500 let extra_len = u16::from_le_bytes([cd[30], cd[31]]) as usize;
501 let comment_len = u16::from_le_bytes([cd[32], cd[33]]) as usize;
502 let local_off = u32::from_le_bytes([cd[42], cd[43], cd[44], cd[45]]) as usize;
503 let name = String::from_utf8_lossy(&cd[46..46 + name_len]).to_string();
504 cd = &cd[46 + name_len + extra_len + comment_len..];
505
506 if !want_suffix.is_empty() && !name.ends_with(want_suffix) {
507 continue;
508 }
509 if local_off + 30 > zip.len() || zip[local_off..local_off + 4] != [0x50, 0x4b, 0x03, 0x04] {
511 return None;
512 }
513 let l_name = u16::from_le_bytes([zip[local_off + 26], zip[local_off + 27]]) as usize;
514 let l_extra = u16::from_le_bytes([zip[local_off + 28], zip[local_off + 29]]) as usize;
515 let data_start = local_off + 30 + l_name + l_extra;
516 let data_end = (data_start + comp_size).min(zip.len());
517 let data = &zip[data_start..data_end];
518 return match method {
519 0 => Some(data.to_vec()),
520 8 => {
521 let mut out = Vec::with_capacity(comp_size * 4);
522 let mut decoder = flate2::read::DeflateDecoder::new(data);
523 decoder.read_to_end(&mut out).ok()?;
524 Some(out)
525 }
526 _ => None,
527 };
528 }
529 None
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 fn scratch(name: &str) -> PathBuf {
537 let dir = std::env::temp_dir()
538 .join(format!("ridge-srtm-test-{}", std::process::id()))
539 .join(name);
540 let _ = std::fs::remove_dir_all(&dir);
541 dir
542 }
543
544 #[test]
545 fn migrate_moves_new_tiles_and_drops_duplicates() {
546 let root = scratch("migrate");
547 let from = root.join("old").join("ridge-redux").join("srtm");
548 let to = root.join("new").join("ridge-redux").join("srtm");
549 std::fs::create_dir_all(&from).unwrap();
550 std::fs::create_dir_all(&to).unwrap();
551 std::fs::write(from.join("N44W072.hgt"), b"old").unwrap();
552 std::fs::write(from.join("N43W071.hgt"), b"old").unwrap();
553 std::fs::write(to.join("N43W071.hgt"), b"new").unwrap();
554
555 assert_eq!(migrate_cache(&from, &to).unwrap(), 1);
556 assert_eq!(std::fs::read(to.join("N44W072.hgt")).unwrap(), b"old");
557 assert_eq!(std::fs::read(to.join("N43W071.hgt")).unwrap(), b"new");
558 assert!(!from.parent().unwrap().exists(), "emptied legacy dirs go");
559 assert_eq!(migrate_cache(&from, &to).unwrap(), 0);
561 std::fs::remove_dir_all(&root).unwrap();
562 }
563
564 #[test]
565 fn migrate_into_itself_is_a_no_op() {
566 let dir = scratch("same").join("ridge-redux").join("srtm");
567 std::fs::create_dir_all(&dir).unwrap();
568 std::fs::write(dir.join("N44W072.hgt"), b"tile").unwrap();
569 assert_eq!(migrate_cache(&dir, &dir).unwrap(), 0);
570 assert!(dir.join("N44W072.hgt").exists());
571 std::fs::remove_dir_all(dir.parent().unwrap().parent().unwrap()).unwrap();
572 }
573
574 #[test]
575 fn remove_cache_reports_and_tidies() {
576 let root = scratch("remove");
577 let dir = root.join("ridge-redux").join("srtm");
578 std::fs::create_dir_all(&dir).unwrap();
579 std::fs::write(dir.join("N44W072.hgt"), [0u8; 10]).unwrap();
580 std::fs::write(dir.join("N43W071.hgt"), [0u8; 5]).unwrap();
581 assert_eq!(remove_cache(&dir).unwrap(), (2, 15));
582 assert!(!root.join("ridge-redux").exists());
583 std::fs::remove_dir_all(&root).unwrap();
584 }
585
586 #[test]
587 fn tile_names_roundtrip() {
588 assert_eq!(tile_name(44.0, -72.0), "N44W072.hgt");
589 assert_eq!(tile_name(-33.5, 151.2), "S33E151.hgt");
590 assert_eq!(parse_tile_name("N44W072.hgt"), Some((44.0, -72.0)));
591 assert_eq!(parse_tile_name("S33E151.hgt"), Some((-33.0, 151.0)));
592 assert_eq!(parse_tile_name("junk.hgt"), None);
593 }
594
595 #[test]
596 fn synthetic_tile_lookup() {
597 let src = SyntheticSource { side: 1201 };
598 let t = src.tile(44, -72).unwrap();
599 let v = t.elevation(44.5, -71.5);
600 let expected = SyntheticSource::value(44.5, -71.5).round();
601 assert!((v - expected).abs() < 1e-9);
602 assert!(t.elevation(45.5, -71.5).is_nan()); }
604
605 #[test]
606 fn zip_roundtrip() {
607 let name = b"N44W072.hgt";
609 let payload = b"hello-tile-data".to_vec();
610 let mut zip = Vec::new();
611 zip.extend_from_slice(&[0x50, 0x4b, 0x03, 0x04, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
613 zip.extend_from_slice(&0u32.to_le_bytes()); zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
615 zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
616 zip.extend_from_slice(&(name.len() as u16).to_le_bytes());
617 zip.extend_from_slice(&0u16.to_le_bytes());
618 zip.extend_from_slice(name);
619 zip.extend_from_slice(&payload);
620 let cd_start = zip.len();
622 zip.extend_from_slice(&[0x50, 0x4b, 0x01, 0x02, 20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
624 zip.extend_from_slice(&0u32.to_le_bytes());
625 zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
626 zip.extend_from_slice(&(payload.len() as u32).to_le_bytes());
627 zip.extend_from_slice(&(name.len() as u16).to_le_bytes());
628 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);
635 let cd_end = zip.len();
636 zip.extend_from_slice(&[0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 1, 0, 1, 0]);
638 zip.extend_from_slice(&((cd_end - cd_start) as u32).to_le_bytes());
639 zip.extend_from_slice(&(cd_start as u32).to_le_bytes());
640 zip.extend_from_slice(&0u16.to_le_bytes());
641
642 let out = unzip_single(&zip, ".hgt").expect("extract");
643 assert_eq!(out, payload);
644 }
645}