color/
dynamic.rs

1// Copyright 2024 the Color Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! CSS colors and syntax.
5
6use crate::{
7    cache_key::{BitEq, BitHash},
8    color::{add_alpha, fixup_hues_for_interpolate, split_alpha},
9    AlphaColor, Chromaticity, ColorSpace, ColorSpaceLayout, ColorSpaceTag, Flags, HueDirection,
10    LinearSrgb, Missing,
11};
12use core::hash::{Hash, Hasher};
13
14/// A color with a [color space tag] decided at runtime.
15///
16/// This type is roughly equivalent to [`AlphaColor`] except with a tag
17/// for color space as opposed being determined at compile time. It can
18/// also represent missing components, which are a feature of the CSS
19/// Color 4 spec.
20///
21/// Missing components are mostly useful for interpolation, and in that
22/// context take the value of the other color being interpolated. For
23/// example, interpolating a color in [Oklch] with `oklch(none 0 none)`
24/// fades the color saturation, ending in a gray with the same lightness.
25///
26/// In other contexts, missing colors are interpreted as a zero value.
27/// When manipulating components directly, setting them nonzero when the
28/// corresponding missing flag is set may yield unexpected results.
29///
30/// [color space tag]: ColorSpaceTag
31/// [Oklch]: crate::Oklch
32#[derive(Clone, Copy, Debug)]
33#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
34pub struct DynamicColor {
35    /// The color space.
36    pub cs: ColorSpaceTag,
37    /// The state of this color, tracking whether it has missing components and how it was
38    /// constructed. See the documentation of [`Flags`] for more information.
39    pub flags: Flags,
40    /// The components.
41    ///
42    /// The first three components are interpreted according to the
43    /// color space tag. The fourth component is alpha, interpreted
44    /// as separate alpha.
45    pub components: [f32; 4],
46}
47
48/// An intermediate struct used for interpolating between colors.
49///
50/// This is the return value of [`DynamicColor::interpolate`].
51#[derive(Clone, Copy)]
52#[expect(
53    missing_debug_implementations,
54    reason = "it's an intermediate struct, only used for eval"
55)]
56pub struct Interpolator {
57    premul1: [f32; 3],
58    alpha1: f32,
59    delta_premul: [f32; 3],
60    delta_alpha: f32,
61    cs: ColorSpaceTag,
62    missing: Missing,
63}
64
65impl DynamicColor {
66    /// Convert to `AlphaColor` with a static color space.
67    ///
68    /// Missing components are interpreted as 0.
69    #[must_use]
70    pub fn to_alpha_color<CS: ColorSpace>(self) -> AlphaColor<CS> {
71        if let Some(cs) = CS::TAG {
72            AlphaColor::new(self.convert(cs).components)
73        } else {
74            self.to_alpha_color::<LinearSrgb>().convert()
75        }
76    }
77
78    /// Convert from `AlphaColor`.
79    #[must_use]
80    pub fn from_alpha_color<CS: ColorSpace>(color: AlphaColor<CS>) -> Self {
81        if let Some(cs) = CS::TAG {
82            Self {
83                cs,
84                flags: Flags::default(),
85                components: color.components,
86            }
87        } else {
88            Self::from_alpha_color(color.convert::<LinearSrgb>())
89        }
90    }
91
92    /// The const-generic parameter `ABSOLUTE` indicates whether the conversion performs chromatic
93    /// adaptation. When `ABSOLUTE` is `true`, no chromatic adaptation is performed.
94    fn convert_impl<const ABSOLUTE: bool>(self, cs: ColorSpaceTag) -> Self {
95        if self.cs == cs {
96            // Note: §12 suggests that changing powerless to missing happens
97            // even when the color is already in the interpolation color space,
98            // but Chrome and color.js don't seem do to that.
99            self
100        } else {
101            let (opaque, alpha) = split_alpha(self.components);
102            let mut components = if ABSOLUTE {
103                add_alpha(self.cs.convert_absolute(cs, opaque), alpha)
104            } else {
105                add_alpha(self.cs.convert(cs, opaque), alpha)
106            };
107            // Reference: §12.2 of Color 4 spec
108            let missing = if !self.flags.missing().is_empty() {
109                if self.cs.same_analogous(cs) {
110                    for (i, component) in components.iter_mut().enumerate() {
111                        if self.flags.missing().contains(i) {
112                            *component = 0.0;
113                        }
114                    }
115                    self.flags.missing()
116                } else {
117                    let mut missing = self.flags.missing() & Missing::single(3);
118                    if self.cs.h_missing(self.flags.missing()) {
119                        cs.set_h_missing(&mut missing, &mut components);
120                    }
121                    if self.cs.c_missing(self.flags.missing()) {
122                        cs.set_c_missing(&mut missing, &mut components);
123                    }
124                    if self.cs.l_missing(self.flags.missing()) {
125                        cs.set_l_missing(&mut missing, &mut components);
126                    }
127                    missing
128                }
129            } else {
130                Missing::default()
131            };
132            let mut result = Self {
133                cs,
134                flags: Flags::from_missing(missing),
135                components,
136            };
137            result.powerless_to_missing();
138            result
139        }
140    }
141
142    #[must_use]
143    /// Convert to a different color space.
144    pub fn convert(self, cs: ColorSpaceTag) -> Self {
145        self.convert_impl::<false>(cs)
146    }
147
148    #[must_use]
149    /// Convert to a different color space, without chromatic adaptation.
150    ///
151    /// For most use-cases you should consider using the chromatically-adapting
152    /// [`DynamicColor::convert`] instead. See the documentation on
153    /// [`ColorSpace::convert_absolute`] for more information.
154    pub fn convert_absolute(self, cs: ColorSpaceTag) -> Self {
155        self.convert_impl::<true>(cs)
156    }
157
158    #[must_use]
159    /// Chromatically adapt the color between the given white point chromaticities.
160    ///
161    /// The color is assumed to be under a reference white point of `from` and is chromatically
162    /// adapted to the given white point `to`. The linear Bradford transform is used to perform the
163    /// chromatic adaptation.
164    pub fn chromatically_adapt(self, from: Chromaticity, to: Chromaticity) -> Self {
165        if from == to {
166            return self;
167        }
168
169        // Treat missing components as zero, as per CSS Color Module Level 4 § 4.4.
170        let (opaque, alpha) = split_alpha(self.zero_missing_components().components);
171        let components = add_alpha(self.cs.chromatically_adapt(opaque, from, to), alpha);
172        Self {
173            cs: self.cs,
174            // After chromatically adapting the color, components may no longer be missing. Don't
175            // forward the flags.
176            flags: Flags::default(),
177            components,
178        }
179    }
180
181    /// Set any missing components to zero.
182    ///
183    /// We have a soft invariant that any bit set in the missing bitflag has
184    /// a corresponding component which is 0. This method restores that
185    /// invariant after manipulation which might invalidate it.
186    fn zero_missing_components(mut self) -> Self {
187        if !self.flags.missing().is_empty() {
188            for (i, component) in self.components.iter_mut().enumerate() {
189                if self.flags.missing().contains(i) {
190                    *component = 0.0;
191                }
192            }
193        }
194        self
195    }
196
197    /// Multiply alpha by the given factor.
198    ///
199    /// If the alpha channel is missing, then the new alpha channel
200    /// will be ignored and the color returned unchanged.
201    #[must_use]
202    pub const fn multiply_alpha(self, rhs: f32) -> Self {
203        if self.flags.missing().contains(3) {
204            self
205        } else {
206            let (opaque, alpha) = split_alpha(self.components);
207            Self {
208                cs: self.cs,
209                flags: Flags::from_missing(self.flags.missing()),
210                components: add_alpha(opaque, alpha * rhs),
211            }
212        }
213    }
214
215    /// Set the alpha channel.
216    ///
217    /// This replaces the existing alpha channel. To scale or
218    /// or otherwise modify the existing alpha channel, use
219    /// [`DynamicColor::multiply_alpha`] or [`DynamicColor::map`].
220    ///
221    /// If the alpha channel is missing, then the new alpha channel
222    /// will be ignored and the color returned unchanged.
223    ///
224    /// ```
225    /// # use color::{parse_color, Srgb};
226    /// let c = parse_color("lavenderblush").unwrap().with_alpha(0.7);
227    /// assert_eq!(0.7, c.to_alpha_color::<Srgb>().split().1);
228    /// ```
229    #[must_use]
230    pub const fn with_alpha(self, alpha: f32) -> Self {
231        if self.flags.missing().contains(3) {
232            self
233        } else {
234            let (opaque, _alpha) = split_alpha(self.components);
235            Self {
236                cs: self.cs,
237                flags: Flags::from_missing(self.flags.missing()),
238                components: add_alpha(opaque, alpha),
239            }
240        }
241    }
242
243    /// Scale the chroma by the given amount.
244    ///
245    /// See [`ColorSpace::scale_chroma`] for more details.
246    #[must_use]
247    pub fn scale_chroma(self, scale: f32) -> Self {
248        let (opaque, alpha) = split_alpha(self.components);
249        let components = self.cs.scale_chroma(opaque, scale);
250
251        let mut flags = self.flags;
252        flags.discard_name();
253        Self {
254            cs: self.cs,
255            flags,
256            components: add_alpha(components, alpha),
257        }
258        .zero_missing_components()
259    }
260
261    /// Clip the color's components to fit within the natural gamut of the color space, and clamp
262    /// the color's alpha to be in the range `[0, 1]`.
263    ///
264    /// See [`ColorSpace::clip`] for more details.
265    #[must_use]
266    pub fn clip(self) -> Self {
267        let (opaque, alpha) = split_alpha(self.components);
268        let components = self.cs.clip(opaque);
269        let alpha = alpha.clamp(0., 1.);
270        Self {
271            cs: self.cs,
272            flags: self.flags,
273            components: add_alpha(components, alpha),
274        }
275    }
276
277    fn premultiply_split(self) -> ([f32; 3], f32) {
278        // Reference: §12.3 of Color 4 spec
279        let (opaque, alpha) = split_alpha(self.components);
280        let premul = if alpha == 1.0 || self.flags.missing().contains(3) {
281            opaque
282        } else {
283            self.cs.layout().scale(opaque, alpha)
284        };
285        (premul, alpha)
286    }
287
288    fn powerless_to_missing(&mut self) {
289        // Note: the spec seems vague on the details of what this should do,
290        // and there is some controversy in discussion threads. For example,
291        // in Lab-like spaces, if L is 0 do the other components become powerless?
292
293        // Note: we use hard-coded epsilons to check for approximate equality here, but these do
294        // not account for the normal value range of components. It might be somewhat more correct
295        // to, e.g., consider `0.000_01` approximately equal to `0` for a component with the
296        // natural range `0-100`, but not for a component with the natural range `0-0.5`.
297
298        match self.cs {
299            // See CSS Color Module level 4 § 7, § 9.3, and § 9.4 (HSL, LCH, Oklch).
300            ColorSpaceTag::Hsl | ColorSpaceTag::Lch | ColorSpaceTag::Oklch
301                if self.components[1] < 1e-6 =>
302            {
303                let mut missing = self.flags.missing();
304                self.cs.set_h_missing(&mut missing, &mut self.components);
305                self.flags.set_missing(missing);
306            }
307
308            // See CSS Color Module level 4 § 8 (HWB).
309            ColorSpaceTag::Hwb if self.components[1] + self.components[2] > 100. - 1e-4 => {
310                let mut missing = self.flags.missing();
311                self.cs.set_h_missing(&mut missing, &mut self.components);
312                self.flags.set_missing(missing);
313            }
314            _ => {}
315        }
316    }
317
318    /// Interpolate two colors.
319    ///
320    /// The colors are interpolated linearly from `self` to `other` in the color space given by
321    /// `cs`. When interpolating in a cylindrical color space, the hue can be interpolated in
322    /// multiple ways. The [`direction`](`HueDirection`) parameter controls the way in which the
323    /// hue is interpolated.
324    ///
325    /// The interpolation proceeds according to [CSS Color Module Level 4 § 12][css-sec].
326    ///
327    /// This method does a bunch of precomputation, resulting in an [`Interpolator`] object that
328    /// can be evaluated at various `t` values.
329    ///
330    /// [css-sec]: https://www.w3.org/TR/css-color-4/#interpolation
331    ///
332    /// # Example
333    ///
334    /// ```rust
335    /// use color::{AlphaColor, ColorSpaceTag, DynamicColor, HueDirection, Srgb};
336    ///
337    /// let start = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([1., 0., 0., 1.]));
338    /// let end = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([0., 1., 0., 1.]));
339    ///
340    /// let interp = start.interpolate(end, ColorSpaceTag::Hsl, HueDirection::Increasing);
341    /// let mid = interp.eval(0.5);
342    /// assert_eq!(mid.cs, ColorSpaceTag::Hsl);
343    /// assert!((mid.components[0] - 60.).abs() < 0.01);
344    /// ```
345    pub fn interpolate(
346        self,
347        other: Self,
348        cs: ColorSpaceTag,
349        direction: HueDirection,
350    ) -> Interpolator {
351        let mut a = self.convert(cs);
352        let mut b = other.convert(cs);
353        let a_missing = a.flags.missing();
354        let b_missing = b.flags.missing();
355        let missing = a_missing & b_missing;
356        if a_missing != b_missing {
357            for i in 0..4 {
358                if (a_missing & !b_missing).contains(i) {
359                    a.components[i] = b.components[i];
360                } else if (!a_missing & b_missing).contains(i) {
361                    b.components[i] = a.components[i];
362                }
363            }
364        }
365        let (premul1, alpha1) = a.premultiply_split();
366        let (mut premul2, alpha2) = b.premultiply_split();
367        fixup_hues_for_interpolate(premul1, &mut premul2, cs.layout(), direction);
368        let delta_premul = [
369            premul2[0] - premul1[0],
370            premul2[1] - premul1[1],
371            premul2[2] - premul1[2],
372        ];
373        Interpolator {
374            premul1,
375            alpha1,
376            delta_premul,
377            delta_alpha: alpha2 - alpha1,
378            cs,
379            missing,
380        }
381    }
382
383    /// Compute the relative luminance of the color.
384    ///
385    /// This can be useful for choosing contrasting colors, and follows the
386    /// [WCAG 2.1 spec].
387    ///
388    /// Note that this method only considers the opaque color, not the alpha.
389    /// Blending semi-transparent colors will reduce contrast, and that
390    /// should also be taken into account.
391    ///
392    /// [WCAG 2.1 spec]: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
393    #[must_use]
394    pub fn relative_luminance(self) -> f32 {
395        let [r, g, b, _] = self.convert(ColorSpaceTag::LinearSrgb).components;
396        0.2126 * r + 0.7152 * g + 0.0722 * b
397    }
398
399    /// Map components.
400    #[must_use]
401    pub fn map(self, f: impl Fn(f32, f32, f32, f32) -> [f32; 4]) -> Self {
402        let [x, y, z, a] = self.components;
403
404        let mut flags = self.flags;
405        flags.discard_name();
406        Self {
407            cs: self.cs,
408            flags,
409            components: f(x, y, z, a),
410        }
411        .zero_missing_components()
412    }
413
414    /// Map components in a given color space.
415    #[must_use]
416    pub fn map_in(self, cs: ColorSpaceTag, f: impl Fn(f32, f32, f32, f32) -> [f32; 4]) -> Self {
417        self.convert(cs).map(f).convert(self.cs)
418    }
419
420    /// Map the lightness of the color.
421    ///
422    /// In a color space that naturally has a lightness component, map that value.
423    /// Otherwise, do the mapping in [Oklab]. The lightness range is normalized so
424    /// that 1.0 is white. That is the normal range for Oklab but differs from the
425    /// range in [Lab], [Lch], and [Hsl].
426    ///
427    /// [Oklab]: crate::Oklab
428    /// [Lab]: crate::Lab
429    /// [Lch]: crate::Lch
430    /// [Hsl]: crate::Hsl
431    #[must_use]
432    pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
433        match self.cs {
434            ColorSpaceTag::Lab | ColorSpaceTag::Lch => {
435                self.map(|l, c1, c2, a| [100.0 * f(l * 0.01), c1, c2, a])
436            }
437            ColorSpaceTag::Oklab | ColorSpaceTag::Oklch => {
438                self.map(|l, c1, c2, a| [f(l), c1, c2, a])
439            }
440            ColorSpaceTag::Hsl => self.map(|h, s, l, a| [h, s, 100.0 * f(l * 0.01), a]),
441            _ => self.map_in(ColorSpaceTag::Oklab, |l, a, b, alpha| [f(l), a, b, alpha]),
442        }
443    }
444
445    /// Map the hue of the color.
446    ///
447    /// In a color space that naturally has a hue component, map that value.
448    /// Otherwise, do the mapping in [Oklch]. The hue is in degrees.
449    ///
450    /// [Oklch]: crate::Oklch
451    #[must_use]
452    pub fn map_hue(self, f: impl Fn(f32) -> f32) -> Self {
453        match self.cs.layout() {
454            ColorSpaceLayout::HueFirst => self.map(|h, c1, c2, a| [f(h), c1, c2, a]),
455            ColorSpaceLayout::HueThird => self.map(|c0, c1, h, a| [c0, c1, f(h), a]),
456            _ => self.map_in(ColorSpaceTag::Oklch, |l, c, h, a| [l, c, f(h), a]),
457        }
458    }
459}
460
461impl PartialEq for DynamicColor {
462    /// Equality is not perceptual, but requires the component values to be equal.
463    ///
464    /// See also [`CacheKey`](crate::cache_key::CacheKey).
465    fn eq(&self, other: &Self) -> bool {
466        // Same as the derive implementation, but we want a doc comment.
467        self.cs == other.cs && self.flags == other.flags && self.components == other.components
468    }
469}
470
471impl BitEq for DynamicColor {
472    fn bit_eq(&self, other: &Self) -> bool {
473        self.cs == other.cs
474            && self.flags == other.flags
475            && self.components.bit_eq(&other.components)
476    }
477}
478
479impl BitHash for DynamicColor {
480    fn bit_hash<H: Hasher>(&self, state: &mut H) {
481        self.cs.hash(state);
482        self.flags.hash(state);
483        self.components.bit_hash(state);
484    }
485}
486
487/// Note that the conversion is only lossless for color spaces that have a corresponding [tag](ColorSpaceTag).
488/// This is why we have this additional trait bound. See also
489/// <https://github.com/linebender/color/pull/155> for more discussion.
490impl<CS: ColorSpace> From<AlphaColor<CS>> for DynamicColor
491where
492    ColorSpaceTag: From<CS>,
493{
494    fn from(value: AlphaColor<CS>) -> Self {
495        const {
496            assert!(
497                CS::TAG.is_some(),
498                "this trait can only be implemented for colors with a tag"
499            );
500        }
501
502        Self::from_alpha_color(value)
503    }
504}
505
506impl Interpolator {
507    /// Evaluate the color ramp at the given point.
508    ///
509    /// Typically `t` ranges between 0 and 1, but that is not enforced,
510    /// so extrapolation is also possible.
511    pub fn eval(&self, t: f32) -> DynamicColor {
512        let premul = [
513            self.premul1[0] + t * self.delta_premul[0],
514            self.premul1[1] + t * self.delta_premul[1],
515            self.premul1[2] + t * self.delta_premul[2],
516        ];
517        let alpha = self.alpha1 + t * self.delta_alpha;
518        let opaque = if alpha == 0.0 || alpha == 1.0 {
519            premul
520        } else {
521            self.cs.layout().scale(premul, 1.0 / alpha)
522        };
523        let components = add_alpha(opaque, alpha);
524        DynamicColor {
525            cs: self.cs,
526            flags: Flags::from_missing(self.missing),
527            components,
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use crate::{parse_color, ColorSpaceTag, DynamicColor, Missing};
535
536    // `DynamicColor` was carefully packed. Ensure its size doesn't accidentally change.
537    const _: () = if size_of::<DynamicColor>() != 20 {
538        panic!("`DynamicColor` size changed");
539    };
540
541    #[test]
542    fn missing_alpha() {
543        let c = parse_color("oklab(0.5 0.2 0 / none)").unwrap();
544        assert_eq!(0., c.components[3]);
545        assert_eq!(Missing::single(3), c.flags.missing());
546
547        // Alpha is missing, so we shouldn't be able to get an alpha added.
548        let c2 = c.with_alpha(0.5);
549        assert_eq!(0., c2.components[3]);
550        assert_eq!(Missing::single(3), c2.flags.missing());
551
552        let c3 = c.multiply_alpha(0.2);
553        assert_eq!(0., c3.components[3]);
554        assert_eq!(Missing::single(3), c3.flags.missing());
555    }
556
557    #[test]
558    fn preserves_rgb_missingness() {
559        let c = parse_color("color(srgb 0.5 none 0)").unwrap();
560        assert_eq!(
561            c.convert(ColorSpaceTag::XyzD65).flags.missing(),
562            Missing::single(1)
563        );
564    }
565
566    #[test]
567    fn drops_missingness_when_not_analogous() {
568        let c = parse_color("oklab(none 0.2 -0.3)").unwrap();
569        assert!(c.convert(ColorSpaceTag::Srgb).flags.missing().is_empty());
570    }
571
572    #[test]
573    fn preserves_hue_missingness() {
574        let c = parse_color("oklch(0.2 0.3 none)").unwrap();
575        assert_eq!(
576            c.convert(ColorSpaceTag::Hsl).flags.missing(),
577            Missing::single(0)
578        );
579    }
580
581    #[test]
582    fn preserves_lightness_missingness() {
583        let c = parse_color("oklab(none 0.2 -0.3)").unwrap();
584        assert_eq!(
585            c.convert(ColorSpaceTag::Hsl).flags.missing(),
586            Missing::single(2)
587        );
588    }
589
590    #[test]
591    fn preserves_saturation_missingness() {
592        let c = parse_color("oklch(0.2 none 240)").unwrap();
593        assert_eq!(c.flags.missing(), Missing::single(1));
594
595        // As saturation is missing, it is effectively 0, meaning the color is achromatic and hue
596        // is powerless. § 4.4.1 says hue must be set missing after conversion.
597        assert_eq!(
598            c.convert(ColorSpaceTag::Hsl).flags.missing(),
599            Missing::single(0) | Missing::single(1)
600        );
601    }
602
603    #[test]
604    fn achromatic_sets_hue_powerless() {
605        let c = parse_color("oklab(0.2 0 0)").unwrap();
606
607        // As the color is achromatic, the hue is powerless. § 4.4.1 says hue must be set missing
608        // after conversion.
609        assert_eq!(
610            c.convert(ColorSpaceTag::Hsl).flags.missing(),
611            Missing::single(0)
612        );
613    }
614
615    #[test]
616    fn powerless_components() {
617        static COLORS_AND_POWERLESS: &[(&str, &[usize])] = &[
618            // Grayscale HWB results in powerless hue...
619            ("hwb(240 80 20)", &[0]),
620            ("hwb(240 79.9999999 19.9999999)", &[0]),
621            // ... also if the grayscale is specified out of gamut...
622            ("hwb(240 120 200)", &[0]),
623            // ... but near-grayscale HWB does not result in powerless hue...
624            ("hwb(240 79.99 20)", &[]),
625            // ... and colorful colors don't either.
626            ("hwb(240 20 15)", &[]),
627            // Unsaturated hue-saturation-lightness-like colors result in powerless hue...
628            ("hsl(240 0 50)", &[0]),
629            ("hsl(240 0.0000001 50)", &[0]),
630            // ... also if the saturation is negative...
631            ("hsl(240 -0.2 50)", &[0]),
632            // ... but near-unsaturated hue-saturation-lightness-like colors do not result
633            // in powerless hue...
634            ("hsl(240 0.01 50)", &[]),
635            // ... and colorful colors don't either.
636            ("hsl(240 0.6 50)", &[]),
637            // In lab-like spaces, zero lightness does not (currently) result in powerless
638            // components.
639            ("lab(0 0.4 -0.3)", &[]),
640            ("oklab(0 0.4 -0.3)", &[]),
641            // sRGB (and in other rectangular spaces) never have powerless components.
642            ("color(srgb 0 0 0)", &[]),
643            ("color(srgb 1 1 1)", &[]),
644            ("color(srgb 500 -200 20)", &[]),
645        ];
646
647        for (color, powerless) in COLORS_AND_POWERLESS {
648            let mut c = parse_color(color).unwrap();
649            c.powerless_to_missing();
650            for idx in *powerless {
651                assert!(
652                    c.flags.missing().contains(*idx),
653                    "Expected color `{color}` to have the following powerless components: {powerless:?}"
654                );
655            }
656        }
657    }
658}