use crate::oklab::oklab_to_linear_srgb;
const CLAMP_ITERATIONS: u32 = 16;
#[must_use]
pub fn in_gamut(rgb: [f64; 3]) -> bool {
rgb[0] >= 0.0
&& rgb[0] <= 1.0
&& rgb[1] >= 0.0
&& rgb[1] <= 1.0
&& rgb[2] >= 0.0
&& rgb[2] <= 1.0
}
#[must_use]
pub fn soft_gamut_clamp(l: f64, a: f64, b: f64, l_blend: f64) -> [f64; 3] {
if in_gamut(oklab_to_linear_srgb([l, a, b])) {
return [l, a, b];
}
let anchor_l = l + l_blend * (0.5 - l);
let mut lo = 0.0_f64;
let mut hi = 1.0_f64;
for _ in 0..CLAMP_ITERATIONS {
let mid = (lo + hi) / 2.0;
let test = [l + (anchor_l - l) * mid, a * (1.0 - mid), b * (1.0 - mid)];
if in_gamut(oklab_to_linear_srgb(test)) {
hi = mid;
} else {
lo = mid;
}
}
[l + (anchor_l - l) * hi, a * (1.0 - hi), b * (1.0 - hi)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_gamut_boundaries() {
assert!(in_gamut([0.0, 0.0, 0.0]));
assert!(in_gamut([1.0, 1.0, 1.0]));
assert!(in_gamut([0.5, 0.3, 0.8]));
assert!(!in_gamut([1.1, 0.5, 0.5]));
assert!(!in_gamut([0.5, -0.1, 0.5]));
}
#[test]
fn in_gamut_color_passes_through() {
for blend in [0.0, 0.35, 0.5] {
assert_eq!(soft_gamut_clamp(0.5, 0.0, 0.0, blend), [0.5, 0.0, 0.0]);
}
}
#[test]
fn blend_zero_preserves_lightness_and_hue() {
let (l, a, b) = (0.5, 0.4, 0.0);
assert!(!in_gamut(oklab_to_linear_srgb([l, a, b])));
let [lo, ao, bo] = soft_gamut_clamp(l, a, b, 0.0);
assert_eq!(lo, l, "L unchanged at blend 0");
assert!(ao.abs() <= a.abs() + 1e-12, "chroma must not grow");
assert!(bo.abs() <= 1e-12);
assert!(in_gamut(oklab_to_linear_srgb([lo, ao, bo])));
}
#[test]
fn preserves_hue_angle() {
let (l, a, b) = (0.5, 0.3, -0.2);
let [_, ao, bo] = soft_gamut_clamp(l, a, b, 0.35);
assert!((a * bo - b * ao).abs() < 1e-12);
assert!(ao * a >= 0.0 && bo * b >= 0.0);
}
#[test]
fn matches_chromahash_clamp_vectors() {
let cases: &[([f64; 4], [f64; 3])] = &[
([0.5, 0.0, 0.0, 0.5], [0.5, 0.0, 0.0]),
([1.0, 0.0, 0.0, 0.5], [1.0, 0.0, 0.0]),
([0.0, 0.0, 0.0, 0.5], [0.0, 0.0, 0.0]),
([0.7, -0.1, 0.1, 0.5], [0.7, -0.1, 0.1]),
([0.5, 0.4, 0.2, 0.5], [0.5, 0.18203125, 0.091015625]),
(
[0.4, -0.1, -0.3, 0.5],
[
0.42991485595703127,
-0.040170288085937506,
-0.12051086425781249,
],
),
(
[0.8, -0.05, 0.3, 0.5],
[
0.7241188049316407,
-0.024706268310546876,
0.14823760986328124,
],
),
([0.5, 0.45, 0.0, 0.5], [0.5, 0.2026908874511719, 0.0]),
([0.5, 0.0, 0.45, 0.5], [0.5, 0.0, 0.1021728515625]),
(
[0.7, 0.25, 0.12, 0.5],
[0.6762924194335938, 0.19073104858398438, 0.0915509033203125],
),
(
[0.4488, -0.0357, -0.3143, 0.5],
[
0.45472265624999997,
-0.02744067077636719,
-0.24158551330566408,
],
),
([0.1, 0.0, 0.0, 0.5], [0.1, 0.0, 0.0]),
([0.9, 0.0, 0.0, 0.5], [0.9, 0.0, 0.0]),
];
for &([l, a, b, blend], want) in cases {
let got = soft_gamut_clamp(l, a, b, blend);
for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
assert!(
(g - w).abs() < 1e-6,
"clamp({l},{a},{b},{blend})[{i}] = {g}, want {w}"
);
}
}
}
}