1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
//! Dave Green's 'cubehelix' colour scheme.  
//! See cubehelix homepage at:  
//! https://www.mrao.cam.ac.uk/~dag/CUBEHELIX/  
//! Calculation from:  
//! http://astron-soc.in/bulletin/11June/289392011.pdf  
//! # Examples
//! ```
//! use cube_helix::CubeHelix;
//! # fn main() {
//! // Get default values
//! let ch: CubeHelix = Default::default();
//! // Returns color white: (255,255,255)
//! let colors = ch.get_color(1.0);
//! # }
//! ```

use std::f64::consts::PI;

// RGB perceived intensity const
/// Red perceived intensity cons
const RPIC: (f64, f64) = (-0.14861, 1.78277);
/// Green perceived intensity cons
const GPIC: (f64, f64) = (-0.29227, -0.90649);
/// Blue perceived intensity cons
const BPIC: (f64, f64) = (1.97294, 0.0);

/// # Examples
/// Using default values:
/// ```
/// use cube_helix::CubeHelix;
/// # fn main() {
/// // Get default values
/// let ch: CubeHelix = Default::default();
/// // Returns color white: (255,255,255)
/// let color = ch.get_color(1.0);
/// assert_eq!(color.0, 255);
/// assert_eq!(color.1, 255);
/// assert_eq!(color.2, 255);
/// # }
/// ```
/// Using defaults partially:
/// ```
/// use cube_helix::CubeHelix;
/// // Override 'start' and 'rotation' values
/// let ch = CubeHelix { start: 0.2, rotations: 1.5, ..Default::default() };
/// // Returns color black: (0,0,0)
/// let color = ch.get_color(0.0);
/// assert_eq!(color.0, 0);
/// assert_eq!(color.1, 0);
/// assert_eq!(color.2, 0);
/// ```
#[derive(Debug)]
pub struct CubeHelix {
    /// Gamma factor.
    pub gamma: f64,
    /// Starting angle: 0.5 -> purple
    pub start: f64,
    /// Rotations (1.5 : R -> G -> B -> R).  
    /// Negative value will 'spin' in opposite direction.
    pub rotations: f64,
    /// Value from 0..1 to control saturation.
    /// Zero value is completelly grayscale.
    pub saturation: f64,
    /// Lowest value in value range. This value represents black.
    pub min: f64,
    /// Highest value in value range. This value represents white.
    pub max: f64,
}

/// Default values to produce the same gradient as D.A Green's paper in Figure 1.  
/// gamma: 1.0  
/// start: 0.5  
/// rotations: -1.5  
/// saturation: 1.0  
/// min: 0.0  
/// max: 1.0
impl Default for CubeHelix {
    fn default() -> CubeHelix {
        CubeHelix {
            gamma: 1.0,
            start: 0.5,
            rotations: -1.5,
            saturation: 1.0,
            min: 0.0,
            max: 1.0,
        }
    }
}

/// Adds color calculation to CubeHelix struct
impl CubeHelix {
    /// Calculates CubeHelix color for given value.  
    /// Value must be in the min..max range.
    /// Returns a tuple with three values: (red: u8, green: u8, blue: u8).
    /// # Examples
    /// ```
    /// use cube_helix::CubeHelix;
    /// # fn main() {
    /// // Use range 0..100 - defalts otherwise
    /// let ch = CubeHelix { min: 0.0, max: 100.0, ..Default::default() };
    /// // Get color in the middle. Returns color: (174,97,158)
    /// let color = ch.get_color(50.0);
    /// assert_eq!(color.0, 174);
    /// assert_eq!(color.1, 97);
    /// assert_eq!(color.2, 158);
    /// # }
    /// ```
    pub fn get_color(&self, value: f64) -> (u8, u8, u8) {
        let rgb: (f64, f64, f64) = calc(self, value);
        ((rgb.0 * 255.0) as u8, (rgb.1 * 255.0) as u8, (rgb.2 * 255.0) as u8)
    }
}

/// Use cubehelix color calculation without CubeHelix struct.  
/// Returns a tuple with three values (red: u8, green: u8, blue: u8)  
/// # Examples
/// ```
/// use cube_helix::color;
/// # fn main() {
/// // Returns color (181, 104, 101)
/// let color = color(1.0, 0.2, -1.5, 1.0, 0.0, 1.0, 0.5);
/// assert_eq!(color.0, 181);
/// assert_eq!(color.1, 104);
/// assert_eq!(color.2, 101);
/// # }
/// ```
pub fn color(
    gamma: f64,
    start: f64,
    rotations: f64,
    saturation: f64,
    min: f64,
    max: f64,
    value: f64,
) -> (u8, u8, u8) {
    let rgb: (f64, f64, f64) = calc(
        &CubeHelix {gamma, start, rotations, saturation, min, max},
        value,
    );
    ((rgb.0 * 255.0) as u8, (rgb.1 * 255.0) as u8, (rgb.2 * 255.0) as u8)
}

fn calc(cube_helix: &CubeHelix, value: f64) -> (f64, f64, f64) {
    // normalize value to min-max range
    let x: f64 = normalize(value, cube_helix.min, cube_helix.max);
    // Apply gamma factor to emphasise low or high intensity values
    let lambda: f64 = x * cube_helix.gamma;
    // Calculate amplitude of deviation
    let amplitude: f64 = amplitude(lambda, cube_helix.saturation);
    // Calculate angle of deviation
    let phi: f64 = phi(cube_helix, value);

    // Calculate rgb values
    let red: f64 = color_calc(lambda, amplitude, phi, RPIC);
    let green: f64 = color_calc(lambda, amplitude, phi, GPIC);
    let blue: f64 = color_calc(lambda, amplitude, phi, BPIC);

    (red, green, blue)
}

