mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs (#4149)
* Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs * Code review fixes
This commit is contained in:
@@ -20,6 +20,7 @@ no-std-types = { workspace = true, features = ["std"] }
|
||||
graphene-hash = { workspace = true, features = ["derive"] }
|
||||
|
||||
# Workspace dependencies
|
||||
color = { workspace = true }
|
||||
bitflags = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
@@ -102,3 +102,37 @@ pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -
|
||||
F64ArrayFormat::List(list) => list.element,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a CSS color string (named color, hex, `rgb(...)`, `hsl(...)`, etc.) into a linear-light [`Color`] using the `color` crate's CSS Color 4 parser.
|
||||
/// Tries the input as-is first (catches CSS named colors like `red`, `rgb(...)`, and well-formed hex like `#abcdef`), then falls back to treating the input as bare hex with length-based expansion to a CSS-parseable form:
|
||||
/// - 1 char `f` → `#fff` (CSS 3-char shorthand)
|
||||
/// - 2 char `ab` → `#ababab` (repeated to 6 chars)
|
||||
/// - 4 char `abcd` → `#00abcd` (left-padded with `00`)
|
||||
/// - 5 char `abcde` → `#0abcde` (left-padded with `0`)
|
||||
/// - 3, 6, 8 char inputs are passed through with a `#` prefix.
|
||||
pub fn parse_css_color(input: &str) -> Option<crate::Color> {
|
||||
let trimmed = input.trim();
|
||||
|
||||
let parsed = color::parse_color(trimmed).ok().or_else(|| {
|
||||
let bare = trimmed.strip_prefix('#').unwrap_or(trimmed);
|
||||
if bare.is_empty() || !bare.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let expanded = match bare.len() {
|
||||
1 => bare.repeat(3),
|
||||
2 => bare.repeat(3),
|
||||
4 => format!("00{bare}"),
|
||||
5 => format!("0{bare}"),
|
||||
_ => bare.to_string(),
|
||||
};
|
||||
let candidate = format!("#{expanded}");
|
||||
// Avoid retrying the exact same string we just failed to parse.
|
||||
(candidate != trimmed).then(|| color::parse_color(&candidate).ok()).flatten()
|
||||
})?;
|
||||
|
||||
let srgb: color::AlphaColor<color::Srgb> = parsed.to_alpha_color();
|
||||
let [red, green, blue, alpha] = srgb.components;
|
||||
// Reject out-of-gamut values that `color::parse_color` accepts for newer CSS syntax (e.g., `rgb(300 -50 200)`).
|
||||
let in_gamut = alpha <= 1. && ![red, green, blue, alpha].iter().any(|c| c.is_sign_negative() || !c.is_finite());
|
||||
in_gamut.then(|| crate::Color::from_gamma_srgb_channels(red, green, blue, alpha))
|
||||
}
|
||||
|
||||
@@ -92,17 +92,112 @@ impl Alpha for RGBA16F {
|
||||
|
||||
impl Pixel for RGBA16F {}
|
||||
|
||||
/// An sRGB color with 8-bit unassociated-alpha channels. Used as the wire format at the DOM boundary:
|
||||
/// bijective with hex codes, byte-identical to CSS/SVG/PNG/peniko conventions. Internal computations use
|
||||
/// the linear-light [`Color`] type. Convert via [`From<SRGBA8> for Color`] and [`From<Color> for SRGBA8`].
|
||||
#[repr(C)]
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
|
||||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable)]
|
||||
#[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))]
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Pod, Zeroable)]
|
||||
pub struct SRGBA8 {
|
||||
red: u8,
|
||||
green: u8,
|
||||
blue: u8,
|
||||
alpha: u8,
|
||||
pub red: u8,
|
||||
pub green: u8,
|
||||
pub blue: u8,
|
||||
pub alpha: u8,
|
||||
}
|
||||
|
||||
impl SRGBA8 {
|
||||
pub const TRANSPARENT: Self = Self::new(0, 0, 0, 0);
|
||||
pub const BLACK: Self = Self::new(0, 0, 0, 255);
|
||||
pub const WHITE: Self = Self::new(255, 255, 255, 255);
|
||||
|
||||
/// Construct from raw 8-bit channels. Alpha is unassociated (not premultiplied), matching CSS/SVG/PNG convention.
|
||||
#[inline(always)]
|
||||
pub const fn new(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
|
||||
Self { red, green, blue, alpha }
|
||||
}
|
||||
|
||||
/// Construct an opaque (alpha = 255) color from raw 8-bit RGB channels.
|
||||
#[inline(always)]
|
||||
pub const fn new_opaque(red: u8, green: u8, blue: u8) -> Self {
|
||||
Self::new(red, green, blue, 255)
|
||||
}
|
||||
|
||||
/// Parse `RRGGBB` or `RRGGBBAA` (with or without a leading `#`). Returns `None` for any other format.
|
||||
/// For full CSS Color 4 parsing (named colors, shorthand hex, `rgb(...)`, `hsl(...)`), parse in the caller and construct via [`Self::new`].
|
||||
#[cfg(feature = "std")]
|
||||
pub fn from_hex_str(hex: &str) -> Option<Self> {
|
||||
let hex = hex.trim().trim_start_matches('#');
|
||||
if hex.len() != 6 && hex.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||
let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||
let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||
let alpha = if hex.len() == 8 { u8::from_str_radix(&hex[6..8], 16).ok()? } else { 255 };
|
||||
|
||||
Some(Self::new(red, green, blue, alpha))
|
||||
}
|
||||
|
||||
/// `rrggbb` (lowercase, no `#` prefix, alpha discarded). Use where alpha is specified separately, e.g. SVG `fill="#..." fill-opacity="..."`.
|
||||
#[cfg(feature = "std")]
|
||||
pub fn to_rgb_hex(self) -> String {
|
||||
format!("{:02x}{:02x}{:02x}", self.red, self.green, self.blue)
|
||||
}
|
||||
|
||||
/// `rrggbbaa` (lowercase, no `#` prefix).
|
||||
#[cfg(feature = "std")]
|
||||
pub fn to_rgba_hex(self) -> String {
|
||||
format!("{:02x}{:02x}{:02x}{:02x}", self.red, self.green, self.blue, self.alpha)
|
||||
}
|
||||
|
||||
/// `#rrggbb` if fully opaque, `#rrggbbaa` otherwise. Suitable for direct insertion into a CSS property or SVG attribute.
|
||||
#[cfg(feature = "std")]
|
||||
pub fn to_css_hex(self) -> String {
|
||||
if self.alpha == 255 {
|
||||
format!("#{}", self.to_rgb_hex())
|
||||
} else {
|
||||
format!("#{}", self.to_rgba_hex())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns [`Self::BLACK`] or [`Self::WHITE`], whichever gives more legible text against this color
|
||||
/// (alpha composited over white in gamma space, WCAG-style relative-luminance threshold).
|
||||
pub fn contrasting_text_color(self) -> Self {
|
||||
// Composite over white in gamma space, then convert to linear for the luminance test.
|
||||
let r = self.red as f32 / 255.;
|
||||
let g = self.green as f32 / 255.;
|
||||
let b = self.blue as f32 / 255.;
|
||||
let a = self.alpha as f32 / 255.;
|
||||
let composited = Color::from_gamma_srgb_channels(1. - a + r * a, 1. - a + g * a, 1. - a + b * a, 1.);
|
||||
let luminance = composited.luminance_rec_709();
|
||||
// WCAG-derived perceptual midpoint between black and white (~0.179)
|
||||
let threshold = (1.05_f32 * 0.05).sqrt() - 0.05;
|
||||
if luminance > threshold { Self::BLACK } else { Self::WHITE }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; 4]> for SRGBA8 {
|
||||
#[inline(always)]
|
||||
fn from(bytes: [u8; 4]) -> Self {
|
||||
let [red, green, blue, alpha] = bytes;
|
||||
Self::new(red, green, blue, alpha)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SRGBA8> for [u8; 4] {
|
||||
#[inline(always)]
|
||||
fn from(c: SRGBA8) -> Self {
|
||||
let SRGBA8 { red, green, blue, alpha } = c;
|
||||
[red, green, blue, alpha]
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets `Image<SRGBA8>` cross the wasm boundary as gamma bytes, since `Color` (linear-light) isn't exposed with Tsify.
|
||||
impl Pixel for SRGBA8 {}
|
||||
|
||||
impl From<Color> for SRGBA8 {
|
||||
#[inline(always)]
|
||||
fn from(c: Color) -> Self {
|
||||
@@ -127,53 +222,6 @@ impl From<SRGBA8> for Color {
|
||||
}
|
||||
}
|
||||
|
||||
impl Luminance for SRGBA8 {
|
||||
type LuminanceChannel = f32;
|
||||
#[inline(always)]
|
||||
fn luminance(&self) -> f32 {
|
||||
// TODO: verify this is correct for sRGB
|
||||
0.2126 * self.red() + 0.7152 * self.green() + 0.0722 * self.blue()
|
||||
}
|
||||
}
|
||||
|
||||
impl RGB for SRGBA8 {
|
||||
type ColorChannel = f32;
|
||||
#[inline(always)]
|
||||
fn red(&self) -> f32 {
|
||||
self.red as f32 / 255.
|
||||
}
|
||||
#[inline(always)]
|
||||
fn green(&self) -> f32 {
|
||||
self.green as f32 / 255.
|
||||
}
|
||||
#[inline(always)]
|
||||
fn blue(&self) -> f32 {
|
||||
self.blue as f32 / 255.
|
||||
}
|
||||
}
|
||||
|
||||
impl Rec709Primaries for SRGBA8 {}
|
||||
impl SRGB for SRGBA8 {}
|
||||
|
||||
impl Alpha for SRGBA8 {
|
||||
type AlphaChannel = f32;
|
||||
#[inline(always)]
|
||||
fn alpha(&self) -> f32 {
|
||||
self.alpha as f32 / 255.
|
||||
}
|
||||
|
||||
const TRANSPARENT: Self = SRGBA8 { red: 0, green: 0, blue: 0, alpha: 0 };
|
||||
|
||||
fn multiplied_alpha(&self, alpha: Self::AlphaChannel) -> Self {
|
||||
let alpha = alpha * 255.;
|
||||
let mut result = *self;
|
||||
result.alpha = (alpha * self.alpha()) as u8;
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Pixel for SRGBA8 {}
|
||||
|
||||
#[repr(C)]
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
|
||||
@@ -216,9 +264,13 @@ impl Pixel for Luma {}
|
||||
/// Internally alpha is stored as `f32` that ranges from `0.0` (transparent) to `1.0` (opaque).
|
||||
/// The other components (RGB) are stored as `f32` that range from `0.0` up to `f32::MAX`,
|
||||
/// the values encode the brightness of each channel proportional to the light intensity in cd/m² (nits) in HDR, and `0.0` (black) to `1.0` (white) in SDR color.
|
||||
/// Linear-light sRGB color with `f32` channels (alpha unassociated for swatch/UI colors, associated/premultiplied for pixel data inside [`Image<Color>`]).
|
||||
///
|
||||
/// Channels range from `0.0` to `f32::MAX`, encoding brightness proportional to light intensity (cd/m² nits in HDR, or `0..=1` mapped to white for SDR).
|
||||
///
|
||||
/// Anything crossing the Wasm/JS boundary must go through [`SRGBA8`] instead.
|
||||
#[repr(C)]
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
|
||||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||||
#[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))]
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable, BufferStruct)]
|
||||
pub struct Color {
|
||||
@@ -231,6 +283,52 @@ pub struct Color {
|
||||
// `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap<CacheHashWrapper<Image<Color>>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper.
|
||||
impl Eq for Color {}
|
||||
|
||||
// TODO: Eventually remove this migration document upgrade code
|
||||
#[cfg(feature = "std")]
|
||||
impl serde::Serialize for Color {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeStruct;
|
||||
// Persist linear-light floats directly and tag with `"linear": true` so legacy gamma-encoded values (which lack this marker) can be detected and upgraded on load.
|
||||
let mut state = serializer.serialize_struct("Color", 5)?;
|
||||
state.serialize_field("red", &self.red)?;
|
||||
state.serialize_field("green", &self.green)?;
|
||||
state.serialize_field("blue", &self.blue)?;
|
||||
state.serialize_field("alpha", &self.alpha)?;
|
||||
// TODO: Remove the `linear` marker when switching to the new document format and Ctrl-C node serialization format
|
||||
state.serialize_field("linear", &true)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Eventually remove this migration document upgrade code
|
||||
#[cfg(feature = "std")]
|
||||
impl<'de> serde::Deserialize<'de> for Color {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Documents from before the linear-storage migration lack the `linear` marker and stored gamma-encoded floats; convert them on load.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct MigrationColor {
|
||||
red: f32,
|
||||
green: f32,
|
||||
blue: f32,
|
||||
alpha: f32,
|
||||
#[serde(default)]
|
||||
// TODO: Remove the `linear` marker when switching to the new document format and Ctrl-C node serialization format
|
||||
linear: bool,
|
||||
}
|
||||
let raw = MigrationColor::deserialize(deserializer)?;
|
||||
Ok(if raw.linear {
|
||||
Color {
|
||||
red: raw.red,
|
||||
green: raw.green,
|
||||
blue: raw.blue,
|
||||
alpha: raw.alpha,
|
||||
}
|
||||
} else {
|
||||
Color::from_gamma_srgb_channels(raw.red, raw.green, raw.blue, raw.alpha)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RGB for Color {
|
||||
type ColorChannel = f32;
|
||||
#[inline(always)]
|
||||
@@ -266,11 +364,14 @@ impl AlphaMut for Color {
|
||||
impl Pixel for Color {
|
||||
#[cfg(feature = "std")]
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
self.to_rgba8_srgb().to_vec()
|
||||
let SRGBA8 { red, green, blue, alpha } = (*self).into();
|
||||
[red, green, blue, alpha].to_vec()
|
||||
}
|
||||
|
||||
fn from_bytes(bytes: &[u8]) -> Self {
|
||||
Color::from_rgba8_srgb(bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
// `Image<Color>` pixel convention is linear-light with associated (premultiplied) alpha.
|
||||
let srgba = SRGBA8::new(bytes[0], bytes[1], bytes[2], bytes[3]);
|
||||
Color::from(srgba).apply_opacity(bytes[3] as f32 / 255.)
|
||||
}
|
||||
fn byte_size() -> usize {
|
||||
4
|
||||
@@ -372,56 +473,26 @@ impl Color {
|
||||
Some(color)
|
||||
}
|
||||
|
||||
/// Return an opaque `Color` from given `f32` RGB channels.
|
||||
/// Construct an opaque `Color` from `f32` RGB channels, with no value validation (use [`Self::from_rgbaf32`] for validation).
|
||||
#[inline(always)]
|
||||
pub const fn from_rgbf32_unchecked(red: f32, green: f32, blue: f32) -> Color {
|
||||
Color { red, green, blue, alpha: 1. }
|
||||
}
|
||||
|
||||
/// Return an opaque `Color` from given `f32` RGB channels.
|
||||
/// Construct a `Color` from `f32` RGBA channels, with no value validation (use [`Self::from_rgbaf32`] for validation).
|
||||
#[inline(always)]
|
||||
pub const fn from_rgbaf32_unchecked(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
|
||||
Color { red, green, blue, alpha }
|
||||
}
|
||||
|
||||
/// Return an opaque `Color` from given `f32` RGB channels.
|
||||
/// Construct a `Color` from unassociated (straight) RGBA channels, premultiplying the RGB channels by alpha.
|
||||
#[inline(always)]
|
||||
pub fn from_unassociated_alpha(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
|
||||
pub fn new_from_unassociated_rgba(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
|
||||
Color::from_rgbaf32_unchecked(red * alpha, green * alpha, blue * alpha, alpha)
|
||||
}
|
||||
|
||||
/// Return an opaque SDR `Color` given RGB channels from `0` to `255`, premultiplied by alpha.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use core_types::color::Color;
|
||||
/// let color = Color::from_rgb8_srgb(0x72, 0x67, 0x62);
|
||||
/// let color2 = Color::from_rgba8_srgb(0x72, 0x67, 0x62, 0xFF);
|
||||
/// assert_eq!(color, color2)
|
||||
/// ```
|
||||
#[inline(always)]
|
||||
pub fn from_rgb8_srgb(red: u8, green: u8, blue: u8) -> Color {
|
||||
Color::from_rgba8_srgb(red, green, blue, 255)
|
||||
}
|
||||
|
||||
// TODO: Should this be premult?
|
||||
/// Return an SDR `Color` given RGBA channels from `0` to `255`, premultiplied by alpha.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use core_types::color::Color;
|
||||
/// let color = Color::from_rgba8_srgb(0x72, 0x67, 0x62, 0x61);
|
||||
/// ```
|
||||
#[inline(always)]
|
||||
pub fn from_rgba8_srgb(red: u8, green: u8, blue: u8, alpha: u8) -> Color {
|
||||
let red = red as f32 / 255.;
|
||||
let green = green as f32 / 255.;
|
||||
let blue = blue as f32 / 255.;
|
||||
let alpha = alpha as f32 / 255.;
|
||||
Color { red, green, blue, alpha }.to_linear_srgb().map_rgb(|channel| channel * alpha)
|
||||
}
|
||||
|
||||
/// Create a [Color] from a hue, saturation, lightness, and alpha (all between 0 and 1)
|
||||
/// Create a linear-light `Color` from HSL coordinates (all between 0 and 1).
|
||||
/// HSL is defined on sRGB display values, so the RGB produced by the HSL math is gamma-encoded and decoded to linear before being wrapped in `Color`.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
@@ -460,10 +531,11 @@ impl Color {
|
||||
map_channel(&mut green, temp2, temp1);
|
||||
map_channel(&mut blue, temp2, temp1);
|
||||
|
||||
Color { red, green, blue, alpha }
|
||||
Color::from_gamma_srgb_channels(red, green, blue, alpha)
|
||||
}
|
||||
|
||||
/// Create a [Color] from hue, saturation, value, and alpha (all between 0 and 1).
|
||||
/// Create a linear-light `Color` from HSV coordinates (all between 0 and 1).
|
||||
/// HSV is defined on sRGB display values, so the RGB produced by the HSV math is gamma-encoded and decoded to linear before being wrapped in `Color`.
|
||||
pub fn from_hsva(hue: f32, saturation: f32, value: f32, alpha: f32) -> Color {
|
||||
let h_prime = (hue * 6.) % 6.;
|
||||
let i = h_prime as i32;
|
||||
@@ -479,7 +551,7 @@ impl Color {
|
||||
4 => (t, p, value),
|
||||
_ => (value, p, q),
|
||||
};
|
||||
Color { red, green, blue, alpha }
|
||||
Color::from_gamma_srgb_channels(red, green, blue, alpha)
|
||||
}
|
||||
|
||||
/// Return the `red` component.
|
||||
@@ -534,48 +606,56 @@ impl Color {
|
||||
self.alpha
|
||||
}
|
||||
|
||||
/// Whether the alpha channel is at (or within an epsilon of) fully opaque.
|
||||
#[inline(always)]
|
||||
pub fn is_opaque(&self) -> bool {
|
||||
self.alpha > 1. - f32::EPSILON
|
||||
}
|
||||
|
||||
/// Mean of the three RGB channels.
|
||||
#[inline(always)]
|
||||
pub fn average_rgb_channels(&self) -> f32 {
|
||||
(self.red + self.green + self.blue) / 3.
|
||||
}
|
||||
|
||||
/// Minimum of the three RGB channels.
|
||||
#[inline(always)]
|
||||
pub fn minimum_rgb_channels(&self) -> f32 {
|
||||
self.red.min(self.green).min(self.blue)
|
||||
}
|
||||
|
||||
/// Maximum of the three RGB channels.
|
||||
#[inline(always)]
|
||||
pub fn maximum_rgb_channels(&self) -> f32 {
|
||||
self.red.max(self.green).max(self.blue)
|
||||
}
|
||||
|
||||
/// Relative luminance using Rec.709 / sRGB-primary weights, computed on linear-light RGB.
|
||||
// From https://stackoverflow.com/a/56678483/775283
|
||||
#[inline(always)]
|
||||
pub fn luminance_srgb(&self) -> f32 {
|
||||
pub fn luminance_rec_709(&self) -> f32 {
|
||||
0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue
|
||||
}
|
||||
|
||||
/// Luma using Rec.601 SDTV coefficients.
|
||||
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
|
||||
#[inline(always)]
|
||||
pub fn luminance_rec_601(&self) -> f32 {
|
||||
0.299 * self.red + 0.587 * self.green + 0.114 * self.blue
|
||||
}
|
||||
|
||||
/// Luma using rounded Rec.601 coefficients (`0.3 / 0.59 / 0.11`), as used by some legacy image processing.
|
||||
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
|
||||
#[inline(always)]
|
||||
pub fn luminance_rec_601_rounded(&self) -> f32 {
|
||||
0.3 * self.red + 0.59 * self.green + 0.11 * self.blue
|
||||
}
|
||||
|
||||
/// Perceptual lightness (CIE L*) of the Rec.709 luminance, normalized to 0..1.
|
||||
// From https://stackoverflow.com/a/56678483/775283
|
||||
#[inline(always)]
|
||||
pub fn luminance_perceptual(&self) -> f32 {
|
||||
let luminance = self.luminance_srgb();
|
||||
let luminance = self.luminance_rec_709();
|
||||
|
||||
if luminance <= 0.008856 {
|
||||
(luminance * 903.3) / 100.
|
||||
@@ -584,6 +664,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct an opaque grayscale color where R = G = B = `luminance`.
|
||||
#[inline(always)]
|
||||
pub fn from_luminance(luminance: f32) -> Color {
|
||||
Color {
|
||||
@@ -594,26 +675,30 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift all RGB channels by the offset that moves Rec.601-rounded luma to `luminance`, clamping channels to 0..1. Approximate; channels above 1 are lost.
|
||||
#[inline(always)]
|
||||
pub fn with_luminance(&self, luminance: f32) -> Color {
|
||||
let delta = luminance - self.luminance_rec_601_rounded();
|
||||
self.map_rgb(|c| (c + delta).clamp(0., 1.))
|
||||
}
|
||||
|
||||
/// The RGB chroma range, `max - min` across the three channels. Not the HSL/HSV saturation (use [`Self::to_hsla`] or [`Self::to_hsva`] for those).
|
||||
#[inline(always)]
|
||||
pub fn saturation(&self) -> f32 {
|
||||
pub fn chroma_range(&self) -> f32 {
|
||||
let max = (self.red).max(self.green).max(self.blue);
|
||||
let min = (self.red).min(self.green).min(self.blue);
|
||||
|
||||
max - min
|
||||
}
|
||||
|
||||
/// Replace HSL saturation with the given value, preserving hue, lightness, and alpha.
|
||||
#[inline(always)]
|
||||
pub fn with_saturation(&self, saturation: f32) -> Color {
|
||||
let [hue, _, lightness, alpha] = self.to_hsla();
|
||||
Color::from_hsla(hue, saturation, lightness, alpha)
|
||||
}
|
||||
|
||||
/// Replace the alpha channel, leaving RGB unchanged.
|
||||
pub fn with_alpha(&self, alpha: f32) -> Color {
|
||||
Color {
|
||||
red: self.red,
|
||||
@@ -623,6 +708,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the red channel, leaving the others unchanged.
|
||||
pub fn with_red(&self, red: f32) -> Color {
|
||||
Color {
|
||||
red,
|
||||
@@ -632,6 +718,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the green channel, leaving the others unchanged.
|
||||
pub fn with_green(&self, green: f32) -> Color {
|
||||
Color {
|
||||
red: self.red,
|
||||
@@ -641,6 +728,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the blue channel, leaving the others unchanged.
|
||||
pub fn with_blue(&self, blue: f32) -> Color {
|
||||
Color {
|
||||
red: self.red,
|
||||
@@ -650,21 +738,25 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel "Normal" blend: returns the source channel unchanged.
|
||||
#[inline(always)]
|
||||
pub fn blend_normal(_c_b: f32, c_s: f32) -> f32 {
|
||||
c_s
|
||||
}
|
||||
|
||||
/// Per-channel "Multiply" blend.
|
||||
#[inline(always)]
|
||||
pub fn blend_multiply(c_b: f32, c_s: f32) -> f32 {
|
||||
c_s * c_b
|
||||
}
|
||||
|
||||
/// Per-channel "Darken" blend: the smaller of the two.
|
||||
#[inline(always)]
|
||||
pub fn blend_darken(c_b: f32, c_s: f32) -> f32 {
|
||||
c_s.min(c_b)
|
||||
}
|
||||
|
||||
/// Per-channel "Color Burn" blend.
|
||||
#[inline(always)]
|
||||
pub fn blend_color_burn(c_b: f32, c_s: f32) -> f32 {
|
||||
if c_b == 1. {
|
||||
@@ -676,41 +768,49 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel "Linear Burn" blend.
|
||||
#[inline(always)]
|
||||
pub fn blend_linear_burn(c_b: f32, c_s: f32) -> f32 {
|
||||
c_b + c_s - 1.
|
||||
}
|
||||
|
||||
/// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB.
|
||||
#[inline(always)]
|
||||
pub fn blend_darker_color(&self, other: Color) -> Color {
|
||||
if self.average_rgb_channels() <= other.average_rgb_channels() { *self } else { other }
|
||||
}
|
||||
|
||||
/// Per-channel "Screen" blend.
|
||||
#[inline(always)]
|
||||
pub fn blend_screen(c_b: f32, c_s: f32) -> f32 {
|
||||
1. - (1. - c_s) * (1. - c_b)
|
||||
}
|
||||
|
||||
/// Per-channel "Lighten" blend: the larger of the two.
|
||||
#[inline(always)]
|
||||
pub fn blend_lighten(c_b: f32, c_s: f32) -> f32 {
|
||||
c_s.max(c_b)
|
||||
}
|
||||
|
||||
/// Per-channel "Color Dodge" blend.
|
||||
#[inline(always)]
|
||||
pub fn blend_color_dodge(c_b: f32, c_s: f32) -> f32 {
|
||||
if c_s == 1. { 1. } else { (c_b / (1. - c_s)).min(1.) }
|
||||
}
|
||||
|
||||
/// Per-channel "Linear Dodge" (Add) blend.
|
||||
#[inline(always)]
|
||||
pub fn blend_linear_dodge(c_b: f32, c_s: f32) -> f32 {
|
||||
c_b + c_s
|
||||
}
|
||||
|
||||
/// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB.
|
||||
#[inline(always)]
|
||||
pub fn blend_lighter_color(&self, other: Color) -> Color {
|
||||
if self.average_rgb_channels() >= other.average_rgb_channels() { *self } else { other }
|
||||
}
|
||||
|
||||
/// Per-channel "Soft Light" blend.
|
||||
pub fn blend_softlight(c_b: f32, c_s: f32) -> f32 {
|
||||
if c_s <= 0.5 {
|
||||
c_b - (1. - 2. * c_s) * c_b * (1. - c_b)
|
||||
@@ -720,6 +820,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel "Hard Light" blend.
|
||||
pub fn blend_hardlight(c_b: f32, c_s: f32) -> f32 {
|
||||
if c_s <= 0.5 {
|
||||
Color::blend_multiply(2. * c_s, c_b)
|
||||
@@ -728,6 +829,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel "Vivid Light" blend.
|
||||
pub fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 {
|
||||
if c_s <= 0.5 {
|
||||
Color::blend_color_burn(2. * c_s, c_b)
|
||||
@@ -736,6 +838,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel "Linear Light" blend.
|
||||
pub fn blend_linear_light(c_b: f32, c_s: f32) -> f32 {
|
||||
if c_s <= 0.5 {
|
||||
Color::blend_linear_burn(2. * c_s, c_b)
|
||||
@@ -744,6 +847,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel "Pin Light" blend.
|
||||
pub fn blend_pin_light(c_b: f32, c_s: f32) -> f32 {
|
||||
if c_s <= 0.5 {
|
||||
Color::blend_darken(2. * c_s, c_b)
|
||||
@@ -752,45 +856,54 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel "Hard Mix" blend: thresholds Linear Light at 0.5.
|
||||
pub fn blend_hard_mix(c_b: f32, c_s: f32) -> f32 {
|
||||
if Color::blend_linear_light(c_b, c_s) < 0.5 { 0. } else { 1. }
|
||||
}
|
||||
|
||||
/// Per-channel "Difference" blend.
|
||||
pub fn blend_difference(c_b: f32, c_s: f32) -> f32 {
|
||||
(c_b - c_s).abs()
|
||||
}
|
||||
|
||||
/// Per-channel "Exclusion" blend.
|
||||
pub fn blend_exclusion(c_b: f32, c_s: f32) -> f32 {
|
||||
c_b + c_s - 2. * c_b * c_s
|
||||
}
|
||||
|
||||
/// Per-channel "Subtract" blend.
|
||||
pub fn blend_subtract(c_b: f32, c_s: f32) -> f32 {
|
||||
c_b - c_s
|
||||
}
|
||||
|
||||
/// Per-channel "Divide" blend.
|
||||
pub fn blend_divide(c_b: f32, c_s: f32) -> f32 {
|
||||
if c_b == 0. { 1. } else { c_b / c_s }
|
||||
}
|
||||
|
||||
/// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma.
|
||||
pub fn blend_hue(&self, c_s: Color) -> Color {
|
||||
let sat_b = self.saturation();
|
||||
let sat_b = self.chroma_range();
|
||||
let lum_b = self.luminance_rec_601();
|
||||
c_s.with_saturation(sat_b).with_luminance(lum_b)
|
||||
}
|
||||
|
||||
/// Whole-color "Saturation" blend: this color's hue/luma with source saturation.
|
||||
pub fn blend_saturation(&self, c_s: Color) -> Color {
|
||||
let sat_s = c_s.saturation();
|
||||
let sat_s = c_s.chroma_range();
|
||||
let lum_b = self.luminance_rec_601();
|
||||
|
||||
self.with_saturation(sat_s).with_luminance(lum_b)
|
||||
}
|
||||
|
||||
/// Whole-color "Color" blend: source hue/saturation with this color's luma.
|
||||
pub fn blend_color(&self, c_s: Color) -> Color {
|
||||
let lum_b = self.luminance_rec_601();
|
||||
|
||||
c_s.with_luminance(lum_b)
|
||||
}
|
||||
|
||||
/// Whole-color "Luminosity" blend: this color's hue/saturation with source luma.
|
||||
pub fn blend_luminosity(&self, c_s: Color) -> Color {
|
||||
let lum_s = c_s.luminance_rec_601();
|
||||
|
||||
@@ -810,98 +923,43 @@ impl Color {
|
||||
(self.red, self.green, self.blue, self.alpha)
|
||||
}
|
||||
|
||||
/// Return an 8-character RGBA hex string (without a # prefix). Use this if the [`Color`] is in linear space.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use core_types::color::Color;
|
||||
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha
|
||||
/// assert_eq!("3240a261", color.to_rgba_hex_srgb()); // Equivalent hex incorporating premultiplied alpha
|
||||
/// ```
|
||||
#[cfg(feature = "std")]
|
||||
pub fn to_rgba_hex_srgb(&self) -> String {
|
||||
let gamma = self.to_gamma_srgb();
|
||||
format!(
|
||||
"{:02x?}{:02x?}{:02x?}{:02x?}",
|
||||
(gamma.r() * 255.) as u8,
|
||||
(gamma.g() * 255.) as u8,
|
||||
(gamma.b() * 255.) as u8,
|
||||
(gamma.a() * 255.) as u8,
|
||||
)
|
||||
}
|
||||
/// Convert this color to HSV coordinates (all between 0 and 1).
|
||||
/// HSV is defined on sRGB display values, so this color's linear RGB is gamma-encoded before the HSV math.
|
||||
pub fn to_hsva(&self) -> [f32; 4] {
|
||||
#[cfg(feature = "std")]
|
||||
let rem = |x: f32, m: f32| x.rem_euclid(m);
|
||||
#[cfg(not(feature = "std"))]
|
||||
let rem = |x: f32, m: f32| x.rem_euclid(&m);
|
||||
|
||||
/// Return a 6-character RGB hex string (without a # prefix). Use this if the [`Color`] is in linear space.
|
||||
/// ```
|
||||
/// use core_types::color::Color;
|
||||
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha
|
||||
/// assert_eq!("3240a2", color.to_rgb_hex_srgb()); // Equivalent hex incorporating premultiplied alpha
|
||||
/// ```
|
||||
#[cfg(feature = "std")]
|
||||
pub fn to_rgb_hex_srgb(&self) -> String {
|
||||
self.to_gamma_srgb().to_rgb_hex_srgb_from_gamma()
|
||||
}
|
||||
let [red, green, blue, alpha] = self.to_gamma_srgb_channels();
|
||||
let max = red.max(green).max(blue);
|
||||
let min = red.min(green).min(blue);
|
||||
let delta = max - min;
|
||||
|
||||
/// Return a 6-character RGB hex string (without a # prefix). Use this if the [`Color`] is in gamma space.
|
||||
/// ```
|
||||
/// use core_types::color::Color;
|
||||
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha
|
||||
/// assert_eq!("3240a2", color.to_rgb_hex_srgb()); // Equivalent hex incorporating premultiplied alpha
|
||||
/// ```
|
||||
#[cfg(feature = "std")]
|
||||
pub fn to_rgb_hex_srgb_from_gamma(&self) -> String {
|
||||
format!("{:02x?}{:02x?}{:02x?}", (self.r() * 255.) as u8, (self.g() * 255.) as u8, (self.b() * 255.) as u8)
|
||||
}
|
||||
let mut hue = if delta == 0. {
|
||||
0.
|
||||
} else if max == red {
|
||||
rem((green - blue) / delta, 6.)
|
||||
} else if max == green {
|
||||
(blue - red) / delta + 2.
|
||||
} else {
|
||||
(red - green) / delta + 4.
|
||||
};
|
||||
hue = rem(hue * 60. + 360., 360.) / 360.;
|
||||
|
||||
/// Return an 8-character RGBA hex string (without a # prefix). Use this if the [`Color`] is in gamma space.
|
||||
#[cfg(feature = "std")]
|
||||
pub fn to_rgba_hex_srgb_from_gamma(&self) -> String {
|
||||
format!(
|
||||
"{:02x?}{:02x?}{:02x?}{:02x?}",
|
||||
(self.r() * 255.) as u8,
|
||||
(self.g() * 255.) as u8,
|
||||
(self.b() * 255.) as u8,
|
||||
(self.a() * 255.) as u8,
|
||||
)
|
||||
}
|
||||
let saturation = if max == 0. { 0. } else { delta / max };
|
||||
let value = max;
|
||||
|
||||
/// [`Color::BLACK`] or [`Color::WHITE`], whichever gives more legible text against this color (alpha composited over white, WCAG-style luminance threshold). Use this if this [`Color`] is in gamma space.
|
||||
pub fn contrasting_text_color_from_gamma(&self) -> Color {
|
||||
let composited = Self::WHITE.alpha_blend(Self::from_unassociated_alpha(self.r(), self.g(), self.b(), self.a()));
|
||||
let luminance = composited.to_linear_srgb().luminance_srgb();
|
||||
// WCAG-derived perceptual midpoint between black and white (~0.179)
|
||||
let threshold = (1.05_f32 * 0.05).sqrt() - 0.05;
|
||||
if luminance > threshold { Self::BLACK } else { Self::WHITE }
|
||||
}
|
||||
|
||||
/// Return the all components as a u8 slice, first component is red, followed by green, followed by blue, followed by alpha. Use this if the [`Color`] is in gamma space.
|
||||
#[inline(always)]
|
||||
pub fn to_rgba8(&self) -> [u8; 4] {
|
||||
[(self.red * 255.) as u8, (self.green * 255.) as u8, (self.blue * 255.) as u8, (self.alpha * 255.) as u8]
|
||||
}
|
||||
|
||||
/// Return the all components as a u8 slice, first component is red, followed by green, followed by blue, followed by alpha. Use this if the [`Color`] is in linear space.
|
||||
#[inline(always)]
|
||||
pub fn to_rgba8_srgb(&self) -> [u8; 4] {
|
||||
self.to_gamma_srgb().to_rgba8()
|
||||
}
|
||||
|
||||
/// Return the all RGB components as a u8 slice, first component is red, followed by green, followed by blue. Use this if the [`Color`] is in gamma space.
|
||||
#[inline(always)]
|
||||
pub fn to_rgb8(&self) -> [u8; 3] {
|
||||
[(self.red * 255.) as u8, (self.green * 255.) as u8, (self.blue * 255.) as u8]
|
||||
}
|
||||
|
||||
/// Return the all RGB components as a u8 slice, first component is red, followed by green, followed by blue. Use this if the [`Color`] is in linear space.
|
||||
#[inline(always)]
|
||||
pub fn to_rgb8_srgb(&self) -> [u8; 3] {
|
||||
self.to_gamma_srgb().to_rgb8()
|
||||
[hue, saturation, value, alpha]
|
||||
}
|
||||
|
||||
// https://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
|
||||
/// Convert a [Color] to a hue, saturation, lightness and alpha (all between 0 and 1)
|
||||
/// Convert this color to HSL coordinates (all between 0 and 1).
|
||||
/// HSL is defined on sRGB display values, so this color's linear RGB is gamma-encoded before the HSL math.
|
||||
pub fn to_hsla(&self) -> [f32; 4] {
|
||||
let min_channel = self.red.min(self.green).min(self.blue);
|
||||
let max_channel = self.red.max(self.green).max(self.blue);
|
||||
let [red, green, blue, alpha] = self.to_gamma_srgb_channels();
|
||||
let min_channel = red.min(green).min(blue);
|
||||
let max_channel = red.max(green).max(blue);
|
||||
|
||||
let lightness = (min_channel + max_channel) / 2.;
|
||||
let saturation = if min_channel == max_channel {
|
||||
@@ -911,39 +969,22 @@ impl Color {
|
||||
} else {
|
||||
(max_channel - min_channel) / (2. - max_channel - min_channel)
|
||||
};
|
||||
let hue = if self.red >= self.green && self.red >= self.blue {
|
||||
(self.green - self.blue) / (max_channel - min_channel)
|
||||
} else if self.green >= self.red && self.green >= self.blue {
|
||||
2. + (self.blue - self.red) / (max_channel - min_channel)
|
||||
let hue = if red >= green && red >= blue {
|
||||
(green - blue) / (max_channel - min_channel)
|
||||
} else if green >= red && green >= blue {
|
||||
2. + (blue - red) / (max_channel - min_channel)
|
||||
} else {
|
||||
4. + (self.red - self.green) / (max_channel - min_channel)
|
||||
4. + (red - green) / (max_channel - min_channel)
|
||||
} / 6.;
|
||||
#[cfg(feature = "std")]
|
||||
let hue = hue.rem_euclid(1.);
|
||||
#[cfg(not(feature = "std"))]
|
||||
let hue = hue.rem_euclid(&1.);
|
||||
|
||||
[hue, saturation, lightness, self.alpha]
|
||||
[hue, saturation, lightness, alpha]
|
||||
}
|
||||
|
||||
/// Creates a color from a hex color code string with an optional `#` prefix, such as `#RRGGBB`, `RRGGBB`, `#RRGGBBAA`, or `RRGGBBAA`.
|
||||
/// Returns `None` for invalid or unrecognized strings.
|
||||
#[cfg(feature = "std")]
|
||||
pub fn from_hex_str(hex: &str) -> Option<Color> {
|
||||
let hex = hex.trim().trim_start_matches('#');
|
||||
if hex.len() != 6 && hex.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
let red = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.;
|
||||
let green = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.;
|
||||
let blue = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.;
|
||||
let alpha = if hex.len() == 8 { u8::from_str_radix(&hex[6..8], 16).ok()? as f32 / 255. } else { 1. };
|
||||
Some(Color { red, green, blue, alpha })
|
||||
}
|
||||
|
||||
/// Linearly interpolates between two colors based on t.
|
||||
///
|
||||
/// T must be between 0 and 1.
|
||||
/// Linearly interpolate each RGBA channel between `self` (`t = 0`) and `other` (`t = 1`); `t` must be in 0..=1.
|
||||
#[inline(always)]
|
||||
pub fn lerp(&self, other: &Color, t: f32) -> Self {
|
||||
assert!((0. ..=1.).contains(&t));
|
||||
@@ -955,70 +996,62 @@ impl Color {
|
||||
)
|
||||
}
|
||||
|
||||
/// Generic power curve `c.powf(1 / exponent)` applied per RGB channel. Distinct from the sRGB transfer curve (see [`Self::to_gamma_srgb_channels`]).
|
||||
/// The expected output must still be treated as linear-light.
|
||||
#[inline(always)]
|
||||
pub fn gamma(&self, gamma: f32) -> Color {
|
||||
let gamma = gamma.max(0.0001);
|
||||
pub fn apply_gamma_exponent(&self, exponent: f32) -> Color {
|
||||
let exponent = exponent.max(0.0001);
|
||||
|
||||
// From https://www.dfstudios.co.uk/articles/programming/image-programming-algorithms/image-processing-algorithms-part-6-gamma-correction/
|
||||
let inverse_gamma = 1. / gamma;
|
||||
self.map_rgb(|c: f32| c.powf(inverse_gamma))
|
||||
let inverse = 1. / exponent;
|
||||
self.map_rgb(|c: f32| c.powf(inverse))
|
||||
}
|
||||
|
||||
/// Decompose into the four channel components after sRGB gamma encoding (linear → gamma). Alpha is unchanged.
|
||||
/// Use [`Self::from_gamma_srgb_channels`] to wrap these gamma-encoded channels back into a linear-light `Color`.
|
||||
#[inline(always)]
|
||||
pub fn to_linear_srgb(&self) -> Self {
|
||||
Self {
|
||||
red: Self::srgb_to_linear(self.red),
|
||||
green: Self::srgb_to_linear(self.green),
|
||||
blue: Self::srgb_to_linear(self.blue),
|
||||
alpha: self.alpha,
|
||||
pub fn to_gamma_srgb_channels(&self) -> [f32; 4] {
|
||||
[super::linear_to_srgb(self.red), super::linear_to_srgb(self.green), super::linear_to_srgb(self.blue), self.alpha]
|
||||
}
|
||||
|
||||
/// Construct a `Color` from sRGB gamma-encoded channel components, decoding RGB to linear-light. Alpha is unchanged.
|
||||
#[inline(always)]
|
||||
pub fn from_gamma_srgb_channels(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
|
||||
Color {
|
||||
red: super::srgb_to_linear(red),
|
||||
green: super::srgb_to_linear(green),
|
||||
blue: super::srgb_to_linear(blue),
|
||||
alpha,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `f` to each RGB channel after sRGB gamma encoding, returning a linear-light `Color`. Alpha is unchanged.
|
||||
/// Equivalent to unpacking via [`Self::to_gamma_srgb_channels`], mapping per channel, and rewrapping via [`Self::from_gamma_srgb_channels`].
|
||||
#[inline(always)]
|
||||
pub fn to_gamma_srgb(&self) -> Self {
|
||||
Self {
|
||||
red: Self::linear_to_srgb(self.red),
|
||||
green: Self::linear_to_srgb(self.green),
|
||||
blue: Self::linear_to_srgb(self.blue),
|
||||
alpha: self.alpha,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn srgb_to_linear(channel: f32) -> f32 {
|
||||
if channel <= 0.04045 { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn linear_to_srgb(channel: f32) -> f32 {
|
||||
if channel <= 0.0031308 { channel * 12.92 } else { 1.055 * channel.powf(1. / 2.4) - 0.055 }
|
||||
pub fn map_gamma_rgb<F: Fn(f32) -> f32>(&self, f: F) -> Color {
|
||||
let [r, g, b, a] = self.to_gamma_srgb_channels();
|
||||
Color::from_gamma_srgb_channels(f(r), f(g), f(b), a)
|
||||
}
|
||||
|
||||
/// Apply `f` to each of the four RGBA channels independently.
|
||||
#[inline(always)]
|
||||
pub fn map_rgba<F: Fn(f32) -> f32>(&self, f: F) -> Self {
|
||||
Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), f(self.a()))
|
||||
}
|
||||
|
||||
/// Apply `f` to each of the three RGB channels; alpha is unchanged.
|
||||
#[inline(always)]
|
||||
pub fn map_rgb<F: Fn(f32) -> f32>(&self, f: F) -> Self {
|
||||
Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), self.a())
|
||||
}
|
||||
|
||||
/// Multiply all four channels (including alpha) by `opacity`, applying an additional premultiplication factor to this Color.
|
||||
#[inline(always)]
|
||||
pub fn apply_opacity(&self, opacity: f32) -> Self {
|
||||
Self::from_rgbaf32_unchecked(self.r() * opacity, self.g() * opacity, self.b() * opacity, self.a() * opacity)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn to_associated_alpha(&self, alpha: f32) -> Self {
|
||||
Self {
|
||||
red: self.red * alpha,
|
||||
green: self.green * alpha,
|
||||
blue: self.blue * alpha,
|
||||
alpha: self.alpha * alpha,
|
||||
}
|
||||
}
|
||||
|
||||
/// Divide RGB by alpha to recover unassociated (straight-alpha) channels; no-op if alpha is zero.
|
||||
#[inline(always)]
|
||||
pub fn to_unassociated_alpha(&self) -> Self {
|
||||
if self.alpha == 0. {
|
||||
@@ -1033,6 +1066,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a per-channel blend function to this color (unmultiplied) and `other`, returning a color with `other`'s alpha; channels are clamped to 0..1.
|
||||
#[inline(always)]
|
||||
pub fn blend_rgb<F: Fn(f32, f32) -> f32>(&self, other: Color, f: F) -> Self {
|
||||
let background = self.to_unassociated_alpha();
|
||||
@@ -1044,6 +1078,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Porter-Duff "source over" composite of `other` over `self`. Both colors must use associated (premultiplied) alpha.
|
||||
#[inline(always)]
|
||||
pub fn alpha_blend(&self, other: Color) -> Self {
|
||||
let inv_alpha = 1. - other.alpha;
|
||||
@@ -1055,6 +1090,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace alpha with `self.alpha + other.alpha`, clamped to 0..1; RGB is unchanged.
|
||||
#[inline(always)]
|
||||
pub fn alpha_add(&self, other: Color) -> Self {
|
||||
Self {
|
||||
@@ -1063,6 +1099,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace alpha with `self.alpha - other.alpha`, clamped to 0..1; RGB is unchanged.
|
||||
#[inline(always)]
|
||||
pub fn alpha_subtract(&self, other: Color) -> Self {
|
||||
Self {
|
||||
@@ -1071,6 +1108,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace alpha with `self.alpha * other.alpha`, clamped to 0..1; RGB is unchanged.
|
||||
#[inline(always)]
|
||||
pub fn alpha_multiply(&self, other: Color) -> Self {
|
||||
Self {
|
||||
@@ -1079,6 +1117,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct from a `glam::Vec4` where `(x, y, z, w)` map to `(red, green, blue, alpha)`.
|
||||
#[inline(always)]
|
||||
pub const fn from_vec4(vec: Vec4) -> Self {
|
||||
Self {
|
||||
@@ -1089,6 +1128,7 @@ impl Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pack into a `glam::Vec4` as `(red, green, blue, alpha)`.
|
||||
#[inline(always)]
|
||||
pub fn to_vec4(&self) -> Vec4 {
|
||||
Vec4::new(self.red, self.green, self.blue, self.alpha)
|
||||
@@ -1123,7 +1163,7 @@ mod tests {
|
||||
(82, 84, 84),
|
||||
(255, 255, 178),
|
||||
] {
|
||||
let col = Color::from_rgb8_srgb(red, green, blue);
|
||||
let col: Color = SRGBA8::new(red, green, blue, 255).into();
|
||||
let [hue, saturation, lightness, alpha] = col.to_hsla();
|
||||
let result = Color::from_hsla(hue, saturation, lightness, alpha);
|
||||
assert!((col.r() - result.r()) < f32::EPSILON * 100.);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
mod color_traits;
|
||||
mod color_types;
|
||||
mod discrete_srgb;
|
||||
mod transfer;
|
||||
|
||||
pub use color_traits::*;
|
||||
pub use color_types::*;
|
||||
pub use discrete_srgb::*;
|
||||
pub use transfer::*;
|
||||
|
||||
19
node-graph/libraries/no-std-types/src/color/transfer.rs
Normal file
19
node-graph/libraries/no-std-types/src/color/transfer.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
//! Analytic per-channel sRGB transfer functions (gamma encoding/decoding).
|
||||
//!
|
||||
//! These work in `f32` at full precision. For round-trip-exact `u8` ⇄ `f32` conversion at the
|
||||
//! display byte boundary, use the lookup tables in [`super::discrete_srgb`] instead.
|
||||
|
||||
#[cfg(not(feature = "std"))]
|
||||
use num_traits::float::Float;
|
||||
|
||||
/// Decode an sRGB gamma-encoded channel value to linear-light.
|
||||
#[inline(always)]
|
||||
pub fn srgb_to_linear(channel: f32) -> f32 {
|
||||
if channel <= 0.04045 { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// Encode a linear-light channel value to sRGB gamma-encoded.
|
||||
#[inline(always)]
|
||||
pub fn linear_to_srgb(channel: f32) -> f32 {
|
||||
if channel <= 0.0031308 { channel * 12.92 } else { 1.055 * channel.powf(1. / 2.4) - 0.055 }
|
||||
}
|
||||
@@ -144,7 +144,14 @@ impl<P: Pixel> Image<P> {
|
||||
impl Image<Color> {
|
||||
/// Generate Image from some frontend image data (the canvas pixels as u8s in a flat array)
|
||||
pub fn from_image_data(image_data: &[u8], width: u32, height: u32) -> Self {
|
||||
let data = image_data.chunks_exact(4).map(|v| Color::from_rgba8_srgb(v[0], v[1], v[2], v[3])).collect();
|
||||
let data = image_data
|
||||
.chunks_exact(4)
|
||||
.map(|v| {
|
||||
// `Image<Color>` pixels are stored linear-light with premultiplied alpha
|
||||
let srgba = SRGBA8::new(v[0], v[1], v[2], v[3]);
|
||||
Color::from(srgba).apply_opacity(v[3] as f32 / 255.)
|
||||
})
|
||||
.collect();
|
||||
Image {
|
||||
width,
|
||||
height,
|
||||
@@ -263,30 +270,6 @@ impl<P: Pixel> AsRef<Image<P>> for Image<P> {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Image<Color>> for Image<SRGBA8> {
|
||||
fn from(image: Image<Color>) -> Self {
|
||||
let data = image.data.into_iter().map(|x| x.into()).collect();
|
||||
Self {
|
||||
data,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
base64_string: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Image<SRGBA8>> for Image<Color> {
|
||||
fn from(image: Image<SRGBA8>) -> Self {
|
||||
let data = image.data.into_iter().map(|x| x.into()).collect();
|
||||
Self {
|
||||
data,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
base64_string: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::renderer::{RenderParams, format_transform_matrix};
|
||||
use core_types::color::SRGBA8;
|
||||
use core_types::uuid::generate_uuid;
|
||||
use glam::DAffine2;
|
||||
use graphic_types::vector_types::gradient::{Gradient, GradientType};
|
||||
@@ -22,7 +23,7 @@ impl RenderExt for Gradient {
|
||||
if position != 0. {
|
||||
let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.);
|
||||
}
|
||||
let _ = write!(stop, r##" stop-color="#{}""##, color.to_rgb_hex_srgb_from_gamma());
|
||||
let _ = write!(stop, r##" stop-color="#{}""##, SRGBA8::from(color).to_rgb_hex());
|
||||
if color.a() < 1. {
|
||||
let _ = write!(stop, r#" stop-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
|
||||
}
|
||||
@@ -86,7 +87,7 @@ impl RenderExt for Fill {
|
||||
match self {
|
||||
Self::None => r#" fill="none""#.to_string(),
|
||||
Self::Solid(color) => {
|
||||
let mut result = format!(r##" fill="#{}""##, color.to_rgb_hex_srgb_from_gamma());
|
||||
let mut result = format!(r##" fill="#{}""##, SRGBA8::from(*color).to_rgb_hex());
|
||||
if color.a() < 1. {
|
||||
let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
|
||||
}
|
||||
@@ -132,7 +133,7 @@ impl RenderExt for Stroke {
|
||||
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow);
|
||||
|
||||
// Render the needed stroke attributes
|
||||
let mut attributes = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma());
|
||||
let mut attributes = format!(r##" stroke="#{}""##, SRGBA8::from(color).to_rgb_hex());
|
||||
if color.a() < 1. {
|
||||
let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::render_ext::RenderExt;
|
||||
use crate::to_peniko::BlendModeExt;
|
||||
use crate::to_peniko::{BlendModeExt, ToPenikoColor};
|
||||
use core_types::CacheHash;
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::bounds::BoundingBox;
|
||||
use core_types::bounds::RenderBoundingBox;
|
||||
use core_types::color::Color;
|
||||
use core_types::color::SRGBA8;
|
||||
use core_types::list::{Item, List};
|
||||
use core_types::math::quad::Quad;
|
||||
use core_types::render_complexity::RenderComplexity;
|
||||
@@ -273,19 +274,20 @@ pub fn black_or_white_for_best_contrast(background: Option<Color>) -> Color {
|
||||
|
||||
let alpha = bg.a();
|
||||
|
||||
// Un-premultiply, then convert to gamma sRGB
|
||||
let srgb = if alpha > f32::EPSILON {
|
||||
Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb()
|
||||
// Un-premultiply, then encode to gamma sRGB to do the composite in display space.
|
||||
let (gamma_r, gamma_g, gamma_b) = if alpha > f32::EPSILON {
|
||||
let [r, g, b, _] = Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb_channels();
|
||||
(r, g, b)
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
(0., 0., 0.)
|
||||
};
|
||||
|
||||
// Composite over black in sRGB space, then convert back to linear for luminance
|
||||
let composited = Color::from_rgbaf32_unchecked(srgb.r() * alpha, srgb.g() * alpha, srgb.b() * alpha, 1.).to_linear_srgb();
|
||||
// Composite over black in sRGB space (premultiplied by alpha), then decode to linear for the luminance test.
|
||||
let composited = Color::from_gamma_srgb_channels(gamma_r * alpha, gamma_g * alpha, gamma_b * alpha, 1.);
|
||||
|
||||
let threshold = (1.05 * 0.05f32).sqrt() - 0.05;
|
||||
|
||||
if composited.luminance_srgb() > threshold { Color::BLACK } else { Color::WHITE }
|
||||
if composited.luminance_rec_709() > threshold { Color::BLACK } else { Color::WHITE }
|
||||
}
|
||||
|
||||
pub fn to_transform(transform: DAffine2) -> usvg::Transform {
|
||||
@@ -311,7 +313,7 @@ fn get_outline_styles(render_params: &RenderParams) -> (kurbo::Stroke, peniko::C
|
||||
};
|
||||
|
||||
let outline_color = black_or_white_for_best_contrast(render_params.artboard_background);
|
||||
let outline_color_peniko = peniko::Color::new([outline_color.r(), outline_color.g(), outline_color.b(), outline_color.a()]);
|
||||
let outline_color_peniko = SRGBA8::from(outline_color).to_peniko_color();
|
||||
|
||||
(outline_stroke, outline_color_peniko)
|
||||
}
|
||||
@@ -573,7 +575,7 @@ impl Render for List<Artboard> {
|
||||
|
||||
// Background
|
||||
render.leaf_tag("rect", |attributes| {
|
||||
attributes.push("fill", format!("#{}", background.to_rgb_hex_srgb_from_gamma()));
|
||||
attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
|
||||
if background.a() < 1. {
|
||||
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
|
||||
}
|
||||
@@ -629,7 +631,7 @@ impl Render for List<Artboard> {
|
||||
|
||||
let artboard_transform = kurbo::Affine::new(transform.to_cols_array());
|
||||
|
||||
let color = peniko::Color::new([background.r(), background.g(), background.b(), background.a()]);
|
||||
let color = SRGBA8::from(background).to_peniko_color();
|
||||
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
|
||||
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
|
||||
scene.pop_layer();
|
||||
@@ -1182,7 +1184,7 @@ impl Render for List<Vector> {
|
||||
// Closures to avoid duplicated fill/stroke drawing logic
|
||||
let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() {
|
||||
Fill::Solid(color) => {
|
||||
let fill = peniko::Brush::Solid(peniko::Color::new([color.r(), color.g(), color.b(), color.a()]));
|
||||
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
|
||||
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
|
||||
}
|
||||
Fill::Gradient(gradient) => {
|
||||
@@ -1190,7 +1192,7 @@ impl Render for List<Vector> {
|
||||
for (position, color, _) in gradient.stops.interpolated_samples() {
|
||||
stops.push(peniko::ColorStop {
|
||||
offset: position as f32,
|
||||
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])),
|
||||
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1267,7 +1269,7 @@ impl Render for List<Vector> {
|
||||
let do_stroke = |scene: &mut Scene, width_scale: f64| {
|
||||
if let Some(stroke) = element.style.stroke() {
|
||||
let color = match stroke.color {
|
||||
Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]),
|
||||
Some(color) => SRGBA8::from(color).to_peniko_color(),
|
||||
None => peniko::Color::TRANSPARENT,
|
||||
};
|
||||
let cap = match stroke.cap {
|
||||
@@ -1811,7 +1813,7 @@ impl Render for List<Color> {
|
||||
const MAX: f64 = 1e7;
|
||||
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
|
||||
|
||||
attributes.push("fill", format!("#{}", color.to_rgb_hex_srgb_from_gamma()));
|
||||
attributes.push("fill", format!("#{}", SRGBA8::from(*color).to_rgb_hex()));
|
||||
if color.a() < 1. {
|
||||
attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string());
|
||||
}
|
||||
@@ -1838,7 +1840,7 @@ impl Render for List<Color> {
|
||||
let blend_mode = blend_mode_attr.to_peniko();
|
||||
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
|
||||
|
||||
let vello_color = peniko::Color::new([color.r(), color.g(), color.b(), color.a()]);
|
||||
let vello_color = SRGBA8::from(*color).to_peniko_color();
|
||||
|
||||
let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.));
|
||||
|
||||
@@ -1895,7 +1897,7 @@ impl Render for List<GradientStops> {
|
||||
|
||||
let mut stop_string = String::new();
|
||||
for (position, color, original_midpoint) in gradient.interpolated_samples() {
|
||||
let _ = write!(stop_string, r##"<stop offset="{}" stop-color="#{}""##, position, color.to_rgb_hex_srgb_from_gamma());
|
||||
let _ = write!(stop_string, r##"<stop offset="{}" stop-color="#{}""##, position, SRGBA8::from(color).to_rgb_hex());
|
||||
if color.a() < 1. {
|
||||
let _ = write!(stop_string, r#" stop-opacity="{}""#, color.a());
|
||||
}
|
||||
@@ -1977,7 +1979,7 @@ impl Render for List<GradientStops> {
|
||||
for (position, color, _) in gradient.interpolated_samples() {
|
||||
stops.push(peniko::ColorStop {
|
||||
offset: position as f32,
|
||||
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])),
|
||||
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
use core_types::BlendMode;
|
||||
use core_types::color::SRGBA8;
|
||||
use vello::peniko;
|
||||
|
||||
pub trait BlendModeExt {
|
||||
fn to_peniko(&self) -> peniko::Mix;
|
||||
}
|
||||
|
||||
pub trait ToPenikoColor {
|
||||
fn to_peniko_color(&self) -> peniko::Color;
|
||||
}
|
||||
|
||||
impl ToPenikoColor for SRGBA8 {
|
||||
#[inline(always)]
|
||||
fn to_peniko_color(&self) -> peniko::Color {
|
||||
peniko::Color::from_rgba8(self.red, self.green, self.blue, self.alpha)
|
||||
}
|
||||
}
|
||||
|
||||
impl BlendModeExt for BlendMode {
|
||||
fn to_peniko(&self) -> peniko::Mix {
|
||||
match self {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use core_types::{Color, render_complexity::RenderComplexity};
|
||||
use core_types::Color;
|
||||
use core_types::color::SRGBA8;
|
||||
use core_types::render_complexity::RenderComplexity;
|
||||
use dyn_any::DynAny;
|
||||
use glam::{DAffine2, DVec2};
|
||||
|
||||
@@ -13,9 +15,9 @@ pub enum GradientType {
|
||||
}
|
||||
|
||||
// TODO: Someday we could switch this to a Box[T] to avoid over-allocation
|
||||
// TODO: Use linear not gamma colors
|
||||
/// A list of colors associated with positions (in the range 0 to 1) along a gradient.
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
/// A list of colors (linear, unassociated alpha) associated with positions (in the range 0 to 1) along a gradient.
|
||||
///
|
||||
/// Not exposed via Tsify; use [`GradientStopsUI`] at the JS boundary.
|
||||
#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
|
||||
pub struct GradientStops {
|
||||
@@ -27,6 +29,59 @@ pub struct GradientStops {
|
||||
pub color: Vec<Color>,
|
||||
}
|
||||
|
||||
/// JS-boundary version of [`GradientStops`] where stop colors are [`SRGBA8`] byte triples instead of linear-light [`Color`].
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
|
||||
#[derive(Debug, Clone, PartialEq, Default, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct GradientStopsUI {
|
||||
pub position: Vec<f64>,
|
||||
pub midpoint: Vec<f64>,
|
||||
pub color: Vec<SRGBA8>,
|
||||
}
|
||||
|
||||
impl From<&GradientStops> for GradientStopsUI {
|
||||
fn from(s: &GradientStops) -> Self {
|
||||
Self {
|
||||
position: s.position.clone(),
|
||||
midpoint: s.midpoint.clone(),
|
||||
color: s.color.iter().map(|c| SRGBA8::from(*c)).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&GradientStopsUI> for GradientStops {
|
||||
fn from(s: &GradientStopsUI) -> Self {
|
||||
Self {
|
||||
position: s.position.clone(),
|
||||
midpoint: s.midpoint.clone(),
|
||||
color: s.color.iter().map(|c| Color::from(*c)).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GradientStopsUI {
|
||||
/// CSS `linear-gradient(...)` string. Stops are emitted as `#rrggbbaa` hex (already gamma-encoded bytes).
|
||||
pub fn to_css_linear_gradient(&self) -> String {
|
||||
if self.position.len() <= 1 {
|
||||
let hex = self.color.first().map(|c| c.to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
|
||||
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
|
||||
}
|
||||
// Sample via the midpoint-aware subdivision used for SVG/Vello stops so browser interpolation matches
|
||||
let stops: GradientStops = self.into();
|
||||
let pieces = stops
|
||||
.interpolated_samples()
|
||||
.into_iter()
|
||||
.map(|(position, color, _)| {
|
||||
let percent = ((position * 100.) * 1e2).round() / 1e2;
|
||||
let hex = SRGBA8::from(color).to_rgba_hex();
|
||||
format!("#{hex} {percent}%")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("linear-gradient(to right, {pieces})")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Eventually remove this migration document upgrade code
|
||||
impl<'de> serde::Deserialize<'de> for GradientStops {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
@@ -294,7 +349,7 @@ impl GradientStops {
|
||||
/// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves so the rendered gradient matches Graphite's interpolation rather than browser defaults.
|
||||
pub fn to_css_linear_gradient(&self) -> String {
|
||||
if self.position.len() <= 1 {
|
||||
let hex = self.color.first().map(|c| c.to_rgba_hex_srgb_from_gamma()).unwrap_or_else(|| "000000ff".to_string());
|
||||
let hex = self.color.first().map(|c| SRGBA8::from(*c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
|
||||
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
|
||||
}
|
||||
let pieces = self
|
||||
@@ -302,7 +357,7 @@ impl GradientStops {
|
||||
.into_iter()
|
||||
.map(|(position, color, _)| {
|
||||
let percent = ((position * 100.) * 1e2).round() / 1e2;
|
||||
format!("#{} {percent}%", color.to_rgba_hex_srgb_from_gamma())
|
||||
format!("#{} {percent}%", SRGBA8::from(color).to_rgba_hex())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
@@ -313,13 +368,17 @@ impl GradientStops {
|
||||
///
|
||||
/// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding
|
||||
/// midpoint for actual gradient stops, and `None` for interpolated samples added to approximate midpoint curves.
|
||||
///
|
||||
/// Interpolation is performed in sRGB gamma space (then lifted back to linear-light for output) because the downstream SVG/CSS
|
||||
/// renderer interpolates between adjacent `<stop>` colors in gamma space; doing the subdivision math in the same space ensures
|
||||
/// the chosen samples actually match the curve the browser will draw.
|
||||
pub fn interpolated_samples(&self) -> Vec<(f64, Color, Option<f64>)> {
|
||||
/// Controls accuracy vs. number of samples tradeoff.
|
||||
/// 2/255 means the linear approximation will deviate by no more than 2 gradations of 8-bit color from the theoretically perfect curve with this midpoint bias.
|
||||
const THRESHOLD: f64 = 2. / 255.;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn subdivide(left: f64, right: f64, midpoint: f64, pos_a: f64, pos_b: f64, color_a: Color, color_b: Color, result: &mut Vec<(f64, Color, Option<f64>)>, depth: u32) {
|
||||
fn subdivide(left: f64, right: f64, midpoint: f64, pos_a: f64, pos_b: f64, color_a_gamma: [f32; 4], color_b_gamma: [f32; 4], result: &mut Vec<(f64, Color, Option<f64>)>, depth: u32) {
|
||||
const MAX_DEPTH: u32 = 20;
|
||||
if depth >= MAX_DEPTH {
|
||||
return;
|
||||
@@ -333,13 +392,18 @@ impl GradientStops {
|
||||
let y_linear = (y_left + y_right) / 2.;
|
||||
|
||||
if (y_actual - y_linear).abs() > THRESHOLD {
|
||||
subdivide(left, mid, midpoint, pos_a, pos_b, color_a, color_b, result, depth + 1);
|
||||
subdivide(left, mid, midpoint, pos_a, pos_b, color_a_gamma, color_b_gamma, result, depth + 1);
|
||||
|
||||
let global_pos = pos_a + mid * (pos_b - pos_a);
|
||||
let color = color_a.lerp(&color_b, y_actual as f32);
|
||||
let t = y_actual as f32;
|
||||
let r = color_a_gamma[0] + (color_b_gamma[0] - color_a_gamma[0]) * t;
|
||||
let g = color_a_gamma[1] + (color_b_gamma[1] - color_a_gamma[1]) * t;
|
||||
let b = color_a_gamma[2] + (color_b_gamma[2] - color_a_gamma[2]) * t;
|
||||
let a = color_a_gamma[3] + (color_b_gamma[3] - color_a_gamma[3]) * t;
|
||||
let color = Color::from_gamma_srgb_channels(r, g, b, a);
|
||||
result.push((global_pos, color, None));
|
||||
|
||||
subdivide(mid, right, midpoint, pos_a, pos_b, color_a, color_b, result, depth + 1);
|
||||
subdivide(mid, right, midpoint, pos_a, pos_b, color_a_gamma, color_b_gamma, result, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +432,7 @@ impl GradientStops {
|
||||
|
||||
// Only subdivide if midpoint deviates from linear (0.5)
|
||||
if (midpoint - 0.5).abs() >= 1e-6 {
|
||||
subdivide(0., 1., midpoint, pos_a, pos_b, color_a, color_b, &mut result, 0);
|
||||
subdivide(0., 1., midpoint, pos_a, pos_b, color_a.to_gamma_srgb_channels(), color_b.to_gamma_srgb_channels(), &mut result, 0);
|
||||
}
|
||||
|
||||
// Add the end stop
|
||||
@@ -440,7 +504,7 @@ impl std::fmt::Display for Gradient {
|
||||
let stops = self
|
||||
.stops
|
||||
.iter()
|
||||
.map(|stop| format!("[{}%: #{}]", round(stop.position * 100.), stop.color.to_rgba_hex_srgb()))
|
||||
.map(|stop| format!("[{}%: #{}]", round(stop.position * 100.), SRGBA8::from(stop.color).to_rgba_hex()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
write!(f, "{} Gradient: {stops}", self.gradient_type)
|
||||
@@ -454,12 +518,12 @@ impl Gradient {
|
||||
GradientStop {
|
||||
position: 0.,
|
||||
midpoint: 0.5,
|
||||
color: start_color.to_gamma_srgb(),
|
||||
color: start_color,
|
||||
},
|
||||
GradientStop {
|
||||
position: 1.,
|
||||
midpoint: 0.5,
|
||||
color: end_color.to_gamma_srgb(),
|
||||
color: end_color,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
pub use crate::gradient::*;
|
||||
use core_types::ATTR_OPACITY;
|
||||
use core_types::Color;
|
||||
use core_types::color::Alpha;
|
||||
use core_types::color::{Alpha, SRGBA8};
|
||||
use core_types::list::List;
|
||||
use core_types::transform::Transform;
|
||||
use dyn_any::DynAny;
|
||||
@@ -30,7 +30,7 @@ impl std::fmt::Display for Fill {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::None => write!(f, "None"),
|
||||
Self::Solid(color) => write!(f, "#{} (Alpha: {}%)", color.to_rgb_hex_srgb(), color.a() * 100.),
|
||||
Self::Solid(color) => write!(f, "#{} (Alpha: {}%)", SRGBA8::from(*color).to_rgb_hex(), color.a() * 100.),
|
||||
Self::Gradient(gradient) => write!(f, "{gradient}"),
|
||||
}
|
||||
}
|
||||
@@ -161,19 +161,75 @@ impl From<Gradient> for Fill {
|
||||
/// Can be None, a solid [Color], or a linear/radial [Gradient].
|
||||
///
|
||||
/// In the future we'll probably also add a pattern fill.
|
||||
///
|
||||
/// Use [`FillChoiceUI`] at the JS boundary.
|
||||
#[repr(C)]
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum FillChoice {
|
||||
#[default]
|
||||
None,
|
||||
/// WARNING: Color is gamma, not linear!
|
||||
Solid(Color),
|
||||
/// WARNING: Color stops are gamma, not linear!
|
||||
Gradient(GradientStops),
|
||||
}
|
||||
|
||||
// TODO: Deprecate [`FillChoice`] and keep this, renamed, as the main widget-controlling type
|
||||
/// JS-boundary version of [`FillChoice`] where the solid color is [`SRGBA8`] and the gradient is [`GradientStopsUI`].
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
|
||||
#[derive(Default, Debug, Clone, PartialEq, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum FillChoiceUI {
|
||||
#[default]
|
||||
None,
|
||||
Solid(SRGBA8),
|
||||
Gradient(GradientStopsUI),
|
||||
}
|
||||
|
||||
impl From<&FillChoice> for FillChoiceUI {
|
||||
fn from(value: &FillChoice) -> Self {
|
||||
match value {
|
||||
FillChoice::None => Self::None,
|
||||
FillChoice::Solid(color) => Self::Solid(SRGBA8::from(*color)),
|
||||
FillChoice::Gradient(stops) => Self::Gradient(GradientStopsUI::from(stops)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&FillChoiceUI> for FillChoice {
|
||||
fn from(value: &FillChoiceUI) -> Self {
|
||||
match value {
|
||||
FillChoiceUI::None => Self::None,
|
||||
FillChoiceUI::Solid(srgba) => Self::Solid(Color::from(*srgba)),
|
||||
FillChoiceUI::Gradient(stops) => Self::Gradient(GradientStops::from(stops)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FillChoiceUI {
|
||||
pub fn as_solid(&self) -> Option<SRGBA8> {
|
||||
let Self::Solid(c) = self else { return None };
|
||||
Some(*c)
|
||||
}
|
||||
|
||||
pub fn as_gradient(&self) -> Option<&GradientStopsUI> {
|
||||
let Self::Gradient(g) = self else { return None };
|
||||
Some(g)
|
||||
}
|
||||
|
||||
/// Build a CSS `background-image` string (always a `linear-gradient(...)`) representing this fill, or `None` if the fill is [`FillChoiceUI::None`].
|
||||
/// Solid colors become a degenerate gradient between the same color so the CSS variable can always be assigned to a `background-image`.
|
||||
pub fn to_css_background_image(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Solid(srgba) => {
|
||||
let hex = srgba.to_rgba_hex();
|
||||
Some(format!("linear-gradient(#{hex}, #{hex})"))
|
||||
}
|
||||
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FillChoice {
|
||||
pub fn as_solid(&self) -> Option<Color> {
|
||||
let Self::Solid(color) = self else { return None };
|
||||
@@ -190,7 +246,7 @@ impl FillChoice {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::Solid(color) => {
|
||||
let hex = color.to_rgba_hex_srgb_from_gamma();
|
||||
let hex = SRGBA8::from(*color).to_rgba_hex();
|
||||
Some(format!("linear-gradient(#{hex}, #{hex})"))
|
||||
}
|
||||
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
|
||||
@@ -526,7 +582,7 @@ impl Default for Stroke {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
weight: 0.,
|
||||
color: Some(Color::from_rgba8_srgb(0, 0, 0, 255)),
|
||||
color: Some(Color::BLACK),
|
||||
dash_lengths: Vec::new(),
|
||||
dash_offset: 0.,
|
||||
cap: StrokeCap::Butt,
|
||||
@@ -553,7 +609,7 @@ impl std::fmt::Display for PathStyle {
|
||||
let fill = &self.fill;
|
||||
|
||||
let stroke = match &self.stroke {
|
||||
Some(stroke) => format!("#{} (Weight: {} px)", stroke.color.map_or("None".to_string(), |c| c.to_rgba_hex_srgb()), stroke.weight),
|
||||
Some(stroke) => format!("#{} (Weight: {} px)", stroke.color.map_or("None".to_string(), |c| SRGBA8::from(c).to_rgba_hex()), stroke.weight),
|
||||
None => "None".to_string(),
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::shader_runtime::ShaderRuntime;
|
||||
use crate::texture_cache::TextureCache;
|
||||
use anyhow::Result;
|
||||
use core_types::Color;
|
||||
use core_types::color::SRGBA8;
|
||||
use futures::lock::Mutex;
|
||||
use glam::{Affine2, UVec2};
|
||||
use graphene_application_io::{ApplicationIo, EditorApi};
|
||||
@@ -55,9 +56,9 @@ impl WgpuExecutor {
|
||||
|
||||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8();
|
||||
let SRGBA8 { red, green, blue, alpha } = background.unwrap_or(Color::TRANSPARENT).into();
|
||||
let render_params = RenderParams {
|
||||
base_color: vello::peniko::Color::from_rgba8(r, g, b, a),
|
||||
base_color: vello::peniko::Color::from_rgba8(red, green, blue, alpha),
|
||||
width: size.x,
|
||||
height: size.y,
|
||||
antialiasing_method: AaConfig::Msaa16,
|
||||
|
||||
@@ -120,7 +120,9 @@ impl RasterGpuToRasterCpuConverter {
|
||||
let start = row * row_stride;
|
||||
let row_slice = &view[start..start + row_bytes];
|
||||
for px in row_slice.chunks_exact(4) {
|
||||
cpu_data.push(Color::from_rgba8_srgb(px[0], px[1], px[2], px[3]));
|
||||
// `Image<Color>` pixels are stored linear-light with associated (premultiplied) alpha
|
||||
let srgba = SRGBA8::new(px[0], px[1], px[2], px[3]);
|
||||
cpu_data.push(Color::from(srgba).apply_opacity(px[3] as f32 / 255.));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user