From c3f73c281dcd8603c1d1b84c4e532dead91f1914 Mon Sep 17 00:00:00 2001 From: Talin Date: Fri, 23 Feb 2024 09:51:31 -0800 Subject: [PATCH] Upstreaming bevy_color. (#12013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective This provides a new set of color types and operations for Bevy. Fixes: #10986 #1402 ## Solution The new crate provides a set of distinct types for various useful color spaces, along with utilities for manipulating and converting colors. This is not a breaking change, as no Bevy APIs are modified (yet). --------- Co-authored-by: François --- crates/bevy_color/Cargo.toml | 20 + crates/bevy_color/crates/gen_tests/Cargo.toml | 10 + crates/bevy_color/crates/gen_tests/README.md | 11 + .../bevy_color/crates/gen_tests/src/main.rs | 90 ++++ crates/bevy_color/src/color.rs | 128 ++++++ crates/bevy_color/src/color_difference.rs | 13 + crates/bevy_color/src/color_ops.rs | 50 ++ crates/bevy_color/src/color_range.rs | 42 ++ crates/bevy_color/src/hsla.rs | 223 +++++++++ crates/bevy_color/src/lcha.rs | 231 ++++++++++ crates/bevy_color/src/lib.rs | 88 ++++ crates/bevy_color/src/linear_rgba.rs | 264 +++++++++++ crates/bevy_color/src/oklaba.rs | 183 ++++++++ crates/bevy_color/src/srgba.rs | 430 ++++++++++++++++++ crates/bevy_color/src/test_colors.rs | 180 ++++++++ crates/bevy_color/src/testing.rs | 15 + tools/publish.sh | 1 + 17 files changed, 1979 insertions(+) create mode 100644 crates/bevy_color/Cargo.toml create mode 100644 crates/bevy_color/crates/gen_tests/Cargo.toml create mode 100644 crates/bevy_color/crates/gen_tests/README.md create mode 100644 crates/bevy_color/crates/gen_tests/src/main.rs create mode 100644 crates/bevy_color/src/color.rs create mode 100644 crates/bevy_color/src/color_difference.rs create mode 100644 crates/bevy_color/src/color_ops.rs create mode 100644 crates/bevy_color/src/color_range.rs create mode 100644 crates/bevy_color/src/hsla.rs create mode 100644 crates/bevy_color/src/lcha.rs create mode 100644 crates/bevy_color/src/lib.rs create mode 100644 crates/bevy_color/src/linear_rgba.rs create mode 100644 crates/bevy_color/src/oklaba.rs create mode 100644 crates/bevy_color/src/srgba.rs create mode 100644 crates/bevy_color/src/test_colors.rs create mode 100644 crates/bevy_color/src/testing.rs diff --git a/crates/bevy_color/Cargo.toml b/crates/bevy_color/Cargo.toml new file mode 100644 index 00000000000000..a5f8a6f0ac80c5 --- /dev/null +++ b/crates/bevy_color/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "bevy_color" +version = "0.13.0" +edition = "2021" +description = "Types for representing and manipulating color values" +homepage = "https://bevyengine.org" +repository = "https://github.com/bevyengine/bevy" +license = "MIT OR Apache-2.0" +keywords = ["bevy", "color"] + +[dependencies] +bevy_math = { path = "../bevy_math", version = "0.14.0-dev" } +bevy_reflect = { path = "../bevy_reflect", version = "0.14.0-dev", features = [ + "bevy", +] } +bevy_render = { path = "../bevy_render", version = "0.14.0-dev" } +serde = "1.0" + +[lints] +workspace = true diff --git a/crates/bevy_color/crates/gen_tests/Cargo.toml b/crates/bevy_color/crates/gen_tests/Cargo.toml new file mode 100644 index 00000000000000..357e7aaba6f061 --- /dev/null +++ b/crates/bevy_color/crates/gen_tests/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "gen_tests" +version = "0.1.0" +edition = "2021" +publish = false + +[workspace] + +[dependencies] +palette = "0.7.4" diff --git a/crates/bevy_color/crates/gen_tests/README.md b/crates/bevy_color/crates/gen_tests/README.md new file mode 100644 index 00000000000000..b3b8b58b8d7150 --- /dev/null +++ b/crates/bevy_color/crates/gen_tests/README.md @@ -0,0 +1,11 @@ +# gen_tests for bevy_color + +The purpose of this crate is to generate test data for validating the color conversion +functions. It is not part of the Bevy library and should only be run by developers +working on Bevy. + +To generate the file: + +```sh +cargo run > ../../src/test_colors.rs +``` diff --git a/crates/bevy_color/crates/gen_tests/src/main.rs b/crates/bevy_color/crates/gen_tests/src/main.rs new file mode 100644 index 00000000000000..d7c0dc5bb71356 --- /dev/null +++ b/crates/bevy_color/crates/gen_tests/src/main.rs @@ -0,0 +1,90 @@ +use palette::{Hsl, IntoColor, Lch, LinSrgb, Oklab, Srgb}; + +const TEST_COLORS: &[(f32, f32, f32, &str)] = &[ + (0., 0., 0., "black"), + (1., 1., 1., "white"), + (1., 0., 0., "red"), + (0., 1., 0., "green"), + (0., 0., 1., "blue"), + (1., 1., 0., "yellow"), + (1., 0., 1., "magenta"), + (0., 1., 1., "cyan"), + (0.5, 0.5, 0.5, "gray"), + (0.5, 0.5, 0., "olive"), + (0.5, 0., 0.5, "purple"), + (0., 0.5, 0.5, "teal"), + (0.5, 0., 0., "maroon"), + (0., 0.5, 0., "lime"), + (0., 0., 0.5, "navy"), + (0.5, 0.5, 0., "orange"), + (0.5, 0., 0.5, "fuchsia"), + (0., 0.5, 0.5, "aqua"), +]; + +fn main() { + println!( + "// Generated by gen_tests. Do not edit. +#[cfg(test)] +use crate::{{Hsla, Srgba, LinearRgba, Oklaba, Lcha}}; + +#[cfg(test)] +pub struct TestColor {{ + pub name: &'static str, + pub rgb: Srgba, + pub linear_rgb: LinearRgba, + pub hsl: Hsla, + pub lch: Lcha, + pub oklab: Oklaba, +}} +" + ); + + println!("// Table of equivalent colors in various color spaces"); + println!("#[cfg(test)]"); + println!("pub const TEST_COLORS: &[TestColor] = &["); + for (r, g, b, name) in TEST_COLORS { + let srgb = Srgb::new(*r, *g, *b); + let linear_rgb: LinSrgb = srgb.into_color(); + let hsl: Hsl = srgb.into_color(); + let lch: Lch = srgb.into_color(); + let oklab: Oklab = srgb.into_color(); + println!(" // {name}"); + println!( + " TestColor {{ + name: \"{name}\", + rgb: Srgba::new({}, {}, {}, 1.0), + linear_rgb: LinearRgba::new({}, {}, {}, 1.0), + hsl: Hsla::new({}, {}, {}, 1.0), + lch: Lcha::new({}, {}, {}, 1.0), + oklab: Oklaba::new({}, {}, {}, 1.0), + }},", + VariablePrecision(srgb.red), + VariablePrecision(srgb.green), + VariablePrecision(srgb.blue), + VariablePrecision(linear_rgb.red), + VariablePrecision(linear_rgb.green), + VariablePrecision(linear_rgb.blue), + VariablePrecision(hsl.hue.into_positive_degrees()), + VariablePrecision(hsl.saturation), + VariablePrecision(hsl.lightness), + VariablePrecision(lch.l / 100.0), + VariablePrecision(lch.chroma / 100.0), + VariablePrecision(lch.hue.into_positive_degrees()), + VariablePrecision(oklab.l), + VariablePrecision(oklab.a), + VariablePrecision(oklab.b), + ); + } + println!("];"); +} + +struct VariablePrecision(f32); + +impl std::fmt::Display for VariablePrecision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.0.fract() == 0.0 { + return write!(f, "{}.0", self.0); + } + write!(f, "{}", self.0) + } +} diff --git a/crates/bevy_color/src/color.rs b/crates/bevy_color/src/color.rs new file mode 100644 index 00000000000000..1cc7ccf8e4386e --- /dev/null +++ b/crates/bevy_color/src/color.rs @@ -0,0 +1,128 @@ +use crate::{Hsla, Lcha, LinearRgba, Oklaba, Srgba}; + +/// An enumerated type that can represent any of the color types in this crate. +/// +/// This is useful when you need to store a color in a data structure that can't be generic over +/// the color type. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Color { + /// A color in the sRGB color space with alpha. + Srgba(Srgba), + /// A color in the linear sRGB color space with alpha. + LinearRgba(LinearRgba), + /// A color in the HSL color space with alpha. + Hsla(Hsla), + /// A color in the LCH color space with alpha. + Lcha(Lcha), + /// A color in the Oklaba color space with alpha. + Oklaba(Oklaba), +} + +impl Color { + /// Return the color as a linear RGBA color. + pub fn linear(&self) -> LinearRgba { + match self { + Color::Srgba(srgba) => (*srgba).into(), + Color::LinearRgba(linear) => *linear, + Color::Hsla(hsla) => (*hsla).into(), + Color::Lcha(lcha) => (*lcha).into(), + Color::Oklaba(oklab) => (*oklab).into(), + } + } +} + +impl Default for Color { + fn default() -> Self { + Self::Srgba(Srgba::WHITE) + } +} + +impl From for Color { + fn from(value: Srgba) -> Self { + Self::Srgba(value) + } +} + +impl From for Color { + fn from(value: LinearRgba) -> Self { + Self::LinearRgba(value) + } +} + +impl From for Color { + fn from(value: Hsla) -> Self { + Self::Hsla(value) + } +} + +impl From for Color { + fn from(value: Oklaba) -> Self { + Self::Oklaba(value) + } +} + +impl From for Color { + fn from(value: Lcha) -> Self { + Self::Lcha(value) + } +} + +impl From for Srgba { + fn from(value: Color) -> Self { + match value { + Color::Srgba(srgba) => srgba, + Color::LinearRgba(linear) => linear.into(), + Color::Hsla(hsla) => hsla.into(), + Color::Lcha(lcha) => lcha.into(), + Color::Oklaba(oklab) => oklab.into(), + } + } +} + +impl From for LinearRgba { + fn from(value: Color) -> Self { + match value { + Color::Srgba(srgba) => srgba.into(), + Color::LinearRgba(linear) => linear, + Color::Hsla(hsla) => hsla.into(), + Color::Lcha(lcha) => lcha.into(), + Color::Oklaba(oklab) => oklab.into(), + } + } +} + +impl From for Hsla { + fn from(value: Color) -> Self { + match value { + Color::Srgba(srgba) => srgba.into(), + Color::LinearRgba(linear) => linear.into(), + Color::Hsla(hsla) => hsla, + Color::Lcha(lcha) => LinearRgba::from(lcha).into(), + Color::Oklaba(oklab) => LinearRgba::from(oklab).into(), + } + } +} + +impl From for Lcha { + fn from(value: Color) -> Self { + match value { + Color::Srgba(srgba) => srgba.into(), + Color::LinearRgba(linear) => linear.into(), + Color::Hsla(hsla) => Srgba::from(hsla).into(), + Color::Lcha(lcha) => lcha, + Color::Oklaba(oklab) => LinearRgba::from(oklab).into(), + } + } +} + +impl From for Oklaba { + fn from(value: Color) -> Self { + match value { + Color::Srgba(srgba) => srgba.into(), + Color::LinearRgba(linear) => linear.into(), + Color::Hsla(hsla) => Srgba::from(hsla).into(), + Color::Lcha(lcha) => LinearRgba::from(lcha).into(), + Color::Oklaba(oklab) => oklab, + } + } +} diff --git a/crates/bevy_color/src/color_difference.rs b/crates/bevy_color/src/color_difference.rs new file mode 100644 index 00000000000000..a2bdf10e14549a --- /dev/null +++ b/crates/bevy_color/src/color_difference.rs @@ -0,0 +1,13 @@ +//! Module for calculating distance between two colors in the same color space. + +/// Calculate the distance between this and another color as if they were coordinates +/// in a Euclidean space. Alpha is not considered in the distance calculation. +pub trait EuclideanDistance: Sized { + /// Distance from `self` to `other`. + fn distance(&self, other: &Self) -> f32 { + self.distance_squared(other).sqrt() + } + + /// Distance squared from `self` to `other`. + fn distance_squared(&self, other: &Self) -> f32; +} diff --git a/crates/bevy_color/src/color_ops.rs b/crates/bevy_color/src/color_ops.rs new file mode 100644 index 00000000000000..08f4cb14c8fda0 --- /dev/null +++ b/crates/bevy_color/src/color_ops.rs @@ -0,0 +1,50 @@ +/// Methods for changing the luminance of a color. Note that these methods are not +/// guaranteed to produce consistent results across color spaces, +/// but will be within a given space. +pub trait Luminance: Sized { + /// Return the luminance of this color (0.0 - 1.0). + fn luminance(&self) -> f32; + + /// Return a new version of this color with the given luminance. The resulting color will + /// be clamped to the valid range for the color space; for some color spaces, clamping + /// may cause the hue or chroma to change. + fn with_luminance(&self, value: f32) -> Self; + + /// Return a darker version of this color. The `amount` should be between 0.0 and 1.0. + /// The amount represents an absolute decrease in luminance, and is distributive: + /// `color.darker(a).darker(b) == color.darker(a + b)`. Colors are clamped to black + /// if the amount would cause them to go below black. + /// + /// For a relative decrease in luminance, you can simply `mix()` with black. + fn darker(&self, amount: f32) -> Self; + + /// Return a lighter version of this color. The `amount` should be between 0.0 and 1.0. + /// The amount represents an absolute increase in luminance, and is distributive: + /// `color.lighter(a).lighter(b) == color.lighter(a + b)`. Colors are clamped to white + /// if the amount would cause them to go above white. + /// + /// For a relative increase in luminance, you can simply `mix()` with white. + fn lighter(&self, amount: f32) -> Self; +} + +/// Linear interpolation of two colors within a given color space. +pub trait Mix: Sized { + /// Linearly interpolate between this and another color, by factor. + /// Factor should be between 0.0 and 1.0. + fn mix(&self, other: &Self, factor: f32) -> Self; + + /// Linearly interpolate between this and another color, by factor, storing the result + /// in this color. Factor should be between 0.0 and 1.0. + fn mix_assign(&mut self, other: Self, factor: f32) { + *self = self.mix(&other, factor); + } +} + +/// Methods for manipulating alpha values. +pub trait Alpha: Sized { + /// Return a new version of this color with the given alpha value. + fn with_alpha(&self, alpha: f32) -> Self; + + /// Return a the alpha component of this color. + fn alpha(&self) -> f32; +} diff --git a/crates/bevy_color/src/color_range.rs b/crates/bevy_color/src/color_range.rs new file mode 100644 index 00000000000000..0f1c8472a1dc35 --- /dev/null +++ b/crates/bevy_color/src/color_range.rs @@ -0,0 +1,42 @@ +use std::ops::Range; + +use crate::Mix; + +/// Represents a range of colors that can be linearly interpolated, defined by a start and +/// end point which must be in the same color space. It works for any color type that +/// implements [`Mix`]. +/// +/// This is useful for defining gradients or animated color transitions. +pub trait ColorRange { + /// Get the color value at the given interpolation factor, which should be between 0.0 (start) + /// and 1.0 (end). + fn at(&self, factor: f32) -> T; +} + +impl ColorRange for Range { + fn at(&self, factor: f32) -> T { + self.start.mix(&self.end, factor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{LinearRgba, Srgba}; + + #[test] + fn test_color_range() { + let range = Srgba::RED..Srgba::BLUE; + assert_eq!(range.at(0.0), Srgba::RED); + assert_eq!(range.at(0.5), Srgba::new(0.5, 0.0, 0.5, 1.0)); + assert_eq!(range.at(1.0), Srgba::BLUE); + + let lred: LinearRgba = Srgba::RED.into(); + let lblue: LinearRgba = Srgba::BLUE.into(); + + let range = lred..lblue; + assert_eq!(range.at(0.0), lred); + assert_eq!(range.at(0.5), LinearRgba::new(0.5, 0.0, 0.5, 1.0)); + assert_eq!(range.at(1.0), lblue); + } +} diff --git a/crates/bevy_color/src/hsla.rs b/crates/bevy_color/src/hsla.rs new file mode 100644 index 00000000000000..2f2267d5347f60 --- /dev/null +++ b/crates/bevy_color/src/hsla.rs @@ -0,0 +1,223 @@ +use crate::{Alpha, LinearRgba, Luminance, Mix, Srgba}; +use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize}; +use bevy_render::color::HslRepresentation; +use serde::{Deserialize, Serialize}; + +/// Color in Hue-Saturation-Lightness color space with alpha +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)] +#[reflect(PartialEq, Serialize, Deserialize)] +pub struct Hsla { + /// The hue channel. [0.0, 360.0] + pub hue: f32, + /// The saturation channel. [0.0, 1.0] + pub saturation: f32, + /// The lightness channel. [0.0, 1.0] + pub lightness: f32, + /// The alpha channel. [0.0, 1.0] + pub alpha: f32, +} + +impl Hsla { + /// Construct a new [`Hsla`] color from components. + /// + /// # Arguments + /// + /// * `hue` - Hue channel. [0.0, 360.0] + /// * `saturation` - Saturation channel. [0.0, 1.0] + /// * `lightness` - Lightness channel. [0.0, 1.0] + /// * `alpha` - Alpha channel. [0.0, 1.0] + pub const fn new(hue: f32, saturation: f32, lightness: f32, alpha: f32) -> Self { + Self { + hue, + saturation, + lightness, + alpha, + } + } + + /// Construct a new [`Hsla`] color from (h, s, l) components, with the default alpha (1.0). + /// + /// # Arguments + /// + /// * `hue` - Hue channel. [0.0, 360.0] + /// * `saturation` - Saturation channel. [0.0, 1.0] + /// * `lightness` - Lightness channel. [0.0, 1.0] + pub const fn hsl(hue: f32, saturation: f32, lightness: f32) -> Self { + Self::new(hue, saturation, lightness, 1.0) + } +} + +impl Default for Hsla { + fn default() -> Self { + Self::new(0., 0., 1., 1.) + } +} + +impl Mix for Hsla { + #[inline] + fn mix(&self, other: &Self, factor: f32) -> Self { + let n_factor = 1.0 - factor; + // TODO: Refactor this into EuclideanModulo::lerp_modulo + let shortest_angle = ((((other.hue - self.hue) % 360.) + 540.) % 360.) - 180.; + let mut hue = self.hue + shortest_angle * factor; + if hue < 0. { + hue += 360.; + } else if hue >= 360. { + hue -= 360.; + } + Self { + hue, + saturation: self.saturation * n_factor + other.saturation * factor, + lightness: self.lightness * n_factor + other.lightness * factor, + alpha: self.alpha * n_factor + other.alpha * factor, + } + } +} + +impl Alpha for Hsla { + #[inline] + fn with_alpha(&self, alpha: f32) -> Self { + Self { alpha, ..*self } + } + + #[inline] + fn alpha(&self) -> f32 { + self.alpha + } +} + +impl Luminance for Hsla { + #[inline] + fn with_luminance(&self, lightness: f32) -> Self { + Self { lightness, ..*self } + } + + fn luminance(&self) -> f32 { + self.lightness + } + + fn darker(&self, amount: f32) -> Self { + Self { + lightness: (self.lightness - amount).clamp(0., 1.), + ..*self + } + } + + fn lighter(&self, amount: f32) -> Self { + Self { + lightness: (self.lightness + amount).min(1.), + ..*self + } + } +} + +impl From for Hsla { + fn from(value: Srgba) -> Self { + let (h, s, l) = + HslRepresentation::nonlinear_srgb_to_hsl([value.red, value.green, value.blue]); + Self::new(h, s, l, value.alpha) + } +} + +impl From for Hsla { + fn from(value: LinearRgba) -> Self { + Hsla::from(Srgba::from(value)) + } +} + +impl From for bevy_render::color::Color { + fn from(value: Hsla) -> Self { + bevy_render::color::Color::Hsla { + hue: value.hue, + saturation: value.saturation, + lightness: value.lightness, + alpha: value.alpha, + } + } +} + +impl From for Hsla { + fn from(value: bevy_render::color::Color) -> Self { + match value.as_hsla() { + bevy_render::color::Color::Hsla { + hue, + saturation, + lightness, + alpha, + } => Hsla::new(hue, saturation, lightness, alpha), + _ => unreachable!(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + color_difference::EuclideanDistance, test_colors::TEST_COLORS, testing::assert_approx_eq, + Srgba, + }; + + #[test] + fn test_to_from_srgba() { + let hsla = Hsla::new(0.5, 0.5, 0.5, 1.0); + let srgba: Srgba = hsla.into(); + let hsla2: Hsla = srgba.into(); + assert_approx_eq!(hsla.hue, hsla2.hue, 0.001); + assert_approx_eq!(hsla.saturation, hsla2.saturation, 0.001); + assert_approx_eq!(hsla.lightness, hsla2.lightness, 0.001); + assert_approx_eq!(hsla.alpha, hsla2.alpha, 0.001); + } + + #[test] + fn test_to_from_srgba_2() { + for color in TEST_COLORS.iter() { + let rgb2: Srgba = (color.hsl).into(); + let hsl2: Hsla = (color.rgb).into(); + assert!( + color.rgb.distance(&rgb2) < 0.000001, + "{}: {:?} != {:?}", + color.name, + color.rgb, + rgb2 + ); + assert_approx_eq!(color.hsl.hue, hsl2.hue, 0.001); + assert_approx_eq!(color.hsl.saturation, hsl2.saturation, 0.001); + assert_approx_eq!(color.hsl.lightness, hsl2.lightness, 0.001); + assert_approx_eq!(color.hsl.alpha, hsl2.alpha, 0.001); + } + } + + #[test] + fn test_to_from_linear() { + let hsla = Hsla::new(0.5, 0.5, 0.5, 1.0); + let linear: LinearRgba = hsla.into(); + let hsla2: Hsla = linear.into(); + assert_approx_eq!(hsla.hue, hsla2.hue, 0.001); + assert_approx_eq!(hsla.saturation, hsla2.saturation, 0.001); + assert_approx_eq!(hsla.lightness, hsla2.lightness, 0.001); + assert_approx_eq!(hsla.alpha, hsla2.alpha, 0.001); + } + + #[test] + fn test_mix_wrap() { + let hsla0 = Hsla::new(10., 0.5, 0.5, 1.0); + let hsla1 = Hsla::new(20., 0.5, 0.5, 1.0); + let hsla2 = Hsla::new(350., 0.5, 0.5, 1.0); + assert_approx_eq!(hsla0.mix(&hsla1, 0.25).hue, 12.5, 0.001); + assert_approx_eq!(hsla0.mix(&hsla1, 0.5).hue, 15., 0.001); + assert_approx_eq!(hsla0.mix(&hsla1, 0.75).hue, 17.5, 0.001); + + assert_approx_eq!(hsla1.mix(&hsla0, 0.25).hue, 17.5, 0.001); + assert_approx_eq!(hsla1.mix(&hsla0, 0.5).hue, 15., 0.001); + assert_approx_eq!(hsla1.mix(&hsla0, 0.75).hue, 12.5, 0.001); + + assert_approx_eq!(hsla0.mix(&hsla2, 0.25).hue, 5., 0.001); + assert_approx_eq!(hsla0.mix(&hsla2, 0.5).hue, 0., 0.001); + assert_approx_eq!(hsla0.mix(&hsla2, 0.75).hue, 355., 0.001); + + assert_approx_eq!(hsla2.mix(&hsla0, 0.25).hue, 355., 0.001); + assert_approx_eq!(hsla2.mix(&hsla0, 0.5).hue, 0., 0.001); + assert_approx_eq!(hsla2.mix(&hsla0, 0.75).hue, 5., 0.001); + } +} diff --git a/crates/bevy_color/src/lcha.rs b/crates/bevy_color/src/lcha.rs new file mode 100644 index 00000000000000..f1712f92b6cc2a --- /dev/null +++ b/crates/bevy_color/src/lcha.rs @@ -0,0 +1,231 @@ +use crate::{Alpha, LinearRgba, Luminance, Mix, Srgba}; +use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize}; +use bevy_render::color::LchRepresentation; +use serde::{Deserialize, Serialize}; + +/// Color in LCH color space, with alpha +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)] +#[reflect(PartialEq, Serialize, Deserialize)] +pub struct Lcha { + /// The lightness channel. [0.0, 1.5] + pub lightness: f32, + /// The chroma channel. [0.0, 1.5] + pub chroma: f32, + /// The hue channel. [0.0, 360.0] + pub hue: f32, + /// The alpha channel. [0.0, 1.0] + pub alpha: f32, +} + +impl Lcha { + /// Construct a new [`Lcha`] color from components. + /// + /// # Arguments + /// + /// * `lightness` - Lightness channel. [0.0, 1.5] + /// * `chroma` - Chroma channel. [0.0, 1.5] + /// * `hue` - Hue channel. [0.0, 360.0] + /// * `alpha` - Alpha channel. [0.0, 1.0] + pub const fn new(lightness: f32, chroma: f32, hue: f32, alpha: f32) -> Self { + Self { + lightness, + chroma, + hue, + alpha, + } + } + + /// Construct a new [`Lcha`] color from (h, s, l) components, with the default alpha (1.0). + /// + /// # Arguments + /// + /// * `lightness` - Lightness channel. [0.0, 1.5] + /// * `chroma` - Chroma channel. [0.0, 1.5] + /// * `hue` - Hue channel. [0.0, 360.0] + pub const fn lch(lightness: f32, chroma: f32, hue: f32) -> Self { + Self { + lightness, + chroma, + hue, + alpha: 1.0, + } + } +} + +impl Default for Lcha { + fn default() -> Self { + Self::new(1., 0., 0., 1.) + } +} + +impl Mix for Lcha { + #[inline] + fn mix(&self, other: &Self, factor: f32) -> Self { + let n_factor = 1.0 - factor; + Self { + lightness: self.lightness * n_factor + other.lightness * factor, + chroma: self.chroma * n_factor + other.chroma * factor, + hue: self.hue * n_factor + other.hue * factor, + alpha: self.alpha * n_factor + other.alpha * factor, + } + } +} + +impl Alpha for Lcha { + #[inline] + fn with_alpha(&self, alpha: f32) -> Self { + Self { alpha, ..*self } + } + + #[inline] + fn alpha(&self) -> f32 { + self.alpha + } +} + +impl Luminance for Lcha { + #[inline] + fn with_luminance(&self, lightness: f32) -> Self { + Self { lightness, ..*self } + } + + fn luminance(&self) -> f32 { + self.lightness + } + + fn darker(&self, amount: f32) -> Self { + Self::new( + (self.lightness - amount).max(0.), + self.chroma, + self.hue, + self.alpha, + ) + } + + fn lighter(&self, amount: f32) -> Self { + Self::new( + (self.lightness + amount).min(1.), + self.chroma, + self.hue, + self.alpha, + ) + } +} + +impl From for Lcha { + fn from(value: Srgba) -> Self { + let (l, c, h) = + LchRepresentation::nonlinear_srgb_to_lch([value.red, value.green, value.blue]); + Lcha::new(l, c, h, value.alpha) + } +} + +impl From for Srgba { + fn from(value: Lcha) -> Self { + let [r, g, b] = + LchRepresentation::lch_to_nonlinear_srgb(value.lightness, value.chroma, value.hue); + Srgba::new(r, g, b, value.alpha) + } +} + +impl From for Lcha { + fn from(value: LinearRgba) -> Self { + Srgba::from(value).into() + } +} + +impl From for LinearRgba { + fn from(value: Lcha) -> Self { + LinearRgba::from(Srgba::from(value)) + } +} + +impl From for bevy_render::color::Color { + fn from(value: Lcha) -> Self { + bevy_render::color::Color::Lcha { + hue: value.hue, + chroma: value.chroma, + lightness: value.lightness, + alpha: value.alpha, + } + } +} + +impl From for Lcha { + fn from(value: bevy_render::color::Color) -> Self { + match value.as_lcha() { + bevy_render::color::Color::Lcha { + hue, + chroma, + lightness, + alpha, + } => Lcha::new(hue, chroma, lightness, alpha), + _ => unreachable!(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + color_difference::EuclideanDistance, test_colors::TEST_COLORS, testing::assert_approx_eq, + Srgba, + }; + + #[test] + fn test_to_from_srgba() { + for color in TEST_COLORS.iter() { + let rgb2: Srgba = (color.lch).into(); + let lcha: Lcha = (color.rgb).into(); + assert!( + color.rgb.distance(&rgb2) < 0.0001, + "{}: {:?} != {:?}", + color.name, + color.rgb, + rgb2 + ); + assert_approx_eq!(color.lch.lightness, lcha.lightness, 0.001); + if lcha.lightness > 0.01 { + assert_approx_eq!(color.lch.chroma, lcha.chroma, 0.1); + } + if lcha.lightness > 0.01 && lcha.chroma > 0.01 { + assert!( + (color.lch.hue - lcha.hue).abs() < 1.7, + "{:?} != {:?}", + color.lch, + lcha + ); + } + assert_approx_eq!(color.lch.alpha, lcha.alpha, 0.001); + } + } + + #[test] + fn test_to_from_linear() { + for color in TEST_COLORS.iter() { + let rgb2: LinearRgba = (color.lch).into(); + let lcha: Lcha = (color.linear_rgb).into(); + assert!( + color.linear_rgb.distance(&rgb2) < 0.0001, + "{}: {:?} != {:?}", + color.name, + color.linear_rgb, + rgb2 + ); + assert_approx_eq!(color.lch.lightness, lcha.lightness, 0.001); + if lcha.lightness > 0.01 { + assert_approx_eq!(color.lch.chroma, lcha.chroma, 0.1); + } + if lcha.lightness > 0.01 && lcha.chroma > 0.01 { + assert!( + (color.lch.hue - lcha.hue).abs() < 1.7, + "{:?} != {:?}", + color.lch, + lcha + ); + } + assert_approx_eq!(color.lch.alpha, lcha.alpha, 0.001); + } + } +} diff --git a/crates/bevy_color/src/lib.rs b/crates/bevy_color/src/lib.rs new file mode 100644 index 00000000000000..dcfa41039d009c --- /dev/null +++ b/crates/bevy_color/src/lib.rs @@ -0,0 +1,88 @@ +//! Representations of colors in various color spaces. +//! +//! This crate provides a number of color representations, including: +//! +//! - [`Srgba`] (standard RGBA, with gamma correction) +//! - [`LinearRgba`] (linear RGBA, without gamma correction) +//! - [`Hsla`] (hue, saturation, lightness, alpha) +//! - [`Lcha`] (lightness, chroma, hue, alpha) +//! - [`Oklaba`] (lightness, a-axis, b-axis, alpha) +//! +//! Each of these color spaces is represented as a distinct Rust type. +//! +//! # Color Space Usage +//! +//! Rendering engines typically use linear RGBA colors, which allow for physically accurate +//! lighting calculations. However, linear RGBA colors are not perceptually uniform, because +//! both human eyes and computer monitors have non-linear responses to light. "Standard" RGBA +//! represents an industry-wide compromise designed to encode colors in a way that looks good to +//! humans in as few bits as possible, but it is not suitable for lighting calculations. +//! +//! Most image file formats and scene graph formats use standard RGBA, because graphic design +//! tools are intended to be used by humans. However, 3D lighting calculations operate in linear +//! RGBA, so it is important to convert standard colors to linear before sending them to the GPU. +//! Most Bevy APIs will handle this conversion automatically, but if you are writing a custom +//! shader, you will need to do this conversion yourself. +//! +//! HSL and LCH are "cylindrical" color spaces, which means they represent colors as a combination +//! of hue, saturation, and lightness (or chroma). These color spaces are useful for working +//! with colors in an artistic way - for example, when creating gradients or color palettes. +//! A gradient in HSL space from red to violet will produce a rainbow. The LCH color space is +//! more perceptually accurate than HSL, but is less intuitive to work with. +//! +//! Oklab is a perceptually uniform color space that is designed to be used for tasks such +//! as image processing. It is not as widely used as the other color spaces, but it is useful +//! for tasks such as color correction and image analysis, where it is important to be able +//! to do things like change color saturation without causing hue shifts. +//! +//! See also the [Wikipedia article on color spaces](https://en.wikipedia.org/wiki/Color_space). +//! +//! # Conversions +//! +//! Each color space can be converted to and from the others using the [`From`] trait. Not all +//! possible combinations of conversions are provided, but every color space has a converstion to +//! and from [`Srgba`] and [`LinearRgba`]. +//! +//! # Other Utilities +//! +//! The crate also provides a number of color operations, such as blending, color difference, +//! and color range operations. +//! +//! In addition, there is a [`Color`] enum that can represent any of the color +//! types in this crate. This is useful when you need to store a color in a data structure +//! that can't be generic over the color type. +//! +//! # Example +//! +//! ``` +//! use bevy_color::{Srgba, Hsla}; +//! +//! let srgba = Srgba::new(0.5, 0.2, 0.8, 1.0); +//! let hsla: Hsla = srgba.into(); +//! +//! println!("Srgba: {:?}", srgba); +//! println!("Hsla: {:?}", hsla); +//! ``` + +mod color; +pub mod color_difference; +mod color_ops; +mod color_range; +mod hsla; +mod lcha; +mod linear_rgba; +mod oklaba; +mod srgba; +#[cfg(test)] +mod test_colors; +#[cfg(test)] +mod testing; + +pub use color::*; +pub use color_ops::*; +pub use color_range::*; +pub use hsla::*; +pub use lcha::*; +pub use linear_rgba::*; +pub use oklaba::*; +pub use srgba::*; diff --git a/crates/bevy_color/src/linear_rgba.rs b/crates/bevy_color/src/linear_rgba.rs new file mode 100644 index 00000000000000..9d4c8a1cc09160 --- /dev/null +++ b/crates/bevy_color/src/linear_rgba.rs @@ -0,0 +1,264 @@ +use crate::{ + color_difference::EuclideanDistance, oklaba::Oklaba, Alpha, Hsla, Luminance, Mix, Srgba, +}; +use bevy_math::Vec4; +use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize}; +use bevy_render::color::SrgbColorSpace; +use serde::{Deserialize, Serialize}; + +/// Linear RGB color with alpha. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)] +#[reflect(PartialEq, Serialize, Deserialize)] +pub struct LinearRgba { + /// The red channel. [0.0, 1.0] + pub red: f32, + /// The green channel. [0.0, 1.0] + pub green: f32, + /// The blue channel. [0.0, 1.0] + pub blue: f32, + /// The alpha channel. [0.0, 1.0] + pub alpha: f32, +} + +impl LinearRgba { + /// Construct a new [`LinearRgba`] color from components. + pub const fn new(red: f32, green: f32, blue: f32, alpha: f32) -> Self { + Self { + red, + green, + blue, + alpha, + } + } + + /// Construct a new [`LinearRgba`] color from (r, g, b) components, with the default alpha (1.0). + /// + /// # Arguments + /// + /// * `red` - Red channel. [0.0, 1.0] + /// * `green` - Green channel. [0.0, 1.0] + /// * `blue` - Blue channel. [0.0, 1.0] + pub const fn rgb(red: f32, green: f32, blue: f32) -> Self { + Self { + red, + green, + blue, + alpha: 1.0, + } + } + + /// Make the color lighter or darker by some amount + fn adjust_lightness(&mut self, amount: f32) { + let luminance = self.luminance(); + let target_luminance = (luminance + amount).clamp(0.0, 1.0); + if target_luminance < luminance { + let adjustment = (luminance - target_luminance) / luminance; + self.mix_assign(Self::new(0.0, 0.0, 0.0, self.alpha), adjustment); + } else if target_luminance > luminance { + let adjustment = (target_luminance - luminance) / (1. - luminance); + self.mix_assign(Self::new(1.0, 1.0, 1.0, self.alpha), adjustment); + } + } +} + +impl Default for LinearRgba { + /// Construct a new [`LinearRgba`] color with the default values (white with full alpha). + fn default() -> Self { + Self { + red: 1., + green: 1., + blue: 1., + alpha: 1., + } + } +} + +impl Luminance for LinearRgba { + /// Luminance calculated using the [CIE XYZ formula](https://en.wikipedia.org/wiki/Relative_luminance). + #[inline] + fn luminance(&self) -> f32 { + self.red * 0.2126 + self.green * 0.7152 + self.blue * 0.0722 + } + + #[inline] + fn with_luminance(&self, luminance: f32) -> Self { + let current_luminance = self.luminance(); + let adjustment = luminance / current_luminance; + Self { + red: (self.red * adjustment).clamp(0., 1.), + green: (self.green * adjustment).clamp(0., 1.), + blue: (self.blue * adjustment).clamp(0., 1.), + alpha: self.alpha, + } + } + + #[inline] + fn darker(&self, amount: f32) -> Self { + let mut result = *self; + result.adjust_lightness(-amount); + result + } + + #[inline] + fn lighter(&self, amount: f32) -> Self { + let mut result = *self; + result.adjust_lightness(amount); + result + } +} + +impl Mix for LinearRgba { + #[inline] + fn mix(&self, other: &Self, factor: f32) -> Self { + let n_factor = 1.0 - factor; + Self { + red: self.red * n_factor + other.red * factor, + green: self.green * n_factor + other.green * factor, + blue: self.blue * n_factor + other.blue * factor, + alpha: self.alpha * n_factor + other.alpha * factor, + } + } +} + +impl Alpha for LinearRgba { + #[inline] + fn with_alpha(&self, alpha: f32) -> Self { + Self { alpha, ..*self } + } + + #[inline] + fn alpha(&self) -> f32 { + self.alpha + } +} + +impl EuclideanDistance for LinearRgba { + #[inline] + fn distance_squared(&self, other: &Self) -> f32 { + let dr = self.red - other.red; + let dg = self.green - other.green; + let db = self.blue - other.blue; + dr * dr + dg * dg + db * db + } +} + +impl From for LinearRgba { + #[inline] + fn from(value: Srgba) -> Self { + Self { + red: value.red.nonlinear_to_linear_srgb(), + green: value.green.nonlinear_to_linear_srgb(), + blue: value.blue.nonlinear_to_linear_srgb(), + alpha: value.alpha, + } + } +} + +impl From for bevy_render::color::Color { + fn from(value: LinearRgba) -> Self { + bevy_render::color::Color::RgbaLinear { + red: value.red, + green: value.green, + blue: value.blue, + alpha: value.alpha, + } + } +} + +impl From for LinearRgba { + fn from(value: bevy_render::color::Color) -> Self { + match value.as_rgba_linear() { + bevy_render::color::Color::RgbaLinear { + red, + green, + blue, + alpha, + } => LinearRgba::new(red, green, blue, alpha), + _ => unreachable!(), + } + } +} + +impl From for [f32; 4] { + fn from(color: LinearRgba) -> Self { + [color.red, color.green, color.blue, color.alpha] + } +} + +impl From for Vec4 { + fn from(color: LinearRgba) -> Self { + Vec4::new(color.red, color.green, color.blue, color.alpha) + } +} + +#[allow(clippy::excessive_precision)] +impl From for LinearRgba { + fn from(value: Oklaba) -> Self { + let Oklaba { l, a, b, alpha } = value; + + // From https://github.com/Ogeon/palette/blob/e75eab2fb21af579353f51f6229a510d0d50a311/palette/src/oklab.rs#L312-L332 + let l_ = l + 0.3963377774 * a + 0.2158037573 * b; + let m_ = l - 0.1055613458 * a - 0.0638541728 * b; + let s_ = l - 0.0894841775 * a - 1.2914855480 * b; + + let l = l_ * l_ * l_; + let m = m_ * m_ * m_; + let s = s_ * s_ * s_; + + let red = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + let green = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + let blue = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + + Self { + red, + green, + blue, + alpha, + } + } +} + +impl From for LinearRgba { + #[inline] + fn from(value: Hsla) -> Self { + LinearRgba::from(Srgba::from(value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn euclidean_distance() { + // White to black + let a = LinearRgba::new(0.0, 0.0, 0.0, 1.0); + let b = LinearRgba::new(1.0, 1.0, 1.0, 1.0); + assert_eq!(a.distance_squared(&b), 3.0); + + // Alpha shouldn't matter + let a = LinearRgba::new(0.0, 0.0, 0.0, 1.0); + let b = LinearRgba::new(1.0, 1.0, 1.0, 0.0); + assert_eq!(a.distance_squared(&b), 3.0); + + // Red to green + let a = LinearRgba::new(0.0, 0.0, 0.0, 1.0); + let b = LinearRgba::new(1.0, 0.0, 0.0, 1.0); + assert_eq!(a.distance_squared(&b), 1.0); + } + + #[test] + fn darker_lighter() { + // Darker and lighter should be commutative. + let color = LinearRgba::new(0.4, 0.5, 0.6, 1.0); + let darker1 = color.darker(0.1); + let darker2 = darker1.darker(0.1); + let twice_as_dark = color.darker(0.2); + assert!(darker2.distance_squared(&twice_as_dark) < 0.0001); + + let lighter1 = color.lighter(0.1); + let lighter2 = lighter1.lighter(0.1); + let twice_as_light = color.lighter(0.2); + assert!(lighter2.distance_squared(&twice_as_light) < 0.0001); + } +} diff --git a/crates/bevy_color/src/oklaba.rs b/crates/bevy_color/src/oklaba.rs new file mode 100644 index 00000000000000..d77237ae801751 --- /dev/null +++ b/crates/bevy_color/src/oklaba.rs @@ -0,0 +1,183 @@ +use crate::{color_difference::EuclideanDistance, Alpha, LinearRgba, Luminance, Mix, Srgba}; +use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize}; +use serde::{Deserialize, Serialize}; + +/// Color in Oklaba color space, with alpha +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)] +#[reflect(PartialEq, Serialize, Deserialize)] +pub struct Oklaba { + /// The 'l' channel. [0.0, 1.0] + pub l: f32, + /// The 'a' channel. [-1.0, 1.0] + pub a: f32, + /// The 'b' channel. [-1.0, 1.0] + pub b: f32, + /// The alpha channel. [0.0, 1.0] + pub alpha: f32, +} + +impl Oklaba { + /// Construct a new [`Oklaba`] color from components. + /// + /// # Arguments + /// + /// * `l` - Lightness channel. [0.0, 1.0] + /// * `a` - Green-red channel. [-1.0, 1.0] + /// * `b` - Blue-yellow channel. [-1.0, 1.0] + /// * `alpha` - Alpha channel. [0.0, 1.0] + pub const fn new(l: f32, a: f32, b: f32, alpha: f32) -> Self { + Self { l, a, b, alpha } + } + + /// Construct a new [`Oklaba`] color from (l, a, b) components, with the default alpha (1.0). + /// + /// # Arguments + /// + /// * `l` - Lightness channel. [0.0, 1.0] + /// * `a` - Green-red channel. [-1.0, 1.0] + /// * `b` - Blue-yellow channel. [-1.0, 1.0] + pub const fn lch(l: f32, a: f32, b: f32) -> Self { + Self { + l, + a, + b, + alpha: 1.0, + } + } +} + +impl Default for Oklaba { + fn default() -> Self { + Self::new(1., 0., 0., 1.) + } +} + +impl Mix for Oklaba { + #[inline] + fn mix(&self, other: &Self, factor: f32) -> Self { + let n_factor = 1.0 - factor; + Self { + l: self.l * n_factor + other.l * factor, + a: self.a * n_factor + other.a * factor, + b: self.b * n_factor + other.b * factor, + alpha: self.alpha * n_factor + other.alpha * factor, + } + } +} + +impl Alpha for Oklaba { + #[inline] + fn with_alpha(&self, alpha: f32) -> Self { + Self { alpha, ..*self } + } + + #[inline] + fn alpha(&self) -> f32 { + self.alpha + } +} + +impl Luminance for Oklaba { + #[inline] + fn with_luminance(&self, l: f32) -> Self { + Self { l, ..*self } + } + + fn luminance(&self) -> f32 { + self.l + } + + fn darker(&self, amount: f32) -> Self { + Self::new((self.l - amount).max(0.), self.a, self.b, self.alpha) + } + + fn lighter(&self, amount: f32) -> Self { + Self::new((self.l + amount).min(1.), self.a, self.b, self.alpha) + } +} + +impl EuclideanDistance for Oklaba { + #[inline] + fn distance_squared(&self, other: &Self) -> f32 { + (self.l - other.l).powi(2) + (self.a - other.a).powi(2) + (self.b - other.b).powi(2) + } +} + +#[allow(clippy::excessive_precision)] +impl From for Oklaba { + fn from(value: LinearRgba) -> Self { + let LinearRgba { + red, + green, + blue, + alpha, + } = value; + // From https://github.com/DougLau/pix + let l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue; + let m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue; + let s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue; + let l_ = l.cbrt(); + let m_ = m.cbrt(); + let s_ = s.cbrt(); + let l = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; + let a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; + let b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; + Oklaba::new(l, a, b, alpha) + } +} + +impl From for Oklaba { + fn from(value: Srgba) -> Self { + Oklaba::from(LinearRgba::from(value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{test_colors::TEST_COLORS, testing::assert_approx_eq, Srgba}; + + #[test] + fn test_to_from_srgba() { + let oklaba = Oklaba::new(0.5, 0.5, 0.5, 1.0); + let srgba: Srgba = oklaba.into(); + let oklaba2: Oklaba = srgba.into(); + assert_approx_eq!(oklaba.l, oklaba2.l, 0.001); + assert_approx_eq!(oklaba.a, oklaba2.a, 0.001); + assert_approx_eq!(oklaba.b, oklaba2.b, 0.001); + assert_approx_eq!(oklaba.alpha, oklaba2.alpha, 0.001); + } + + #[test] + fn test_to_from_srgba_2() { + for color in TEST_COLORS.iter() { + let rgb2: Srgba = (color.oklab).into(); + let oklab: Oklaba = (color.rgb).into(); + assert!( + color.rgb.distance(&rgb2) < 0.0001, + "{}: {:?} != {:?}", + color.name, + color.rgb, + rgb2 + ); + assert!( + color.oklab.distance(&oklab) < 0.0001, + "{}: {:?} != {:?}", + color.name, + color.oklab, + oklab + ); + } + } + + #[test] + fn test_to_from_linear() { + let oklaba = Oklaba::new(0.5, 0.5, 0.5, 1.0); + let linear: LinearRgba = oklaba.into(); + let oklaba2: Oklaba = linear.into(); + assert_approx_eq!(oklaba.l, oklaba2.l, 0.001); + assert_approx_eq!(oklaba.a, oklaba2.a, 0.001); + assert_approx_eq!(oklaba.b, oklaba2.b, 0.001); + assert_approx_eq!(oklaba.alpha, oklaba2.alpha, 0.001); + } +} diff --git a/crates/bevy_color/src/srgba.rs b/crates/bevy_color/src/srgba.rs new file mode 100644 index 00000000000000..a25bb58baa8290 --- /dev/null +++ b/crates/bevy_color/src/srgba.rs @@ -0,0 +1,430 @@ +use crate::color_difference::EuclideanDistance; +use crate::oklaba::Oklaba; +use crate::{Alpha, Hsla, LinearRgba, Luminance, Mix}; +use bevy_math::Vec4; +use bevy_reflect::{Reflect, ReflectDeserialize, ReflectSerialize}; +use bevy_render::color::{HexColorError, HslRepresentation, SrgbColorSpace}; +use serde::{Deserialize, Serialize}; + +/// Non-linear standard RGB with alpha. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Reflect)] +#[reflect(PartialEq, Serialize, Deserialize)] +pub struct Srgba { + /// The red channel. [0.0, 1.0] + pub red: f32, + /// The green channel. [0.0, 1.0] + pub green: f32, + /// The blue channel. [0.0, 1.0] + pub blue: f32, + /// The alpha channel. [0.0, 1.0] + pub alpha: f32, +} + +impl Srgba { + // The standard VGA colors, with alpha set to 1.0. + // https://en.wikipedia.org/wiki/Web_colors#Basic_colors + + ///
+ pub const BLACK: Srgba = Srgba::new(0.0, 0.0, 0.0, 1.0); + ///
+ pub const BLUE: Srgba = Srgba::new(0.0, 0.0, 1.0, 1.0); + ///
+ pub const CYAN: Srgba = Srgba::new(0.0, 1.0, 1.0, 1.0); + ///
+ pub const DARK_GRAY: Srgba = Srgba::new(0.25, 0.25, 0.25, 1.0); + ///
+ pub const GREEN: Srgba = Srgba::new(0.0, 0.5, 0.0, 1.0); + ///
+ pub const FUCHSIA: Srgba = Srgba::new(1.0, 0.0, 1.0, 1.0); + ///
+ pub const GRAY: Srgba = Srgba::new(0.5, 0.5, 0.5, 1.0); + ///
+ pub const LIME: Srgba = Srgba::new(0.0, 1.0, 0.0, 1.0); + ///
+ pub const MAROON: Srgba = Srgba::new(0.5, 0.0, 0.0, 1.0); + ///
+ pub const NAVY: Srgba = Srgba::new(0.0, 0.0, 0.5, 1.0); + ///
+ #[doc(alias = "transparent")] + pub const NONE: Srgba = Srgba::new(0.0, 0.0, 0.0, 0.0); + ///
+ pub const OLIVE: Srgba = Srgba::new(0.5, 0.5, 0.0, 1.0); + ///
+ pub const PURPLE: Srgba = Srgba::new(0.5, 0.0, 0.5, 1.0); + ///
+ pub const RED: Srgba = Srgba::new(1.0, 0.0, 0.0, 1.0); + ///
+ pub const SILVER: Srgba = Srgba::new(0.75, 0.75, 0.75, 1.0); + ///
+ pub const TEAL: Srgba = Srgba::new(0.0, 0.5, 0.5, 1.0); + ///
+ pub const WHITE: Srgba = Srgba::new(1.0, 1.0, 1.0, 1.0); + ///
+ pub const YELLOW: Srgba = Srgba::new(1.0, 1.0, 0.0, 1.0); + + /// Construct a new [`Srgba`] color from components. + /// + /// # Arguments + /// + /// * `red` - Red channel. [0.0, 1.0] + /// * `green` - Green channel. [0.0, 1.0] + /// * `blue` - Blue channel. [0.0, 1.0] + /// * `alpha` - Alpha channel. [0.0, 1.0] + pub const fn new(red: f32, green: f32, blue: f32, alpha: f32) -> Self { + Self { + red, + green, + blue, + alpha, + } + } + + /// Construct a new [`Srgba`] color from (r, g, b) components, with the default alpha (1.0). + /// + /// # Arguments + /// + /// * `red` - Red channel. [0.0, 1.0] + /// * `green` - Green channel. [0.0, 1.0] + /// * `blue` - Blue channel. [0.0, 1.0] + pub const fn rgb(red: f32, green: f32, blue: f32) -> Self { + Self { + red, + green, + blue, + alpha: 1.0, + } + } + + /// New `Srgba` from a CSS-style hexadecimal string. + /// + /// # Examples + /// + /// ``` + /// # use bevy_color::Srgba; + /// let color = Srgba::hex("FF00FF").unwrap(); // fuchsia + /// let color = Srgba::hex("FF00FF7F").unwrap(); // partially transparent fuchsia + /// + /// // A standard hex color notation is also available + /// assert_eq!(Srgba::hex("#FFFFFF").unwrap(), Srgba::new(1.0, 1.0, 1.0, 1.0)); + /// ``` + pub fn hex>(hex: T) -> Result { + let hex = hex.as_ref(); + let hex = hex.strip_prefix('#').unwrap_or(hex); + + match *hex.as_bytes() { + // RGB + [r, g, b] => { + let [r, g, b, ..] = decode_hex([r, r, g, g, b, b])?; + Ok(Self::rgb_u8(r, g, b)) + } + // RGBA + [r, g, b, a] => { + let [r, g, b, a, ..] = decode_hex([r, r, g, g, b, b, a, a])?; + Ok(Self::rgba_u8(r, g, b, a)) + } + // RRGGBB + [r1, r2, g1, g2, b1, b2] => { + let [r, g, b, ..] = decode_hex([r1, r2, g1, g2, b1, b2])?; + Ok(Self::rgb_u8(r, g, b)) + } + // RRGGBBAA + [r1, r2, g1, g2, b1, b2, a1, a2] => { + let [r, g, b, a, ..] = decode_hex([r1, r2, g1, g2, b1, b2, a1, a2])?; + Ok(Self::rgba_u8(r, g, b, a)) + } + _ => Err(HexColorError::Length), + } + } + + /// Convert this color to CSS-style hexadecimal notation. + pub fn to_hex(&self) -> String { + let r = (self.red * 255.0).round() as u8; + let g = (self.green * 255.0).round() as u8; + let b = (self.blue * 255.0).round() as u8; + let a = (self.alpha * 255.0).round() as u8; + match a { + 255 => format!("#{:02X}{:02X}{:02X}", r, g, b), + _ => format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a), + } + } + + /// New `Srgba` from sRGB colorspace. + /// + /// # Arguments + /// + /// * `r` - Red channel. [0, 255] + /// * `g` - Green channel. [0, 255] + /// * `b` - Blue channel. [0, 255] + /// + /// See also [`Srgba::new`], [`Srgba::rgba_u8`], [`Srgba::hex`]. + /// + pub fn rgb_u8(r: u8, g: u8, b: u8) -> Self { + Self::rgba_u8(r, g, b, u8::MAX) + } + + // Float operations in const fn are not stable yet + // see https://github.com/rust-lang/rust/issues/57241 + /// New `Srgba` from sRGB colorspace. + /// + /// # Arguments + /// + /// * `r` - Red channel. [0, 255] + /// * `g` - Green channel. [0, 255] + /// * `b` - Blue channel. [0, 255] + /// * `a` - Alpha channel. [0, 255] + /// + /// See also [`Srgba::new`], [`Srgba::rgb_u8`], [`Srgba::hex`]. + /// + pub fn rgba_u8(r: u8, g: u8, b: u8, a: u8) -> Self { + Self::new( + r as f32 / u8::MAX as f32, + g as f32 / u8::MAX as f32, + b as f32 / u8::MAX as f32, + a as f32 / u8::MAX as f32, + ) + } +} + +impl Default for Srgba { + fn default() -> Self { + Self::WHITE + } +} + +impl Luminance for Srgba { + #[inline] + fn luminance(&self) -> f32 { + let linear: LinearRgba = (*self).into(); + linear.luminance() + } + + #[inline] + fn with_luminance(&self, luminance: f32) -> Self { + let linear: LinearRgba = (*self).into(); + linear + .with_luminance(luminance.nonlinear_to_linear_srgb()) + .into() + } + + #[inline] + fn darker(&self, amount: f32) -> Self { + let linear: LinearRgba = (*self).into(); + linear.darker(amount).into() + } + + #[inline] + fn lighter(&self, amount: f32) -> Self { + let linear: LinearRgba = (*self).into(); + linear.lighter(amount).into() + } +} + +impl Mix for Srgba { + #[inline] + fn mix(&self, other: &Self, factor: f32) -> Self { + let n_factor = 1.0 - factor; + Self { + red: self.red * n_factor + other.red * factor, + green: self.green * n_factor + other.green * factor, + blue: self.blue * n_factor + other.blue * factor, + alpha: self.alpha * n_factor + other.alpha * factor, + } + } +} + +impl Alpha for Srgba { + #[inline] + fn with_alpha(&self, alpha: f32) -> Self { + Self { alpha, ..*self } + } + + #[inline] + fn alpha(&self) -> f32 { + self.alpha + } +} + +impl EuclideanDistance for Srgba { + #[inline] + fn distance_squared(&self, other: &Self) -> f32 { + let dr = self.red - other.red; + let dg = self.green - other.green; + let db = self.blue - other.blue; + dr * dr + dg * dg + db * db + } +} + +impl From for Srgba { + #[inline] + fn from(value: LinearRgba) -> Self { + Self { + red: value.red.linear_to_nonlinear_srgb(), + green: value.green.linear_to_nonlinear_srgb(), + blue: value.blue.linear_to_nonlinear_srgb(), + alpha: value.alpha, + } + } +} + +impl From for Srgba { + fn from(value: Hsla) -> Self { + let [r, g, b] = + HslRepresentation::hsl_to_nonlinear_srgb(value.hue, value.saturation, value.lightness); + Self::new(r, g, b, value.alpha) + } +} + +impl From for Srgba { + fn from(value: Oklaba) -> Self { + Srgba::from(LinearRgba::from(value)) + } +} + +impl From for bevy_render::color::Color { + fn from(value: Srgba) -> Self { + bevy_render::color::Color::Rgba { + red: value.red, + green: value.green, + blue: value.blue, + alpha: value.alpha, + } + } +} + +impl From for Srgba { + fn from(value: bevy_render::color::Color) -> Self { + match value.as_rgba() { + bevy_render::color::Color::Rgba { + red, + green, + blue, + alpha, + } => Srgba::new(red, green, blue, alpha), + _ => unreachable!(), + } + } +} + +impl From for [f32; 4] { + fn from(color: Srgba) -> Self { + [color.red, color.green, color.blue, color.alpha] + } +} + +impl From for Vec4 { + fn from(color: Srgba) -> Self { + Vec4::new(color.red, color.green, color.blue, color.alpha) + } +} + +/// Converts hex bytes to an array of RGB\[A\] components +/// +/// # Example +/// For RGB: *b"ffffff" -> [255, 255, 255, ..] +/// For RGBA: *b"E2E2E2FF" -> [226, 226, 226, 255, ..] +const fn decode_hex(mut bytes: [u8; N]) -> Result<[u8; N], HexColorError> { + let mut i = 0; + while i < bytes.len() { + // Convert single hex digit to u8 + let val = match hex_value(bytes[i]) { + Ok(val) => val, + Err(byte) => return Err(HexColorError::Char(byte as char)), + }; + bytes[i] = val; + i += 1; + } + // Modify the original bytes to give an `N / 2` length result + i = 0; + while i < bytes.len() / 2 { + // Convert pairs of u8 to R/G/B/A + // e.g `ff` -> [102, 102] -> [15, 15] = 255 + bytes[i] = bytes[i * 2] * 16 + bytes[i * 2 + 1]; + i += 1; + } + Ok(bytes) +} + +/// Parse a single hex digit (a-f/A-F/0-9) as a `u8` +const fn hex_value(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'A'..=b'F' => Ok(b - b'A' + 10), + b'a'..=b'f' => Ok(b - b'a' + 10), + // Wrong hex digit + _ => Err(b), + } +} + +#[cfg(test)] +mod tests { + use crate::testing::assert_approx_eq; + + use super::*; + + #[test] + fn test_to_from_linear() { + let srgba = Srgba::new(0.0, 0.5, 1.0, 1.0); + let linear_rgba: LinearRgba = srgba.into(); + assert_eq!(linear_rgba.red, 0.0); + assert_approx_eq!(linear_rgba.green, 0.2140, 0.0001); + assert_approx_eq!(linear_rgba.blue, 1.0, 0.0001); + assert_eq!(linear_rgba.alpha, 1.0); + let srgba2: Srgba = linear_rgba.into(); + assert_eq!(srgba2.red, 0.0); + assert_approx_eq!(srgba2.green, 0.5, 0.0001); + assert_approx_eq!(srgba2.blue, 1.0, 0.0001); + assert_eq!(srgba2.alpha, 1.0); + } + + #[test] + fn euclidean_distance() { + // White to black + let a = Srgba::new(0.0, 0.0, 0.0, 1.0); + let b = Srgba::new(1.0, 1.0, 1.0, 1.0); + assert_eq!(a.distance_squared(&b), 3.0); + + // Alpha shouldn't matter + let a = Srgba::new(0.0, 0.0, 0.0, 1.0); + let b = Srgba::new(1.0, 1.0, 1.0, 0.0); + assert_eq!(a.distance_squared(&b), 3.0); + + // Red to green + let a = Srgba::new(0.0, 0.0, 0.0, 1.0); + let b = Srgba::new(1.0, 0.0, 0.0, 1.0); + assert_eq!(a.distance_squared(&b), 1.0); + } + + #[test] + fn darker_lighter() { + // Darker and lighter should be commutative. + let color = Srgba::new(0.4, 0.5, 0.6, 1.0); + let darker1 = color.darker(0.1); + let darker2 = darker1.darker(0.1); + let twice_as_dark = color.darker(0.2); + assert!(darker2.distance_squared(&twice_as_dark) < 0.0001); + + let lighter1 = color.lighter(0.1); + let lighter2 = lighter1.lighter(0.1); + let twice_as_light = color.lighter(0.2); + assert!(lighter2.distance_squared(&twice_as_light) < 0.0001); + } + + #[test] + fn hex_color() { + assert_eq!(Srgba::hex("FFF"), Ok(Srgba::WHITE)); + assert_eq!(Srgba::hex("FFFF"), Ok(Srgba::WHITE)); + assert_eq!(Srgba::hex("FFFFFF"), Ok(Srgba::WHITE)); + assert_eq!(Srgba::hex("FFFFFFFF"), Ok(Srgba::WHITE)); + assert_eq!(Srgba::hex("000"), Ok(Srgba::BLACK)); + assert_eq!(Srgba::hex("000F"), Ok(Srgba::BLACK)); + assert_eq!(Srgba::hex("000000"), Ok(Srgba::BLACK)); + assert_eq!(Srgba::hex("000000FF"), Ok(Srgba::BLACK)); + assert_eq!(Srgba::hex("03a9f4"), Ok(Srgba::rgb_u8(3, 169, 244))); + assert_eq!(Srgba::hex("yy"), Err(HexColorError::Length)); + assert_eq!(Srgba::hex("yyy"), Err(HexColorError::Char('y'))); + assert_eq!(Srgba::hex("#f2a"), Ok(Srgba::rgb_u8(255, 34, 170))); + assert_eq!(Srgba::hex("#e23030"), Ok(Srgba::rgb_u8(226, 48, 48))); + assert_eq!(Srgba::hex("#ff"), Err(HexColorError::Length)); + assert_eq!(Srgba::hex("##fff"), Err(HexColorError::Char('#'))); + } +} diff --git a/crates/bevy_color/src/test_colors.rs b/crates/bevy_color/src/test_colors.rs new file mode 100644 index 00000000000000..6d0e95a29e67f9 --- /dev/null +++ b/crates/bevy_color/src/test_colors.rs @@ -0,0 +1,180 @@ +// Generated by gen_tests. Do not edit. +#[cfg(test)] +use crate::{Hsla, Lcha, LinearRgba, Oklaba, Srgba}; + +#[cfg(test)] +pub struct TestColor { + pub name: &'static str, + pub rgb: Srgba, + pub linear_rgb: LinearRgba, + pub hsl: Hsla, + pub lch: Lcha, + pub oklab: Oklaba, +} + +// Table of equivalent colors in various color spaces +#[cfg(test)] +pub const TEST_COLORS: &[TestColor] = &[ + // black + TestColor { + name: "black", + rgb: Srgba::new(0.0, 0.0, 0.0, 1.0), + linear_rgb: LinearRgba::new(0.0, 0.0, 0.0, 1.0), + hsl: Hsla::new(0.0, 0.0, 0.0, 1.0), + lch: Lcha::new(0.0, 0.0, 0.0000136603785, 1.0), + oklab: Oklaba::new(0.0, 0.0, 0.0, 1.0), + }, + // white + TestColor { + name: "white", + rgb: Srgba::new(1.0, 1.0, 1.0, 1.0), + linear_rgb: LinearRgba::new(1.0, 1.0, 1.0, 1.0), + hsl: Hsla::new(0.0, 0.0, 1.0, 1.0), + lch: Lcha::new(1.0, 0.0, 0.0000136603785, 1.0), + oklab: Oklaba::new(1.0, 0.0, 0.000000059604645, 1.0), + }, + // red + TestColor { + name: "red", + rgb: Srgba::new(1.0, 0.0, 0.0, 1.0), + linear_rgb: LinearRgba::new(1.0, 0.0, 0.0, 1.0), + hsl: Hsla::new(0.0, 1.0, 0.5, 1.0), + lch: Lcha::new(0.53240794, 1.0455177, 39.99901, 1.0), + oklab: Oklaba::new(0.6279554, 0.22486295, 0.1258463, 1.0), + }, + // green + TestColor { + name: "green", + rgb: Srgba::new(0.0, 1.0, 0.0, 1.0), + linear_rgb: LinearRgba::new(0.0, 1.0, 0.0, 1.0), + hsl: Hsla::new(120.0, 1.0, 0.5, 1.0), + lch: Lcha::new(0.87734723, 1.1977587, 136.01595, 1.0), + oklab: Oklaba::new(0.8664396, -0.2338874, 0.1794985, 1.0), + }, + // blue + TestColor { + name: "blue", + rgb: Srgba::new(0.0, 0.0, 1.0, 1.0), + linear_rgb: LinearRgba::new(0.0, 0.0, 1.0, 1.0), + hsl: Hsla::new(240.0, 1.0, 0.5, 1.0), + lch: Lcha::new(0.32297012, 1.3380761, 306.28494, 1.0), + oklab: Oklaba::new(0.4520137, -0.032456964, -0.31152815, 1.0), + }, + // yellow + TestColor { + name: "yellow", + rgb: Srgba::new(1.0, 1.0, 0.0, 1.0), + linear_rgb: LinearRgba::new(1.0, 1.0, 0.0, 1.0), + hsl: Hsla::new(60.0, 1.0, 0.5, 1.0), + lch: Lcha::new(0.9713927, 0.96905375, 102.85126, 1.0), + oklab: Oklaba::new(0.9679827, -0.07136908, 0.19856972, 1.0), + }, + // magenta + TestColor { + name: "magenta", + rgb: Srgba::new(1.0, 0.0, 1.0, 1.0), + linear_rgb: LinearRgba::new(1.0, 0.0, 1.0, 1.0), + hsl: Hsla::new(300.0, 1.0, 0.5, 1.0), + lch: Lcha::new(0.6032421, 1.1554068, 328.23495, 1.0), + oklab: Oklaba::new(0.7016738, 0.27456632, -0.16915613, 1.0), + }, + // cyan + TestColor { + name: "cyan", + rgb: Srgba::new(0.0, 1.0, 1.0, 1.0), + linear_rgb: LinearRgba::new(0.0, 1.0, 1.0, 1.0), + hsl: Hsla::new(180.0, 1.0, 0.5, 1.0), + lch: Lcha::new(0.9111322, 0.50120866, 196.37614, 1.0), + oklab: Oklaba::new(0.90539926, -0.1494439, -0.039398134, 1.0), + }, + // gray + TestColor { + name: "gray", + rgb: Srgba::new(0.5, 0.5, 0.5, 1.0), + linear_rgb: LinearRgba::new(0.21404114, 0.21404114, 0.21404114, 1.0), + hsl: Hsla::new(0.0, 0.0, 0.5, 1.0), + lch: Lcha::new(0.5338897, 0.00000011920929, 90.0, 1.0), + oklab: Oklaba::new(0.5981807, 0.00000011920929, 0.0, 1.0), + }, + // olive + TestColor { + name: "olive", + rgb: Srgba::new(0.5, 0.5, 0.0, 1.0), + linear_rgb: LinearRgba::new(0.21404114, 0.21404114, 0.0, 1.0), + hsl: Hsla::new(60.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.51677734, 0.57966936, 102.851265, 1.0), + oklab: Oklaba::new(0.57902855, -0.042691574, 0.11878061, 1.0), + }, + // purple + TestColor { + name: "purple", + rgb: Srgba::new(0.5, 0.0, 0.5, 1.0), + linear_rgb: LinearRgba::new(0.21404114, 0.0, 0.21404114, 1.0), + hsl: Hsla::new(300.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.29655674, 0.69114214, 328.23495, 1.0), + oklab: Oklaba::new(0.41972777, 0.1642403, -0.10118592, 1.0), + }, + // teal + TestColor { + name: "teal", + rgb: Srgba::new(0.0, 0.5, 0.5, 1.0), + linear_rgb: LinearRgba::new(0.0, 0.21404114, 0.21404114, 1.0), + hsl: Hsla::new(180.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.48073065, 0.29981336, 196.37614, 1.0), + oklab: Oklaba::new(0.54159236, -0.08939436, -0.02356726, 1.0), + }, + // maroon + TestColor { + name: "maroon", + rgb: Srgba::new(0.5, 0.0, 0.0, 1.0), + linear_rgb: LinearRgba::new(0.21404114, 0.0, 0.0, 1.0), + hsl: Hsla::new(0.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.2541851, 0.61091745, 38.350803, 1.0), + oklab: Oklaba::new(0.3756308, 0.13450874, 0.07527886, 1.0), + }, + // lime + TestColor { + name: "lime", + rgb: Srgba::new(0.0, 0.5, 0.0, 1.0), + linear_rgb: LinearRgba::new(0.0, 0.21404114, 0.0, 1.0), + hsl: Hsla::new(120.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.46052113, 0.71647626, 136.01596, 1.0), + oklab: Oklaba::new(0.5182875, -0.13990697, 0.10737252, 1.0), + }, + // navy + TestColor { + name: "navy", + rgb: Srgba::new(0.0, 0.0, 0.5, 1.0), + linear_rgb: LinearRgba::new(0.0, 0.0, 0.21404114, 1.0), + hsl: Hsla::new(240.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.12890343, 0.8004114, 306.28494, 1.0), + oklab: Oklaba::new(0.27038592, -0.01941514, -0.18635012, 1.0), + }, + // orange + TestColor { + name: "orange", + rgb: Srgba::new(0.5, 0.5, 0.0, 1.0), + linear_rgb: LinearRgba::new(0.21404114, 0.21404114, 0.0, 1.0), + hsl: Hsla::new(60.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.51677734, 0.57966936, 102.851265, 1.0), + oklab: Oklaba::new(0.57902855, -0.042691574, 0.11878061, 1.0), + }, + // fuchsia + TestColor { + name: "fuchsia", + rgb: Srgba::new(0.5, 0.0, 0.5, 1.0), + linear_rgb: LinearRgba::new(0.21404114, 0.0, 0.21404114, 1.0), + hsl: Hsla::new(300.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.29655674, 0.69114214, 328.23495, 1.0), + oklab: Oklaba::new(0.41972777, 0.1642403, -0.10118592, 1.0), + }, + // aqua + TestColor { + name: "aqua", + rgb: Srgba::new(0.0, 0.5, 0.5, 1.0), + linear_rgb: LinearRgba::new(0.0, 0.21404114, 0.21404114, 1.0), + hsl: Hsla::new(180.0, 1.0, 0.25, 1.0), + lch: Lcha::new(0.48073065, 0.29981336, 196.37614, 1.0), + oklab: Oklaba::new(0.54159236, -0.08939436, -0.02356726, 1.0), + }, +]; diff --git a/crates/bevy_color/src/testing.rs b/crates/bevy_color/src/testing.rs new file mode 100644 index 00000000000000..0c87fe226c749e --- /dev/null +++ b/crates/bevy_color/src/testing.rs @@ -0,0 +1,15 @@ +#[cfg(test)] +macro_rules! assert_approx_eq { + ($x:expr, $y:expr, $d:expr) => { + if ($x - $y).abs() >= $d { + panic!( + "assertion failed: `(left !== right)` \ + (left: `{:?}`, right: `{:?}`, tolerance: `{:?}`)", + $x, $y, $d + ); + } + }; +} + +#[cfg(test)] +pub(crate) use assert_approx_eq; diff --git a/tools/publish.sh b/tools/publish.sh index 7819bd3f5907d5..3bb2f9b09cf24d 100644 --- a/tools/publish.sh +++ b/tools/publish.sh @@ -43,6 +43,7 @@ crates=( bevy_winit bevy_internal bevy_dylib + bevy_color ) if [ -n "$(git status --porcelain)" ]; then