Skip to main content

rustial_engine/visualization/
scalar_field.rs

1//! Grid-aligned scalar field with generation tracking.
2
3/// A 2D scalar field aligned to a [`GeoGrid`](super::GeoGrid).
4///
5/// Values are stored row-major. Each cell stores a single `f32` value.
6/// The field tracks two generation counters:
7///
8/// - `generation` -- bumped when the grid topology changes (rows, cols).
9/// - `value_generation` -- bumped when only values change (same topology).
10///
11/// Renderers use these to choose between a full mesh rebuild and a
12/// texture-only upload.
13#[derive(Debug, Clone)]
14pub struct ScalarField2D {
15    /// Number of rows.
16    pub rows: usize,
17    /// Number of columns.
18    pub cols: usize,
19    /// Row-major scalar values. Length must equal `rows * cols`.
20    pub data: Vec<f32>,
21    /// Minimum value in the field (user-provided or computed).
22    pub min: f32,
23    /// Maximum value in the field (user-provided or computed).
24    pub max: f32,
25    /// Optional sentinel value treated as missing data / NaN.
26    pub nan_value: Option<f32>,
27    /// Structural generation counter. Bump when rows/cols change.
28    pub generation: u64,
29    /// Value-only generation counter. Bump when values change but
30    /// topology is unchanged.
31    pub value_generation: u64,
32}
33
34impl ScalarField2D {
35    /// Create a field from raw data with automatic min/max computation.
36    ///
37    /// `nan_value` samples are excluded from the min/max scan.
38    pub fn from_data(rows: usize, cols: usize, data: Vec<f32>) -> Self {
39        assert_eq!(
40            data.len(),
41            rows * cols,
42            "data length must equal rows * cols"
43        );
44        let (min, max) = min_max(&data, None);
45        Self {
46            rows,
47            cols,
48            data,
49            min,
50            max,
51            nan_value: None,
52            generation: 0,
53            value_generation: 0,
54        }
55    }
56
57    /// Create a field with an explicit min/max range.
58    pub fn from_data_with_range(
59        rows: usize,
60        cols: usize,
61        data: Vec<f32>,
62        min: f32,
63        max: f32,
64    ) -> Self {
65        assert_eq!(
66            data.len(),
67            rows * cols,
68            "data length must equal rows * cols"
69        );
70        Self {
71            rows,
72            cols,
73            data,
74            min,
75            max,
76            nan_value: None,
77            generation: 0,
78            value_generation: 0,
79        }
80    }
81
82    /// Sample the raw value at `(row, col)`.
83    ///
84    /// Returns `None` if out of bounds or if the value matches `nan_value`.
85    pub fn sample(&self, row: usize, col: usize) -> Option<f32> {
86        if row >= self.rows || col >= self.cols {
87            return None;
88        }
89        let v = self.data[row * self.cols + col];
90        if let Some(nan) = self.nan_value {
91            if (v - nan).abs() < f32::EPSILON {
92                return None;
93            }
94        }
95        if v.is_nan() {
96            return None;
97        }
98        Some(v)
99    }
100
101    /// Sample the value at `(row, col)` normalized to `[0.0, 1.0]`
102    /// using the field min / max range.
103    ///
104    /// Returns `None` if the sample is missing or if `min == max`.
105    pub fn normalized(&self, row: usize, col: usize) -> Option<f32> {
106        let v = self.sample(row, col)?;
107        let range = self.max - self.min;
108        if range.abs() < f32::EPSILON {
109            return Some(0.5);
110        }
111        Some(((v - self.min) / range).clamp(0.0, 1.0))
112    }
113
114    /// Replace values in place, bump `value_generation`, and recompute
115    /// min/max.
116    ///
117    /// Panics if `new_data.len() != rows * cols`.
118    pub fn update_values(&mut self, new_data: Vec<f32>) {
119        assert_eq!(
120            new_data.len(),
121            self.rows * self.cols,
122            "new data length must match existing topology"
123        );
124        let (min, max) = min_max(&new_data, self.nan_value);
125        self.data = new_data;
126        self.min = min;
127        self.max = max;
128        self.value_generation = self.value_generation.wrapping_add(1);
129    }
130}
131
132fn min_max(data: &[f32], nan_value: Option<f32>) -> (f32, f32) {
133    let mut lo = f32::INFINITY;
134    let mut hi = f32::NEG_INFINITY;
135    for &v in data {
136        if v.is_nan() {
137            continue;
138        }
139        if let Some(nan) = nan_value {
140            if (v - nan).abs() < f32::EPSILON {
141                continue;
142            }
143        }
144        lo = lo.min(v);
145        hi = hi.max(v);
146    }
147    if lo > hi {
148        (0.0, 0.0)
149    } else {
150        (lo, hi)
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn from_data_computes_min_max() {
160        let field = ScalarField2D::from_data(2, 3, vec![1.0, 5.0, 3.0, 2.0, 4.0, 0.0]);
161        assert!((field.min - 0.0).abs() < 1e-6);
162        assert!((field.max - 5.0).abs() < 1e-6);
163    }
164
165    #[test]
166    fn sample_basic() {
167        let field = ScalarField2D::from_data(2, 2, vec![10.0, 20.0, 30.0, 40.0]);
168        assert_eq!(field.sample(0, 0), Some(10.0));
169        assert_eq!(field.sample(1, 1), Some(40.0));
170        assert_eq!(field.sample(2, 0), None);
171    }
172
173    #[test]
174    fn sample_nan_value() {
175        let mut field = ScalarField2D::from_data(1, 3, vec![1.0, -9999.0, 3.0]);
176        field.nan_value = Some(-9999.0);
177        assert_eq!(field.sample(0, 0), Some(1.0));
178        assert_eq!(field.sample(0, 1), None);
179        assert_eq!(field.sample(0, 2), Some(3.0));
180    }
181
182    #[test]
183    fn normalized_range() {
184        let field = ScalarField2D::from_data(1, 3, vec![0.0, 50.0, 100.0]);
185        assert!((field.normalized(0, 0).unwrap() - 0.0).abs() < 1e-6);
186        assert!((field.normalized(0, 1).unwrap() - 0.5).abs() < 1e-6);
187        assert!((field.normalized(0, 2).unwrap() - 1.0).abs() < 1e-6);
188    }
189
190    #[test]
191    fn normalized_constant_field() {
192        let field = ScalarField2D::from_data(1, 2, vec![5.0, 5.0]);
193        assert!((field.normalized(0, 0).unwrap() - 0.5).abs() < 1e-6);
194    }
195
196    #[test]
197    fn update_values_bumps_generation() {
198        let mut field = ScalarField2D::from_data(1, 2, vec![1.0, 2.0]);
199        assert_eq!(field.value_generation, 0);
200        field.update_values(vec![3.0, 4.0]);
201        assert_eq!(field.value_generation, 1);
202        assert!((field.min - 3.0).abs() < 1e-6);
203        assert!((field.max - 4.0).abs() < 1e-6);
204    }
205
206    #[test]
207    fn normalized_with_nan() {
208        let field = ScalarField2D::from_data(1, 3, vec![0.0, f32::NAN, 100.0]);
209        assert!(field.normalized(0, 1).is_none());
210    }
211}