Add OpenStreetMap base layer with Carto tile overlay

- Fetch Carto Voyager no-label base tiles and label-only tiles separately,
  cached under cache/osm_tiles/{z}/ and cache/osm_tiles/labels/{z}/
- Rasterize both into the orthographic projection via exact inverse projection
  (new unproject/pixel_to_geo methods on OrthoProjection)
- Render pipeline: basemap → cloud blend → label overlay (labels above clouds)
- Bilinear interpolation for base tiles; premultiplied-alpha bilinear for label
  tiles to prevent dark-fringe artifacts at text edges
- Dynamic zoom selection (floor-based) from actual geographic bounding box
- Fix horizontal squish: derive HALF_W_DEG from pixel aspect ratio so
  degrees-per-pixel is equal on both axes
- Add --no-basemap flag to skip tile fetching for offline/fast use
- Remove hardcoded city markers/labels when tile label overlay is present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Schuwi
2026-03-07 10:52:34 +01:00
parent ac9f9da3c4
commit ff69c1d2db
4 changed files with 524 additions and 35 deletions

View File

@@ -50,6 +50,43 @@ impl OrthoProjection {
Some((x.to_degrees(), y.to_degrees()))
}
/// Inverse of `project`: convert projected (x, y) in degrees-of-arc back to
/// geographic (lat, lon) in degrees.
pub fn unproject(&self, x: f64, y: f64) -> (f64, f64) {
let x_rad = x.to_radians();
let y_rad = y.to_radians();
let rho = (x_rad * x_rad + y_rad * y_rad).sqrt();
if rho < 1e-14 {
// At the projection centre
return (self.phi0.to_degrees(), self.lam0.to_degrees());
}
let c = rho.asin();
let sin_c = c.sin();
let cos_c = c.cos();
let lat = (cos_c * self.phi0.sin() + y_rad * sin_c * self.phi0.cos() / rho).asin();
let lon = self.lam0
+ (x_rad * sin_c).atan2(rho * self.phi0.cos() * cos_c - y_rad * self.phi0.sin() * sin_c);
(lat.to_degrees(), lon.to_degrees())
}
/// Inverse of `to_pixel`: convert pixel (col, row) back to projected (x, y)
/// in degrees-of-arc.
pub fn from_pixel(&self, col: f64, row: f64, width: u32, height: u32) -> (f64, f64) {
let x = col / width as f64 * (2.0 * self.half_width) - self.half_width;
let y = self.half_height - row / height as f64 * (2.0 * self.half_height);
(x, y)
}
/// Convenience: convert pixel (col, row) directly to geographic (lat, lon).
pub fn pixel_to_geo(&self, col: f64, row: f64, width: u32, height: u32) -> (f64, f64) {
let (x, y) = self.from_pixel(col, row, width, height);
self.unproject(x, y)
}
/// Convert projected (x, y) in degrees-of-arc to pixel (col, row).
///
/// Returns `None` if the point falls outside the image boundary.