ridge-core 0.1.0

Ridgeline terrain art pipeline: SRTM fetch, preprocess, geometry, SVG
Documentation
//! CLI renderer — `cargo run -p ridge-core --bin render -- --help`
//!
//! Milestone tool for Phase 2: run the whole pipeline offline and emit an
//! SVG, mirroring upstream README examples.

use ridge_core::colormap::LineColor;
use ridge_core::geometry::{build_scene, ColorKind, DEFAULT_SIZE_SCALE};
use ridge_core::svg::{render_svg, Annotation, LabelStyle, LineColorSpec, PlotStyle, VAlign};
use ridge_core::{srtm, Bbox, DEFAULT_BBOX};

#[derive(Debug, Clone, PartialEq)]
struct Args {
    bbox: Bbox,
    num_lines: usize,
    elevation_pts: usize,
    viewpoint_angle: f64,
    crop: bool,
    interpolation: u32,
    water_ntile: f64,
    lake_flatness: i32,
    vertical_ratio: f64,
    linewidth_pt: f64,
    color: String,
    kind: ColorKind,
    background: String,
    label: String,
    label_color: Option<String>,
    label_x: f64,
    label_y: f64,
    label_size_pt: f64,
    size_scale: f64,
    annotation: Option<(f64, f64, String)>,
    srtm_base: String,
    cache_dir: Option<String>,
    fixture_dir: Option<String>,
    out: String,
}

impl Default for Args {
    fn default() -> Self {
        Args {
            bbox: DEFAULT_BBOX,
            num_lines: 80,
            elevation_pts: 300,
            viewpoint_angle: 0.0,
            crop: false,
            interpolation: 0,
            water_ntile: 10.0,
            lake_flatness: 3,
            vertical_ratio: 40.0,
            linewidth_pt: 2.0,
            color: "black".into(),
            kind: ColorKind::Gradient,
            background: "#ece9ec".into(),
            label: "The White\nMountains".into(),
            label_color: None,
            label_x: 0.62,
            label_y: 0.15,
            label_size_pt: 60.0,
            size_scale: DEFAULT_SIZE_SCALE,
            annotation: None,
            srtm_base: "https://srtm.kurviger.de/SRTM1/,https://srtm.kurviger.de/SRTM3/".into(),
            cache_dir: None,
            fixture_dir: None,
            out: "ridge.svg".into(),
        }
    }
}

fn usage() -> &'static str {
    r#"ridge render — ridgeline terrain art to SVG

USAGE:
  render [OPTIONS]

OPTIONS:
  --bbox "lon0,lat0,lon1,lat1"   Bounding box (default: White Mountains, NH)
  --num-lines N                  Horizontal lines (default 80)
  --elevation-pts N              Points per line (default 300)
  --angle DEG                    Viewpoint angle (default 0)
  --crop                         Crop corners when rotating
  --interpolation 0|1            Rotation interpolation (default 0)
  --water-ntile P                Water percentile cutoff (default 10)
  --lake-flatness N              Flatness cutoff for lakes (default 3)
  --vertical-ratio R             Vertical exaggeration (default 40)
  --linewidth PT                 Line width in points (default 2)
  --color NAME                   Color or colormap: black, orange, #0f0f0f,
                                 viridis, ocean, spring, cool, bone, gnuplot...
  --kind gradient|elevation      Colormap coloring mode (default gradient)
  --background NAME              Background color (default #ece9ec)
  --label TEXT                   Label ("\n" for line breaks; "" for none)
  --label-color NAME             Label color (defaults to line color)
  --label-x F --label-y F        Label position in axes fractions
  --label-size PT                Label font size (default 60)
  --size-scale IN                Figure width in inches (default 20)
  --annotate "lon,lat,TEXT"      Dot + label at a coordinate
  --srtm-base URL                Tile mirror (default kurviger SRTM1)
  --cache-dir DIR                Tile cache (default ~/.cache/ridge-redux/srtm)
  --fixture-dir DIR              Read .hgt tiles from DIR instead (offline)
  --out FILE                     Output SVG (default ridge.svg)
"#
}

fn parse_color(name: &str) -> Result<LineColor, String> {
    LineColor::parse(name).ok_or_else(|| format!("unknown color {name:?}"))
}

