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| {
34            a.value
35                .partial_cmp(&b.value)
36                .unwrap_or(std::cmp::Ordering::Equal)
37        });
38        Self { stops }
39    }
40
41    /// Evaluate the ramp at `t`, returning a linearly interpolated
42    /// RGBA colour. Clamps to the boundary stops outside the range.
43    pub fn evaluate(&self, t: f32) -> [f32; 4] {
44        if self.stops.len() == 1 || t <= self.stops[0].value {
45            return self.stops[0].color;
46        }
47        let last = &self.stops[self.stops.len() - 1];
48        if t >= last.value {
49            return last.color;
50        }
51        // Find the two bounding stops.
52        for i in 1..self.stops.len() {
53            if t <= self.stops[i].value {
54                let a = &self.stops[i - 1];
55                let b = &self.stops[i];
56                let range = b.value - a.value;
57                let frac = if range.abs() < f32::EPSILON {
58                    0.0
59                } else {
60                    (t - a.value) / range
61                };
62                return lerp_color(&a.color, &b.color, frac);
63            }
64        }
65        last.color
66    }
67
68    /// Generate a 1D RGBA8 lookup-table texture of `width` texels.
69    ///
70    /// Useful for GPU-side colour ramp evaluation via texture sampling.
71    pub fn as_texture_data(&self, width: u32) -> Vec<u8> {
72        let mut out = Vec::with_capacity(width as usize * 4);
73        for i in 0..width {
74            let t = if width <= 1 {
75                0.5
76            } else {
77                i as f32 / (width - 1) as f32
78            };
79            let [r, g, b, a] = self.evaluate(t);
80            out.push((r.clamp(0.0, 1.0) * 255.0) as u8);
81            out.push((g.clamp(0.0, 1.0) * 255.0) as u8);
82            out.push((b.clamp(0.0, 1.0) * 255.0) as u8);
83            out.push((a.clamp(0.0, 1.0) * 255.0) as u8);
84        }
85        out
86    }
87
88    /// Number of stops.
89    #[inline]
90    pub fn len(&self) -> usize {
91        self.stops.len()
92    }
93
94    /// Whether the ramp has no stops (never true after construction).
95    #[inline]
96    pub fn is_empty(&self) -> bool {
97        self.stops.is_empty()
98    }
99}
100
101fn lerp_color(a: &[f32; 4], b: &[f32; 4], t: f32) -> [f32; 4] {
102    [
103        a[0] + (b[0] - a[0]) * t,
104        a[1] + (b[1] - a[1]) * t,
105        a[2] + (b[2] - a[2]) * t,
106        a[3] + (b[3] - a[3]) * t,
107    ]
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn blue_to_red() -> ColorRamp {
115        ColorRamp::new(vec![
116            ColorStop {
117                value: 0.0,
118                color: [0.0, 0.0, 1.0, 1.0],
119            },
120            ColorStop {
121                value: 1.0,
122                color: [1.0, 0.0, 0.0, 1.0],
123            },
124        ])
125    }
126
127    #[test]
128    fn evaluate_at_stops() {
129        let ramp = blue_to_red();
130        assert_eq!(ramp.evaluate(0.0), [0.0, 0.0, 1.0, 1.0]);
131        assert_eq!(ramp.evaluate(1.0), [1.0, 0.0, 0.0, 1.0]);
132    }
133
134    #[test]
135    fn evaluate_midpoint() {
136        let ramp = blue_to_red();
137        let c = ramp.evaluate(0.5);
138        assert!((c[0] - 0.5).abs() < 1e-5);
139        assert!((c[2] - 0.5).abs() < 1e-5);
140    }
141
142    #[test]
143    fn evaluate_clamps_below() {
144        let ramp = blue_to_red();
145        assert_eq!(ramp.evaluate(-1.0), [0.0, 0.0, 1.0, 1.0]);
146    }
147
148    #[test]
149    fn evaluate_clamps_above() {
150        let ramp = blue_to_red();
151        assert_eq!(ramp.evaluate(2.0), [1.0, 0.0, 0.0, 1.0]);
152    }
153
154    #[test]
155    fn three_stop_ramp() {
156        let ramp = ColorRamp::new(vec![
157            ColorStop {
158                value: 0.0,
159                color: [0.0, 0.0, 0.0, 1.0],
160            },
161            ColorStop {
162                value: 0.5,
163                color: [1.0, 1.0, 1.0, 1.0],
164            },
165            ColorStop {
166                value: 1.0,
167                color: [1.0, 0.0, 0.0, 1.0],
168            },
169        ]);
170        let mid = ramp.evaluate(0.5);
171        assert!((mid[0] - 1.0).abs() < 1e-5);
172        assert!((mid[1] - 1.0).abs() < 1e-5);
173        assert!((mid[2] - 1.0).abs() < 1e-5);
174    }
175
176    #[test]
177    fn as_texture_data_length() {
178        let ramp = blue_to_red();
179        let tex = ramp.as_texture_data(256);
180        assert_eq!(tex.len(), 256 * 4);
181    }
182
183    #[test]
184    fn as_texture_data_boundary_values() {
185        let ramp = blue_to_red();
186        let tex = ramp.as_texture_data(2);
187        // First texel: blue
188        assert_eq!(tex[0], 0); // R
189        assert_eq!(tex[1], 0); // G
190        assert_eq!(tex[2], 255); // B
191        assert_eq!(tex[3], 255); // A
192                                 // Last texel: red
193        assert_eq!(tex[4], 255); // R
194        assert_eq!(tex[5], 0); // G
195        assert_eq!(tex[6], 0); // B
196        assert_eq!(tex[7], 255); // A
197    }
198
199    #[test]
200    fn single_stop_ramp() {
201        let ramp = ColorRamp::new(vec![ColorStop {
202            value: 0.5,
203            color: [0.5, 0.5, 0.5, 1.0],
204        }]);
205        assert_eq!(ramp.evaluate(0.0), [0.5, 0.5, 0.5, 1.0]);
206        assert_eq!(ramp.evaluate(1.0), [0.5, 0.5, 0.5, 1.0]);
207    }
208}