// lambda: given value normalized and gamma corrected
//         (value between 0-1 via normalized)
// amplitude: deviation of amplitude in the black and white line
// phi: deviation of angle in the black and white diagonal line
// color_const: perceived intesity constant tuple for red, green or blue
fn color_calc(lambda: f64, amplitude: f64, phi: f64, color_const: (f64, f64)) -> f64 {
    lambda + amplitude * (color_const.0 * phi.cos() + color_const.1 * phi.sin())
}

// phi: deviation of angle in the black and white diagonal line
// φ = 2π(s/3 + rλ)
fn phi(cube_helix: &CubeHelix, value: f64) -> f64 {
    2.0 * PI * (cube_helix.start / 3.0 + cube_helix.rotations * value)
}

// amplitude: deviation of amplitude in the black and white line
//
// Calculate amplitude and angle of deviation from the black
// to white diagonal in the plane of constant
// perceived intensity.
// a = hλ(1 − λ)/2
fn amplitude(lambda: f64, saturation: f64) -> f64 {
    saturation * lambda * (1.0 - lambda) / 2.0
}

// Normalize value in the range of min..max to 0..1
fn normalize(value: f64, min: f64, max: f64) -> f64 {
    if value < min || value > max {
        panic!("Value: {:?} not in range: {:?} - {:?}", value, min, max);
    }
    if min > max {
        panic!("Incorrect range: min > max");
    }
    if (min - max).signum() == 0.0 {
        panic!("Incorrect range: {:?} - {:?}", min, max);
    }
    (value - min) / (max - min)
}

//Unit tests for functions and structs in this file.
#[cfg(test)]
mod cube_helix_tests {
    use super::*;

    // Struct tests
    #[test]
    fn defaults() {
        let ch: CubeHelix = Default::default();
        assert_eq!(ch.gamma, 1.0);
        assert_eq!(ch.start, 0.5);
        assert_eq!(ch.rotations, -1.5);
        assert_eq!(ch.saturation, 1.0);
        assert_eq!(ch.min, 0.0);
        assert_eq!(ch.max, 1.0);
    }

    #[test]
    fn partial_defaults() {
        let ch = CubeHelix {
            start: 0.2,
            rotations: 1.5,
            ..Default::default()
        };
        assert_eq!(ch.gamma, 1.0);
        assert_eq!(ch.start, 0.2);
        assert_eq!(ch.rotations, 1.5);
        assert_eq!(ch.saturation, 1.0);
        assert_eq!(ch.min, 0.0);
        assert_eq!(ch.max, 1.0);
    }

    #[test]
    fn odd_start_and_rotations() {
        let ch = CubeHelix {
            start: 77.2,
            rotations: -21.5,
            ..Default::default()
        };
        let color = ch.get_color(0.3);
        assert_eq!(color.0, 124);
        assert_eq!(color.1, 54);
        assert_eq!(color.2, 65);
 
    }

    // Color call tests
    #[test]
    fn partial_fn_call() {
        let color = color(1.0, 0.2, -1.5, 1.0, 0.0, 1.0, 0.5);
        assert_eq!(color.0, 181);
        assert_eq!(color.1, 104);
        assert_eq!(color.2, 101);
    }

    #[test]
    fn max_is_white() {
        let ch: CubeHelix = Default::default();
        let colors = ch.get_color(1.0);
        assert_eq!(colors.0, 255);
        assert_eq!(colors.1, 255);
        assert_eq!(colors.2, 255);
    }

    #[test]
    fn min_is_black() {
        let ch: CubeHelix = Default::default();
        let colors = ch.get_color(0.0);
        assert_eq!(colors.0, 0);
        assert_eq!(colors.1, 0);
        assert_eq!(colors.2, 0);
    }

    // Tests for normalize
    #[test]
    fn normalize_with_defaults() {
        assert_eq!(normalize(0.5, 0.0, 1.0), 0.5);
        assert_eq!(normalize(0.2, 0.0, 1.0), 0.2);
        assert_eq!(normalize(0.0, 0.0, 1.0), 0.0);
        assert_eq!(normalize(1.0, 0.0, 1.0), 1.0);
    }

    // Panic: if user tries to normalize value that is not in his min-max range,
    // the given rgb value would most likely be unwanted.
    #[test]
    #[should_panic]
    fn normalize_with_value_not_in_range() {
        assert_eq!(normalize(-0.5, 0.0, 1.0), 666.0);
    }

    #[test]
    fn normalize_with_negative_to_positive_range() {
        assert_eq!(normalize(0.0, -0.5, 0.5), 0.5);
        assert_eq!(normalize(0.5, -0.5, 0.5), 1.0);
        assert_eq!(normalize(-0.5, -0.5, 0.5), 0.0);
        assert_eq!(normalize(0.2, -0.5, 0.5), 0.7);
    }

    // It is better to panic rather than have implicit behaviour.
    // If min and max value are the same: division with zero value would occur
    // Possible implicit behaviour could be just to return value 1.0.
    #[test]
    #[should_panic]
    fn normalize_with_incorrect_range() {
        assert_eq!(normalize(1.1, 1.1, 1.1), 0.0);
    }
}