Skip to main content

rustial_engine/visualization/
color_ramp.rs

1//! Interpolated colour transfer function.
2
3/// A single stop in a [`ColorRamp`].
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct ColorStop {
6    /// Normalized input value (typically `0.0..=1.0`).
7    pub value: f32,
8    /// Linear RGBA colour at this stop.
9    pub color: [f32; 4],
10}
11
12/// An interpolated colour transfer function defined by ordered stops.
13///
14/// Evaluates a scalar `t` to a linear RGBA colour by linearly
15/// interpolating between the two nearest stops. Values outside the
16/// stop range are clamped to the first / last stop colour.
17#[derive(Debug, Clone)]
18pub struct ColorRamp {
19    /// Ordered colour stops. Must contain at least one entry.
20    pub stops: Vec<ColorStop>,
21}
22
23impl ColorRamp {
24    /// Create a new ramp from the given stops.
25    ///
26    /// Stops are sorted by `value` on construction.
27    ///
28    /// # Panics
29    ///
30    /// Panics if `stops` is empty.
31    pub fn new(mut stops: Vec<ColorStop>) -> Self {
32        assert!(!stops.is_empty(), "ColorRamp requires at least one stop");
33        stops.sort_by(|a, b| a.value.partial_cmp(&b.value).unwrap_or(std::cmp::Ordering::Equal));
34        Self { stops }
35    }
36
37    /// Evaluate the ramp at `t`, returning a linearly interpolated
38    /// RGBA colour. Clamps to the boundary stops outside the range.
39    pub fn evaluate(&self, t: f32) -> [f32; 4] {
40        if self.stops.len() == 1 || t <= self.stops[0].value {
41            return self.stops[0].color;
42        }
43        let last = &self.stops[self.stops.len() - 1];
44        if t >= last.value {
45            return last.color;
46        }
47        // Find the two bounding stops.
48        for i in 1..self.stops.len() {
49            if t <= self.stops[i].value {
50                let a = &self.stops[i - 1];
51                let b = &self.stops[i];
52                let range = b.value - a.value;
53                let frac = if range.abs() < f32::EPSILON {
54                    0.0
55                } else {
56                    (t - a.value) / range
57                };
58                return lerp_color(&a.color, &b.color, frac);
59            }
60        }
61        last.color
62    }
63
64    /// Generate a 1D RGBA8 lookup-table texture of `width` texels.
65    ///
66    /// Useful for GPU-side colour ramp evaluation via texture sampling.
67    pub fn as_texture_data(&self, width: u32) -> Vec<u8> {
68        let mut out = Vec::with_capacity(width as usize * 4);
69        for i in 0..width {
70            let t = if width <= 1 {
71                0.5
72            } else {
73                i as f32 / (width - 1) as f32
74            };
75            let [r, g, b, a] = self.evaluate(t);
76            out.push((r.clamp(0.0, 1.0) * 255.0) as u8);
77            out.push((g.clamp(0.0, 1.0) * 255.0) as u8);
78            out.push((b.clamp(0.0, 1.0) * 255.0) as u8);
79            out.push((a.clamp(0.0, 1.0) * 255.0) as u8);
80        }
81        out
82    }
83
84    /// Number of stops.
85    #[inline]
86    pub fn len(&self) -> usize {
87        self.stops.len()
88    }
89
90    /// Whether the ramp has no stops (never true after construction).
91    #[inline]
92    pub fn is_empty(&self) -> bool {
93        self.stops.is_empty()
94    }
95}
96
97fn lerp_color(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] {
98    [
99        a[0] + (b[0] - a[0]) * t,
100        a[1] + (b[1] - a[1]) * t,
101        a[2] + (b[2] - a[2]) * t,
102        a[3] + (b[3] - a[3]) * t,
103    ]
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn blue_to_red() -> ColorRamp {
111        ColorRamp::new(vec![
112            ColorStop { value: 0.0, color: [0.0, 0.0, 1.0, 1.0] },
113            ColorStop { value: 1.0, color: [1.0, 0.0, 0.0, 1.0] },
114        ])
115    }
116
117    #[test]
118    fn evaluate_at_stops() {
119        let ramp = blue_to_red();
120        assert_eq!(ramp.evaluate(0.0), [0.0, 0.0, 1.0, 1.0]);
121        assert_eq!(ramp.evaluate(1.0), [1.0, 0.0, 0.0, 1.0]);
122    }
123
124    #[test]
125    fn evaluate_midpoint() {
126        let ramp = blue_to_red();
127        let c = ramp.evaluate(0.5);
128        assert!((c[0] - 0.5).abs() < 1e-5);
129        assert!((c[2] - 0.5).abs() < 1e-5);
130    }
131
132    #[test]
133    fn evaluate_clamps_below() {
134        let ramp = blue_to_red();
135        assert_eq!(ramp.evaluate(-1.0), [0.0, 0.0, 1.0, 1.0]);
136    }
137
138    #[test]
139    fn evaluate_clamps_above() {
140        let ramp = blue_to_red();
141        assert_eq!(ramp.evaluate(2.0), [1.0, 0.0, 0.0, 1.0]);
142    }
143
144    #[test]
145    fn three_stop_ramp() {
146        let ramp = ColorRamp::new(vec![
147            ColorStop { value: 0.0, color: [0.0, 0.0, 0.0, 1.0] },
148            ColorStop { value: 0.5, color: [1.0, 1.0, 1.0, 1.0] },
149            ColorStop { value: 1.0, color: [1.0, 0.0, 0.0, 1.0] },
150        ]);
151        let mid = ramp.evaluate(0.5);
152        assert!((mid[0] - 1.0).abs() < 1e-5);
153        assert!((mid[1] - 1.0).abs() < 1e-5);
154        assert!((mid[2] - 1.0).abs() < 1e-5);
155    }
156
157    #[test]
158    fn as_texture_data_length() {
159        let ramp = blue_to_red();
160        let tex = ramp.as_texture_data(256);
161        assert_eq!(tex.len(), 256 * 4);
162    }
163
164    #[test]
165    fn as_texture_data_boundary_values() {
166        let ramp = blue_to_red();
167        let tex = ramp.as_texture_data(2);
168        // First texel: blue
169        assert_eq!(tex[0], 0);   // R
170        assert_eq!(tex[1], 0);   // G
171        assert_eq!(tex[2], 255); // B
172        assert_eq!(tex[3], 255); // A
173        // Last texel: red
174        assert_eq!(tex[4], 255); // R
175        assert_eq!(tex[5], 0);   // G
176        assert_eq!(tex[6], 0);   // B
177        assert_eq!(tex[7], 255); // A
178    }
179
180    #[test]
181    fn single_stop_ramp() {
182        let ramp = ColorRamp::new(vec![
183            ColorStop { value: 0.5, color: [0.5, 0.5, 0.5, 1.0] },
184        ]);
185        assert_eq!(ramp.evaluate(0.0), [0.5, 0.5, 0.5, 1.0]);
186        assert_eq!(ramp.evaluate(1.0), [0.5, 0.5, 0.5, 1.0]);
187    }
188}