Color gradient curve (#14976)

# Objective

- Currently we have the `ColorRange` trait to interpolate linearly
between two colors
- It would be cool to have:
  1. linear interpolation between n colors where `n >= 1`
  2. other kinds of interpolation

## Solution

1. Implement `ColorGradient` which takes `n >= 1` colors and linearly
interpolates between consecutive pairs of them
2. Implement `Curve` intergration for this `ColorGradient` which yields
a curve struct. After that we can apply all of the cool curve adaptors
like `.reparametrize()` and `.map()` to the gradient

## Testing

- Added doc tests
- Added tests

## Showcase

```rust
// let gradient = ColorGradient::new(vec![]).unwrap(); // panic! 💥
let gradient = ColorGradient::new([basic::RED, basic::LIME, basic::BLUE]).expect("non-empty");
let curve = gradient.to_curve();
let brighter_curve = curve.map(|c| c.mix(&basic::WHITE, 0.5));
```

--- 

Kind of related to
https://github.com/bevyengine/bevy/pull/14971#discussion_r1736337631

---------

Co-authored-by: Zachary Harrold <zac@harrold.com.au>
Co-authored-by: Matty <weatherleymatthew@gmail.com>
This commit is contained in:
Robert Walter 2024-09-02 23:26:30 +00:00 committed by GitHub
parent 01a3b0e830
commit 8a64b7621d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 109 additions and 1 deletions

View file

@ -0,0 +1,102 @@
use crate::Mix;
use bevy_math::curve::{
cores::{EvenCore, EvenCoreError},
Curve, Interval,
};
/// A curve whose samples are defined by a collection of colors.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))]
pub struct ColorCurve<T> {
core: EvenCore<T>,
}
impl<T> ColorCurve<T>
where
T: Mix + Clone,
{
/// Create a new [`ColorCurve`] from a collection of [mixable] types. The domain of this curve
/// will always be `[0.0, len - 1]` where `len` is the amount of mixable objects in the
/// collection.
///
/// This fails if there's not at least two mixable things in the collection.
///
/// [mixable]: `Mix`
///
/// # Example
///
/// ```
/// # use bevy_color::palettes::basic::*;
/// # use bevy_color::Mix;
/// # use bevy_color::Srgba;
/// # use bevy_color::ColorCurve;
/// # use bevy_math::curve::Interval;
/// # use bevy_math::curve::Curve;
/// let broken = ColorCurve::new([RED]);
/// assert!(broken.is_err());
/// let gradient = ColorCurve::new([RED, GREEN, BLUE]);
/// assert!(gradient.is_ok());
/// assert_eq!(gradient.unwrap().domain(), Interval::new(0.0, 2.0).unwrap());
/// ```
pub fn new(colors: impl IntoIterator<Item = T>) -> Result<Self, EvenCoreError> {
let colors = colors.into_iter().collect::<Vec<_>>();
Interval::new(0.0, colors.len().saturating_sub(1) as f32)
.map_err(|_| EvenCoreError::NotEnoughSamples {
samples: colors.len(),
})
.and_then(|domain| EvenCore::new(domain, colors))
.map(|core| Self { core })
}
}
impl<T> Curve<T> for ColorCurve<T>
where
T: Mix + Clone,
{
fn domain(&self) -> Interval {
self.core.domain()
}
fn sample_unchecked(&self, t: f32) -> T {
self.core.sample_with(t, T::mix)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::palettes::basic;
use crate::Srgba;
#[test]
fn test_color_curve() {
let broken = ColorCurve::new([basic::RED]);
assert!(broken.is_err());
let gradient = [basic::RED, basic::LIME, basic::BLUE];
let curve = ColorCurve::new(gradient).unwrap();
assert_eq!(curve.domain(), Interval::new(0.0, 2.0).unwrap());
let brighter_curve = curve.map(|c: Srgba| c.mix(&basic::WHITE, 0.5));
[
(-0.1, None),
(0.0, Some([1.0, 0.5, 0.5, 1.0])),
(0.5, Some([0.75, 0.75, 0.5, 1.0])),
(1.0, Some([0.5, 1.0, 0.5, 1.0])),
(1.5, Some([0.5, 0.75, 0.75, 1.0])),
(2.0, Some([0.5, 0.5, 1.0, 1.0])),
(2.1, None),
]
.map(|(t, maybe_rgba)| {
let maybe_srgba = maybe_rgba.map(|[r, g, b, a]| Srgba::new(r, g, b, a));
(t, maybe_srgba)
})
.into_iter()
.for_each(|(t, maybe_color)| {
assert_eq!(brighter_curve.sample(t), maybe_color);
});
}
}

View file

@ -15,7 +15,7 @@ pub trait ColorRange<T: Mix> {
impl<T: Mix> ColorRange<T> for Range<T> {
fn at(&self, factor: f32) -> T {
self.start.mix(&self.end, factor)
self.start.mix(&self.end, factor.clamp(0.0, 1.0))
}
}
@ -28,16 +28,20 @@ mod tests {
#[test]
fn test_color_range() {
let range = basic::RED..basic::BLUE;
assert_eq!(range.at(-0.5), basic::RED);
assert_eq!(range.at(0.0), basic::RED);
assert_eq!(range.at(0.5), Srgba::new(0.5, 0.0, 0.5, 1.0));
assert_eq!(range.at(1.0), basic::BLUE);
assert_eq!(range.at(1.5), basic::BLUE);
let lred: LinearRgba = basic::RED.into();
let lblue: LinearRgba = basic::BLUE.into();
let range = lred..lblue;
assert_eq!(range.at(-0.5), lred);
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);
assert_eq!(range.at(1.5), lblue);
}
}

View file

@ -92,6 +92,7 @@
mod color;
pub mod color_difference;
mod color_gradient;
mod color_ops;
mod color_range;
mod hsla;
@ -127,6 +128,7 @@ pub mod prelude {
}
pub use color::*;
pub use color_gradient::*;
pub use color_ops::*;
pub use color_range::*;
pub use hsla::*;