cube_helix 0.1.1

Rust implementation of Dave Green's cubehelix colour scheme.
Documentation
//! 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);
    }
}