Skip to main content

lux_rs/
color.rs

1use crate::cam::{
2    cam16_forward, cam16_ucs_forward, cam16_ucs_inverse, cam_forward, cam_inverse, cam_ucs_forward,
3    ciecam02_forward, ciecam02_ucs_forward, ciecam02_ucs_inverse, CamAppearance, CamUcsAppearance,
4    CamUcsType, CamViewingConditions as ModelCamViewingConditions,
5};
6use crate::error::{LuxError, LuxResult};
7use crate::spectrum::Spectrum;
8use std::fmt::{Display, Formatter};
9use std::str::FromStr;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Observer {
13    Cie1931_2,
14    Cie1964_10,
15    Cie2006_2,
16    Cie2006_10,
17    Cie2015_2,
18    Cie2015_10,
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub struct TristimulusObserver {
23    pub wavelengths: Vec<f64>,
24    pub x_bar: Vec<f64>,
25    pub y_bar: Vec<f64>,
26    pub z_bar: Vec<f64>,
27    pub k: f64,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct MesopicLuminousEfficiency {
32    pub curves: Spectrum,
33    pub k_mesopic: Vec<f64>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub struct Tristimulus {
38    values: Vec<[f64; 3]>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum DeltaEFormula {
43    Cie76,
44    Ciede2000,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum CatTransform {
49    Bradford,
50    Cat02,
51    Cat16,
52    Sharp,
53    Bianco,
54    Cmc,
55    Kries,
56    Judd1945,
57    Judd1945Cie016,
58    Judd1935,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum CatSurround {
63    Average,
64    Dim,
65    Dark,
66    Display,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum CatMode {
71    OneStep,
72    SourceToBaseline,
73    BaselineToTarget,
74    TwoStep,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub struct CatViewingConditions {
79    pub surround: CatSurround,
80    pub adapting_luminance: f64,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct CatContext {
85    pub source_white: [f64; 3],
86    pub target_white: [f64; 3],
87    pub baseline_white: Option<[f64; 3]>,
88    pub transform: CatTransform,
89    pub mode: CatMode,
90    pub source_conditions: CatViewingConditions,
91    pub target_conditions: CatViewingConditions,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct CatConditionPair {
96    pub source: CatViewingConditions,
97    pub target: CatViewingConditions,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct CatAdapter {
102    matrix: Matrix3,
103}
104
105pub type Matrix3 = [[f64; 3]; 3];
106
107const EPSILON: f64 = 1e-15;
108const LAB_LINEAR_THRESHOLD: f64 = (24.0 / 116.0) * (24.0 / 116.0) * (24.0 / 116.0);
109const LAB_LINEAR_SCALE: f64 = 841.0 / 108.0;
110const LAB_INVERSE_LINEAR_SCALE: f64 = 108.0 / 841.0;
111const LUV_LINEAR_THRESHOLD: f64 = (6.0 / 29.0) * (6.0 / 29.0) * (6.0 / 29.0);
112const LUV_LINEAR_SCALE: f64 = (29.0 / 3.0) * (29.0 / 3.0) * (29.0 / 3.0);
113const SRGB_XYZ_TO_RGB: Matrix3 = [
114    [3.2404542, -1.5371385, -0.4985314],
115    [-0.9692660, 1.8760108, 0.0415560],
116    [0.0556434, -0.2040259, 1.0572252],
117];
118const SRGB_RGB_TO_XYZ: Matrix3 = [
119    [0.4124564, 0.3575761, 0.1804375],
120    [0.2126729, 0.7151522, 0.0721750],
121    [0.0193339, 0.1191920, 0.9503041],
122];
123const XYZ_TO_LMS_CIE1931_2: Matrix3 = [
124    [0.38971, 0.68898, -0.07868],
125    [-0.22981, 1.1834, 0.04641],
126    [0.0, 0.0, 1.0],
127];
128const XYZ_TO_LMS_CIE1964_10: Matrix3 = [
129    [
130        0.217_010_449_691_388_16,
131        0.835_733_670_117_584_4,
132        -0.043_510_597_212_556_935,
133    ],
134    [
135        -0.429_979_507_573_619_8,
136        1.203_889_456_462_98,
137        0.086_210_895_329_211_28,
138    ],
139    [0.0, 0.0, 0.465_792_338_736_113],
140];
141const XYZ_TO_LMS_CIE2006_2: Matrix3 = [
142    [
143        0.444_040_252_514_163_8,
144        0.263_446_288_529_080_84,
145        -0.025_183_902_796_622_027,
146    ],
147    [0.877_340_233_784_257_2, 1.909_499_428_404_36, 0.0],
148    [0.0, 0.0, 0.516_835_881_576_572_3],
149];
150const XYZ_TO_LMS_CIE2006_10: Matrix3 = [
151    [
152        0.434_511_873_864_139_4,
153        0.239_073_351_151_345_67,
154        -0.087_584_241_936_804_45,
155    ],
156    [0.860_328_562_152_002_9, 1.858_437_464_814_796_6, 0.0],
157    [0.0, 0.0, 0.465_793_720_749_544_95],
158];
159
160#[derive(Debug, Clone, Copy)]
161struct ObserverSpec {
162    name: &'static str,
163    data: &'static str,
164    k: f64,
165    xyz_to_lms: Matrix3,
166}
167
168impl Observer {
169    pub const fn all() -> &'static [Self] {
170        &[
171            Self::Cie1931_2,
172            Self::Cie1964_10,
173            Self::Cie2006_2,
174            Self::Cie2006_10,
175            Self::Cie2015_2,
176            Self::Cie2015_10,
177        ]
178    }
179
180    pub fn name(self) -> &'static str {
181        self.spec().name
182    }
183
184    pub fn from_name(name: &str) -> LuxResult<Self> {
185        canonicalize_observer_name(name)
186            .and_then(observer_from_canonical_name)
187            .ok_or(LuxError::UnsupportedObserver("observer name"))
188    }
189
190    fn spec(self) -> ObserverSpec {
191        match self {
192            Self::Cie1931_2 => ObserverSpec {
193                name: "1931_2",
194                data: include_str!("../data/cmfs/ciexyz_1931_2.dat"),
195                k: 683.002,
196                xyz_to_lms: XYZ_TO_LMS_CIE1931_2,
197            },
198            Self::Cie1964_10 => ObserverSpec {
199                name: "1964_10",
200                data: include_str!("../data/cmfs/ciexyz_1964_10.dat"),
201                k: 683.599,
202                xyz_to_lms: XYZ_TO_LMS_CIE1964_10,
203            },
204            Self::Cie2006_2 => ObserverSpec {
205                name: "2006_2",
206                data: include_str!("../data/cmfs/ciexyz_2006_2.dat"),
207                k: 683.358,
208                xyz_to_lms: XYZ_TO_LMS_CIE2006_2,
209            },
210            Self::Cie2006_10 => ObserverSpec {
211                name: "2006_10",
212                data: include_str!("../data/cmfs/ciexyz_2006_10.dat"),
213                k: 683.144,
214                xyz_to_lms: XYZ_TO_LMS_CIE2006_10,
215            },
216            Self::Cie2015_2 => ObserverSpec {
217                name: "2015_2",
218                // CIE 2015 here reuses the same tabulated CMFs as the CIE 2006 set.
219                data: include_str!("../data/cmfs/ciexyz_2006_2.dat"),
220                k: 683.358,
221                xyz_to_lms: XYZ_TO_LMS_CIE2006_2,
222            },
223            Self::Cie2015_10 => ObserverSpec {
224                name: "2015_10",
225                // CIE 2015 here reuses the same tabulated CMFs as the CIE 2006 set.
226                data: include_str!("../data/cmfs/ciexyz_2006_10.dat"),
227                k: 683.144,
228                xyz_to_lms: XYZ_TO_LMS_CIE2006_10,
229            },
230        }
231    }
232
233    pub fn standard(self) -> LuxResult<TristimulusObserver> {
234        let spec = self.spec();
235        TristimulusObserver::from_csv(spec.data, spec.k)
236    }
237
238    pub fn xyzbar(self) -> LuxResult<Spectrum> {
239        self.standard()?.xyz_spectra()
240    }
241
242    pub fn xyzbar_linear(self, target_wavelengths: &[f64]) -> LuxResult<Spectrum> {
243        self.xyzbar()?.cie_interp_linear(target_wavelengths, false)
244    }
245
246    pub fn vlbar(self) -> LuxResult<(Spectrum, f64)> {
247        let observer = self.standard()?;
248        Ok((observer.vl_spectrum()?, observer.k))
249    }
250
251    pub fn vlbar_linear(self, target_wavelengths: &[f64]) -> LuxResult<(Spectrum, f64)> {
252        let (vl, k) = self.vlbar()?;
253        Ok((vl.cie_interp_linear(target_wavelengths, false)?, k))
254    }
255
256    pub fn xyz_to_lms_matrix(self) -> LuxResult<Matrix3> {
257        Ok(self.spec().xyz_to_lms)
258    }
259}
260
261impl Display for Observer {
262    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
263        f.write_str(self.name())
264    }
265}
266
267impl FromStr for Observer {
268    type Err = LuxError;
269
270    fn from_str(s: &str) -> Result<Self, Self::Err> {
271        Self::from_name(s)
272    }
273}
274
275fn canonicalize_observer_name(name: &str) -> Option<&'static str> {
276    let trimmed = name.trim();
277    if trimmed.is_empty() {
278        return None;
279    }
280
281    let normalized = trimmed
282        .to_ascii_lowercase()
283        .replace([' ', '\t', '\n', '\r', '-', '_'], "");
284    let normalized = normalized.strip_prefix("cie").unwrap_or(&normalized);
285
286    match normalized {
287        "19312" | "1931" => Some("1931_2"),
288        "196410" | "1964" => Some("1964_10"),
289        "20062" => Some("2006_2"),
290        "200610" => Some("2006_10"),
291        "20152" => Some("2015_2"),
292        "201510" => Some("2015_10"),
293        _ => None,
294    }
295}
296
297fn observer_from_canonical_name(name: &'static str) -> Option<Observer> {
298    match name {
299        "1931_2" => Some(Observer::Cie1931_2),
300        "1964_10" => Some(Observer::Cie1964_10),
301        "2006_2" => Some(Observer::Cie2006_2),
302        "2006_10" => Some(Observer::Cie2006_10),
303        "2015_2" => Some(Observer::Cie2015_2),
304        "2015_10" => Some(Observer::Cie2015_10),
305        _ => None,
306    }
307}
308
309impl Tristimulus {
310    pub fn new(values: Vec<[f64; 3]>) -> Self {
311        Self { values }
312    }
313
314    pub fn from_single(value: [f64; 3]) -> Self {
315        Self::new(vec![value])
316    }
317
318    pub fn values(&self) -> &[[f64; 3]] {
319        &self.values
320    }
321
322    pub fn iter(&self) -> impl Iterator<Item = [f64; 3]> + '_ {
323        self.values.iter().copied()
324    }
325
326    pub fn len(&self) -> usize {
327        self.values.len()
328    }
329
330    pub fn is_empty(&self) -> bool {
331        self.values.is_empty()
332    }
333
334    pub fn into_vec(self) -> Vec<[f64; 3]> {
335        self.values
336    }
337
338    pub fn xyz_to_yxy(&self) -> Self {
339        Self::new(
340            self.values
341                .iter()
342                .copied()
343                .map(xyz_to_yxy)
344                .collect::<Vec<_>>(),
345        )
346    }
347
348    pub fn yxy_to_xyz(&self) -> Self {
349        Self::new(
350            self.values
351                .iter()
352                .copied()
353                .map(yxy_to_xyz)
354                .collect::<Vec<_>>(),
355        )
356    }
357
358    pub fn xyz_to_yuv(&self) -> Self {
359        Self::new(
360            self.values
361                .iter()
362                .copied()
363                .map(xyz_to_yuv)
364                .collect::<Vec<_>>(),
365        )
366    }
367
368    pub fn yuv_to_xyz(&self) -> Self {
369        Self::new(
370            self.values
371                .iter()
372                .copied()
373                .map(yuv_to_xyz)
374                .collect::<Vec<_>>(),
375        )
376    }
377
378    pub fn xyz_to_lab(&self, white_point: [f64; 3]) -> Self {
379        Self::new(
380            self.values
381                .iter()
382                .copied()
383                .map(|value| xyz_to_lab(value, white_point))
384                .collect::<Vec<_>>(),
385        )
386    }
387
388    pub fn lab_to_xyz(&self, white_point: [f64; 3]) -> Self {
389        Self::new(
390            self.values
391                .iter()
392                .copied()
393                .map(|value| lab_to_xyz(value, white_point))
394                .collect::<Vec<_>>(),
395        )
396    }
397
398    pub fn xyz_to_oklab(&self) -> Self {
399        Self::new(
400            self.values
401                .iter()
402                .copied()
403                .map(xyz_to_oklab)
404                .collect::<Vec<_>>(),
405        )
406    }
407
408    pub fn oklab_to_xyz(&self) -> Self {
409        Self::new(
410            self.values
411                .iter()
412                .copied()
413                .map(oklab_to_xyz)
414                .collect::<Vec<_>>(),
415        )
416    }
417
418    pub fn oklab_to_oklch(&self) -> Self {
419        Self::new(
420            self.values
421                .iter()
422                .copied()
423                .map(oklab_to_oklch)
424                .collect::<Vec<_>>(),
425        )
426    }
427
428    pub fn oklch_to_oklab(&self) -> Self {
429        Self::new(
430            self.values
431                .iter()
432                .copied()
433                .map(oklch_to_oklab)
434                .collect::<Vec<_>>(),
435        )
436    }
437
438    pub fn xyz_to_oklch(&self) -> Self {
439        Self::new(
440            self.values
441                .iter()
442                .copied()
443                .map(xyz_to_oklch)
444                .collect::<Vec<_>>(),
445        )
446    }
447
448    pub fn oklch_to_xyz(&self) -> Self {
449        Self::new(
450            self.values
451                .iter()
452                .copied()
453                .map(oklch_to_xyz)
454                .collect::<Vec<_>>(),
455        )
456    }
457
458    pub fn xyz_to_luv(&self, white_point: [f64; 3]) -> Self {
459        Self::new(
460            self.values
461                .iter()
462                .copied()
463                .map(|value| xyz_to_luv(value, white_point))
464                .collect::<Vec<_>>(),
465        )
466    }
467
468    pub fn luv_to_xyz(&self, white_point: [f64; 3]) -> Self {
469        Self::new(
470            self.values
471                .iter()
472                .copied()
473                .map(|value| luv_to_xyz(value, white_point))
474                .collect::<Vec<_>>(),
475        )
476    }
477
478    pub fn xyz_to_lms(&self, observer: Observer) -> LuxResult<Self> {
479        Ok(Self::new(
480            self.values
481                .iter()
482                .copied()
483                .map(|value| xyz_to_lms(value, observer))
484                .collect::<LuxResult<Vec<_>>>()?,
485        ))
486    }
487
488    pub fn lms_to_xyz(&self, observer: Observer) -> LuxResult<Self> {
489        Ok(Self::new(
490            self.values
491                .iter()
492                .copied()
493                .map(|value| lms_to_xyz(value, observer))
494                .collect::<LuxResult<Vec<_>>>()?,
495        ))
496    }
497
498    pub fn xyz_to_srgb(&self, gamma: f64, offset: f64, use_linear_part: bool) -> Self {
499        Self::new(
500            self.values
501                .iter()
502                .copied()
503                .map(|value| xyz_to_srgb(value, gamma, offset, use_linear_part))
504                .collect::<Vec<_>>(),
505        )
506    }
507
508    pub fn srgb_to_xyz(&self, gamma: f64, offset: f64, use_linear_part: bool) -> Self {
509        Self::new(
510            self.values
511                .iter()
512                .copied()
513                .map(|value| srgb_to_xyz(value, gamma, offset, use_linear_part))
514                .collect::<Vec<_>>(),
515        )
516    }
517
518    /// Map each stored XYZ triplet through [`rgb_to_xyz`] using `space`.
519    pub fn rgb_to_xyz(&self, space: RgbColorSpace) -> Self {
520        Self::new(
521            self.values
522                .iter()
523                .copied()
524                .map(|value| rgb_to_xyz(value, space))
525                .collect::<Vec<_>>(),
526        )
527    }
528
529    /// Map each stored XYZ triplet through [`xyz_to_rgb`] using `space`.
530    pub fn xyz_to_rgb(&self, space: RgbColorSpace) -> Self {
531        Self::new(
532            self.values
533                .iter()
534                .copied()
535                .map(|value| xyz_to_rgb(value, space))
536                .collect::<Vec<_>>(),
537        )
538    }
539
540    pub fn cat_apply(
541        &self,
542        source_white: [f64; 3],
543        target_white: [f64; 3],
544        transform: CatTransform,
545        degree_of_adaptation: f64,
546    ) -> LuxResult<Self> {
547        self.cat_apply_adapter(CatAdapter::from_degree(
548            source_white,
549            target_white,
550            transform,
551            degree_of_adaptation,
552        )?)
553    }
554
555    pub fn cat_apply_mode(
556        &self,
557        source_white: [f64; 3],
558        target_white: [f64; 3],
559        baseline_white: Option<[f64; 3]>,
560        transform: CatTransform,
561        mode: CatMode,
562        degrees_of_adaptation: [f64; 2],
563    ) -> LuxResult<Self> {
564        self.cat_apply_adapter(CatAdapter::from_mode(
565            source_white,
566            target_white,
567            baseline_white,
568            transform,
569            mode,
570            degrees_of_adaptation,
571        )?)
572    }
573
574    pub fn cat_apply_with_conditions(
575        &self,
576        source_white: [f64; 3],
577        target_white: [f64; 3],
578        transform: CatTransform,
579        surround: CatSurround,
580        adapting_luminance: f64,
581    ) -> LuxResult<Self> {
582        self.cat_apply_adapter(CatAdapter::from_degree(
583            source_white,
584            target_white,
585            transform,
586            cat_degree_of_adaptation(surround, adapting_luminance)?,
587        )?)
588    }
589
590    pub fn cat_apply_mode_with_conditions(
591        &self,
592        source_white: [f64; 3],
593        target_white: [f64; 3],
594        baseline_white: Option<[f64; 3]>,
595        transform: CatTransform,
596        mode: CatMode,
597        conditions: CatConditionPair,
598    ) -> LuxResult<Self> {
599        self.cat_apply_adapter(CatAdapter::from_conditions(
600            source_white,
601            target_white,
602            baseline_white,
603            transform,
604            mode,
605            conditions.source,
606            conditions.target,
607        )?)
608    }
609
610    pub fn cat_apply_context(&self, context: CatContext) -> LuxResult<Self> {
611        self.cat_apply_adapter(CatAdapter::from_context(context)?)
612    }
613
614    pub fn cat_apply_adapter(&self, adapter: CatAdapter) -> LuxResult<Self> {
615        Ok(Self::new(
616            self.values
617                .iter()
618                .copied()
619                .map(|value| adapter.apply(value))
620                .collect::<LuxResult<Vec<_>>>()?,
621        ))
622    }
623
624    pub fn delta_e(
625        &self,
626        other: &Self,
627        white_point: [f64; 3],
628        formula: DeltaEFormula,
629    ) -> LuxResult<Vec<f64>> {
630        if self.len() != other.len() {
631            return Err(LuxError::MismatchedLengths {
632                wavelengths: self.len(),
633                values: other.len(),
634            });
635        }
636
637        Ok(self
638            .values
639            .iter()
640            .copied()
641            .zip(other.values.iter().copied())
642            .map(|(left, right)| delta_e(left, right, white_point, formula))
643            .collect::<Vec<_>>())
644    }
645
646    pub fn cam_forward(
647        &self,
648        conditions: ModelCamViewingConditions,
649    ) -> LuxResult<Vec<CamAppearance>> {
650        self.values
651            .iter()
652            .copied()
653            .map(|value| cam_forward(value, conditions))
654            .collect::<LuxResult<Vec<_>>>()
655    }
656
657    pub fn cam16_forward(
658        &self,
659        conditions: ModelCamViewingConditions,
660    ) -> LuxResult<Vec<CamAppearance>> {
661        self.values
662            .iter()
663            .copied()
664            .map(|value| cam16_forward(value, conditions))
665            .collect::<LuxResult<Vec<_>>>()
666    }
667
668    pub fn ciecam02_forward(
669        &self,
670        conditions: ModelCamViewingConditions,
671    ) -> LuxResult<Vec<CamAppearance>> {
672        self.values
673            .iter()
674            .copied()
675            .map(|value| ciecam02_forward(value, conditions))
676            .collect::<LuxResult<Vec<_>>>()
677    }
678
679    pub fn cam_ucs_forward(
680        &self,
681        conditions: ModelCamViewingConditions,
682        ucs_type: CamUcsType,
683    ) -> LuxResult<Vec<CamUcsAppearance>> {
684        self.values
685            .iter()
686            .copied()
687            .map(|value| cam_ucs_forward(value, conditions, ucs_type))
688            .collect::<LuxResult<Vec<_>>>()
689    }
690
691    pub fn cam16_ucs_forward(
692        &self,
693        conditions: ModelCamViewingConditions,
694        ucs_type: CamUcsType,
695    ) -> LuxResult<Vec<CamUcsAppearance>> {
696        self.values
697            .iter()
698            .copied()
699            .map(|value| cam16_ucs_forward(value, conditions, ucs_type))
700            .collect::<LuxResult<Vec<_>>>()
701    }
702
703    pub fn ciecam02_ucs_forward(
704        &self,
705        conditions: ModelCamViewingConditions,
706        ucs_type: CamUcsType,
707    ) -> LuxResult<Vec<CamUcsAppearance>> {
708        self.values
709            .iter()
710            .copied()
711            .map(|value| ciecam02_ucs_forward(value, conditions, ucs_type))
712            .collect::<LuxResult<Vec<_>>>()
713    }
714
715    pub fn cam_inverse(&self, conditions: ModelCamViewingConditions) -> LuxResult<Self> {
716        Ok(Self::new(
717            self.values
718                .iter()
719                .copied()
720                .map(|value| {
721                    cam_inverse(
722                        CamAppearance {
723                            lightness: value[0],
724                            brightness: 0.0,
725                            chroma: 0.0,
726                            colorfulness: (value[1] * value[1] + value[2] * value[2]).sqrt(),
727                            saturation: 0.0,
728                            hue_angle: 0.0,
729                            a_m: value[1],
730                            b_m: value[2],
731                            a_c: 0.0,
732                            b_c: 0.0,
733                        },
734                        conditions,
735                    )
736                })
737                .collect::<LuxResult<Vec<_>>>()?,
738        ))
739    }
740
741    pub fn cam16_ucs_inverse(
742        &self,
743        conditions: ModelCamViewingConditions,
744        ucs_type: CamUcsType,
745    ) -> LuxResult<Self> {
746        Ok(Self::new(
747            self.values
748                .iter()
749                .copied()
750                .map(|value| {
751                    cam16_ucs_inverse(
752                        CamUcsAppearance {
753                            j_prime: value[0],
754                            a_prime: value[1],
755                            b_prime: value[2],
756                        },
757                        conditions,
758                        ucs_type,
759                    )
760                })
761                .collect::<LuxResult<Vec<_>>>()?,
762        ))
763    }
764
765    pub fn ciecam02_ucs_inverse(
766        &self,
767        conditions: ModelCamViewingConditions,
768        ucs_type: CamUcsType,
769    ) -> LuxResult<Self> {
770        Ok(Self::new(
771            self.values
772                .iter()
773                .copied()
774                .map(|value| {
775                    ciecam02_ucs_inverse(
776                        CamUcsAppearance {
777                            j_prime: value[0],
778                            a_prime: value[1],
779                            b_prime: value[2],
780                        },
781                        conditions,
782                        ucs_type,
783                    )
784                })
785                .collect::<LuxResult<Vec<_>>>()?,
786        ))
787    }
788}
789
790impl From<Vec<[f64; 3]>> for Tristimulus {
791    fn from(values: Vec<[f64; 3]>) -> Self {
792        Self::new(values)
793    }
794}
795
796impl From<[f64; 3]> for Tristimulus {
797    fn from(value: [f64; 3]) -> Self {
798        Self::from_single(value)
799    }
800}
801
802impl CatTransform {
803    pub fn matrix(self) -> Matrix3 {
804        match self {
805            Self::Bradford => [
806                [0.8951, 0.2664, -0.1614],
807                [-0.7502, 1.7135, 0.0367],
808                [0.0389, -0.0685, 1.0296],
809            ],
810            Self::Cat02 => [
811                [0.7328, 0.4296, -0.1624],
812                [-0.7036, 1.6975, 0.0061],
813                [0.0030, 0.0136, 0.9834],
814            ],
815            Self::Cat16 => [
816                [0.401288, 0.650173, -0.051461],
817                [-0.250268, 1.204414, 0.045854],
818                [-0.002079, 0.048952, 0.953127],
819            ],
820            Self::Sharp => [
821                [1.2694, -0.0988, -0.1706],
822                [-0.8364, 1.8006, 0.0357],
823                [0.0297, -0.0315, 1.0018],
824            ],
825            Self::Bianco => [
826                [0.8752, 0.2787, -0.1539],
827                [-0.8904, 1.8709, 0.0195],
828                [-0.0061, 0.0162, 0.9899],
829            ],
830            Self::Cmc => [
831                [0.7982, 0.3389, -0.1371],
832                [-0.5918, 1.5512, 0.0406],
833                [0.0008, 0.0239, 0.9753],
834            ],
835            Self::Kries => [
836                [0.40024, 0.70760, -0.08081],
837                [-0.22630, 1.16532, 0.04570],
838                [0.0, 0.0, 0.91822],
839            ],
840            Self::Judd1945 => [[0.0, 1.0, 0.0], [-0.460, 1.359, 0.101], [0.0, 0.0, 1.0]],
841            Self::Judd1945Cie016 => [[0.0, 1.0, 0.0], [-0.460, 1.360, 0.102], [0.0, 0.0, 1.0]],
842            Self::Judd1935 => [
843                [3.1956, 2.4478, -0.1434],
844                [-2.5455, 7.0942, 0.9963],
845                [0.0, 0.0, 1.0],
846            ],
847        }
848    }
849}
850
851impl CatSurround {
852    pub fn factor(self) -> f64 {
853        match self {
854            Self::Average => 1.0,
855            Self::Dim => 0.9,
856            Self::Dark => 0.8,
857            Self::Display => 0.0,
858        }
859    }
860}
861
862impl CatMode {
863    pub fn default_baseline_white(self) -> [f64; 3] {
864        match self {
865            Self::SourceToBaseline | Self::BaselineToTarget | Self::TwoStep => {
866                [100.0, 100.0, 100.0]
867            }
868            Self::OneStep => [100.0, 100.0, 100.0],
869        }
870    }
871}
872
873impl CatViewingConditions {
874    pub fn new(surround: CatSurround, adapting_luminance: f64) -> LuxResult<Self> {
875        validate_adapting_luminance(adapting_luminance)?;
876        Ok(Self {
877            surround,
878            adapting_luminance,
879        })
880    }
881
882    pub fn degree_of_adaptation(self) -> LuxResult<f64> {
883        cat_degree_of_adaptation(self.surround, self.adapting_luminance)
884    }
885}
886
887impl CatContext {
888    pub fn new(
889        source_white: [f64; 3],
890        target_white: [f64; 3],
891        baseline_white: Option<[f64; 3]>,
892        transform: CatTransform,
893        mode: CatMode,
894        source_conditions: CatViewingConditions,
895        target_conditions: CatViewingConditions,
896    ) -> Self {
897        Self {
898            source_white,
899            target_white,
900            baseline_white,
901            transform,
902            mode,
903            source_conditions,
904            target_conditions,
905        }
906    }
907
908    pub fn baseline_white_or_default(self) -> [f64; 3] {
909        self.baseline_white
910            .unwrap_or(self.mode.default_baseline_white())
911    }
912
913    pub fn degrees_of_adaptation(self) -> LuxResult<[f64; 2]> {
914        cat_mode_degrees_from_conditions(self.mode, self.source_conditions, self.target_conditions)
915    }
916}
917
918impl CatConditionPair {
919    pub const fn new(source: CatViewingConditions, target: CatViewingConditions) -> Self {
920        Self { source, target }
921    }
922}
923
924impl CatAdapter {
925    pub fn new(matrix: Matrix3) -> Self {
926        Self { matrix }
927    }
928
929    pub fn matrix(self) -> Matrix3 {
930        self.matrix
931    }
932
933    pub fn apply(self, xyz: [f64; 3]) -> LuxResult<[f64; 3]> {
934        validate_xyz_triplet(xyz, "xyz values must be finite")?;
935        Ok(multiply_matrix3_vector3(self.matrix, xyz))
936    }
937
938    pub fn from_degree(
939        source_white: [f64; 3],
940        target_white: [f64; 3],
941        transform: CatTransform,
942        degree_of_adaptation: f64,
943    ) -> LuxResult<Self> {
944        validate_xyz_triplet(source_white, "source white values must be finite")?;
945        validate_xyz_triplet(target_white, "target white values must be finite")?;
946        validate_degree(
947            degree_of_adaptation,
948            "degree_of_adaptation must be finite and within 0..=1",
949        )?;
950
951        let sensor_matrix = transform.matrix();
952        let inverse = invert_matrix3(sensor_matrix);
953        let rgbw_source = multiply_matrix3_vector3(sensor_matrix, source_white);
954        let rgbw_target = multiply_matrix3_vector3(sensor_matrix, target_white);
955        let mut diagonal = [[0.0; 3]; 3];
956
957        for index in 0..3 {
958            if rgbw_source[index].abs() <= EPSILON {
959                return Err(LuxError::InvalidInput(
960                    "source white produces zero CAT sensor response",
961                ));
962            }
963            let ratio = rgbw_target[index] / rgbw_source[index];
964            diagonal[index][index] = degree_of_adaptation * ratio + (1.0 - degree_of_adaptation);
965        }
966
967        Ok(Self::new(multiply_matrix3(
968            inverse,
969            multiply_matrix3(diagonal, sensor_matrix),
970        )))
971    }
972
973    pub fn from_mode(
974        source_white: [f64; 3],
975        target_white: [f64; 3],
976        baseline_white: Option<[f64; 3]>,
977        transform: CatTransform,
978        mode: CatMode,
979        degrees_of_adaptation: [f64; 2],
980    ) -> LuxResult<Self> {
981        validate_xyz_triplet(source_white, "source white values must be finite")?;
982        validate_xyz_triplet(target_white, "target white values must be finite")?;
983        validate_degree(
984            degrees_of_adaptation[0],
985            "degrees_of_adaptation[0] must be finite and within 0..=1",
986        )?;
987        validate_degree(
988            degrees_of_adaptation[1],
989            "degrees_of_adaptation[1] must be finite and within 0..=1",
990        )?;
991
992        let baseline_white = baseline_white.unwrap_or(mode.default_baseline_white());
993        validate_xyz_triplet(baseline_white, "baseline white values must be finite")?;
994
995        match mode {
996            CatMode::OneStep => Self::from_degree(
997                source_white,
998                target_white,
999                transform,
1000                degrees_of_adaptation[0],
1001            ),
1002            CatMode::SourceToBaseline => Self::from_degree(
1003                source_white,
1004                baseline_white,
1005                transform,
1006                degrees_of_adaptation[0],
1007            ),
1008            CatMode::BaselineToTarget => Self::from_degree(
1009                baseline_white,
1010                target_white,
1011                transform,
1012                degrees_of_adaptation[0],
1013            ),
1014            CatMode::TwoStep => {
1015                let sensor_matrix = transform.matrix();
1016                let inverse = invert_matrix3(sensor_matrix);
1017                let rgbw1 = multiply_matrix3_vector3(sensor_matrix, source_white);
1018                let rgbw2 = multiply_matrix3_vector3(sensor_matrix, target_white);
1019                let rgbw0 = multiply_matrix3_vector3(sensor_matrix, baseline_white);
1020                let mut diagonal = [[0.0; 3]; 3];
1021
1022                for index in 0..3 {
1023                    if rgbw1[index].abs() <= EPSILON {
1024                        return Err(LuxError::InvalidInput(
1025                            "source white produces zero CAT sensor response",
1026                        ));
1027                    }
1028                    if rgbw2[index].abs() <= EPSILON {
1029                        return Err(LuxError::InvalidInput(
1030                            "target white produces zero CAT sensor response",
1031                        ));
1032                    }
1033                    let scale10 = degrees_of_adaptation[0] * (rgbw0[index] / rgbw1[index])
1034                        + (1.0 - degrees_of_adaptation[0]);
1035                    let scale20 = degrees_of_adaptation[1] * (rgbw0[index] / rgbw2[index])
1036                        + (1.0 - degrees_of_adaptation[1]);
1037                    diagonal[index][index] = scale10 / scale20;
1038                }
1039
1040                Ok(Self::new(multiply_matrix3(
1041                    inverse,
1042                    multiply_matrix3(diagonal, sensor_matrix),
1043                )))
1044            }
1045        }
1046    }
1047
1048    pub fn from_conditions(
1049        source_white: [f64; 3],
1050        target_white: [f64; 3],
1051        baseline_white: Option<[f64; 3]>,
1052        transform: CatTransform,
1053        mode: CatMode,
1054        source_conditions: CatViewingConditions,
1055        target_conditions: CatViewingConditions,
1056    ) -> LuxResult<Self> {
1057        let degrees = cat_mode_degrees_from_conditions(mode, source_conditions, target_conditions)?;
1058        Self::from_mode(
1059            source_white,
1060            target_white,
1061            baseline_white,
1062            transform,
1063            mode,
1064            degrees,
1065        )
1066    }
1067
1068    pub fn from_context(context: CatContext) -> LuxResult<Self> {
1069        Self::from_conditions(
1070            context.source_white,
1071            context.target_white,
1072            context.baseline_white,
1073            context.transform,
1074            context.mode,
1075            context.source_conditions,
1076            context.target_conditions,
1077        )
1078    }
1079}
1080
1081impl TristimulusObserver {
1082    pub fn from_csv(csv: &str, k: f64) -> LuxResult<Self> {
1083        let mut wavelengths = Vec::new();
1084        let mut x_bar = Vec::new();
1085        let mut y_bar = Vec::new();
1086        let mut z_bar = Vec::new();
1087
1088        for line in csv.lines() {
1089            let trimmed = line.trim();
1090            if trimmed.is_empty() {
1091                continue;
1092            }
1093            let mut parts = trimmed.split(',');
1094            let wl = parts
1095                .next()
1096                .ok_or(LuxError::ParseError("missing wavelength"))?
1097                .trim()
1098                .parse::<f64>()
1099                .map_err(|_| LuxError::ParseError("invalid wavelength"))?;
1100            let x = parts
1101                .next()
1102                .ok_or(LuxError::ParseError("missing x_bar"))?
1103                .trim()
1104                .parse::<f64>()
1105                .map_err(|_| LuxError::ParseError("invalid x_bar"))?;
1106            let y = parts
1107                .next()
1108                .ok_or(LuxError::ParseError("missing y_bar"))?
1109                .trim()
1110                .parse::<f64>()
1111                .map_err(|_| LuxError::ParseError("invalid y_bar"))?;
1112            let z = parts
1113                .next()
1114                .ok_or(LuxError::ParseError("missing z_bar"))?
1115                .trim()
1116                .parse::<f64>()
1117                .map_err(|_| LuxError::ParseError("invalid z_bar"))?;
1118
1119            wavelengths.push(wl);
1120            x_bar.push(x);
1121            y_bar.push(y);
1122            z_bar.push(z);
1123        }
1124
1125        if wavelengths.is_empty() {
1126            return Err(LuxError::EmptyInput);
1127        }
1128
1129        Ok(Self {
1130            wavelengths,
1131            x_bar,
1132            y_bar,
1133            z_bar,
1134            k,
1135        })
1136    }
1137
1138    pub fn vl_spectrum(&self) -> LuxResult<Spectrum> {
1139        Spectrum::new(self.wavelengths.clone(), self.y_bar.clone())
1140    }
1141
1142    pub fn xyz_spectra(&self) -> LuxResult<Spectrum> {
1143        Spectrum::new(
1144            self.wavelengths.clone(),
1145            vec![self.x_bar.clone(), self.y_bar.clone(), self.z_bar.clone()],
1146        )
1147    }
1148
1149    pub fn x_bar_spectrum(&self) -> LuxResult<Spectrum> {
1150        Spectrum::new(self.wavelengths.clone(), self.x_bar.clone())
1151    }
1152
1153    pub fn z_bar_spectrum(&self) -> LuxResult<Spectrum> {
1154        Spectrum::new(self.wavelengths.clone(), self.z_bar.clone())
1155    }
1156}
1157
1158fn nonzero(value: f64) -> f64 {
1159    if value == 0.0 {
1160        EPSILON
1161    } else {
1162        value
1163    }
1164}
1165
1166fn lab_response_curve(value: f64, white: f64) -> f64 {
1167    let ratio = value / white;
1168    if ratio <= LAB_LINEAR_THRESHOLD {
1169        LAB_LINEAR_SCALE * ratio + 16.0 / 116.0
1170    } else {
1171        ratio.cbrt()
1172    }
1173}
1174
1175fn lab_inverse_response_curve(response: f64, white: f64) -> f64 {
1176    if response <= 24.0 / 116.0 {
1177        white * ((response - 16.0 / 116.0) * LAB_INVERSE_LINEAR_SCALE)
1178    } else {
1179        white * response.powi(3)
1180    }
1181}
1182
1183fn cie_lightness_from_ratio(y_ratio: f64) -> f64 {
1184    if y_ratio <= LUV_LINEAR_THRESHOLD {
1185        LUV_LINEAR_SCALE * y_ratio
1186    } else {
1187        116.0 * y_ratio.cbrt() - 16.0
1188    }
1189}
1190
1191fn cie_y_ratio_from_lightness(lightness: f64) -> f64 {
1192    let y_ratio = ((lightness + 16.0) / 116.0).powi(3);
1193    if y_ratio < LUV_LINEAR_THRESHOLD {
1194        lightness / LUV_LINEAR_SCALE
1195    } else {
1196        y_ratio
1197    }
1198}
1199
1200fn multiply_matrix3_vector3(matrix: Matrix3, vector: [f64; 3]) -> [f64; 3] {
1201    [
1202        matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
1203        matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
1204        matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2],
1205    ]
1206}
1207
1208fn multiply_matrix3(left: Matrix3, right: Matrix3) -> Matrix3 {
1209    let mut out = [[0.0; 3]; 3];
1210    for row in 0..3 {
1211        for col in 0..3 {
1212            out[row][col] = left[row][0] * right[0][col]
1213                + left[row][1] * right[1][col]
1214                + left[row][2] * right[2][col];
1215        }
1216    }
1217    out
1218}
1219
1220fn clamp(value: f64, min: f64, max: f64) -> f64 {
1221    value.max(min).min(max)
1222}
1223
1224fn degrees_to_radians(value: f64) -> f64 {
1225    value * std::f64::consts::PI / 180.0
1226}
1227
1228fn hue_angle_degrees(a: f64, b: f64) -> f64 {
1229    let angle = b.atan2(a).to_degrees();
1230    if angle < 0.0 {
1231        angle + 360.0
1232    } else {
1233        angle
1234    }
1235}
1236
1237fn validate_xyz_triplet(xyz: [f64; 3], label: &'static str) -> LuxResult<()> {
1238    if xyz.iter().all(|value| value.is_finite()) {
1239        Ok(())
1240    } else {
1241        Err(LuxError::InvalidInput(label))
1242    }
1243}
1244
1245fn validate_degree(value: f64, label: &'static str) -> LuxResult<()> {
1246    if !value.is_finite() || !(0.0..=1.0).contains(&value) {
1247        Err(LuxError::InvalidInput(label))
1248    } else {
1249        Ok(())
1250    }
1251}
1252
1253fn validate_adapting_luminance(adapting_luminance: f64) -> LuxResult<()> {
1254    if !adapting_luminance.is_finite() || adapting_luminance < 0.0 {
1255        Err(LuxError::InvalidInput(
1256            "adapting_luminance must be finite and non-negative",
1257        ))
1258    } else {
1259        Ok(())
1260    }
1261}
1262
1263pub(crate) fn invert_matrix3(matrix: Matrix3) -> Matrix3 {
1264    let a = matrix[0][0];
1265    let b = matrix[0][1];
1266    let c = matrix[0][2];
1267    let d = matrix[1][0];
1268    let e = matrix[1][1];
1269    let f = matrix[1][2];
1270    let g = matrix[2][0];
1271    let h = matrix[2][1];
1272    let i = matrix[2][2];
1273
1274    let cofactor00 = e * i - f * h;
1275    let cofactor01 = -(d * i - f * g);
1276    let cofactor02 = d * h - e * g;
1277    let cofactor10 = -(b * i - c * h);
1278    let cofactor11 = a * i - c * g;
1279    let cofactor12 = -(a * h - b * g);
1280    let cofactor20 = b * f - c * e;
1281    let cofactor21 = -(a * f - c * d);
1282    let cofactor22 = a * e - b * d;
1283
1284    let determinant = a * cofactor00 + b * cofactor01 + c * cofactor02;
1285    let inv_det = 1.0 / determinant;
1286
1287    [
1288        [
1289            cofactor00 * inv_det,
1290            cofactor10 * inv_det,
1291            cofactor20 * inv_det,
1292        ],
1293        [
1294            cofactor01 * inv_det,
1295            cofactor11 * inv_det,
1296            cofactor21 * inv_det,
1297        ],
1298        [
1299            cofactor02 * inv_det,
1300            cofactor12 * inv_det,
1301            cofactor22 * inv_det,
1302        ],
1303    ]
1304}
1305
1306// CIE chromaticity transforms.
1307
1308pub fn xyz_to_yxy(xyz: [f64; 3]) -> [f64; 3] {
1309    let sum = xyz[0] + xyz[1] + xyz[2];
1310    let denominator = nonzero(sum);
1311    [xyz[1], xyz[0] / denominator, xyz[1] / denominator]
1312}
1313
1314pub fn yxy_to_xyz(yxy: [f64; 3]) -> [f64; 3] {
1315    let y = nonzero(yxy[2]);
1316    [
1317        yxy[0] * yxy[1] / y,
1318        yxy[0],
1319        yxy[0] * (1.0 - yxy[1] - yxy[2]) / y,
1320    ]
1321}
1322
1323pub fn xyz_to_yuv(xyz: [f64; 3]) -> [f64; 3] {
1324    let denominator = xyz[0] + 15.0 * xyz[1] + 3.0 * xyz[2];
1325    let denominator = nonzero(denominator);
1326    [
1327        xyz[1],
1328        4.0 * xyz[0] / denominator,
1329        9.0 * xyz[1] / denominator,
1330    ]
1331}
1332
1333pub fn yuv_to_xyz(yuv: [f64; 3]) -> [f64; 3] {
1334    let v = nonzero(yuv[2]);
1335    [
1336        yuv[0] * (9.0 * yuv[1]) / (4.0 * v),
1337        yuv[0],
1338        yuv[0] * (12.0 - 3.0 * yuv[1] - 20.0 * yuv[2]) / (4.0 * v),
1339    ]
1340}
1341
1342// LMS transforms.
1343
1344pub fn xyz_to_lms_with_matrix(xyz: [f64; 3], matrix: Matrix3) -> [f64; 3] {
1345    multiply_matrix3_vector3(matrix, xyz)
1346}
1347
1348pub fn lms_to_xyz_with_matrix(lms: [f64; 3], matrix: Matrix3) -> [f64; 3] {
1349    multiply_matrix3_vector3(invert_matrix3(matrix), lms)
1350}
1351
1352pub fn xyz_to_lms(xyz: [f64; 3], observer: Observer) -> LuxResult<[f64; 3]> {
1353    Ok(xyz_to_lms_with_matrix(xyz, observer.xyz_to_lms_matrix()?))
1354}
1355
1356pub fn lms_to_xyz(lms: [f64; 3], observer: Observer) -> LuxResult<[f64; 3]> {
1357    Ok(lms_to_xyz_with_matrix(lms, observer.xyz_to_lms_matrix()?))
1358}
1359
1360// Chromatic adaptation.
1361
1362pub fn cat_apply(
1363    xyz: [f64; 3],
1364    source_white: [f64; 3],
1365    target_white: [f64; 3],
1366    transform: CatTransform,
1367    degree_of_adaptation: f64,
1368) -> LuxResult<[f64; 3]> {
1369    cat_compile(source_white, target_white, transform, degree_of_adaptation)?.apply(xyz)
1370}
1371
1372pub fn cat_apply_mode(
1373    xyz: [f64; 3],
1374    source_white: [f64; 3],
1375    target_white: [f64; 3],
1376    baseline_white: Option<[f64; 3]>,
1377    transform: CatTransform,
1378    mode: CatMode,
1379    degrees_of_adaptation: [f64; 2],
1380) -> LuxResult<[f64; 3]> {
1381    cat_compile_mode(
1382        source_white,
1383        target_white,
1384        baseline_white,
1385        transform,
1386        mode,
1387        degrees_of_adaptation,
1388    )?
1389    .apply(xyz)
1390}
1391
1392pub fn cat_compile(
1393    source_white: [f64; 3],
1394    target_white: [f64; 3],
1395    transform: CatTransform,
1396    degree_of_adaptation: f64,
1397) -> LuxResult<CatAdapter> {
1398    CatAdapter::from_degree(source_white, target_white, transform, degree_of_adaptation)
1399}
1400
1401pub fn cat_compile_mode(
1402    source_white: [f64; 3],
1403    target_white: [f64; 3],
1404    baseline_white: Option<[f64; 3]>,
1405    transform: CatTransform,
1406    mode: CatMode,
1407    degrees_of_adaptation: [f64; 2],
1408) -> LuxResult<CatAdapter> {
1409    CatAdapter::from_mode(
1410        source_white,
1411        target_white,
1412        baseline_white,
1413        transform,
1414        mode,
1415        degrees_of_adaptation,
1416    )
1417}
1418
1419pub fn cat_degree_of_adaptation(surround: CatSurround, adapting_luminance: f64) -> LuxResult<f64> {
1420    validate_adapting_luminance(adapting_luminance)?;
1421
1422    let factor = surround.factor();
1423    let degree = factor * (1.0 - (1.0 / 3.6) * ((-adapting_luminance - 42.0) / 92.0).exp());
1424    Ok(clamp(degree, 0.0, 1.0))
1425}
1426
1427pub fn cat_mode_degrees_from_conditions(
1428    mode: CatMode,
1429    source_conditions: CatViewingConditions,
1430    target_conditions: CatViewingConditions,
1431) -> LuxResult<[f64; 2]> {
1432    let source_degree = source_conditions.degree_of_adaptation()?;
1433    let target_degree = target_conditions.degree_of_adaptation()?;
1434
1435    Ok(match mode {
1436        CatMode::OneStep | CatMode::SourceToBaseline => [source_degree, source_degree],
1437        CatMode::BaselineToTarget => [target_degree, target_degree],
1438        CatMode::TwoStep => [source_degree, target_degree],
1439    })
1440}
1441
1442pub fn cat_apply_with_conditions(
1443    xyz: [f64; 3],
1444    source_white: [f64; 3],
1445    target_white: [f64; 3],
1446    transform: CatTransform,
1447    surround: CatSurround,
1448    adapting_luminance: f64,
1449) -> LuxResult<[f64; 3]> {
1450    cat_compile_with_conditions(
1451        source_white,
1452        target_white,
1453        transform,
1454        surround,
1455        adapting_luminance,
1456    )?
1457    .apply(xyz)
1458}
1459
1460pub fn cat_apply_mode_with_conditions(
1461    xyz: [f64; 3],
1462    source_white: [f64; 3],
1463    target_white: [f64; 3],
1464    baseline_white: Option<[f64; 3]>,
1465    transform: CatTransform,
1466    mode: CatMode,
1467    conditions: CatConditionPair,
1468) -> LuxResult<[f64; 3]> {
1469    cat_compile_mode_with_conditions(
1470        source_white,
1471        target_white,
1472        baseline_white,
1473        transform,
1474        mode,
1475        conditions.source,
1476        conditions.target,
1477    )
1478    .and_then(|adapter| adapter.apply(xyz))
1479}
1480
1481pub fn cat_apply_context(xyz: [f64; 3], context: CatContext) -> LuxResult<[f64; 3]> {
1482    cat_compile_context(context)?.apply(xyz)
1483}
1484
1485pub fn cat_compile_with_conditions(
1486    source_white: [f64; 3],
1487    target_white: [f64; 3],
1488    transform: CatTransform,
1489    surround: CatSurround,
1490    adapting_luminance: f64,
1491) -> LuxResult<CatAdapter> {
1492    let degree = cat_degree_of_adaptation(surround, adapting_luminance)?;
1493    cat_compile(source_white, target_white, transform, degree)
1494}
1495
1496pub fn cat_compile_mode_with_conditions(
1497    source_white: [f64; 3],
1498    target_white: [f64; 3],
1499    baseline_white: Option<[f64; 3]>,
1500    transform: CatTransform,
1501    mode: CatMode,
1502    source_conditions: CatViewingConditions,
1503    target_conditions: CatViewingConditions,
1504) -> LuxResult<CatAdapter> {
1505    CatAdapter::from_conditions(
1506        source_white,
1507        target_white,
1508        baseline_white,
1509        transform,
1510        mode,
1511        source_conditions,
1512        target_conditions,
1513    )
1514}
1515
1516pub fn cat_compile_context(context: CatContext) -> LuxResult<CatAdapter> {
1517    CatAdapter::from_context(context)
1518}
1519
1520// RGB color spaces.
1521//
1522// An RGB color space is the combination of two things:
1523//   1. primaries + white point  ->  the linear RGB <-> XYZ matrix
1524//   2. a transfer function (EOTF/OETF)  ->  the encoding of linear light
1525//
1526// `RgbColorSpace` bundles a precomputed forward/inverse matrix with a
1527// `TransferFunction`; `rgb_to_xyz` / `xyz_to_rgb` apply the transfer function
1528// per channel around the matrix multiply. Encoded RGB is normalized to [0, 1]
1529// and XYZ uses the 0-100 scale (white = 100) used elsewhere in this module.
1530
1531/// CIE Standard Illuminant D65 chromaticity (xy).
1532pub const D65_XY: [f64; 2] = [0.3127, 0.3290];
1533/// CIE Standard Illuminant D65 tristimulus (XYZ, Y normalized to 100).
1534pub const D65_XYZ: [f64; 3] = [95.047, 100.0, 108.883];
1535
1536/// sRGB / BT.709 primaries as CIE xy chromaticities: `[red, green, blue]`.
1537pub const SRGB_PRIMARIES: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
1538/// Display P3 primaries as CIE xy chromaticities: `[red, green, blue]`.
1539pub const DISPLAY_P3_PRIMARIES: [[f64; 2]; 3] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
1540/// BT.2020 / Rec.2100 primaries as CIE xy chromaticities: `[red, green, blue]`.
1541pub const BT2020_PRIMARIES: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
1542
1543/// Electro-optical transfer function describing how an encoded RGB signal maps
1544/// to linear light. Each variant defines an `eotf` (encoded -> linear) and its
1545/// analytic inverse `oetf` (linear -> encoded); the pair is exact so that
1546/// `rgb_to_xyz` / `xyz_to_rgb` round-trip.
1547#[derive(Debug, Clone, Copy, PartialEq)]
1548pub enum TransferFunction {
1549    /// sRGB / Display P3 piecewise transfer function (IEC 61966-2-1).
1550    SRgb,
1551    /// Parameterized sRGB-family piecewise transfer function used by the legacy
1552    /// `srgb_to_xyz` / `xyz_to_srgb`: `value = ((E - offset) / (1 - offset)) ^
1553    /// gamma`, with a linear toe `E / 12.92` when `linear_part` is set.
1554    SegmentedGamma {
1555        gamma: f64,
1556        offset: f64,
1557        linear_part: bool,
1558    },
1559    /// Pure power law `linear = encoded ^ gamma` (e.g. Adobe RGB gamma 2.2).
1560    Gamma(f64),
1561    /// Identity transfer (values are already linear).
1562    Linear,
1563    /// SMPTE ST 2084 (PQ) perceptual quantizer. Linear 1.0 is the 10000 cd/m²
1564    /// peak.
1565    Pq,
1566    /// ARIB STD-B67 / Rec.2100 HLG. `peak_luminance` (cd/m²) sets the display
1567    /// system gamma; linear 1.0 is the peak white.
1568    Hlg { peak_luminance: f64 },
1569}
1570
1571impl TransferFunction {
1572    /// Encoded signal value -> linear light (both in [0, 1] for sRGB/P3/gamma;
1573    /// PQ/HLG output is normalized so 1.0 is the display peak).
1574    pub fn eotf(self, encoded: f64) -> f64 {
1575        match self {
1576            TransferFunction::SRgb => segmented_gamma_eotf(encoded, 2.4, -0.055, true),
1577            TransferFunction::SegmentedGamma {
1578                gamma,
1579                offset,
1580                linear_part,
1581            } => segmented_gamma_eotf(encoded, gamma, offset, linear_part),
1582            TransferFunction::Gamma(gamma) => encoded.powf(gamma),
1583            TransferFunction::Linear => encoded,
1584            TransferFunction::Pq => pq_eotf(encoded),
1585            TransferFunction::Hlg { peak_luminance } => hlg_eotf(encoded, peak_luminance),
1586        }
1587    }
1588
1589    /// Linear light -> encoded signal value (analytic inverse of `eotf`).
1590    pub fn oetf(self, linear: f64) -> f64 {
1591        match self {
1592            TransferFunction::SRgb => segmented_gamma_oetf(linear, 2.4, -0.055, true),
1593            TransferFunction::SegmentedGamma {
1594                gamma,
1595                offset,
1596                linear_part,
1597            } => segmented_gamma_oetf(linear, gamma, offset, linear_part),
1598            TransferFunction::Gamma(gamma) => linear.powf(1.0 / gamma),
1599            TransferFunction::Linear => linear,
1600            TransferFunction::Pq => pq_oetf(linear),
1601            TransferFunction::Hlg { peak_luminance } => hlg_oetf(linear, peak_luminance),
1602        }
1603    }
1604}
1605
1606fn segmented_gamma_eotf(encoded: f64, gamma: f64, offset: f64, linear_part: bool) -> f64 {
1607    let mut value = ((encoded - offset) / (1.0 - offset)).powf(gamma);
1608    if linear_part && value < 0.0031308 {
1609        value = encoded / 12.92;
1610    }
1611    value
1612}
1613
1614fn segmented_gamma_oetf(linear: f64, gamma: f64, offset: f64, linear_part: bool) -> f64 {
1615    let clamped = clamp(linear, 0.0, 1.0);
1616    let mut encoded = (1.0 - offset) * clamped.powf(1.0 / gamma) + offset;
1617    if linear_part && clamped <= 0.0031308 {
1618        encoded = clamped * 12.92;
1619    }
1620    encoded
1621}
1622
1623fn pq_constants() -> (f64, f64, f64, f64, f64) {
1624    (0.1593017578125, 78.84375, 0.8359375, 18.8515625, 18.6875)
1625}
1626
1627fn pq_eotf(encoded: f64) -> f64 {
1628    let (m1, m2, c1, c2, c3) = pq_constants();
1629    let em = encoded.powf(1.0 / m2);
1630    let num = (em - c1).max(0.0);
1631    let den = c2 - c3 * em;
1632    (num / den).powf(1.0 / m1)
1633}
1634
1635fn pq_oetf(linear: f64) -> f64 {
1636    let (m1, m2, c1, c2, c3) = pq_constants();
1637    let lm = linear.powf(m1);
1638    ((c1 + c2 * lm) / (1.0 + c3 * lm)).powf(m2)
1639}
1640
1641const HLG_A: f64 = 0.17883277;
1642const HLG_B: f64 = 0.28466892;
1643const HLG_C: f64 = 0.55991073;
1644
1645fn hlg_system_gamma(peak_luminance: f64) -> f64 {
1646    1.2 + 0.42 * (peak_luminance / 1000.0).log10()
1647}
1648
1649fn hlg_oetf_inverse(encoded: f64) -> f64 {
1650    if encoded <= 0.5 {
1651        encoded * encoded / 3.0
1652    } else {
1653        (((encoded - HLG_C) / HLG_A).exp() + HLG_B) / 12.0
1654    }
1655}
1656
1657fn hlg_oetf_scene(scene: f64) -> f64 {
1658    if scene <= 1.0 / 12.0 {
1659        (3.0 * scene).sqrt()
1660    } else {
1661        HLG_A * (12.0 * scene - HLG_B).ln() + HLG_C
1662    }
1663}
1664
1665fn hlg_eotf(encoded: f64, peak_luminance: f64) -> f64 {
1666    let gamma = hlg_system_gamma(peak_luminance);
1667    hlg_oetf_inverse(encoded).powf(gamma)
1668}
1669
1670fn hlg_oetf(linear: f64, peak_luminance: f64) -> f64 {
1671    let gamma = hlg_system_gamma(peak_luminance);
1672    hlg_oetf_scene(linear.powf(1.0 / gamma))
1673}
1674
1675/// Derive the linear RGB -> XYZ matrix (white normalized to Y = 1) from CIE xy
1676/// primaries and white point. Columns are each primary's XYZ tristimulus scaled
1677/// so that equal unit RGB sums to the white point.
1678fn primaries_to_matrix(primaries: [[f64; 2]; 3], white_xy: [f64; 2]) -> Matrix3 {
1679    let columns: [[f64; 3]; 3] = primaries.map(|[x, y]| [x / y, 1.0, (1.0 - x - y) / y]);
1680    let p: Matrix3 = [
1681        [columns[0][0], columns[1][0], columns[2][0]],
1682        [columns[0][1], columns[1][1], columns[2][1]],
1683        [columns[0][2], columns[1][2], columns[2][2]],
1684    ];
1685    let [wx, wy] = white_xy;
1686    let white = [wx / wy, 1.0, (1.0 - wx - wy) / wy];
1687    let scale = multiply_matrix3_vector3(invert_matrix3(p), white);
1688    [
1689        [p[0][0] * scale[0], p[0][1] * scale[1], p[0][2] * scale[2]],
1690        [p[1][0] * scale[0], p[1][1] * scale[1], p[1][2] * scale[2]],
1691        [p[2][0] * scale[0], p[2][1] * scale[1], p[2][2] * scale[2]],
1692    ]
1693}
1694
1695/// An RGB color space: a precomputed linear RGB <-> XYZ matrix pair plus the
1696/// transfer function mapping encoded RGB to/from linear light.
1697///
1698/// Use a preset ([`srgb_space`], [`display_p3_space`], [`rec2100_pq_space`],
1699/// [`rec2100_hlg_space`]) or build a custom one with [`RgbColorSpace::from_primaries`].
1700///
1701/// # Example
1702///
1703/// ```
1704/// use lux_rs::{display_p3_space, rgb_to_xyz, xyz_to_yxy};
1705///
1706/// let space = display_p3_space();
1707/// // Pure red maps to the Display P3 red-primary chromaticity (0.68, 0.32).
1708/// let yxy = xyz_to_yxy(rgb_to_xyz([1.0, 0.0, 0.0], space));
1709/// assert!((yxy[1] - 0.680).abs() < 1e-9);
1710/// assert!((yxy[2] - 0.320).abs() < 1e-9);
1711/// ```
1712#[derive(Debug, Clone, Copy, PartialEq)]
1713pub struct RgbColorSpace {
1714    name: &'static str,
1715    rgb_to_xyz: Matrix3,
1716    xyz_to_rgb: Matrix3,
1717    transfer: TransferFunction,
1718}
1719
1720impl RgbColorSpace {
1721    /// Construct a color space by deriving the linear matrices from CIE xy
1722    /// primaries and white point.
1723    ///
1724    /// # Example
1725    ///
1726    /// ```
1727    /// use lux_rs::{rgb_to_xyz, xyz_to_yxy, D65_XY, RgbColorSpace, TransferFunction};
1728    ///
1729    /// // Adobe RGB (1998): D65 white, simple gamma 2.2 transfer.
1730    /// let adobe = RgbColorSpace::from_primaries(
1731    ///     "adobe-rgb",
1732    ///     [[0.640, 0.330], [0.210, 0.710], [0.150, 0.060]],
1733    ///     D65_XY,
1734    ///     TransferFunction::Gamma(2.2),
1735    /// );
1736    /// let yxy = xyz_to_yxy(rgb_to_xyz([0.0, 1.0, 0.0], adobe));
1737    /// assert!((yxy[1] - 0.210).abs() < 1e-9);
1738    /// assert!((yxy[2] - 0.710).abs() < 1e-9);
1739    /// ```
1740    pub fn from_primaries(
1741        name: &'static str,
1742        primaries: [[f64; 2]; 3],
1743        white_xy: [f64; 2],
1744        transfer: TransferFunction,
1745    ) -> Self {
1746        let rgb_to_xyz = primaries_to_matrix(primaries, white_xy);
1747        let xyz_to_rgb = invert_matrix3(rgb_to_xyz);
1748        Self {
1749            name,
1750            rgb_to_xyz,
1751            xyz_to_rgb,
1752            transfer,
1753        }
1754    }
1755
1756    /// Name of this color space (e.g. "display-p3").
1757    pub fn name(self) -> &'static str {
1758        self.name
1759    }
1760
1761    /// Transfer function used by this color space.
1762    pub fn transfer(self) -> TransferFunction {
1763        self.transfer
1764    }
1765
1766    /// Forward linear RGB -> XYZ matrix (white normalized to Y = 1).
1767    pub fn rgb_to_xyz_matrix(self) -> Matrix3 {
1768        self.rgb_to_xyz
1769    }
1770}
1771
1772/// sRGB color space (BT.709 primaries, D65 white, sRGB transfer function).
1773pub fn srgb_space() -> RgbColorSpace {
1774    RgbColorSpace::from_primaries("srgb", SRGB_PRIMARIES, D65_XY, TransferFunction::SRgb)
1775}
1776
1777/// Display P3 color space (P3 primaries, D65 white, sRGB transfer function).
1778pub fn display_p3_space() -> RgbColorSpace {
1779    RgbColorSpace::from_primaries(
1780        "display-p3",
1781        DISPLAY_P3_PRIMARIES,
1782        D65_XY,
1783        TransferFunction::SRgb,
1784    )
1785}
1786
1787/// Rec.2100 PQ color space (BT.2020 primaries, D65 white, SMPTE ST 2084 transfer).
1788pub fn rec2100_pq_space() -> RgbColorSpace {
1789    RgbColorSpace::from_primaries(
1790        "rec2100-pq",
1791        BT2020_PRIMARIES,
1792        D65_XY,
1793        TransferFunction::Pq,
1794    )
1795}
1796
1797/// Rec.2100 HLG color space (BT.2020 primaries, D65 white, ARIB STD-B67 transfer,
1798/// 1000 cd/m² peak).
1799pub fn rec2100_hlg_space() -> RgbColorSpace {
1800    RgbColorSpace::from_primaries(
1801        "rec2100-hlg",
1802        BT2020_PRIMARIES,
1803        D65_XY,
1804        TransferFunction::Hlg {
1805            peak_luminance: 1000.0,
1806        },
1807    )
1808}
1809
1810/// Convert encoded RGB (normalized [0, 1]) in `space` to CIE XYZ (0-100 scale,
1811/// white = 100), applying the space's transfer function and linear matrix.
1812///
1813/// # Example
1814///
1815/// ```
1816/// use lux_rs::{rgb_to_xyz, srgb_space};
1817///
1818/// // sRGB white (1, 1, 1) maps to the D65 white point at Y = 100.
1819/// let [_, y, _] = rgb_to_xyz([1.0, 1.0, 1.0], srgb_space());
1820/// assert!((y - 100.0).abs() < 1e-9);
1821/// ```
1822pub fn rgb_to_xyz(rgb: [f64; 3], space: RgbColorSpace) -> [f64; 3] {
1823    let linear = [
1824        space.transfer.eotf(rgb[0]),
1825        space.transfer.eotf(rgb[1]),
1826        space.transfer.eotf(rgb[2]),
1827    ];
1828    let xyz = multiply_matrix3_vector3(space.rgb_to_xyz, linear);
1829    [xyz[0] * 100.0, xyz[1] * 100.0, xyz[2] * 100.0]
1830}
1831
1832/// Convert CIE XYZ (0-100 scale, white = 100) to encoded RGB (normalized [0, 1])
1833/// in `space`. Out-of-gamut values are returned as-is (may fall outside [0, 1]);
1834/// callers may clamp for display encoding.
1835///
1836/// `xyz_to_rgb` is the exact inverse of [`rgb_to_xyz`], so the pair round-trips.
1837///
1838/// # Example
1839///
1840/// ```
1841/// use lux_rs::{display_p3_space, rgb_to_xyz, xyz_to_rgb};
1842///
1843/// let space = display_p3_space();
1844/// let rgb = [0.3, 0.6, 0.9];
1845/// let back = xyz_to_rgb(rgb_to_xyz(rgb, space), space);
1846/// for channel in 0..3 {
1847///     assert!((back[channel] - rgb[channel]).abs() < 1e-9);
1848/// }
1849/// ```
1850pub fn xyz_to_rgb(xyz: [f64; 3], space: RgbColorSpace) -> [f64; 3] {
1851    let linear = multiply_matrix3_vector3(
1852        space.xyz_to_rgb,
1853        [xyz[0] / 100.0, xyz[1] / 100.0, xyz[2] / 100.0],
1854    );
1855    [
1856        space.transfer.oetf(linear[0]),
1857        space.transfer.oetf(linear[1]),
1858        space.transfer.oetf(linear[2]),
1859    ]
1860}
1861
1862// sRGB transforms.
1863
1864// These legacy entry points keep the original 0-255 API and parameterized
1865// piecewise transfer function, but delegate to the generic `rgb_to_xyz` /
1866// `xyz_to_rgb` machinery using the fixed sRGB matrices.
1867
1868fn srgb_parameterized_space(gamma: f64, offset: f64, linear_part: bool) -> RgbColorSpace {
1869    RgbColorSpace {
1870        name: "srgb-parameterized",
1871        rgb_to_xyz: SRGB_RGB_TO_XYZ,
1872        xyz_to_rgb: SRGB_XYZ_TO_RGB,
1873        transfer: TransferFunction::SegmentedGamma {
1874            gamma,
1875            offset,
1876            linear_part,
1877        },
1878    }
1879}
1880
1881pub fn xyz_to_srgb(xyz: [f64; 3], gamma: f64, offset: f64, use_linear_part: bool) -> [f64; 3] {
1882    let space = srgb_parameterized_space(gamma, offset, use_linear_part);
1883    let rgb = xyz_to_rgb(xyz, space);
1884    [
1885        clamp(rgb[0] * 255.0, 0.0, 255.0),
1886        clamp(rgb[1] * 255.0, 0.0, 255.0),
1887        clamp(rgb[2] * 255.0, 0.0, 255.0),
1888    ]
1889}
1890
1891pub fn srgb_to_xyz(rgb: [f64; 3], gamma: f64, offset: f64, use_linear_part: bool) -> [f64; 3] {
1892    let space = srgb_parameterized_space(gamma, offset, use_linear_part);
1893    rgb_to_xyz([rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0], space)
1894}
1895
1896// CIE perceptual color spaces with explicit white point input.
1897
1898pub fn xyz_to_lab(xyz: [f64; 3], white_point: [f64; 3]) -> [f64; 3] {
1899    let fx = lab_response_curve(xyz[0], white_point[0]);
1900    let fy = lab_response_curve(xyz[1], white_point[1]);
1901    let fz = lab_response_curve(xyz[2], white_point[2]);
1902    let l = cie_lightness_from_ratio(xyz[1] / white_point[1]);
1903
1904    [l, 500.0 * (fx - fy), 200.0 * (fy - fz)]
1905}
1906
1907pub fn lab_to_xyz(lab: [f64; 3], white_point: [f64; 3]) -> [f64; 3] {
1908    let fy = (lab[0] + 16.0) / 116.0;
1909    let fx = lab[1] / 500.0 + fy;
1910    let fz = fy - lab[2] / 200.0;
1911
1912    [
1913        lab_inverse_response_curve(fx, white_point[0]),
1914        lab_inverse_response_curve(fy, white_point[1]),
1915        lab_inverse_response_curve(fz, white_point[2]),
1916    ]
1917}
1918
1919/// Convert CIE XYZ (0-100 scale) to OKLab (`L` in 0-1).
1920///
1921/// OKLab is defined relative to the D65 white point. No chromatic adaptation is
1922/// performed, so XYZ values using another white point should be adapted first.
1923pub fn xyz_to_oklab(xyz: [f64; 3]) -> [f64; 3] {
1924    let x = xyz[0] / 100.0;
1925    let y = xyz[1] / 100.0;
1926    let z = xyz[2] / 100.0;
1927
1928    let l = (0.819_022_437_996_703 * x
1929        + 0.361_906_260_052_890_4 * y
1930        - 0.128_873_781_520_987_9 * z)
1931        .cbrt();
1932    let m = (0.032_983_653_932_388_5 * x
1933        + 0.929_286_861_586_343_4 * y
1934        + 0.036_144_666_350_642_4 * z)
1935        .cbrt();
1936    let s = (0.048_177_189_359_624_2 * x
1937        + 0.264_239_531_752_730_8 * y
1938        + 0.633_547_828_469_430_9 * z)
1939        .cbrt();
1940
1941    [
1942        0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s,
1943        1.977_998_495_1 * l - 2.428_592_205 * m + 0.450_593_709_9 * s,
1944        0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766 * s,
1945    ]
1946}
1947
1948/// Convert OKLab (`L` in 0-1) to CIE XYZ (0-100 scale, D65 white point).
1949pub fn oklab_to_xyz(oklab: [f64; 3]) -> [f64; 3] {
1950    let l = (oklab[0] + 0.396_337_777_4 * oklab[1] + 0.215_803_757_3 * oklab[2]).powi(3);
1951    let m = (oklab[0] - 0.105_561_345_8 * oklab[1] - 0.063_854_172_8 * oklab[2]).powi(3);
1952    let s = (oklab[0] - 0.089_484_177_5 * oklab[1] - 1.291_485_548 * oklab[2]).powi(3);
1953
1954    [
1955        (1.226_879_873_374_155_7 * l
1956            - 0.557_814_996_555_481_3 * m
1957            + 0.281_391_050_177_215_83 * s)
1958            * 100.0,
1959        (-0.040_575_762_624_313_72 * l
1960            + 1.112_286_829_397_059_4 * m
1961            - 0.071_711_066_661_517_01 * s)
1962            * 100.0,
1963        (-0.076_372_949_746_721_42 * l
1964            - 0.421_493_323_962_791_4 * m
1965            + 1.586_924_024_427_242_2 * s)
1966            * 100.0,
1967    ]
1968}
1969
1970/// Convert OKLab to cylindrical OKLCH. Hue is returned in degrees in `[0, 360)`.
1971pub fn oklab_to_oklch(oklab: [f64; 3]) -> [f64; 3] {
1972    let chroma = oklab[1].hypot(oklab[2]);
1973    let hue = oklab[2].atan2(oklab[1]).to_degrees().rem_euclid(360.0);
1974    [oklab[0], chroma, hue]
1975}
1976
1977/// Convert cylindrical OKLCH (hue in degrees) to OKLab.
1978pub fn oklch_to_oklab(oklch: [f64; 3]) -> [f64; 3] {
1979    let hue = oklch[2].to_radians();
1980    [oklch[0], oklch[1] * hue.cos(), oklch[1] * hue.sin()]
1981}
1982
1983/// Convert CIE XYZ (0-100 scale, D65 white point) directly to OKLCH.
1984pub fn xyz_to_oklch(xyz: [f64; 3]) -> [f64; 3] {
1985    oklab_to_oklch(xyz_to_oklab(xyz))
1986}
1987
1988/// Convert OKLCH directly to CIE XYZ (0-100 scale, D65 white point).
1989pub fn oklch_to_xyz(oklch: [f64; 3]) -> [f64; 3] {
1990    oklab_to_xyz(oklch_to_oklab(oklch))
1991}
1992
1993pub fn xyz_to_luv(xyz: [f64; 3], white_point: [f64; 3]) -> [f64; 3] {
1994    let yuv = xyz_to_yuv(xyz);
1995    let white_yuv = xyz_to_yuv(white_point);
1996    let y_ratio = yuv[0] / white_yuv[0];
1997    let l = cie_lightness_from_ratio(y_ratio);
1998
1999    [
2000        l,
2001        13.0 * l * (yuv[1] - white_yuv[1]),
2002        13.0 * l * (yuv[2] - white_yuv[2]),
2003    ]
2004}
2005
2006pub fn luv_to_xyz(luv: [f64; 3], white_point: [f64; 3]) -> [f64; 3] {
2007    let white_yuv = xyz_to_yuv(white_point);
2008    let mut yuv = [0.0; 3];
2009    if luv[0] == 0.0 {
2010        yuv[1] = 0.0;
2011        yuv[2] = 0.0;
2012    } else {
2013        yuv[1] = luv[1] / (13.0 * luv[0]) + white_yuv[1];
2014        yuv[2] = luv[2] / (13.0 * luv[0]) + white_yuv[2];
2015    }
2016
2017    yuv[0] = white_yuv[0] * cie_y_ratio_from_lightness(luv[0]);
2018
2019    yuv_to_xyz(yuv)
2020}
2021
2022// Color difference.
2023
2024fn delta_e_lab(lab1: [f64; 3], lab2: [f64; 3], formula: DeltaEFormula) -> f64 {
2025    match formula {
2026        DeltaEFormula::Cie76 => delta_e_cie76_lab(lab1, lab2),
2027        DeltaEFormula::Ciede2000 => delta_e_ciede2000_lab(lab1, lab2),
2028    }
2029}
2030
2031pub fn delta_e(
2032    xyz1: [f64; 3],
2033    xyz2: [f64; 3],
2034    white_point: [f64; 3],
2035    formula: DeltaEFormula,
2036) -> f64 {
2037    delta_e_lab(
2038        xyz_to_lab(xyz1, white_point),
2039        xyz_to_lab(xyz2, white_point),
2040        formula,
2041    )
2042}
2043
2044pub fn delta_e_cie76(xyz1: [f64; 3], xyz2: [f64; 3], white_point: [f64; 3]) -> f64 {
2045    delta_e(xyz1, xyz2, white_point, DeltaEFormula::Cie76)
2046}
2047
2048pub fn delta_e_ciede2000(xyz1: [f64; 3], xyz2: [f64; 3], white_point: [f64; 3]) -> f64 {
2049    delta_e(xyz1, xyz2, white_point, DeltaEFormula::Ciede2000)
2050}
2051
2052fn delta_e_cie76_lab(lab1: [f64; 3], lab2: [f64; 3]) -> f64 {
2053    let dl = lab1[0] - lab2[0];
2054    let da = lab1[1] - lab2[1];
2055    let db = lab1[2] - lab2[2];
2056    (dl * dl + da * da + db * db).sqrt()
2057}
2058
2059fn delta_e_ciede2000_lab(lab1: [f64; 3], lab2: [f64; 3]) -> f64 {
2060    let (l1, a1, b1) = (lab1[0], lab1[1], lab1[2]);
2061    let (l2, a2, b2) = (lab2[0], lab2[1], lab2[2]);
2062
2063    let c1 = (a1 * a1 + b1 * b1).sqrt();
2064    let c2 = (a2 * a2 + b2 * b2).sqrt();
2065    let c_bar = (c1 + c2) / 2.0;
2066    let c_bar7 = c_bar.powi(7);
2067    let g = 0.5 * (1.0 - (c_bar7 / (c_bar7 + 25_f64.powi(7))).sqrt());
2068
2069    let a1_prime = (1.0 + g) * a1;
2070    let a2_prime = (1.0 + g) * a2;
2071    let c1_prime = (a1_prime * a1_prime + b1 * b1).sqrt();
2072    let c2_prime = (a2_prime * a2_prime + b2 * b2).sqrt();
2073    let h1_prime = if c1_prime == 0.0 {
2074        0.0
2075    } else {
2076        hue_angle_degrees(a1_prime, b1)
2077    };
2078    let h2_prime = if c2_prime == 0.0 {
2079        0.0
2080    } else {
2081        hue_angle_degrees(a2_prime, b2)
2082    };
2083
2084    let delta_l_prime = l2 - l1;
2085    let delta_c_prime = c2_prime - c1_prime;
2086
2087    let delta_h_prime = if c1_prime == 0.0 || c2_prime == 0.0 {
2088        0.0
2089    } else {
2090        let mut delta = h2_prime - h1_prime;
2091        if delta > 180.0 {
2092            delta -= 360.0;
2093        } else if delta < -180.0 {
2094            delta += 360.0;
2095        }
2096        delta
2097    };
2098    let delta_big_h_prime =
2099        2.0 * (c1_prime * c2_prime).sqrt() * degrees_to_radians(delta_h_prime / 2.0).sin();
2100
2101    let l_bar_prime = (l1 + l2) / 2.0;
2102    let c_bar_prime = (c1_prime + c2_prime) / 2.0;
2103    let h_bar_prime = if c1_prime == 0.0 || c2_prime == 0.0 {
2104        h1_prime + h2_prime
2105    } else if (h1_prime - h2_prime).abs() > 180.0 {
2106        if h1_prime + h2_prime < 360.0 {
2107            (h1_prime + h2_prime + 360.0) / 2.0
2108        } else {
2109            (h1_prime + h2_prime - 360.0) / 2.0
2110        }
2111    } else {
2112        (h1_prime + h2_prime) / 2.0
2113    };
2114
2115    let t = 1.0 - 0.17 * degrees_to_radians(h_bar_prime - 30.0).cos()
2116        + 0.24 * degrees_to_radians(2.0 * h_bar_prime).cos()
2117        + 0.32 * degrees_to_radians(3.0 * h_bar_prime + 6.0).cos()
2118        - 0.20 * degrees_to_radians(4.0 * h_bar_prime - 63.0).cos();
2119    let delta_theta = 30.0 * (-(((h_bar_prime - 275.0) / 25.0).powi(2))).exp();
2120    let c_bar_prime7 = c_bar_prime.powi(7);
2121    let r_c = 2.0 * (c_bar_prime7 / (c_bar_prime7 + 25_f64.powi(7))).sqrt();
2122    let s_l =
2123        1.0 + (0.015 * (l_bar_prime - 50.0).powi(2)) / (20.0 + (l_bar_prime - 50.0).powi(2)).sqrt();
2124    let s_c = 1.0 + 0.045 * c_bar_prime;
2125    let s_h = 1.0 + 0.015 * c_bar_prime * t;
2126    let r_t = -degrees_to_radians(2.0 * delta_theta).sin() * r_c;
2127
2128    let l_term = delta_l_prime / s_l;
2129    let c_term = delta_c_prime / s_c;
2130    let h_term = delta_big_h_prime / s_h;
2131
2132    (l_term * l_term + c_term * c_term + h_term * h_term + r_t * c_term * h_term).sqrt()
2133}
2134
2135pub fn get_cie_mesopic_adaptation(
2136    photopic_luminance: &[f64],
2137    scotopic_luminance: Option<&[f64]>,
2138    s_p_ratio: Option<&[f64]>,
2139) -> LuxResult<(Vec<f64>, Vec<f64>)> {
2140    if photopic_luminance.is_empty() {
2141        return Err(LuxError::EmptyInput);
2142    }
2143    if scotopic_luminance.is_some() == s_p_ratio.is_some() {
2144        return Err(LuxError::InvalidInput(
2145            "provide exactly one of scotopic_luminance or s_p_ratio",
2146        ));
2147    }
2148
2149    let len = photopic_luminance.len();
2150    if let Some(ls) = scotopic_luminance {
2151        if ls.len() != len {
2152            return Err(LuxError::MismatchedLengths {
2153                wavelengths: len,
2154                values: ls.len(),
2155            });
2156        }
2157    }
2158    if let Some(sp) = s_p_ratio {
2159        if sp.len() != len {
2160            return Err(LuxError::MismatchedLengths {
2161                wavelengths: len,
2162                values: sp.len(),
2163            });
2164        }
2165    }
2166
2167    let mut lmes = Vec::with_capacity(len);
2168    let mut m_values = Vec::with_capacity(len);
2169
2170    for index in 0..len {
2171        let lp = photopic_luminance[index];
2172        if !lp.is_finite() || lp <= 0.0 {
2173            return Err(LuxError::InvalidInput(
2174                "photopic luminance values must be finite and positive",
2175            ));
2176        }
2177
2178        let sp = if let Some(ls) = scotopic_luminance {
2179            let scotopic = ls[index];
2180            if !scotopic.is_finite() || scotopic < 0.0 {
2181                return Err(LuxError::InvalidInput(
2182                    "scotopic luminance values must be finite and non-negative",
2183                ));
2184            }
2185            scotopic / lp
2186        } else {
2187            let ratio = s_p_ratio.unwrap()[index];
2188            if !ratio.is_finite() || ratio < 0.0 {
2189                return Err(LuxError::InvalidInput(
2190                    "S/P ratio values must be finite and non-negative",
2191                ));
2192            }
2193            ratio
2194        };
2195
2196        let f_lmes = |m: f64| {
2197            ((m * lp) + (1.0 - m) * sp * 683.0 / 1699.0) / (m + (1.0 - m) * 683.0 / 1699.0)
2198        };
2199        let f_m = |m: f64| 0.767 + 0.3334 * f_lmes(m).log10();
2200
2201        let mut previous = 0.5;
2202        let mut current = f_m(previous);
2203        let mut iterations = 0;
2204        while (current - previous).abs() > 1e-12 && iterations < 100 {
2205            previous = current;
2206            current = f_m(previous);
2207            iterations += 1;
2208        }
2209
2210        lmes.push(f_lmes(current));
2211        m_values.push(current.clamp(0.0, 1.0));
2212    }
2213
2214    Ok((lmes, m_values))
2215}
2216
2217pub fn vlbar_cie_mesopic(
2218    m_values: &[f64],
2219    target_wavelengths: Option<&[f64]>,
2220) -> LuxResult<MesopicLuminousEfficiency> {
2221    if m_values.is_empty() {
2222        return Err(LuxError::EmptyInput);
2223    }
2224
2225    let photopic = Observer::Cie1931_2.vlbar()?.0;
2226    let wavelengths = photopic.wavelengths().to_vec();
2227    let scotopic = load_scotopic_vlbar_on(&wavelengths)?;
2228    let peak_index = wavelengths
2229        .iter()
2230        .position(|&wavelength| (wavelength - 555.0).abs() < 1e-12)
2231        .ok_or(LuxError::ParseError(
2232            "missing 555 nm in mesopic source data",
2233        ))?;
2234
2235    let mut curves = Vec::with_capacity(m_values.len());
2236    let mut k_mesopic = Vec::with_capacity(m_values.len());
2237
2238    for &m in m_values {
2239        let m = m.clamp(0.0, 1.0);
2240        let values: Vec<f64> = photopic
2241            .values()
2242            .iter()
2243            .zip(scotopic.values().iter())
2244            .map(|(vp, vs)| m * vp + (1.0 - m) * vs)
2245            .collect::<Vec<_>>();
2246
2247        let k = 683.0 / values[peak_index];
2248        curves.push(values);
2249        k_mesopic.push(k);
2250    }
2251
2252    let curves = Spectrum::new(wavelengths, curves)?;
2253    let curves = if let Some(target_wavelengths) = target_wavelengths {
2254        curves.cie_interp_linear(target_wavelengths, false)?
2255    } else {
2256        curves
2257    };
2258    let normalization =
2259        vec![crate::spectrum::SpectrumNormalization::Max(1.0); curves.spectrum_count()];
2260    let curves = curves.normalize_each(&normalization, None)?;
2261
2262    Ok(MesopicLuminousEfficiency { curves, k_mesopic })
2263}
2264
2265fn load_scotopic_vlbar_on(target_wavelengths: &[f64]) -> LuxResult<Spectrum> {
2266    let base = TristimulusObserver::from_csv(
2267        include_str!("../data/cmfs/ciexyz_1951_20_scotopic.dat"),
2268        1699.0,
2269    )?
2270    .vl_spectrum()?;
2271
2272    let source_wavelengths = base.wavelengths().to_vec();
2273    let interpolated = base.cie_interp_linear(target_wavelengths, false)?;
2274    let clipped = target_wavelengths
2275        .iter()
2276        .zip(interpolated.values().iter())
2277        .map(|(&wavelength, &value)| {
2278            if wavelength < source_wavelengths[0]
2279                || wavelength > source_wavelengths[source_wavelengths.len() - 1]
2280                || value.is_sign_negative()
2281            {
2282                0.0
2283            } else {
2284                value
2285            }
2286        })
2287        .collect::<Vec<_>>();
2288
2289    Spectrum::new(target_wavelengths.to_vec(), clipped)
2290}
2291
2292#[cfg(test)]
2293mod tests {
2294    use super::{
2295        cat_apply, cat_apply_context, cat_apply_mode, cat_apply_mode_with_conditions,
2296        cat_apply_with_conditions, cat_compile, cat_compile_context, cat_compile_mode,
2297        cat_compile_mode_with_conditions, cat_compile_with_conditions, cat_degree_of_adaptation,
2298        cat_mode_degrees_from_conditions, delta_e_cie76, delta_e_cie76_lab, delta_e_ciede2000,
2299        delta_e_ciede2000_lab, lab_to_xyz, CatAdapter, CatConditionPair, CatContext, CatMode,
2300        CatSurround, CatTransform, CatViewingConditions, Tristimulus,
2301    };
2302
2303    #[test]
2304    fn internal_lab_paths_match_xyz_paths() {
2305        let white = [95.047, 100.0, 108.883];
2306        let lab1 = [50.0, 2.6772, -79.7751];
2307        let lab2 = [50.0, 0.0, -82.7485];
2308        let xyz1 = lab_to_xyz(lab1, white);
2309        let xyz2 = lab_to_xyz(lab2, white);
2310        assert!((delta_e_cie76(xyz1, xyz2, white) - delta_e_cie76_lab(lab1, lab2)).abs() < 1e-12);
2311        assert!(
2312            (delta_e_ciede2000(xyz1, xyz2, white) - delta_e_ciede2000_lab(lab1, lab2)).abs()
2313                < 1e-12
2314        );
2315    }
2316
2317    #[test]
2318    fn applies_bradford_chromatic_adaptation() {
2319        let adapted = cat_apply(
2320            [19.01, 20.0, 21.78],
2321            [95.047, 100.0, 108.883],
2322            [109.85, 100.0, 35.585],
2323            CatTransform::Bradford,
2324            1.0,
2325        )
2326        .unwrap();
2327        assert!((adapted[0] - 21.970_203_102_921_214).abs() < 1e-12);
2328        assert!((adapted[1] - 19.999_901_615_516_674).abs() < 1e-12);
2329        assert!((adapted[2] - 7.118_055_791_689_174).abs() < 1e-12);
2330    }
2331
2332    #[test]
2333    fn applies_cat02_chromatic_adaptation() {
2334        let adapted = cat_apply(
2335            [19.01, 20.0, 21.78],
2336            [95.047, 100.0, 108.883],
2337            [109.85, 100.0, 35.585],
2338            CatTransform::Cat02,
2339            1.0,
2340        )
2341        .unwrap();
2342        assert!((adapted[0] - 21.970_153_635_389_728).abs() < 1e-12);
2343        assert!((adapted[1] - 19.999_847_882_170_943).abs() < 1e-12);
2344        assert!((adapted[2] - 7.118_149_458_933_564).abs() < 1e-12);
2345    }
2346
2347    #[test]
2348    fn applies_cat16_chromatic_adaptation() {
2349        let adapted = cat_apply(
2350            [19.01, 20.0, 21.78],
2351            [95.047, 100.0, 108.883],
2352            [109.85, 100.0, 35.585],
2353            CatTransform::Cat16,
2354            1.0,
2355        )
2356        .unwrap();
2357        assert!((adapted[0] - 21.970_301_223_531_525).abs() < 1e-12);
2358        assert!((adapted[1] - 20.000_021_021_038_33).abs() < 1e-12);
2359        assert!((adapted[2] - 7.118_208_448_159_319).abs() < 1e-12);
2360    }
2361
2362    #[test]
2363    fn applies_sharp_chromatic_adaptation() {
2364        let adapted = cat_apply(
2365            [19.01, 20.0, 21.78],
2366            [95.047, 100.0, 108.883],
2367            [109.85, 100.0, 35.585],
2368            CatTransform::Sharp,
2369            1.0,
2370        )
2371        .unwrap();
2372        assert!((adapted[0] - 21.970_337_526_821_627).abs() < 1e-12);
2373        assert!((adapted[1] - 19.999_953_773_116_744).abs() < 1e-12);
2374        assert!((adapted[2] - 7.118_112_433_644_779).abs() < 1e-12);
2375    }
2376
2377    #[test]
2378    fn applies_bianco_chromatic_adaptation() {
2379        let adapted = cat_apply(
2380            [19.01, 20.0, 21.78],
2381            [95.047, 100.0, 108.883],
2382            [109.85, 100.0, 35.585],
2383            CatTransform::Bianco,
2384            1.0,
2385        )
2386        .unwrap();
2387        assert!((adapted[0] - 21.970_237_997_793_32).abs() < 1e-12);
2388        assert!((adapted[1] - 19.999_886_291_132_23).abs() < 1e-12);
2389        assert!((adapted[2] - 7.118_132_338_899_736).abs() < 1e-12);
2390    }
2391
2392    #[test]
2393    fn applies_cmc_chromatic_adaptation() {
2394        let adapted = cat_apply(
2395            [19.01, 20.0, 21.78],
2396            [95.047, 100.0, 108.883],
2397            [109.85, 100.0, 35.585],
2398            CatTransform::Cmc,
2399            1.0,
2400        )
2401        .unwrap();
2402        assert!((adapted[0] - 21.970_245_614_232_79).abs() < 1e-12);
2403        assert!((adapted[1] - 19.999_939_194_170_24).abs() < 1e-12);
2404        assert!((adapted[2] - 7.118_164_955_975_901).abs() < 1e-12);
2405    }
2406
2407    #[test]
2408    fn applies_kries_chromatic_adaptation() {
2409        let adapted = cat_apply(
2410            [19.01, 20.0, 21.78],
2411            [95.047, 100.0, 108.883],
2412            [109.85, 100.0, 35.585],
2413            CatTransform::Kries,
2414            1.0,
2415        )
2416        .unwrap();
2417        assert!((adapted[0] - 21.970_131_711_895_99).abs() < 1e-12);
2418        assert!((adapted[1] - 19.999_997_693_482_22).abs() < 1e-12);
2419        assert!((adapted[2] - 7.118_111_183_564_011).abs() < 1e-12);
2420    }
2421
2422    #[test]
2423    fn applies_judd1945_chromatic_adaptation() {
2424        let adapted = cat_apply(
2425            [19.01, 20.0, 21.78],
2426            [95.047, 100.0, 108.883],
2427            [109.85, 100.0, 35.585],
2428            CatTransform::Judd1945,
2429            1.0,
2430        )
2431        .unwrap();
2432        assert!((adapted[0] - 21.970_117_638_953_994).abs() < 1e-12);
2433        assert!((adapted[1] - 20.0).abs() < 1e-12);
2434        assert!((adapted[2] - 7.118_111_183_564_01).abs() < 1e-12);
2435    }
2436
2437    #[test]
2438    fn applies_judd1945_cie016_chromatic_adaptation() {
2439        let adapted = cat_apply(
2440            [19.01, 20.0, 21.78],
2441            [95.047, 100.0, 108.883],
2442            [109.85, 100.0, 35.585],
2443            CatTransform::Judd1945Cie016,
2444            1.0,
2445        )
2446        .unwrap();
2447        assert!((adapted[0] - 21.970_113_747_706_712).abs() < 1e-12);
2448        assert!((adapted[1] - 20.0).abs() < 1e-12);
2449        assert!((adapted[2] - 7.118_111_183_564_01).abs() < 1e-12);
2450    }
2451
2452    #[test]
2453    fn applies_judd1935_chromatic_adaptation() {
2454        let adapted = cat_apply(
2455            [19.01, 20.0, 21.78],
2456            [95.047, 100.0, 108.883],
2457            [109.85, 100.0, 35.585],
2458            CatTransform::Judd1935,
2459            1.0,
2460        )
2461        .unwrap();
2462        assert!((adapted[0] - 21.970_394_658_200_817).abs() < 1e-12);
2463        assert!((adapted[1] - 20.000_197_359_403_444).abs() < 1e-12);
2464        assert!((adapted[2] - 7.118_111_183_564_01).abs() < 1e-12);
2465    }
2466
2467    #[test]
2468    fn rejects_invalid_adaptation_degree() {
2469        let err = cat_apply(
2470            [19.01, 20.0, 21.78],
2471            [95.047, 100.0, 108.883],
2472            [109.85, 100.0, 35.585],
2473            CatTransform::Bradford,
2474            1.5,
2475        )
2476        .unwrap_err();
2477        assert_eq!(
2478            err.to_string(),
2479            "invalid input: degree_of_adaptation must be finite and within 0..=1"
2480        );
2481    }
2482
2483    #[test]
2484    fn computes_degree_of_adaptation_for_average_surround() {
2485        let degree = cat_degree_of_adaptation(CatSurround::Average, 318.31).unwrap();
2486        assert!((degree - 0.994_468_780_088_437_4).abs() < 1e-12);
2487    }
2488
2489    #[test]
2490    fn computes_degree_of_adaptation_for_dim_surround() {
2491        let degree = cat_degree_of_adaptation(CatSurround::Dim, 20.0).unwrap();
2492        assert!((degree - 0.772_572_461_903_455_1).abs() < 1e-12);
2493    }
2494
2495    #[test]
2496    fn computes_degree_of_adaptation_for_dark_surround() {
2497        let degree = cat_degree_of_adaptation(CatSurround::Dark, 0.0).unwrap();
2498        assert!((degree - 0.659_225_947_140_2).abs() < 1e-12);
2499    }
2500
2501    #[test]
2502    fn computes_degree_of_adaptation_from_viewing_conditions() {
2503        let conditions = CatViewingConditions::new(CatSurround::Average, 318.31).unwrap();
2504        let degree = conditions.degree_of_adaptation().unwrap();
2505        assert!((degree - 0.994_468_780_088_437_4).abs() < 1e-12);
2506    }
2507
2508    #[test]
2509    fn applies_chromatic_adaptation_with_conditions() {
2510        let adapted = cat_apply_with_conditions(
2511            [19.01, 20.0, 21.78],
2512            [95.047, 100.0, 108.883],
2513            [109.85, 100.0, 35.585],
2514            CatTransform::Bradford,
2515            CatSurround::Average,
2516            318.31,
2517        )
2518        .unwrap();
2519        assert!((adapted[0] - 21.953_829_568_576_072).abs() < 1e-12);
2520        assert!((adapted[1] - 19.999_902_159_702_89).abs() < 1e-12);
2521        assert!((adapted[2] - 7.199_154_229_436_402).abs() < 1e-12);
2522    }
2523
2524    #[test]
2525    fn resolves_mode_degrees_from_viewing_conditions() {
2526        let source = CatViewingConditions::new(CatSurround::Average, 318.31).unwrap();
2527        let target = CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap();
2528        let degrees = cat_mode_degrees_from_conditions(CatMode::TwoStep, source, target).unwrap();
2529        assert!((degrees[0] - 0.994_468_780_088_437_4).abs() < 1e-12);
2530        assert!((degrees[1] - 0.772_572_461_903_455_1).abs() < 1e-12);
2531
2532        let target_only =
2533            cat_mode_degrees_from_conditions(CatMode::BaselineToTarget, source, target).unwrap();
2534        assert!((target_only[0] - 0.772_572_461_903_455_1).abs() < 1e-12);
2535    }
2536
2537    #[test]
2538    fn applies_mode_chromatic_adaptation_with_conditions() {
2539        let source = CatViewingConditions::new(CatSurround::Average, 318.31).unwrap();
2540        let target = CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap();
2541        let adapted = cat_apply_mode_with_conditions(
2542            [19.01, 20.0, 21.78],
2543            [95.047, 100.0, 108.883],
2544            [109.85, 100.0, 35.585],
2545            Some([100.0, 100.0, 100.0]),
2546            CatTransform::Bradford,
2547            CatMode::TwoStep,
2548            CatConditionPair::new(source, target),
2549        )
2550        .unwrap();
2551        let manual = cat_apply_mode(
2552            [19.01, 20.0, 21.78],
2553            [95.047, 100.0, 108.883],
2554            [109.85, 100.0, 35.585],
2555            Some([100.0, 100.0, 100.0]),
2556            CatTransform::Bradford,
2557            CatMode::TwoStep,
2558            [
2559                source.degree_of_adaptation().unwrap(),
2560                target.degree_of_adaptation().unwrap(),
2561            ],
2562        )
2563        .unwrap();
2564        assert_eq!(adapted, manual);
2565    }
2566
2567    #[test]
2568    fn baseline_to_target_mode_uses_target_conditions_degree() {
2569        let source = CatViewingConditions::new(CatSurround::Average, 318.31).unwrap();
2570        let target = CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap();
2571        let adapted = cat_apply_mode_with_conditions(
2572            [19.01, 20.0, 21.78],
2573            [95.047, 100.0, 108.883],
2574            [109.85, 100.0, 35.585],
2575            Some([100.0, 100.0, 100.0]),
2576            CatTransform::Bradford,
2577            CatMode::BaselineToTarget,
2578            CatConditionPair::new(source, target),
2579        )
2580        .unwrap();
2581        let manual = cat_apply_mode(
2582            [19.01, 20.0, 21.78],
2583            [95.047, 100.0, 108.883],
2584            [109.85, 100.0, 35.585],
2585            Some([100.0, 100.0, 100.0]),
2586            CatTransform::Bradford,
2587            CatMode::BaselineToTarget,
2588            [
2589                target.degree_of_adaptation().unwrap(),
2590                target.degree_of_adaptation().unwrap(),
2591            ],
2592        )
2593        .unwrap();
2594        assert_eq!(adapted, manual);
2595    }
2596
2597    #[test]
2598    fn context_exposes_default_baseline_and_mode_degrees() {
2599        let context = CatContext::new(
2600            [95.047, 100.0, 108.883],
2601            [109.85, 100.0, 35.585],
2602            None,
2603            CatTransform::Bradford,
2604            CatMode::TwoStep,
2605            CatViewingConditions::new(CatSurround::Average, 318.31).unwrap(),
2606            CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap(),
2607        );
2608        assert_eq!(context.baseline_white_or_default(), [100.0, 100.0, 100.0]);
2609        let degrees = context.degrees_of_adaptation().unwrap();
2610        assert!((degrees[0] - 0.994_468_780_088_437_4).abs() < 1e-12);
2611        assert!((degrees[1] - 0.772_572_461_903_455_1).abs() < 1e-12);
2612    }
2613
2614    #[test]
2615    fn applies_chromatic_adaptation_from_context() {
2616        let context = CatContext::new(
2617            [95.047, 100.0, 108.883],
2618            [109.85, 100.0, 35.585],
2619            Some([100.0, 100.0, 100.0]),
2620            CatTransform::Bradford,
2621            CatMode::TwoStep,
2622            CatViewingConditions::new(CatSurround::Average, 318.31).unwrap(),
2623            CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap(),
2624        );
2625        let adapted = cat_apply_context([19.01, 20.0, 21.78], context).unwrap();
2626        let manual = cat_apply_mode_with_conditions(
2627            [19.01, 20.0, 21.78],
2628            context.source_white,
2629            context.target_white,
2630            context.baseline_white,
2631            context.transform,
2632            context.mode,
2633            CatConditionPair::new(context.source_conditions, context.target_conditions),
2634        )
2635        .unwrap();
2636        assert_eq!(adapted, manual);
2637    }
2638
2639    #[test]
2640    fn compiled_adapter_matches_single_step_helper() {
2641        let adapter = CatAdapter::from_degree(
2642            [95.047, 100.0, 108.883],
2643            [109.85, 100.0, 35.585],
2644            CatTransform::Bradford,
2645            1.0,
2646        )
2647        .unwrap();
2648        let adapted = adapter.apply([19.01, 20.0, 21.78]).unwrap();
2649        let helper = cat_apply(
2650            [19.01, 20.0, 21.78],
2651            [95.047, 100.0, 108.883],
2652            [109.85, 100.0, 35.585],
2653            CatTransform::Bradford,
2654            1.0,
2655        )
2656        .unwrap();
2657        assert_eq!(adapted, helper);
2658    }
2659
2660    #[test]
2661    fn cat_compile_matches_single_step_helper() {
2662        let adapter = cat_compile(
2663            [95.047, 100.0, 108.883],
2664            [109.85, 100.0, 35.585],
2665            CatTransform::Bradford,
2666            1.0,
2667        )
2668        .unwrap();
2669        let adapted = adapter.apply([19.01, 20.0, 21.78]).unwrap();
2670        let helper = cat_apply(
2671            [19.01, 20.0, 21.78],
2672            [95.047, 100.0, 108.883],
2673            [109.85, 100.0, 35.585],
2674            CatTransform::Bradford,
2675            1.0,
2676        )
2677        .unwrap();
2678        assert_eq!(adapted, helper);
2679    }
2680
2681    #[test]
2682    fn compiled_mode_adapter_matches_mode_helper() {
2683        let adapter = CatAdapter::from_mode(
2684            [95.047, 100.0, 108.883],
2685            [109.85, 100.0, 35.585],
2686            Some([100.0, 100.0, 100.0]),
2687            CatTransform::Bradford,
2688            CatMode::TwoStep,
2689            [0.8, 0.6],
2690        )
2691        .unwrap();
2692        let adapted = adapter.apply([19.01, 20.0, 21.78]).unwrap();
2693        let helper = cat_apply_mode(
2694            [19.01, 20.0, 21.78],
2695            [95.047, 100.0, 108.883],
2696            [109.85, 100.0, 35.585],
2697            Some([100.0, 100.0, 100.0]),
2698            CatTransform::Bradford,
2699            CatMode::TwoStep,
2700            [0.8, 0.6],
2701        )
2702        .unwrap();
2703        assert_eq!(adapted, helper);
2704    }
2705
2706    #[test]
2707    fn cat_compile_mode_matches_mode_helper() {
2708        let adapter = cat_compile_mode(
2709            [95.047, 100.0, 108.883],
2710            [109.85, 100.0, 35.585],
2711            Some([100.0, 100.0, 100.0]),
2712            CatTransform::Bradford,
2713            CatMode::TwoStep,
2714            [0.8, 0.6],
2715        )
2716        .unwrap();
2717        let adapted = adapter.apply([19.01, 20.0, 21.78]).unwrap();
2718        let helper = cat_apply_mode(
2719            [19.01, 20.0, 21.78],
2720            [95.047, 100.0, 108.883],
2721            [109.85, 100.0, 35.585],
2722            Some([100.0, 100.0, 100.0]),
2723            CatTransform::Bradford,
2724            CatMode::TwoStep,
2725            [0.8, 0.6],
2726        )
2727        .unwrap();
2728        assert_eq!(adapted, helper);
2729    }
2730
2731    #[test]
2732    fn compiled_context_adapter_matches_context_helper() {
2733        let context = CatContext::new(
2734            [95.047, 100.0, 108.883],
2735            [109.85, 100.0, 35.585],
2736            Some([100.0, 100.0, 100.0]),
2737            CatTransform::Bradford,
2738            CatMode::TwoStep,
2739            CatViewingConditions::new(CatSurround::Average, 318.31).unwrap(),
2740            CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap(),
2741        );
2742        let adapter = CatAdapter::from_context(context).unwrap();
2743        let adapted = adapter.apply([19.01, 20.0, 21.78]).unwrap();
2744        let helper = cat_apply_context([19.01, 20.0, 21.78], context).unwrap();
2745        assert_eq!(adapted, helper);
2746    }
2747
2748    #[test]
2749    fn cat_compile_with_conditions_matches_helper() {
2750        let adapter = cat_compile_with_conditions(
2751            [95.047, 100.0, 108.883],
2752            [109.85, 100.0, 35.585],
2753            CatTransform::Bradford,
2754            CatSurround::Average,
2755            318.31,
2756        )
2757        .unwrap();
2758        let adapted = adapter.apply([19.01, 20.0, 21.78]).unwrap();
2759        let helper = cat_apply_with_conditions(
2760            [19.01, 20.0, 21.78],
2761            [95.047, 100.0, 108.883],
2762            [109.85, 100.0, 35.585],
2763            CatTransform::Bradford,
2764            CatSurround::Average,
2765            318.31,
2766        )
2767        .unwrap();
2768        assert_eq!(adapted, helper);
2769    }
2770
2771    #[test]
2772    fn cat_compile_mode_with_conditions_matches_helper() {
2773        let source = CatViewingConditions::new(CatSurround::Average, 318.31).unwrap();
2774        let target = CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap();
2775        let adapter = cat_compile_mode_with_conditions(
2776            [95.047, 100.0, 108.883],
2777            [109.85, 100.0, 35.585],
2778            Some([100.0, 100.0, 100.0]),
2779            CatTransform::Bradford,
2780            CatMode::TwoStep,
2781            source,
2782            target,
2783        )
2784        .unwrap();
2785        let adapted = adapter.apply([19.01, 20.0, 21.78]).unwrap();
2786        let helper = cat_apply_mode_with_conditions(
2787            [19.01, 20.0, 21.78],
2788            [95.047, 100.0, 108.883],
2789            [109.85, 100.0, 35.585],
2790            Some([100.0, 100.0, 100.0]),
2791            CatTransform::Bradford,
2792            CatMode::TwoStep,
2793            CatConditionPair::new(source, target),
2794        )
2795        .unwrap();
2796        assert_eq!(adapted, helper);
2797    }
2798
2799    #[test]
2800    fn cat_compile_context_matches_helper() {
2801        let context = CatContext::new(
2802            [95.047, 100.0, 108.883],
2803            [109.85, 100.0, 35.585],
2804            Some([100.0, 100.0, 100.0]),
2805            CatTransform::Bradford,
2806            CatMode::TwoStep,
2807            CatViewingConditions::new(CatSurround::Average, 318.31).unwrap(),
2808            CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap(),
2809        );
2810        let adapter = cat_compile_context(context).unwrap();
2811        let adapted = adapter.apply([19.01, 20.0, 21.78]).unwrap();
2812        let helper = cat_apply_context([19.01, 20.0, 21.78], context).unwrap();
2813        assert_eq!(adapted, helper);
2814    }
2815
2816    #[test]
2817    fn tristimulus_adapter_wrapper_matches_adapter() {
2818        let adapter = CatAdapter::from_degree(
2819            [95.047, 100.0, 108.883],
2820            [109.85, 100.0, 35.585],
2821            CatTransform::Bradford,
2822            1.0,
2823        )
2824        .unwrap();
2825        let adapted = Tristimulus::new(vec![[19.01, 20.0, 21.78]])
2826            .cat_apply_adapter(adapter)
2827            .unwrap()
2828            .into_vec();
2829        assert_eq!(adapted[0], adapter.apply([19.01, 20.0, 21.78]).unwrap());
2830    }
2831
2832    #[test]
2833    fn applies_two_step_bradford_chromatic_adaptation() {
2834        let adapted = cat_apply_mode(
2835            [19.01, 20.0, 21.78],
2836            [95.047, 100.0, 108.883],
2837            [109.85, 100.0, 35.585],
2838            Some([100.0, 100.0, 100.0]),
2839            CatTransform::Bradford,
2840            CatMode::TwoStep,
2841            [0.8, 0.6],
2842        )
2843        .unwrap();
2844        assert!((adapted[0] - 20.321_183_547_718_547).abs() < 1e-12);
2845        assert!((adapted[1] - 19.738_985_345_802_1).abs() < 1e-12);
2846        assert!((adapted[2] - 9.694_619_002_109_818).abs() < 1e-12);
2847    }
2848
2849    #[test]
2850    fn applies_two_step_cat16_chromatic_adaptation() {
2851        let adapted = cat_apply_mode(
2852            [19.01, 20.0, 21.78],
2853            [95.047, 100.0, 108.883],
2854            [109.85, 100.0, 35.585],
2855            Some([100.0, 100.0, 100.0]),
2856            CatTransform::Cat16,
2857            CatMode::TwoStep,
2858            [0.8, 0.6],
2859        )
2860        .unwrap();
2861        assert!((adapted[0] - 20.564_644_514_788_387).abs() < 1e-12);
2862        assert!((adapted[1] - 20.001_575_611_836_01).abs() < 1e-12);
2863        assert!((adapted[2] - 9.934_161_728_245_801).abs() < 1e-12);
2864    }
2865
2866    #[test]
2867    fn source_to_baseline_mode_matches_one_step_helper() {
2868        let adapted = cat_apply_mode(
2869            [19.01, 20.0, 21.78],
2870            [95.047, 100.0, 108.883],
2871            [109.85, 100.0, 35.585],
2872            Some([100.0, 100.0, 100.0]),
2873            CatTransform::Bradford,
2874            CatMode::SourceToBaseline,
2875            [1.0, 1.0],
2876        )
2877        .unwrap();
2878        let helper = cat_apply(
2879            [19.01, 20.0, 21.78],
2880            [95.047, 100.0, 108.883],
2881            [100.0, 100.0, 100.0],
2882            CatTransform::Bradford,
2883            1.0,
2884        )
2885        .unwrap();
2886        assert_eq!(adapted, helper);
2887    }
2888
2889    #[test]
2890    fn batch_cat_transforms_match_scalar_versions() {
2891        let xyz = [[19.01, 20.0, 21.78], [20.0, 21.0, 22.0]];
2892        let source_conditions = CatViewingConditions::new(CatSurround::Average, 318.31).unwrap();
2893        let target_conditions = CatViewingConditions::new(CatSurround::Dim, 20.0).unwrap();
2894        let context = CatContext::new(
2895            [95.047, 100.0, 108.883],
2896            [109.85, 100.0, 35.585],
2897            Some([100.0, 100.0, 100.0]),
2898            CatTransform::Bradford,
2899            CatMode::TwoStep,
2900            source_conditions,
2901            target_conditions,
2902        );
2903        let many = Tristimulus::new(xyz.to_vec())
2904            .cat_apply(
2905                [95.047, 100.0, 108.883],
2906                [109.85, 100.0, 35.585],
2907                CatTransform::Bradford,
2908                1.0,
2909            )
2910            .unwrap()
2911            .into_vec();
2912        assert_eq!(
2913            many,
2914            vec![
2915                cat_apply(
2916                    xyz[0],
2917                    [95.047, 100.0, 108.883],
2918                    [109.85, 100.0, 35.585],
2919                    CatTransform::Bradford,
2920                    1.0
2921                )
2922                .unwrap(),
2923                cat_apply(
2924                    xyz[1],
2925                    [95.047, 100.0, 108.883],
2926                    [109.85, 100.0, 35.585],
2927                    CatTransform::Bradford,
2928                    1.0
2929                )
2930                .unwrap()
2931            ]
2932        );
2933        let context_many = Tristimulus::new(xyz.to_vec())
2934            .cat_apply_context(context)
2935            .unwrap()
2936            .into_vec();
2937        assert_eq!(context_many[0], cat_apply_context(xyz[0], context).unwrap());
2938        let adapter = CatAdapter::from_context(context).unwrap();
2939        let adapter_many = Tristimulus::new(xyz.to_vec())
2940            .cat_apply_adapter(adapter)
2941            .unwrap()
2942            .into_vec();
2943        assert_eq!(adapter_many[0], adapter.apply(xyz[0]).unwrap());
2944        assert_eq!(adapter_many[1], adapter.apply(xyz[1]).unwrap());
2945        let conditioned = Tristimulus::new(xyz.to_vec())
2946            .cat_apply_with_conditions(
2947                [95.047, 100.0, 108.883],
2948                [109.85, 100.0, 35.585],
2949                CatTransform::Bradford,
2950                CatSurround::Average,
2951                318.31,
2952            )
2953            .unwrap()
2954            .into_vec();
2955        assert_eq!(
2956            conditioned[0],
2957            cat_apply_with_conditions(
2958                xyz[0],
2959                [95.047, 100.0, 108.883],
2960                [109.85, 100.0, 35.585],
2961                CatTransform::Bradford,
2962                CatSurround::Average,
2963                318.31
2964            )
2965            .unwrap()
2966        );
2967        let mode_many = Tristimulus::new(xyz.to_vec())
2968            .cat_apply_mode(
2969                [95.047, 100.0, 108.883],
2970                [109.85, 100.0, 35.585],
2971                Some([100.0, 100.0, 100.0]),
2972                CatTransform::Bradford,
2973                CatMode::TwoStep,
2974                [0.8, 0.6],
2975            )
2976            .unwrap()
2977            .into_vec();
2978        assert_eq!(
2979            mode_many[0],
2980            cat_apply_mode(
2981                xyz[0],
2982                [95.047, 100.0, 108.883],
2983                [109.85, 100.0, 35.585],
2984                Some([100.0, 100.0, 100.0]),
2985                CatTransform::Bradford,
2986                CatMode::TwoStep,
2987                [0.8, 0.6]
2988            )
2989            .unwrap()
2990        );
2991        let mode_conditioned = Tristimulus::new(xyz.to_vec())
2992            .cat_apply_mode_with_conditions(
2993                [95.047, 100.0, 108.883],
2994                [109.85, 100.0, 35.585],
2995                Some([100.0, 100.0, 100.0]),
2996                CatTransform::Bradford,
2997                CatMode::TwoStep,
2998                CatConditionPair::new(source_conditions, target_conditions),
2999            )
3000            .unwrap()
3001            .into_vec();
3002        assert_eq!(
3003            mode_conditioned[0],
3004            cat_apply_mode_with_conditions(
3005                xyz[0],
3006                [95.047, 100.0, 108.883],
3007                [109.85, 100.0, 35.585],
3008                Some([100.0, 100.0, 100.0]),
3009                CatTransform::Bradford,
3010                CatMode::TwoStep,
3011                CatConditionPair::new(source_conditions, target_conditions)
3012            )
3013            .unwrap()
3014        );
3015    }
3016}