Skip to main content

terrand/
contour.rs

1//! Contour line generation using the marching squares algorithm.
2//!
3//! Generates isolines at regular elevation intervals from a DEM. Contour
4//! coordinates are in **grid space** (column, row), where `(0.0, 0.0)` is the
5//! top-left corner of the top-left cell. To convert to geographic coordinates,
6//! apply your raster's affine transform externally.
7//!
8//! # Algorithm
9//!
10//! Each 2x2 cell quad is classified into one of 16 marching-squares cases.
11//! Contour crossings are linearly interpolated along cell edges, then connected
12//! into polylines using an endpoint hash map for O(n) assembly.
13//!
14//! # NaN handling
15//!
16//! NaN cells are treated as nodata holes. They are ignored when determining
17//! the contour elevation range, and any 2x2 quad containing NaN is skipped.
18
19use std::collections::HashMap;
20
21use ndarray::Array2;
22
23use crate::error::{Error, Result};
24
25/// A single contour line at a given elevation.
26#[derive(Clone, Debug)]
27pub struct ContourLine {
28    /// Elevation value of this contour.
29    pub elevation: f64,
30    /// Coordinates as `(col, row)` pairs in grid space.
31    pub coordinates: Vec<(f64, f64)>,
32}
33
34/// Configuration for contour generation.
35#[derive(Clone, Debug)]
36pub struct ContourConfig {
37    /// Spacing between contour lines. Must be positive and finite.
38    pub interval: f64,
39    /// Base elevation for contour alignment. Contour levels are generated at
40    /// multiples of `interval` starting from `base`. Default: 0.0.
41    pub base: f64,
42    /// Optional minimum elevation filter. Contours below this are discarded.
43    pub min_value: Option<f64>,
44    /// Optional maximum elevation filter. Contours above this are discarded.
45    pub max_value: Option<f64>,
46}
47
48impl ContourConfig {
49    /// Create a config with the given interval and default settings.
50    pub fn new(interval: f64) -> Self {
51        Self {
52            interval,
53            base: 0.0,
54            min_value: None,
55            max_value: None,
56        }
57    }
58}
59
60/// Generate contour lines from a DEM.
61///
62/// # Errors
63///
64/// Returns [`Error::InvalidContourInterval`] if `interval` is not positive and
65/// finite.
66///
67/// # Examples
68///
69/// ```
70/// use ndarray::Array2;
71/// use terrand::contour::{contour, ContourConfig};
72///
73/// let dem = ndarray::Array2::from_shape_fn((20, 20), |(r, _)| r as f64 * 10.0);
74/// let lines = contour(&dem, &ContourConfig::new(25.0)).unwrap();
75/// assert!(!lines.is_empty());
76/// ```
77pub fn contour(dem: &Array2<f64>, config: &ContourConfig) -> Result<Vec<ContourLine>> {
78    if !config.interval.is_finite() || config.interval <= 0.0 {
79        return Err(Error::InvalidContourInterval(config.interval));
80    }
81
82    let (h, w) = dem.dim();
83    if h < 2 || w < 2 {
84        return Ok(Vec::new());
85    }
86
87    // Find elevation range.
88    let mut min_elev = f64::INFINITY;
89    let mut max_elev = f64::NEG_INFINITY;
90    for &v in dem.iter() {
91        if v.is_finite() {
92            if v < min_elev {
93                min_elev = v;
94            }
95            if v > max_elev {
96                max_elev = v;
97            }
98        }
99    }
100
101    if !min_elev.is_finite() || !max_elev.is_finite() {
102        return Ok(Vec::new());
103    }
104
105    // Generate contour levels aligned to `base`.
106    let first_level =
107        ((min_elev - config.base) / config.interval).ceil() * config.interval + config.base;
108
109    let mut contours = Vec::new();
110    let mut level = first_level;
111
112    while level <= max_elev {
113        // Apply min/max filters.
114        if let Some(min) = config.min_value {
115            if level < min {
116                level += config.interval;
117                continue;
118            }
119        }
120        if let Some(max) = config.max_value {
121            if level > max {
122                break;
123            }
124        }
125
126        let segments = march_squares(dem, level);
127        let lines = connect_segments(segments);
128
129        for coords in lines {
130            if coords.len() >= 2 {
131                contours.push(ContourLine {
132                    elevation: level,
133                    coordinates: coords,
134                });
135            }
136        }
137
138        level += config.interval;
139    }
140
141    Ok(contours)
142}
143
144// ---------------------------------------------------------------------------
145// Marching squares
146// ---------------------------------------------------------------------------
147
148/// A line segment between two 2D points.
149type Segment = ((f64, f64), (f64, f64));
150
151/// Generate contour segments for a single level using marching squares.
152fn march_squares(grid: &Array2<f64>, level: f64) -> Vec<Segment> {
153    let (h, w) = grid.dim();
154    let mut segments = Vec::new();
155
156    for row in 0..h - 1 {
157        for col in 0..w - 1 {
158            let tl = grid[[row, col]];
159            let tr = grid[[row, col + 1]];
160            let br = grid[[row + 1, col + 1]];
161            let bl = grid[[row + 1, col]];
162
163            if tl.is_nan() || tr.is_nan() || br.is_nan() || bl.is_nan() {
164                continue;
165            }
166
167            // Case index: bit 0 = TL, bit 1 = TR, bit 2 = BR, bit 3 = BL.
168            let mut case = 0u8;
169            if tl >= level {
170                case |= 1;
171            }
172            if tr >= level {
173                case |= 2;
174            }
175            if br >= level {
176                case |= 4;
177            }
178            if bl >= level {
179                case |= 8;
180            }
181
182            if case == 0 || case == 15 {
183                continue;
184            }
185
186            let top = lerp_h(col as f64, tl, (col + 1) as f64, tr, level, row as f64);
187            let right = lerp_v(
188                row as f64,
189                tr,
190                (row + 1) as f64,
191                br,
192                level,
193                (col + 1) as f64,
194            );
195            let bottom = lerp_h(
196                col as f64,
197                bl,
198                (col + 1) as f64,
199                br,
200                level,
201                (row + 1) as f64,
202            );
203            let left = lerp_v(row as f64, tl, (row + 1) as f64, bl, level, col as f64);
204
205            match case {
206                1 | 14 => segments.push((top, left)),
207                2 | 13 => segments.push((top, right)),
208                3 | 12 => segments.push((left, right)),
209                4 | 11 => segments.push((right, bottom)),
210                5 => {
211                    segments.push((top, right));
212                    segments.push((bottom, left));
213                }
214                6 | 9 => segments.push((top, bottom)),
215                7 | 8 => segments.push((left, bottom)),
216                10 => {
217                    segments.push((top, left));
218                    segments.push((right, bottom));
219                }
220                _ => {}
221            }
222        }
223    }
224
225    segments
226}
227
228/// Linearly interpolate along a horizontal cell edge. Returns `(col, row)`.
229#[inline]
230fn lerp_h(col0: f64, val0: f64, col1: f64, val1: f64, level: f64, fixed_row: f64) -> (f64, f64) {
231    let t = interp_t(val0, val1, level);
232    (col0 + t * (col1 - col0), fixed_row)
233}
234
235/// Linearly interpolate along a vertical cell edge. Returns `(col, row)`.
236#[inline]
237fn lerp_v(row0: f64, val0: f64, row1: f64, val1: f64, level: f64, fixed_col: f64) -> (f64, f64) {
238    let t = interp_t(val0, val1, level);
239    (fixed_col, row0 + t * (row1 - row0))
240}
241
242#[inline]
243fn interp_t(val0: f64, val1: f64, level: f64) -> f64 {
244    let denom = val1 - val0;
245    if denom.abs() < f64::EPSILON {
246        0.5
247    } else {
248        ((level - val0) / denom).clamp(0.0, 1.0)
249    }
250}
251
252// ---------------------------------------------------------------------------
253// Segment connection (O(n) via endpoint hash map)
254// ---------------------------------------------------------------------------
255
256/// Quantize a point to an integer key for hash-map-based endpoint matching.
257/// Multiplying by 1e6 gives sub-cell precision while avoiding FP comparison.
258fn point_key(p: (f64, f64)) -> (i64, i64) {
259    ((p.0 * 1e6).round() as i64, (p.1 * 1e6).round() as i64)
260}
261
262/// Connect segments into polylines using an endpoint hash map.
263///
264/// This is O(n) amortized, compared to the naive O(n^2) scan.
265fn connect_segments(segments: Vec<Segment>) -> Vec<Vec<(f64, f64)>> {
266    if segments.is_empty() {
267        return Vec::new();
268    }
269
270    // Map from quantized endpoint -> list of segment indices that have that endpoint.
271    let mut endpoint_map: HashMap<(i64, i64), Vec<usize>> =
272        HashMap::with_capacity(segments.len() * 2);
273
274    for (i, seg) in segments.iter().enumerate() {
275        endpoint_map.entry(point_key(seg.0)).or_default().push(i);
276        endpoint_map.entry(point_key(seg.1)).or_default().push(i);
277    }
278
279    let mut used = vec![false; segments.len()];
280    let mut lines: Vec<Vec<(f64, f64)>> = Vec::new();
281
282    for start_idx in 0..segments.len() {
283        if used[start_idx] {
284            continue;
285        }
286        used[start_idx] = true;
287
288        let mut line = vec![segments[start_idx].0, segments[start_idx].1];
289
290        // Extend forward from the tail.
291        loop {
292            let end = *line.last().unwrap();
293            let key = point_key(end);
294            let mut found = false;
295
296            if let Some(indices) = endpoint_map.get(&key) {
297                for &j in indices {
298                    if used[j] {
299                        continue;
300                    }
301                    let seg = &segments[j];
302                    if point_key(seg.0) == key {
303                        line.push(seg.1);
304                        used[j] = true;
305                        found = true;
306                        break;
307                    } else if point_key(seg.1) == key {
308                        line.push(seg.0);
309                        used[j] = true;
310                        found = true;
311                        break;
312                    }
313                }
314            }
315
316            if !found {
317                break;
318            }
319        }
320
321        lines.push(line);
322    }
323
324    lines
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn gradient_produces_contours() {
333        let dem = Array2::from_shape_fn((10, 10), |(r, _)| r as f64 * 10.0);
334        let lines = contour(&dem, &ContourConfig::new(20.0)).unwrap();
335        assert!(!lines.is_empty(), "gradient should produce contour lines");
336        let levels: Vec<f64> = lines.iter().map(|l| l.elevation).collect();
337        assert!(
338            levels.contains(&20.0),
339            "should have contour at 20, got {levels:?}"
340        );
341        assert!(
342            levels.contains(&40.0),
343            "should have contour at 40, got {levels:?}"
344        );
345    }
346
347    #[test]
348    fn flat_dem_no_contours() {
349        let dem = Array2::from_elem((10, 10), 100.0);
350        let lines = contour(&dem, &ContourConfig::new(10.0)).unwrap();
351        assert!(
352            lines.len() <= 1,
353            "flat DEM should produce 0 or 1 contour lines"
354        );
355    }
356
357    #[test]
358    fn invalid_interval_errors() {
359        let dem = Array2::from_elem((5, 5), 10.0);
360        assert!(contour(&dem, &ContourConfig::new(0.0)).is_err());
361        assert!(contour(&dem, &ContourConfig::new(-5.0)).is_err());
362        assert!(contour(&dem, &ContourConfig::new(f64::NAN)).is_err());
363    }
364
365    #[test]
366    fn small_grid_empty() {
367        let dem = Array2::from_elem((1, 1), 100.0);
368        let lines = contour(&dem, &ContourConfig::new(10.0)).unwrap();
369        assert!(lines.is_empty());
370    }
371
372    #[test]
373    fn min_max_filter() {
374        let dem = Array2::from_shape_fn((10, 10), |(r, _)| r as f64 * 10.0);
375        let config = ContourConfig {
376            interval: 10.0,
377            base: 0.0,
378            min_value: Some(25.0),
379            max_value: Some(55.0),
380        };
381        let lines = contour(&dem, &config).unwrap();
382        for line in &lines {
383            assert!(line.elevation >= 25.0);
384            assert!(line.elevation <= 55.0);
385        }
386    }
387
388    #[test]
389    fn contours_have_coordinates() {
390        let dem = Array2::from_shape_fn((5, 5), |(r, _)| r as f64 * 25.0);
391        let lines = contour(&dem, &ContourConfig::new(25.0)).unwrap();
392        for l in &lines {
393            assert!(
394                l.coordinates.len() >= 2,
395                "contour should have >= 2 points, got {}",
396                l.coordinates.len()
397            );
398        }
399    }
400
401    #[test]
402    fn base_alignment() {
403        // With base = 5 and interval = 10, levels should be 5, 15, 25, ...
404        let dem = Array2::from_shape_fn((10, 10), |(r, _)| r as f64 * 10.0);
405        let config = ContourConfig {
406            interval: 10.0,
407            base: 5.0,
408            min_value: None,
409            max_value: None,
410        };
411        let lines = contour(&dem, &config).unwrap();
412        for l in &lines {
413            let offset = (l.elevation - 5.0) % 10.0;
414            assert!(
415                offset.abs() < 1e-6 || (offset - 10.0).abs() < 1e-6,
416                "contour at {} not aligned to base 5 + interval 10",
417                l.elevation
418            );
419        }
420    }
421}