1use std::collections::HashMap;
16use std::io::Read;
17use std::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 = 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 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 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
363fn 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
385pub fn unzip_single(zip: &[u8], want_suffix: &str) -> Option<Vec<u8>> {
388 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; }
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 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()); }
464
465 #[test]
466 fn zip_roundtrip() {
467 let name = b"N44W072.hgt";
469 let payload = b"hello-tile-data".to_vec();
470 let mut zip = Vec::new();
471 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()); 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 let cd_start = zip.len();
482 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()); 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);
495 let cd_end = zip.len();
496 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}