rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
//! Interpolated colour transfer function.

/// A single stop in a [`ColorRamp`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ColorStop {
    /// Normalized input value (typically `0.0..=1.0`).
    pub value: f32,
    /// Linear RGBA colour at this stop.
    pub color: [f32; 4],
}

/// An interpolated colour transfer function defined by ordered stops.
///
/// Evaluates a scalar `t` to a linear RGBA colour by linearly
/// interpolating between the two nearest stops. Values outside the
/// stop range are clamped to the first / last stop colour.
#[derive(Debug, Clone)]
pub struct ColorRamp {
    /// Ordered colour stops. Must contain at least one entry.
    pub stops: Vec<ColorStop>,
}

impl ColorRamp {
    /// Create a new ramp from the given stops.
    ///
    /// Stops are sorted by `value` on construction.
    ///
    /// # Panics
    ///
    /// Panics if `stops` is empty.
    pub fn new(mut stops: Vec<ColorStop>) -> Self {
        assert!(!stops.is_empty(), "ColorRamp requires at least one stop");
        stops.sort_by(|a, b| {
            a.value
                .partial_cmp(&b.value)
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        Self { stops }
    }

    /// Evaluate the ramp at `t`, returning a linearly interpolated
    /// RGBA colour. Clamps to the boundary stops outside the range.
    pub fn evaluate(&self, t: f32) -> [f32; 4] {
        if self.stops.len() == 1 || t <= self.stops[0].value {
            return self.stops[0].color;
        }
        let last = &self.stops[self.stops.len() - 1];
        if t >= last.value {
            return last.color;
        }
        // Find the two bounding stops.
        for i in 1..self.stops.len() {
            if t <= self.stops[i].value {
                let a = &self.stops[i - 1];
                let b = &self.stops[i];
                let range = b.value - a.value;
                let frac = if range.abs() < f32::EPSILON {
                    0.0
                } else {
                    (t - a.value) / range
                };
                return lerp_color(&a.color, &b.color, frac);
            }
        }
        last.color
    }

    /// Generate a 1D RGBA8 lookup-table texture of `width` texels.
    ///
    /// Useful for GPU-side colour ramp evaluation via texture sampling.
    pub fn as_texture_data(&self, width: u32) -> Vec<u8> {
        let mut out = Vec::with_capacity(width as usize * 4);
        for i in 0..width {
            let t = if width <= 1 {
                0.5
            } else {
                i as f32 / (width - 1) as f32
            };
            let [r, g, b, a] = self.evaluate(t);
            out.push((r.clamp(0.0, 1.0) * 255.0) as u8);
            out.push((g.clamp(0.0, 1.0) * 255.0) as u8);
            out.push((b.clamp(0.0, 1.0) * 255.0) as u8);
            out.push((a.clamp(0.0, 1.0) * 255.0) as u8);
        }
        out
    }

    /// Number of stops.
    #[inline]
    pub fn len(&self) -> usize {
        self.stops.len()
    }

    /// Whether the ramp has no stops (never true after construction).
    #[inline]
    pub fn is_empty(&self) -> bool {
        self.stops.is_empty()
    }
}

fn lerp_color(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] {
    [
        a[0] + (b[0] - a[0]) * t,
        a[1] + (b[1] - a[1]) * t,
        a[2] + (b[2] - a[2]) * t,
        a[3] + (b[3] - a[3]) * t,
    ]
}

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

    fn blue_to_red() -> ColorRamp {
        ColorRamp::new(vec![
            ColorStop {
                value: 0.0,
                color: [0.0, 0.0, 1.0, 1.0],
            },
            ColorStop {
                value: 1.0,
                color: [1.0, 0.0, 0.0, 1.0],
            },
        ])
    }

    #[test]
    fn evaluate_at_stops() {
        let ramp = blue_to_red();
        assert_eq!(ramp.evaluate(0.0), [0.0, 0.0, 1.0, 1.0]);
        assert_eq!(ramp.evaluate(1.0), [1.0, 0.0, 0.0, 1.0]);
    }

    #[test]
    fn evaluate_midpoint() {
        let ramp = blue_to_red();
        let c = ramp.evaluate(0.5);
        assert!((c[0] - 0.5).abs() < 1e-5);
        assert!((c[2] - 0.5).abs() < 1e-5);
    }

    #[test]
    fn evaluate_clamps_below() {
        let ramp = blue_to_red();
        assert_eq!(ramp.evaluate(-1.0), [0.0, 0.0, 1.0, 1.0]);
    }

    #[test]
    fn evaluate_clamps_above() {
        let ramp = blue_to_red();
        assert_eq!(ramp.evaluate(2.0), [1.0, 0.0, 0.0, 1.0]);
    }

    #[test]
    fn three_stop_ramp() {
        let ramp = ColorRamp::new(vec![
            ColorStop {
                value: 0.0,
                color: [0.0, 0.0, 0.0, 1.0],
            },
            ColorStop {
                value: 0.5,
                color: [1.0, 1.0, 1.0, 1.0],
            },
            ColorStop {
                value: 1.0,
                color: [1.0, 0.0, 0.0, 1.0],
            },
        ]);
        let mid = ramp.evaluate(0.5);
        assert!((mid[0] - 1.0).abs() < 1e-5);
        assert!((mid[1] - 1.0).abs() < 1e-5);
        assert!((mid[2] - 1.0).abs() < 1e-5);
    }

    #[test]
    fn as_texture_data_length() {
        let ramp = blue_to_red();
        let tex = ramp.as_texture_data(256);
        assert_eq!(tex.len(), 256 * 4);
    }

    #[test]
    fn as_texture_data_boundary_values() {
        let ramp = blue_to_red();
        let tex = ramp.as_texture_data(2);
        // First texel: blue
        assert_eq!(tex[0], 0); // R
        assert_eq!(tex[1], 0); // G
        assert_eq!(tex[2], 255); // B
        assert_eq!(tex[3], 255); // A
                                 // Last texel: red
        assert_eq!(tex[4], 255); // R
        assert_eq!(tex[5], 0); // G
        assert_eq!(tex[6], 0); // B
        assert_eq!(tex[7], 255); // A
    }

    #[test]
    fn single_stop_ramp() {
        let ramp = ColorRamp::new(vec![ColorStop {
            value: 0.5,
            color: [0.5, 0.5, 0.5, 1.0],
        }]);
        assert_eq!(ramp.evaluate(0.0), [0.5, 0.5, 0.5, 1.0]);
        assert_eq!(ramp.evaluate(1.0), [0.5, 0.5, 0.5, 1.0]);
    }
}