colr 0.3.0

A general purpose, extensible color type unifying color models and their operations at the type level.
// Generating a perceptually smooth gradient between two colours using Oklch.
//
// Blending colours in plain RGB often looks wrong. The middle of the gradient
// can appear darker or muddier than expected, because RGB was designed for
// screens rather than for how humans see colour.
//
// Oklch describes colour the way we think about it:
//   L  lightness  (0 = black, 1 = white)
//   C  vividness  (0 = grey, higher = more colourful)
//   H  hue        (which colour: red, orange, yellow, green, ...)
//
// Blending in Oklch keeps lightness and vividness even, and moves the hue
// along the shortest path. No muddy browns or surprise swings through grey.
//
// One catch: screens can only show a limited range of colours. Even if both
// starting colours are displayable, the path between them can pass through
// colours that are too vivid to show, especially in the purple range. The
// check below catches this. If it fails, use slightly less vivid colours.

use std::fs::File;
use std::io::BufWriter;

use rand::Rng;

use colr::illuminant::D65;
use colr::layout::Rgb;
use colr::{Color, LinearSrgb, Oklch, Srgb, Xyz};

// Converts sRGB to Oklch for blending.
//
// sRGB values are gamma-encoded for display hardware, not for maths. We
// decode them to linear light first, then convert through XYZ, the
// intermediate space all colour conversions in this library route through.
fn srgb_to_oklch(c: Color<[f32; 3], Srgb<Rgb>>) -> Color<[f32; 3], Oklch> {
    let linear: Color<[f32; 3], LinearSrgb<Rgb>> = c.decode();
    let xyz: Color<[f32; 3], Xyz<D65>> = linear.into();
    xyz.into()
}

// Converts Oklch back to sRGB for display.
fn oklch_to_srgb(c: Color<[f32; 3], Oklch>) -> Color<[f32; 3], Srgb<Rgb>> {
    let xyz: Color<[f32; 3], Xyz<D65>> = c.into();
    let linear: Color<[f32; 3], LinearSrgb<Rgb>> = xyz.into();
    linear.encode()
}

// Returns true if all channels are in [0, 1], meaning the colour can be shown
// on a standard screen.
fn is_displayable(c: Color<[f32; 3], Srgb<Rgb>>) -> bool {
    c.inner().iter().all(|&v| (0.0..=1.0).contains(&v))
}

const STEPS: usize = 8;

fn main() {
    // Starting colours as plain sRGB, the kind of values from a colour picker.
    // These are slightly less vivid than pure red/blue so the gradient stays
    // within what a screen can show. If you want more vivid colours, try them
    // and see if the check below passes. If it does not, dial back the vividness.
    let red_srgb: Color<[f32; 3], Srgb<Rgb>> = Color::new([0.769, 0.180, 0.154]);
    let blue_srgb: Color<[f32; 3], Srgb<Rgb>> = Color::new([0.114, 0.240, 0.845]);

    let red = srgb_to_oklch(red_srgb);
    let blue = srgb_to_oklch(blue_srgb);

    // Check every step of the gradient is actually displayable. Even with
    // displayable endpoints, the path can pass through colours too vivid for
    // sRGB, particularly in the purple range. If this fires, reduce vividness.
    let all_displayable = (0..=STEPS)
        .map(|i| red.lerp(blue, i as f32 / STEPS as f32))
        .map(oklch_to_srgb)
        .all(is_displayable);

    assert!(
        all_displayable,
        "some gradient steps cannot be shown on a standard screen, use less vivid colours"
    );

    // Render a 512x64 PNG where each column is one point along the gradient.
    let width: u32 = 512;
    let height: u32 = 64;

    let columns: Vec<Color<[f32; 3], Srgb<Rgb>>> = (0..width)
        .map(|x| {
            let t = x as f32 / (width - 1) as f32;
            oklch_to_srgb(red.lerp(blue, t))
        })
        .collect();

    save_to_png("oklch_gradient.png", &columns, width, height);
    println!("Wrote oklch_gradient.png");
}

// Writes a horizontal gradient to a PNG file. `columns` holds one float sRGB
// value per column. Dithering is applied per pixel before quantizing to 8-bit,
// which breaks up the banding that appears in smooth gradients.
fn save_to_png(path: &str, columns: &[Color<[f32; 3], Srgb<Rgb>>], width: u32, height: u32) {
    let mut rng = rand::rng();
    let range = 0.0..(1.0 / 255.0);

    let mut data = vec![0u8; (width * height * 3) as usize];
    for y in 0..height as usize {
        for (x, &colour) in columns.iter().enumerate() {
            let dither = rng.random_range(range.clone()) - rng.random_range(range.clone());
            let [r, g, b] = colour.to_u8_dithered(dither).inner();
            let dst = (y * width as usize + x) * 3;
            data[dst] = r;
            data[dst + 1] = g;
            data[dst + 2] = b;
        }
    }

    let file = BufWriter::new(File::create(path).unwrap());
    let mut encoder = png::Encoder::new(file, width, height);
    encoder.set_color(png::ColorType::Rgb);
    encoder.set_depth(png::BitDepth::Eight);
    encoder
        .write_header()
        .unwrap()
        .write_image_data(&data)
        .unwrap();
}