#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[repr(C)]
pub struct Hsl {
pub h: f32,
pub s: f32,
pub l: f32,
}
impl Hsl {
#[must_use]
pub const fn new(h: f32, s: f32, l: f32) -> Self {
Self { h, s, l }
}
#[must_use]
pub fn lerp(self, other: Self, t: f32) -> Self {
use crate::space::math::{lerp_f32, lerp_hue};
Self {
h: lerp_hue(self.h, other.h, t),
s: lerp_f32(self.s, other.s, t),
l: lerp_f32(self.l, other.l, t),
}
}
#[must_use]
pub fn lighten(self, amount: f32) -> Self {
Self {
l: (self.l + amount).clamp(0.0, 1.0),
..self
}
}
#[must_use]
pub fn darken(self, amount: f32) -> Self {
Self {
l: (self.l - amount).clamp(0.0, 1.0),
..self
}
}
#[must_use]
pub fn saturate(self, amount: f32) -> Self {
Self {
s: (self.s + amount).clamp(0.0, 1.0),
..self
}
}
#[must_use]
pub fn desaturate(self, amount: f32) -> Self {
Self {
s: (self.s - amount).clamp(0.0, 1.0),
..self
}
}
#[must_use]
pub fn complement(self) -> Self {
use crate::space::math::rem_euclid;
Self {
h: rem_euclid(self.h + 0.5, 1.0),
..self
}
}
#[must_use]
pub fn hue_degrees(self) -> f32 {
self.h * 360.0
}
#[must_use]
pub fn from_degrees(h_deg: f32, s: f32, l: f32) -> Self {
Self::new(h_deg / 360.0, s, l)
}
}
impl From<[f32; 3]> for Hsl {
fn from([h, s, l]: [f32; 3]) -> Self {
Self { h, s, l }
}
}
impl From<Hsl> for [f32; 3] {
fn from(c: Hsl) -> Self {
[c.h, c.s, c.l]
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
use crate::space::Srgb;
#[test]
fn red_to_hsl() {
let hsl = Hsl::from(Srgb::RED);
assert!(
hsl.h.abs() < 1e-4 || (hsl.h - 1.0).abs() < 1e-4,
"hue={}",
hsl.h
);
assert!((hsl.s - 1.0).abs() < 1e-4, "sat={}", hsl.s);
assert!((hsl.l - 0.5).abs() < 1e-4, "lit={}", hsl.l);
}
#[test]
fn green_to_hsl() {
let hsl = Hsl::from(Srgb::GREEN);
assert!((hsl.h - 1.0 / 3.0).abs() < 1e-4, "hue={}", hsl.h);
assert!((hsl.s - 1.0).abs() < 1e-4, "sat={}", hsl.s);
assert!((hsl.l - 0.5).abs() < 1e-4, "lit={}", hsl.l);
}
#[test]
fn hsl_to_srgb_roundtrip() {
let original = Srgb::new(0.8, 0.3, 0.5);
let hsl = Hsl::from(original);
let back = Srgb::from(hsl);
assert!(
(back.r - original.r).abs() < 1e-5,
"r: {} vs {}",
back.r,
original.r
);
assert!(
(back.g - original.g).abs() < 1e-5,
"g: {} vs {}",
back.g,
original.g
);
assert!(
(back.b - original.b).abs() < 1e-5,
"b: {} vs {}",
back.b,
original.b
);
}
#[test]
fn gray_has_zero_saturation() {
let gray = Srgb::new(0.5, 0.5, 0.5);
let hsl = Hsl::from(gray);
assert!(hsl.s < 1e-5, "saturation={}", hsl.s);
assert!((hsl.l - 0.5).abs() < 1e-5, "lightness={}", hsl.l);
}
#[test]
fn lighten() {
let hsl = Hsl::new(0.0, 1.0, 0.3).lighten(0.2);
assert!((hsl.l - 0.5).abs() < 1e-6);
}
#[test]
fn darken() {
let hsl = Hsl::new(0.0, 1.0, 0.8).darken(0.2);
assert!((hsl.l - 0.6).abs() < 1e-6);
}
#[test]
fn saturate() {
let hsl = Hsl::new(0.0, 0.3, 0.5).saturate(0.3);
assert!((hsl.s - 0.6).abs() < 1e-6);
}
#[test]
fn desaturate() {
let hsl = Hsl::new(0.0, 1.0, 0.5).desaturate(0.4);
assert!((hsl.s - 0.6).abs() < 1e-6);
}
#[test]
fn lighten_clamps() {
let hsl = Hsl::new(0.0, 1.0, 0.9).lighten(0.5);
assert_eq!(hsl.l, 1.0);
}
#[test]
fn complement_red_is_cyan() {
let red = Hsl::new(0.0, 1.0, 0.5);
let cyan = red.complement();
assert!((cyan.h - 0.5).abs() < 1e-6);
}
#[test]
fn complement_wraps() {
let hsl = Hsl::new(0.7, 1.0, 0.5);
let comp = hsl.complement();
assert!((comp.h - 0.2).abs() < 1e-6);
}
#[test]
fn hue_degrees_roundtrip() {
let hsl = Hsl::new(0.25, 1.0, 0.5);
assert!((hsl.hue_degrees() - 90.0).abs() < 1e-4);
let hsl2 = Hsl::from_degrees(90.0, 1.0, 0.5);
assert!((hsl2.h - 0.25).abs() < 1e-4);
}
#[test]
fn lerp_hue_shortest_path() {
let a = Hsl::new(0.9, 1.0, 0.5);
let b = Hsl::new(0.1, 1.0, 0.5);
let mid = a.lerp(b, 0.5);
assert!(mid.h < 0.05 || mid.h > 0.95, "hue={}", mid.h);
}
}