rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
//! Grid-aligned scalar field with generation tracking.

/// A 2D scalar field aligned to a [`GeoGrid`](super::GeoGrid).
///
/// Values are stored row-major. Each cell stores a single `f32` value.
/// The field tracks two generation counters:
///
/// - `generation` -- bumped when the grid topology changes (rows, cols).
/// - `value_generation` -- bumped when only values change (same topology).
///
/// Renderers use these to choose between a full mesh rebuild and a
/// texture-only upload.
#[derive(Debug, Clone)]
pub struct ScalarField2D {
    /// Number of rows.
    pub rows: usize,
    /// Number of columns.
    pub cols: usize,
    /// Row-major scalar values. Length must equal `rows * cols`.
    pub data: Vec<f32>,
    /// Minimum value in the field (user-provided or computed).
    pub min: f32,
    /// Maximum value in the field (user-provided or computed).
    pub max: f32,
    /// Optional sentinel value treated as missing data / NaN.
    pub nan_value: Option<f32>,
    /// Structural generation counter. Bump when rows/cols change.
    pub generation: u64,
    /// Value-only generation counter. Bump when values change but
    /// topology is unchanged.
    pub value_generation: u64,
}

impl ScalarField2D {
    /// Create a field from raw data with automatic min/max computation.
    ///
    /// `nan_value` samples are excluded from the min/max scan.
    pub fn from_data(rows: usize, cols: usize, data: Vec<f32>) -> Self {
        assert_eq!(
            data.len(),
            rows * cols,
            "data length must equal rows * cols"
        );
        let (min, max) = min_max(&data, None);
        Self {
            rows,
            cols,
            data,
            min,
            max,
            nan_value: None,
            generation: 0,
            value_generation: 0,
        }
    }

    /// Create a field with an explicit min/max range.
    pub fn from_data_with_range(
        rows: usize,
        cols: usize,
        data: Vec<f32>,
        min: f32,
        max: f32,
    ) -> Self {
        assert_eq!(
            data.len(),
            rows * cols,
            "data length must equal rows * cols"
        );
        Self {
            rows,
            cols,
            data,
            min,
            max,
            nan_value: None,
            generation: 0,
            value_generation: 0,
        }
    }

    /// Sample the raw value at `(row, col)`.
    ///
    /// Returns `None` if out of bounds or if the value matches `nan_value`.
    pub fn sample(&self, row: usize, col: usize) -> Option<f32> {
        if row >= self.rows || col >= self.cols {
            return None;
        }
        let v = self.data[row * self.cols + col];
        if let Some(nan) = self.nan_value {
            if (v - nan).abs() < f32::EPSILON {
                return None;
            }
        }
        if v.is_nan() {
            return None;
        }
        Some(v)
    }

    /// Sample the value at `(row, col)` normalized to `[0.0, 1.0]`
    /// using the field min / max range.
    ///
    /// Returns `None` if the sample is missing or if `min == max`.
    pub fn normalized(&self, row: usize, col: usize) -> Option<f32> {
        let v = self.sample(row, col)?;
        let range = self.max - self.min;
        if range.abs() < f32::EPSILON {
            return Some(0.5);
        }
        Some(((v - self.min) / range).clamp(0.0, 1.0))
    }

    /// Replace values in place, bump `value_generation`, and recompute
    /// min/max.
    ///
    /// Panics if `new_data.len() != rows * cols`.
    pub fn update_values(&mut self, new_data: Vec<f32>) {
        assert_eq!(
            new_data.len(),
            self.rows * self.cols,
            "new data length must match existing topology"
        );
        let (min, max) = min_max(&new_data, self.nan_value);
        self.data = new_data;
        self.min = min;
        self.max = max;
        self.value_generation = self.value_generation.wrapping_add(1);
    }
}

fn min_max(data: &[f32], nan_value: Option<f32>) -> (f32, f32) {
    let mut lo = f32::INFINITY;
    let mut hi = f32::NEG_INFINITY;
    for &v in data {
        if v.is_nan() {
            continue;
        }
        if let Some(nan) = nan_value {
            if (v - nan).abs() < f32::EPSILON {
                continue;
            }
        }
        lo = lo.min(v);
        hi = hi.max(v);
    }
    if lo > hi {
        (0.0, 0.0)
    } else {
        (lo, hi)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn from_data_computes_min_max() {
        let field = ScalarField2D::from_data(2, 3, vec![1.0, 5.0, 3.0, 2.0, 4.0, 0.0]);
        assert!((field.min - 0.0).abs() < 1e-6);
        assert!((field.max - 5.0).abs() < 1e-6);
    }

    #[test]
    fn sample_basic() {
        let field = ScalarField2D::from_data(2, 2, vec![10.0, 20.0, 30.0, 40.0]);
        assert_eq!(field.sample(0, 0), Some(10.0));
        assert_eq!(field.sample(1, 1), Some(40.0));
        assert_eq!(field.sample(2, 0), None);
    }

    #[test]
    fn sample_nan_value() {
        let mut field = ScalarField2D::from_data(1, 3, vec![1.0, -9999.0, 3.0]);
        field.nan_value = Some(-9999.0);
        assert_eq!(field.sample(0, 0), Some(1.0));
        assert_eq!(field.sample(0, 1), None);
        assert_eq!(field.sample(0, 2), Some(3.0));
    }

    #[test]
    fn normalized_range() {
        let field = ScalarField2D::from_data(1, 3, vec![0.0, 50.0, 100.0]);
        assert!((field.normalized(0, 0).unwrap() - 0.0).abs() < 1e-6);
        assert!((field.normalized(0, 1).unwrap() - 0.5).abs() < 1e-6);
        assert!((field.normalized(0, 2).unwrap() - 1.0).abs() < 1e-6);
    }

    #[test]
    fn normalized_constant_field() {
        let field = ScalarField2D::from_data(1, 2, vec![5.0, 5.0]);
        assert!((field.normalized(0, 0).unwrap() - 0.5).abs() < 1e-6);
    }

    #[test]
    fn update_values_bumps_generation() {
        let mut field = ScalarField2D::from_data(1, 2, vec![1.0, 2.0]);
        assert_eq!(field.value_generation, 0);
        field.update_values(vec![3.0, 4.0]);
        assert_eq!(field.value_generation, 1);
        assert!((field.min - 3.0).abs() < 1e-6);
        assert!((field.max - 4.0).abs() < 1e-6);
    }

    #[test]
    fn normalized_with_nan() {
        let field = ScalarField2D::from_data(1, 3, vec![0.0, f32::NAN, 100.0]);
        assert!(field.normalized(0, 1).is_none());
    }
}