From e2ada424e3453b16100ac5ab4041adf4eac332dc Mon Sep 17 00:00:00 2001 From: Aaron Allen Date: Thu, 12 Feb 2026 07:40:45 -0600 Subject: [PATCH] Add gamut mapping and realizability checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces four gamut mapping strategies on `Rgb` and the `ColorSpace` trait: `clip_to_gamut()` (clamp), `scale_to_gamut()` (linear RGB scaling), `compress_to_gamut()` (CIELAB chroma reduction, feature-gated behind `space-lab`), and `perceptually_map_to_gamut()` (LMS scaling relative to reference white) — each with `with_gamut_*` builder variants. Adds `is_in_gamut()` on `Rgb` and the `ColorSpace` trait for checking whether a color falls within an RGB gamut, and `is_realizable()` on `Xyz` and the `ColorSpace` trait for checking physical realizability against the observer's spectral locus. `Rgb::from_normalized` and `LinearRgb::from_normalized` no longer clamp values to 0.0-1.0, preserving out-of-gamut information for gamut mapping workflows. fixes #2 --- CHANGELOG.md | 9 + src/space.rs | 95 ++++++++++ src/space/cie/lab.rs | 202 +++++++++++++++++++++ src/space/cie/xyz.rs | 74 ++++++++ src/space/rgb/linear.rs | 23 ++- src/space/rgb/space.rs | 392 +++++++++++++++++++++++++++++++++++++++- 6 files changed, 784 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f926fba..9a20ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve ### Added +- Add gamut mapping with four strategies on `Rgb` and the `ColorSpace` trait: `clip_to_gamut()` (clamp), + `scale_to_gamut()` (linear RGB scaling), `compress_to_gamut()` (CIELAB chroma reduction, feature-gated behind + `space-lab`), and `perceptually_map_to_gamut()` (LMS scaling relative to reference white) — each with + `with_gamut_*` builder variants +- Add `is_in_gamut()` on `Rgb` and the `ColorSpace` trait for checking whether a color falls within an RGB gamut +- Add `is_realizable()` on `Xyz` and the `ColorSpace` trait for checking physical realizability against the + observer's spectral locus - Add `contrast` module with six perceptual contrast algorithms: AERT brightness difference, APCA lightness contrast, Michelson, RMS, WCAG 2.x contrast ratio, and Weber contrast — each feature-gated behind `contrast-*` flags and bundled under `all-contrast` @@ -27,6 +34,8 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve ### Changed +- **BREAKING:** `Rgb::from_normalized` and `LinearRgb::from_normalized` no longer clamp values to 0.0-1.0, + preserving out-of-gamut information for gamut mapping workflows - **BREAKING:** `IlluminantBuilder::new` now requires `name` and `IlluminantType` at construction time; - `IlluminantBuilder` now accepts non-`'static` references, allowing construction from dynamically generated data - `ObserverBuilder` now accepts non-`'static` references, allowing construction from dynamically generated data diff --git a/src/space.rs b/src/space.rs index 4a31eb6..a31cdc4 100644 --- a/src/space.rs +++ b/src/space.rs @@ -91,9 +91,30 @@ pub trait ColorSpace: Copy + Clone + From { self.chromaticity().to_uv() } + /// Clamps all components into the gamut of the specified RGB space. + fn clip_to_gamut(&mut self) + where + S: RgbSpec, + { + let mut rgb = self.to_rgb::(); + rgb.clip_to_gamut(); + self.set_components(Self::from(rgb.to_xyz()).components()) + } + /// Returns the color's components as an array. fn components(&self) -> [f64; N]; + /// Reduces chroma in CIELAB space until the color fits the specified RGB gamut. + #[cfg(feature = "space-lab")] + fn compress_to_gamut(&mut self) + where + S: RgbSpec, + { + let mut rgb = self.to_rgb::(); + rgb.compress_to_gamut(); + self.set_components(Self::from(rgb.to_xyz()).components()) + } + /// Returns the WCAG 2.x contrast ratio between this color and another. /// /// The result is always >= 1.0 and is order-independent. Use @@ -306,6 +327,19 @@ pub trait ColorSpace: Copy + Clone + From { self.set_alpha(self.with_opacity_incremented_by(amount).alpha()) } + /// Returns `true` if this color is within the gamut of the specified RGB space. + fn is_in_gamut(&self) -> bool + where + S: RgbSpec, + { + self.to_rgb::().is_in_gamut() + } + + /// Returns `true` if this color is physically realizable under the default observer. + fn is_realizable(&self) -> bool { + self.to_xyz().is_realizable() + } + /// Returns the APCA lightness contrast (Lc) between this color and the given background. /// /// Positive values indicate dark-on-light (normal polarity), negative values indicate @@ -338,6 +372,16 @@ pub trait ColorSpace: Copy + Clone + From { self.alpha() * 100.0 } + /// Maps to gamut by scaling LMS components relative to the reference white. + fn perceptually_map_to_gamut(&mut self) + where + S: RgbSpec, + { + let mut rgb = self.to_rgb::(); + rgb.perceptually_map_to_gamut(); + self.set_components(Self::from(rgb.to_xyz()).components()) + } + /// Returns the sRGB red channel as a u8 (0-255). fn red(&self) -> u8 { self.to_rgb::().red() @@ -379,6 +423,16 @@ pub trait ColorSpace: Copy + Clone + From { self.set_alpha(self.with_opacity_scaled_by(factor).alpha()) } + /// Scales linear RGB components to fit within the specified RGB gamut. + fn scale_to_gamut(&mut self) + where + S: RgbSpec, + { + let mut rgb = self.to_rgb::(); + rgb.scale_to_gamut(); + self.set_components(Self::from(rgb.to_xyz()).components()) + } + /// Sets the alpha value in place on a 0.0 to 1.0 scale. fn set_alpha(&mut self, alpha: impl Into); @@ -582,6 +636,47 @@ pub trait ColorSpace: Copy + Clone + From { self.with_chroma(self.chroma() * factor.into().0) } + /// Returns a new color with all components clamped into the specified RGB gamut. + fn with_gamut_clipped(&self) -> Self + where + S: RgbSpec, + { + let mut color = *self; + color.clip_to_gamut::(); + color + } + + /// Returns a new color with chroma reduced in CIELAB space until the color fits the specified RGB gamut. + #[cfg(feature = "space-lab")] + fn with_gamut_compressed(&self) -> Self + where + S: RgbSpec, + { + let mut color = *self; + color.compress_to_gamut::(); + color + } + + /// Returns a new color mapped to the specified RGB gamut by scaling LMS components relative to the reference white. + fn with_gamut_perceptually_mapped(&self) -> Self + where + S: RgbSpec, + { + let mut color = *self; + color.perceptually_map_to_gamut::(); + color + } + + /// Returns a new color with linear RGB components scaled to fit within the specified RGB gamut. + fn with_gamut_scaled(&self) -> Self + where + S: RgbSpec, + { + let mut color = *self; + color.scale_to_gamut::(); + color + } + /// Returns a new color with the given Oklch hue in degrees. #[cfg(feature = "space-oklch")] fn with_hue(&self, hue: impl Into) -> Self { diff --git a/src/space/cie/lab.rs b/src/space/cie/lab.rs index 0d6f4c9..f2c6a00 100644 --- a/src/space/cie/lab.rs +++ b/src/space/cie/lab.rs @@ -725,6 +725,54 @@ mod test { } } + mod clip_to_gamut { + use super::*; + + #[test] + fn it_moves_out_of_gamut_lab_closer_to_srgb_gamut() { + let mut lab = Lab::new(50.0, 100.0, 100.0); + lab.clip_to_gamut::(); + let rgb = lab.to_rgb::(); + + assert!(rgb.r() >= -1e-6 && rgb.r() <= 1.0 + 1e-6); + assert!(rgb.g() >= -1e-6 && rgb.g() <= 1.0 + 1e-6); + assert!(rgb.b() >= -1e-6 && rgb.b() <= 1.0 + 1e-6); + } + + #[test] + fn it_leaves_in_gamut_color_approximately_unchanged() { + let mut lab = Lab::new(50.0, 0.0, 0.0); + let orig = lab; + lab.clip_to_gamut::(); + + assert!((lab.l() - orig.l()).abs() < 0.5); + } + } + + mod compress_to_gamut { + use super::*; + + #[test] + fn it_moves_out_of_gamut_lab_closer_to_srgb_gamut() { + let mut lab = Lab::new(50.0, 100.0, 100.0); + lab.compress_to_gamut::(); + let rgb = lab.to_rgb::(); + + assert!(rgb.r() >= -1e-6 && rgb.r() <= 1.0 + 1e-6); + assert!(rgb.g() >= -1e-6 && rgb.g() <= 1.0 + 1e-6); + assert!(rgb.b() >= -1e-6 && rgb.b() <= 1.0 + 1e-6); + } + + #[test] + fn it_leaves_in_gamut_color_approximately_unchanged() { + let mut lab = Lab::new(50.0, 0.0, 0.0); + let orig = lab; + lab.compress_to_gamut::(); + + assert!((lab.l() - orig.l()).abs() < 0.5); + } + } + mod decrement_a { use super::*; @@ -916,6 +964,42 @@ mod test { } } + mod is_in_gamut { + use super::*; + + #[test] + fn it_returns_true_for_neutral_gray() { + let lab = Lab::new(50.0, 0.0, 0.0); + + assert!(lab.is_in_gamut::()); + } + + #[test] + fn it_returns_false_for_highly_saturated_color() { + let lab = Lab::new(50.0, 100.0, 100.0); + + assert!(!lab.is_in_gamut::()); + } + } + + mod is_realizable { + use super::*; + + #[test] + fn it_returns_true_for_neutral_gray() { + let lab = Lab::new(50.0, 0.0, 0.0); + + assert!(lab.is_realizable()); + } + + #[test] + fn it_returns_true_for_black() { + let lab = Lab::new(0.0, 0.0, 0.0); + + assert!(lab.is_realizable()); + } + } + mod l { use super::*; @@ -965,6 +1049,19 @@ mod test { } } + mod perceptually_map_to_gamut { + use super::*; + + #[test] + fn it_leaves_in_gamut_color_approximately_unchanged() { + let mut lab = Lab::new(50.0, 0.0, 0.0); + let orig = lab; + lab.perceptually_map_to_gamut::(); + + assert!((lab.l() - orig.l()).abs() < 0.5); + } + } + mod roundtrip { use super::*; @@ -1035,6 +1132,30 @@ mod test { } } + mod scale_to_gamut { + use super::*; + + #[test] + fn it_moves_out_of_gamut_lab_closer_to_srgb_gamut() { + let mut lab = Lab::new(50.0, 100.0, 100.0); + lab.scale_to_gamut::(); + let rgb = lab.to_rgb::(); + + assert!(rgb.r() >= -1e-6 && rgb.r() <= 1.0 + 1e-6); + assert!(rgb.g() >= -1e-6 && rgb.g() <= 1.0 + 1e-6); + assert!(rgb.b() >= -1e-6 && rgb.b() <= 1.0 + 1e-6); + } + + #[test] + fn it_leaves_in_gamut_color_approximately_unchanged() { + let mut lab = Lab::new(50.0, 0.0, 0.0); + let orig = lab; + lab.scale_to_gamut::(); + + assert!((lab.l() - orig.l()).abs() < 0.5); + } + } + mod to_rgb { use super::*; @@ -1229,6 +1350,87 @@ mod test { } } + mod with_gamut_clipped { + use super::*; + + #[test] + fn it_returns_approximately_in_gamut_color() { + let lab = Lab::new(50.0, 100.0, 100.0); + let result = lab.with_gamut_clipped::(); + let rgb = result.to_rgb::(); + + assert!(rgb.r() >= -1e-6 && rgb.r() <= 1.0 + 1e-6); + assert!(rgb.g() >= -1e-6 && rgb.g() <= 1.0 + 1e-6); + assert!(rgb.b() >= -1e-6 && rgb.b() <= 1.0 + 1e-6); + } + + #[test] + fn it_does_not_mutate_original() { + let lab = Lab::new(50.0, 100.0, 100.0); + let _ = lab.with_gamut_clipped::(); + + assert!((lab.a() - 100.0).abs() < 1e-10); + } + } + + mod with_gamut_compressed { + use super::*; + + #[test] + fn it_returns_approximately_in_gamut_color() { + let lab = Lab::new(50.0, 100.0, 100.0); + let result = lab.with_gamut_compressed::(); + let rgb = result.to_rgb::(); + + assert!(rgb.r() >= -1e-6 && rgb.r() <= 1.0 + 1e-6); + assert!(rgb.g() >= -1e-6 && rgb.g() <= 1.0 + 1e-6); + assert!(rgb.b() >= -1e-6 && rgb.b() <= 1.0 + 1e-6); + } + + #[test] + fn it_does_not_mutate_original() { + let lab = Lab::new(50.0, 100.0, 100.0); + let _ = lab.with_gamut_compressed::(); + + assert!((lab.a() - 100.0).abs() < 1e-10); + } + } + + mod with_gamut_perceptually_mapped { + use super::*; + + #[test] + fn it_does_not_mutate_original() { + let lab = Lab::new(50.0, 100.0, 100.0); + let _ = lab.with_gamut_perceptually_mapped::(); + + assert!((lab.a() - 100.0).abs() < 1e-10); + } + } + + mod with_gamut_scaled { + use super::*; + + #[test] + fn it_returns_approximately_in_gamut_color() { + let lab = Lab::new(50.0, 100.0, 100.0); + let result = lab.with_gamut_scaled::(); + let rgb = result.to_rgb::(); + + assert!(rgb.r() >= -1e-6 && rgb.r() <= 1.0 + 1e-6); + assert!(rgb.g() >= -1e-6 && rgb.g() <= 1.0 + 1e-6); + assert!(rgb.b() >= -1e-6 && rgb.b() <= 1.0 + 1e-6); + } + + #[test] + fn it_does_not_mutate_original() { + let lab = Lab::new(50.0, 100.0, 100.0); + let _ = lab.with_gamut_scaled::(); + + assert!((lab.a() - 100.0).abs() < 1e-10); + } + } + mod with_l { use super::*; diff --git a/src/space/cie/xyz.rs b/src/space/cie/xyz.rs index c35a1dc..6c1c75d 100644 --- a/src/space/cie/xyz.rs +++ b/src/space/cie/xyz.rs @@ -195,6 +195,27 @@ impl Xyz { self.z += amount.into(); } + /// Returns `true` if this color is physically realizable under the current observer. + /// + /// A color is realizable when all components are non-negative and its chromaticity + /// falls within the spectral locus of the context's observer. The origin (0, 0, 0) + /// is considered realizable as the absence of light. + pub fn is_realizable(&self) -> bool { + let [x, y, z] = self.components(); + + if x < 0.0 || y < 0.0 || z < 0.0 { + false + } else if y == 0.0 { + x == 0.0 && z == 0.0 + } else { + self + .context() + .observer() + .chromaticity_coordinates() + .contains_chromaticity(self.chromaticity()) + } + } + /// Returns the relative luminance (Y component). pub fn luminance(&self) -> f64 { self.y() @@ -1092,6 +1113,59 @@ mod test { } } + mod is_realizable { + use super::*; + + #[test] + fn it_returns_true_for_origin() { + let xyz = Xyz::new(0.0, 0.0, 0.0); + + assert!(xyz.is_realizable()); + } + + #[test] + fn it_returns_true_for_d65_white_point() { + let xyz = Xyz::new(0.95047, 1.0, 1.08883); + + assert!(xyz.is_realizable()); + } + + #[test] + fn it_returns_false_when_x_is_negative() { + let xyz = Xyz::new(-0.1, 0.5, 0.5); + + assert!(!xyz.is_realizable()); + } + + #[test] + fn it_returns_false_when_y_is_negative() { + let xyz = Xyz::new(0.5, -0.1, 0.5); + + assert!(!xyz.is_realizable()); + } + + #[test] + fn it_returns_false_when_z_is_negative() { + let xyz = Xyz::new(0.5, 0.5, -0.1); + + assert!(!xyz.is_realizable()); + } + + #[test] + fn it_returns_false_when_y_is_zero_but_x_is_nonzero() { + let xyz = Xyz::new(0.1, 0.0, 0.0); + + assert!(!xyz.is_realizable()); + } + + #[test] + fn it_returns_false_when_y_is_zero_but_z_is_nonzero() { + let xyz = Xyz::new(0.0, 0.0, 0.1); + + assert!(!xyz.is_realizable()); + } + } + mod partial_eq { use pretty_assertions::{assert_eq, assert_ne}; diff --git a/src/space/rgb/linear.rs b/src/space/rgb/linear.rs index 9eb8a36..dbb1d0c 100644 --- a/src/space/rgb/linear.rs +++ b/src/space/rgb/linear.rs @@ -25,13 +25,15 @@ impl LinearRgb where S: RgbSpec, { - /// Creates linear RGB from normalized (0.0-1.0) component values. + /// Creates linear RGB from normalized component values. + /// + /// Values outside 0.0-1.0 are preserved to retain out-of-gamut information. pub fn from_normalized(r: impl Into, g: impl Into, b: impl Into) -> Self { Self { alpha: Component::new(1.0), - b: b.into().clamp(0.0, 1.0), - g: g.into().clamp(0.0, 1.0), - r: r.into().clamp(0.0, 1.0), + b: b.into(), + g: g.into(), + r: r.into(), _spec: PhantomData, } } @@ -153,6 +155,19 @@ mod test { use super::*; use crate::space::{ColorSpace, Srgb}; + mod from_normalized { + use super::*; + + #[test] + fn it_preserves_out_of_range_values() { + let linear = LinearRgb::::from_normalized(1.5, -0.5, 0.5); + + assert!((linear.r() - 1.5).abs() < 1e-10); + assert!((linear.g() - -0.5).abs() < 1e-10); + assert!((linear.b() - 0.5).abs() < 1e-10); + } + } + mod display { use pretty_assertions::assert_eq; diff --git a/src/space/rgb/space.rs b/src/space/rgb/space.rs index 7e58a5e..7e51093 100644 --- a/src/space/rgb/space.rs +++ b/src/space/rgb/space.rs @@ -261,14 +261,16 @@ where Ok(Self::new(r, g, b)) } - /// Creates an RGB color from normalized (0.0-1.0) component values. + /// Creates an RGB color from normalized component values. + /// + /// Values outside 0.0-1.0 are preserved to retain out-of-gamut information. pub fn from_normalized(r: impl Into, g: impl Into, b: impl Into) -> Self { Self { alpha: Component::new(1.0), - b: b.into().clamp(0.0, 1.0), + b: b.into(), context: S::CONTEXT, - g: g.into().clamp(0.0, 1.0), - r: r.into().clamp(0.0, 1.0), + g: g.into(), + r: r.into(), _spec: PhantomData, } } @@ -311,11 +313,55 @@ where (self.b.0 * 255.0).round() as u8 } + /// Clamps all components to the 0.0-1.0 range. + pub fn clip_to_gamut(&mut self) { + if self.is_in_gamut() { + return; + } + + self.r = self.r.clamp(0.0, 1.0); + self.g = self.g.clamp(0.0, 1.0); + self.b = self.b.clamp(0.0, 1.0); + } + /// Returns the [R, G, B] components as normalized values. pub fn components(&self) -> [f64; 3] { [self.r.0, self.g.0, self.b.0] } + /// Reduces chroma in CIELAB space until the color fits the gamut. + #[cfg(feature = "space-lab")] + pub fn compress_to_gamut(&mut self) { + if self.is_in_gamut() { + return; + } + + let lab = self.to_xyz().to_lab(); + let [l, a, b] = lab.components(); + let chroma = (a * a + b * b).sqrt(); + + if chroma < 1e-10 { + return self.clip_to_gamut(); + } + + let mut min_scale = 0.0_f64; + let mut max_scale = 1.0_f64; + + for _ in 0..10 { + let mid = (min_scale + max_scale) / 2.0; + let test = Lab::new(l, a * mid, b * mid).to_xyz().to_rgb::(); + if test.is_in_gamut() { + min_scale = mid; + } else { + max_scale = mid; + } + } + + let mut result = Lab::new(l, a * min_scale, b * min_scale).to_xyz().to_rgb::(); + result.clip_to_gamut(); + self.set_components(result.components()) + } + /// Returns the viewing context for this color space. pub fn context(&self) -> &ColorimetricContext { &self.context @@ -418,6 +464,39 @@ where self.r = (self.r + amount.into() / 255.0).clamp(0.0, 1.0); } + /// Returns `true` if all components are within the 0.0-1.0 range. + pub fn is_in_gamut(&self) -> bool { + (0.0..=1.0).contains(&self.r.0) && (0.0..=1.0).contains(&self.g.0) && (0.0..=1.0).contains(&self.b.0) + } + + /// Maps to gamut by scaling LMS components relative to the reference white. + pub fn perceptually_map_to_gamut(&mut self) { + let lms = self.to_xyz().to_lms(); + let [l, m, s] = lms.components(); + + let white_lms = self.context.reference_white().to_lms(); + let [wl, wm, ws] = white_lms.components(); + + let nl = if wl.abs() > 1e-10 { l / wl } else { 0.0 }; + let nm = if wm.abs() > 1e-10 { m / wm } else { 0.0 }; + let ns = if ws.abs() > 1e-10 { s / ws } else { 0.0 }; + + let max_normalized = nl.max(nm).max(ns); + + if max_normalized <= 1.0 { + return; + } + + let scale = 1.0 / max_normalized; + let mut scaled = Lms::new(l * scale, m * scale, s * scale) + .with_context(*lms.context()) + .to_xyz() + .to_rgb::(); + scaled.clip_to_gamut(); + + self.set_components(scaled.components()) + } + /// Returns the normalized red component (0.0-1.0). pub fn r(&self) -> f64 { self.r.0 @@ -458,6 +537,23 @@ where self.scale_r(factor); } + /// Scales linear RGB components to fit within the gamut. + pub fn scale_to_gamut(&mut self) { + if self.is_in_gamut() { + return; + } + + let linear = self.to_linear(); + let [r, g, b] = linear.components(); + + let max_value = r.abs().max(g.abs()).max(b.abs()); + let scale = if max_value > 1.0 { 1.0 / max_value } else { 1.0 }; + + let mut result = LinearRgb::::from_normalized(r * scale, g * scale, b * scale).to_encoded(); + result.clip_to_gamut(); + self.set_components(result.components()) + } + /// Sets the blue channel to the given normalized value (0.0-1.0). pub fn set_b(&mut self, b: impl Into) { self.b = b.into().clamp(0.0, 1.0); @@ -737,6 +833,35 @@ where self.with_b_scaled_by(factor) } + /// Returns a new color with all components clamped to the 0.0-1.0 range. + pub fn with_gamut_clipped(&self) -> Self { + let mut rgb = *self; + rgb.clip_to_gamut(); + rgb + } + + /// Returns a new color with chroma reduced in CIELAB space until the color fits the gamut. + #[cfg(feature = "space-lab")] + pub fn with_gamut_compressed(&self) -> Self { + let mut rgb = *self; + rgb.compress_to_gamut(); + rgb + } + + /// Returns a new color mapped to gamut by scaling LMS components relative to the reference white. + pub fn with_gamut_perceptually_mapped(&self) -> Self { + let mut rgb = *self; + rgb.perceptually_map_to_gamut(); + rgb + } + + /// Returns a new color with linear RGB components scaled to fit within the gamut. + pub fn with_gamut_scaled(&self) -> Self { + let mut rgb = *self; + rgb.scale_to_gamut(); + rgb + } + /// Returns a new color with the given normalized green channel value (0.0-1.0). pub fn with_g(&self, g: impl Into) -> Self { Self { @@ -1248,6 +1373,79 @@ mod test { } } + mod clip_to_gamut { + use super::*; + + #[test] + fn it_clamps_components_to_gamut() { + let mut rgb = Rgb::::from_normalized(1.5, -0.2, 0.5); + rgb.clip_to_gamut(); + + assert!((rgb.r() - 1.0).abs() < 1e-10); + assert!((rgb.g() - 0.0).abs() < 1e-10); + assert!((rgb.b() - 0.5).abs() < 1e-10); + assert!(rgb.is_in_gamut()); + } + + #[test] + fn it_returns_unchanged_when_in_gamut() { + let mut rgb = Rgb::::from_normalized(0.5, 0.3, 0.8); + rgb.clip_to_gamut(); + + assert!((rgb.r() - 0.5).abs() < 1e-10); + assert!((rgb.g() - 0.3).abs() < 1e-10); + assert!((rgb.b() - 0.8).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let mut rgb = Rgb::::from_normalized(1.5, 0.5, 0.5).with_alpha(0.7); + rgb.clip_to_gamut(); + + assert!((rgb.alpha() - 0.7).abs() < 1e-10); + } + } + + #[cfg(feature = "space-lab")] + mod compress_to_gamut { + use super::*; + + #[test] + fn it_brings_out_of_gamut_color_into_gamut() { + let mut rgb = Rgb::::from_normalized(1.5, -0.2, 0.5); + rgb.compress_to_gamut(); + + assert!(rgb.is_in_gamut()); + } + + #[test] + fn it_returns_unchanged_when_in_gamut() { + let mut rgb = Rgb::::from_normalized(0.5, 0.3, 0.8); + let orig = rgb; + rgb.compress_to_gamut(); + + assert!((rgb.r() - orig.r()).abs() < 1e-10); + assert!((rgb.g() - orig.g()).abs() < 1e-10); + assert!((rgb.b() - orig.b()).abs() < 1e-10); + } + + #[test] + fn it_handles_achromatic_colors() { + let mut rgb = Rgb::::from_normalized(1.5, 1.5, 1.5); + rgb.compress_to_gamut(); + + assert!(rgb.is_in_gamut()); + } + + #[test] + fn it_preserves_alpha() { + let mut rgb = Rgb::::from_normalized(1.5, 0.5, -0.1).with_alpha(0.4); + rgb.compress_to_gamut(); + + assert!((rgb.alpha() - 0.4).abs() < 1e-10); + } + } + mod decrement_b { use super::*; @@ -1511,11 +1709,11 @@ mod test { use super::*; #[test] - fn it_clamps_values_to_0_1_range() { + fn it_preserves_out_of_range_values() { let rgb = Rgb::::from_normalized(1.5, -0.5, 0.5); - assert_eq!(rgb.r(), 1.0); - assert_eq!(rgb.g(), 0.0); + assert_eq!(rgb.r(), 1.5); + assert_eq!(rgb.g(), -0.5); assert_eq!(rgb.b(), 0.5); } } @@ -1756,6 +1954,38 @@ mod test { } } + mod is_in_gamut { + use super::*; + + #[test] + fn it_returns_true_when_all_components_in_range() { + let rgb = Rgb::::from_normalized(0.5, 0.5, 0.5); + + assert!(rgb.is_in_gamut()); + } + + #[test] + fn it_returns_true_at_boundary_values() { + let rgb = Rgb::::from_normalized(0.0, 1.0, 0.0); + + assert!(rgb.is_in_gamut()); + } + + #[test] + fn it_returns_false_when_component_exceeds_one() { + let rgb = Rgb::::from_normalized(1.5, 0.5, 0.5); + + assert!(!rgb.is_in_gamut()); + } + + #[test] + fn it_returns_false_when_component_below_zero() { + let rgb = Rgb::::from_normalized(0.5, -0.1, 0.5); + + assert!(!rgb.is_in_gamut()); + } + } + mod mul { use super::*; @@ -1801,6 +2031,37 @@ mod test { } } + mod perceptually_map_to_gamut { + use super::*; + + #[test] + fn it_brings_out_of_gamut_color_into_gamut() { + let mut rgb = Rgb::::from_normalized(1.5, -0.2, 0.5); + rgb.perceptually_map_to_gamut(); + + assert!(rgb.is_in_gamut()); + } + + #[test] + fn it_returns_unchanged_when_in_gamut() { + let mut rgb = Rgb::::from_normalized(0.5, 0.3, 0.8); + let orig = rgb; + rgb.perceptually_map_to_gamut(); + + assert!((rgb.r() - orig.r()).abs() < 1e-10); + assert!((rgb.g() - orig.g()).abs() < 1e-10); + assert!((rgb.b() - orig.b()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let mut rgb = Rgb::::from_normalized(1.5, 0.5, 0.5).with_alpha(0.6); + rgb.perceptually_map_to_gamut(); + + assert!((rgb.alpha() - 0.6).abs() < 1e-10); + } + } + mod scale_b { use pretty_assertions::assert_eq; @@ -1851,6 +2112,37 @@ mod test { } } + mod scale_to_gamut { + use super::*; + + #[test] + fn it_scales_to_bring_into_gamut() { + let mut rgb = Rgb::::from_normalized(1.5, 0.5, 0.5); + rgb.scale_to_gamut(); + + assert!(rgb.is_in_gamut()); + } + + #[test] + fn it_returns_unchanged_when_in_gamut() { + let mut rgb = Rgb::::from_normalized(0.5, 0.3, 0.8); + let orig = rgb; + rgb.scale_to_gamut(); + + assert!((rgb.r() - orig.r()).abs() < 1e-10); + assert!((rgb.g() - orig.g()).abs() < 1e-10); + assert!((rgb.b() - orig.b()).abs() < 1e-10); + } + + #[test] + fn it_preserves_alpha() { + let mut rgb = Rgb::::from_normalized(1.5, 0.5, 0.5).with_alpha(0.5); + rgb.scale_to_gamut(); + + assert!((rgb.alpha() - 0.5).abs() < 1e-10); + } + } + mod sub { use pretty_assertions::assert_eq; @@ -2448,6 +2740,92 @@ mod test { } } + mod with_gamut_clipped { + use super::*; + + #[test] + fn it_returns_clipped_color() { + let rgb = Rgb::::from_normalized(1.5, -0.2, 0.5); + let result = rgb.with_gamut_clipped(); + + assert!(result.is_in_gamut()); + assert!((result.r() - 1.0).abs() < 1e-10); + assert!((result.g() - 0.0).abs() < 1e-10); + assert!((result.b() - 0.5).abs() < 1e-10); + } + + #[test] + fn it_does_not_mutate_original() { + let rgb = Rgb::::from_normalized(1.5, -0.2, 0.5); + let _ = rgb.with_gamut_clipped(); + + assert!((rgb.r() - 1.5).abs() < 1e-10); + assert!((rgb.g() - -0.2).abs() < 1e-10); + } + } + + #[cfg(feature = "space-lab")] + mod with_gamut_compressed { + use super::*; + + #[test] + fn it_returns_compressed_color() { + let rgb = Rgb::::from_normalized(1.5, -0.2, 0.5); + let result = rgb.with_gamut_compressed(); + + assert!(result.is_in_gamut()); + } + + #[test] + fn it_does_not_mutate_original() { + let rgb = Rgb::::from_normalized(1.5, -0.2, 0.5); + let _ = rgb.with_gamut_compressed(); + + assert!((rgb.r() - 1.5).abs() < 1e-10); + assert!((rgb.g() - -0.2).abs() < 1e-10); + } + } + + mod with_gamut_perceptually_mapped { + use super::*; + + #[test] + fn it_returns_perceptually_mapped_color() { + let rgb = Rgb::::from_normalized(1.5, 0.5, 0.5); + let result = rgb.with_gamut_perceptually_mapped(); + + assert!(result.is_in_gamut()); + } + + #[test] + fn it_does_not_mutate_original() { + let rgb = Rgb::::from_normalized(1.5, 0.5, 0.5); + let _ = rgb.with_gamut_perceptually_mapped(); + + assert!((rgb.r() - 1.5).abs() < 1e-10); + } + } + + mod with_gamut_scaled { + use super::*; + + #[test] + fn it_returns_scaled_color() { + let rgb = Rgb::::from_normalized(1.5, 0.5, 0.5); + let result = rgb.with_gamut_scaled(); + + assert!(result.is_in_gamut()); + } + + #[test] + fn it_does_not_mutate_original() { + let rgb = Rgb::::from_normalized(1.5, 0.5, 0.5); + let _ = rgb.with_gamut_scaled(); + + assert!((rgb.r() - 1.5).abs() < 1e-10); + } + } + mod with_g { use super::*; -- knotmirror 0.0.1