Skip to main content

ridge_core/
geometry.rs

1//! Turn a processed elevation grid into drawable ridge lines.
2//!
3//! Upstream `plot_map` draws row `i` at `y = row - 6*i` with a
4//! background-colored fill from the baseline up to the line (the occlusion
5//! trick that makes front ridges hide back ridges). `RidgeScene` captures
6//! that geometry, plus the matplotlib figure layout (figure size, axes rect,
7//! data limits) so the SVG exporter and the web canvas render identically.
8
9use ndarray::Array2;
10
11use crate::colormap::LineColor;
12use crate::Error;
13
14/// Upstream: `y_base = -6 * idx * np.ones_like(row)`.
15pub const LINE_SPACING: f64 = 6.0;
16/// matplotlib default dpi used for px math (figsize is in inches).
17pub const FIG_DPI: f64 = 100.0;
18/// matplotlib default `figure.figsize` used by upstream (`size_scale=20`).
19pub const DEFAULT_SIZE_SCALE: f64 = 20.0;
20/// matplotlib default `subplot_params` (fractions of the figure).
21pub const SUBPLOT_LEFT: f64 = 0.125;
22pub const SUBPLOT_RIGHT: f64 = 0.9;
23pub const SUBPLOT_BOTTOM: f64 = 0.11;
24pub const SUBPLOT_TOP: f64 = 0.88;
25/// matplotlib default axes margins (5% padding each side of the data).
26pub const AXES_MARGIN: f64 = 0.05;
27
28/// Where the axes rect sits and how data maps into it.
29#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
30pub struct FigureLayout {
31    /// Figure size in px (`size_scale` inches at `FIG_DPI`).
32    pub width_px: f64,
33    pub height_px: f64,
34    /// Axes rect in px from the top-left: `[x0, y0, x1, y1]`.
35    pub axes: [f64; 4],
36    /// Data window shown in the axes (includes matplotlib's 5% margins).
37    pub xlim: [f64; 2],
38    pub ylim: [f64; 2],
39}
40
41impl FigureLayout {
42    /// Map scene data coords to figure px (y flipped, SVG convention).
43    pub fn to_px(&self, x: f64, y: f64) -> (f64, f64) {
44        let fx = (x - self.xlim[0]) / (self.xlim[1] - self.xlim[0]);
45        let fy = (y - self.ylim[0]) / (self.ylim[1] - self.ylim[0]);
46        let ax_x0 = self.axes[0];
47        let ax_x1 = self.axes[2];
48        let ax_y0 = self.axes[1]; // top
49        let ax_y1 = self.axes[3]; // bottom
50        (
51            ax_x0 + fx * (ax_x1 - ax_x0),
52            ax_y0 + (1.0 - fy) * (ax_y1 - ax_y0),
53        )
54    }
55
56    /// Axes-fraction coords (upstream `transform=ax.transAxes`) to figure px.
57    pub fn frac_to_px(&self, fx: f64, fy: f64) -> (f64, f64) {
58        let x = self.axes[0] + fx * (self.axes[2] - self.axes[0]);
59        let y = self.axes[1] + (1.0 - fy) * (self.axes[3] - self.axes[1]);
60        (x, y)
61    }
62}
63
64/// One ridge line: its baseline and y-values (`NaN` = gap, no line drawn).
65#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
66pub struct RidgeRow {
67    pub baseline: f64,
68    /// y = elevation * vertical_ratio + baseline; `NaN` marks water/gaps.
69    pub y: Vec<f64>,
70}
71
72impl RidgeRow {
73    /// Contiguous (start, end) runs of finite values, for gap-aware drawing.
74    pub fn runs(&self) -> Vec<(usize, usize)> {
75        let mut runs = Vec::new();
76        let mut start: Option<usize> = None;
77        for (i, v) in self.y.iter().enumerate() {
78            if v.is_finite() {
79                if start.is_none() {
80                    start = Some(i);
81                }
82            } else if let Some(s) = start.take() {
83                if i > s {
84                    runs.push((s, i)); // exclusive end
85                }
86            }
87        }
88        if let Some(s) = start {
89            if self.y.len() > s {
90                runs.push((s, self.y.len()));
91            }
92        }
93        runs
94    }
95}
96
97/// What drives per-line color.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
99#[serde(rename_all = "lowercase")]
100pub enum ColorKind {
101    /// Color by line index (upstream `kind="gradient"`).
102    Gradient,
103    /// Color by actual elevation along the line (upstream `kind="elevation"`).
104    Elevation,
105}
106
107#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
108pub struct RidgeScene {
109    pub rows: Vec<RidgeRow>,
110    pub n_points: usize,
111    /// Range of the (scaled) processed elevations, for `Elevation` coloring.
112    pub vmin: f64,
113    pub vmax: f64,
114    pub layout: FigureLayout,
115}
116
117impl RidgeScene {
118    /// Build a scene from a preprocessed grid (already masked, flipped and
119    /// vertically scaled by `preprocess::preprocess`).
120    pub fn from_grid(processed: &Array2<f64>, bbox_ratio: f64, size_scale: f64) -> RidgeScene {
121        let (nrows, ncols) = processed.dim();
122        let mut rows = Vec::with_capacity(nrows);
123        let mut vmin = f64::INFINITY;
124        let mut vmax = f64::NEG_INFINITY;
125        for i in 0..nrows {
126            let baseline = -LINE_SPACING * i as f64;
127            let mut y = Vec::with_capacity(ncols);
128            for c in 0..ncols {
129                let v = processed[(i, c)];
130                if v.is_finite() {
131                    vmin = vmin.min(v);
132                    vmax = vmax.max(v);
133                }
134                y.push(v + baseline);
135            }
136            rows.push(RidgeRow { baseline, y });
137        }
138        if !vmin.is_finite() {
139            vmin = 0.0;
140            vmax = 1.0;
141        }
142
143        // matplotlib autoscale with 5% margins — around the ACTUAL drawn
144        // content, so the scene stays framed at any rotation angle (the
145        // camera operator keeps the subject centered at constant size).
146        let mut xmin = usize::MAX;
147        let mut xmax_data = 0usize;
148        let mut ymin = f64::INFINITY;
149        let mut ymax_data = f64::NEG_INFINITY;
150        for row in &rows {
151            let mut has_data = false;
152            for (c, &y) in row.y.iter().enumerate() {
153                if y.is_finite() {
154                    has_data = true;
155                    if y > ymax_data {
156                        ymax_data = y;
157                    }
158                    if c < xmin {
159                        xmin = c;
160                    }
161                    if c > xmax_data {
162                        xmax_data = c;
163                    }
164                }
165            }
166            // Fills reach the baseline, so it bounds the content from below.
167            if has_data && row.baseline < ymin {
168                ymin = row.baseline;
169            }
170        }
171        let (xmin, xmax_data, ymin, ymax_data) = if xmin == usize::MAX {
172            // No drawable content: fall back to the theoretical frame.
173            (0, ncols - 1, -LINE_SPACING * (nrows - 1) as f64, vmax)
174        } else {
175            (xmin, xmax_data, ymin, ymax_data)
176        };
177        let dx = (xmax_data - xmin) as f64 * AXES_MARGIN;
178        let dy = (ymax_data - ymin) * AXES_MARGIN;
179
180        let width_px = size_scale * FIG_DPI;
181        let height_px = size_scale * bbox_ratio * FIG_DPI;
182        let layout = FigureLayout {
183            width_px,
184            height_px,
185            axes: [
186                SUBPLOT_LEFT * width_px,
187                (1.0 - SUBPLOT_TOP) * height_px,
188                SUBPLOT_RIGHT * width_px,
189                (1.0 - SUBPLOT_BOTTOM) * height_px,
190            ],
191            xlim: [xmin as f64 - dx, xmax_data as f64 + dx],
192            ylim: [ymin - dy, ymax_data + dy],
193        };
194
195        RidgeScene {
196            rows,
197            n_points: ncols,
198            vmin,
199            vmax,
200            layout,
201        }
202    }
203
204    /// Color for line `idx` under `Gradient` mode (upstream `line_color(i/n)`).
205    pub fn gradient_color(&self, line: &LineColor, idx: usize) -> crate::colormap::Rgb {
206        match line {
207            LineColor::Solid(rgb) => *rgb,
208            LineColor::Map(cm) => {
209                let denom = self.rows.len().saturating_sub(1).max(1) as f64;
210                cm.at(idx as f64 / denom)
211            }
212        }
213    }
214
215    /// Color for a point value under `Elevation` mode (upstream norm).
216    pub fn elevation_color(&self, line: &LineColor, value: f64) -> crate::colormap::Rgb {
217        let LineColor::Map(cm) = line else {
218            return [0, 0, 0];
219        };
220        let t = if self.vmax > self.vmin {
221            (value - self.vmin) / (self.vmax - self.vmin)
222        } else {
223            0.0
224        };
225        cm.at(t)
226    }
227
228    /// The upstream default label color: `line_color(0.0)` for colormaps.
229    pub fn label_color(&self, line: &LineColor) -> crate::colormap::Rgb {
230        match line {
231            LineColor::Solid(rgb) => *rgb,
232            LineColor::Map(cm) => cm.at(0.0),
233        }
234    }
235}
236
237/// How the rotated grid fits the canvas.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239#[serde(rename_all = "lowercase")]
240pub enum Fit {
241    /// Upstream behavior: scipy `reshape=True/False`, zero-filled borders.
242    Reshape,
243    /// Fixed canvas rotating about the center; out-of-plane cells become
244    /// gaps. Used for interactive client-side rotation and WYSIWYG export.
245    Plane,
246}
247
248/// Run the full pipeline: sample -> (optional rotate) -> preprocess -> scene.
249#[allow(clippy::too_many_arguments)]
250pub fn build_scene(
251    source: &dyn crate::srtm::TileSource,
252    bbox: &crate::Bbox,
253    num_lines: usize,
254    elevation_pts: usize,
255    viewpoint_angle: f64,
256    crop: bool,
257    interpolation: u32,
258    lock_resolution: bool,
259    water_ntile: f64,
260    lake_flatness: i32,
261    vertical_ratio: f64,
262    size_scale: f64,
263) -> Result<RidgeScene, Error> {
264    build_scene_fit(
265        source,
266        bbox,
267        num_lines,
268        elevation_pts,
269        viewpoint_angle,
270        crop,
271        interpolation,
272        lock_resolution,
273        Fit::Reshape,
274        water_ntile,
275        lake_flatness,
276        vertical_ratio,
277        size_scale,
278    )
279}
280
281/// As `build_scene`, with an explicit [`Fit`]. `Fit::Plane` also skips the
282/// upstream axis swap (the client rotates a fixed-resolution plane), so the
283/// scene the browser recomputes locally matches the exported SVG exactly.
284#[allow(clippy::too_many_arguments)]
285pub fn build_scene_fit(
286    source: &dyn crate::srtm::TileSource,
287    bbox: &crate::Bbox,
288    num_lines: usize,
289    elevation_pts: usize,
290    viewpoint_angle: f64,
291    crop: bool,
292    interpolation: u32,
293    lock_resolution: bool,
294    fit: Fit,
295    water_ntile: f64,
296    lake_flatness: i32,
297    vertical_ratio: f64,
298    size_scale: f64,
299) -> Result<RidgeScene, Error> {
300    if !bbox.is_valid() {
301        return Err(Error::InvalidBbox(*bbox));
302    }
303    let rotating = viewpoint_angle.rem_euclid(360.0) != 0.0;
304    let mut values = match fit {
305        Fit::Plane => {
306            let _ = lock_resolution;
307            crate::grid::sample(source, bbox, num_lines, elevation_pts)
308        }
309        Fit::Reshape => {
310            let (mut lines, mut pts) = (num_lines, elevation_pts);
311            if !lock_resolution && crate::grid::swap_for_angle(viewpoint_angle) {
312                std::mem::swap(&mut lines, &mut pts);
313            }
314            crate::grid::sample(source, bbox, lines, pts)
315        }
316    };
317    if rotating {
318        values = match fit {
319            Fit::Plane => {
320                crate::rotate::rotate_fixed_plane(&values, viewpoint_angle, interpolation)
321            }
322            Fit::Reshape => crate::rotate::rotate(&values, viewpoint_angle, !crop, interpolation),
323        };
324    }
325    let processed = crate::preprocess::preprocess(
326        &values,
327        water_ntile,
328        lake_flatness,
329        vertical_ratio,
330        1.0,  // reshape is upstream-faithful: naive threshold at grid sampling
331        None, // stats over the whole (rotated) grid
332    )?;
333    Ok(RidgeScene::from_grid(&processed, bbox.ratio(), size_scale))
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::srtm::SyntheticSource;
340
341    #[test]
342    fn runs_split_at_gaps() {
343        let row = RidgeRow {
344            baseline: -6.0,
345            y: vec![1.0, f64::NAN, f64::NAN, 2.0, 3.0, f64::NAN, 4.0],
346        };
347        assert_eq!(row.runs(), vec![(0, 1), (3, 5), (6, 7)]);
348    }
349
350    #[test]
351    fn scene_geometry() {
352        let src = SyntheticSource { side: 1201 };
353        let scene = build_scene(
354            &src,
355            &crate::DEFAULT_BBOX,
356            20,
357            30,
358            0.0,
359            false,
360            0,
361            false,
362            10.0,
363            3,
364            40.0,
365            DEFAULT_SIZE_SCALE,
366        )
367        .unwrap();
368        assert_eq!(scene.rows.len(), 20);
369        assert_eq!(scene.n_points, 30);
370        // Row baselines step by -6.
371        assert_eq!(scene.rows[0].baseline, 0.0);
372        assert_eq!(scene.rows[7].baseline, -42.0);
373        // Layout: 20 in x 100 dpi = 2000 px wide.
374        assert_eq!(scene.layout.width_px, 2000.0);
375        assert_eq!(scene.layout.height_px, 2000.0 * crate::DEFAULT_BBOX.ratio());
376    }
377}