feat(palette): add palette expansion via color interpolation

Add `Color::euclidian_distance` and `Rgb::lerp`, then use them in a new
`Palette<T>` type whose `expand` method adds colors between close
pairs. Split `palette.rs` into a module, with `NORD_PALETTE` in its own
file.
This commit is contained in:
2026-08-28 13:41:18 +02:00
parent f1fd2a1edd
commit 4cbc25f2d6
4 changed files with 229 additions and 23 deletions
+56 -3
View File
@@ -1,7 +1,14 @@
use std::sync::LazyLock;
use wide::f64x4;
pub trait Color {}
pub trait Color {
/// Computes the Euclidean distance between `self` and `other`.
///
/// This method finds the difference of each component, squares each
/// difference, sums the squares, then takes the square root of the
/// sum.
fn euclidian_distance(&self, other: &Self) -> f64;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgb {
@@ -10,7 +17,36 @@ pub struct Rgb {
pub b: u8,
}
impl Color for Rgb {}
impl Color for Rgb {
fn euclidian_distance(&self, other: &Self) -> f64 {
let diff_r = (self.r as f64) - (other.r as f64);
let diff_g = (self.g as f64) - (other.g as f64);
let diff_b = (self.b as f64) - (other.b as f64);
(diff_r * diff_r + diff_g * diff_g + diff_b * diff_b).sqrt()
}
}
impl Rgb {
/// Blends `self` and `other` by `alpha`.
///
/// This method finds a color between `self` and `other`. An `alpha`
/// of `0.0` returns `self`. An `alpha` of `1.0` returns `other`. A
/// value between `0.0` and `1.0` mixes the two colors in that
/// proportion.
///
/// The name `lerp` is short for "linear interpolation," a common term
/// in graphics code.
pub fn lerp(&self, other: &Rgb, alpha: f64) -> Rgb {
let r = (1.0 - alpha) * self.r as f64 + alpha * other.r as f64;
let g = (1.0 - alpha) * self.g as f64 + alpha * other.g as f64;
let b = (1.0 - alpha) * self.b as f64 + alpha * other.b as f64;
Rgb {
r: r as u8,
g: g as u8,
b: b as u8,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Lab {
@@ -19,7 +55,14 @@ pub struct Lab {
pub b: f64,
}
impl Color for Lab {}
impl Color for Lab {
fn euclidian_distance(&self, other: &Self) -> f64 {
let diff_l = self.l - other.l;
let diff_a = self.a - other.a;
let diff_b = self.b - other.b;
(diff_l * diff_l + diff_a * diff_a + diff_b * diff_b).sqrt()
}
}
/// Constants for the sRGB transfer function. `srgb_to_linear` uses these
/// to convert a normalized (`0.0..=1.0`) sRGB channel value to linear-light
@@ -264,6 +307,16 @@ impl RgbPlanar {
}
}
impl From<&[Rgb]> for RgbPlanar {
fn from(value: &[Rgb]) -> Self {
// let collected: Vec<(u8, u8, u8)> = value.iter().map(|rgb| (rgb.r, rgb.g, rgb.b)).collect();
let r: Vec<f64> = value.iter().map(|rgb| rgb.r as f64).collect();
let g: Vec<f64> = value.iter().map(|rgb| rgb.g as f64).collect();
let b: Vec<f64> = value.iter().map(|rgb| rgb.b as f64).collect();
Self { r, g, b }
}
}
impl PlanarColor<Rgb> for RgbPlanar {
fn len(&self) -> usize {
self.r.len()
-20
View File
@@ -1,20 +0,0 @@
use crate::color::Rgb;
pub const NORD_PALETTE: [Rgb; 16] = [
Rgb {r: 46, g: 52, b: 64,}, // nord0
Rgb {r: 59, g: 66, b: 82,}, // nord1
Rgb {r: 67, g: 76, b: 94,}, // nord2
Rgb {r: 76, g: 86, b: 106,}, // nord3
Rgb {r: 216, g: 222, b: 233,}, // nord4
Rgb {r: 229, g: 233, b: 240,}, // nord5
Rgb {r: 236, g: 239, b: 244,}, // nord6
Rgb {r: 143, g: 188, b: 187,}, // nord7
Rgb {r: 136, g: 192, b: 208,}, // nord8
Rgb {r: 129, g: 161, b: 193,}, // nord9
Rgb {r: 94, g: 129, b: 172,}, // nord10
Rgb {r: 191, g: 97, b: 106,}, // nord11
Rgb {r: 208, g: 135, b: 112,}, // nord12
Rgb {r: 235, g: 203, b: 139,}, // nord13
Rgb {r: 163, g: 190, b: 140,}, // nord14
Rgb {r: 180, g: 142, b: 173,}, // nord15
];
+89
View File
@@ -0,0 +1,89 @@
use crate::color::{Color, LabPlanar, Rgb, RgbPlanar};
mod nord;
pub use nord::NORD_PALETTE;
pub trait Expansion {}
pub struct CanExpand;
pub struct CannotExpand;
impl Expansion for CanExpand {}
impl Expansion for CannotExpand {}
pub struct Palette<T: Expansion> {
colors: Vec<Rgb>,
rgb_planar: RgbPlanar,
lab_planar: LabPlanar,
phantom: std::marker::PhantomData<T>,
}
impl<T: Expansion> Palette<T> {
pub fn new(colors: Vec<Rgb>) -> Self {
let rgb_planar: RgbPlanar = (&(*colors)).into();
let lab_planar: LabPlanar = (&rgb_planar).into();
Self {
colors,
rgb_planar,
lab_planar,
phantom: std::marker::PhantomData,
}
}
pub fn colors(&self) -> &[Rgb] {
&self.colors
}
pub fn len(&self) -> usize {
self.colors.len()
}
pub fn is_empty(&self) -> bool {
self.colors.is_empty()
}
pub(crate) fn rgb_planar(&self) -> &RgbPlanar {
&self.rgb_planar
}
pub(crate) fn lab_planar(&self) -> &LabPlanar {
&self.lab_planar
}
}
/// The max distance between two colors before `Palette::expand` treats
/// them as too different to interpolate.
///
/// `expand` measures distance with `Color::euclidian_distance`. It skips
/// a pair of colors when their distance is at or above this value. This
/// value matches the threshold in the `nordify` Python reference
/// implementation.
const EXPANSION_DISTANCE_THRESHOLD: f64 = 150.0;
impl Palette<CanExpand> {
/// Builds a new palette that includes every color from `self`,
/// plus extra colors interpolated between close pairs.
///
/// This method compares every pair of colors in the palette. If
/// the distance between a pair is less than
/// `EXPANSION_DISTANCE_THRESHOLD`, the method adds
/// `expansion_factor - 1` new colors between them. Each new color
/// is a linear interpolation of the pair, spaced evenly from one
/// color to the other.
///
/// A low `expansion_factor` value (`0` or `1`) adds no new colors.
#[must_use]
pub fn expand(&self, expansion_factor: usize) -> Palette<CannotExpand> {
let mut colors = self.colors.clone();
for (i, color1) in self.colors.iter().enumerate() {
for color2 in self.colors.iter().skip(i + 1) {
let distance = color1.euclidian_distance(color2);
if distance < EXPANSION_DISTANCE_THRESHOLD {
for step in 1..expansion_factor {
let alpha = step as f64 / expansion_factor as f64;
colors.push(color1.lerp(color2, alpha));
}
}
}
}
Palette::<CannotExpand>::new(colors)
}
}
+84
View File
@@ -0,0 +1,84 @@
use crate::color::Rgb;
pub const NORD_PALETTE: [Rgb; 16] = [
Rgb {
r: 46,
g: 52,
b: 64,
}, // nord0
Rgb {
r: 59,
g: 66,
b: 82,
}, // nord1
Rgb {
r: 67,
g: 76,
b: 94,
}, // nord2
Rgb {
r: 76,
g: 86,
b: 106,
}, // nord3
Rgb {
r: 216,
g: 222,
b: 233,
}, // nord4
Rgb {
r: 229,
g: 233,
b: 240,
}, // nord5
Rgb {
r: 236,
g: 239,
b: 244,
}, // nord6
Rgb {
r: 143,
g: 188,
b: 187,
}, // nord7
Rgb {
r: 136,
g: 192,
b: 208,
}, // nord8
Rgb {
r: 129,
g: 161,
b: 193,
}, // nord9
Rgb {
r: 94,
g: 129,
b: 172,
}, // nord10
Rgb {
r: 191,
g: 97,
b: 106,
}, // nord11
Rgb {
r: 208,
g: 135,
b: 112,
}, // nord12
Rgb {
r: 235,
g: 203,
b: 139,
}, // nord13
Rgb {
r: 163,
g: 190,
b: 140,
}, // nord14
Rgb {
r: 180,
g: 142,
b: 173,
}, // nord15
];