From d9aef9797cff10da937edc142168fe563e48b92f Mon Sep 17 00:00:00 2001 From: Aaron Allen Date: Mon, 9 Feb 2026 09:34:23 -0600 Subject: [PATCH] Add Oklab family of perceptual color spaces Introduces four new perceptual color spaces: Oklab, Oklch, Okhsl, and Okhsv. Each is feature-gated behind its own flag (`space-oklab`, `space-oklch`, `space-okhsl`, `space-okhsv`) with a convenience `space-oklab-family` feature that enables all four. Oklab and Oklch provide perceptual uniformity for color manipulation. Okhsl and Okhsv extend the family with hue-saturation-lightness and hue-saturation-value cylindrical forms. All spaces support full round-trip conversions through XYZ, direct conversion from Rgb via linear sRGB, and alpha channel preservation. Adds `to_oklab()`, `to_oklch()`, `to_okhsl()`, and `to_okhsv()` methods to the `ColorSpace` trait and `From` implementations on Xyz, Lms, and Rgb for seamless interoperability with existing color spaces. --- CHANGELOG.md | 5 +- Cargo.toml | 6 + src/space.rs | 33 +- src/space/cie/xyz.rs | 81 ++ src/space/cylindrical/hsl.rs | 48 + src/space/cylindrical/hsv.rs | 48 + src/space/cylindrical/hwb.rs | 48 + src/space/perceptual.rs | 17 + src/space/perceptual/okhsl.rs | 1660 +++++++++++++++++++++++++++++++ src/space/perceptual/okhsv.rs | 1665 ++++++++++++++++++++++++++++++++ src/space/perceptual/oklab.rs | 1396 ++++++++++++++++++++++++++ src/space/perceptual/oklch.rs | 1376 ++++++++++++++++++++++++++ src/space/physiological/lms.rs | 36 + src/space/rgb/space.rs | 113 +++ src/space/subtractive/cmy.rs | 48 + src/space/subtractive/cmyk.rs | 48 + 16 files changed, 6623 insertions(+), 5 deletions(-) create mode 100644 src/space/perceptual.rs create mode 100644 src/space/perceptual/okhsl.rs create mode 100644 src/space/perceptual/okhsv.rs create mode 100644 src/space/perceptual/oklab.rs create mode 100644 src/space/perceptual/oklch.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b7ad3..3328f7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve - Add alpha/opacity support to all color spaces (`Xyz`, `Lms`, `Rgb`, `LinearRgb`, `Hsl`, `Hsv`, `Hwb`, `Cmy`, `Cmyk`) with `alpha()`, `set_alpha()`, `with_alpha()`, `opacity()`, `set_opacity()`, `with_opacity()`, and increment/decrement/scale variants on the `ColorSpace` trait +- Add `Oklab` perceptual color space with full conversion support, feature-gated behind `space-oklab` +- Add `Oklch` perceptual color space (cylindrical form of Oklab) with full conversion support, feature-gated behind `space-oklch` +- Add `Okhsl` perceptual color space with full conversion support, feature-gated behind `space-okhsl` +- Add `Okhsv` perceptual color space with full conversion support, feature-gated behind `space-okhsv` - Add alpha compositing on `Rgb` via `flatten_alpha()`, `flatten_alpha_against()`, and their `with_*` builder variants - Add `Rgb::BLACK` and `Rgb::WHITE` associated constants -- Preserve alpha through all color space conversions and chromatic adaptation transforms ### Changed diff --git a/Cargo.toml b/Cargo.toml index 4b31138..4ca3ba3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ all-spaces = [ "space-hsl", "space-hsv", "space-hwb", + "space-oklab-family", ] cat-bradford = [] cat-cat02 = [] @@ -270,6 +271,11 @@ space-hsb = ["space-hsv"] space-hsl = [] space-hsv = [] space-hwb = [] +space-okhsl = ["space-oklab"] +space-okhsv = ["space-oklab"] +space-oklab = [] +space-oklab-family = ["space-okhsl", "space-okhsv", "space-oklab", "space-oklch"] +space-oklch = ["space-oklab"] [dev-dependencies] pretty_assertions = "1.4" diff --git a/src/space.rs b/src/space.rs index d7736d8..bcb5cc8 100644 --- a/src/space.rs +++ b/src/space.rs @@ -1,17 +1,18 @@ mod cie; -#[cfg(any(feature = "space-hsl", feature = "space-hsv", feature = "space-hwb"))] mod cylindrical; +mod perceptual; mod physiological; mod rgb; -#[cfg(any(feature = "space-cmy", feature = "space-cmyk"))] mod subtractive; pub use cie::*; -#[cfg(any(feature = "space-hsl", feature = "space-hsv", feature = "space-hwb"))] +#[allow(unused_imports)] pub use cylindrical::*; +#[allow(unused_imports)] +pub use perceptual::*; pub use physiological::*; pub use rgb::*; -#[cfg(any(feature = "space-cmy", feature = "space-cmyk"))] +#[allow(unused_imports)] pub use subtractive::*; use crate::{chromaticity::Xy, component::Component}; @@ -218,6 +219,30 @@ pub trait ColorSpace: Copy + Clone + From { self.to_xyz().to_lms().with_alpha(self.alpha()) } + #[cfg(feature = "space-okhsl")] + /// Converts to the Okhsl perceptual color space. + fn to_okhsl(&self) -> Okhsl { + self.to_oklab().to_okhsl().with_alpha(self.alpha()) + } + + #[cfg(feature = "space-okhsv")] + /// Converts to the Okhsv perceptual color space. + fn to_okhsv(&self) -> Okhsv { + self.to_oklab().to_okhsv().with_alpha(self.alpha()) + } + + #[cfg(feature = "space-oklab")] + /// Converts to the Oklab perceptual color space. + fn to_oklab(&self) -> Oklab { + self.to_xyz().to_oklab().with_alpha(self.alpha()) + } + + #[cfg(feature = "space-oklch")] + /// Converts to the Oklch perceptual color space. + fn to_oklch(&self) -> Oklch { + self.to_oklab().to_oklch().with_alpha(self.alpha()) + } + /// Converts to the specified RGB color space. fn to_rgb(&self) -> Rgb where diff --git a/src/space/cie/xyz.rs b/src/space/cie/xyz.rs index ebc42e9..30685f0 100644 --- a/src/space/cie/xyz.rs +++ b/src/space/cie/xyz.rs @@ -13,6 +13,14 @@ use crate::space::Hsl; use crate::space::Hsv; #[cfg(feature = "space-hwb")] use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklab")] +use crate::space::Oklab; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; use crate::{ ColorimetricContext, chromaticity::Xy, @@ -233,6 +241,17 @@ impl Xyz { .with_alpha(self.alpha) } + #[cfg(feature = "space-oklab")] + /// Converts to the Oklab perceptual color space. + pub fn to_oklab(&self) -> Oklab { + let adapted = self.adapt_to(Oklab::DEFAULT_CONTEXT); + let linear_lms = Oklab::LINEAR_XYZ_MATRIX * adapted.components(); + let cube_root_lms = [linear_lms[0].cbrt(), linear_lms[1].cbrt(), linear_lms[2].cbrt()]; + let [l, a, b] = Oklab::LINEAR_LMS_MATRIX * cube_root_lms; + + Oklab::new(l, a, b).with_alpha(self.alpha) + } + /// Converts to the specified RGB color space. pub fn to_rgb(&self) -> Rgb where @@ -529,6 +548,34 @@ impl From for Xyz { } } +#[cfg(feature = "space-okhsl")] +impl From for Xyz { + fn from(okhsl: Okhsl) -> Self { + okhsl.to_xyz() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Xyz { + fn from(okhsv: Okhsv) -> Self { + okhsv.to_xyz() + } +} + +#[cfg(feature = "space-oklab")] +impl From for Xyz { + fn from(oklab: Oklab) -> Self { + oklab.to_xyz() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Xyz { + fn from(oklch: Oklch) -> Self { + oklch.to_xyz() + } +} + impl From> for Xyz where S: RgbSpec, @@ -1341,6 +1388,40 @@ mod test { } } + #[cfg(feature = "space-oklab")] + mod to_oklab { + use super::*; + + #[test] + fn it_converts_to_oklab() { + let xyz = Xyz::new(0.5, 0.5, 0.5); + let oklab = xyz.to_oklab(); + + assert!(oklab.l() > 0.0); + assert!(oklab.l().is_finite()); + assert!(oklab.a().is_finite()); + assert!(oklab.b().is_finite()); + } + + #[test] + fn it_roundtrips_through_oklab() { + let original = Xyz::new(0.5, 0.5, 0.5); + let roundtrip = original.to_oklab().to_xyz(); + + assert!((original.x() - roundtrip.x()).abs() < 1e-10); + assert!((original.y() - roundtrip.y()).abs() < 1e-10); + assert!((original.z() - roundtrip.z()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let xyz = Xyz::new(0.5, 0.5, 0.5).with_alpha(0.3); + let oklab = xyz.to_oklab(); + + assert!((oklab.alpha() - 0.3).abs() < 1e-10); + } + } + mod to_rgb { use pretty_assertions::assert_eq; diff --git a/src/space/cylindrical/hsl.rs b/src/space/cylindrical/hsl.rs index efe032e..5cf434a 100644 --- a/src/space/cylindrical/hsl.rs +++ b/src/space/cylindrical/hsl.rs @@ -10,6 +10,14 @@ use crate::space::Cmy; use crate::space::Cmyk; #[cfg(feature = "space-hwb")] use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklab")] +use crate::space::Oklab; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; #[cfg(feature = "space-hsv")] use crate::space::{Hsb, Hsv}; use crate::{ @@ -628,6 +636,46 @@ where } } +#[cfg(feature = "space-okhsl")] +impl From for Hsl +where + S: RgbSpec, +{ + fn from(okhsl: Okhsl) -> Self { + okhsl.to_rgb::().to_hsl() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Hsl +where + S: RgbSpec, +{ + fn from(okhsv: Okhsv) -> Self { + okhsv.to_rgb::().to_hsl() + } +} + +#[cfg(feature = "space-oklab")] +impl From for Hsl +where + S: RgbSpec, +{ + fn from(oklab: Oklab) -> Self { + oklab.to_rgb::().to_hsl() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Hsl +where + S: RgbSpec, +{ + fn from(oklch: Oklch) -> Self { + oklch.to_rgb::().to_hsl() + } +} + impl From> for Hsl where OS: RgbSpec, diff --git a/src/space/cylindrical/hsv.rs b/src/space/cylindrical/hsv.rs index 395c52f..16948d3 100644 --- a/src/space/cylindrical/hsv.rs +++ b/src/space/cylindrical/hsv.rs @@ -12,6 +12,14 @@ use crate::space::Cmyk; use crate::space::Hsl; #[cfg(feature = "space-hwb")] use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklab")] +use crate::space::Oklab; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; use crate::{ ColorimetricContext, component::Component, @@ -707,6 +715,46 @@ where } } +#[cfg(feature = "space-okhsl")] +impl From for Hsv +where + S: RgbSpec, +{ + fn from(okhsl: Okhsl) -> Self { + okhsl.to_rgb::().to_hsv() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Hsv +where + S: RgbSpec, +{ + fn from(okhsv: Okhsv) -> Self { + okhsv.to_rgb::().to_hsv() + } +} + +#[cfg(feature = "space-oklab")] +impl From for Hsv +where + S: RgbSpec, +{ + fn from(oklab: Oklab) -> Self { + oklab.to_rgb::().to_hsv() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Hsv +where + S: RgbSpec, +{ + fn from(oklch: Oklch) -> Self { + oklch.to_rgb::().to_hsv() + } +} + impl From> for Hsv where OS: RgbSpec, diff --git a/src/space/cylindrical/hwb.rs b/src/space/cylindrical/hwb.rs index 14f1c54..042e160 100644 --- a/src/space/cylindrical/hwb.rs +++ b/src/space/cylindrical/hwb.rs @@ -10,6 +10,14 @@ use crate::space::Cmy; use crate::space::Cmyk; #[cfg(feature = "space-hsl")] use crate::space::Hsl; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklab")] +use crate::space::Oklab; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; #[cfg(feature = "space-hsv")] use crate::space::{Hsb, Hsv}; use crate::{ @@ -628,6 +636,46 @@ where } } +#[cfg(feature = "space-okhsl")] +impl From for Hwb +where + S: RgbSpec, +{ + fn from(okhsl: Okhsl) -> Self { + okhsl.to_rgb::().to_hwb() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Hwb +where + S: RgbSpec, +{ + fn from(okhsv: Okhsv) -> Self { + okhsv.to_rgb::().to_hwb() + } +} + +#[cfg(feature = "space-oklab")] +impl From for Hwb +where + S: RgbSpec, +{ + fn from(oklab: Oklab) -> Self { + oklab.to_rgb::().to_hwb() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Hwb +where + S: RgbSpec, +{ + fn from(oklch: Oklch) -> Self { + oklch.to_rgb::().to_hwb() + } +} + impl From> for Hwb where OS: RgbSpec, diff --git a/src/space/perceptual.rs b/src/space/perceptual.rs new file mode 100644 index 0000000..99319ff --- /dev/null +++ b/src/space/perceptual.rs @@ -0,0 +1,17 @@ +#[cfg(feature = "space-okhsl")] +mod okhsl; +#[cfg(feature = "space-okhsv")] +mod okhsv; +#[cfg(feature = "space-oklab")] +mod oklab; +#[cfg(feature = "space-oklch")] +mod oklch; + +#[cfg(feature = "space-okhsl")] +pub use okhsl::Okhsl; +#[cfg(feature = "space-okhsv")] +pub use okhsv::Okhsv; +#[cfg(feature = "space-oklab")] +pub use oklab::Oklab; +#[cfg(feature = "space-oklch")] +pub use oklch::Oklch; diff --git a/src/space/perceptual/okhsl.rs b/src/space/perceptual/okhsl.rs new file mode 100644 index 0000000..da6c46e --- /dev/null +++ b/src/space/perceptual/okhsl.rs @@ -0,0 +1,1660 @@ +use std::{ + fmt::{Display, Formatter, Result as FmtResult}, + ops::{Add, Div, Mul, Sub}, +}; + +#[cfg(feature = "space-cmy")] +use crate::space::Cmy; +#[cfg(feature = "space-cmyk")] +use crate::space::Cmyk; +#[cfg(feature = "space-hsl")] +use crate::space::Hsl; +#[cfg(feature = "space-hsv")] +use crate::space::Hsv; +#[cfg(feature = "space-hwb")] +use crate::space::Hwb; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; +use crate::{ + ColorimetricContext, Illuminant, Observer, + component::Component, + space::{ColorSpace, Lms, Oklab, Rgb, RgbSpec, Srgb, Xyz}, +}; + +/// Okhsl perceptual color space (HSL model in the Oklab perceptual framework). +/// +/// A perceptually uniform HSL-like color space derived from Oklab. H represents +/// hue stored internally as a 0.0-1.0 fraction (0-360°), S represents saturation +/// (0.0-1.0), and L represents perceived lightness (0.0-1.0). Designed for +/// intuitive color manipulation with perceptual uniformity, using sRGB gamut +/// boundaries for saturation mapping. +#[derive(Clone, Copy, Debug)] +pub struct Okhsl { + alpha: Component, + context: ColorimetricContext, + h: Component, + l: Component, + s: Component, +} + +impl Okhsl { + /// The default viewing context for Okhsl (D65 illuminant, CIE 1931 2° observer). + pub const DEFAULT_CONTEXT: ColorimetricContext = ColorimetricContext::new() + .with_illuminant(Illuminant::D65) + .with_observer(Observer::CIE_1931_2D); + + /// Creates a new Okhsl color from hue (0-360°), saturation (0-100%), and lightness (0-100%). + pub fn new(h: impl Into, s: impl Into, l: impl Into) -> Self { + Self { + alpha: Component::new(1.0), + context: Self::DEFAULT_CONTEXT, + h: Component::new((h.into().0 / 360.0).rem_euclid(1.0)), + l: l.into() / 100.0, + s: s.into() / 100.0, + } + } + + /// Creates a new Okhsl color in a const context from hue (0-360°), saturation (0-100%), and lightness (0-100%). + pub const fn new_const(h: f64, s: f64, l: f64) -> Self { + let r = (h / 360.0) % 1.0; + + Self { + alpha: Component::new_const(1.0), + context: Self::DEFAULT_CONTEXT, + h: Component::new_const(if r < 0.0 { r + 1.0 } else { r }), + l: Component::new_const(l / 100.0), + s: Component::new_const(s / 100.0), + } + } + + /// Returns the [H, S, L] components as normalized values (all 0.0-1.0). + pub fn components(&self) -> [f64; 3] { + [self.h.0, self.s.0, self.l.0] + } + + /// Returns the viewing context for this color. + pub fn context(&self) -> &ColorimetricContext { + &self.context + } + + /// Decreases the normalized hue by the given amount (wraps around 0.0-1.0). + pub fn decrement_h(&mut self, amount: impl Into) { + self.h = Component::new((self.h.0 - amount.into().0).rem_euclid(1.0)); + } + + /// Decreases the hue by the given amount in degrees (wraps around 0-360°). + pub fn decrement_hue(&mut self, amount: impl Into) { + self.decrement_h(amount.into() / 360.0) + } + + /// Decreases the normalized lightness by the given amount. + pub fn decrement_l(&mut self, amount: impl Into) { + self.l -= amount.into(); + } + + /// Decreases the lightness by the given amount in percentage points. + pub fn decrement_lightness(&mut self, amount: impl Into) { + self.decrement_l(amount.into() / 100.0) + } + + /// Decreases the normalized saturation by the given amount. + pub fn decrement_s(&mut self, amount: impl Into) { + self.s -= amount.into(); + } + + /// Decreases the saturation by the given amount in percentage points. + pub fn decrement_saturation(&mut self, amount: impl Into) { + self.decrement_s(amount.into() / 100.0) + } + + /// Returns the normalized hue component (0.0-1.0). + pub fn h(&self) -> f64 { + self.h.0 + } + + /// Returns the hue in degrees (0-360°). + pub fn hue(&self) -> f64 { + self.h.0 * 360.0 + } + + /// Increases the normalized hue by the given amount (wraps around 0.0-1.0). + pub fn increment_h(&mut self, amount: impl Into) { + self.h = Component::new((self.h.0 + amount.into().0).rem_euclid(1.0)); + } + + /// Increases the hue by the given amount in degrees (wraps around 0-360°). + pub fn increment_hue(&mut self, amount: impl Into) { + self.increment_h(amount.into() / 360.0) + } + + /// Increases the normalized lightness by the given amount. + pub fn increment_l(&mut self, amount: impl Into) { + self.l += amount.into(); + } + + /// Increases the lightness by the given amount in percentage points. + pub fn increment_lightness(&mut self, amount: impl Into) { + self.increment_l(amount.into() / 100.0) + } + + /// Increases the normalized saturation by the given amount. + pub fn increment_s(&mut self, amount: impl Into) { + self.s += amount.into(); + } + + /// Increases the saturation by the given amount in percentage points. + pub fn increment_saturation(&mut self, amount: impl Into) { + self.increment_s(amount.into() / 100.0) + } + + /// Returns the normalized lightness component (0.0-1.0). + pub fn l(&self) -> f64 { + self.l.0 + } + + /// Returns the lightness as a percentage (0-100%). + pub fn lightness(&self) -> f64 { + self.l.0 * 100.0 + } + + /// Returns the normalized saturation component (0.0-1.0). + pub fn s(&self) -> f64 { + self.s.0 + } + + /// Returns the saturation as a percentage (0-100%). + pub fn saturation(&self) -> f64 { + self.s.0 * 100.0 + } + + /// Scales the normalized hue by the given factor (wraps around 0.0-1.0). + pub fn scale_h(&mut self, factor: impl Into) { + self.h = Component::new((self.h.0 * factor.into().0).rem_euclid(1.0)); + } + + /// Alias for [`Self::scale_h`]. + pub fn scale_hue(&mut self, factor: impl Into) { + self.scale_h(factor) + } + + /// Scales the normalized lightness by the given factor. + pub fn scale_l(&mut self, factor: impl Into) { + self.l *= factor.into(); + } + + /// Alias for [`Self::scale_l`]. + pub fn scale_lightness(&mut self, factor: impl Into) { + self.scale_l(factor) + } + + /// Scales the normalized saturation by the given factor. + pub fn scale_s(&mut self, factor: impl Into) { + self.s *= factor.into(); + } + + /// Alias for [`Self::scale_s`]. + pub fn scale_saturation(&mut self, factor: impl Into) { + self.scale_s(factor) + } + + /// Sets all three components from normalized values. + pub fn set_components(&mut self, components: [impl Into + Clone; 3]) { + self.set_h(components[0].clone()); + self.set_s(components[1].clone()); + self.set_l(components[2].clone()); + } + + /// Sets the normalized hue component (0.0-1.0). + pub fn set_h(&mut self, h: impl Into) { + self.h = h.into(); + } + + /// Sets the hue from a value in degrees (0-360°). + pub fn set_hue(&mut self, hue: impl Into) { + self.h = Component::new((hue.into().0 / 360.0).rem_euclid(1.0)); + } + + /// Sets the normalized lightness component (0.0-1.0). + pub fn set_l(&mut self, l: impl Into) { + self.l = l.into(); + } + + /// Sets the lightness from a percentage value (0-100%). + pub fn set_lightness(&mut self, lightness: impl Into) { + self.l = lightness.into() / 100.0; + } + + /// Sets the normalized saturation component (0.0-1.0). + pub fn set_s(&mut self, s: impl Into) { + self.s = s.into(); + } + + /// Sets the saturation from a percentage value (0-100%). + pub fn set_saturation(&mut self, saturation: impl Into) { + self.s = saturation.into() / 100.0; + } + + /// Converts to the Oklab perceptual color space. + pub fn to_oklab(&self) -> Oklab { + let [h, s, l] = self.components(); + let lab_l = Oklab::toe_inv(l); + let cusp = Oklab::cusp_for_hue(h); + let max_c = Oklab::max_chroma_at_lightness(cusp, lab_l); + let c = s * max_c; + + let h_rad = h * 2.0 * std::f64::consts::PI; + let a = c * h_rad.cos(); + let b = c * h_rad.sin(); + + Oklab::new(lab_l, a, b).with_alpha(self.alpha) + } + + /// Converts to the specified RGB color space. + pub fn to_rgb(&self) -> Rgb + where + S: RgbSpec, + { + self.to_oklab().to_rgb::() + } + + /// Converts to the CIE XYZ color space. + pub fn to_xyz(&self) -> Xyz { + self.to_oklab().to_xyz() + } + + /// Returns this color with a different viewing context (without adaptation). + pub fn with_context(&self, context: ColorimetricContext) -> Self { + Self { + context, + ..*self + } + } + + /// Returns a new color with the given normalized hue value. + pub fn with_h(&self, h: impl Into) -> Self { + Self { + h: h.into(), + ..*self + } + } + + /// Returns a new color with the normalized hue decreased by the given amount. + pub fn with_h_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.decrement_h(amount); + okhsl + } + + /// Returns a new color with the normalized hue increased by the given amount. + pub fn with_h_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.increment_h(amount); + okhsl + } + + /// Returns a new color with the normalized hue scaled by the given factor. + pub fn with_h_scaled_by(&self, factor: impl Into) -> Self { + let mut okhsl = *self; + okhsl.scale_h(factor); + okhsl + } + + /// Returns a new color with the given hue in degrees (0-360°). + pub fn with_hue(&self, hue: impl Into) -> Self { + Self { + h: Component::new((hue.into().0 / 360.0).rem_euclid(1.0)), + ..*self + } + } + + /// Returns a new color with the hue decreased by the given degrees. + pub fn with_hue_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.decrement_hue(amount); + okhsl + } + + /// Returns a new color with the hue increased by the given degrees. + pub fn with_hue_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.increment_hue(amount); + okhsl + } + + /// Alias for [`Self::with_h_scaled_by`]. + pub fn with_hue_scaled_by(&self, factor: impl Into) -> Self { + self.with_h_scaled_by(factor) + } + + /// Returns a new color with the given normalized lightness value. + pub fn with_l(&self, l: impl Into) -> Self { + Self { + l: l.into(), + ..*self + } + } + + /// Returns a new color with normalized lightness decreased by the given amount. + pub fn with_l_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.decrement_l(amount); + okhsl + } + + /// Returns a new color with normalized lightness increased by the given amount. + pub fn with_l_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.increment_l(amount); + okhsl + } + + /// Returns a new color with normalized lightness scaled by the given factor. + pub fn with_l_scaled_by(&self, factor: impl Into) -> Self { + let mut okhsl = *self; + okhsl.scale_l(factor); + okhsl + } + + /// Returns a new color with the given lightness in percentage (0-100%). + pub fn with_lightness(&self, lightness: impl Into) -> Self { + Self { + l: lightness.into() / 100.0, + ..*self + } + } + + /// Returns a new color with lightness decreased by the given percentage points. + pub fn with_lightness_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.decrement_lightness(amount); + okhsl + } + + /// Returns a new color with lightness increased by the given percentage points. + pub fn with_lightness_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.increment_lightness(amount); + okhsl + } + + /// Alias for [`Self::with_l_scaled_by`]. + pub fn with_lightness_scaled_by(&self, factor: impl Into) -> Self { + self.with_l_scaled_by(factor) + } + + /// Returns a new color with the given normalized saturation value. + pub fn with_s(&self, s: impl Into) -> Self { + Self { + s: s.into(), + ..*self + } + } + + /// Returns a new color with normalized saturation decreased by the given amount. + pub fn with_s_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.decrement_s(amount); + okhsl + } + + /// Returns a new color with normalized saturation increased by the given amount. + pub fn with_s_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.increment_s(amount); + okhsl + } + + /// Returns a new color with normalized saturation scaled by the given factor. + pub fn with_s_scaled_by(&self, factor: impl Into) -> Self { + let mut okhsl = *self; + okhsl.scale_s(factor); + okhsl + } + + /// Returns a new color with the given saturation in percentage (0-100%). + pub fn with_saturation(&self, saturation: impl Into) -> Self { + Self { + s: saturation.into() / 100.0, + ..*self + } + } + + /// Returns a new color with saturation decreased by the given percentage points. + pub fn with_saturation_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.decrement_saturation(amount); + okhsl + } + + /// Returns a new color with saturation increased by the given percentage points. + pub fn with_saturation_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsl = *self; + okhsl.increment_saturation(amount); + okhsl + } + + /// Alias for [`Self::with_s_scaled_by`]. + pub fn with_saturation_scaled_by(&self, factor: impl Into) -> Self { + self.with_s_scaled_by(factor) + } +} + +impl Add for Okhsl +where + T: Into, +{ + type Output = Self; + + fn add(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() + rhs.into().to_rgb::()) + } +} + +impl ColorSpace<3> for Okhsl { + fn alpha(&self) -> f64 { + self.alpha.0 + } + + fn components(&self) -> [f64; 3] { + self.components() + } + + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + + fn set_components(&mut self, components: [impl Into + Clone; 3]) { + self.set_components(components) + } + + fn to_xyz(&self) -> Xyz { + self.to_xyz() + } +} + +impl Display for Okhsl { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let precision = f.precision().unwrap_or(2); + if self.alpha.0 < 1.0 { + write!( + f, + "Okhsl({:.precision$}°, {:.precision$}%, {:.precision$}%, {:.0}%)", + self.hue(), + self.saturation(), + self.lightness(), + self.opacity() + ) + } else { + write!( + f, + "Okhsl({:.precision$}°, {:.precision$}%, {:.precision$}%)", + self.hue(), + self.saturation(), + self.lightness() + ) + } + } +} + +impl Div for Okhsl +where + T: Into, +{ + type Output = Self; + + fn div(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() / rhs.into().to_rgb::()) + } +} + +impl From<[T; 3]> for Okhsl +where + T: Into, +{ + fn from([h, s, l]: [T; 3]) -> Self { + Self::new(h, s, l) + } +} + +#[cfg(feature = "space-cmy")] +impl From> for Okhsl +where + S: RgbSpec, +{ + fn from(cmy: Cmy) -> Self { + cmy.to_okhsl() + } +} + +#[cfg(feature = "space-cmyk")] +impl From> for Okhsl +where + S: RgbSpec, +{ + fn from(cmyk: Cmyk) -> Self { + cmyk.to_okhsl() + } +} + +#[cfg(feature = "space-hsl")] +impl From> for Okhsl +where + S: RgbSpec, +{ + fn from(hsl: Hsl) -> Self { + hsl.to_okhsl() + } +} + +#[cfg(feature = "space-hsv")] +impl From> for Okhsl +where + S: RgbSpec, +{ + fn from(hsv: Hsv) -> Self { + hsv.to_okhsl() + } +} + +#[cfg(feature = "space-hwb")] +impl From> for Okhsl +where + S: RgbSpec, +{ + fn from(hwb: Hwb) -> Self { + hwb.to_okhsl() + } +} + +impl From for Okhsl { + fn from(lms: Lms) -> Self { + lms.to_okhsl() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Okhsl { + fn from(okhsv: Okhsv) -> Self { + okhsv.to_okhsl() + } +} + +impl From for Okhsl { + fn from(oklab: Oklab) -> Self { + oklab.to_okhsl() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Okhsl { + fn from(oklch: Oklch) -> Self { + oklch.to_okhsl() + } +} + +impl From> for Okhsl +where + S: RgbSpec, +{ + fn from(rgb: Rgb) -> Self { + rgb.to_okhsl() + } +} + +impl From for Okhsl { + fn from(xyz: Xyz) -> Self { + xyz.to_okhsl() + } +} + +impl Mul for Okhsl +where + T: Into, +{ + type Output = Self; + + fn mul(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() * rhs.into().to_rgb::()) + } +} + +impl PartialEq for Okhsl +where + T: Into + Copy, +{ + fn eq(&self, other: &T) -> bool { + let other = (*other).into(); + self.alpha == other.alpha && self.h == other.h && self.s == other.s && self.l == other.l + } +} + +impl Sub for Okhsl +where + T: Into, +{ + type Output = Self; + + fn sub(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() - rhs.into().to_rgb::()) + } +} + +impl TryFrom<&str> for Okhsl { + type Error = crate::Error; + + fn try_from(value: &str) -> Result { + Ok(Self::from(Rgb::::try_from(value)?.to_xyz())) + } +} + +impl TryFrom for Okhsl { + type Error = crate::Error; + + fn try_from(value: String) -> Result { + Ok(Self::from(Rgb::::try_from(value)?.to_xyz())) + } +} + +#[cfg(test)] +mod test { + use super::*; + + mod add { + use super::*; + + #[test] + fn it_adds_two_okhsl_values_via_rgb() { + let a = Okhsl::new(0.0, 50.0, 25.0); + let b = Okhsl::new(0.0, 50.0, 25.0); + let result = a + b; + + assert!(result.h().is_finite()); + assert!(result.s().is_finite()); + assert!(result.l().is_finite()); + } + } + + mod components { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_components_as_array() { + let okhsl = Okhsl::new(180.0, 50.0, 75.0); + let [h, s, l] = okhsl.components(); + + assert_eq!(h, 0.5); + assert_eq!(s, 0.5); + assert_eq!(l, 0.75); + } + } + + mod decrement_h { + use super::*; + + #[test] + fn it_subtracts_from_h() { + let mut okhsl = Okhsl::new(180.0, 50.0, 50.0); + okhsl.decrement_h(0.25); + + assert!((okhsl.h() - 0.25).abs() < 1e-10); + } + + #[test] + fn it_wraps_below_zero() { + let mut okhsl = Okhsl::new(36.0, 50.0, 50.0); + okhsl.decrement_h(0.2); + + assert!((okhsl.h() - 0.9).abs() < 1e-10); + } + } + + mod decrement_hue { + use super::*; + + #[test] + fn it_subtracts_degrees_from_hue() { + let mut okhsl = Okhsl::new(180.0, 50.0, 50.0); + okhsl.decrement_hue(90.0); + + assert!((okhsl.hue() - 90.0).abs() < 1e-10); + } + } + + mod decrement_l { + use super::*; + + #[test] + fn it_subtracts_from_l() { + let mut okhsl = Okhsl::new(0.0, 50.0, 50.0); + okhsl.decrement_l(0.2); + + assert!((okhsl.l() - 0.3).abs() < 1e-10); + } + } + + mod decrement_lightness { + use super::*; + + #[test] + fn it_subtracts_percentage_from_lightness() { + let mut okhsl = Okhsl::new(0.0, 50.0, 50.0); + okhsl.decrement_lightness(20.0); + + assert!((okhsl.lightness() - 30.0).abs() < 1e-10); + } + } + + mod decrement_s { + use super::*; + + #[test] + fn it_subtracts_from_s() { + let mut okhsl = Okhsl::new(0.0, 50.0, 50.0); + okhsl.decrement_s(0.2); + + assert!((okhsl.s() - 0.3).abs() < 1e-10); + } + } + + mod decrement_saturation { + use super::*; + + #[test] + fn it_subtracts_percentage_from_saturation() { + let mut okhsl = Okhsl::new(0.0, 50.0, 50.0); + okhsl.decrement_saturation(20.0); + + assert!((okhsl.saturation() - 30.0).abs() < 1e-10); + } + } + + mod display { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_formats_with_default_precision() { + let okhsl = Okhsl::new(120.0, 50.0, 75.0); + + assert_eq!(format!("{}", okhsl), "Okhsl(120.00°, 50.00%, 75.00%)"); + } + + #[test] + fn it_formats_with_custom_precision() { + let okhsl = Okhsl::new(120.12345, 50.6789, 75.4321); + + assert_eq!(format!("{:.4}", okhsl), "Okhsl(120.1235°, 50.6789%, 75.4321%)"); + } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let okhsl = Okhsl::new(120.0, 50.0, 75.0).with_alpha(0.5); + + assert_eq!(format!("{}", okhsl), "Okhsl(120.00°, 50.00%, 75.00%, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let okhsl = Okhsl::new(120.0, 50.0, 75.0); + + assert_eq!(format!("{}", okhsl), "Okhsl(120.00°, 50.00%, 75.00%)"); + } + } + + mod div { + use super::*; + + #[test] + fn it_divides_two_okhsl_values_via_rgb() { + let a = Okhsl::new(0.0, 50.0, 50.0); + let b = Okhsl::new(0.0, 50.0, 50.0); + let result = a / b; + + assert!(result.h().is_finite()); + assert!(result.s().is_finite()); + assert!(result.l().is_finite()); + } + } + + mod from_array { + use super::*; + + #[test] + fn it_creates_from_f64_array() { + let okhsl = Okhsl::from([180.0, 50.0, 75.0]); + + assert!((okhsl.hue() - 180.0).abs() < 1e-10); + assert!((okhsl.saturation() - 50.0).abs() < 1e-10); + assert!((okhsl.lightness() - 75.0).abs() < 1e-10); + } + } + + mod from_oklab { + use super::*; + + #[test] + fn it_converts_from_oklab() { + let oklab = Oklab::new(0.5, 0.0, 0.0); + let okhsl = Okhsl::from(oklab); + + assert!((okhsl.l() - Oklab::toe(0.5)).abs() < 1e-10); + assert!(okhsl.s() < 1e-3); + } + + #[test] + fn it_converts_black() { + let oklab = Oklab::new(0.0, 0.0, 0.0); + let okhsl = Okhsl::from(oklab); + + assert!(okhsl.l().abs() < 1e-10); + } + + #[test] + fn it_converts_white() { + let oklab = Oklab::new(1.0, 0.0, 0.0); + let okhsl = Okhsl::from(oklab); + + assert!((okhsl.l() - 1.0).abs() < 1e-10); + assert!(okhsl.s() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let oklab = Oklab::new(0.5, 0.1, -0.1).with_alpha(0.5); + let okhsl = Okhsl::from(oklab); + + assert!((okhsl.alpha() - 0.5).abs() < 1e-10); + } + } + + mod from_rgb { + use super::*; + + #[test] + fn it_converts_white_correctly() { + let rgb = Rgb::::new(255, 255, 255); + let okhsl = Okhsl::from(rgb); + + assert!((okhsl.l() - 1.0).abs() < 1e-3); + assert!(okhsl.s() < 1e-3); + } + + #[test] + fn it_converts_black_correctly() { + let rgb = Rgb::::new(0, 0, 0); + let okhsl = Okhsl::from(rgb); + + assert!(okhsl.l().abs() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let rgb = Rgb::::new(128, 64, 32).with_alpha(0.5); + let okhsl = Okhsl::from(rgb); + + assert!((okhsl.alpha() - 0.5).abs() < 1e-10); + } + } + + mod from_xyz { + use super::*; + + #[test] + fn it_converts_from_xyz() { + let xyz = Xyz::new(0.5, 0.5, 0.5); + let okhsl = Okhsl::from(xyz); + + assert!(okhsl.h().is_finite()); + assert!(okhsl.s().is_finite()); + assert!(okhsl.l().is_finite()); + } + + #[test] + fn it_preserves_alpha() { + let xyz = Xyz::new(0.5, 0.5, 0.5).with_alpha(0.3); + let okhsl = Okhsl::from(xyz); + + assert!((okhsl.alpha() - 0.3).abs() < 1e-10); + } + } + + mod h { + use super::*; + + #[test] + fn it_returns_normalized_hue() { + let okhsl = Okhsl::new(180.0, 50.0, 50.0); + + assert!((okhsl.h() - 0.5).abs() < 1e-10); + } + } + + mod hue { + use super::*; + + #[test] + fn it_returns_hue_in_degrees() { + let okhsl = Okhsl::new(180.0, 50.0, 50.0); + + assert!((okhsl.hue() - 180.0).abs() < 1e-10); + } + } + + mod increment_h { + use super::*; + + #[test] + fn it_adds_to_h() { + let mut okhsl = Okhsl::new(90.0, 50.0, 50.0); + okhsl.increment_h(0.25); + + assert!((okhsl.h() - 0.5).abs() < 1e-10); + } + + #[test] + fn it_wraps_above_one() { + let mut okhsl = Okhsl::new(324.0, 50.0, 50.0); + okhsl.increment_h(0.2); + + assert!((okhsl.h() - 0.1).abs() < 1e-10); + } + } + + mod increment_hue { + use super::*; + + #[test] + fn it_adds_degrees_to_hue() { + let mut okhsl = Okhsl::new(90.0, 50.0, 50.0); + okhsl.increment_hue(90.0); + + assert!((okhsl.hue() - 180.0).abs() < 1e-10); + } + } + + mod increment_l { + use super::*; + + #[test] + fn it_adds_to_l() { + let mut okhsl = Okhsl::new(0.0, 50.0, 25.0); + okhsl.increment_l(0.25); + + assert!((okhsl.l() - 0.5).abs() < 1e-10); + } + } + + mod increment_lightness { + use super::*; + + #[test] + fn it_adds_percentage_to_lightness() { + let mut okhsl = Okhsl::new(0.0, 50.0, 25.0); + okhsl.increment_lightness(25.0); + + assert!((okhsl.lightness() - 50.0).abs() < 1e-10); + } + } + + mod increment_s { + use super::*; + + #[test] + fn it_adds_to_s() { + let mut okhsl = Okhsl::new(0.0, 25.0, 50.0); + okhsl.increment_s(0.25); + + assert!((okhsl.s() - 0.5).abs() < 1e-10); + } + } + + mod increment_saturation { + use super::*; + + #[test] + fn it_adds_percentage_to_saturation() { + let mut okhsl = Okhsl::new(0.0, 25.0, 50.0); + okhsl.increment_saturation(25.0); + + assert!((okhsl.saturation() - 50.0).abs() < 1e-10); + } + } + + mod l { + use super::*; + + #[test] + fn it_returns_normalized_lightness() { + let okhsl = Okhsl::new(0.0, 50.0, 75.0); + + assert!((okhsl.l() - 0.75).abs() < 1e-10); + } + } + + mod lightness { + use super::*; + + #[test] + fn it_returns_lightness_as_percentage() { + let okhsl = Okhsl::new(0.0, 50.0, 75.0); + + assert!((okhsl.lightness() - 75.0).abs() < 1e-10); + } + } + + mod mul { + use super::*; + + #[test] + fn it_multiplies_two_okhsl_values_via_rgb() { + let a = Okhsl::new(0.0, 50.0, 50.0); + let b = Okhsl::new(0.0, 50.0, 50.0); + let result = a * b; + + assert!(result.h().is_finite()); + assert!(result.s().is_finite()); + assert!(result.l().is_finite()); + } + } + + mod new { + use super::*; + + #[test] + fn it_creates_with_default_alpha() { + let okhsl = Okhsl::new(180.0, 50.0, 75.0); + + assert!((okhsl.alpha() - 1.0).abs() < 1e-10); + } + + #[test] + fn it_creates_with_default_context() { + let okhsl = Okhsl::new(180.0, 50.0, 75.0); + + assert_eq!(okhsl.context().illuminant().name(), "D65"); + } + + #[test] + fn it_normalizes_hue_to_zero_one() { + let okhsl = Okhsl::new(450.0, 50.0, 50.0); + + assert!((okhsl.h() - 0.25).abs() < 1e-10); + } + + #[test] + fn it_normalizes_negative_hue() { + let okhsl = Okhsl::new(-90.0, 50.0, 50.0); + + assert!((okhsl.h() - 0.75).abs() < 1e-10); + } + } + + mod new_const { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_normalizes_hue_to_0_1() { + const OKHSL: Okhsl = Okhsl::new_const(270.0, 50.0, 50.0); + + assert_eq!(OKHSL.h(), 0.75); + } + + #[test] + fn it_normalizes_negative_hue() { + const OKHSL: Okhsl = Okhsl::new_const(-90.0, 50.0, 50.0); + + assert_eq!(OKHSL.h(), 0.75); + } + + #[test] + fn it_normalizes_saturation_to_0_1() { + const OKHSL: Okhsl = Okhsl::new_const(0.0, 75.0, 50.0); + + assert_eq!(OKHSL.s(), 0.75); + } + + #[test] + fn it_normalizes_lightness_to_0_1() { + const OKHSL: Okhsl = Okhsl::new_const(0.0, 50.0, 75.0); + + assert_eq!(OKHSL.l(), 0.75); + } + } + + mod partial_eq { + use super::*; + + #[test] + fn it_compares_equal_colors() { + let a = Okhsl::new(180.0, 50.0, 50.0); + let b = Okhsl::new(180.0, 50.0, 50.0); + + assert!(a == b); + } + + #[test] + fn it_compares_unequal_colors() { + let a = Okhsl::new(180.0, 50.0, 50.0); + let b = Okhsl::new(180.0, 50.0, 60.0); + + assert!(a != b); + } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Okhsl::new(180.0, 50.0, 50.0).with_alpha(0.5); + let b = Okhsl::new(180.0, 50.0, 50.0); + + assert!(a != b); + } + } + + mod s { + use super::*; + + #[test] + fn it_returns_normalized_saturation() { + let okhsl = Okhsl::new(0.0, 75.0, 50.0); + + assert!((okhsl.s() - 0.75).abs() < 1e-10); + } + } + + mod saturation { + use super::*; + + #[test] + fn it_returns_saturation_as_percentage() { + let okhsl = Okhsl::new(0.0, 75.0, 50.0); + + assert!((okhsl.saturation() - 75.0).abs() < 1e-10); + } + } + + mod scale_h { + use super::*; + + #[test] + fn it_multiplies_h_by_factor() { + let mut okhsl = Okhsl::new(90.0, 50.0, 50.0); + okhsl.scale_h(2.0); + + assert!((okhsl.h() - 0.5).abs() < 1e-10); + } + } + + mod scale_l { + use super::*; + + #[test] + fn it_multiplies_l_by_factor() { + let mut okhsl = Okhsl::new(0.0, 50.0, 25.0); + okhsl.scale_l(2.0); + + assert!((okhsl.l() - 0.5).abs() < 1e-10); + } + } + + mod scale_s { + use super::*; + + #[test] + fn it_multiplies_s_by_factor() { + let mut okhsl = Okhsl::new(0.0, 25.0, 50.0); + okhsl.scale_s(2.0); + + assert!((okhsl.s() - 0.5).abs() < 1e-10); + } + } + + mod sub { + use super::*; + + #[test] + fn it_subtracts_two_okhsl_values_via_rgb() { + let a = Okhsl::new(0.0, 50.0, 50.0); + let b = Okhsl::new(0.0, 25.0, 25.0); + let result = a - b; + + assert!(result.h().is_finite()); + assert!(result.s().is_finite()); + assert!(result.l().is_finite()); + } + } + + mod to_oklab { + use super::*; + + #[test] + fn it_converts_achromatic_gray() { + let okhsl = Okhsl::new(0.0, 0.0, 50.0); + let oklab = okhsl.to_oklab(); + + assert!((oklab.l() - Oklab::toe_inv(0.5)).abs() < 1e-10); + assert!(oklab.a().abs() < 1e-10); + assert!(oklab.b().abs() < 1e-10); + } + + #[test] + fn it_converts_black() { + let okhsl = Okhsl::new(0.0, 100.0, 0.0); + let oklab = okhsl.to_oklab(); + + assert!(oklab.l().abs() < 1e-10); + assert!(oklab.a().abs() < 1e-10); + assert!(oklab.b().abs() < 1e-10); + } + + #[test] + fn it_converts_white() { + let okhsl = Okhsl::new(0.0, 0.0, 100.0); + let oklab = okhsl.to_oklab(); + + assert!((oklab.l() - 1.0).abs() < 1e-10); + assert!(oklab.a().abs() < 1e-10); + assert!(oklab.b().abs() < 1e-10); + } + + #[test] + fn it_roundtrips_through_oklab() { + let original = Okhsl::new(210.0, 80.0, 50.0); + let roundtrip = Okhsl::from(original.to_oklab()); + + assert!((original.h() - roundtrip.h()).abs() < 1e-10); + assert!((original.s() - roundtrip.s()).abs() < 1e-10); + assert!((original.l() - roundtrip.l()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let okhsl = Okhsl::new(120.0, 50.0, 50.0).with_alpha(0.3); + let oklab = okhsl.to_oklab(); + + assert!((oklab.alpha() - 0.3).abs() < 1e-10); + } + } + + mod to_rgb { + use super::*; + + #[test] + fn it_converts_to_srgb() { + let okhsl = Okhsl::new(0.0, 50.0, 50.0); + let rgb = okhsl.to_rgb::(); + + assert!(rgb.red() > 0); + } + + #[test] + fn it_converts_black() { + let okhsl = Okhsl::new(0.0, 0.0, 0.0); + let rgb = okhsl.to_rgb::(); + + assert_eq!(rgb.red(), 0); + assert_eq!(rgb.green(), 0); + assert_eq!(rgb.blue(), 0); + } + + #[test] + fn it_converts_white() { + let okhsl = Okhsl::new(0.0, 0.0, 100.0); + let rgb = okhsl.to_rgb::(); + + assert_eq!(rgb.red(), 255); + assert_eq!(rgb.green(), 255); + assert_eq!(rgb.blue(), 255); + } + + #[test] + fn it_preserves_alpha() { + let okhsl = Okhsl::new(120.0, 50.0, 50.0).with_alpha(0.7); + let rgb = okhsl.to_rgb::(); + + assert!((rgb.alpha() - 0.7).abs() < 1e-10); + } + } + + mod to_xyz { + use super::*; + + #[test] + fn it_converts_to_xyz() { + let okhsl = Okhsl::new(120.0, 50.0, 50.0); + let xyz = okhsl.to_xyz(); + + assert!(xyz.x().is_finite()); + assert!(xyz.y().is_finite()); + assert!(xyz.z().is_finite()); + } + + #[test] + fn it_preserves_alpha() { + let okhsl = Okhsl::new(120.0, 50.0, 50.0).with_alpha(0.3); + let xyz = okhsl.to_xyz(); + + assert!((xyz.alpha() - 0.3).abs() < 1e-10); + } + } + + mod try_from_str { + use super::*; + + #[test] + fn it_parses_hex_string() { + let okhsl = Okhsl::try_from("#FF5733").unwrap(); + + assert!(okhsl.l() > 0.0); + } + + #[test] + fn it_returns_error_for_invalid_hex() { + let result = Okhsl::try_from("not_a_color"); + + assert!(result.is_err()); + } + } + + mod with_alpha { + use super::*; + + #[test] + fn it_returns_new_color_with_alpha() { + let okhsl = Okhsl::new(180.0, 50.0, 50.0); + let result = okhsl.with_alpha(0.5); + + assert!((result.alpha() - 0.5).abs() < 1e-10); + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_context { + use super::*; + + #[test] + fn it_returns_new_color_with_context() { + let okhsl = Okhsl::new(180.0, 50.0, 50.0); + let context = ColorimetricContext::default(); + let result = okhsl.with_context(context); + + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_h { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_okhsl_with_new_h() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_h(0.75); + + assert_eq!(result.h(), 0.75); + assert_eq!(result.s(), okhsl.s()); + assert_eq!(result.l(), okhsl.l()); + } + } + + mod with_h_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_decremented_h() { + let okhsl = Okhsl::new(180.0, 50.0, 50.0); + let result = okhsl.with_h_decremented_by(0.25); + + assert!((result.h() - 0.25).abs() < 1e-10); + assert!((okhsl.h() - 0.5).abs() < 1e-10); + } + } + + mod with_h_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_incremented_h() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_h_incremented_by(0.25); + + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_h_scaled_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_scaled_h() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_h_scaled_by(2.0); + + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_hue { + use super::*; + + #[test] + fn it_returns_okhsl_with_new_hue_in_degrees() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_hue(270.0); + + assert!((result.hue() - 270.0).abs() < 1e-10); + assert_eq!(result.s(), okhsl.s()); + assert_eq!(result.l(), okhsl.l()); + } + } + + mod with_hue_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_decremented_hue() { + let okhsl = Okhsl::new(180.0, 50.0, 50.0); + let result = okhsl.with_hue_decremented_by(90.0); + + assert!((result.hue() - 90.0).abs() < 1e-10); + } + } + + mod with_hue_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_incremented_hue() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_hue_incremented_by(90.0); + + assert!((result.hue() - 180.0).abs() < 1e-10); + } + } + + mod with_l { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_okhsl_with_new_l() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_l(0.75); + + assert_eq!(result.l(), 0.75); + assert_eq!(result.h(), okhsl.h()); + assert_eq!(result.s(), okhsl.s()); + } + } + + mod with_l_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_decremented_l() { + let okhsl = Okhsl::new(0.0, 50.0, 50.0); + let result = okhsl.with_l_decremented_by(0.2); + + assert!((result.l() - 0.3).abs() < 1e-10); + } + } + + mod with_l_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_incremented_l() { + let okhsl = Okhsl::new(0.0, 50.0, 25.0); + let result = okhsl.with_l_incremented_by(0.25); + + assert!((result.l() - 0.5).abs() < 1e-10); + } + } + + mod with_l_scaled_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_scaled_l() { + let okhsl = Okhsl::new(0.0, 50.0, 25.0); + let result = okhsl.with_l_scaled_by(2.0); + + assert!((result.l() - 0.5).abs() < 1e-10); + } + } + + mod with_lightness { + use super::*; + + #[test] + fn it_returns_okhsl_with_new_lightness_in_percent() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_lightness(75.0); + + assert!((result.lightness() - 75.0).abs() < 1e-10); + assert_eq!(result.h(), okhsl.h()); + assert_eq!(result.s(), okhsl.s()); + } + } + + mod with_lightness_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_decremented_lightness() { + let okhsl = Okhsl::new(0.0, 50.0, 50.0); + let result = okhsl.with_lightness_decremented_by(20.0); + + assert!((result.lightness() - 30.0).abs() < 1e-10); + } + } + + mod with_lightness_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_incremented_lightness() { + let okhsl = Okhsl::new(0.0, 50.0, 25.0); + let result = okhsl.with_lightness_incremented_by(25.0); + + assert!((result.lightness() - 50.0).abs() < 1e-10); + } + } + + mod with_s { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_okhsl_with_new_s() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_s(0.75); + + assert_eq!(result.s(), 0.75); + assert_eq!(result.h(), okhsl.h()); + assert_eq!(result.l(), okhsl.l()); + } + } + + mod with_s_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_decremented_s() { + let okhsl = Okhsl::new(0.0, 50.0, 50.0); + let result = okhsl.with_s_decremented_by(0.2); + + assert!((result.s() - 0.3).abs() < 1e-10); + } + } + + mod with_s_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_incremented_s() { + let okhsl = Okhsl::new(0.0, 25.0, 50.0); + let result = okhsl.with_s_incremented_by(0.25); + + assert!((result.s() - 0.5).abs() < 1e-10); + } + } + + mod with_s_scaled_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_scaled_s() { + let okhsl = Okhsl::new(0.0, 25.0, 50.0); + let result = okhsl.with_s_scaled_by(2.0); + + assert!((result.s() - 0.5).abs() < 1e-10); + } + } + + mod with_saturation { + use super::*; + + #[test] + fn it_returns_okhsl_with_new_saturation_in_percent() { + let okhsl = Okhsl::new(90.0, 50.0, 50.0); + let result = okhsl.with_saturation(75.0); + + assert!((result.saturation() - 75.0).abs() < 1e-10); + assert_eq!(result.h(), okhsl.h()); + assert_eq!(result.l(), okhsl.l()); + } + } + + mod with_saturation_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_decremented_saturation() { + let okhsl = Okhsl::new(0.0, 50.0, 50.0); + let result = okhsl.with_saturation_decremented_by(20.0); + + assert!((result.saturation() - 30.0).abs() < 1e-10); + } + } + + mod with_saturation_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsl_with_incremented_saturation() { + let okhsl = Okhsl::new(0.0, 25.0, 50.0); + let result = okhsl.with_saturation_incremented_by(25.0); + + assert!((result.saturation() - 50.0).abs() < 1e-10); + } + } +} diff --git a/src/space/perceptual/okhsv.rs b/src/space/perceptual/okhsv.rs new file mode 100644 index 0000000..1348317 --- /dev/null +++ b/src/space/perceptual/okhsv.rs @@ -0,0 +1,1665 @@ +use std::{ + fmt::{Display, Formatter, Result as FmtResult}, + ops::{Add, Div, Mul, Sub}, +}; + +#[cfg(feature = "space-cmy")] +use crate::space::Cmy; +#[cfg(feature = "space-cmyk")] +use crate::space::Cmyk; +#[cfg(feature = "space-hsl")] +use crate::space::Hsl; +#[cfg(feature = "space-hsv")] +use crate::space::Hsv; +#[cfg(feature = "space-hwb")] +use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; +use crate::{ + ColorimetricContext, Illuminant, Observer, + component::Component, + space::{ColorSpace, Lms, Oklab, Rgb, RgbSpec, Srgb, Xyz}, +}; + +/// Okhsv perceptual color space (HSV model in the Oklab perceptual framework). +/// +/// A perceptually uniform HSV-like color space derived from Oklab. H represents +/// hue stored internally as a 0.0-1.0 fraction (0-360°), S represents saturation +/// (0.0-1.0), and V represents perceived value/brightness (0.0-1.0). Designed for +/// intuitive color manipulation with perceptual uniformity, using sRGB gamut +/// boundaries for saturation mapping. +#[derive(Clone, Copy, Debug)] +pub struct Okhsv { + alpha: Component, + context: ColorimetricContext, + h: Component, + s: Component, + v: Component, +} + +impl Okhsv { + /// The default viewing context for Okhsv (D65 illuminant, CIE 1931 2° observer). + pub const DEFAULT_CONTEXT: ColorimetricContext = ColorimetricContext::new() + .with_illuminant(Illuminant::D65) + .with_observer(Observer::CIE_1931_2D); + + /// Creates a new Okhsv color from hue (0-360°), saturation (0-100%), and value (0-100%). + pub fn new(h: impl Into, s: impl Into, v: impl Into) -> Self { + Self { + alpha: Component::new(1.0), + context: Self::DEFAULT_CONTEXT, + h: Component::new((h.into().0 / 360.0).rem_euclid(1.0)), + s: s.into() / 100.0, + v: v.into() / 100.0, + } + } + + /// Creates a new Okhsv color in a const context from hue (0-360°), saturation (0-100%), and value (0-100%). + pub const fn new_const(h: f64, s: f64, v: f64) -> Self { + let r = (h / 360.0) % 1.0; + + Self { + alpha: Component::new_const(1.0), + context: Self::DEFAULT_CONTEXT, + h: Component::new_const(if r < 0.0 { r + 1.0 } else { r }), + s: Component::new_const(s / 100.0), + v: Component::new_const(v / 100.0), + } + } + + /// Returns the [H, S, V] components as normalized values (all 0.0-1.0). + pub fn components(&self) -> [f64; 3] { + [self.h.0, self.s.0, self.v.0] + } + + /// Returns the viewing context for this color. + pub fn context(&self) -> &ColorimetricContext { + &self.context + } + + /// Decreases the normalized hue by the given amount (wraps around 0.0-1.0). + pub fn decrement_h(&mut self, amount: impl Into) { + self.h = Component::new((self.h.0 - amount.into().0).rem_euclid(1.0)); + } + + /// Decreases the hue by the given amount in degrees (wraps around 0-360°). + pub fn decrement_hue(&mut self, amount: impl Into) { + self.decrement_h(amount.into() / 360.0) + } + + /// Decreases the normalized saturation by the given amount. + pub fn decrement_s(&mut self, amount: impl Into) { + self.s -= amount.into(); + } + + /// Decreases the saturation by the given amount in percentage points. + pub fn decrement_saturation(&mut self, amount: impl Into) { + self.decrement_s(amount.into() / 100.0) + } + + /// Decreases the normalized value by the given amount. + pub fn decrement_v(&mut self, amount: impl Into) { + self.v -= amount.into(); + } + + /// Decreases the value by the given amount in percentage points. + pub fn decrement_value(&mut self, amount: impl Into) { + self.decrement_v(amount.into() / 100.0) + } + + /// Returns the normalized hue component (0.0-1.0). + pub fn h(&self) -> f64 { + self.h.0 + } + + /// Returns the hue in degrees (0-360°). + pub fn hue(&self) -> f64 { + self.h.0 * 360.0 + } + + /// Increases the normalized hue by the given amount (wraps around 0.0-1.0). + pub fn increment_h(&mut self, amount: impl Into) { + self.h = Component::new((self.h.0 + amount.into().0).rem_euclid(1.0)); + } + + /// Increases the hue by the given amount in degrees (wraps around 0-360°). + pub fn increment_hue(&mut self, amount: impl Into) { + self.increment_h(amount.into() / 360.0) + } + + /// Increases the normalized saturation by the given amount. + pub fn increment_s(&mut self, amount: impl Into) { + self.s += amount.into(); + } + + /// Increases the saturation by the given amount in percentage points. + pub fn increment_saturation(&mut self, amount: impl Into) { + self.increment_s(amount.into() / 100.0) + } + + /// Increases the normalized value by the given amount. + pub fn increment_v(&mut self, amount: impl Into) { + self.v += amount.into(); + } + + /// Increases the value by the given amount in percentage points. + pub fn increment_value(&mut self, amount: impl Into) { + self.increment_v(amount.into() / 100.0) + } + + /// Returns the normalized saturation component (0.0-1.0). + pub fn s(&self) -> f64 { + self.s.0 + } + + /// Returns the saturation as a percentage (0-100%). + pub fn saturation(&self) -> f64 { + self.s.0 * 100.0 + } + + /// Scales the normalized hue by the given factor (wraps around 0.0-1.0). + pub fn scale_h(&mut self, factor: impl Into) { + self.h = Component::new((self.h.0 * factor.into().0).rem_euclid(1.0)); + } + + /// Alias for [`Self::scale_h`]. + pub fn scale_hue(&mut self, factor: impl Into) { + self.scale_h(factor) + } + + /// Scales the normalized saturation by the given factor. + pub fn scale_s(&mut self, factor: impl Into) { + self.s *= factor.into(); + } + + /// Alias for [`Self::scale_s`]. + pub fn scale_saturation(&mut self, factor: impl Into) { + self.scale_s(factor) + } + + /// Scales the normalized value by the given factor. + pub fn scale_v(&mut self, factor: impl Into) { + self.v *= factor.into(); + } + + /// Alias for [`Self::scale_v`]. + pub fn scale_value(&mut self, factor: impl Into) { + self.scale_v(factor) + } + + /// Sets all three components from normalized values. + pub fn set_components(&mut self, components: [impl Into + Clone; 3]) { + self.set_h(components[0].clone()); + self.set_s(components[1].clone()); + self.set_v(components[2].clone()); + } + + /// Sets the normalized hue component (0.0-1.0). + pub fn set_h(&mut self, h: impl Into) { + self.h = h.into(); + } + + /// Sets the hue from a value in degrees (0-360°). + pub fn set_hue(&mut self, hue: impl Into) { + self.h = Component::new((hue.into().0 / 360.0).rem_euclid(1.0)); + } + + /// Sets the normalized saturation component (0.0-1.0). + pub fn set_s(&mut self, s: impl Into) { + self.s = s.into(); + } + + /// Sets the saturation from a percentage value (0-100%). + pub fn set_saturation(&mut self, saturation: impl Into) { + self.s = saturation.into() / 100.0; + } + + /// Sets the normalized value component (0.0-1.0). + pub fn set_v(&mut self, v: impl Into) { + self.v = v.into(); + } + + /// Sets the value from a percentage (0-100%). + pub fn set_value(&mut self, value: impl Into) { + self.v = value.into() / 100.0; + } + + /// Converts to the Oklab perceptual color space. + /// + /// Uses an HSV cone model where V=1, S=1 maps to the cusp (maximum chroma) + /// and V=1, S=0 maps to white. Reducing V scales toward black. + pub fn to_oklab(&self) -> Oklab { + let [h, s, v] = self.components(); + let cusp = Oklab::cusp_for_hue(h); + let (l_cusp, c_cusp) = cusp; + + let tv = Oklab::toe_inv(v); + let lab_l = tv * (1.0 - s * (1.0 - l_cusp)); + let c = tv * s * c_cusp; + + let h_rad = h * 2.0 * std::f64::consts::PI; + let a = c * h_rad.cos(); + let b = c * h_rad.sin(); + + Oklab::new(lab_l, a, b).with_alpha(self.alpha) + } + + /// Converts to the specified RGB color space. + pub fn to_rgb(&self) -> Rgb + where + S: RgbSpec, + { + self.to_oklab().to_rgb::() + } + + /// Converts to the CIE XYZ color space. + pub fn to_xyz(&self) -> Xyz { + self.to_oklab().to_xyz() + } + + /// Returns the normalized value component (0.0-1.0). + pub fn v(&self) -> f64 { + self.v.0 + } + + /// Returns the value as a percentage (0-100%). + pub fn value(&self) -> f64 { + self.v.0 * 100.0 + } + + /// Returns this color with a different viewing context (without adaptation). + pub fn with_context(&self, context: ColorimetricContext) -> Self { + Self { + context, + ..*self + } + } + + /// Returns a new color with the given normalized hue value. + pub fn with_h(&self, h: impl Into) -> Self { + Self { + h: h.into(), + ..*self + } + } + + /// Returns a new color with the normalized hue decreased by the given amount. + pub fn with_h_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.decrement_h(amount); + okhsv + } + + /// Returns a new color with the normalized hue increased by the given amount. + pub fn with_h_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.increment_h(amount); + okhsv + } + + /// Returns a new color with the normalized hue scaled by the given factor. + pub fn with_h_scaled_by(&self, factor: impl Into) -> Self { + let mut okhsv = *self; + okhsv.scale_h(factor); + okhsv + } + + /// Returns a new color with the given hue in degrees (0-360°). + pub fn with_hue(&self, hue: impl Into) -> Self { + Self { + h: Component::new((hue.into().0 / 360.0).rem_euclid(1.0)), + ..*self + } + } + + /// Returns a new color with the hue decreased by the given degrees. + pub fn with_hue_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.decrement_hue(amount); + okhsv + } + + /// Returns a new color with the hue increased by the given degrees. + pub fn with_hue_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.increment_hue(amount); + okhsv + } + + /// Alias for [`Self::with_h_scaled_by`]. + pub fn with_hue_scaled_by(&self, factor: impl Into) -> Self { + self.with_h_scaled_by(factor) + } + + /// Returns a new color with the given normalized saturation value. + pub fn with_s(&self, s: impl Into) -> Self { + Self { + s: s.into(), + ..*self + } + } + + /// Returns a new color with normalized saturation decreased by the given amount. + pub fn with_s_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.decrement_s(amount); + okhsv + } + + /// Returns a new color with normalized saturation increased by the given amount. + pub fn with_s_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.increment_s(amount); + okhsv + } + + /// Returns a new color with normalized saturation scaled by the given factor. + pub fn with_s_scaled_by(&self, factor: impl Into) -> Self { + let mut okhsv = *self; + okhsv.scale_s(factor); + okhsv + } + + /// Returns a new color with the given saturation in percentage (0-100%). + pub fn with_saturation(&self, saturation: impl Into) -> Self { + Self { + s: saturation.into() / 100.0, + ..*self + } + } + + /// Returns a new color with saturation decreased by the given percentage points. + pub fn with_saturation_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.decrement_saturation(amount); + okhsv + } + + /// Returns a new color with saturation increased by the given percentage points. + pub fn with_saturation_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.increment_saturation(amount); + okhsv + } + + /// Alias for [`Self::with_s_scaled_by`]. + pub fn with_saturation_scaled_by(&self, factor: impl Into) -> Self { + self.with_s_scaled_by(factor) + } + + /// Returns a new color with the given normalized value. + pub fn with_v(&self, v: impl Into) -> Self { + Self { + v: v.into(), + ..*self + } + } + + /// Returns a new color with normalized value decreased by the given amount. + pub fn with_v_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.decrement_v(amount); + okhsv + } + + /// Returns a new color with normalized value increased by the given amount. + pub fn with_v_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.increment_v(amount); + okhsv + } + + /// Returns a new color with normalized value scaled by the given factor. + pub fn with_v_scaled_by(&self, factor: impl Into) -> Self { + let mut okhsv = *self; + okhsv.scale_v(factor); + okhsv + } + + /// Returns a new color with the given value in percentage (0-100%). + pub fn with_value(&self, value: impl Into) -> Self { + Self { + v: value.into() / 100.0, + ..*self + } + } + + /// Returns a new color with value decreased by the given percentage points. + pub fn with_value_decremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.decrement_value(amount); + okhsv + } + + /// Returns a new color with value increased by the given percentage points. + pub fn with_value_incremented_by(&self, amount: impl Into) -> Self { + let mut okhsv = *self; + okhsv.increment_value(amount); + okhsv + } + + /// Alias for [`Self::with_v_scaled_by`]. + pub fn with_value_scaled_by(&self, factor: impl Into) -> Self { + self.with_v_scaled_by(factor) + } +} + +impl Add for Okhsv +where + T: Into, +{ + type Output = Self; + + fn add(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() + rhs.into().to_rgb::()) + } +} + +impl ColorSpace<3> for Okhsv { + fn alpha(&self) -> f64 { + self.alpha.0 + } + + fn components(&self) -> [f64; 3] { + self.components() + } + + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + + fn set_components(&mut self, components: [impl Into + Clone; 3]) { + self.set_components(components) + } + + fn to_xyz(&self) -> Xyz { + self.to_xyz() + } +} + +impl Display for Okhsv { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let precision = f.precision().unwrap_or(2); + if self.alpha.0 < 1.0 { + write!( + f, + "Okhsv({:.precision$}°, {:.precision$}%, {:.precision$}%, {:.0}%)", + self.hue(), + self.saturation(), + self.value(), + self.opacity() + ) + } else { + write!( + f, + "Okhsv({:.precision$}°, {:.precision$}%, {:.precision$}%)", + self.hue(), + self.saturation(), + self.value() + ) + } + } +} + +impl Div for Okhsv +where + T: Into, +{ + type Output = Self; + + fn div(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() / rhs.into().to_rgb::()) + } +} + +impl From<[T; 3]> for Okhsv +where + T: Into, +{ + fn from([h, s, v]: [T; 3]) -> Self { + Self::new(h, s, v) + } +} + +#[cfg(feature = "space-cmy")] +impl From> for Okhsv +where + S: RgbSpec, +{ + fn from(cmy: Cmy) -> Self { + cmy.to_okhsv() + } +} + +#[cfg(feature = "space-cmyk")] +impl From> for Okhsv +where + S: RgbSpec, +{ + fn from(cmyk: Cmyk) -> Self { + cmyk.to_okhsv() + } +} + +#[cfg(feature = "space-hsl")] +impl From> for Okhsv +where + S: RgbSpec, +{ + fn from(hsl: Hsl) -> Self { + hsl.to_okhsv() + } +} + +#[cfg(feature = "space-hsv")] +impl From> for Okhsv +where + S: RgbSpec, +{ + fn from(hsv: Hsv) -> Self { + hsv.to_okhsv() + } +} + +#[cfg(feature = "space-hwb")] +impl From> for Okhsv +where + S: RgbSpec, +{ + fn from(hwb: Hwb) -> Self { + hwb.to_okhsv() + } +} + +impl From for Okhsv { + fn from(lms: Lms) -> Self { + lms.to_okhsv() + } +} + +impl From for Okhsv { + fn from(oklab: Oklab) -> Self { + oklab.to_okhsv() + } +} + +#[cfg(feature = "space-okhsl")] +impl From for Okhsv { + fn from(okhsl: Okhsl) -> Self { + okhsl.to_okhsv() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Okhsv { + fn from(oklch: Oklch) -> Self { + oklch.to_okhsv() + } +} + +impl From> for Okhsv +where + S: RgbSpec, +{ + fn from(rgb: Rgb) -> Self { + rgb.to_okhsv() + } +} + +impl From for Okhsv { + fn from(xyz: Xyz) -> Self { + xyz.to_okhsv() + } +} + +impl Mul for Okhsv +where + T: Into, +{ + type Output = Self; + + fn mul(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() * rhs.into().to_rgb::()) + } +} + +impl PartialEq for Okhsv +where + T: Into + Copy, +{ + fn eq(&self, other: &T) -> bool { + let other = (*other).into(); + self.alpha == other.alpha && self.h == other.h && self.s == other.s && self.v == other.v + } +} + +impl Sub for Okhsv +where + T: Into, +{ + type Output = Self; + + fn sub(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() - rhs.into().to_rgb::()) + } +} + +impl TryFrom<&str> for Okhsv { + type Error = crate::Error; + + fn try_from(value: &str) -> Result { + Ok(Self::from(Rgb::::try_from(value)?.to_xyz())) + } +} + +impl TryFrom for Okhsv { + type Error = crate::Error; + + fn try_from(value: String) -> Result { + Ok(Self::from(Rgb::::try_from(value)?.to_xyz())) + } +} + +#[cfg(test)] +mod test { + use super::*; + + mod add { + use super::*; + + #[test] + fn it_adds_two_okhsv_values_via_rgb() { + let a = Okhsv::new(0.0, 50.0, 25.0); + let b = Okhsv::new(0.0, 50.0, 25.0); + let result = a + b; + + assert!(result.h().is_finite()); + assert!(result.s().is_finite()); + assert!(result.v().is_finite()); + } + } + + mod components { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_components_as_array() { + let okhsv = Okhsv::new(180.0, 50.0, 75.0); + let [h, s, v] = okhsv.components(); + + assert_eq!(h, 0.5); + assert_eq!(s, 0.5); + assert_eq!(v, 0.75); + } + } + + mod decrement_h { + use super::*; + + #[test] + fn it_subtracts_from_h() { + let mut okhsv = Okhsv::new(180.0, 50.0, 50.0); + okhsv.decrement_h(0.25); + + assert!((okhsv.h() - 0.25).abs() < 1e-10); + } + + #[test] + fn it_wraps_below_zero() { + let mut okhsv = Okhsv::new(36.0, 50.0, 50.0); + okhsv.decrement_h(0.2); + + assert!((okhsv.h() - 0.9).abs() < 1e-10); + } + } + + mod decrement_hue { + use super::*; + + #[test] + fn it_subtracts_degrees_from_hue() { + let mut okhsv = Okhsv::new(180.0, 50.0, 50.0); + okhsv.decrement_hue(90.0); + + assert!((okhsv.hue() - 90.0).abs() < 1e-10); + } + } + + mod decrement_s { + use super::*; + + #[test] + fn it_subtracts_from_s() { + let mut okhsv = Okhsv::new(0.0, 50.0, 50.0); + okhsv.decrement_s(0.2); + + assert!((okhsv.s() - 0.3).abs() < 1e-10); + } + } + + mod decrement_saturation { + use super::*; + + #[test] + fn it_subtracts_percentage_from_saturation() { + let mut okhsv = Okhsv::new(0.0, 50.0, 50.0); + okhsv.decrement_saturation(20.0); + + assert!((okhsv.saturation() - 30.0).abs() < 1e-10); + } + } + + mod decrement_v { + use super::*; + + #[test] + fn it_subtracts_from_v() { + let mut okhsv = Okhsv::new(0.0, 50.0, 50.0); + okhsv.decrement_v(0.2); + + assert!((okhsv.v() - 0.3).abs() < 1e-10); + } + } + + mod decrement_value { + use super::*; + + #[test] + fn it_subtracts_percentage_from_value() { + let mut okhsv = Okhsv::new(0.0, 50.0, 50.0); + okhsv.decrement_value(20.0); + + assert!((okhsv.value() - 30.0).abs() < 1e-10); + } + } + + mod display { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_formats_with_default_precision() { + let okhsv = Okhsv::new(120.0, 50.0, 75.0); + + assert_eq!(format!("{}", okhsv), "Okhsv(120.00°, 50.00%, 75.00%)"); + } + + #[test] + fn it_formats_with_custom_precision() { + let okhsv = Okhsv::new(120.12345, 50.6789, 75.4321); + + assert_eq!(format!("{:.4}", okhsv), "Okhsv(120.1235°, 50.6789%, 75.4321%)"); + } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let okhsv = Okhsv::new(120.0, 50.0, 75.0).with_alpha(0.5); + + assert_eq!(format!("{}", okhsv), "Okhsv(120.00°, 50.00%, 75.00%, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let okhsv = Okhsv::new(120.0, 50.0, 75.0); + + assert_eq!(format!("{}", okhsv), "Okhsv(120.00°, 50.00%, 75.00%)"); + } + } + + mod div { + use super::*; + + #[test] + fn it_divides_two_okhsv_values_via_rgb() { + let a = Okhsv::new(0.0, 50.0, 50.0); + let b = Okhsv::new(0.0, 50.0, 50.0); + let result = a / b; + + assert!(result.h().is_finite()); + assert!(result.s().is_finite()); + assert!(result.v().is_finite()); + } + } + + mod from_array { + use super::*; + + #[test] + fn it_creates_from_f64_array() { + let okhsv = Okhsv::from([180.0, 50.0, 75.0]); + + assert!((okhsv.hue() - 180.0).abs() < 1e-10); + assert!((okhsv.saturation() - 50.0).abs() < 1e-10); + assert!((okhsv.value() - 75.0).abs() < 1e-10); + } + } + + mod from_oklab { + use super::*; + + #[test] + fn it_converts_from_oklab() { + let oklab = Oklab::new(0.5, 0.0, 0.0); + let okhsv = Okhsv::from(oklab); + + assert!((okhsv.v() - Oklab::toe(0.5)).abs() < 1e-10); + assert!(okhsv.s() < 1e-3); + } + + #[test] + fn it_converts_black() { + let oklab = Oklab::new(0.0, 0.0, 0.0); + let okhsv = Okhsv::from(oklab); + + assert!(okhsv.v().abs() < 1e-10); + } + + #[test] + fn it_converts_white() { + let oklab = Oklab::new(1.0, 0.0, 0.0); + let okhsv = Okhsv::from(oklab); + + assert!((okhsv.v() - 1.0).abs() < 1e-10); + assert!(okhsv.s() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let oklab = Oklab::new(0.5, 0.1, -0.1).with_alpha(0.5); + let okhsv = Okhsv::from(oklab); + + assert!((okhsv.alpha() - 0.5).abs() < 1e-10); + } + } + + mod from_rgb { + use super::*; + + #[test] + fn it_converts_white_correctly() { + let rgb = Rgb::::new(255, 255, 255); + let okhsv = Okhsv::from(rgb); + + assert!((okhsv.v() - 1.0).abs() < 1e-3); + assert!(okhsv.s() < 1e-3); + } + + #[test] + fn it_converts_black_correctly() { + let rgb = Rgb::::new(0, 0, 0); + let okhsv = Okhsv::from(rgb); + + assert!(okhsv.v().abs() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let rgb = Rgb::::new(128, 64, 32).with_alpha(0.5); + let okhsv = Okhsv::from(rgb); + + assert!((okhsv.alpha() - 0.5).abs() < 1e-10); + } + } + + mod from_xyz { + use super::*; + + #[test] + fn it_converts_from_xyz() { + let xyz = Xyz::new(0.5, 0.5, 0.5); + let okhsv = Okhsv::from(xyz); + + assert!(okhsv.h().is_finite()); + assert!(okhsv.s().is_finite()); + assert!(okhsv.v().is_finite()); + } + + #[test] + fn it_preserves_alpha() { + let xyz = Xyz::new(0.5, 0.5, 0.5).with_alpha(0.3); + let okhsv = Okhsv::from(xyz); + + assert!((okhsv.alpha() - 0.3).abs() < 1e-10); + } + } + + mod h { + use super::*; + + #[test] + fn it_returns_normalized_hue() { + let okhsv = Okhsv::new(180.0, 50.0, 50.0); + + assert!((okhsv.h() - 0.5).abs() < 1e-10); + } + } + + mod hue { + use super::*; + + #[test] + fn it_returns_hue_in_degrees() { + let okhsv = Okhsv::new(180.0, 50.0, 50.0); + + assert!((okhsv.hue() - 180.0).abs() < 1e-10); + } + } + + mod increment_h { + use super::*; + + #[test] + fn it_adds_to_h() { + let mut okhsv = Okhsv::new(90.0, 50.0, 50.0); + okhsv.increment_h(0.25); + + assert!((okhsv.h() - 0.5).abs() < 1e-10); + } + + #[test] + fn it_wraps_above_one() { + let mut okhsv = Okhsv::new(324.0, 50.0, 50.0); + okhsv.increment_h(0.2); + + assert!((okhsv.h() - 0.1).abs() < 1e-10); + } + } + + mod increment_hue { + use super::*; + + #[test] + fn it_adds_degrees_to_hue() { + let mut okhsv = Okhsv::new(90.0, 50.0, 50.0); + okhsv.increment_hue(90.0); + + assert!((okhsv.hue() - 180.0).abs() < 1e-10); + } + } + + mod increment_s { + use super::*; + + #[test] + fn it_adds_to_s() { + let mut okhsv = Okhsv::new(0.0, 25.0, 50.0); + okhsv.increment_s(0.25); + + assert!((okhsv.s() - 0.5).abs() < 1e-10); + } + } + + mod increment_saturation { + use super::*; + + #[test] + fn it_adds_percentage_to_saturation() { + let mut okhsv = Okhsv::new(0.0, 25.0, 50.0); + okhsv.increment_saturation(25.0); + + assert!((okhsv.saturation() - 50.0).abs() < 1e-10); + } + } + + mod increment_v { + use super::*; + + #[test] + fn it_adds_to_v() { + let mut okhsv = Okhsv::new(0.0, 50.0, 25.0); + okhsv.increment_v(0.25); + + assert!((okhsv.v() - 0.5).abs() < 1e-10); + } + } + + mod increment_value { + use super::*; + + #[test] + fn it_adds_percentage_to_value() { + let mut okhsv = Okhsv::new(0.0, 50.0, 25.0); + okhsv.increment_value(25.0); + + assert!((okhsv.value() - 50.0).abs() < 1e-10); + } + } + + mod mul { + use super::*; + + #[test] + fn it_multiplies_two_okhsv_values_via_rgb() { + let a = Okhsv::new(0.0, 50.0, 50.0); + let b = Okhsv::new(0.0, 50.0, 50.0); + let result = a * b; + + assert!(result.h().is_finite()); + assert!(result.s().is_finite()); + assert!(result.v().is_finite()); + } + } + + mod new { + use super::*; + + #[test] + fn it_creates_with_default_alpha() { + let okhsv = Okhsv::new(180.0, 50.0, 75.0); + + assert!((okhsv.alpha() - 1.0).abs() < 1e-10); + } + + #[test] + fn it_creates_with_default_context() { + let okhsv = Okhsv::new(180.0, 50.0, 75.0); + + assert_eq!(okhsv.context().illuminant().name(), "D65"); + } + + #[test] + fn it_normalizes_hue_to_zero_one() { + let okhsv = Okhsv::new(450.0, 50.0, 50.0); + + assert!((okhsv.h() - 0.25).abs() < 1e-10); + } + + #[test] + fn it_normalizes_negative_hue() { + let okhsv = Okhsv::new(-90.0, 50.0, 50.0); + + assert!((okhsv.h() - 0.75).abs() < 1e-10); + } + } + + mod new_const { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_normalizes_hue_to_0_1() { + const OKHSV: Okhsv = Okhsv::new_const(270.0, 50.0, 50.0); + + assert_eq!(OKHSV.h(), 0.75); + } + + #[test] + fn it_normalizes_negative_hue() { + const OKHSV: Okhsv = Okhsv::new_const(-90.0, 50.0, 50.0); + + assert_eq!(OKHSV.h(), 0.75); + } + + #[test] + fn it_normalizes_saturation_to_0_1() { + const OKHSV: Okhsv = Okhsv::new_const(0.0, 75.0, 50.0); + + assert_eq!(OKHSV.s(), 0.75); + } + + #[test] + fn it_normalizes_value_to_0_1() { + const OKHSV: Okhsv = Okhsv::new_const(0.0, 50.0, 75.0); + + assert_eq!(OKHSV.v(), 0.75); + } + } + + mod partial_eq { + use super::*; + + #[test] + fn it_compares_equal_colors() { + let a = Okhsv::new(180.0, 50.0, 50.0); + let b = Okhsv::new(180.0, 50.0, 50.0); + + assert!(a == b); + } + + #[test] + fn it_compares_unequal_colors() { + let a = Okhsv::new(180.0, 50.0, 50.0); + let b = Okhsv::new(180.0, 50.0, 60.0); + + assert!(a != b); + } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Okhsv::new(180.0, 50.0, 50.0).with_alpha(0.5); + let b = Okhsv::new(180.0, 50.0, 50.0); + + assert!(a != b); + } + } + + mod s { + use super::*; + + #[test] + fn it_returns_normalized_saturation() { + let okhsv = Okhsv::new(0.0, 75.0, 50.0); + + assert!((okhsv.s() - 0.75).abs() < 1e-10); + } + } + + mod saturation { + use super::*; + + #[test] + fn it_returns_saturation_as_percentage() { + let okhsv = Okhsv::new(0.0, 75.0, 50.0); + + assert!((okhsv.saturation() - 75.0).abs() < 1e-10); + } + } + + mod scale_h { + use super::*; + + #[test] + fn it_multiplies_h_by_factor() { + let mut okhsv = Okhsv::new(90.0, 50.0, 50.0); + okhsv.scale_h(2.0); + + assert!((okhsv.h() - 0.5).abs() < 1e-10); + } + } + + mod scale_s { + use super::*; + + #[test] + fn it_multiplies_s_by_factor() { + let mut okhsv = Okhsv::new(0.0, 25.0, 50.0); + okhsv.scale_s(2.0); + + assert!((okhsv.s() - 0.5).abs() < 1e-10); + } + } + + mod scale_v { + use super::*; + + #[test] + fn it_multiplies_v_by_factor() { + let mut okhsv = Okhsv::new(0.0, 50.0, 25.0); + okhsv.scale_v(2.0); + + assert!((okhsv.v() - 0.5).abs() < 1e-10); + } + } + + mod sub { + use super::*; + + #[test] + fn it_subtracts_two_okhsv_values_via_rgb() { + let a = Okhsv::new(0.0, 50.0, 50.0); + let b = Okhsv::new(0.0, 25.0, 25.0); + let result = a - b; + + assert!(result.h().is_finite()); + assert!(result.s().is_finite()); + assert!(result.v().is_finite()); + } + } + + mod to_oklab { + use super::*; + + #[test] + fn it_converts_achromatic_gray() { + let okhsv = Okhsv::new(0.0, 0.0, 50.0); + let oklab = okhsv.to_oklab(); + + assert!((oklab.l() - Oklab::toe_inv(0.5)).abs() < 1e-10); + assert!(oklab.a().abs() < 1e-10); + assert!(oklab.b().abs() < 1e-10); + } + + #[test] + fn it_converts_black() { + let okhsv = Okhsv::new(0.0, 100.0, 0.0); + let oklab = okhsv.to_oklab(); + + assert!(oklab.l().abs() < 1e-10); + assert!(oklab.a().abs() < 1e-10); + assert!(oklab.b().abs() < 1e-10); + } + + #[test] + fn it_converts_white() { + let okhsv = Okhsv::new(0.0, 0.0, 100.0); + let oklab = okhsv.to_oklab(); + + assert!((oklab.l() - 1.0).abs() < 1e-10); + assert!(oklab.a().abs() < 1e-10); + assert!(oklab.b().abs() < 1e-10); + } + + #[test] + fn it_roundtrips_through_oklab() { + let original = Okhsv::new(210.0, 80.0, 50.0); + let roundtrip = Okhsv::from(original.to_oklab()); + + assert!((original.h() - roundtrip.h()).abs() < 1e-10); + assert!((original.s() - roundtrip.s()).abs() < 1e-10); + assert!((original.v() - roundtrip.v()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let okhsv = Okhsv::new(120.0, 50.0, 50.0).with_alpha(0.3); + let oklab = okhsv.to_oklab(); + + assert!((oklab.alpha() - 0.3).abs() < 1e-10); + } + } + + mod to_rgb { + use super::*; + + #[test] + fn it_converts_to_srgb() { + let okhsv = Okhsv::new(0.0, 50.0, 50.0); + let rgb = okhsv.to_rgb::(); + + assert!(rgb.red() > 0); + } + + #[test] + fn it_converts_black() { + let okhsv = Okhsv::new(0.0, 0.0, 0.0); + let rgb = okhsv.to_rgb::(); + + assert_eq!(rgb.red(), 0); + assert_eq!(rgb.green(), 0); + assert_eq!(rgb.blue(), 0); + } + + #[test] + fn it_converts_white() { + let okhsv = Okhsv::new(0.0, 0.0, 100.0); + let rgb = okhsv.to_rgb::(); + + assert_eq!(rgb.red(), 255); + assert_eq!(rgb.green(), 255); + assert_eq!(rgb.blue(), 255); + } + + #[test] + fn it_preserves_alpha() { + let okhsv = Okhsv::new(120.0, 50.0, 50.0).with_alpha(0.7); + let rgb = okhsv.to_rgb::(); + + assert!((rgb.alpha() - 0.7).abs() < 1e-10); + } + } + + mod to_xyz { + use super::*; + + #[test] + fn it_converts_to_xyz() { + let okhsv = Okhsv::new(120.0, 50.0, 50.0); + let xyz = okhsv.to_xyz(); + + assert!(xyz.x().is_finite()); + assert!(xyz.y().is_finite()); + assert!(xyz.z().is_finite()); + } + + #[test] + fn it_preserves_alpha() { + let okhsv = Okhsv::new(120.0, 50.0, 50.0).with_alpha(0.3); + let xyz = okhsv.to_xyz(); + + assert!((xyz.alpha() - 0.3).abs() < 1e-10); + } + } + + mod try_from_str { + use super::*; + + #[test] + fn it_parses_hex_string() { + let okhsv = Okhsv::try_from("#FF5733").unwrap(); + + assert!(okhsv.v() > 0.0); + } + + #[test] + fn it_returns_error_for_invalid_hex() { + let result = Okhsv::try_from("not_a_color"); + + assert!(result.is_err()); + } + } + + mod v { + use super::*; + + #[test] + fn it_returns_normalized_value() { + let okhsv = Okhsv::new(0.0, 50.0, 75.0); + + assert!((okhsv.v() - 0.75).abs() < 1e-10); + } + } + + mod value { + use super::*; + + #[test] + fn it_returns_value_as_percentage() { + let okhsv = Okhsv::new(0.0, 50.0, 75.0); + + assert!((okhsv.value() - 75.0).abs() < 1e-10); + } + } + + mod with_alpha { + use super::*; + + #[test] + fn it_returns_new_color_with_alpha() { + let okhsv = Okhsv::new(180.0, 50.0, 50.0); + let result = okhsv.with_alpha(0.5); + + assert!((result.alpha() - 0.5).abs() < 1e-10); + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_context { + use super::*; + + #[test] + fn it_returns_new_color_with_context() { + let okhsv = Okhsv::new(180.0, 50.0, 50.0); + let context = ColorimetricContext::default(); + let result = okhsv.with_context(context); + + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_h { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_okhsv_with_new_h() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_h(0.75); + + assert_eq!(result.h(), 0.75); + assert_eq!(result.s(), okhsv.s()); + assert_eq!(result.v(), okhsv.v()); + } + } + + mod with_h_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_decremented_h() { + let okhsv = Okhsv::new(180.0, 50.0, 50.0); + let result = okhsv.with_h_decremented_by(0.25); + + assert!((result.h() - 0.25).abs() < 1e-10); + assert!((okhsv.h() - 0.5).abs() < 1e-10); + } + } + + mod with_h_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_incremented_h() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_h_incremented_by(0.25); + + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_h_scaled_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_scaled_h() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_h_scaled_by(2.0); + + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_hue { + use super::*; + + #[test] + fn it_returns_okhsv_with_new_hue_in_degrees() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_hue(270.0); + + assert!((result.hue() - 270.0).abs() < 1e-10); + assert_eq!(result.s(), okhsv.s()); + assert_eq!(result.v(), okhsv.v()); + } + } + + mod with_hue_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_decremented_hue() { + let okhsv = Okhsv::new(180.0, 50.0, 50.0); + let result = okhsv.with_hue_decremented_by(90.0); + + assert!((result.hue() - 90.0).abs() < 1e-10); + } + } + + mod with_hue_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_incremented_hue() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_hue_incremented_by(90.0); + + assert!((result.hue() - 180.0).abs() < 1e-10); + } + } + + mod with_s { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_okhsv_with_new_s() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_s(0.75); + + assert_eq!(result.s(), 0.75); + assert_eq!(result.h(), okhsv.h()); + assert_eq!(result.v(), okhsv.v()); + } + } + + mod with_s_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_decremented_s() { + let okhsv = Okhsv::new(0.0, 50.0, 50.0); + let result = okhsv.with_s_decremented_by(0.2); + + assert!((result.s() - 0.3).abs() < 1e-10); + } + } + + mod with_s_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_incremented_s() { + let okhsv = Okhsv::new(0.0, 25.0, 50.0); + let result = okhsv.with_s_incremented_by(0.25); + + assert!((result.s() - 0.5).abs() < 1e-10); + } + } + + mod with_s_scaled_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_scaled_s() { + let okhsv = Okhsv::new(0.0, 25.0, 50.0); + let result = okhsv.with_s_scaled_by(2.0); + + assert!((result.s() - 0.5).abs() < 1e-10); + } + } + + mod with_saturation { + use super::*; + + #[test] + fn it_returns_okhsv_with_new_saturation_in_percent() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_saturation(75.0); + + assert!((result.saturation() - 75.0).abs() < 1e-10); + assert_eq!(result.h(), okhsv.h()); + assert_eq!(result.v(), okhsv.v()); + } + } + + mod with_saturation_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_decremented_saturation() { + let okhsv = Okhsv::new(0.0, 50.0, 50.0); + let result = okhsv.with_saturation_decremented_by(20.0); + + assert!((result.saturation() - 30.0).abs() < 1e-10); + } + } + + mod with_saturation_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_incremented_saturation() { + let okhsv = Okhsv::new(0.0, 25.0, 50.0); + let result = okhsv.with_saturation_incremented_by(25.0); + + assert!((result.saturation() - 50.0).abs() < 1e-10); + } + } + + mod with_v { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_okhsv_with_new_v() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_v(0.75); + + assert_eq!(result.v(), 0.75); + assert_eq!(result.h(), okhsv.h()); + assert_eq!(result.s(), okhsv.s()); + } + } + + mod with_v_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_decremented_v() { + let okhsv = Okhsv::new(0.0, 50.0, 50.0); + let result = okhsv.with_v_decremented_by(0.2); + + assert!((result.v() - 0.3).abs() < 1e-10); + } + } + + mod with_v_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_incremented_v() { + let okhsv = Okhsv::new(0.0, 50.0, 25.0); + let result = okhsv.with_v_incremented_by(0.25); + + assert!((result.v() - 0.5).abs() < 1e-10); + } + } + + mod with_v_scaled_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_scaled_v() { + let okhsv = Okhsv::new(0.0, 50.0, 25.0); + let result = okhsv.with_v_scaled_by(2.0); + + assert!((result.v() - 0.5).abs() < 1e-10); + } + } + + mod with_value { + use super::*; + + #[test] + fn it_returns_okhsv_with_new_value_in_percent() { + let okhsv = Okhsv::new(90.0, 50.0, 50.0); + let result = okhsv.with_value(75.0); + + assert!((result.value() - 75.0).abs() < 1e-10); + assert_eq!(result.h(), okhsv.h()); + assert_eq!(result.s(), okhsv.s()); + } + } + + mod with_value_decremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_decremented_value() { + let okhsv = Okhsv::new(0.0, 50.0, 50.0); + let result = okhsv.with_value_decremented_by(20.0); + + assert!((result.value() - 30.0).abs() < 1e-10); + } + } + + mod with_value_incremented_by { + use super::*; + + #[test] + fn it_returns_okhsv_with_incremented_value() { + let okhsv = Okhsv::new(0.0, 50.0, 25.0); + let result = okhsv.with_value_incremented_by(25.0); + + assert!((result.value() - 50.0).abs() < 1e-10); + } + } +} diff --git a/src/space/perceptual/oklab.rs b/src/space/perceptual/oklab.rs new file mode 100644 index 0000000..903b4c4 --- /dev/null +++ b/src/space/perceptual/oklab.rs @@ -0,0 +1,1396 @@ +use std::{ + fmt::{Display, Formatter, Result as FmtResult}, + ops::{Add, Div, Mul, Sub}, +}; + +#[cfg(feature = "space-cmy")] +use crate::space::Cmy; +#[cfg(feature = "space-cmyk")] +use crate::space::Cmyk; +#[cfg(feature = "space-hsl")] +use crate::space::Hsl; +#[cfg(feature = "space-hsv")] +use crate::space::Hsv; +#[cfg(feature = "space-hwb")] +use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; +use crate::{ + ColorimetricContext, Illuminant, Observer, + component::Component, + matrix::Matrix3, + space::{ColorSpace, LinearRgb, Lms, Rgb, RgbSpec, Srgb, Xyz}, +}; + +/// Ok* toe-function constants. +const K1: f64 = 0.206; +const K2: f64 = 0.03; +const K3: f64 = (1.0 + K1) / (1.0 + K2); + +/// Oklab perceptual color space. +/// +/// A perceptual color space designed for image processing, where L represents +/// perceived lightness (0.0-1.0), a represents green-red chromaticity, and b +/// represents blue-yellow chromaticity. Designed to be perceptually uniform. +#[derive(Clone, Copy, Debug)] +pub struct Oklab { + a: Component, + alpha: Component, + b: Component, + context: ColorimetricContext, + l: Component, +} + +impl Oklab { + /// The default viewing context for Oklab (D65 illuminant, CIE 1931 2° observer). + pub const DEFAULT_CONTEXT: ColorimetricContext = ColorimetricContext::new() + .with_illuminant(Illuminant::D65) + .with_observer(Observer::CIE_1931_2D); + /// Matrix for converting cube-root LMS to Oklab L, a, b. + pub const LINEAR_LMS_MATRIX: Matrix3 = Matrix3::new([ + [0.2104542553, 0.7936177850, -0.0040720468], + [1.9779984951, -2.4285922050, 0.4505937099], + [0.0259040371, 0.7827717662, -0.8086757660], + ]); + /// Matrix for converting linear sRGB to linear LMS. + pub const LINEAR_RGB_MATRIX: Matrix3 = Matrix3::new([ + [0.4122214708, 0.5363325363, 0.0514459929], + [0.2119034982, 0.6806995451, 0.1073969566], + [0.0883024619, 0.2817188376, 0.6299787005], + ]); + /// Matrix for converting XYZ to linear LMS. + pub const LINEAR_XYZ_MATRIX: Matrix3 = Matrix3::new([ + [0.8189330101, 0.3618667424, -0.1288597137], + [0.0329845436, 0.9293118715, 0.0361456387], + [0.0482003018, 0.2643662691, 0.6338517070], + ]); + + /// Creates a new Oklab color with the default viewing context. + pub fn new(l: impl Into, a: impl Into, b: impl Into) -> Self { + Self { + a: a.into(), + alpha: Component::new(1.0), + b: b.into(), + context: Self::DEFAULT_CONTEXT, + l: l.into(), + } + } + + /// Creates a new Oklab color in a const context. + pub const fn new_const(l: f64, a: f64, b: f64) -> Self { + Self { + a: Component::new_const(a), + alpha: Component::new_const(1.0), + b: Component::new_const(b), + context: Self::DEFAULT_CONTEXT, + l: Component::new_const(l), + } + } + + /// Finds the cusp (L, C) for a given normalized hue (0.0-1.0). + /// + /// The cusp is the point of maximum chroma on the sRGB gamut boundary + /// for the given hue. + pub(crate) fn cusp_for_hue(h: f64) -> (f64, f64) { + let h_rad = h * 2.0 * std::f64::consts::PI; + let a = h_rad.cos(); + let b = h_rad.sin(); + + let s_cusp = compute_max_saturation(a, b); + let rgb = oklab_to_linear_srgb(1.0, s_cusp * a, s_cusp * b); + let l_cusp = (1.0 / rgb[0].max(rgb[1]).max(rgb[2])).cbrt(); + let c_cusp = l_cusp * s_cusp; + + (l_cusp, c_cusp) + } + + /// Maximum chroma at a given lightness for a cusp (L_cusp, C_cusp). + /// + /// Returns the maximum chroma achievable at the given lightness within + /// the sRGB gamut for the hue defined by the cusp. + pub(crate) fn max_chroma_at_lightness(cusp: (f64, f64), l: f64) -> f64 { + let (l_cusp, c_cusp) = cusp; + + if l <= l_cusp { + if l_cusp <= 0.0 { 0.0 } else { c_cusp * l / l_cusp } + } else if l_cusp >= 1.0 { + 0.0 + } else { + c_cusp * (1.0 - l) / (1.0 - l_cusp) + } + } + + /// Maps Oklab lightness to Ok* perceived lightness via the toe function. + /// + /// Improves perceptual uniformity at the dark end of the lightness range. + pub(crate) fn toe(x: f64) -> f64 { + 0.5 * ((K3 * x) - K1 + ((K3 * x - K1).powi(2) + 4.0 * K2 * K3 * x).sqrt()) + } + + /// Inverse toe function: maps Ok* perceived lightness to Oklab lightness. + pub(crate) fn toe_inv(x: f64) -> f64 { + (x * x + K1 * x) / (K3 * (x + K2)) + } + + /// Returns the a (green-red) component. + pub fn a(&self) -> f64 { + self.a.0 + } + + /// Returns the b (blue-yellow) component. + pub fn b(&self) -> f64 { + self.b.0 + } + + /// Returns the [L, a, b] components as an array. + pub fn components(&self) -> [f64; 3] { + [self.l.0, self.a.0, self.b.0] + } + + /// Returns the viewing context for this color. + pub fn context(&self) -> &ColorimetricContext { + &self.context + } + + /// Decreases the a component by the given amount. + pub fn decrement_a(&mut self, amount: impl Into) { + self.a -= amount.into(); + } + + /// Decreases the b component by the given amount. + pub fn decrement_b(&mut self, amount: impl Into) { + self.b -= amount.into(); + } + + /// Decreases the L component by the given amount. + pub fn decrement_l(&mut self, amount: impl Into) { + self.l -= amount.into(); + } + + /// Increases the a component by the given amount. + pub fn increment_a(&mut self, amount: impl Into) { + self.a += amount.into(); + } + + /// Increases the b component by the given amount. + pub fn increment_b(&mut self, amount: impl Into) { + self.b += amount.into(); + } + + /// Increases the L component by the given amount. + pub fn increment_l(&mut self, amount: impl Into) { + self.l += amount.into(); + } + + /// Returns the L (lightness) component. + pub fn l(&self) -> f64 { + self.l.0 + } + + /// Scales the a component by the given factor. + pub fn scale_a(&mut self, factor: impl Into) { + self.a *= factor.into(); + } + + /// Scales the b component by the given factor. + pub fn scale_b(&mut self, factor: impl Into) { + self.b *= factor.into(); + } + + /// Scales the L component by the given factor. + pub fn scale_l(&mut self, factor: impl Into) { + self.l *= factor.into(); + } + + /// Sets the [L, a, b] components from an array. + pub fn set_components(&mut self, components: [impl Into + Clone; 3]) { + self.set_l(components[0].clone()); + self.set_a(components[1].clone()); + self.set_b(components[2].clone()); + } + + /// Sets the a component. + pub fn set_a(&mut self, a: impl Into) { + self.a = a.into(); + } + + /// Sets the b component. + pub fn set_b(&mut self, b: impl Into) { + self.b = b.into(); + } + + /// Sets the L component. + pub fn set_l(&mut self, l: impl Into) { + self.l = l.into(); + } + + #[cfg(feature = "space-okhsl")] + /// Converts to the Okhsl perceptual color space. + pub fn to_okhsl(&self) -> Okhsl { + let [l, a, b] = self.components(); + let h_rad = b.atan2(a); + let h = (h_rad / (2.0 * std::f64::consts::PI)).rem_euclid(1.0); + let c = (a * a + b * b).sqrt(); + let okhsl_l = Self::toe(l); + let s = if c < 1e-4 { + 0.0 + } else { + let cusp = Self::cusp_for_hue(h); + let max_c = Self::max_chroma_at_lightness(cusp, l); + if max_c < 1e-10 { 0.0 } else { (c / max_c).min(1.0) } + }; + + Okhsl::new(h * 360.0, s * 100.0, okhsl_l * 100.0).with_alpha(self.alpha) + } + + #[cfg(feature = "space-okhsv")] + /// Converts to the Okhsv perceptual color space (HSV form). + /// + /// Uses an HSV cone model where V=1, S=1 is the cusp (maximum chroma) + /// and V=1, S=0 is white. The inverse of `Okhsv::to_oklab`. + pub fn to_okhsv(&self) -> Okhsv { + let [l, a, b] = self.components(); + let h_rad = b.atan2(a); + let h = (h_rad / (2.0 * std::f64::consts::PI)).rem_euclid(1.0); + let c = (a * a + b * b).sqrt(); + + let cusp = Self::cusp_for_hue(h); + let (l_cusp, c_cusp) = cusp; + + if c_cusp < 1e-10 || l < 1e-10 { + let v = Self::toe(l); + return Okhsv::new(h * 360.0, 0.0, v * 100.0).with_alpha(self.alpha); + } + + let tv = l + c * (1.0 - l_cusp) / c_cusp; + let v = Self::toe(tv); + let s = if tv > 1e-10 { (c / (tv * c_cusp)).min(1.0) } else { 0.0 }; + + Okhsv::new(h * 360.0, s * 100.0, v * 100.0).with_alpha(self.alpha) + } + + #[cfg(feature = "space-oklch")] + /// Converts to the Oklch perceptual color space (cylindrical form). + pub fn to_oklch(&self) -> Oklch { + let [l, a, b] = self.components(); + let c = (a * a + b * b).sqrt(); + let h = b.atan2(a).to_degrees(); + + Oklch::new(l, c, h).with_alpha(self.alpha) + } + + /// Converts to the specified RGB color space. + pub fn to_rgb(&self) -> Rgb + where + S: RgbSpec, + { + let [l, a, b] = self.components(); + + let ll = l + (0.3963377774 * a) + (0.2158037573 * b); + let lm = l - (0.1055613458 * a) - (0.0638541728 * b); + let ls = l - (0.0894841775 * a) - (1.2914855480 * b); + + let linear_lms = [ll * ll * ll, lm * lm * lm, ls * ls * ls]; + let [r, g, b] = Self::LINEAR_RGB_MATRIX.inverse() * linear_lms; + + LinearRgb::::from_normalized(r, g, b) + .with_alpha(self.alpha) + .to_encoded() + } + + /// Converts to the CIE XYZ color space. + pub fn to_xyz(&self) -> Xyz { + let lab = self.components(); + + let lms = Self::LINEAR_LMS_MATRIX.inverse() * lab; + let linear = [ + lms[0] * lms[0] * lms[0], + lms[1] * lms[1] * lms[1], + lms[2] * lms[2] * lms[2], + ]; + let [x, y, z] = Self::LINEAR_XYZ_MATRIX.inverse() * linear; + + Xyz::new(x, y, z).with_context(self.context).with_alpha(self.alpha) + } + + /// Returns a new color with the given a value. + pub fn with_a(&self, a: impl Into) -> Self { + Self { + a: a.into(), + ..*self + } + } + + /// Returns a new color with a decreased by the given amount. + pub fn with_a_decremented_by(&self, amount: impl Into) -> Self { + let mut oklab = *self; + oklab.decrement_a(amount); + oklab + } + + /// Returns a new color with a increased by the given amount. + pub fn with_a_incremented_by(&self, amount: impl Into) -> Self { + let mut oklab = *self; + oklab.increment_a(amount); + oklab + } + + /// Returns a new color with a scaled by the given factor. + pub fn with_a_scaled_by(&self, factor: impl Into) -> Self { + let mut oklab = *self; + oklab.scale_a(factor); + oklab + } + + /// Returns a new color with the given b value. + pub fn with_b(&self, b: impl Into) -> Self { + Self { + b: b.into(), + ..*self + } + } + + /// Returns a new color with b decreased by the given amount. + pub fn with_b_decremented_by(&self, amount: impl Into) -> Self { + let mut oklab = *self; + oklab.decrement_b(amount); + oklab + } + + /// Returns a new color with b increased by the given amount. + pub fn with_b_incremented_by(&self, amount: impl Into) -> Self { + let mut oklab = *self; + oklab.increment_b(amount); + oklab + } + + /// Returns a new color with b scaled by the given factor. + pub fn with_b_scaled_by(&self, factor: impl Into) -> Self { + let mut oklab = *self; + oklab.scale_b(factor); + oklab + } + + /// Returns this color with a different viewing context (without adaptation). + pub fn with_context(&self, context: ColorimetricContext) -> Self { + Self { + context, + ..*self + } + } + + /// Returns a new color with the given L value. + pub fn with_l(&self, l: impl Into) -> Self { + Self { + l: l.into(), + ..*self + } + } + + /// Returns a new color with L decreased by the given amount. + pub fn with_l_decremented_by(&self, amount: impl Into) -> Self { + let mut oklab = *self; + oklab.decrement_l(amount); + oklab + } + + /// Returns a new color with L increased by the given amount. + pub fn with_l_incremented_by(&self, amount: impl Into) -> Self { + let mut oklab = *self; + oklab.increment_l(amount); + oklab + } + + /// Returns a new color with L scaled by the given factor. + pub fn with_l_scaled_by(&self, factor: impl Into) -> Self { + let mut oklab = *self; + oklab.scale_l(factor); + oklab + } +} + +impl Add for Oklab +where + T: Into, +{ + type Output = Self; + + fn add(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() + rhs.into().to_rgb::()) + } +} + +impl ColorSpace<3> for Oklab { + fn alpha(&self) -> f64 { + self.alpha.0 + } + + fn components(&self) -> [f64; 3] { + self.components() + } + + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + + fn set_components(&mut self, components: [impl Into + Clone; 3]) { + self.set_components(components) + } + + #[cfg(feature = "space-okhsl")] + fn to_okhsl(&self) -> Okhsl { + self.to_okhsl() + } + + #[cfg(feature = "space-okhsv")] + fn to_okhsv(&self) -> Okhsv { + self.to_okhsv() + } + + fn to_xyz(&self) -> Xyz { + self.to_xyz() + } +} + +impl Display for Oklab { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let precision = f.precision().unwrap_or(4); + if self.alpha.0 < 1.0 { + write!( + f, + "Oklab({:.precision$}, {:.precision$}, {:.precision$}, {:.0}%)", + self.l, + self.a, + self.b, + self.opacity() + ) + } else { + write!( + f, + "Oklab({:.precision$}, {:.precision$}, {:.precision$})", + self.l, self.a, self.b + ) + } + } +} + +impl Div for Oklab +where + T: Into, +{ + type Output = Self; + + fn div(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() / rhs.into().to_rgb::()) + } +} + +impl From<[T; 3]> for Oklab +where + T: Into, +{ + fn from([l, a, b]: [T; 3]) -> Self { + Self::new(l, a, b) + } +} + +#[cfg(feature = "space-cmy")] +impl From> for Oklab +where + S: RgbSpec, +{ + fn from(cmy: Cmy) -> Self { + cmy.to_oklab() + } +} + +#[cfg(feature = "space-cmyk")] +impl From> for Oklab +where + S: RgbSpec, +{ + fn from(cmyk: Cmyk) -> Self { + cmyk.to_oklab() + } +} + +#[cfg(feature = "space-hsl")] +impl From> for Oklab +where + S: RgbSpec, +{ + fn from(hsl: Hsl) -> Self { + hsl.to_oklab() + } +} + +#[cfg(feature = "space-hsv")] +impl From> for Oklab +where + S: RgbSpec, +{ + fn from(hsv: Hsv) -> Self { + hsv.to_oklab() + } +} + +#[cfg(feature = "space-hwb")] +impl From> for Oklab +where + S: RgbSpec, +{ + fn from(hwb: Hwb) -> Self { + hwb.to_oklab() + } +} + +impl From for Oklab { + fn from(lms: Lms) -> Self { + lms.to_oklab() + } +} + +#[cfg(feature = "space-okhsl")] +impl From for Oklab { + fn from(okhsl: Okhsl) -> Self { + okhsl.to_oklab() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Oklab { + fn from(okhsv: Okhsv) -> Self { + okhsv.to_oklab() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Oklab { + fn from(oklch: Oklch) -> Self { + oklch.to_oklab() + } +} + +impl From> for Oklab +where + S: RgbSpec, +{ + fn from(rgb: Rgb) -> Self { + rgb.to_oklab() + } +} + +impl From for Oklab { + fn from(xyz: Xyz) -> Self { + xyz.to_oklab() + } +} + +impl Mul for Oklab +where + T: Into, +{ + type Output = Self; + + fn mul(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() * rhs.into().to_rgb::()) + } +} + +impl PartialEq for Oklab +where + T: Into + Copy, +{ + fn eq(&self, other: &T) -> bool { + let other = (*other).into(); + self.alpha == other.alpha && self.l == other.l && self.a == other.a && self.b == other.b + } +} + +impl Sub for Oklab +where + T: Into, +{ + type Output = Self; + + fn sub(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() - rhs.into().to_rgb::()) + } +} + +impl TryFrom<&str> for Oklab { + type Error = crate::Error; + + fn try_from(value: &str) -> Result { + Ok(Self::from(Rgb::::try_from(value)?.to_xyz())) + } +} + +impl TryFrom for Oklab { + type Error = crate::Error; + + fn try_from(value: String) -> Result { + Ok(Self::from(Rgb::::try_from(value)?.to_xyz())) + } +} + +/// Computes the maximum saturation for a given hue direction (a_, b_) in Oklab. +/// +/// Based on Björn Ottosson's reference implementation. Uses a polynomial +/// approximation refined by one step of Halley's method. +fn compute_max_saturation(a: f64, b: f64) -> f64 { + let (k0, k1, k2, k3, k4, wl, wm, ws); + + if -1.88170328 * a - 0.80936493 * b > 1.0 { + k0 = 1.19086277; + k1 = 1.76576728; + k2 = 0.59662641; + k3 = 0.75515197; + k4 = 0.56771245; + wl = 4.0767416621; + wm = -3.3077115913; + ws = 0.2309699292; + } else if 1.81444104 * a - 1.19445276 * b > 1.0 { + k0 = 0.73956515; + k1 = -0.45954404; + k2 = 0.08285427; + k3 = 0.12541070; + k4 = -0.14503204; + wl = -1.2684380046; + wm = 2.6097574011; + ws = -0.3413193965; + } else { + k0 = 1.35733652; + k1 = -0.00915799; + k2 = -1.15130210; + k3 = -0.50559606; + k4 = 0.00692167; + wl = -0.0041960863; + wm = -0.7034186147; + ws = 1.7076147010; + } + + let sat = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b; + + let k_l = 0.3963377774 * a + 0.2158037573 * b; + let k_m = -0.1055613458 * a - 0.0638541728 * b; + let k_s = -0.0894841775 * a - 1.2914855480 * b; + + let l_ = 1.0 + sat * k_l; + let m_ = 1.0 + sat * k_m; + let s_ = 1.0 + sat * k_s; + + let l = l_ * l_ * l_; + let m = m_ * m_ * m_; + let s = s_ * s_ * s_; + + let l_ds = 3.0 * k_l * l_ * l_; + let m_ds = 3.0 * k_m * m_ * m_; + let s_ds = 3.0 * k_s * s_ * s_; + + let l_ds2 = 6.0 * k_l * k_l * l_; + let m_ds2 = 6.0 * k_m * k_m * m_; + let s_ds2 = 6.0 * k_s * k_s * s_; + + let f = wl * l + wm * m + ws * s; + let f1 = wl * l_ds + wm * m_ds + ws * s_ds; + let f2 = wl * l_ds2 + wm * m_ds2 + ws * s_ds2; + + sat - f * f1 / (f1 * f1 - 0.5 * f * f2) +} + +/// Converts Oklab L, a, b to linear sRGB components. +fn oklab_to_linear_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { + let l_ = l + 0.3963377774 * a + 0.2158037573 * b; + let m_ = l - 0.1055613458 * a - 0.0638541728 * b; + let s_ = l - 0.0894841775 * a - 1.2914855480 * b; + + let l3 = l_ * l_ * l_; + let m3 = m_ * m_ * m_; + let s3 = s_ * s_ * s_; + + [ + 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3, + -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3, + -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3, + ] +} + +#[cfg(test)] +mod test { + use super::*; + + mod a { + use super::*; + + #[test] + fn it_returns_a_component() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + + assert!((oklab.a() - 0.1).abs() < 1e-10); + } + } + + mod add { + use super::*; + + #[test] + fn it_adds_two_oklab_colors() { + let a = Oklab::new(0.5, 0.1, -0.1); + let b = Oklab::new(0.3, -0.05, 0.05); + let result = a + b; + + assert!(result.l() > 0.0); + } + } + + mod b { + use super::*; + + #[test] + fn it_returns_b_component() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + + assert!((oklab.b() - -0.1).abs() < 1e-10); + } + } + + mod components { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_components_as_array() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let [l, a, b] = oklab.components(); + + assert_eq!(l, 0.5); + assert_eq!(a, 0.1); + assert_eq!(b, -0.1); + } + } + + mod decrement_a { + use super::*; + + #[test] + fn it_decreases_a_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.decrement_a(0.05); + + assert!((oklab.a() - 0.05).abs() < 1e-10); + } + } + + mod decrement_b { + use super::*; + + #[test] + fn it_decreases_b_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.decrement_b(0.05); + + assert!((oklab.b() - -0.15).abs() < 1e-10); + } + } + + mod decrement_l { + use super::*; + + #[test] + fn it_decreases_l_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.decrement_l(0.1); + + assert!((oklab.l() - 0.4).abs() < 1e-10); + } + } + + mod display { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_formats_with_default_precision() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + + assert_eq!(format!("{}", oklab), "Oklab(0.5000, 0.1000, -0.1000)"); + } + + #[test] + fn it_formats_with_custom_precision() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + + assert_eq!(format!("{:.2}", oklab), "Oklab(0.50, 0.10, -0.10)"); + } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let oklab = Oklab::new(0.5, 0.1, -0.1).with_alpha(0.5); + + assert_eq!(format!("{}", oklab), "Oklab(0.5000, 0.1000, -0.1000, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + + assert_eq!(format!("{}", oklab), "Oklab(0.5000, 0.1000, -0.1000)"); + } + } + + mod from_array { + use super::*; + + #[test] + fn it_creates_from_f64_array() { + let oklab = Oklab::from([0.5, 0.1, -0.1]); + + assert!((oklab.l() - 0.5).abs() < 1e-10); + assert!((oklab.a() - 0.1).abs() < 1e-10); + assert!((oklab.b() - -0.1).abs() < 1e-10); + } + } + + mod from_rgb { + use super::*; + + #[test] + fn it_converts_white_correctly() { + let rgb = Rgb::::new(255, 255, 255); + let oklab = Oklab::from(rgb); + + assert!((oklab.l() - 1.0).abs() < 1e-3); + assert!(oklab.a().abs() < 1e-3); + assert!(oklab.b().abs() < 1e-3); + } + + #[test] + fn it_converts_black_correctly() { + let rgb = Rgb::::new(0, 0, 0); + let oklab = Oklab::from(rgb); + + assert!(oklab.l().abs() < 1e-3); + assert!(oklab.a().abs() < 1e-3); + assert!(oklab.b().abs() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let rgb = Rgb::::new(128, 64, 32).with_alpha(0.5); + let oklab = Oklab::from(rgb); + + assert!((oklab.alpha() - 0.5).abs() < 1e-10); + } + } + + mod from_xyz { + use super::*; + + #[test] + fn it_converts_from_xyz() { + let xyz = Xyz::new(0.5, 0.5, 0.5); + let oklab = Oklab::from(xyz); + + assert!(oklab.l() > 0.0); + } + + #[test] + fn it_preserves_alpha() { + let xyz = Xyz::new(0.5, 0.5, 0.5).with_alpha(0.3); + let oklab = Oklab::from(xyz); + + assert!((oklab.alpha() - 0.3).abs() < 1e-10); + } + } + + mod increment_a { + use super::*; + + #[test] + fn it_increases_a_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.increment_a(0.05); + + assert!((oklab.a() - 0.15).abs() < 1e-10); + } + } + + mod increment_b { + use super::*; + + #[test] + fn it_increases_b_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.increment_b(0.05); + + assert!((oklab.b() - -0.05).abs() < 1e-10); + } + } + + mod increment_l { + use super::*; + + #[test] + fn it_increases_l_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.increment_l(0.1); + + assert!((oklab.l() - 0.6).abs() < 1e-10); + } + } + + mod l { + use super::*; + + #[test] + fn it_returns_l_component() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + + assert!((oklab.l() - 0.5).abs() < 1e-10); + } + } + + mod new { + use super::*; + + #[test] + fn it_creates_with_default_alpha() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + + assert!((oklab.alpha() - 1.0).abs() < 1e-10); + } + + #[test] + fn it_creates_with_default_context() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + + assert_eq!(oklab.context().illuminant().name(), "D65"); + } + } + + mod partial_eq { + use super::*; + + #[test] + fn it_compares_equal_colors() { + let a = Oklab::new(0.5, 0.1, -0.1); + let b = Oklab::new(0.5, 0.1, -0.1); + + assert!(a == b); + } + + #[test] + fn it_compares_unequal_colors() { + let a = Oklab::new(0.5, 0.1, -0.1); + let b = Oklab::new(0.6, 0.1, -0.1); + + assert!(a != b); + } + } + + mod scale_a { + use super::*; + + #[test] + fn it_scales_a_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.scale_a(2.0); + + assert!((oklab.a() - 0.2).abs() < 1e-10); + } + } + + mod scale_b { + use super::*; + + #[test] + fn it_scales_b_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.scale_b(2.0); + + assert!((oklab.b() - -0.2).abs() < 1e-10); + } + } + + mod scale_l { + use super::*; + + #[test] + fn it_scales_l_component() { + let mut oklab = Oklab::new(0.5, 0.1, -0.1); + oklab.scale_l(2.0); + + assert!((oklab.l() - 1.0).abs() < 1e-10); + } + } + + #[cfg(feature = "space-okhsl")] + mod to_okhsl { + use super::*; + + #[test] + fn it_converts_achromatic_to_zero_saturation() { + let oklab = Oklab::new(0.5, 0.0, 0.0); + let okhsl = oklab.to_okhsl(); + + assert!(okhsl.s() < 1e-3); + } + + #[test] + fn it_converts_black() { + let oklab = Oklab::new(0.0, 0.0, 0.0); + let okhsl = oklab.to_okhsl(); + + assert!(okhsl.l().abs() < 1e-10); + } + + #[test] + fn it_converts_white() { + let oklab = Oklab::new(1.0, 0.0, 0.0); + let okhsl = oklab.to_okhsl(); + + assert!((okhsl.l() - 1.0).abs() < 1e-10); + assert!(okhsl.s() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let oklab = Oklab::new(0.5, 0.1, -0.1).with_alpha(0.3); + let okhsl = oklab.to_okhsl(); + + assert!((okhsl.alpha() - 0.3).abs() < 1e-10); + } + } + + #[cfg(feature = "space-okhsv")] + mod to_okhsv { + use super::*; + + #[test] + fn it_converts_achromatic_to_zero_saturation() { + let oklab = Oklab::new(0.5, 0.0, 0.0); + let okhsv = oklab.to_okhsv(); + + assert!(okhsv.s() < 1e-3); + } + + #[test] + fn it_converts_black() { + let oklab = Oklab::new(0.0, 0.0, 0.0); + let okhsv = oklab.to_okhsv(); + + assert!(okhsv.v().abs() < 1e-10); + } + + #[test] + fn it_converts_white() { + let oklab = Oklab::new(1.0, 0.0, 0.0); + let okhsv = oklab.to_okhsv(); + + assert!((okhsv.v() - 1.0).abs() < 1e-10); + assert!(okhsv.s() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let oklab = Oklab::new(0.5, 0.1, -0.1).with_alpha(0.3); + let okhsv = oklab.to_okhsv(); + + assert!((okhsv.alpha() - 0.3).abs() < 1e-10); + } + } + + #[cfg(feature = "space-oklch")] + mod to_oklch { + use super::*; + + #[test] + fn it_converts_to_oklch() { + let oklab = Oklab::new(0.5, 0.0, 0.15); + let oklch = oklab.to_oklch(); + + assert!((oklch.l() - 0.5).abs() < 1e-10); + assert!((oklch.c() - 0.15).abs() < 1e-10); + assert!((oklch.hue() - 90.0).abs() < 1e-10); + } + + #[test] + fn it_roundtrips_through_oklch() { + let original = Oklab::new(0.5, 0.1, -0.1); + let roundtrip = Oklch::from(original).to_oklab(); + + assert!((original.l() - roundtrip.l()).abs() < 1e-10); + assert!((original.a() - roundtrip.a()).abs() < 1e-10); + assert!((original.b() - roundtrip.b()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let oklab = Oklab::new(0.5, 0.1, -0.1).with_alpha(0.7); + let oklch = oklab.to_oklch(); + + assert!((oklch.alpha() - 0.7).abs() < 1e-10); + } + } + + mod to_rgb { + use super::*; + + #[test] + fn it_converts_to_srgb() { + let oklab = Oklab::new(0.5, 0.0, 0.0); + let rgb = oklab.to_rgb::(); + + assert!(rgb.red() > 0); + } + + #[test] + fn it_roundtrips_through_rgb() { + let original = Oklab::from(Rgb::::new(128, 64, 200)); + let roundtrip = Oklab::from(original.to_rgb::()); + + assert!((original.l() - roundtrip.l()).abs() < 1e-3); + assert!((original.a() - roundtrip.a()).abs() < 1e-3); + assert!((original.b() - roundtrip.b()).abs() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let oklab = Oklab::new(0.5, 0.0, 0.0).with_alpha(0.7); + let rgb = oklab.to_rgb::(); + + assert!((rgb.alpha() - 0.7).abs() < 1e-10); + } + } + + mod to_xyz { + use super::*; + + #[test] + fn it_converts_to_xyz() { + let oklab = Oklab::new(0.5, 0.0, 0.0); + let xyz = oklab.to_xyz(); + + assert!(xyz.y() > 0.0); + } + + #[test] + fn it_roundtrips_through_xyz() { + let original = Oklab::new(0.5, 0.1, -0.1); + let roundtrip = Oklab::from(original.to_xyz()); + + assert!((original.l() - roundtrip.l()).abs() < 1e-10); + assert!((original.a() - roundtrip.a()).abs() < 1e-10); + assert!((original.b() - roundtrip.b()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let oklab = Oklab::new(0.5, 0.0, 0.0).with_alpha(0.3); + let xyz = oklab.to_xyz(); + + assert!((xyz.alpha() - 0.3).abs() < 1e-10); + } + } + + mod try_from_str { + use super::*; + + #[test] + fn it_parses_hex_string() { + let oklab = Oklab::try_from("#FF5733").unwrap(); + + assert!(oklab.l() > 0.0); + } + + #[test] + fn it_returns_error_for_invalid_hex() { + let result = Oklab::try_from("not_a_color"); + + assert!(result.is_err()); + } + } + + mod with_a { + use super::*; + + #[test] + fn it_returns_new_color_with_a() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_a(0.2); + + assert!((result.a() - 0.2).abs() < 1e-10); + assert!((result.l() - 0.5).abs() < 1e-10); + assert!((result.b() - -0.1).abs() < 1e-10); + } + } + + mod with_a_decremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_a_decremented() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_a_decremented_by(0.05); + + assert!((result.a() - 0.05).abs() < 1e-10); + } + } + + mod with_a_incremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_a_incremented() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_a_incremented_by(0.05); + + assert!((result.a() - 0.15).abs() < 1e-10); + } + } + + mod with_a_scaled_by { + use super::*; + + #[test] + fn it_returns_new_color_with_a_scaled() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_a_scaled_by(2.0); + + assert!((result.a() - 0.2).abs() < 1e-10); + } + } + + mod with_alpha { + use super::*; + + #[test] + fn it_returns_new_color_with_alpha() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_alpha(0.5); + + assert!((result.alpha() - 0.5).abs() < 1e-10); + assert!((result.l() - 0.5).abs() < 1e-10); + } + } + + mod with_b { + use super::*; + + #[test] + fn it_returns_new_color_with_b() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_b(0.2); + + assert!((result.b() - 0.2).abs() < 1e-10); + assert!((result.l() - 0.5).abs() < 1e-10); + assert!((result.a() - 0.1).abs() < 1e-10); + } + } + + mod with_b_decremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_b_decremented() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_b_decremented_by(0.05); + + assert!((result.b() - -0.15).abs() < 1e-10); + } + } + + mod with_b_incremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_b_incremented() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_b_incremented_by(0.05); + + assert!((result.b() - -0.05).abs() < 1e-10); + } + } + + mod with_b_scaled_by { + use super::*; + + #[test] + fn it_returns_new_color_with_b_scaled() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_b_scaled_by(2.0); + + assert!((result.b() - -0.2).abs() < 1e-10); + } + } + + mod with_context { + use super::*; + + #[test] + fn it_returns_new_color_with_context() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let context = ColorimetricContext::default(); + let result = oklab.with_context(context); + + assert!((result.l() - 0.5).abs() < 1e-10); + } + } + + mod with_l { + use super::*; + + #[test] + fn it_returns_new_color_with_l() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_l(0.8); + + assert!((result.l() - 0.8).abs() < 1e-10); + assert!((result.a() - 0.1).abs() < 1e-10); + assert!((result.b() - -0.1).abs() < 1e-10); + } + } + + mod with_l_decremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_l_decremented() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_l_decremented_by(0.1); + + assert!((result.l() - 0.4).abs() < 1e-10); + } + } + + mod with_l_incremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_l_incremented() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_l_incremented_by(0.1); + + assert!((result.l() - 0.6).abs() < 1e-10); + } + } + + mod with_l_scaled_by { + use super::*; + + #[test] + fn it_returns_new_color_with_l_scaled() { + let oklab = Oklab::new(0.5, 0.1, -0.1); + let result = oklab.with_l_scaled_by(2.0); + + assert!((result.l() - 1.0).abs() < 1e-10); + } + } +} diff --git a/src/space/perceptual/oklch.rs b/src/space/perceptual/oklch.rs new file mode 100644 index 0000000..a8f2404 --- /dev/null +++ b/src/space/perceptual/oklch.rs @@ -0,0 +1,1376 @@ +use std::{ + fmt::{Display, Formatter, Result as FmtResult}, + ops::{Add, Div, Mul, Sub}, +}; + +#[cfg(feature = "space-cmy")] +use crate::space::Cmy; +#[cfg(feature = "space-cmyk")] +use crate::space::Cmyk; +#[cfg(feature = "space-hsl")] +use crate::space::Hsl; +#[cfg(feature = "space-hsv")] +use crate::space::Hsv; +#[cfg(feature = "space-hwb")] +use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +use crate::{ + ColorimetricContext, Illuminant, Observer, + component::Component, + space::{ColorSpace, Lms, Oklab, Rgb, RgbSpec, Srgb, Xyz}, +}; + +/// Oklch perceptual color space (cylindrical form of Oklab). +/// +/// A cylindrical representation of the Oklab perceptual color space where L represents +/// perceived lightness (0.0-1.0), C represents chroma (colorfulness), and H represents +/// hue stored internally as a 0.0-1.0 fraction (0-360°). Designed for intuitive color +/// manipulation with perceptual uniformity. +#[derive(Clone, Copy, Debug)] +pub struct Oklch { + alpha: Component, + c: Component, + context: ColorimetricContext, + h: Component, + l: Component, +} + +impl Oklch { + /// The default viewing context for Oklch (D65 illuminant, CIE 1931 2° observer). + pub const DEFAULT_CONTEXT: ColorimetricContext = ColorimetricContext::new() + .with_illuminant(Illuminant::D65) + .with_observer(Observer::CIE_1931_2D); + + /// Creates a new Oklch color from lightness (0.0-1.0), chroma, and hue (0-360°). + pub fn new(l: impl Into, c: impl Into, h: impl Into) -> Self { + Self { + alpha: Component::new(1.0), + c: c.into(), + context: Self::DEFAULT_CONTEXT, + h: Component::new((h.into().0 / 360.0).rem_euclid(1.0)), + l: l.into(), + } + } + + /// Creates a new Oklch color in a const context from lightness, chroma, and hue (0-360°). + pub const fn new_const(l: f64, c: f64, h: f64) -> Self { + let r = (h / 360.0) % 1.0; + + Self { + alpha: Component::new_const(1.0), + c: Component::new_const(c), + context: Self::DEFAULT_CONTEXT, + h: Component::new_const(if r < 0.0 { r + 1.0 } else { r }), + l: Component::new_const(l), + } + } + + /// Returns the C (chroma) component. + pub fn c(&self) -> f64 { + self.c.0 + } + + /// Returns the chroma value (alias for [`Self::c`]). + pub fn chroma(&self) -> f64 { + self.c.0 + } + + /// Returns the [L, C, H] components as an array (hue normalized to 0.0-1.0). + pub fn components(&self) -> [f64; 3] { + [self.l.0, self.c.0, self.h.0] + } + + /// Returns the viewing context for this color. + pub fn context(&self) -> &ColorimetricContext { + &self.context + } + + /// Decreases the chroma by the given amount. + pub fn decrement_c(&mut self, amount: impl Into) { + self.c -= amount.into(); + } + + /// Alias for [`Self::decrement_c`]. + pub fn decrement_chroma(&mut self, amount: impl Into) { + self.decrement_c(amount) + } + + /// Decreases the normalized hue by the given amount (wraps around 0.0-1.0). + pub fn decrement_h(&mut self, amount: impl Into) { + self.h = Component::new((self.h.0 - amount.into().0).rem_euclid(1.0)); + } + + /// Decreases the hue by the given amount in degrees (wraps around 0-360°). + pub fn decrement_hue(&mut self, amount: impl Into) { + self.decrement_h(amount.into() / 360.0) + } + + /// Decreases the L component by the given amount. + pub fn decrement_l(&mut self, amount: impl Into) { + self.l -= amount.into(); + } + + /// Returns the normalized hue component (0.0-1.0). + pub fn h(&self) -> f64 { + self.h.0 + } + + /// Returns the hue in degrees (0-360°). + pub fn hue(&self) -> f64 { + self.h.0 * 360.0 + } + + /// Increases the chroma by the given amount. + pub fn increment_c(&mut self, amount: impl Into) { + self.c += amount.into(); + } + + /// Alias for [`Self::increment_c`]. + pub fn increment_chroma(&mut self, amount: impl Into) { + self.increment_c(amount) + } + + /// Increases the normalized hue by the given amount (wraps around 0.0-1.0). + pub fn increment_h(&mut self, amount: impl Into) { + self.h = Component::new((self.h.0 + amount.into().0).rem_euclid(1.0)); + } + + /// Increases the hue by the given amount in degrees (wraps around 0-360°). + pub fn increment_hue(&mut self, amount: impl Into) { + self.increment_h(amount.into() / 360.0) + } + + /// Increases the L component by the given amount. + pub fn increment_l(&mut self, amount: impl Into) { + self.l += amount.into(); + } + + /// Returns the L (lightness) component. + pub fn l(&self) -> f64 { + self.l.0 + } + + /// Scales the chroma by the given factor. + pub fn scale_c(&mut self, factor: impl Into) { + self.c *= factor.into(); + } + + /// Alias for [`Self::scale_c`]. + pub fn scale_chroma(&mut self, factor: impl Into) { + self.scale_c(factor) + } + + /// Scales the normalized hue by the given factor (wraps around 0.0-1.0). + pub fn scale_h(&mut self, factor: impl Into) { + self.h = Component::new((self.h.0 * factor.into().0).rem_euclid(1.0)); + } + + /// Alias for [`Self::scale_h`]. + pub fn scale_hue(&mut self, factor: impl Into) { + self.scale_h(factor) + } + + /// Scales the L component by the given factor. + pub fn scale_l(&mut self, factor: impl Into) { + self.l *= factor.into(); + } + + /// Sets the [L, C, H] components from an array. + pub fn set_components(&mut self, components: [impl Into + Clone; 3]) { + self.set_l(components[0].clone()); + self.set_c(components[1].clone()); + self.set_h(components[2].clone()); + } + + /// Sets the C component. + pub fn set_c(&mut self, c: impl Into) { + self.c = c.into(); + } + + /// Alias for [`Self::set_c`]. + pub fn set_chroma(&mut self, chroma: impl Into) { + self.set_c(chroma) + } + + /// Sets the normalized hue component (0.0-1.0). + pub fn set_h(&mut self, h: impl Into) { + self.h = h.into(); + } + + /// Sets the hue from a value in degrees (0-360°). + pub fn set_hue(&mut self, hue: impl Into) { + self.h = Component::new((hue.into().0 / 360.0).rem_euclid(1.0)); + } + + /// Sets the L component. + pub fn set_l(&mut self, l: impl Into) { + self.l = l.into(); + } + + /// Converts to the Oklab perceptual color space. + pub fn to_oklab(&self) -> Oklab { + let h_rad = self.h.0 * 2.0 * std::f64::consts::PI; + let a = self.c.0 * h_rad.cos(); + let b = self.c.0 * h_rad.sin(); + + Oklab::new(self.l, a, b).with_alpha(self.alpha) + } + + /// Converts to the specified RGB color space. + pub fn to_rgb(&self) -> Rgb + where + S: RgbSpec, + { + self.to_oklab().to_rgb::() + } + + /// Converts to the CIE XYZ color space. + pub fn to_xyz(&self) -> Xyz { + self.to_oklab().to_xyz() + } + + /// Returns a new color with the given C value. + pub fn with_c(&self, c: impl Into) -> Self { + Self { + c: c.into(), + ..*self + } + } + + /// Returns a new color with C decreased by the given amount. + pub fn with_c_decremented_by(&self, amount: impl Into) -> Self { + let mut oklch = *self; + oklch.decrement_c(amount); + oklch + } + + /// Returns a new color with C increased by the given amount. + pub fn with_c_incremented_by(&self, amount: impl Into) -> Self { + let mut oklch = *self; + oklch.increment_c(amount); + oklch + } + + /// Returns a new color with C scaled by the given factor. + pub fn with_c_scaled_by(&self, factor: impl Into) -> Self { + let mut oklch = *self; + oklch.scale_c(factor); + oklch + } + + /// Alias for [`Self::with_c`]. + pub fn with_chroma(&self, chroma: impl Into) -> Self { + self.with_c(chroma) + } + + /// Alias for [`Self::with_c_decremented_by`]. + pub fn with_chroma_decremented_by(&self, amount: impl Into) -> Self { + self.with_c_decremented_by(amount) + } + + /// Alias for [`Self::with_c_incremented_by`]. + pub fn with_chroma_incremented_by(&self, amount: impl Into) -> Self { + self.with_c_incremented_by(amount) + } + + /// Alias for [`Self::with_c_scaled_by`]. + pub fn with_chroma_scaled_by(&self, factor: impl Into) -> Self { + self.with_c_scaled_by(factor) + } + + /// Returns this color with a different viewing context (without adaptation). + pub fn with_context(&self, context: ColorimetricContext) -> Self { + Self { + context, + ..*self + } + } + + /// Returns a new color with the given normalized hue (0.0-1.0). + pub fn with_h(&self, h: impl Into) -> Self { + Self { + h: h.into(), + ..*self + } + } + + /// Returns a new color with normalized hue decreased by the given amount (wraps around 0.0-1.0). + pub fn with_h_decremented_by(&self, amount: impl Into) -> Self { + let mut oklch = *self; + oklch.decrement_h(amount); + oklch + } + + /// Returns a new color with normalized hue increased by the given amount (wraps around 0.0-1.0). + pub fn with_h_incremented_by(&self, amount: impl Into) -> Self { + let mut oklch = *self; + oklch.increment_h(amount); + oklch + } + + /// Returns a new color with normalized hue scaled by the given factor (wraps around 0.0-1.0). + pub fn with_h_scaled_by(&self, factor: impl Into) -> Self { + let mut oklch = *self; + oklch.scale_h(factor); + oklch + } + + /// Returns a new color with the given hue in degrees (0-360°). + pub fn with_hue(&self, hue: impl Into) -> Self { + Self { + h: Component::new((hue.into().0 / 360.0).rem_euclid(1.0)), + ..*self + } + } + + /// Returns a new color with hue decreased by the given amount in degrees. + pub fn with_hue_decremented_by(&self, amount: impl Into) -> Self { + let mut oklch = *self; + oklch.decrement_hue(amount); + oklch + } + + /// Returns a new color with hue increased by the given amount in degrees. + pub fn with_hue_incremented_by(&self, amount: impl Into) -> Self { + let mut oklch = *self; + oklch.increment_hue(amount); + oklch + } + + /// Returns a new color with hue scaled by the given factor. + pub fn with_hue_scaled_by(&self, factor: impl Into) -> Self { + let mut oklch = *self; + oklch.scale_hue(factor); + oklch + } + + /// Returns a new color with the given L value. + pub fn with_l(&self, l: impl Into) -> Self { + Self { + l: l.into(), + ..*self + } + } + + /// Returns a new color with L decreased by the given amount. + pub fn with_l_decremented_by(&self, amount: impl Into) -> Self { + let mut oklch = *self; + oklch.decrement_l(amount); + oklch + } + + /// Returns a new color with L increased by the given amount. + pub fn with_l_incremented_by(&self, amount: impl Into) -> Self { + let mut oklch = *self; + oklch.increment_l(amount); + oklch + } + + /// Returns a new color with L scaled by the given factor. + pub fn with_l_scaled_by(&self, factor: impl Into) -> Self { + let mut oklch = *self; + oklch.scale_l(factor); + oklch + } +} + +impl Add for Oklch +where + T: Into, +{ + type Output = Self; + + fn add(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() + rhs.into().to_rgb::()) + } +} + +impl ColorSpace<3> for Oklch { + fn alpha(&self) -> f64 { + self.alpha.0 + } + + fn components(&self) -> [f64; 3] { + self.components() + } + + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + + fn set_components(&mut self, components: [impl Into + Clone; 3]) { + self.set_components(components) + } + + fn to_xyz(&self) -> Xyz { + self.to_xyz() + } +} + +impl Display for Oklch { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + let precision = f.precision().unwrap_or(4); + if self.alpha.0 < 1.0 { + write!( + f, + "Oklch({:.precision$}, {:.precision$}, {:.precision$}°, {:.0}%)", + self.l, + self.c, + self.hue(), + self.opacity() + ) + } else { + write!( + f, + "Oklch({:.precision$}, {:.precision$}, {:.precision$}°)", + self.l, + self.c, + self.hue() + ) + } + } +} + +impl Div for Oklch +where + T: Into, +{ + type Output = Self; + + fn div(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() / rhs.into().to_rgb::()) + } +} + +impl From<[T; 3]> for Oklch +where + T: Into, +{ + fn from([l, c, h]: [T; 3]) -> Self { + Self::new(l, c, h) + } +} + +#[cfg(feature = "space-cmy")] +impl From> for Oklch +where + S: RgbSpec, +{ + fn from(cmy: Cmy) -> Self { + cmy.to_oklch() + } +} + +#[cfg(feature = "space-cmyk")] +impl From> for Oklch +where + S: RgbSpec, +{ + fn from(cmyk: Cmyk) -> Self { + cmyk.to_oklch() + } +} + +#[cfg(feature = "space-hsl")] +impl From> for Oklch +where + S: RgbSpec, +{ + fn from(hsl: Hsl) -> Self { + hsl.to_oklch() + } +} + +#[cfg(feature = "space-hsv")] +impl From> for Oklch +where + S: RgbSpec, +{ + fn from(hsv: Hsv) -> Self { + hsv.to_oklch() + } +} + +#[cfg(feature = "space-hwb")] +impl From> for Oklch +where + S: RgbSpec, +{ + fn from(hwb: Hwb) -> Self { + hwb.to_oklch() + } +} + +impl From for Oklch { + fn from(lms: Lms) -> Self { + lms.to_oklch() + } +} + +#[cfg(feature = "space-okhsl")] +impl From for Oklch { + fn from(okhsl: Okhsl) -> Self { + okhsl.to_oklch() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Oklch { + fn from(okhsv: Okhsv) -> Self { + okhsv.to_oklch() + } +} + +impl From for Oklch { + fn from(oklab: Oklab) -> Self { + oklab.to_oklch() + } +} + +impl From> for Oklch +where + S: RgbSpec, +{ + fn from(rgb: Rgb) -> Self { + rgb.to_oklch() + } +} + +impl From for Oklch { + fn from(xyz: Xyz) -> Self { + xyz.to_oklch() + } +} + +impl Mul for Oklch +where + T: Into, +{ + type Output = Self; + + fn mul(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() * rhs.into().to_rgb::()) + } +} + +impl PartialEq for Oklch +where + T: Into + Copy, +{ + fn eq(&self, other: &T) -> bool { + let other = (*other).into(); + self.alpha == other.alpha && self.l == other.l && self.c == other.c && self.h == other.h + } +} + +impl Sub for Oklch +where + T: Into, +{ + type Output = Self; + + fn sub(self, rhs: T) -> Self::Output { + Self::from(self.to_rgb::() - rhs.into().to_rgb::()) + } +} + +impl TryFrom<&str> for Oklch { + type Error = crate::Error; + + fn try_from(value: &str) -> Result { + Ok(Self::from(Rgb::::try_from(value)?.to_xyz())) + } +} + +impl TryFrom for Oklch { + type Error = crate::Error; + + fn try_from(value: String) -> Result { + Ok(Self::from(Rgb::::try_from(value)?.to_xyz())) + } +} + +#[cfg(test)] +mod test { + use super::*; + + mod add { + use super::*; + + #[test] + fn it_adds_two_oklch_colors() { + let a = Oklch::new(0.5, 0.15, 180.0); + let b = Oklch::new(0.3, 0.1, 90.0); + let result = a + b; + + assert!(result.l() > 0.0); + } + } + + mod c { + use super::*; + + #[test] + fn it_returns_c_component() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert!((oklch.c() - 0.15).abs() < 1e-10); + } + } + + mod chroma { + use super::*; + + #[test] + fn it_returns_chroma_as_alias() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert!((oklch.chroma() - 0.15).abs() < 1e-10); + } + } + + mod components { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_components_as_array() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let [l, c, h] = oklch.components(); + + assert_eq!(l, 0.5); + assert_eq!(c, 0.15); + assert_eq!(h, 0.5); + } + } + + mod decrement_c { + use super::*; + + #[test] + fn it_decreases_c_component() { + let mut oklch = Oklch::new(0.5, 0.15, 180.0); + oklch.decrement_c(0.05); + + assert!((oklch.c() - 0.1).abs() < 1e-10); + } + } + + mod decrement_h { + use super::*; + + #[test] + fn it_decreases_h_with_wrapping() { + let mut oklch = Oklch::new(0.5, 0.15, 36.0); + oklch.decrement_h(0.2); + + assert!((oklch.h() - 0.9).abs() < 1e-10); + } + } + + mod decrement_hue { + use super::*; + + #[test] + fn it_decreases_hue_in_degrees() { + let mut oklch = Oklch::new(0.5, 0.15, 180.0); + oklch.decrement_hue(90.0); + + assert!((oklch.hue() - 90.0).abs() < 1e-10); + } + } + + mod decrement_l { + use super::*; + + #[test] + fn it_decreases_l_component() { + let mut oklch = Oklch::new(0.5, 0.15, 180.0); + oklch.decrement_l(0.1); + + assert!((oklch.l() - 0.4).abs() < 1e-10); + } + } + + mod display { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_formats_with_default_precision() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert_eq!(format!("{}", oklch), "Oklch(0.5000, 0.1500, 180.0000°)"); + } + + #[test] + fn it_formats_with_custom_precision() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert_eq!(format!("{:.2}", oklch), "Oklch(0.50, 0.15, 180.00°)"); + } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let oklch = Oklch::new(0.5, 0.15, 180.0).with_alpha(0.5); + + assert_eq!(format!("{}", oklch), "Oklch(0.5000, 0.1500, 180.0000°, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert_eq!(format!("{}", oklch), "Oklch(0.5000, 0.1500, 180.0000°)"); + } + } + + mod from_array { + use super::*; + + #[test] + fn it_creates_from_f64_array() { + let oklch = Oklch::from([0.5, 0.15, 180.0]); + + assert!((oklch.l() - 0.5).abs() < 1e-10); + assert!((oklch.c() - 0.15).abs() < 1e-10); + assert!((oklch.hue() - 180.0).abs() < 1e-10); + } + } + + mod from_oklab { + use super::*; + + #[test] + fn it_converts_from_oklab() { + let oklab = Oklab::new(0.5, 0.0, 0.15); + let oklch = Oklch::from(oklab); + + assert!((oklch.l() - 0.5).abs() < 1e-10); + assert!((oklch.c() - 0.15).abs() < 1e-10); + assert!((oklch.hue() - 90.0).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let oklab = Oklab::new(0.5, 0.1, -0.1).with_alpha(0.5); + let oklch = Oklch::from(oklab); + + assert!((oklch.alpha() - 0.5).abs() < 1e-10); + } + } + + mod from_rgb { + use super::*; + + #[test] + fn it_converts_white_correctly() { + let rgb = Rgb::::new(255, 255, 255); + let oklch = Oklch::from(rgb); + + assert!((oklch.l() - 1.0).abs() < 1e-3); + assert!(oklch.c().abs() < 1e-3); + } + + #[test] + fn it_converts_black_correctly() { + let rgb = Rgb::::new(0, 0, 0); + let oklch = Oklch::from(rgb); + + assert!(oklch.l().abs() < 1e-3); + assert!(oklch.c().abs() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let rgb = Rgb::::new(128, 64, 32).with_alpha(0.5); + let oklch = Oklch::from(rgb); + + assert!((oklch.alpha() - 0.5).abs() < 1e-10); + } + } + + mod from_xyz { + use super::*; + + #[test] + fn it_converts_from_xyz() { + let xyz = Xyz::new(0.5, 0.5, 0.5); + let oklch = Oklch::from(xyz); + + assert!(oklch.l() > 0.0); + } + + #[test] + fn it_preserves_alpha() { + let xyz = Xyz::new(0.5, 0.5, 0.5).with_alpha(0.3); + let oklch = Oklch::from(xyz); + + assert!((oklch.alpha() - 0.3).abs() < 1e-10); + } + } + + mod h { + use super::*; + + #[test] + fn it_returns_normalized_hue() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert!((oklch.h() - 0.5).abs() < 1e-10); + } + } + + mod hue { + use super::*; + + #[test] + fn it_returns_hue_in_degrees() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert!((oklch.hue() - 180.0).abs() < 1e-10); + } + } + + mod increment_c { + use super::*; + + #[test] + fn it_increases_c_component() { + let mut oklch = Oklch::new(0.5, 0.15, 180.0); + oklch.increment_c(0.05); + + assert!((oklch.c() - 0.2).abs() < 1e-10); + } + } + + mod increment_h { + use super::*; + + #[test] + fn it_increases_h_with_wrapping() { + let mut oklch = Oklch::new(0.5, 0.15, 324.0); + oklch.increment_h(0.2); + + assert!((oklch.h() - 0.1).abs() < 1e-10); + } + } + + mod increment_hue { + use super::*; + + #[test] + fn it_increases_hue_in_degrees() { + let mut oklch = Oklch::new(0.5, 0.15, 180.0); + oklch.increment_hue(90.0); + + assert!((oklch.hue() - 270.0).abs() < 1e-10); + } + } + + mod increment_l { + use super::*; + + #[test] + fn it_increases_l_component() { + let mut oklch = Oklch::new(0.5, 0.15, 180.0); + oklch.increment_l(0.1); + + assert!((oklch.l() - 0.6).abs() < 1e-10); + } + } + + mod l { + use super::*; + + #[test] + fn it_returns_l_component() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert!((oklch.l() - 0.5).abs() < 1e-10); + } + } + + mod new { + use super::*; + + #[test] + fn it_creates_with_default_alpha() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert!((oklch.alpha() - 1.0).abs() < 1e-10); + } + + #[test] + fn it_creates_with_default_context() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + + assert_eq!(oklch.context().illuminant().name(), "D65"); + } + + #[test] + fn it_normalizes_hue_to_zero_one() { + let oklch = Oklch::new(0.5, 0.15, 450.0); + + assert!((oklch.h() - 0.25).abs() < 1e-10); + } + + #[test] + fn it_normalizes_negative_hue() { + let oklch = Oklch::new(0.5, 0.15, -90.0); + + assert!((oklch.h() - 0.75).abs() < 1e-10); + } + } + + mod partial_eq { + use super::*; + + #[test] + fn it_compares_equal_colors() { + let a = Oklch::new(0.5, 0.15, 180.0); + let b = Oklch::new(0.5, 0.15, 180.0); + + assert!(a == b); + } + + #[test] + fn it_compares_unequal_colors() { + let a = Oklch::new(0.5, 0.15, 180.0); + let b = Oklch::new(0.6, 0.15, 180.0); + + assert!(a != b); + } + } + + mod scale_c { + use super::*; + + #[test] + fn it_scales_c_component() { + let mut oklch = Oklch::new(0.5, 0.15, 180.0); + oklch.scale_c(2.0); + + assert!((oklch.c() - 0.3).abs() < 1e-10); + } + } + + mod scale_h { + use super::*; + + #[test] + fn it_scales_h_with_wrapping() { + let mut oklch = Oklch::new(0.5, 0.15, 270.0); + oklch.scale_h(2.0); + + assert!((oklch.h() - 0.5).abs() < 1e-10); + } + } + + mod scale_l { + use super::*; + + #[test] + fn it_scales_l_component() { + let mut oklch = Oklch::new(0.5, 0.15, 180.0); + oklch.scale_l(2.0); + + assert!((oklch.l() - 1.0).abs() < 1e-10); + } + } + + mod to_oklab { + use super::*; + + #[test] + fn it_converts_to_oklab() { + let oklch = Oklch::new(0.5, 0.15, 90.0); + let oklab = oklch.to_oklab(); + + assert!((oklab.l() - 0.5).abs() < 1e-10); + assert!(oklab.a().abs() < 1e-10); + assert!((oklab.b() - 0.15).abs() < 1e-10); + } + + #[test] + fn it_roundtrips_through_oklab() { + let original = Oklch::new(0.5, 0.15, 180.0); + let roundtrip = Oklch::from(original.to_oklab()); + + assert!((original.l() - roundtrip.l()).abs() < 1e-10); + assert!((original.c() - roundtrip.c()).abs() < 1e-10); + assert!((original.h() - roundtrip.h()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let oklch = Oklch::new(0.5, 0.15, 180.0).with_alpha(0.7); + let oklab = oklch.to_oklab(); + + assert!((oklab.alpha() - 0.7).abs() < 1e-10); + } + } + + #[cfg(feature = "space-okhsl")] + mod to_okhsl { + use super::*; + + #[test] + fn it_converts_to_okhsl() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let okhsl = oklch.to_okhsl(); + + assert!(okhsl.l() > 0.0); + assert!(okhsl.h().is_finite()); + } + + #[test] + fn it_converts_black() { + let oklch = Oklch::new(0.0, 0.0, 0.0); + let okhsl = oklch.to_okhsl(); + + assert!(okhsl.l().abs() < 1e-10); + } + + #[test] + fn it_converts_white() { + let oklch = Oklch::new(1.0, 0.0, 0.0); + let okhsl = oklch.to_okhsl(); + + assert!((okhsl.l() - 1.0).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let oklch = Oklch::new(0.5, 0.15, 180.0).with_alpha(0.3); + let okhsl = oklch.to_okhsl(); + + assert!((okhsl.alpha() - 0.3).abs() < 1e-10); + } + } + + #[cfg(feature = "space-okhsv")] + mod to_okhsv { + use super::*; + + #[test] + fn it_converts_to_okhsv() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let okhsv = oklch.to_okhsv(); + + assert!(okhsv.v() > 0.0); + assert!(okhsv.h().is_finite()); + } + + #[test] + fn it_converts_black() { + let oklch = Oklch::new(0.0, 0.0, 0.0); + let okhsv = oklch.to_okhsv(); + + assert!(okhsv.v().abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let oklch = Oklch::new(0.5, 0.15, 180.0).with_alpha(0.3); + let okhsv = oklch.to_okhsv(); + + assert!((okhsv.alpha() - 0.3).abs() < 1e-10); + } + } + + mod to_rgb { + use super::*; + + #[test] + fn it_converts_to_srgb() { + let oklch = Oklch::new(0.5, 0.0, 0.0); + let rgb = oklch.to_rgb::(); + + assert!(rgb.red() > 0); + } + + #[test] + fn it_roundtrips_through_rgb() { + let original = Oklch::from(Rgb::::new(128, 64, 200)); + let roundtrip = Oklch::from(original.to_rgb::()); + + assert!((original.l() - roundtrip.l()).abs() < 1e-3); + assert!((original.c() - roundtrip.c()).abs() < 1e-3); + assert!((original.h() - roundtrip.h()).abs() < 1e-3); + } + + #[test] + fn it_preserves_alpha() { + let oklch = Oklch::new(0.5, 0.15, 180.0).with_alpha(0.7); + let rgb = oklch.to_rgb::(); + + assert!((rgb.alpha() - 0.7).abs() < 1e-10); + } + } + + mod to_xyz { + use super::*; + + #[test] + fn it_converts_to_xyz() { + let oklch = Oklch::new(0.5, 0.0, 0.0); + let xyz = oklch.to_xyz(); + + assert!(xyz.y() > 0.0); + } + + #[test] + fn it_roundtrips_through_xyz() { + let original = Oklch::new(0.5, 0.15, 180.0); + let roundtrip = Oklch::from(original.to_xyz()); + + assert!((original.l() - roundtrip.l()).abs() < 1e-10); + assert!((original.c() - roundtrip.c()).abs() < 1e-10); + assert!((original.h() - roundtrip.h()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let oklch = Oklch::new(0.5, 0.15, 180.0).with_alpha(0.3); + let xyz = oklch.to_xyz(); + + assert!((xyz.alpha() - 0.3).abs() < 1e-10); + } + } + + mod try_from_str { + use super::*; + + #[test] + fn it_parses_hex_string() { + let oklch = Oklch::try_from("#FF5733").unwrap(); + + assert!(oklch.l() > 0.0); + } + + #[test] + fn it_returns_error_for_invalid_hex() { + let result = Oklch::try_from("not_a_color"); + + assert!(result.is_err()); + } + } + + mod with_alpha { + use super::*; + + #[test] + fn it_returns_new_color_with_alpha() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_alpha(0.5); + + assert!((result.alpha() - 0.5).abs() < 1e-10); + assert!((result.l() - 0.5).abs() < 1e-10); + } + } + + mod with_c { + use super::*; + + #[test] + fn it_returns_new_color_with_c() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_c(0.2); + + assert!((result.c() - 0.2).abs() < 1e-10); + assert!((result.l() - 0.5).abs() < 1e-10); + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_c_decremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_c_decremented() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_c_decremented_by(0.05); + + assert!((result.c() - 0.1).abs() < 1e-10); + } + } + + mod with_c_incremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_c_incremented() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_c_incremented_by(0.05); + + assert!((result.c() - 0.2).abs() < 1e-10); + } + } + + mod with_c_scaled_by { + use super::*; + + #[test] + fn it_returns_new_color_with_c_scaled() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_c_scaled_by(2.0); + + assert!((result.c() - 0.3).abs() < 1e-10); + } + } + + mod with_context { + use super::*; + + #[test] + fn it_returns_new_color_with_context() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let context = ColorimetricContext::default(); + let result = oklch.with_context(context); + + assert!((result.l() - 0.5).abs() < 1e-10); + } + } + + mod with_h { + use super::*; + + #[test] + fn it_returns_new_color_with_h() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_h(0.75); + + assert!((result.h() - 0.75).abs() < 1e-10); + assert!((result.l() - 0.5).abs() < 1e-10); + assert!((result.c() - 0.15).abs() < 1e-10); + } + } + + mod with_h_decremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_h_decremented() { + let oklch = Oklch::new(0.5, 0.15, 36.0); + let result = oklch.with_h_decremented_by(0.2); + + assert!((result.h() - 0.9).abs() < 1e-10); + } + } + + mod with_h_incremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_h_incremented() { + let oklch = Oklch::new(0.5, 0.15, 324.0); + let result = oklch.with_h_incremented_by(0.2); + + assert!((result.h() - 0.1).abs() < 1e-10); + } + } + + mod with_h_scaled_by { + use super::*; + + #[test] + fn it_returns_new_color_with_h_scaled() { + let oklch = Oklch::new(0.5, 0.15, 270.0); + let result = oklch.with_h_scaled_by(2.0); + + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_hue { + use super::*; + + #[test] + fn it_returns_new_color_with_hue_in_degrees() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_hue(270.0); + + assert!((result.hue() - 270.0).abs() < 1e-10); + } + } + + mod with_hue_decremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_hue_decremented() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_hue_decremented_by(90.0); + + assert!((result.hue() - 90.0).abs() < 1e-10); + } + } + + mod with_hue_incremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_hue_incremented() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_hue_incremented_by(90.0); + + assert!((result.hue() - 270.0).abs() < 1e-10); + } + } + + mod with_l { + use super::*; + + #[test] + fn it_returns_new_color_with_l() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_l(0.8); + + assert!((result.l() - 0.8).abs() < 1e-10); + assert!((result.c() - 0.15).abs() < 1e-10); + assert!((result.h() - 0.5).abs() < 1e-10); + } + } + + mod with_l_decremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_l_decremented() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_l_decremented_by(0.1); + + assert!((result.l() - 0.4).abs() < 1e-10); + } + } + + mod with_l_incremented_by { + use super::*; + + #[test] + fn it_returns_new_color_with_l_incremented() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_l_incremented_by(0.1); + + assert!((result.l() - 0.6).abs() < 1e-10); + } + } + + mod with_l_scaled_by { + use super::*; + + #[test] + fn it_returns_new_color_with_l_scaled() { + let oklch = Oklch::new(0.5, 0.15, 180.0); + let result = oklch.with_l_scaled_by(2.0); + + assert!((result.l() - 1.0).abs() < 1e-10); + } + } +} diff --git a/src/space/physiological/lms.rs b/src/space/physiological/lms.rs index bc538df..0c6a918 100644 --- a/src/space/physiological/lms.rs +++ b/src/space/physiological/lms.rs @@ -13,6 +13,14 @@ use crate::space::Hsl; use crate::space::Hsv; #[cfg(feature = "space-hwb")] use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklab")] +use crate::space::Oklab; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; use crate::{ ColorimetricContext, component::Component, @@ -525,6 +533,34 @@ where } } +#[cfg(feature = "space-okhsl")] +impl From for Lms { + fn from(okhsl: Okhsl) -> Self { + okhsl.to_lms() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Lms { + fn from(okhsv: Okhsv) -> Self { + okhsv.to_lms() + } +} + +#[cfg(feature = "space-oklab")] +impl From for Lms { + fn from(oklab: Oklab) -> Self { + oklab.to_lms() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Lms { + fn from(oklch: Oklch) -> Self { + oklch.to_lms() + } +} + impl From> for Lms where S: RgbSpec, diff --git a/src/space/rgb/space.rs b/src/space/rgb/space.rs index 4954410..3edbab2 100644 --- a/src/space/rgb/space.rs +++ b/src/space/rgb/space.rs @@ -169,6 +169,14 @@ use crate::space::Cmyk; use crate::space::Hsl; #[cfg(feature = "space-hwb")] use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklab")] +use crate::space::Oklab; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; #[cfg(feature = "space-hsv")] use crate::space::{Hsb, Hsv}; use crate::{ @@ -624,6 +632,18 @@ where } } + #[cfg(feature = "space-oklab")] + /// Converts to the Oklab perceptual color space via linear sRGB. + pub fn to_oklab(&self) -> Oklab { + let linear_srgb = self.to_rgb::().to_linear().components(); + + let lms_linear = Oklab::LINEAR_RGB_MATRIX * linear_srgb; + let lms_nonlinear = [lms_linear[0].cbrt(), lms_linear[1].cbrt(), lms_linear[2].cbrt()]; + let [l, a, b] = Oklab::LINEAR_LMS_MATRIX * lms_nonlinear; + + Oklab::new(l, a, b).with_alpha(self.alpha) + } + /// Converts to CIE XYZ via linear RGB and the space's RGB-to-XYZ matrix. pub fn to_xyz(&self) -> Xyz { let linear = self.to_linear(); @@ -991,6 +1011,46 @@ where } } +#[cfg(feature = "space-okhsl")] +impl From for Rgb +where + S: RgbSpec, +{ + fn from(okhsl: Okhsl) -> Self { + okhsl.to_rgb::() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Rgb +where + S: RgbSpec, +{ + fn from(okhsv: Okhsv) -> Self { + okhsv.to_rgb::() + } +} + +#[cfg(feature = "space-oklab")] +impl From for Rgb +where + S: RgbSpec, +{ + fn from(oklab: Oklab) -> Self { + oklab.to_rgb::() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Rgb +where + S: RgbSpec, +{ + fn from(oklch: Oklch) -> Self { + oklch.to_rgb::() + } +} + impl From for Rgb where S: RgbSpec, @@ -2038,6 +2098,59 @@ mod test { } } + #[cfg(feature = "space-oklab")] + mod to_oklab { + use super::*; + + #[test] + fn it_converts_to_oklab() { + let rgb = Rgb::::new(128, 64, 200); + let oklab = rgb.to_oklab(); + + assert!(oklab.l() > 0.0); + assert!(oklab.a().is_finite()); + assert!(oklab.b().is_finite()); + } + + #[test] + fn it_converts_white() { + let rgb = Rgb::::new(255, 255, 255); + let oklab = rgb.to_oklab(); + + assert!((oklab.l() - 1.0).abs() < 1e-3); + assert!(oklab.a().abs() < 1e-3); + assert!(oklab.b().abs() < 1e-3); + } + + #[test] + fn it_converts_black() { + let rgb = Rgb::::new(0, 0, 0); + let oklab = rgb.to_oklab(); + + assert!(oklab.l().abs() < 1e-3); + assert!(oklab.a().abs() < 1e-3); + assert!(oklab.b().abs() < 1e-3); + } + + #[test] + fn it_roundtrips_through_oklab() { + let original = Rgb::::new(128, 64, 200); + let roundtrip = original.to_oklab().to_rgb::(); + + assert_eq!(roundtrip.red(), original.red()); + assert_eq!(roundtrip.green(), original.green()); + assert_eq!(roundtrip.blue(), original.blue()); + } + + #[test] + fn it_preserves_alpha() { + let rgb = Rgb::::new(128, 64, 200).with_alpha(0.5); + let oklab = rgb.to_oklab(); + + assert!((oklab.alpha() - 0.5).abs() < 1e-10); + } + } + mod to_rgb { use pretty_assertions::assert_eq; diff --git a/src/space/subtractive/cmy.rs b/src/space/subtractive/cmy.rs index 613afd2..5e6ac04 100644 --- a/src/space/subtractive/cmy.rs +++ b/src/space/subtractive/cmy.rs @@ -12,6 +12,14 @@ use crate::space::Hsl; use crate::space::Hsv; #[cfg(feature = "space-hwb")] use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklab")] +use crate::space::Oklab; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; use crate::{ ColorimetricContext, component::Component, @@ -585,6 +593,46 @@ where } } +#[cfg(feature = "space-okhsl")] +impl From for Cmy +where + S: RgbSpec, +{ + fn from(okhsl: Okhsl) -> Self { + okhsl.to_rgb::().to_cmy() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Cmy +where + S: RgbSpec, +{ + fn from(okhsv: Okhsv) -> Self { + okhsv.to_rgb::().to_cmy() + } +} + +#[cfg(feature = "space-oklab")] +impl From for Cmy +where + S: RgbSpec, +{ + fn from(oklab: Oklab) -> Self { + oklab.to_rgb::().to_cmy() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Cmy +where + S: RgbSpec, +{ + fn from(oklch: Oklch) -> Self { + oklch.to_rgb::().to_cmy() + } +} + impl From> for Cmy where OS: RgbSpec, diff --git a/src/space/subtractive/cmyk.rs b/src/space/subtractive/cmyk.rs index 7822a52..6cefacc 100644 --- a/src/space/subtractive/cmyk.rs +++ b/src/space/subtractive/cmyk.rs @@ -12,6 +12,14 @@ use crate::space::Hsl; use crate::space::Hsv; #[cfg(feature = "space-hwb")] use crate::space::Hwb; +#[cfg(feature = "space-okhsl")] +use crate::space::Okhsl; +#[cfg(feature = "space-okhsv")] +use crate::space::Okhsv; +#[cfg(feature = "space-oklab")] +use crate::space::Oklab; +#[cfg(feature = "space-oklch")] +use crate::space::Oklch; use crate::{ ColorimetricContext, component::Component, @@ -711,6 +719,46 @@ where } } +#[cfg(feature = "space-okhsl")] +impl From for Cmyk +where + S: RgbSpec, +{ + fn from(okhsl: Okhsl) -> Self { + okhsl.to_rgb::().to_cmyk() + } +} + +#[cfg(feature = "space-okhsv")] +impl From for Cmyk +where + S: RgbSpec, +{ + fn from(okhsv: Okhsv) -> Self { + okhsv.to_rgb::().to_cmyk() + } +} + +#[cfg(feature = "space-oklab")] +impl From for Cmyk +where + S: RgbSpec, +{ + fn from(oklab: Oklab) -> Self { + oklab.to_rgb::().to_cmyk() + } +} + +#[cfg(feature = "space-oklch")] +impl From for Cmyk +where + S: RgbSpec, +{ + fn from(oklch: Oklch) -> Self { + oklch.to_rgb::().to_cmyk() + } +} + impl From> for Cmyk where OS: RgbSpec, -- knotmirror 0.0.1