impl Args {
    fn parse(mut args: impl Iterator<Item = String>) -> Result<Args, String> {
        let mut a = Args::default();
        while let Some(arg) = args.next() {
            let mut val = || args.next().ok_or(format!("missing value for {arg}"));
            match arg.as_str() {
                "--help" | "-h" => return Err(usage().to_string()),
                "--bbox" => {
                    let v = val()?;
                    let nums: Vec<f64> = v
                        .split(',')
                        .map(|s| s.trim().parse().map_err(|_| format!("bad number {s}")))
                        .collect::<Result<_, _>>()?;
                    if nums.len() != 4 {
                        return Err(format!("--bbox wants 4 numbers, got {}", nums.len()));
                    }
                    a.bbox = Bbox::new(nums[0], nums[1], nums[2], nums[3]);
                }
                "--num-lines" => a.num_lines = val()?.parse().map_err(|e| format!("{e}"))?,
                "--elevation-pts" => {
                    a.elevation_pts = val()?.parse().map_err(|e| format!("{e}"))?
                }
                "--angle" => a.viewpoint_angle = val()?.parse().map_err(|e| format!("{e}"))?,
                "--crop" => a.crop = true,
                "--interpolation" => {
                    let v: u32 = val()?.parse().map_err(|e| format!("{e}"))?;
                    if v > 1 {
                        return Err("only interpolation 0 and 1 are supported".into());
                    }
                    a.interpolation = v;
                }
                "--water-ntile" => a.water_ntile = val()?.parse().map_err(|e| format!("{e}"))?,
                "--lake-flatness" => {
                    a.lake_flatness = val()?.parse().map_err(|e| format!("{e}"))?
                }
                "--vertical-ratio" => {
                    a.vertical_ratio = val()?.parse().map_err(|e| format!("{e}"))?
                }
                "--linewidth" => a.linewidth_pt = val()?.parse().map_err(|e| format!("{e}"))?,
                "--color" => a.color = val()?,
                "--kind" => {
                    a.kind = match val()?.as_str() {
                        "gradient" => ColorKind::Gradient,
                        "elevation" => ColorKind::Elevation,
                        other => return Err(format!("bad --kind {other:?}")),
                    }
                }
                "--background" => a.background = val()?,
                "--label" => a.label = val()?,
                "--label-color" => a.label_color = Some(val()?),
                "--label-x" => a.label_x = val()?.parse().map_err(|e| format!("{e}"))?,
                "--label-y" => a.label_y = val()?.parse().map_err(|e| format!("{e}"))?,
                "--label-size" => a.label_size_pt = val()?.parse().map_err(|e| format!("{e}"))?,
                "--size-scale" => a.size_scale = val()?.parse().map_err(|e| format!("{e}"))?,
                "--annotate" => {
                    let v = val()?;
                    let parts: Vec<&str> = v.splitn(3, ',').collect();
                    if parts.len() != 3 {
                        return Err("--annotate wants lon,lat,text".into());
                    }
                    a.annotation = Some((
                        parts[0].trim().parse().map_err(|e| format!("{e}"))?,
                        parts[1].trim().parse().map_err(|e| format!("{e}"))?,
                        parts[2].to_string(),
                    ));
                }
                "--srtm-base" => a.srtm_base = val()?,
                "--cache-dir" => a.cache_dir = Some(val()?),
                "--fixture-dir" => a.fixture_dir = Some(val()?),
                "--out" => a.out = val()?,
                other => return Err(format!("unknown argument {other:?}\n\n{}", usage())),
            }
        }
        Ok(a)
    }
}

fn main() {
    let args = match Args::parse(std::env::args().skip(1)) {
        Ok(a) => a,
        Err(msg) => {
            let is_help = msg.contains("USAGE") || msg.contains("ridge render");
            eprintln!("{msg}");
            std::process::exit(if is_help { 0 } else { 2 });
        }
    };

    let source: Box<dyn srtm::TileSource> = if let Some(dir) = &args.fixture_dir {
        Box::new(srtm::DirSource::new(dir))
    } else {
        let cache_dir = match args.cache_dir.clone() {
            Some(d) => std::path::PathBuf::from(d),
            None => dirs_default(),
        };
        let bases: Vec<&str> = args.srtm_base.split(',').map(str::trim).collect();
        match srtm::RemoteSource::new(&bases, &cache_dir) {
            Ok(src) => Box::new(src),
            Err(e) => {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        }
    };

    let line_color = match parse_color(&args.color) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("error: {e}");
            std::process::exit(2);
        }
    };
    let label_color = match &args.label_color {
        Some(name) => match parse_color(name) {
            Ok(c) => Some(c),
            Err(e) => {
                eprintln!("error: {e}");
                std::process::exit(2);
            }
        },
        None => None,
    };
    let background = ridge_core::colormap::hex_checked(&args.background).unwrap_or([236, 232, 236]);

    let scene = match build_scene(
        source.as_ref(),
        &args.bbox,
        args.num_lines,
        args.elevation_pts,
        args.viewpoint_angle,
        args.crop,
        args.interpolation,
        false,
        args.water_ntile,
        args.lake_flatness,
        args.vertical_ratio,
        args.size_scale,
    ) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("error: {e}");
            std::process::exit(1);
        }
    };

    let label_color = label_color
        .map(|c| match c {
            LineColor::Solid(rgb) => rgb,
            LineColor::Map(cm) => cm.at(0.0),
        })
        .unwrap_or_else(|| scene.label_color(&line_color));

    let annotation = args.annotation.map(|(lon, lat, text)| {
        let (lon0, lon1) = args.bbox.longs();
        let (lat0, lat1) = args.bbox.lats();
        let x = (lon - lon0) / (lon1 - lon0);
        let y = (lat - lat0) / (lat1 - lat0);
        Annotation {
            label: text,
            x,
            y,
            x_offset: 0.005,
            y_offset: 0.005,
            label_size_pt: 20.0,
            dot_pt: 8.0,
            color: label_color,
            background: false,
        }
    });

    let style = PlotStyle {
        line: LineColorSpec::from(&line_color),
        kind: args.kind,
        background,
        linewidth_pt: args.linewidth_pt,
        size_scale: args.size_scale,
        label: if args.label.is_empty() {
            None
        } else {
            Some(LabelStyle {
                text: args.label.clone(),
                color: label_color,
                x: args.label_x,
                y: args.label_y,
                size_pt: args.label_size_pt,
                vertical_alignment: VAlign::Bottom,
                font_family: "Cinzel".into(),
                background: true,
            })
        },
        annotation,
    };

    let svg = render_svg(&scene, &style);
    if let Err(e) = std::fs::write(&args.out, &svg) {
        eprintln!("error writing {}: {e}", args.out);
        std::process::exit(1);
    }
    println!(
        "wrote {} ({} rows x {} points, {:.1} KiB)",
        args.out,
        scene.rows.len(),
        scene.n_points,
        svg.len() as f64 / 1024.0
    );
}

fn dirs_default() -> std::path::PathBuf {
    std::env::var("XDG_CACHE_HOME")
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|_| {
            std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()))
                .join(".cache")
        })
        .join("ridge-redux")
        .join("srtm")
}