1use crate::ValidationError;
4use std::sync::Arc;
5
6#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct Cdl {
9 pub slope: [f32; 3],
11 pub offset: [f32; 3],
13 pub power: [f32; 3],
15 pub saturation: f32,
17}
18
19impl Default for Cdl {
20 fn default() -> Self {
21 Self {
22 slope: [1.0; 3],
23 offset: [0.0; 3],
24 power: [1.0; 3],
25 saturation: 1.0,
26 }
27 }
28}
29
30impl Cdl {
31 pub fn temperature_tint(
33 mut self,
34 temperature: f32,
35 tint: f32,
36 ) -> Result<Self, ValidationError> {
37 if !(-1.0..=1.0).contains(&temperature) || !(-1.0..=1.0).contains(&tint) {
38 return Err(ValidationError::invalid(
39 "cdl",
40 "white_balance",
41 "Temperature and tint must be finite in -1..=1",
42 ));
43 }
44 let gains = [
45 libm::exp2f(temperature * 0.5 + tint * 0.25),
46 libm::exp2f(-tint * 0.5),
47 libm::exp2f(-temperature * 0.5 + tint * 0.25),
48 ];
49 self.slope = std::array::from_fn(|channel| self.slope[channel] * gains[channel]);
50 self.validate()?;
51 Ok(self)
52 }
53
54 pub fn validate(&self) -> Result<(), ValidationError> {
56 if self
57 .slope
58 .iter()
59 .chain(&self.offset)
60 .chain(&self.power)
61 .any(|value| !value.is_finite())
62 || self
63 .slope
64 .iter()
65 .chain(&self.power)
66 .any(|value| *value < 0.0)
67 || !self.saturation.is_finite()
68 || self.saturation < 0.0
69 {
70 return Err(ValidationError::invalid(
71 "cdl",
72 "parameters",
73 "Require finite CDL parameters and nonnegative gain, power and saturation",
74 ));
75 }
76 Ok(())
77 }
78
79 pub fn apply(self, rgb: [f32; 3]) -> [f32; 3] {
81 if self == Self::default() {
82 return rgb;
83 }
84 let rgb = std::array::from_fn(|channel| {
85 libm::powf(
86 (rgb[channel] * self.slope[channel] + self.offset[channel]).max(0.0),
87 self.power[channel],
88 )
89 });
90 if self.saturation == 1.0 {
91 return rgb;
92 }
93 let luma = rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;
94 rgb.map(|value| luma + (value - luma) * self.saturation)
95 }
96}
97
98#[derive(Clone, Copy, Debug, Default, PartialEq)]
100pub enum LutInput {
101 #[default]
103 Linear,
104 SrgbEncoded,
106 Log {
108 minimum: f32,
110 maximum: f32,
112 },
113}
114
115impl LutInput {
116 pub fn validate(self) -> Result<(), ValidationError> {
118 if let Self::Log { minimum, maximum } = self
119 && (!minimum.is_finite()
120 || !maximum.is_finite()
121 || minimum >= maximum
122 || minimum < -64.0
123 || maximum > 64.0)
124 {
125 return Err(ValidationError::invalid(
126 "lut",
127 "encoding",
128 "Require ordered log limits within -64..=64 stops",
129 ));
130 }
131 Ok(())
132 }
133}
134
135#[derive(Clone, Debug, PartialEq)]
145pub struct Lut1d {
146 domain_min: [f32; 3],
147 domain_max: [f32; 3],
148 data: Arc<[[f32; 3]]>,
149}
150
151impl Lut1d {
152 pub fn new(
154 domain_min: [f32; 3],
155 domain_max: [f32; 3],
156 data: Vec<[f32; 3]>,
157 ) -> Result<Self, ValidationError> {
158 if !(2..=65536).contains(&data.len())
159 || (0..3).any(|channel| {
160 !domain_min[channel].is_finite()
161 || !domain_max[channel].is_finite()
162 || domain_min[channel] >= domain_max[channel]
163 || !(domain_max[channel] - domain_min[channel]).is_finite()
164 })
165 || data.iter().flatten().any(|value| !value.is_finite())
166 {
167 return Err(ValidationError::invalid(
168 "lut",
169 "shaper",
170 "Require 2..=65536 finite samples and ordered finite domains",
171 ));
172 }
173 Ok(Self {
174 domain_min,
175 domain_max,
176 data: data.into(),
177 })
178 }
179
180 pub fn domain_min(&self) -> [f32; 3] {
182 self.domain_min
183 }
184
185 pub fn domain_max(&self) -> [f32; 3] {
187 self.domain_max
188 }
189
190 pub fn data(&self) -> &[[f32; 3]] {
192 &self.data
193 }
194
195 pub fn from_cube(text: &str) -> Result<Self, ValidationError> {
197 let parsed = ParsedCube::parse(text)?;
198 if parsed.size.is_some() {
199 return Err(cube_error());
200 }
201 parsed.shaper.ok_or_else(cube_error)
202 }
203
204 pub fn sample(&self, rgb: [f32; 3]) -> [f32; 3] {
206 std::array::from_fn(|channel| {
207 let value = if rgb[channel].is_nan() {
208 self.domain_min[channel]
209 } else {
210 rgb[channel]
211 };
212 let position = ((value - self.domain_min[channel])
213 / (self.domain_max[channel] - self.domain_min[channel]))
214 .clamp(0.0, 1.0)
215 * (self.data.len() - 1) as f32;
216 let index = (position as usize).min(self.data.len() - 2);
217 let fraction = position - index as f32;
218 self.data[index][channel] * (1.0 - fraction) + self.data[index + 1][channel] * fraction
219 })
220 }
221}
222
223#[derive(Clone, Debug)]
225pub struct Lut3d {
226 size: u16,
227 domain_min: [f32; 3],
228 domain_max: [f32; 3],
229 data: Arc<[[f32; 3]]>,
230 shaper: Option<Lut1d>,
231
232 fingerprint: u64,
233}
234
235impl PartialEq for Lut3d {
236 fn eq(&self, other: &Self) -> bool {
237 self.fingerprint == other.fingerprint
238 && self.size == other.size
239 && self.domain_min == other.domain_min
240 && self.domain_max == other.domain_max
241 && self.data == other.data
242 && self.shaper == other.shaper
243 }
244}
245
246#[derive(Clone, Copy, Debug, Default, PartialEq)]
248pub struct ColorWheels {
249 pub shadows: [f32; 3],
251 pub midtones: [f32; 3],
253 pub highlights: [f32; 3],
255}
256
257impl ColorWheels {
258 pub fn apply(self, rgb: [f32; 3]) -> [f32; 3] {
260 let luma = (rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722).max(0.0);
261 let smooth = |value: f32| {
262 let value = value.clamp(0.0, 1.0);
263 value * value * (3.0 - 2.0 * value)
264 };
265 let shadow = 1.0 - smooth(luma / 0.5);
266 let highlight = smooth((luma - 0.5) / 0.5);
267 let midtone = if luma <= 0.18 {
268 smooth(luma / 0.18)
269 } else {
270 1.0 - smooth((luma - 0.18) / 0.82)
271 };
272 std::array::from_fn(|channel| {
273 rgb[channel]
274 + self.shadows[channel] * shadow
275 + self.midtones[channel] * midtone
276 + self.highlights[channel] * highlight
277 })
278 }
279
280 pub fn bake(self, size: u16, maximum: f32, cdl: Cdl) -> Result<Lut3d, ValidationError> {
282 cdl.validate()?;
283 if !(2..=64).contains(&size)
284 || !maximum.is_finite()
285 || maximum <= 0.0
286 || self
287 .shadows
288 .iter()
289 .chain(&self.midtones)
290 .chain(&self.highlights)
291 .any(|value| !value.is_finite())
292 {
293 return Err(ValidationError::invalid(
294 "color_wheels",
295 "parameters",
296 "Require finite wheels, positive domain and size 2..=64",
297 ));
298 }
299 let mut data = Vec::with_capacity(usize::from(size).pow(3));
300 for blue in 0..size {
301 for green in 0..size {
302 for red in 0..size {
303 data.push(
304 self.apply(
305 cdl.apply(
306 [red, green, blue]
307 .map(|value| f32::from(value) / f32::from(size - 1) * maximum),
308 ),
309 ),
310 );
311 }
312 }
313 }
314 Lut3d::new(size, [0.0; 3], [maximum; 3], data)
315 }
316}
317
318impl Lut3d {
319 pub fn new(
321 size: u16,
322 domain_min: [f32; 3],
323 domain_max: [f32; 3],
324 data: Vec<[f32; 3]>,
325 ) -> Result<Self, ValidationError> {
326 if !(2..=64).contains(&size)
327 || data.len() != usize::from(size).pow(3)
328 || (0..3).any(|channel| {
329 !domain_min[channel].is_finite()
330 || !domain_max[channel].is_finite()
331 || domain_min[channel] >= domain_max[channel]
332 || !(domain_max[channel] - domain_min[channel]).is_finite()
333 })
334 || data.iter().flatten().any(|value| !value.is_finite())
335 {
336 return Err(ValidationError::invalid(
337 "lut",
338 "cube",
339 "Require a finite 2..=64 cube and ordered finite domain",
340 ));
341 }
342 let mut fingerprint = 0xcbf29ce484222325u64;
343 for value in domain_min
344 .iter()
345 .chain(&domain_max)
346 .chain(data.iter().flatten())
347 {
348 for byte in value.to_bits().to_le_bytes() {
349 fingerprint = (fingerprint ^ u64::from(byte)).wrapping_mul(0x100000001b3);
350 }
351 }
352 Ok(Self {
353 size,
354 domain_min,
355 domain_max,
356 data: data.into(),
357 shaper: None,
358 fingerprint,
359 })
360 }
361
362 pub fn from_cube(text: &str) -> Result<Self, ValidationError> {
367 let parsed = ParsedCube::parse(text)?;
368 let mut lut = Self::new(
369 parsed.size.ok_or_else(cube_error)?,
370 parsed.min,
371 parsed.max,
372 parsed.data,
373 )?;
374 lut.shaper = parsed.shaper;
375 Ok(lut)
376 }
377
378 pub fn size(&self) -> u16 {
380 self.size
381 }
382
383 pub fn domain_min(&self) -> [f32; 3] {
385 self.domain_min
386 }
387
388 pub fn domain_max(&self) -> [f32; 3] {
390 self.domain_max
391 }
392
393 pub fn data(&self) -> &[[f32; 3]] {
395 &self.data
396 }
397
398 pub fn with_shaper(mut self, shaper: Lut1d) -> Self {
400 self.shaper = Some(shaper);
401 self
402 }
403
404 pub fn shaper(&self) -> Option<&Lut1d> {
406 self.shaper.as_ref()
407 }
408
409 pub fn apply(&self, rgb: [f32; 3], input: LutInput) -> [f32; 3] {
411 let encoded = rgb.map(|value| match input {
412 LutInput::Linear => value,
413 LutInput::SrgbEncoded => crate::linear_to_srgb(value.max(0.0)),
414 LutInput::Log { minimum, maximum } => {
415 (libm::log2f(value.max(libm::exp2f(minimum))) - minimum) / (maximum - minimum)
416 }
417 });
418 self.sample(encoded).map(|value| match input {
419 LutInput::Linear => value,
420 LutInput::SrgbEncoded => crate::srgb_to_linear(value.max(0.0)),
421 LutInput::Log { minimum, maximum } => {
422 libm::exp2f(value * (maximum - minimum) + minimum)
423 }
424 })
425 }
426
427 pub fn sample(&self, rgb: [f32; 3]) -> [f32; 3] {
429 let rgb = self
430 .shaper
431 .as_ref()
432 .map_or(rgb, |shaper| shaper.sample(rgb));
433 let rgb: [f32; 3] = std::array::from_fn(|channel| {
434 if rgb[channel].is_nan() {
435 self.domain_min[channel]
436 } else {
437 rgb[channel]
438 }
439 });
440 let edge = usize::from(self.size);
441 let position: [f32; 3] = std::array::from_fn(|channel| {
442 ((rgb[channel] - self.domain_min[channel])
443 / (self.domain_max[channel] - self.domain_min[channel]))
444 .clamp(0.0, 1.0)
445 * (edge - 1) as f32
446 });
447 let mut cell = position.map(|value| (value as usize).min(edge - 2));
448 let fraction: [f32; 3] =
449 std::array::from_fn(|channel| position[channel] - cell[channel] as f32);
450 let mut order = [0, 1, 2];
451 order.sort_by(|first, second| fraction[*second].total_cmp(&fraction[*first]));
452 let at = |cell: [usize; 3]| self.data[cell[0] + edge * (cell[1] + edge * cell[2])];
453 let start = at(cell);
454 cell[order[0]] += 1;
455 let first = at(cell);
456 cell[order[1]] += 1;
457 let second = at(cell);
458 cell[order[2]] += 1;
459 let end = at(cell);
460 std::array::from_fn(|channel| {
461 start[channel]
462 + fraction[order[0]] * (first[channel] - start[channel])
463 + fraction[order[1]] * (second[channel] - first[channel])
464 + fraction[order[2]] * (end[channel] - second[channel])
465 })
466 }
467}
468
469fn cube_error() -> ValidationError {
470 ValidationError::invalid(
471 "lut",
472 "cube",
473 "Malformed, ambiguous or unsupported .cube data",
474 )
475}
476
477struct ParsedCube {
478 size: Option<u16>,
479 min: [f32; 3],
480 max: [f32; 3],
481 data: Vec<[f32; 3]>,
482 shaper: Option<Lut1d>,
483}
484
485impl ParsedCube {
486 fn parse(text: &str) -> Result<Self, ValidationError> {
487 if text.len() > 8 * 1024 * 1024 {
488 return Err(cube_error());
489 }
490 let mut size = None;
491 let mut shaper_size = None;
492 let mut domain_min = None;
493 let mut domain_max = None;
494 let mut input1 = None;
495 let mut input3 = None;
496 let mut title = false;
497 let mut data = Vec::new();
498 for line in text.lines() {
499 let line = line.split('#').next().unwrap_or_default().trim();
500 if line.is_empty() {
501 continue;
502 }
503 let words: Vec<_> = line.split_whitespace().collect();
504 let number = |index: usize| -> Result<f32, ValidationError> {
505 let value: f32 = words
506 .get(index)
507 .ok_or_else(cube_error)?
508 .parse()
509 .map_err(|_| cube_error())?;
510 if !value.is_finite() {
511 return Err(cube_error());
512 }
513 Ok(value)
514 };
515 match words[0] {
516 "TITLE"
517 if !title
518 && data.is_empty()
519 && line[5..].trim().starts_with('"')
520 && line.ends_with('"') =>
521 {
522 title = true;
523 }
524 "LUT_3D_SIZE" if size.is_none() && data.is_empty() && words.len() == 2 => {
525 let count: u16 = words[1].parse().map_err(|_| cube_error())?;
526 if !(2..=64).contains(&count) {
527 return Err(cube_error());
528 }
529 size = Some(count);
530 }
531 "LUT_1D_SIZE" if shaper_size.is_none() && data.is_empty() && words.len() == 2 => {
532 let count: usize = words[1].parse().map_err(|_| cube_error())?;
533 if !(2..=65536).contains(&count) {
534 return Err(cube_error());
535 }
536 shaper_size = Some(count);
537 }
538 "DOMAIN_MIN" if domain_min.is_none() && data.is_empty() && words.len() == 4 => {
539 domain_min = Some([number(1)?, number(2)?, number(3)?]);
540 }
541 "DOMAIN_MAX" if domain_max.is_none() && data.is_empty() && words.len() == 4 => {
542 domain_max = Some([number(1)?, number(2)?, number(3)?]);
543 }
544 "LUT_1D_INPUT_RANGE" if input1.is_none() && data.is_empty() && words.len() == 3 => {
545 input1 = Some((number(1)?, number(2)?));
546 }
547 "LUT_3D_INPUT_RANGE" if input3.is_none() && data.is_empty() && words.len() == 3 => {
548 input3 = Some((number(1)?, number(2)?));
549 }
550 _ => {
551 let count = shaper_size.unwrap_or(0)
552 + size.map_or(0, |value| usize::from(value).pow(3));
553 if words.len() != 3 || data.len() >= count {
554 return Err(cube_error());
555 }
556 if data.is_empty() {
557 data.try_reserve_exact(count).map_err(|_| cube_error())?;
558 }
559 data.push([number(0)?, number(1)?, number(2)?]);
560 }
561 }
562 }
563 let has_domain = domain_min.is_some() || domain_max.is_some();
564 if (input1.is_some() && (shaper_size.is_none() || has_domain))
565 || (input3.is_some() && (size.is_none() || (shaper_size.is_none() && has_domain)))
566 || (size.is_none() && shaper_size.is_none())
567 || data.len()
568 != shaper_size.unwrap_or(0) + size.map_or(0, |value| usize::from(value).pow(3))
569 {
570 return Err(cube_error());
571 }
572 let min = domain_min.unwrap_or([0.0; 3]);
573 let max = domain_max.unwrap_or([1.0; 3]);
574 let shaper = if let Some(count) = shaper_size {
575 let (min, max) = input1.map_or((min, max), |(min, max)| ([min; 3], [max; 3]));
576 Some(Lut1d::new(min, max, data.drain(..count).collect())?)
577 } else {
578 None
579 };
580 let (min, max) = input3.map_or(
581 if shaper.is_some() {
582 ([0.0; 3], [1.0; 3])
583 } else {
584 (min, max)
585 },
586 |(min, max)| ([min; 3], [max; 3]),
587 );
588 Ok(Self {
589 size,
590 min,
591 max,
592 data,
593 shaper,
594 })
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn shaper_precedes_cube_and_preserves_unbounded_output() {
604 let shaper = Lut1d::new(
605 [0.0; 3],
606 [4.0; 3],
607 vec![[0.0; 3], [0.2, 0.4, 0.6], [1.0; 3]],
608 )
609 .unwrap();
610 assert_eq!(shaper.sample([2.0; 3]), [0.2, 0.4, 0.6]);
611 assert_eq!(
612 shaper.sample([f32::NAN, f32::INFINITY, -1.0]),
613 [0.0, 1.0, 0.0]
614 );
615 let cube = ColorWheels::default().bake(2, 1.0, Cdl::default()).unwrap();
616 let shaped = cube.clone().with_shaper(shaper);
617 assert_ne!(cube, shaped);
618 for (actual, expected) in shaped.sample([2.0; 3]).into_iter().zip([0.2, 0.4, 0.6]) {
619 assert!((actual - expected).abs() < 1e-6);
620 }
621 let hdr = Lut1d::new([0.0; 3], [1.0; 3], vec![[-2.0; 3], [8.0; 3]]).unwrap();
622 assert_eq!(hdr.sample([0.5; 3]), [3.0; 3]);
623 assert!(Lut1d::new([0.0; 3], [0.0; 3], vec![[0.0; 3]; 2]).is_err());
624 assert!(Lut1d::new([0.0; 3], [1.0; 3], vec![[0.0; 3]]).is_err());
625 }
626
627 #[test]
628 fn standalone_and_combined_cube_shapers_parse() {
629 let header = "LUT_1D_SIZE 3\nLUT_1D_INPUT_RANGE 0 4\n";
630 let rows = "0 0 0\n0.2 0.4 0.6\n1 1 1\n";
631 let standalone = Lut1d::from_cube(&format!("{header}{rows}")).unwrap();
632 assert_eq!(standalone.sample([2.0; 3]), [0.2, 0.4, 0.6]);
633 let cube = "0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
634 let combined = format!("{header}LUT_3D_SIZE 2\nLUT_3D_INPUT_RANGE 0 1\n{rows}{cube}");
635 let lut = Lut3d::from_cube(&combined).unwrap();
636 assert_eq!(lut.shaper(), Some(&standalone));
637 for (actual, expected) in lut.sample([2.0; 3]).into_iter().zip([0.2, 0.4, 0.6]) {
638 assert!((actual - expected).abs() < 1e-6);
639 }
640 assert!(Lut1d::from_cube(&combined).is_err());
641 for text in [
642 "LUT_1D_SIZE 65537",
643 "LUT_1D_SIZE 2\n0 0 0",
644 "LUT_1D_SIZE 2\nLUT_1D_SIZE 2",
645 "LUT_1D_SIZE 2\nLUT_1D_INPUT_RANGE 1 0\n0 0 0\n1 1 1",
646 "LUT_1D_SIZE 2\nLUT_1D_INPUT_RANGE 0 1\nDOMAIN_MIN 0 0 0\n0 0 0\n1 1 1",
647 "LUT_1D_SIZE 2\n0 0 0\nNaN 1 1",
648 "LUT_1D_SIZE 2\n0 0 0\n1 1 1\n2 2 2",
649 ] {
650 assert!(Lut1d::from_cube(text).is_err(), "{text}");
651 }
652 }
653
654 #[test]
655 fn shaper_runs_between_input_encoding_and_output_decoding() {
656 let shaper = Lut1d::new(
657 [0.0; 3],
658 [1.0; 3],
659 vec![[0.0; 3], [0.7, 0.2, 0.4], [1.0; 3]],
660 )
661 .unwrap();
662 let lut = ColorWheels::default()
663 .bake(2, 1.0, Cdl::default())
664 .unwrap()
665 .with_shaper(shaper.clone());
666 let rgb = [0.01, 0.18, 0.8];
667 for input in [
668 LutInput::Linear,
669 LutInput::SrgbEncoded,
670 LutInput::Log {
671 minimum: -8.0,
672 maximum: 4.0,
673 },
674 ] {
675 let encoded = rgb.map(|value| match input {
676 LutInput::Linear => value,
677 LutInput::SrgbEncoded => crate::linear_to_srgb(value),
678 LutInput::Log { minimum, maximum } => {
679 (libm::log2f(value) - minimum) / (maximum - minimum)
680 }
681 });
682 let expected = shaper.sample(encoded).map(|value| match input {
683 LutInput::Linear => value,
684 LutInput::SrgbEncoded => crate::srgb_to_linear(value),
685 LutInput::Log { minimum, maximum } => {
686 libm::exp2f(value * (maximum - minimum) + minimum)
687 }
688 });
689 for (actual, expected) in lut.apply(rgb, input).into_iter().zip(expected) {
690 assert!((actual - expected).abs() <= 1e-5 * expected.abs().max(1.0));
691 }
692 }
693 }
694
695 #[test]
696 fn identity_cube_and_tetrahedral_boundaries() {
697 let lut = ColorWheels::default().bake(4, 1.0, Cdl::default()).unwrap();
698 for rgb in [
699 [0.0; 3],
700 [1.0; 3],
701 [0.13, 0.83, 0.42],
702 [0.8, 0.2, 0.2],
703 [0.5; 3],
704 ] {
705 let actual = lut.sample(rgb);
706 for channel in 0..3 {
707 assert!((actual[channel] - rgb[channel]).abs() < 1e-5);
708 }
709 }
710 assert_eq!(lut.sample([-1.0, 2.0, 0.0]), [0.0, 1.0, 0.0]);
711 assert_eq!(
712 lut,
713 ColorWheels::default().bake(4, 1.0, Cdl::default()).unwrap()
714 );
715 }
716
717 #[test]
718 fn cube_parser_rejects_missing_cube_duplicates_nan_and_truncation() {
719 for text in [
720 "LUT_1D_SIZE 2",
721 "LUT_3D_SIZE 1",
722 "LUT_3D_SIZE 2\n0 0 0",
723 "LUT_3D_SIZE 2\nLUT_3D_SIZE 2",
724 "LUT_3D_SIZE 2\nNaN 0 0",
725 ] {
726 assert!(Lut3d::from_cube(text).is_err());
727 }
728 let data = "TITLE \"identity\"\nLUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
729 assert_eq!(
730 Lut3d::from_cube(data).unwrap().sample([0.2, 0.7, 0.4]),
731 [0.2, 0.7, 0.4]
732 );
733 }
734
735 #[test]
736 fn cdl_white_balance_and_wheels_validate() {
737 assert_eq!(Cdl::default().apply([-0.1, 0.18, 4.0]), [-0.1, 0.18, 4.0]);
738 assert_eq!(
739 Cdl::default().temperature_tint(0.0, 0.0).unwrap(),
740 Cdl::default()
741 );
742 assert!(Cdl::default().temperature_tint(f32::NAN, 0.0).is_err());
743 assert!(
744 ColorWheels {
745 shadows: [f32::NAN; 3],
746 ..ColorWheels::default()
747 }
748 .bake(2, 1.0, Cdl::default())
749 .is_err()
750 );
751 }
752}