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 rows: Vec<RidgeRow> = processed
123            .outer_iter()
124            .enumerate()
125            .map(|(i, src)| {
126                let baseline = -LINE_SPACING * i as f64;
127                RidgeRow {
128                    baseline,
129                    y: src.iter().map(|&v| v + baseline).collect(),
130                }
131            })
132            .collect();
133
134        let (mut vmin, mut vmax) = processed
135            .iter()
136            .copied()
137            .filter(|v| v.is_finite())
138            .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
139                (lo.min(v), hi.max(v))
140            });
141        if !vmin.is_finite() {
142            vmin = 0.0;
143            vmax = 1.0;
144        }
145
146        // matplotlib autoscale with 5% margins — around the ACTUAL drawn
147        // content, so the scene stays framed at any rotation angle (the
148        // camera operator keeps the subject centered at constant size).
149        let (xmin, xmax_data, ymax_data) = rows
150            .iter()
151            .flat_map(|row| row.y.iter().enumerate())
152            .filter(|&(_, y)| y.is_finite())
153            .fold(
154                (usize::MAX, 0usize, f64::NEG_INFINITY),
155                |(xmin, xmax, ymax), (c, &y)| (xmin.min(c), xmax.max(c), ymax.max(y)),
156            );
157        // Fills reach the baseline, so a row with any data bounds the content
158        // from below.
159        let ymin = rows
160            .iter()
161            .filter(|row| row.y.iter().any(|y| y.is_finite()))
162            .map(|row| row.baseline)
163            .fold(f64::INFINITY, f64::min);
164        let (xmin, xmax_data, ymin, ymax_data) = if xmin == usize::MAX {
165            // No drawable content: fall back to the theoretical frame.
166            (0, ncols - 1, -LINE_SPACING * (nrows - 1) as f64, vmax)
167        } else {
168            (xmin, xmax_data, ymin, ymax_data)
169        };
170        let dx = (xmax_data - xmin) as f64 * AXES_MARGIN;
171        let dy = (ymax_data - ymin) * AXES_MARGIN;
172
173        let width_px = size_scale * FIG_DPI;
174        let height_px = size_scale * bbox_ratio * FIG_DPI;
175        let layout = FigureLayout {
176            width_px,
177            height_px,
178            axes: [
179                SUBPLOT_LEFT * width_px,
180                (1.0 - SUBPLOT_TOP) * height_px,
181                SUBPLOT_RIGHT * width_px,
182                (1.0 - SUBPLOT_BOTTOM) * height_px,
183            ],
184            xlim: [xmin as f64 - dx, xmax_data as f64 + dx],
185            ylim: [ymin - dy, ymax_data + dy],
186        };
187
188        RidgeScene {
189            rows,
190            n_points: ncols,
191            vmin,
192            vmax,
193            layout,
194        }
195    }
196
197    /// Color for line `idx` under `Gradient` mode (upstream `line_color(i/n)`).
198    pub fn gradient_color(&self, line: &LineColor, idx: usize) -> crate::colormap::Rgb {
199        match line {
200            LineColor::Solid(rgb) => *rgb,
201            LineColor::Map(cm) => {
202                let denom = self.rows.len().saturating_sub(1).max(1) as f64;
203                cm.at(idx as f64 / denom)
204            }
205        }
206    }
207
208    /// Color for a point value under `Elevation` mode (upstream norm).
209    pub fn elevation_color(&self, line: &LineColor, value: f64) -> crate::colormap::Rgb {
210        let LineColor::Map(cm) = line else {
211            return [0, 0, 0];
212        };
213        let t = if self.vmax > self.vmin {
214            (value - self.vmin) / (self.vmax - self.vmin)
215        } else {
216            0.0
217        };
218        cm.at(t)
219    }
220
221    /// The upstream default label color: `line_color(0.0)` for colormaps.
222    pub fn label_color(&self, line: &LineColor) -> crate::colormap::Rgb {
223        match line {
224            LineColor::Solid(rgb) => *rgb,
225            LineColor::Map(cm) => cm.at(0.0),
226        }
227    }
228}
229
230/// How the rotated grid fits the canvas.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
232#[serde(rename_all = "lowercase")]
233pub enum Fit {
234    /// Upstream behavior: scipy `reshape=True/False`, zero-filled borders.
235    Reshape,
236    /// Fixed canvas rotating about the center; out-of-plane cells become
237    /// gaps. Used for interactive client-side rotation and WYSIWYG export.
238    Plane,
239}
240
241/// Run the full pipeline: sample -> (optional rotate) -> preprocess -> scene.
242#[allow(clippy::too_many_arguments)]
243pub fn build_scene(
244    source: &dyn crate::srtm::TileSource,
245    bbox: &crate::Bbox,
246    num_lines: usize,
247    elevation_pts: usize,
248    viewpoint_angle: f64,
249    crop: bool,
250    interpolation: u32,
251    lock_resolution: bool,
252    water_ntile: f64,
253    lake_flatness: i32,
254    vertical_ratio: f64,
255    size_scale: f64,
256) -> Result<RidgeScene, Error> {
257    build_scene_fit(
258        source,
259        bbox,
260        num_lines,
261        elevation_pts,
262        viewpoint_angle,
263        crop,
264        interpolation,
265        lock_resolution,
266        Fit::Reshape,
267        water_ntile,
268        lake_flatness,
269        vertical_ratio,
270        size_scale,
271    )
272}
273
274/// As `build_scene`, with an explicit [`Fit`]. `Fit::Plane` also skips the
275/// upstream axis swap (the client rotates a fixed-resolution plane), so the
276/// scene the browser recomputes locally matches the exported SVG exactly.
277#[allow(clippy::too_many_arguments)]
278pub fn build_scene_fit(
279    source: &dyn crate::srtm::TileSource,
280    bbox: &crate::Bbox,
281    num_lines: usize,
282    elevation_pts: usize,
283    viewpoint_angle: f64,
284    crop: bool,
285    interpolation: u32,
286    lock_resolution: bool,
287    fit: Fit,
288    water_ntile: f64,
289    lake_flatness: i32,
290    vertical_ratio: f64,
291    size_scale: f64,
292) -> Result<RidgeScene, Error> {
293    if !bbox.is_valid() {
294        return Err(Error::InvalidBbox(*bbox));
295    }
296    let rotating = viewpoint_angle.rem_euclid(360.0) != 0.0;
297    let mut values = match fit {
298        Fit::Plane => {
299            let _ = lock_resolution;
300            crate::grid::sample(source, bbox, num_lines, elevation_pts)
301        }
302        Fit::Reshape => {
303            let (mut lines, mut pts) = (num_lines, elevation_pts);
304            if !lock_resolution && crate::grid::swap_for_angle(viewpoint_angle) {
305                std::mem::swap(&mut lines, &mut pts);
306            }
307            crate::grid::sample(source, bbox, lines, pts)
308        }
309    };
310    if rotating {
311        values = match fit {
312            Fit::Plane => {
313                crate::rotate::rotate_fixed_plane(&values, viewpoint_angle, interpolation)
314            }
315            Fit::Reshape => crate::rotate::rotate(&values, viewpoint_angle, !crop, interpolation),
316        };
317    }
318    let processed = crate::preprocess::preprocess(
319        &values,
320        water_ntile,
321        lake_flatness,
322        vertical_ratio,
323        1.0,  // reshape is upstream-faithful: naive threshold at grid sampling
324        None, // stats over the whole (rotated) grid
325    )?;
326    Ok(RidgeScene::from_grid(&processed, bbox.ratio(), size_scale))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::srtm::SyntheticSource;
333
334    #[test]
335    fn runs_split_at_gaps() {
336        let row = RidgeRow {
337            baseline: -6.0,
338            y: vec![1.0, f64::NAN, f64::NAN, 2.0, 3.0, f64::NAN, 4.0],
339        };
340        assert_eq!(row.runs(), vec![(0, 1), (3, 5), (6, 7)]);
341    }
342
343    #[test]
344    fn scene_geometry() {
345        let src = SyntheticSource { side: 1201 };
346        let scene = build_scene(
347            &src,
348            &crate::DEFAULT_BBOX,
349            20,
350            30,
351            0.0,
352            false,
353            0,
354            false,
355            10.0,
356            3,
357            40.0,
358            DEFAULT_SIZE_SCALE,
359        )
360        .unwrap();
361        assert_eq!(scene.rows.len(), 20);
362        assert_eq!(scene.n_points, 30);
363        // Row baselines step by -6.
364        assert_eq!(scene.rows[0].baseline, 0.0);
365        assert_eq!(scene.rows[7].baseline, -42.0);
366        // Layout: 20 in x 100 dpi = 2000 px wide.
367        assert_eq!(scene.layout.width_px, 2000.0);
368        assert_eq!(scene.layout.height_px, 2000.0 * crate::DEFAULT_BBOX.ratio());
369    }
370}