Skip to main content

oxigdal_algorithms/raster/
contour.rs

1//! Contour line generation using the marching squares algorithm
2//!
3//! Extracts isolines (contour lines) from a 2D elevation grid at specified
4//! intervals. The marching squares algorithm classifies each 2x2 cell by
5//! comparing corner values to the contour level, then interpolates edge
6//! crossings and chains them into polylines.
7
8use crate::error::{AlgorithmError, Result};
9use std::collections::BTreeMap;
10
11/// A point on a contour line
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct ContourPoint {
14    pub x: f64,
15    pub y: f64,
16}
17
18impl ContourPoint {
19    #[must_use]
20    pub const fn new(x: f64, y: f64) -> Self {
21        Self { x, y }
22    }
23}
24
25/// A single contour line at a specific elevation level
26#[derive(Debug, Clone)]
27pub struct ContourLine {
28    pub level: f64,
29    pub points: Vec<ContourPoint>,
30    pub is_closed: bool,
31}
32
33/// Configuration for contour generation
34#[derive(Debug, Clone)]
35pub struct ContourConfig {
36    /// Contour interval (must be > 0)
37    pub interval: f64,
38    /// Base level (default 0.0)
39    pub base: f64,
40    /// Nodata value to skip
41    pub nodata: Option<f64>,
42}
43
44impl ContourConfig {
45    /// Create a new contour configuration with the given interval.
46    ///
47    /// # Errors
48    /// Returns an error if `interval` is not positive.
49    pub fn new(interval: f64) -> Result<Self> {
50        if interval <= 0.0 || !interval.is_finite() {
51            return Err(AlgorithmError::InvalidParameter {
52                parameter: "interval",
53                message: format!("interval must be positive, got {interval}"),
54            });
55        }
56        Ok(Self {
57            interval,
58            base: 0.0,
59            nodata: None,
60        })
61    }
62
63    #[must_use]
64    pub fn with_base(mut self, base: f64) -> Self {
65        self.base = base;
66        self
67    }
68
69    #[must_use]
70    pub fn with_nodata(mut self, nodata: f64) -> Self {
71        self.nodata = Some(nodata);
72        self
73    }
74
75    /// Compute the contour levels that span [min_val, max_val].
76    fn compute_levels(&self, min_val: f64, max_val: f64) -> Vec<f64> {
77        if min_val > max_val || !min_val.is_finite() || !max_val.is_finite() {
78            return vec![];
79        }
80
81        let start = ((min_val - self.base) / self.interval).ceil();
82        let end = ((max_val - self.base) / self.interval).floor();
83
84        let start_i = start as i64;
85        let end_i = end as i64;
86
87        let mut levels = Vec::new();
88        for i in start_i..=end_i {
89            let level = self.base + (i as f64) * self.interval;
90            if level > min_val && level < max_val {
91                levels.push(level);
92            }
93        }
94        levels
95    }
96}
97
98/// A segment produced by marching squares for one cell.
99#[derive(Debug, Clone, Copy)]
100struct Segment {
101    p0: ContourPoint,
102    p1: ContourPoint,
103}
104
105/// Linearly interpolate between two values to find where `level` falls.
106#[inline]
107fn lerp_frac(v0: f64, v1: f64, level: f64) -> f64 {
108    let denom = v1 - v0;
109    if denom.abs() < 1e-15 {
110        0.5
111    } else {
112        (level - v0) / denom
113    }
114}
115
116/// Generate segments for a single cell using marching squares.
117///
118/// Corners ordered:
119///   tl(0) --- tr(1)
120///    |          |
121///   bl(2) --- br(3)
122///
123/// Bit assignment: tl=bit3, tr=bit2, bl=bit1, br=bit0
124fn cell_segments(
125    col: usize,
126    row: usize,
127    tl: f64,
128    tr: f64,
129    bl: f64,
130    br: f64,
131    level: f64,
132) -> Vec<Segment> {
133    let case = ((tl >= level) as u8) << 3
134        | ((tr >= level) as u8) << 2
135        | ((bl >= level) as u8) << 1
136        | (br >= level) as u8;
137
138    // Edge midpoints via linear interpolation
139    // top edge: tl -> tr
140    let top = || {
141        let t = lerp_frac(tl, tr, level);
142        ContourPoint::new(col as f64 + t, row as f64)
143    };
144    // bottom edge: bl -> br
145    let bottom = || {
146        let t = lerp_frac(bl, br, level);
147        ContourPoint::new(col as f64 + t, row as f64 + 1.0)
148    };
149    // left edge: tl -> bl
150    let left = || {
151        let t = lerp_frac(tl, bl, level);
152        ContourPoint::new(col as f64, row as f64 + t)
153    };
154    // right edge: tr -> br
155    let right = || {
156        let t = lerp_frac(tr, br, level);
157        ContourPoint::new(col as f64 + 1.0, row as f64 + t)
158    };
159
160    match case {
161        0 | 15 => vec![], // all below or all above
162        1 => vec![Segment {
163            p0: bottom(),
164            p1: right(),
165        }],
166        2 => vec![Segment {
167            p0: left(),
168            p1: bottom(),
169        }],
170        3 => vec![Segment {
171            p0: left(),
172            p1: right(),
173        }],
174        4 => vec![Segment {
175            p0: top(),
176            p1: right(),
177        }],
178        5 => {
179            // Saddle point: disambiguate using center value
180            let center = (tl + tr + bl + br) * 0.25;
181            if center >= level {
182                vec![
183                    Segment {
184                        p0: top(),
185                        p1: right(),
186                    },
187                    Segment {
188                        p0: left(),
189                        p1: bottom(),
190                    },
191                ]
192            } else {
193                vec![
194                    Segment {
195                        p0: top(),
196                        p1: left(),
197                    },
198                    Segment {
199                        p0: bottom(),
200                        p1: right(),
201                    },
202                ]
203            }
204        }
205        6 => vec![Segment {
206            p0: top(),
207            p1: bottom(),
208        }],
209        7 => vec![Segment {
210            p0: top(),
211            p1: left(),
212        }],
213        8 => vec![Segment {
214            p0: top(),
215            p1: left(),
216        }],
217        9 => vec![Segment {
218            p0: top(),
219            p1: bottom(),
220        }],
221        10 => {
222            // Saddle point: disambiguate using center value
223            let center = (tl + tr + bl + br) * 0.25;
224            if center >= level {
225                vec![
226                    Segment {
227                        p0: top(),
228                        p1: left(),
229                    },
230                    Segment {
231                        p0: bottom(),
232                        p1: right(),
233                    },
234                ]
235            } else {
236                vec![
237                    Segment {
238                        p0: top(),
239                        p1: right(),
240                    },
241                    Segment {
242                        p0: left(),
243                        p1: bottom(),
244                    },
245                ]
246            }
247        }
248        11 => vec![Segment {
249            p0: top(),
250            p1: right(),
251        }],
252        12 => vec![Segment {
253            p0: left(),
254            p1: right(),
255        }],
256        13 => vec![Segment {
257            p0: left(),
258            p1: bottom(),
259        }],
260        14 => vec![Segment {
261            p0: bottom(),
262            p1: right(),
263        }],
264        _ => vec![], // unreachable for 4-bit
265    }
266}
267
268/// Check if a value is considered nodata.
269#[inline]
270fn is_nodata(val: f64, nodata: Option<f64>) -> bool {
271    if !val.is_finite() {
272        return true;
273    }
274    if let Some(nd) = nodata {
275        (val - nd).abs() < 1e-10
276    } else {
277        false
278    }
279}
280
281/// Chain segments into polylines by matching endpoints.
282fn chain_segments(segments: &[Segment]) -> Vec<(Vec<ContourPoint>, bool)> {
283    if segments.is_empty() {
284        return vec![];
285    }
286
287    // We use a simple approach: maintain a list of chains, try to attach each
288    // segment to an existing chain by matching endpoints.
289    let eps = 1e-9;
290
291    let points_eq = |a: &ContourPoint, b: &ContourPoint| -> bool {
292        (a.x - b.x).abs() < eps && (a.y - b.y).abs() < eps
293    };
294
295    let mut chains: Vec<Vec<ContourPoint>> = Vec::new();
296
297    for seg in segments {
298        let mut attached = false;
299
300        for chain in chains.iter_mut() {
301            let first = chain[0];
302            let last = chain[chain.len() - 1];
303
304            if points_eq(&last, &seg.p0) {
305                chain.push(seg.p1);
306                attached = true;
307                break;
308            } else if points_eq(&last, &seg.p1) {
309                chain.push(seg.p0);
310                attached = true;
311                break;
312            } else if points_eq(&first, &seg.p1) {
313                chain.insert(0, seg.p0);
314                attached = true;
315                break;
316            } else if points_eq(&first, &seg.p0) {
317                chain.insert(0, seg.p1);
318                attached = true;
319                break;
320            }
321        }
322
323        if !attached {
324            chains.push(vec![seg.p0, seg.p1]);
325        }
326    }
327
328    // Merge chains that can be connected
329    let mut merged = true;
330    while merged {
331        merged = false;
332        let mut i = 0;
333        while i < chains.len() {
334            let mut j = i + 1;
335            while j < chains.len() {
336                let i_first = chains[i][0];
337                let i_last = chains[i][chains[i].len() - 1];
338                let j_first = chains[j][0];
339                let j_last = chains[j][chains[j].len() - 1];
340
341                if points_eq(&i_last, &j_first) {
342                    let mut taken = chains.remove(j);
343                    taken.remove(0); // avoid duplicate point
344                    chains[i].append(&mut taken);
345                    merged = true;
346                    continue; // don't increment j, re-check
347                } else if points_eq(&i_first, &j_last) {
348                    let mut taken = chains.remove(j);
349                    taken.pop(); // avoid duplicate point
350                    taken.append(&mut chains[i]);
351                    chains[i] = taken;
352                    merged = true;
353                    continue;
354                } else if points_eq(&i_last, &j_last) {
355                    let mut taken = chains.remove(j);
356                    taken.reverse();
357                    taken.remove(0);
358                    chains[i].append(&mut taken);
359                    merged = true;
360                    continue;
361                } else if points_eq(&i_first, &j_first) {
362                    let mut taken = chains.remove(j);
363                    taken.reverse();
364                    taken.pop();
365                    taken.append(&mut chains[i]);
366                    chains[i] = taken;
367                    merged = true;
368                    continue;
369                }
370                j += 1;
371            }
372            i += 1;
373        }
374    }
375
376    chains
377        .into_iter()
378        .map(|pts: Vec<ContourPoint>| {
379            let closed = pts.len() >= 3 && points_eq(&pts[0], &pts[pts.len() - 1]);
380            (pts, closed)
381        })
382        .collect()
383}
384
385/// Generate contour lines from a 2D grid using marching squares.
386///
387/// # Arguments
388/// * `data` - Row-major 2D grid of elevation values (width * height)
389/// * `width` - Grid width
390/// * `height` - Grid height
391/// * `config` - Contour generation configuration
392///
393/// # Errors
394/// Returns an error if `data.len() != width * height`.
395///
396/// # Returns
397/// A `Vec<ContourLine>` sorted by level.
398pub fn generate_contours(
399    data: &[f64],
400    width: usize,
401    height: usize,
402    config: &ContourConfig,
403) -> Result<Vec<ContourLine>> {
404    // Empty grids produce no contours
405    if width == 0 || height == 0 {
406        return Ok(vec![]);
407    }
408
409    if data.len() != width * height {
410        return Err(AlgorithmError::InvalidDimensions {
411            message: "data length must equal width * height",
412            actual: data.len(),
413            expected: width * height,
414        });
415    }
416
417    // Need at least a 2x2 grid for marching squares
418    if width < 2 || height < 2 {
419        return Ok(vec![]);
420    }
421
422    // Find min/max, skipping nodata
423    let mut min_val = f64::INFINITY;
424    let mut max_val = f64::NEG_INFINITY;
425    for &v in data {
426        if is_nodata(v, config.nodata) {
427            continue;
428        }
429        if v < min_val {
430            min_val = v;
431        }
432        if v > max_val {
433            max_val = v;
434        }
435    }
436
437    if !min_val.is_finite() || !max_val.is_finite() {
438        return Ok(vec![]);
439    }
440
441    let levels = config.compute_levels(min_val, max_val);
442    if levels.is_empty() {
443        return Ok(vec![]);
444    }
445
446    // Group results by level for sorted output
447    let mut result: BTreeMap<i64, Vec<Segment>> = BTreeMap::new();
448
449    // For each level, run marching squares over every cell
450    for &level in &levels {
451        // Use a stable integer key for BTreeMap ordering
452        let key = (level * 1e10) as i64;
453        let segments = result.entry(key).or_default();
454
455        for row in 0..height - 1 {
456            for col in 0..width - 1 {
457                let tl = data[row * width + col];
458                let tr = data[row * width + col + 1];
459                let bl = data[(row + 1) * width + col];
460                let br = data[(row + 1) * width + col + 1];
461
462                // Skip cell if any corner is nodata
463                if is_nodata(tl, config.nodata)
464                    || is_nodata(tr, config.nodata)
465                    || is_nodata(bl, config.nodata)
466                    || is_nodata(br, config.nodata)
467                {
468                    continue;
469                }
470
471                let cell_segs = cell_segments(col, row, tl, tr, bl, br, level);
472                segments.extend_from_slice(&cell_segs);
473            }
474        }
475    }
476
477    // Chain segments into polylines per level and collect results
478    let mut contour_lines: Vec<ContourLine> = Vec::new();
479
480    for level in levels.iter() {
481        let key = (*level * 1e10) as i64;
482        if let Some(segments) = result.get(&key) {
483            let chains: Vec<(Vec<ContourPoint>, bool)> = chain_segments(segments);
484            for (points, is_closed) in chains {
485                if points.len() >= 2 {
486                    contour_lines.push(ContourLine {
487                        level: *level,
488                        points,
489                        is_closed,
490                    });
491                }
492            }
493        }
494    }
495
496    Ok(contour_lines)
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_contour_config_valid() {
505        let config = ContourConfig::new(10.0);
506        assert!(config.is_ok());
507        let config = config.expect("should be ok");
508        assert!((config.interval - 10.0).abs() < f64::EPSILON);
509        assert!((config.base - 0.0).abs() < f64::EPSILON);
510        assert!(config.nodata.is_none());
511    }
512
513    #[test]
514    fn test_contour_config_zero_interval_error() {
515        let result = ContourConfig::new(0.0);
516        assert!(result.is_err());
517    }
518
519    #[test]
520    fn test_contour_config_negative_interval_error() {
521        let result = ContourConfig::new(-5.0);
522        assert!(result.is_err());
523    }
524
525    #[test]
526    fn test_compute_levels() {
527        let config = ContourConfig::new(10.0).expect("valid config");
528        let levels = config.compute_levels(5.0, 35.0);
529        assert_eq!(levels, vec![10.0, 20.0, 30.0]);
530    }
531
532    #[test]
533    fn test_contour_flat_grid() {
534        let data = vec![100.0; 16];
535        let config = ContourConfig::new(10.0).expect("valid config");
536        let contours = generate_contours(&data, 4, 4, &config).expect("should succeed");
537        assert!(contours.is_empty());
538    }
539
540    #[test]
541    fn test_contour_simple_slope() {
542        // 4x4 grid with linear ramp from 0 to 30
543        // Row 0: 0  10  20  30
544        // Row 1: 0  10  20  30
545        // Row 2: 0  10  20  30
546        // Row 3: 0  10  20  30
547        let data = vec![
548            0.0, 10.0, 20.0, 30.0, 0.0, 10.0, 20.0, 30.0, 0.0, 10.0, 20.0, 30.0, 0.0, 10.0, 20.0,
549            30.0,
550        ];
551        let config = ContourConfig::new(15.0).expect("valid config");
552        let contours = generate_contours(&data, 4, 4, &config).expect("should succeed");
553
554        // Should have contour at level 15.0 (between 0-30, strictly inside)
555        assert!(!contours.is_empty());
556        let levels: Vec<f64> = contours.iter().map(|c| c.level).collect();
557        assert!(levels.contains(&15.0));
558
559        // Each contour line should have at least 2 points
560        for c in &contours {
561            assert!(c.points.len() >= 2);
562        }
563    }
564
565    #[test]
566    fn test_contour_single_level() {
567        // 3x3 grid:
568        //  0   5  10
569        //  0   5  10
570        //  0   5  10
571        let data = vec![0.0, 5.0, 10.0, 0.0, 5.0, 10.0, 0.0, 5.0, 10.0];
572        let config = ContourConfig::new(3.0).expect("valid config");
573        let contours = generate_contours(&data, 3, 3, &config).expect("should succeed");
574
575        // Should produce contours at 3, 6, 9
576        let levels: Vec<f64> = contours.iter().map(|c| c.level).collect();
577        assert!(levels.contains(&3.0));
578        assert!(levels.contains(&6.0));
579        assert!(levels.contains(&9.0));
580    }
581
582    #[test]
583    fn test_contour_nodata_handling() {
584        // 3x3 grid with nodata in center
585        let nodata = -9999.0;
586        let data = vec![0.0, 5.0, 10.0, 0.0, nodata, 10.0, 0.0, 5.0, 10.0];
587        let config = ContourConfig::new(4.0)
588            .expect("valid config")
589            .with_nodata(nodata);
590        let contours = generate_contours(&data, 3, 3, &config).expect("should succeed");
591
592        // Contours should still be generated, but cells touching nodata are skipped.
593        // The center cell is nodata, so the 4 cells that touch it are all skipped.
594        // Only corners that don't touch center could produce segments, but in a 3x3
595        // grid all 4 cells touch the center — so no contours produced.
596        // Actually, let's verify: cells are (0,0),(1,0),(0,1),(1,1).
597        // Cell (0,0): corners are data[0],data[1],data[3],data[4]=nodata → skip
598        // Cell (1,0): corners are data[1],data[2],data[4]=nodata,data[5] → skip
599        // Cell (0,1): corners are data[3],data[4]=nodata,data[6],data[7] → skip
600        // Cell (1,1): corners are data[4]=nodata,data[5],data[7],data[8] → skip
601        // All cells skipped because they all touch center nodata.
602        assert!(contours.is_empty());
603    }
604
605    #[test]
606    fn test_contour_closed_loop() {
607        // A "hill" grid: high value in center, low values on edges
608        // 5x5 grid
609        let data = vec![
610            0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 5.0, 5.0, 0.0, 0.0, 5.0, 20.0, 5.0, 0.0, 0.0, 5.0,
611            5.0, 5.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
612        ];
613        let config = ContourConfig::new(10.0).expect("valid config");
614        let contours = generate_contours(&data, 5, 5, &config).expect("should succeed");
615
616        // Should have a contour at level 10.0
617        assert!(!contours.is_empty());
618        let at_10: Vec<&ContourLine> = contours
619            .iter()
620            .filter(|c| (c.level - 10.0).abs() < 1e-10)
621            .collect();
622        assert!(!at_10.is_empty());
623
624        // The contour around the central peak should be closed
625        let has_closed = at_10.iter().any(|c| c.is_closed);
626        assert!(has_closed, "expected a closed contour around the hill");
627    }
628
629    #[test]
630    fn test_contour_saddle_point() {
631        // Create a configuration that produces saddle points (cases 5/10).
632        // Diagonal pattern: high in TL and BR, low in TR and BL (or vice versa).
633        // 3x3 grid:
634        //  10   0   10
635        //   0  10    0
636        //  10   0   10
637        let data = vec![10.0, 0.0, 10.0, 0.0, 10.0, 0.0, 10.0, 0.0, 10.0];
638        let config = ContourConfig::new(4.0).expect("valid config");
639        let contours = generate_contours(&data, 3, 3, &config).expect("should succeed");
640
641        // Should produce contours (the saddle cells are disambiguated)
642        assert!(!contours.is_empty());
643
644        // All contour lines should have valid points
645        for c in &contours {
646            assert!(c.points.len() >= 2);
647            for p in &c.points {
648                assert!(p.x.is_finite() && p.y.is_finite());
649            }
650        }
651    }
652
653    #[test]
654    fn test_contour_empty_grid() {
655        let config = ContourConfig::new(10.0).expect("valid config");
656
657        // Width 0
658        let contours = generate_contours(&[], 0, 0, &config).expect("should succeed");
659        assert!(contours.is_empty());
660
661        // Height 0
662        let contours = generate_contours(&[], 0, 5, &config).expect("should succeed");
663        assert!(contours.is_empty());
664
665        // Width 0, height > 0
666        let contours = generate_contours(&[], 5, 0, &config).expect("should succeed");
667        assert!(contours.is_empty());
668    }
669
670    #[test]
671    fn test_contour_wrong_data_size() {
672        let data = vec![1.0, 2.0, 3.0]; // 3 elements
673        let config = ContourConfig::new(10.0).expect("valid config");
674
675        // 2x2 expects 4 elements
676        let result = generate_contours(&data, 2, 2, &config);
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn test_compute_levels_with_base() {
682        let config = ContourConfig::new(10.0)
683            .expect("valid config")
684            .with_base(5.0);
685        let levels = config.compute_levels(0.0, 30.0);
686        // base=5, interval=10, so levels: 5, 15, 25
687        // But must be strictly inside (0, 30), so 5, 15, 25 all qualify
688        assert!(levels.contains(&15.0));
689        assert!(levels.contains(&25.0));
690    }
691
692    #[test]
693    fn test_contour_config_nan_interval() {
694        let result = ContourConfig::new(f64::NAN);
695        assert!(result.is_err());
696    }
697
698    #[test]
699    fn test_contour_config_inf_interval() {
700        let result = ContourConfig::new(f64::INFINITY);
701        assert!(result.is_err());
702    }
703}