use crate::core::{PlottingError, Result};
#[derive(Debug, Clone)]
pub struct ContourLevel {
pub level: f64,
pub segments: Vec<(f64, f64, f64, f64)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContourBand {
pub lower: f64,
pub upper: f64,
pub polygons: Vec<Vec<(f64, f64)>>,
}
fn validate_grid(
operation: &'static str,
x: &[f64],
y: &[f64],
z: &[Vec<f64>],
) -> Result<(usize, usize)> {
let ny = z.len();
let nx = z.first().map_or(0, Vec::len);
for (row_index, row) in z.iter().enumerate() {
if row.len() != nx {
return Err(PlottingError::RaggedData2D {
context: operation,
row: row_index,
expected_columns: nx,
actual_columns: row.len(),
});
}
}
if y.len() != ny || x.len() != nx {
return Err(PlottingError::GridShapeMismatch {
operation,
expected_rows: y.len(),
expected_columns: x.len(),
actual_rows: ny,
actual_columns: nx,
});
}
Ok((ny, nx))
}
pub fn contour_lines(
x: &[f64],
y: &[f64],
z: &[Vec<f64>],
levels: &[f64],
) -> Result<Vec<ContourLevel>> {
let (ny, nx) = validate_grid("contour_lines", x, y, z)?;
if ny < 2 || nx < 2 {
return Ok(vec![]);
}
Ok(levels
.iter()
.map(|&level| ContourLevel {
level,
segments: marching_squares_unchecked(x, y, z, ny, nx, level),
})
.collect())
}
pub fn marching_squares(
x: &[f64],
y: &[f64],
z: &[Vec<f64>],
level: f64,
) -> Result<Vec<(f64, f64, f64, f64)>> {
let (ny, nx) = validate_grid("marching_squares", x, y, z)?;
if ny < 2 || nx < 2 {
return Ok(vec![]);
}
Ok(marching_squares_unchecked(x, y, z, ny, nx, level))
}
fn interpolate_crossing(va: f64, vb: f64, a: f64, b: f64, level: f64) -> f64 {
if (vb - va).abs() < 1e-12 {
(a + b) / 2.0
} else {
a + (level - va) / (vb - va) * (b - a)
}
}
fn marching_squares_unchecked(
x: &[f64],
y: &[f64],
z: &[Vec<f64>],
ny: usize,
nx: usize,
level: f64,
) -> Vec<(f64, f64, f64, f64)> {
let mut segments = Vec::new();
for j in 0..(ny - 1) {
for i in 0..(nx - 1) {
let v0 = z[j][i]; let v1 = z[j][i + 1]; let v2 = z[j + 1][i + 1]; let v3 = z[j + 1][i];
if !v0.is_finite() || !v1.is_finite() || !v2.is_finite() || !v3.is_finite() {
continue;
}
let case = ((v0 >= level) as u8)
| (((v1 >= level) as u8) << 1)
| (((v2 >= level) as u8) << 2)
| (((v3 >= level) as u8) << 3);
let x0 = x[i];
let x1 = x[i + 1];
let y0 = y[j];
let y1 = y[j + 1];
let cross = |va: f64, vb: f64, a: f64, b: f64| -> f64 {
interpolate_crossing(va, vb, a, b, level)
};
let bottom = || (cross(v0, v1, x0, x1), y0);
let right = || (x1, cross(v1, v2, y0, y1));
let top = || (cross(v3, v2, x0, x1), y1);
let left = || (x0, cross(v0, v3, y0, y1));
match case {
0 | 15 => {} 1 | 14 => {
let (bx, by) = bottom();
let (lx, ly) = left();
segments.push((bx, by, lx, ly));
}
2 | 13 => {
let (bx, by) = bottom();
let (rx, ry) = right();
segments.push((bx, by, rx, ry));
}
3 | 12 => {
let (lx, ly) = left();
let (rx, ry) = right();
segments.push((lx, ly, rx, ry));
}
4 | 11 => {
let (rx, ry) = right();
let (tx, ty) = top();
segments.push((rx, ry, tx, ty));
}
5 => {
let center = (v0 + v1 + v2 + v3) / 4.0;
let (bx, by) = bottom();
let (rx, ry) = right();
let (tx, ty) = top();
let (lx, ly) = left();
if center >= level {
segments.push((bx, by, rx, ry));
segments.push((tx, ty, lx, ly));
} else {
segments.push((bx, by, lx, ly));
segments.push((rx, ry, tx, ty));
}
}
6 | 9 => {
let (bx, by) = bottom();
let (tx, ty) = top();
segments.push((bx, by, tx, ty));
}
7 | 8 => {
let (tx, ty) = top();
let (lx, ly) = left();
segments.push((tx, ty, lx, ly));
}
10 => {
let center = (v0 + v1 + v2 + v3) / 4.0;
let (bx, by) = bottom();
let (rx, ry) = right();
let (tx, ty) = top();
let (lx, ly) = left();
if center >= level {
segments.push((bx, by, lx, ly));
segments.push((rx, ry, tx, ty));
} else {
segments.push((bx, by, rx, ry));
segments.push((tx, ty, lx, ly));
}
}
_ => {}
}
}
}
segments
}
#[derive(Debug, Clone, Copy)]
struct BandVertex {
x: f64,
y: f64,
z: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Diagonal {
BottomLeftTopRight,
BottomRightTopLeft,
}
pub(crate) fn polygon_area(ring: &[(f64, f64)]) -> f64 {
if ring.len() < 3 {
return 0.0;
}
let mut twice_area = 0.0;
for index in 0..ring.len() {
let (x0, y0) = ring[index];
let (x1, y1) = ring[(index + 1) % ring.len()];
twice_area += x0 * y1 - x1 * y0;
}
(twice_area / 2.0).abs()
}
fn clip_to_level(
polygon: &[BandVertex],
level: f64,
inside: impl Fn(f64) -> bool,
) -> Vec<BandVertex> {
if polygon.len() < 3 {
return Vec::new();
}
let mut clipped = Vec::with_capacity(polygon.len() + 2);
for (index, ¤t) in polygon.iter().enumerate() {
let previous = polygon[(index + polygon.len() - 1) % polygon.len()];
let current_inside = inside(current.z);
if current_inside != inside(previous.z) {
clipped.push(BandVertex {
x: interpolate_crossing(previous.z, current.z, previous.x, current.x, level),
y: interpolate_crossing(previous.z, current.z, previous.y, current.y, level),
z: level,
});
}
if current_inside {
clipped.push(current);
}
}
clipped
}
fn clip_piece_to_band(
piece: &[BandVertex],
lower: f64,
upper: f64,
area_epsilon: f64,
) -> Option<Vec<(f64, f64)>> {
let mut polygon = if lower.is_finite() {
clip_to_level(piece, lower, |z| z >= lower)
} else {
piece.to_vec()
};
if upper.is_finite() {
polygon = clip_to_level(&polygon, upper, |z| z < upper);
}
let ring: Vec<(f64, f64)> = polygon.iter().map(|vertex| (vertex.x, vertex.y)).collect();
(polygon_area(&ring) > area_epsilon).then_some(ring)
}
fn is_alternating(above: [bool; 4]) -> bool {
above[0] == above[2] && above[1] == above[3] && above[0] != above[1]
}
fn corners_above(corners: &[BandVertex; 4], level: f64) -> [bool; 4] {
[
corners[0].z >= level,
corners[1].z >= level,
corners[2].z >= level,
corners[3].z >= level,
]
}
fn saddle_split(
corners: &[BandVertex; 4],
center: f64,
lower: f64,
upper: f64,
) -> Option<Diagonal> {
if lower.is_finite() {
let above = corners_above(corners, lower);
if is_alternating(above) && center < lower {
return Some(if above[0] {
Diagonal::BottomRightTopLeft
} else {
Diagonal::BottomLeftTopRight
});
}
}
if upper.is_finite() {
let above = corners_above(corners, upper);
if is_alternating(above) && center >= upper {
return Some(if above[0] {
Diagonal::BottomLeftTopRight
} else {
Diagonal::BottomRightTopLeft
});
}
}
None
}
fn push_cell_band_polygons(
corners: &[BandVertex; 4],
center: f64,
lower: f64,
upper: f64,
area_epsilon: f64,
polygons: &mut Vec<Vec<(f64, f64)>>,
) {
let mut push = |piece: &[BandVertex]| {
if let Some(polygon) = clip_piece_to_band(piece, lower, upper, area_epsilon) {
polygons.push(polygon);
}
};
match saddle_split(corners, center, lower, upper) {
None => push(corners.as_slice()),
Some(Diagonal::BottomLeftTopRight) => {
push([corners[0], corners[1], corners[2]].as_slice());
push([corners[0], corners[2], corners[3]].as_slice());
}
Some(Diagonal::BottomRightTopLeft) => {
push([corners[0], corners[1], corners[3]].as_slice());
push([corners[1], corners[2], corners[3]].as_slice());
}
}
}
fn band_bounds(levels: &[f64]) -> Vec<(f64, f64)> {
let mut sorted: Vec<f64> = levels
.iter()
.copied()
.filter(|level| level.is_finite())
.collect();
sorted.sort_by(f64::total_cmp);
sorted.dedup();
if sorted.is_empty() {
return Vec::new();
}
let mut bounds = Vec::with_capacity(sorted.len() + 1);
bounds.push((f64::NEG_INFINITY, sorted[0]));
for pair in sorted.windows(2) {
bounds.push((pair[0], pair[1]));
}
bounds.push((sorted[sorted.len() - 1], f64::INFINITY));
bounds
}
pub fn contour_bands(
x: &[f64],
y: &[f64],
z: &[Vec<f64>],
levels: &[f64],
) -> Result<Vec<ContourBand>> {
let (ny, nx) = validate_grid("contour_bands", x, y, z)?;
let bounds = band_bounds(levels);
if ny < 2 || nx < 2 || bounds.is_empty() {
return Ok(vec![]);
}
let mut polygons: Vec<Vec<Vec<(f64, f64)>>> = vec![Vec::new(); bounds.len()];
for j in 0..(ny - 1) {
for i in 0..(nx - 1) {
let corners = [
BandVertex {
x: x[i],
y: y[j],
z: z[j][i],
},
BandVertex {
x: x[i + 1],
y: y[j],
z: z[j][i + 1],
},
BandVertex {
x: x[i + 1],
y: y[j + 1],
z: z[j + 1][i + 1],
},
BandVertex {
x: x[i],
y: y[j + 1],
z: z[j + 1][i],
},
];
if !corners.iter().all(|corner| corner.z.is_finite()) {
continue;
}
let cell_min = corners.iter().fold(f64::INFINITY, |acc, c| acc.min(c.z));
let cell_max = corners
.iter()
.fold(f64::NEG_INFINITY, |acc, c| acc.max(c.z));
let center = corners.iter().map(|corner| corner.z).sum::<f64>() / 4.0;
let cell_area = ((corners[1].x - corners[0].x) * (corners[3].y - corners[0].y)).abs();
let area_epsilon = cell_area * 1e-12;
for (band_index, &(lower, upper)) in bounds.iter().enumerate() {
if cell_max < lower || cell_min >= upper {
continue;
}
if cell_min >= lower && cell_max < upper {
polygons[band_index].push(vec![
(corners[0].x, corners[0].y),
(corners[1].x, corners[1].y),
(corners[2].x, corners[2].y),
(corners[3].x, corners[3].y),
]);
continue;
}
push_cell_band_polygons(
&corners,
center,
lower,
upper,
area_epsilon,
&mut polygons[band_index],
);
}
}
}
Ok(bounds
.into_iter()
.zip(polygons)
.map(|((lower, upper), polygons)| ContourBand {
lower,
upper,
polygons,
})
.collect())
}
pub fn auto_levels(z: &[Vec<f64>], n_levels: usize) -> Vec<f64> {
let mut z_min = f64::INFINITY;
let mut z_max = f64::NEG_INFINITY;
for row in z {
for &val in row {
if val.is_finite() {
z_min = z_min.min(val);
z_max = z_max.max(val);
}
}
}
if !z_min.is_finite() || !z_max.is_finite() || n_levels == 0 {
return vec![];
}
let step = (z_max - z_min) / (n_levels + 1) as f64;
(1..=n_levels).map(|i| z_min + i as f64 * step).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_marching_squares_simple() {
let x = vec![0.0, 1.0];
let y = vec![0.0, 1.0];
let z = vec![vec![0.0, 1.0], vec![1.0, 2.0]];
let segments = marching_squares(&x, &y, &z, 0.5).expect("square grid is valid");
assert!(!segments.is_empty());
}
#[test]
fn test_contour_lines() {
let x = vec![0.0, 1.0, 2.0];
let y = vec![0.0, 1.0, 2.0];
let z = vec![
vec![0.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0],
vec![0.0, 0.0, 0.0],
];
let contours = contour_lines(&x, &y, &z, &[0.5]).expect("square grid is valid");
assert_eq!(contours.len(), 1);
assert!(!contours[0].segments.is_empty());
}
#[test]
fn test_auto_levels() {
let z = vec![
vec![0.0, 1.0, 2.0],
vec![1.0, 2.0, 3.0],
vec![2.0, 3.0, 4.0],
];
let levels = auto_levels(&z, 3);
assert_eq!(levels.len(), 3);
assert!(levels[0] > 0.0);
assert!(levels[2] < 4.0);
}
#[test]
fn test_empty_input() {
let contours = contour_lines(&[], &[], &[], &[0.5]).expect("empty grid is a valid shape");
assert!(contours.is_empty());
}
#[test]
fn test_marching_squares_reports_empty_grid_instead_of_panicking() {
assert!(
marching_squares(&[], &[], &[], 0.5)
.expect("empty grid is a valid shape")
.is_empty()
);
assert!(
marching_squares(&[0.0], &[0.0], &[vec![1.0]], 0.5)
.expect("1x1 grid is a valid shape")
.is_empty()
);
}
#[test]
fn test_ragged_grid_is_rejected() {
let x = vec![0.0, 1.0, 2.0];
let y = vec![0.0, 1.0];
let z = vec![vec![0.0, 1.0, 2.0], vec![3.0, 4.0]];
assert!(matches!(
marching_squares(&x, &y, &z, 0.5),
Err(PlottingError::RaggedData2D { row: 1, .. })
));
assert!(matches!(
contour_lines(&x, &y, &z, &[0.5]),
Err(PlottingError::RaggedData2D { row: 1, .. })
));
}
fn peak_grid(n: usize) -> (Vec<f64>, Vec<f64>, Vec<Vec<f64>>) {
let x: Vec<f64> = (0..n).map(|i| i as f64).collect();
let center = (n - 1) as f64 / 2.0;
let z = (0..n)
.map(|iy| {
(0..n)
.map(|ix| {
let dx = ix as f64 - center;
let dy = iy as f64 - center;
(-(dx * dx + dy * dy) / 4.0).exp()
})
.collect()
})
.collect();
(x.clone(), x, z)
}
fn total_band_area(bands: &[ContourBand]) -> f64 {
bands
.iter()
.flat_map(|band| band.polygons.iter())
.map(|polygon| polygon_area(polygon.as_slice()))
.sum()
}
#[test]
fn test_isobands_cut_cells_along_the_level() {
let x = vec![0.0, 1.0];
let y = vec![0.0, 1.0];
let z = vec![vec![0.0, 0.0], vec![2.0, 2.0]];
let bands = contour_bands(&x, &y, &z, &[1.0]).expect("square grid is valid");
assert_eq!(bands.len(), 2);
for band in &bands {
assert_eq!(band.polygons.len(), 1);
assert!(
(polygon_area(&band.polygons[0]) - 0.5).abs() < 1e-12,
"a level through the middle of a cell must halve it, got {:?}",
band.polygons[0]
);
}
assert!(
bands[0].polygons[0]
.iter()
.any(|&(_, py)| (py - 0.5).abs() < 1e-12),
"the band boundary must sit at the interpolated crossing"
);
}
#[test]
fn test_isoband_edges_land_on_the_contour_line() {
let (x, y, z) = peak_grid(5);
let level = 0.5;
let segments = marching_squares(&x, &y, &z, level).expect("square grid is valid");
assert!(!segments.is_empty());
let vertices: Vec<(f64, f64)> = contour_bands(&x, &y, &z, &[level])
.expect("square grid is valid")
.iter()
.flat_map(|band| band.polygons.iter())
.flatten()
.copied()
.collect();
for (x1, y1, x2, y2) in segments {
for point in [(x1, y1), (x2, y2)] {
assert!(
vertices
.iter()
.any(|v| (v.0 - point.0).abs() < 1e-12 && (v.1 - point.1).abs() < 1e-12),
"contour line endpoint {point:?} is not a band vertex"
);
}
}
}
#[test]
fn test_isobands_tile_the_grid() {
let (x, y, z) = peak_grid(9);
let levels = auto_levels(&z, 5);
let bands = contour_bands(&x, &y, &z, &levels).expect("square grid is valid");
assert_eq!(bands.len(), levels.len() + 1);
assert!((total_band_area(&bands) - 64.0).abs() < 1e-9);
}
#[test]
fn test_isobands_split_ambiguous_saddle_cells() {
let x = vec![0.0, 1.0];
let y = vec![0.0, 1.0];
let z = vec![vec![12.0, 0.0], vec![0.0, 12.0]];
let bands = contour_bands(&x, &y, &z, &[1.0, 10.0]).expect("square grid is valid");
assert_eq!(bands.len(), 3);
assert_eq!(
bands[0].polygons.len(),
2,
"two corners below the low level"
);
assert_eq!(bands[1].polygons.len(), 1, "the middle band is connected");
assert_eq!(
bands[2].polygons.len(),
2,
"two corners above the high level"
);
assert!((total_band_area(&bands) - 1.0).abs() < 1e-12);
}
#[test]
fn test_isobands_are_open_ended() {
let (x, y, z) = peak_grid(7);
let bands = contour_bands(&x, &y, &z, &[0.2, 0.4]).expect("square grid is valid");
assert_eq!(bands.len(), 3);
assert_eq!(bands[0].lower, f64::NEG_INFINITY);
assert_eq!(bands[2].upper, f64::INFINITY);
assert!(!bands[0].polygons.is_empty(), "the pit must be painted");
assert!(!bands[2].polygons.is_empty(), "the peak must be painted");
assert!((total_band_area(&bands) - 36.0).abs() < 1e-9);
}
#[test]
fn test_isobands_with_a_single_level() {
let (x, y, z) = peak_grid(5);
let bands = contour_bands(&x, &y, &z, &[0.5]).expect("square grid is valid");
assert_eq!(bands.len(), 2);
assert!((total_band_area(&bands) - 16.0).abs() < 1e-9);
}
#[test]
fn test_isobands_on_a_constant_field_paint_once() {
let x = vec![0.0, 1.0, 2.0];
let y = vec![0.0, 1.0, 2.0];
let z = vec![vec![5.0; 3]; 3];
let bands = contour_bands(&x, &y, &z, &[5.0]).expect("square grid is valid");
assert_eq!(bands.len(), 2);
assert!(bands[0].polygons.is_empty(), "nothing is below the level");
assert_eq!(bands[1].polygons.len(), 4, "every cell, exactly once");
assert!((total_band_area(&bands) - 4.0).abs() < 1e-12);
}
#[test]
fn test_isoband_levels_are_sorted_and_deduplicated() {
let (x, y, z) = peak_grid(5);
let ordered = contour_bands(&x, &y, &z, &[0.2, 0.5, 0.8]).expect("square grid is valid");
let jumbled =
contour_bands(&x, &y, &z, &[0.8, 0.2, 0.5, 0.2]).expect("square grid is valid");
assert_eq!(ordered, jumbled);
assert!((total_band_area(&ordered) - 16.0).abs() < 1e-9);
}
#[test]
fn test_isobands_skip_non_finite_cells() {
let (x, y, mut z) = peak_grid(5);
z[2][2] = f64::NAN;
let bands = contour_bands(&x, &y, &z, &[0.3, 0.6]).expect("square grid is valid");
assert!((total_band_area(&bands) - 12.0).abs() < 1e-9);
}
#[test]
fn test_contour_bands_degenerate_inputs() {
assert!(
contour_bands(&[], &[], &[], &[0.5])
.expect("empty grid is a valid shape")
.is_empty()
);
assert!(
contour_bands(&[0.0], &[0.0], &[vec![1.0]], &[0.5])
.expect("1x1 grid is a valid shape")
.is_empty()
);
let (x, y, z) = peak_grid(3);
assert!(
contour_bands(&x, &y, &z, &[])
.expect("no levels is a valid request")
.is_empty(),
"no levels means no bands to separate"
);
assert!(
contour_bands(&x, &y, &z, &[f64::NAN])
.expect("a NaN level is not a shape error")
.is_empty()
);
let ragged = vec![vec![0.0, 1.0, 2.0], vec![3.0, 4.0]];
assert!(matches!(
contour_bands(&[0.0, 1.0, 2.0], &[0.0, 1.0], &ragged, &[0.5]),
Err(PlottingError::RaggedData2D { row: 1, .. })
));
let wide = vec![vec![0.0, 1.0, 2.0], vec![3.0, 4.0, 5.0]];
assert!(matches!(
contour_bands(&[0.0, 1.0], &[0.0, 1.0], &wide, &[0.5]),
Err(PlottingError::GridShapeMismatch {
expected_columns: 2,
actual_columns: 3,
..
})
));
}
#[test]
fn test_coordinate_length_mismatch_is_rejected() {
let x = vec![0.0, 1.0];
let y = vec![0.0, 1.0];
let z = vec![vec![0.0, 1.0, 2.0], vec![3.0, 4.0, 5.0]];
assert!(matches!(
marching_squares(&x, &y, &z, 0.5),
Err(PlottingError::GridShapeMismatch {
expected_columns: 2,
actual_columns: 3,
..
})
));
assert!(matches!(
contour_lines(&x, &y, &z, &[0.5]),
Err(PlottingError::GridShapeMismatch {
expected_rows: 2,
actual_rows: 2,
..
})
));
}
}