diff --git a/Cargo.lock b/Cargo.lock index c8c3be78f2..9aca9385f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -887,6 +887,7 @@ dependencies = [ "base64", "bitflags 2.11.0", "bytemuck", + "color", "ctor", "dyn-any", "glam", @@ -2216,6 +2217,7 @@ dependencies = [ name = "graphite-wasm-wrapper" version = "0.0.0" dependencies = [ + "bytemuck", "graph-craft", "graphene-std", "graphite-editor", diff --git a/editor/src/messages/color_picker/color_picker_message.rs b/editor/src/messages/color_picker/color_picker_message.rs index 149e2660f1..e8ec19173f 100644 --- a/editor/src/messages/color_picker/color_picker_message.rs +++ b/editor/src/messages/color_picker/color_picker_message.rs @@ -21,7 +21,7 @@ pub enum HsvChannel { #[impl_message(Message, ColorPicker)] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum ColorPickerMessage { - /// Initialize the picker state from an external color/gradient and announce its options. Called by the frontend when a `` opens. + /// Initialize the picker state from an external color/gradient and announce its options. Called by the frontend when a `` opens. Open { initial_value: FillChoice, allow_none: bool, disabled: bool }, /// Clear the picker state. Called by the frontend when the popover closes. Close, diff --git a/editor/src/messages/color_picker/color_picker_message_handler.rs b/editor/src/messages/color_picker/color_picker_message_handler.rs index cbdbefabe9..9894e463b9 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -2,9 +2,10 @@ use crate::messages::color_picker::color_picker_message::{HsvChannel, RgbChannel use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::layout::utility_types::widgets::input_widgets::{ColorPresetsInputUpdate, SpectrumInputUpdate, SpectrumMarker, VisualColorPickersInputUpdate}; use crate::messages::prelude::*; -use color::{AlphaColor, Srgb}; use graphene_std::Color; -use graphene_std::vector::style::{FillChoice, GradientStops}; +use graphene_std::color::SRGBA8; +use graphene_std::core_types::misc::parse_css_color; +use graphene_std::vector::style::{FillChoice, FillChoiceUI, GradientStops, GradientStopsUI}; /// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops). const MIN_MIDPOINT: f64 = 0.01; @@ -105,10 +106,13 @@ impl MessageHandler for ColorPickerMessageHandler { ColorPickerMessage::SetChannelRgb { channel, value } => { let Some(strength) = value else { return }; let Some(current) = self.current_color() else { return }; + // The RGB inputs are 0..255 sRGB display values; substitute the new channel into the gamma triple and lift back to linear for storage. + let new_gamma_channel = (strength / 255.) as f32; + let [cur_r, cur_g, cur_b, cur_a] = current.to_gamma_srgb_channels(); let updated = match channel { - RgbChannel::Red => Color::from_rgbaf32_unchecked((strength / 255.) as f32, current.g(), current.b(), current.a()), - RgbChannel::Green => Color::from_rgbaf32_unchecked(current.r(), (strength / 255.) as f32, current.b(), current.a()), - RgbChannel::Blue => Color::from_rgbaf32_unchecked(current.r(), current.g(), (strength / 255.) as f32, current.a()), + RgbChannel::Red => Color::from_gamma_srgb_channels(new_gamma_channel, cur_g, cur_b, cur_a), + RgbChannel::Green => Color::from_gamma_srgb_channels(cur_r, new_gamma_channel, cur_b, cur_a), + RgbChannel::Blue => Color::from_gamma_srgb_channels(cur_r, cur_g, new_gamma_channel, cur_a), }; self.adopt_color(updated); self.emit_color(responses); @@ -150,7 +154,7 @@ impl MessageHandler for ColorPickerMessageHandler { match preset { FillChoice::None => { self.set_new_hsva(0., 0., 0., 1., true); - responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None }); + responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoiceUI::None }); } FillChoice::Solid(color) => { self.adopt_color(color); @@ -175,7 +179,7 @@ impl MessageHandler for ColorPickerMessageHandler { self.set_old_hsva(temp.0, temp.1, temp.2, temp.3, temp.4); if self.is_none { - responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::None }); + responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoiceUI::None }); } else { self.emit_color(responses); } @@ -197,6 +201,7 @@ impl MessageHandler for ColorPickerMessageHandler { } impl ColorPickerMessageHandler { + // The picker's internal HSV state is HSV of sRGB display values fn current_color(&self) -> Option { if self.is_none { None @@ -239,7 +244,8 @@ impl ColorPickerMessageHandler { /// Set HSV state from a Color, preserving hue and saturation in degenerate cases. fn adopt_color(&mut self, color: Color) { - let [target_h, target_s, target_v] = rgb_to_hsv(color.r() as f64, color.g() as f64, color.b() as f64); + let [target_h, target_s, target_v, target_a] = color.to_hsva(); + let (target_h, target_s, target_v, target_a) = (target_h as f64, target_s as f64, target_v as f64, target_a as f64); // Preserve hue: avoid jumping from 360° (top) to 0° (bottom) and don't reset hue when the color is desaturated or fully dark. if !(target_h == 0. && self.hue == 1.) && target_s > 0. && target_v > 0. { @@ -250,7 +256,7 @@ impl ColorPickerMessageHandler { self.saturation = target_s; } self.value = target_v; - self.alpha = color.a() as f64; + self.alpha = target_a; self.is_none = false; } @@ -264,9 +270,15 @@ impl ColorPickerMessageHandler { { *stop_color = color; let stops = gradient.clone(); - responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(stops) }); + let fill_choice = FillChoice::Gradient(stops); + responses.add(FrontendMessage::ColorPickerColorChanged { + value: FillChoiceUI::from(&fill_choice), + }); } else { - responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Solid(color) }); + let fill_choice = FillChoice::Solid(color); + responses.add(FrontendMessage::ColorPickerColorChanged { + value: FillChoiceUI::from(&fill_choice), + }); } } @@ -364,8 +376,9 @@ impl ColorPickerMessageHandler { } self.gradient = Some(gradient.clone()); + let fill_choice = FillChoice::Gradient(gradient); responses.add(FrontendMessage::ColorPickerColorChanged { - value: FillChoice::Gradient(gradient), + value: FillChoiceUI::from(&fill_choice), }); self.send_layouts(responses); } @@ -392,7 +405,7 @@ impl ColorPickerMessageHandler { // For gradient editing, the markers' handle colors mirror their gradient stop colors let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect(); let mut row_widgets = vec![ - SpectrumInput::new(gradient.clone()) + SpectrumInput::new(GradientStopsUI::from(gradient)) .markers(markers) .active_marker_index(self.active_marker_index) .active_marker_is_midpoint(self.active_marker_is_midpoint) @@ -453,7 +466,11 @@ impl ColorPickerMessageHandler { let old_color = self.old_color(); let hex_value = new_color.map(|c| color_to_hex_optional_alpha(&c)).unwrap_or_else(|| "-".to_string()); - let rgb_255 = new_color.map(|c| (c.r() as f64 * 255., c.g() as f64 * 255., c.b() as f64 * 255.)); + // RGB readouts display sRGB byte values to the user, so we convert from linear-light to gamma here before quantizing. + let rgb_255 = new_color.map(|c| { + let [r, g, b, _] = c.to_gamma_srgb_channels(); + (r as f64 * 255., g as f64 * 255., b as f64 * 255.) + }); // Epsilon comparison since the picker round-trips through HSV let differs = match (new_color, old_color) { @@ -470,7 +487,7 @@ impl ColorPickerMessageHandler { // New/old comparison swatch with swap button groups.push(LayoutGroup::row(vec![ - ColorComparisonInput::new(new_color, old_color) + ColorComparisonInput::new(new_color.map(SRGBA8::from), old_color.map(SRGBA8::from)) .is_none(self.is_none) .old_is_none(self.old_is_none) .disabled(self.disabled) @@ -566,7 +583,10 @@ impl ColorPickerMessageHandler { .disabled(self.disabled) .show_none_option(self.allow_none && self.gradient.is_none()) .on_update(|update: &ColorPresetsInputUpdate| match update { - ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset { preset: fill_choice.clone() }.into(), + ColorPresetsInputUpdate::Preset(fill_choice) => ColorPickerMessage::PickPreset { + preset: FillChoice::from(fill_choice), + } + .into(), ColorPresetsInputUpdate::EyedropperColorCode(code) => ColorPickerMessage::EyedropperColorCode { code: code.clone() }.into(), }) .widget_instance(), @@ -611,33 +631,10 @@ const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color const VALUE_DESCRIPTION: &str = "The brightness from black to full color."; const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%)."; -/// Convert an `rgb(0..1)` triple to `hsv(0..1)`. Mirrors the legacy frontend `colorToHSV`. -fn rgb_to_hsv(red: f64, green: f64, blue: f64) -> [f64; 3] { - let max = red.max(green).max(blue); - let min = red.min(green).min(blue); - let delta = max - min; - - let mut hue = if delta == 0. { - 0. - } else if max == red { - ((green - blue) / delta).rem_euclid(6.) - } else if max == green { - (blue - red) / delta + 2. - } else { - (red - green) / delta + 4. - }; - hue = (hue * 60. + 360.).rem_euclid(360.) / 360.; - - let saturation = if max == 0. { 0. } else { delta / max }; - let value = max; - - [hue, saturation, value] -} - -/// The popover's background color (the `--color-2-mildblack` design token, `#222`). Used by the comparison swatch's -/// outline computation to brighten the inset border for colors close to this background. -const POPOVER_BACKGROUND: Color = Color::from_rgbaf32_unchecked(0x22 as f32 / 255., 0x22 as f32 / 255., 0x22 as f32 / 255., 1.); -/// The luminance window (in linear-light) within which a color is considered close enough to the popover background +/// The popover's background color as sRGB gamma-encoded channels (the `--color-2-mildblack` design token, `#222`). +/// Used by the comparison swatch's outline computation to brighten the inset border for colors close to this background. +const POPOVER_BACKGROUND_GAMMA_CHANNELS: [f32; 4] = [0x22 as f32 / 255., 0x22 as f32 / 255., 0x22 as f32 / 255., 1.]; +/// The luminance window within which a color is considered close enough to the popover background /// to warrant an outline. Mirrors the `proximityRange` argument the legacy frontend passed to `contrastingOutlineFactor`. const OUTLINE_PROXIMITY_RANGE: f64 = 0.01; @@ -646,61 +643,22 @@ const OUTLINE_PROXIMITY_RANGE: f64 = 0.01; fn contrasting_outline_factor(color: Option) -> f64 { let Some(color) = color else { return 0. }; - // WCAG-style relative luminance, with alpha composited over white in gamma space - let luminance = |color: Color| { - // TODO: Remove the `.to_linear_srgb()` once we move to correctly treating `Color` as linear. - Color::WHITE - .alpha_blend(Color::from_unassociated_alpha(color.r(), color.g(), color.b(), color.a())) - .to_linear_srgb() - .luminance_srgb() as f64 + // WCAG-style relative luminance, with alpha composited over white in sRGB gamma space (matching the perceptual intent of `SRGBA8::contrasting_text_color`). + let luminance_from_gamma_channels = |[r, g, b, a]: [f32; 4]| -> f64 { + let inv_a = 1. - a; + Color::from_gamma_srgb_channels(inv_a + r * a, inv_a + g * a, inv_a + b * a, 1.).luminance_rec_709() as f64 }; - let distance = (luminance(POPOVER_BACKGROUND) - luminance(color)).abs().max(0.); + let color_gamma_channels = color.to_gamma_srgb_channels(); + let distance = (luminance_from_gamma_channels(POPOVER_BACKGROUND_GAMMA_CHANNELS) - luminance_from_gamma_channels(color_gamma_channels)) + .abs() + .max(0.); let proximity = 1. - (distance / OUTLINE_PROXIMITY_RANGE).min(1.); - let [_, saturation, _] = rgb_to_hsv(color.r() as f64, color.g() as f64, color.b() as f64); - proximity * (1. - saturation) + let [_, saturation, _, _] = color.to_hsva(); + proximity * (1. - saturation as f64) } -/// Format a Color as a `#`-prefixed hex string, including the alpha component only if it's not fully opaque. +/// Format a linear `Color` as a `#`-prefixed hex string, including the alpha component only if it's not fully opaque. fn color_to_hex_optional_alpha(color: &Color) -> String { - format!( - "#{}", - if color.a() >= 1. { - color.to_rgb_hex_srgb_from_gamma() - } else { - color.to_rgba_hex_srgb_from_gamma() - } - ) -} - -/// Parse a CSS color string (named color, hex, `rgb(...)`, etc.) into a `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. -fn parse_css_color(input: &str) -> Option { - 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: AlphaColor = parsed.to_alpha_color(); - let [red, green, blue, alpha] = srgb.components; - Color::from_rgbaf32(red, green, blue, alpha) + SRGBA8::from(*color).to_css_hex() } diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 0e68146dbe..c7b4489fbb 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -13,10 +13,10 @@ use crate::messages::portfolio::utility_types::WorkspacePanelLayout; use crate::messages::prelude::*; use crate::messages::tool::tool_messages::eyedropper_tool::PrimarySecondary; use graph_craft::document::NodeId; +use graphene_std::color::SRGBA8; use graphene_std::raster::Image; -use graphene_std::raster::color::Color; use graphene_std::text::Font; -use graphene_std::vector::style::FillChoice; +use graphene_std::vector::style::FillChoiceUI; use std::path::PathBuf; #[cfg(not(target_family = "wasm"))] @@ -167,16 +167,16 @@ pub enum FrontendMessage { document_id: DocumentId, }, UpdateGradientStopColorPickerPosition { - color: Color, // TODO: Color (without `none`) -> Color (with `none`) + color: SRGBA8, // TODO: Color (without `none`) -> Color (with `none`) position: (f64, f64), }, - /// The Rust color picker handler picked a new color/gradient. The frontend `` forwards this as its `colorOrGradient` event. + /// The Rust color picker handler picked a new color/gradient. The frontend `` forwards this as its `colorOrGradient` event. ColorPickerColorChanged { - value: FillChoice, + value: FillChoiceUI, }, - /// The Rust color picker handler is starting an undo transaction. The frontend `` forwards this as its `startHistoryTransaction` event. + /// The Rust color picker handler is starting an undo transaction. The frontend `` forwards this as its `startHistoryTransaction` event. ColorPickerStartHistoryTransaction, - /// The Rust color picker handler is committing the in-flight undo transaction. The frontend `` forwards this as its `commitHistoryTransaction` event. + /// The Rust color picker handler is committing the in-flight undo transaction. The frontend `` forwards this as its `commitHistoryTransaction` event. ColorPickerCommitHistoryTransaction, UpdateImportsExports { /// If the primary import is not visible, then it is None. @@ -241,7 +241,7 @@ pub enum FrontendMessage { svg: String, }, UpdateImageData { - image_data: Vec<(u64, Image)>, + image_data: Vec<(u64, Image)>, }, UpdateDocumentLayerDetails { data: LayerPanelEntry, diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index aed576b9f8..7d9e619fbb 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -14,7 +14,7 @@ pub struct DocumentInfo { pub is_saved: bool, } -#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints, from_wasm_abi))] #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct PersistedState { pub documents: Vec, diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 323d0de113..c465765545 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -1,7 +1,8 @@ use crate::messages::input_mapper::utility_types::input_keyboard::KeysGroup; use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::prelude::*; -use graphene_std::vector::style::FillChoice; +use graphene_std::color::SRGBA8; +use graphene_std::vector::style::FillChoiceUI; use serde_json::Value; use std::collections::HashMap; @@ -179,11 +180,11 @@ impl LayoutMessageHandler { let callback_message = match action { WidgetValueAction::Commit => (color_button.on_commit.callback)(&()), WidgetValueAction::Update => { - let Ok(fill_choice) = serde_json::from_value::(value) else { - warn!("ColorInput update was not able to be parsed as FillChoice: {color_button:?}"); + let Ok(fill_choice_ui) = serde_json::from_value::(value) else { + warn!("ColorInput update was not able to be parsed as FillChoiceUI: {color_button:?}"); return; }; - color_button.value = fill_choice; + color_button.value = fill_choice_ui; (color_button.on_update.callback)(color_button) } }; @@ -496,25 +497,14 @@ fn populate_computed_display_fields(layout: &mut Layout) { } Widget::SpectrumInput(spectrum_input) => { spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient(); - spectrum_input.track_start_css = spectrum_input - .track - .color - .first() - .map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())) - .unwrap_or_else(|| "black".to_string()); - spectrum_input.track_end_css = spectrum_input - .track - .color - .last() - .map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())) - .unwrap_or_else(|| "black".to_string()); + spectrum_input.track_start_css = spectrum_input.track.color.first().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string()); + spectrum_input.track_end_css = spectrum_input.track.color.last().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string()); } Widget::ColorComparisonInput(comparison) => { - use graphene_std::Color; - let contrasting = |color: Option| format!("#{}", color.map_or(Color::BLACK, |color| color.contrasting_text_color_from_gamma()).to_rgba_hex_srgb_from_gamma()); - comparison.new_color_css = comparison.new_color.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())).unwrap_or_default(); + let contrasting = |color: Option| color.map_or(SRGBA8::BLACK, |color| color.contrasting_text_color()).to_css_hex(); + comparison.new_color_css = comparison.new_color.map(|color| color.to_css_hex()).unwrap_or_default(); comparison.new_color_contrasting = contrasting(comparison.new_color); - comparison.old_color_css = comparison.old_color.map(|color| format!("#{}", color.to_rgba_hex_srgb_from_gamma())).unwrap_or_default(); + comparison.old_color_css = comparison.old_color.map(|color| color.to_css_hex()).unwrap_or_default(); comparison.old_color_contrasting = contrasting(comparison.old_color); } _ => {} diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index a2280f8395..f2fa6cd700 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -22,7 +22,7 @@ impl core::fmt::Display for WidgetId { macro_rules! define_layout_target { ($($(#[$attr:meta])* $variant:ident),* $(,)?) => { - #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] + #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] #[derive(PartialEq, Clone, Debug, Hash, Eq, Copy, serde::Serialize, serde::Deserialize)] #[repr(u8)] pub enum LayoutTarget { diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs index eaabac500d..18937a5a9f 100644 --- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs @@ -4,7 +4,7 @@ use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::node_graph::utility_types::FrontendGraphDataType; use crate::messages::tool::tool_messages::tool_prelude::WidgetCallback; use derivative::*; -use graphene_std::vector::style::FillChoice; +use graphene_std::vector::style::FillChoiceUI; use graphite_proc_macros::WidgetBuilder; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -187,11 +187,10 @@ pub struct ImageButton { #[derivative(Debug, PartialEq, Default)] pub struct ColorInput { // Content - /// WARNING: The colors are gamma, not linear! #[widget_builder(constructor)] - pub value: FillChoice, + pub value: FillChoiceUI, /// CSS `linear-gradient(...)` (or solid-color stand-in) for the swatch's `background-image`. Auto-populated from `value` at layout-send time. - /// `None` when `value` is `FillChoice::None`, in which case the frontend uses its "none" fallback styling. + /// `None` when `value` is `FillChoiceUI::None`, in which case the frontend uses its "none" fallback styling. #[serde(rename = "chosenGradient")] #[widget_builder(skip)] pub chosen_gradient: Option, diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index 1d664917a6..a19aac95bf 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -4,8 +4,9 @@ use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use derivative::*; use graphene_std::Color; +use graphene_std::color::SRGBA8; use graphene_std::transform::ReferencePoint; -use graphene_std::vector::style::{FillChoice, GradientStops}; +use graphene_std::vector::style::{FillChoiceUI, GradientStopsUI}; use graphite_proc_macros::WidgetBuilder; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -349,9 +350,9 @@ pub struct RadioEntryData { pub struct WorkingColorsInput { // Content #[widget_builder(constructor)] - pub primary: Color, + pub primary: SRGBA8, #[widget_builder(constructor)] - pub secondary: Color, + pub secondary: SRGBA8, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -484,10 +485,10 @@ pub struct ColorComparisonInput { // Content #[widget_builder(constructor)] #[serde(rename = "newColor")] - pub new_color: Option, + pub new_color: Option, #[widget_builder(constructor)] #[serde(rename = "oldColor")] - pub old_color: Option, + pub old_color: Option, #[serde(rename = "isNone")] pub is_none: bool, #[serde(rename = "oldIsNone")] @@ -544,7 +545,7 @@ pub struct ColorPresetsInput { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum ColorPresetsInputUpdate { - Preset(FillChoice), + Preset(FillChoiceUI), EyedropperColorCode(String), } @@ -555,7 +556,7 @@ pub struct SpectrumInput { // Content /// The colored gradient drawn behind the markers (display-only, caller-owned). #[widget_builder(constructor)] - pub track: GradientStops, + pub track: GradientStopsUI, /// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time. #[serde(rename = "trackCSS")] #[widget_builder(skip)] @@ -607,14 +608,14 @@ pub struct SpectrumMarker { position: f64, /// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true. The last marker's value is ignored. midpoint: f64, - /// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a [`Color`] (gamma space). + /// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`]. #[serde(rename = "handleColorCSS")] handle_color_css: String, } impl SpectrumMarker { pub fn new(position: f64, midpoint: f64, handle_color: Color) -> Self { - let handle_color_css = format!("#{}", handle_color.to_rgba_hex_srgb_from_gamma()); + let handle_color_css = SRGBA8::from(handle_color).to_css_hex(); Self { position, midpoint, handle_color_css } } } diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 3b298ad244..36cde235a8 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -7,12 +7,13 @@ use crate::messages::tool::tool_messages::tool_prelude::*; use glam::{Affine2, DAffine2, Vec2}; use graph_craft::document::NodeId; use graphene_std::blending::BlendMode; +use graphene_std::color::SRGBA8; use graphene_std::gradient::GradientStops; use graphene_std::list::List; use graphene_std::memo::IORecord; use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::vector::Vector; -use graphene_std::vector::style::{Fill, FillChoice, GradientSpreadMethod, GradientType}; +use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientType}; use graphene_std::{Artboard, Color, Context, Graphic}; use std::any::Any; use std::sync::Arc; @@ -385,11 +386,15 @@ impl TableItemLayout for Vector { match self.style.fill.clone() { Fill::None => table_rows.push(vec![ TextLabel::new("Fill").narrow(true).widget_instance(), - ColorInput::new(FillChoice::None).disabled(true).menu_direction(Some(MenuDirection::Top)).narrow(true).widget_instance(), + ColorInput::new(FillChoiceUI::None) + .disabled(true) + .menu_direction(Some(MenuDirection::Top)) + .narrow(true) + .widget_instance(), ]), Fill::Solid(color) => table_rows.push(vec![ TextLabel::new("Fill").narrow(true).widget_instance(), - ColorInput::new(FillChoice::Solid(color)) + ColorInput::new(FillChoiceUI::from(&FillChoice::Solid(color))) .disabled(true) .menu_direction(Some(MenuDirection::Top)) .narrow(true) @@ -398,7 +403,7 @@ impl TableItemLayout for Vector { Fill::Gradient(gradient) => { table_rows.push(vec![ TextLabel::new("Fill").narrow(true).widget_instance(), - ColorInput::new(FillChoice::Gradient(gradient.stops)) + ColorInput::new(FillChoiceUI::from(&FillChoice::Gradient(gradient.stops))) .disabled(true) .menu_direction(Some(MenuDirection::Top)) .narrow(true) @@ -423,7 +428,11 @@ impl TableItemLayout for Vector { let color = if let Some(color) = stroke.color { FillChoice::Solid(color) } else { FillChoice::None }; table_rows.push(vec![ TextLabel::new("Stroke").narrow(true).widget_instance(), - ColorInput::new(color).disabled(true).menu_direction(Some(MenuDirection::Top)).narrow(true).widget_instance(), + ColorInput::new(FillChoiceUI::from(&color)) + .disabled(true) + .menu_direction(Some(MenuDirection::Top)) + .narrow(true) + .widget_instance(), ]); table_rows.push(vec![ TextLabel::new("Stroke Weight").narrow(true).widget_instance(), @@ -561,10 +570,10 @@ impl TableItemLayout for Color { "Color" } fn identifier(&self) -> String { - format!("Color (#{})", self.to_gamma_srgb().to_rgba_hex_srgb()) + format!("Color (#{})", SRGBA8::from(*self).to_rgba_hex()) } fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance { - ColorInput::new(FillChoice::Solid(*self)) + ColorInput::new(FillChoiceUI::from(&FillChoice::Solid(*self))) .disabled(true) .menu_direction(Some(MenuDirection::Top)) .narrow(true) @@ -584,7 +593,7 @@ impl TableItemLayout for GradientStops { format!("Gradient ({} stops)", self.len()) } fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance { - ColorInput::new(FillChoice::Gradient(self.clone())) + ColorInput::new(FillChoiceUI::from(&FillChoice::Gradient(self.clone()))) .menu_direction(Some(MenuDirection::Top)) .disabled(true) .narrow(true) diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 1e6383c988..a58576c408 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -30,7 +30,7 @@ use graphene_std::text_nodes::StringCapitalization; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::misc::BooleanOperation; use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; -use graphene_std::vector::style::{Fill, FillChoice, GradientSpreadMethod, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientStops, GradientStopsUI, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; pub(crate) fn string_properties(text: &str) -> Vec { @@ -1164,19 +1164,19 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button: match &**tagged_value { TaggedValue::Color(color) => widgets.push( color_button - .value(match color { + .value(FillChoiceUI::from(&match color { Some(color) => FillChoice::Solid(*color), None => FillChoice::None, - }) - .on_update(update_value(|input: &ColorInput| TaggedValue::Color(input.value.as_solid()), node_id, index)) + })) + .on_update(update_value(|input: &ColorInput| TaggedValue::Color(input.value.as_solid().map(Color::from)), node_id, index)) .on_commit(commit_value) .widget_instance(), ), TaggedValue::Gradient(stops) => widgets.push( color_button - .value(FillChoice::Gradient(stops.clone())) + .value(FillChoiceUI::from(&FillChoice::Gradient(stops.clone()))) .on_update(update_value( - |input: &ColorInput| TaggedValue::Gradient(input.value.as_gradient().cloned().unwrap_or_default()), + |input: &ColorInput| TaggedValue::Gradient(input.value.as_gradient().map(GradientStops::from).unwrap_or_default()), node_id, index, )) @@ -1417,7 +1417,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo // Build the shared spectrum widget (placed on the first non-exposed row) let spectrum_widget = (!spectrum_markers.is_empty()).then(|| { - SpectrumInput::new(bw_track()) + SpectrumInput::new(GradientStopsUI::from(&bw_track())) .markers(spectrum_markers) .show_midpoints(false) .allow_insert(false) @@ -1591,7 +1591,7 @@ fn spectrum_slider_row( let position_to_value = move |position: f64| value_min + position * value_range; row.push( - SpectrumInput::new(track) + SpectrumInput::new(GradientStopsUI::from(&track)) .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) .show_midpoints(false) .allow_insert(false) @@ -2482,7 +2482,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); widgets_first_row.push( ColorInput::default() - .value(fill.clone().into()) + .value(FillChoiceUI::from(&FillChoice::from(fill.clone()))) .on_update(move |x: &ColorInput| Message::Batched { messages: Box::new([ match &fill2 { @@ -2508,7 +2508,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte NodeGraphMessage::SetInputValue { node_id, input_index: FillInput::::INDEX, - value: TaggedValue::Fill(x.value.to_fill(fill2.as_gradient())), + value: TaggedValue::Fill(FillChoice::from(&x.value).to_fill(fill2.as_gradient())), } .into(), ]), diff --git a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs index 4bc5348f5f..ede2a26091 100644 --- a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs +++ b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs @@ -3,9 +3,9 @@ use crate::messages::portfolio::document::overlays::utility_types::OverlayContex use crate::messages::portfolio::document::utility_types::misc::{GridSnapping, GridType}; use crate::messages::prelude::*; use glam::DVec2; -use graphene_std::raster::color::Color; +use graphene_std::color::SRGBA8; use graphene_std::renderer::Quad; -use graphene_std::vector::style::FillChoice; +use graphene_std::vector::style::FillChoiceUI; fn grid_overlay_rectangular(document: &DocumentMessageHandler, overlay_context: &mut OverlayContext, spacing: DVec2) { let origin = document.snapping_state.grid.origin; @@ -274,12 +274,12 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec { Separator::new(SeparatorStyle::Related).widget_instance(), ]); color_widgets.push( - ColorInput::new(FillChoice::Solid(Color::from_hex_str(&grid.color).unwrap_or(Color::BLACK))) + ColorInput::new(FillChoiceUI::Solid(SRGBA8::from_hex_str(&grid.color).unwrap_or(SRGBA8::BLACK))) .tooltip_label("Grid Display Color") .allow_none(false) .on_update(update_val::(grid, |grid, color| { - if let Some(color) = color.value.as_solid() { - grid.color = format!("#{}", color.to_rgba_hex_srgb_from_gamma()); + if let Some(srgba) = color.value.as_solid() { + grid.color = srgba.to_css_hex(); } })) .widget_instance(), diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index 269961e453..8dd03294ec 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -999,13 +999,10 @@ impl MessageHandler> for Portfolio }); // Add default fill and stroke to the layer - let fill_color = Color::WHITE; - let stroke_color = Color::BLACK; - - let fill = graphene_std::vector::style::Fill::solid(fill_color.to_gamma_srgb()); + let fill = graphene_std::vector::style::Fill::solid(Color::WHITE); responses.add(GraphOperationMessage::FillSet { layer, fill }); - let stroke = graphene_std::vector::style::Stroke::new(Some(stroke_color.to_gamma_srgb()), DEFAULT_STROKE_WIDTH); + let stroke = graphene_std::vector::style::Stroke::new(Some(Color::BLACK), DEFAULT_STROKE_WIDTH); responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); // Create new point ids and add those into the existing Vector path diff --git a/editor/src/messages/portfolio/utility_types.rs b/editor/src/messages/portfolio/utility_types.rs index e3f3ba1373..ebb39c5c96 100644 --- a/editor/src/messages/portfolio/utility_types.rs +++ b/editor/src/messages/portfolio/utility_types.rs @@ -34,6 +34,7 @@ impl FontCatalog { } } +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct FontCatalogFamily { /// The font family name. @@ -55,6 +56,7 @@ impl FontCatalogFamily { } } +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] pub struct FontCatalogStyle { pub weight: u32, @@ -88,7 +90,7 @@ impl FontCatalogStyle { } } -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] #[derive(PartialEq, Eq, Hash, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] pub enum PanelType { Welcome, @@ -124,7 +126,7 @@ impl PanelType { pub struct PanelGroupId(pub u64); /// Which edge of a panel group to split on when docking a dragged panel. -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum DockingSplitDirection { Left, diff --git a/editor/src/messages/tool/common_functionality/color_selector.rs b/editor/src/messages/tool/common_functionality/color_selector.rs index 3f61d4a508..7fc3f02dd5 100644 --- a/editor/src/messages/tool/common_functionality/color_selector.rs +++ b/editor/src/messages/tool/common_functionality/color_selector.rs @@ -6,7 +6,7 @@ use crate::messages::prelude::*; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::utility_types::DocumentToolData; use graphene_std::Color; -use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{FillChoice, FillChoiceUI, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; /// Color selector widgets seen in [`LayoutTarget::ToolOptions`] bar. pub struct ToolColorOptions { @@ -47,13 +47,12 @@ impl ToolColorOptions { self.enabled == Some(true) } - /// The active solid color in linear sRGB, suitable for storing in a working color or downstream rendering input. - /// `fill_choice` is stored in gamma space (per [`FillChoice`]'s contract), so this method converts to linear before returning. + /// The active solid color, suitable for storing in a working color or downstream rendering input. pub fn active_color(&self) -> Option { if !self.is_active() { return None; } - Some(self.fill_choice.as_ref()?.as_solid()?.to_linear_srgb()) + self.fill_choice.as_ref()?.as_solid() } pub fn apply_fill(&self, layer: LayerNodeIdentifier, responses: &mut VecDeque) { @@ -86,7 +85,8 @@ impl ToolColorOptions { // In the mixed state (`fill_choice` is `None`) the dash overlay covers the swatch, so the underlying widget value just drives the picker's initial position. // `FillChoice::None` gives it a neutral starting point. let mixed_color = self.fill_choice.is_none(); - let widget_value = self.fill_choice.clone().unwrap_or(FillChoice::None); + // Convert the internal linear-light `FillChoice` to the JS-boundary `FillChoiceUI` (with `SRGBA8` colors) for the widget value. + let widget_value = FillChoiceUI::from(self.fill_choice.as_ref().unwrap_or(&FillChoice::None)); let mixed_enabled = self.enabled.is_none(); // In the mixed-enabled state the underlying `checked` value is hidden behind the indeterminate dash. // The frontend's click handler sends `true` when the user resolves the mixed state by clicking. @@ -191,10 +191,9 @@ impl DrawingToolState { } } -/// Builds a `FillChoice::Solid` from a linear-space color, applying gamma conversion to display sRGB. -/// Common helper used throughout the color-syncing code where working colors (linear) flow into swatches that store gamma-encoded colors. -pub fn solid_gamma(color: Color) -> FillChoice { - FillChoice::Solid(color.to_gamma_srgb()) +/// Builds a `FillChoice::Solid` from a color. +pub fn solid(color: Color) -> FillChoice { + FillChoice::Solid(color) } /// The fill working color (the source for the fill swatch when nothing is selected). @@ -219,8 +218,8 @@ pub fn sync_color_options( document: &DocumentMessageHandler, selection_changed: bool, ) -> bool { - let fill_fallback = solid_gamma(fill_working_color(global, drawing.colors_swapped)); - let stroke_fallback = solid_gamma(stroke_working_color(global, drawing.colors_swapped)); + let fill_fallback = solid(fill_working_color(global, drawing.colors_swapped)); + let stroke_fallback = solid(stroke_working_color(global, drawing.colors_swapped)); let mut changed = false; @@ -364,7 +363,7 @@ fn sync_stroke_options(drawing: &mut DrawingToolState, document: &DocumentMessag /// Same as [`sync_color_options`] but for tools that only have a fill option (e.g., text). The fill follows the given working color when nothing is selected. pub fn sync_fill_only(fill: &mut ToolColorOptions, natural_fill_enabled: bool, fill_color: Color, document: &DocumentMessageHandler, selection_changed: bool) -> bool { - let fill_fallback = solid_gamma(fill_color); + let fill_fallback = solid(fill_color); let new_fill = if let Some(state) = graph_modification_utils::selected_fill_state(document) { let active = state.enabled == Some(true); @@ -417,11 +416,7 @@ pub fn apply_fill_only_color_pick(fill: &mut ToolColorOptions, fill_choice: Fill } graph_modification_utils::set_fill_for_selected_layers(fill_choice, document, responses); } else if let FillChoice::Solid(color) = fill_choice { - // Swatch is gamma; working colors are linear. - responses.add(ToolMessage::SelectWorkingColor { - color: color.to_linear_srgb(), - primary: slot_is_primary, - }); + responses.add(ToolMessage::SelectWorkingColor { color, primary: slot_is_primary }); } } @@ -436,9 +431,8 @@ pub fn apply_stroke_color_pick(drawing: &mut DrawingToolState, color: Option> for ToolMessageHandler let r = (random_number >> 16) as u8; let g = (random_number >> 8) as u8; let b = random_number as u8; - let random_color = Color::from_rgba8_srgb(r, g, b, 255); + let random_color = Color::from(SRGBA8::new(r, g, b, 255)); if primary { document_data.primary_color = random_color; diff --git a/editor/src/messages/tool/tool_messages/brush_tool.rs b/editor/src/messages/tool/tool_messages/brush_tool.rs index e97d4573cc..e166f696a1 100644 --- a/editor/src/messages/tool/tool_messages/brush_tool.rs +++ b/editor/src/messages/tool/tool_messages/brush_tool.rs @@ -4,13 +4,13 @@ use crate::messages::portfolio::document::graph_operation::transform_utils::get_ use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_proto_node_type}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::FlowType; -use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, solid_gamma}; +use crate::messages::tool::common_functionality::color_selector::{ToolColorOptions, solid}; use graph_craft::document::NodeId; use graph_craft::document::value::TaggedValue; use graphene_std::Color; use graphene_std::brush::brush_stroke::{BrushInputSample, BrushStroke, BrushStyle}; use graphene_std::raster::BlendMode; -use graphene_std::vector::style::FillChoice; +use graphene_std::vector::style::{FillChoice, FillChoiceUI}; const BRUSH_MAX_SIZE: f64 = 5000.; @@ -104,13 +104,12 @@ impl ToolMetadata for BrushTool { impl LayoutHolder for BrushTool { fn layout(&self) -> Layout { let mut widgets = vec![ - ColorInput::new(self.options.color.fill_choice.clone().unwrap_or(FillChoice::None)) + ColorInput::new(FillChoiceUI::from(self.options.color.fill_choice.as_ref().unwrap_or(&FillChoice::None))) .mixed(self.options.color.fill_choice.is_none()) .narrow(true) .on_update(|color: &ColorInput| { BrushToolMessage::UpdateOptions { - // The picker emits gamma-space colors; working colors are stored in linear sRGB. - options: BrushToolMessageOptionsUpdate::Color(color.value.as_solid().map(|c| c.to_linear_srgb())), + options: BrushToolMessageOptionsUpdate::Color(color.value.as_solid().map(Color::from)), } .into() }) @@ -245,7 +244,7 @@ impl<'a> MessageHandler> for Brus } } BrushToolMessageOptionsUpdate::WorkingColorsChanged => { - self.options.color.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); + self.options.color.fill_choice = Some(solid(context.global_tool_data.primary_color)); } } diff --git a/editor/src/messages/tool/tool_messages/eyedropper_tool.rs b/editor/src/messages/tool/tool_messages/eyedropper_tool.rs index 1c525b361a..e968a48847 100644 --- a/editor/src/messages/tool/tool_messages/eyedropper_tool.rs +++ b/editor/src/messages/tool/tool_messages/eyedropper_tool.rs @@ -1,6 +1,7 @@ use super::tool_prelude::*; use crate::messages::frontend::utility_types::EyedropperPreviewImage; use crate::messages::tool::utility_types::DocumentToolData; +use graphene_std::color::SRGBA8; use graphene_std::vector::style::RenderMode; #[derive(Default, ExtractField)] @@ -233,8 +234,8 @@ fn update_cursor_preview_common( responses.add(FrontendMessage::UpdateEyedropperSamplingState { image, mouse_position: Some(input.mouse.position.into()), - primary_color: "#".to_string() + global_tool_data.primary_color.to_rgb_hex_srgb().as_str(), - secondary_color: "#".to_string() + global_tool_data.secondary_color.to_rgb_hex_srgb().as_str(), + primary_color: SRGBA8::from(global_tool_data.primary_color).to_css_hex(), + secondary_color: SRGBA8::from(global_tool_data.secondary_color).to_css_hex(), set_color_choice, }); } diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 15d145e6bc..19b15bf693 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -1,9 +1,10 @@ use super::tool_prelude::*; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::tool::common_functionality::color_selector::solid_gamma; +use crate::messages::tool::common_functionality::color_selector::solid; use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; +use graphene_std::color::SRGBA8; use graphene_std::raster::color::Color; -use graphene_std::vector::style::Fill; +use graphene_std::vector::style::{Fill, FillChoiceUI}; #[derive(Default, ExtractField)] pub struct FillTool { @@ -43,11 +44,11 @@ impl ToolMetadata for FillTool { impl LayoutHolder for FillTool { fn layout(&self) -> Layout { let widgets = vec![ - ColorInput::new(solid_gamma(self.primary_color)) + ColorInput::new(FillChoiceUI::from(&solid(self.primary_color))) .narrow(true) .on_update(|color: &ColorInput| { FillToolMessage::SetColor { - color: color.value.as_solid().map(|c| c.to_linear_srgb()), + color: color.value.as_solid().map(Color::from), } .into() }) @@ -141,7 +142,7 @@ impl Fsm for FillToolFsmState { // Get the layer the user is hovering over if let Some(layer) = document.click(input, viewport) { - let color_hex = format!("#{}", preview_color.to_rgba_hex_srgb()); + let color_hex = SRGBA8::from(preview_color).to_css_hex(); overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &color_hex); } @@ -161,8 +162,8 @@ impl Fsm for FillToolFsmState { return self; } let fill = match color_event { - FillToolMessage::FillPrimaryColor => Fill::Solid(global_tool_data.primary_color.to_gamma_srgb()), - FillToolMessage::FillSecondaryColor => Fill::Solid(global_tool_data.secondary_color.to_gamma_srgb()), + FillToolMessage::FillPrimaryColor => Fill::Solid(global_tool_data.primary_color), + FillToolMessage::FillSecondaryColor => Fill::Solid(global_tool_data.secondary_color), _ => return self, }; @@ -201,6 +202,7 @@ impl Fsm for FillToolFsmState { #[cfg(test)] mod test_fill { pub use crate::test_utils::test_prelude::*; + use graphene_std::color::SRGBA8; use graphene_std::vector::fill; use graphene_std::vector::style::Fill; @@ -240,7 +242,7 @@ mod test_fill { editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::empty()).await; let fills = get_fills(&mut editor).await; assert_eq!(fills.len(), 1); - assert_eq!(fills[0].as_solid().unwrap().to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb()); + assert_eq!(SRGBA8::from(fills[0].as_solid().unwrap()), SRGBA8::from(Color::GREEN)); } #[tokio::test] @@ -252,6 +254,6 @@ mod test_fill { editor.click_tool(ToolType::Fill, MouseKeys::LEFT, DVec2::new(2., 2.), ModifierKeys::SHIFT).await; let fills = get_fills(&mut editor).await; assert_eq!(fills.len(), 1); - assert_eq!(fills[0].as_solid().unwrap().to_rgba8_srgb(), Color::YELLOW.to_rgba8_srgb()); + assert_eq!(SRGBA8::from(fills[0].as_solid().unwrap()), SRGBA8::from(Color::YELLOW)); } } diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs index 052ce9e4b0..d93a1c117a 100644 --- a/editor/src/messages/tool/tool_messages/freehand_tool.rs +++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs @@ -97,7 +97,7 @@ impl LayoutHolder for FreehandTool { }, |color: &ColorInput| { FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::FillColor(color.value.clone()), + options: FreehandOptionsUpdate::FillColor(FillChoice::from(&color.value)), } .into() }, @@ -127,7 +127,7 @@ impl LayoutHolder for FreehandTool { }, |color: &ColorInput| { FreehandToolMessage::UpdateOptions { - options: FreehandOptionsUpdate::StrokeColor(color.value.as_solid()), + options: FreehandOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)), } .into() }, diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 1105a99f3d..7785edebf4 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -11,8 +11,9 @@ use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_gradient_stops}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; use graph_craft::document::value::TaggedValue; +use graphene_std::color::SRGBA8; use graphene_std::raster::color::Color; -use graphene_std::vector::style::{Fill, FillChoice, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType}; +use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientStopsUI, GradientType}; #[derive(Default, ExtractField)] pub struct GradientTool { @@ -49,7 +50,7 @@ pub enum GradientToolMessage { CommitTransactionForColorStop, CloseStopColorPicker, UpdateStopColor { color: Color }, - UpdateStops { stops: GradientStops }, + UpdateStops { stops: GradientStopsUI }, UpdateOptions { options: GradientOptionsUpdate }, } @@ -120,7 +121,7 @@ impl<'a> MessageHandler> for Grad } } ToolMessage::Gradient(GradientToolMessage::UpdateStops { stops }) => { - apply_stops_update(&mut self.data, context, responses, stops); + apply_stops_update(&mut self.data, context, responses, GradientStops::from(&stops)); } ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => { if self.data.color_picker_transaction_open { @@ -243,7 +244,7 @@ impl LayoutHolder for GradientTool { }, ])) }); - let stops_widget = ColorInput::new(stops_value) + let stops_widget = ColorInput::new(FillChoiceUI::from(&stops_value)) .allow_none(false) .narrow(true) .tooltip_label("Gradient Stops") @@ -856,7 +857,7 @@ impl Fsm for GradientToolFsmState { let (start, end) = (transform.transform_point2(*start), transform.transform_point2(*end)); fn color_to_hex(color: graphene_std::Color) -> String { - format!("#{}", color.to_rgb_hex_srgb_from_gamma()) + SRGBA8::from(color).to_css_hex() } let start_hex = stops.color.first().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); @@ -1024,10 +1025,10 @@ impl Fsm for GradientToolFsmState { let transform = gradient_space_transform(layer, document); let gradient = &selected_gradient.gradient; if stop_index < gradient.stops.position.len() { - let color = gradient.stops.color[stop_index].to_gamma_srgb(); + let color = gradient.stops.color[stop_index]; let position = gradient.stops.position[stop_index]; let position = transform.transform_point2(gradient.start.lerp(gradient.end, position)).into(); - responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color, position }); + responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: color.into(), position }); } } @@ -1081,9 +1082,9 @@ impl Fsm for GradientToolFsmState { .transform .transform_point2(selected_gradient.gradient.start.lerp(selected_gradient.gradient.end, stop_pos)); let position = viewport_pos.into(); - let color = selected_gradient.gradient.stops.color[stop_index].to_gamma_srgb(); + let color = selected_gradient.gradient.stops.color[stop_index]; tool_data.color_picker_editing_color_stop = Some(stop_index); - responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color, position }); + responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: color.into(), position }); } } _ => {} @@ -1910,6 +1911,7 @@ mod test_gradient { pub use crate::test_utils::test_prelude::*; use glam::DAffine2; use graph_craft::document::value::TaggedValue; + use graphene_std::color::SRGBA8; use graphene_std::vector::style::{Fill, Gradient}; use graphene_std::vector::{GradientStop, GradientStops, fill}; @@ -2022,8 +2024,8 @@ mod test_gradient { let (gradient, transform) = get_gradient(&mut editor).await; // Gradient goes from primary color to secondary color - let stops = gradient.stops.iter().map(|stop| (stop.position, stop.color.to_rgba8_srgb())).collect::>(); - assert_eq!(stops, vec![(0., Color::GREEN.to_rgba8_srgb()), (1., Color::BLUE.to_rgba8_srgb())]); + let stops = gradient.stops.iter().map(|stop| (stop.position, SRGBA8::from(stop.color))).collect::>(); + assert_eq!(stops, vec![(0., SRGBA8::from(Color::GREEN)), (1., SRGBA8::from(Color::BLUE))]); assert!(transform.transform_point2(gradient.start).abs_diff_eq(DVec2::new(2., 3.), 1e-10)); assert!(transform.transform_point2(gradient.end).abs_diff_eq(DVec2::new(24., 4.), 1e-10)); } @@ -2215,7 +2217,7 @@ mod test_gradient { let positions: Vec = stops.iter().map(|stop| stop.position).collect(); assert_stops_at_positions(&positions, &[0., 0.25, 1.], 0.1); - let middle_color = stops.color[1].to_rgba8_srgb(); + let middle_color = SRGBA8::from(stops.color[1]); // Simulate dragging the middle stop to position 0.8 let click_position = DVec2::new(25., 0.); @@ -2256,9 +2258,9 @@ mod test_gradient { assert_stops_at_positions(&updated_positions, &[0., 0.8, 1.], 0.1); // Colors should maintain their associations with the stop points - assert_eq!(updated_stops.color[0].to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb()); - assert_eq!(updated_stops.color[1].to_rgba8_srgb(), middle_color); - assert_eq!(updated_stops.color[2].to_rgba8_srgb(), Color::BLUE.to_rgba8_srgb()); + assert_eq!(SRGBA8::from(updated_stops.color[0]), SRGBA8::from(Color::GREEN)); + assert_eq!(SRGBA8::from(updated_stops.color[1]), middle_color); + assert_eq!(SRGBA8::from(updated_stops.color[2]), SRGBA8::from(Color::BLUE)); } #[tokio::test] @@ -2495,8 +2497,8 @@ mod test_gradient { assert_eq!(updated.stops.len(), 3, "Stop count should be preserved"); assert_stops_at_positions(&updated.stops.position, &[0., 0.5, 1.], 1e-10); - assert_eq!(updated.stops.color[0].to_rgba8_srgb(), Color::RED.to_rgba8_srgb(), "First stop color should be preserved"); - assert_eq!(updated.stops.color[1].to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb(), "Middle stop color should be preserved"); - assert_eq!(updated.stops.color[2].to_rgba8_srgb(), Color::BLUE.to_rgba8_srgb(), "Last stop color should be preserved"); + assert_eq!(SRGBA8::from(updated.stops.color[0]), SRGBA8::from(Color::RED), "First stop color should be preserved"); + assert_eq!(SRGBA8::from(updated.stops.color[1]), SRGBA8::from(Color::GREEN), "Middle stop color should be preserved"); + assert_eq!(SRGBA8::from(updated.stops.color[2]), SRGBA8::from(Color::BLUE), "Last stop color should be preserved"); } } diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 3cd2a3b133..b3dd093cb2 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -2850,13 +2850,10 @@ impl Fsm for PathToolFsmState { let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); // Defaults chosen because the pasted geometry has no inherent associated style - let stroke_color = Color::BLACK; - let fill_color = Color::WHITE; - - let stroke = graphene_std::vector::style::Stroke::new(Some(stroke_color.to_gamma_srgb()), DEFAULT_STROKE_WIDTH); + let stroke = graphene_std::vector::style::Stroke::new(Some(Color::BLACK), DEFAULT_STROKE_WIDTH); responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); - let fill = graphene_std::vector::style::Fill::solid(fill_color.to_gamma_srgb()); + let fill = graphene_std::vector::style::Fill::solid(Color::WHITE); responses.add(GraphOperationMessage::FillSet { layer, fill }); new_layers.push(layer); diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index 4c6c50c63a..529404b120 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -154,7 +154,7 @@ impl LayoutHolder for PenTool { }, |color: &ColorInput| { PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::FillColor(color.value.clone()), + options: PenOptionsUpdate::FillColor(FillChoice::from(&color.value)), } .into() }, @@ -184,7 +184,7 @@ impl LayoutHolder for PenTool { }, |color: &ColorInput| { PenToolMessage::UpdateOptions { - options: PenOptionsUpdate::StrokeColor(color.value.as_solid()), + options: PenOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)), } .into() }, diff --git a/editor/src/messages/tool/tool_messages/select_tool.rs b/editor/src/messages/tool/tool_messages/select_tool.rs index 0a8773539b..c46643a5b4 100644 --- a/editor/src/messages/tool/tool_messages/select_tool.rs +++ b/editor/src/messages/tool/tool_messages/select_tool.rs @@ -248,7 +248,7 @@ impl LayoutHolder for SelectTool { }, |color: &ColorInput| { SelectToolMessage::SelectOptions { - options: SelectOptionsUpdate::FillColor(color.value.clone()), + options: SelectOptionsUpdate::FillColor(FillChoice::from(&color.value)), } .into() }, @@ -278,7 +278,7 @@ impl LayoutHolder for SelectTool { }, |color: &ColorInput| { SelectToolMessage::SelectOptions { - options: SelectOptionsUpdate::StrokeColor(color.value.as_solid()), + options: SelectOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)), } .into() }, diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index ebe6f869d8..671c989d43 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -467,7 +467,7 @@ impl LayoutHolder for ShapeTool { }, |color: &ColorInput| { ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::FillColor(color.value.clone()), + options: ShapeOptionsUpdate::FillColor(FillChoice::from(&color.value)), } .into() }, @@ -498,7 +498,7 @@ impl LayoutHolder for ShapeTool { }, |color: &ColorInput| { ShapeToolMessage::UpdateOptions { - options: ShapeOptionsUpdate::StrokeColor(color.value.as_solid()), + options: ShapeOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)), } .into() }, diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs index 7973a428c3..6e21014345 100644 --- a/editor/src/messages/tool/tool_messages/spline_tool.rs +++ b/editor/src/messages/tool/tool_messages/spline_tool.rs @@ -105,7 +105,7 @@ impl LayoutHolder for SplineTool { }, |color: &ColorInput| { SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::FillColor(color.value.clone()), + options: SplineOptionsUpdate::FillColor(FillChoice::from(&color.value)), } .into() }, @@ -135,7 +135,7 @@ impl LayoutHolder for SplineTool { }, |color: &ColorInput| { SplineToolMessage::UpdateOptions { - options: SplineOptionsUpdate::StrokeColor(color.value.as_solid()), + options: SplineOptionsUpdate::StrokeColor(color.value.as_solid().map(Color::from)), } .into() }, diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs index 870d73cbf0..7c94b8d82c 100644 --- a/editor/src/messages/tool/tool_messages/text_tool.rs +++ b/editor/src/messages/tool/tool_messages/text_tool.rs @@ -9,7 +9,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::Inpu use crate::messages::portfolio::utility_types::{CachedData, FontCatalog, FontCatalogStyle}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::color_selector::{ - ToolColorOptions, apply_fill_only_color_pick, apply_fill_only_enabled, refresh_slot_working_color, selection_changed_since_last_sync, solid_gamma, sync_fill_only, + ToolColorOptions, apply_fill_only_color_pick, apply_fill_only_enabled, refresh_slot_working_color, selection_changed_since_last_sync, solid, sync_fill_only, }; use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::resize::Resize; @@ -20,9 +20,10 @@ use crate::messages::tool::utility_types::ToolRefreshOptions; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graphene_std::choice_type::ChoiceTypeStatic; +use graphene_std::color::SRGBA8; use graphene_std::renderer::Quad; use graphene_std::text::{Font, FontCache, TextAlign, TypesettingConfig, lines_clipping}; -use graphene_std::vector::style::{Fill, FillChoice}; +use graphene_std::vector::style::{Fill, FillChoice, FillChoiceUI}; use graphene_std::{Color, NodeInputDecleration}; #[derive(Default, ExtractField)] @@ -269,12 +270,12 @@ impl TextTool { fn layout(&self, font_catalog: &FontCatalog, document: &DocumentMessageHandler) -> Layout { let mut widgets = vec![ - ColorInput::new(self.options.fill.fill_choice.clone().unwrap_or(graphene_std::vector::style::FillChoice::None)) + ColorInput::new(FillChoiceUI::from(self.options.fill.fill_choice.as_ref().unwrap_or(&FillChoice::None))) .mixed(self.options.fill.fill_choice.is_none()) .narrow(true) .on_update(|color: &ColorInput| { TextToolMessage::UpdateOptions { - options: TextOptionsUpdate::FillColor(color.value.clone()), + options: TextOptionsUpdate::FillColor(FillChoice::from(&color.value)), } .into() }) @@ -295,7 +296,7 @@ impl<'a> MessageHandler> for Text // reset the displayed fill color so the next activation starts fresh from the current working color. // Guarded on `Ready` so Esc-mid-editing (which also fires Abort) doesn't wipe the user's customized fill option. if matches!(&message, ToolMessage::Text(TextToolMessage::Abort)) && self.fsm_state == TextToolFsmState::Ready { - self.options.fill.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); + self.options.fill.fill_choice = Some(solid(context.global_tool_data.primary_color)); } let options = match message { @@ -319,7 +320,7 @@ impl<'a> MessageHandler> for Text if can_edit_selected(context.document).is_some() { sync_fill_only(&mut self.options.fill, true, context.global_tool_data.primary_color, context.document, selection_changed); } else if selection_changed { - self.options.fill.fill_choice = Some(solid_gamma(context.global_tool_data.primary_color)); + self.options.fill.fill_choice = Some(solid(context.global_tool_data.primary_color)); self.options.fill.tracks_working_color = true; } // Text tool has no fill checkbox; keep enabled so new text never starts with `None` @@ -497,7 +498,7 @@ impl TextToolData { text: editing_text.text.clone(), line_height_ratio: editing_text.typesetting.line_height_ratio, font_size: editing_text.typesetting.font_size, - color: editing_text.color.map_or("#000000".to_string(), |color| format!("#{}", color.to_rgba_hex_srgb())), + color: editing_text.color.map_or("#000000".to_string(), |color| SRGBA8::from(color).to_css_hex()), font_data: font_cache.get(&editing_text.font).map(|(data, _)| data.clone()).unwrap_or_default().into(), transform: editing_text.transform.to_cols_array(), max_width: editing_text.typesetting.max_width, @@ -574,7 +575,7 @@ impl TextToolData { }); responses.add(GraphOperationMessage::FillSet { layer: self.layer, - fill: if let Some(color) = editing_text.color { Fill::Solid(color.to_gamma_srgb()) } else { Fill::None }, + fill: if let Some(color) = editing_text.color { Fill::Solid(color) } else { Fill::None }, }); let transform = editing_text.transform; self.editing_text = Some(editing_text); diff --git a/editor/src/messages/tool/utility_types.rs b/editor/src/messages/tool/utility_types.rs index 566a2af75c..b29884fa25 100644 --- a/editor/src/messages/tool/utility_types.rs +++ b/editor/src/messages/tool/utility_types.rs @@ -14,6 +14,7 @@ use crate::messages::preferences::PreferencesMessageHandler; use crate::messages::prelude::*; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeType; use crate::node_graph_executor::NodeGraphExecutor; +use graphene_std::color::SRGBA8; use graphene_std::raster::color::Color; use std::borrow::Cow; use std::fmt::{self, Debug}; @@ -128,9 +129,7 @@ pub struct DocumentToolData { impl DocumentToolData { pub fn update_working_colors(&self, responses: &mut VecDeque) { let layout = Layout(vec![ - LayoutGroup::row(vec![ - WorkingColorsInput::new(self.primary_color.to_gamma_srgb(), self.secondary_color.to_gamma_srgb()).widget_instance(), - ]), + LayoutGroup::row(vec![WorkingColorsInput::new(SRGBA8::from(self.primary_color), SRGBA8::from(self.secondary_color)).widget_instance()]), LayoutGroup::row(vec![ IconButton::new("SwapVertical", 16) .tooltip_label("Swap Working Colors") diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 2c317a5b5b..30bcf64f1f 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -6,6 +6,7 @@ use graph_craft::document::value::{RenderOutput, RenderOutputType, TaggedValue}; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput}; use graph_craft::proto::GraphErrors; use graphene_std::application_io::{NodeGraphUpdateMessage, RenderConfig, TimingInformation}; +use graphene_std::color::SRGBA8; use graphene_std::raster::{CPU, Raster}; use graphene_std::renderer::RenderMetadata; use graphene_std::text::FontCache; @@ -398,7 +399,21 @@ impl NodeGraphExecutor { match render_output.data { RenderOutputType::Svg { svg, image_data } => { - // Send to frontend + // Convert each linear-light `Image` into the JS-boundary `Image` form (gamma byte channels) before dispatching. + let image_data = image_data + .into_iter() + .map(|(id, image)| { + ( + id, + graphene_std::raster::Image { + width: image.width, + height: image.height, + data: image.data.iter().map(|&c| SRGBA8::from(c)).collect(), + base64_string: image.base64_string, + }, + ) + }) + .collect(); responses.add(FrontendMessage::UpdateImageData { image_data }); responses.add(FrontendMessage::UpdateDocumentArtwork { svg }); } diff --git a/frontend/src/components/floating-menus/ColorPicker.svelte b/frontend/src/components/floating-menus/ColorPicker.svelte index b23620edf0..faabf12cd9 100644 --- a/frontend/src/components/floating-menus/ColorPicker.svelte +++ b/frontend/src/components/floating-menus/ColorPicker.svelte @@ -5,14 +5,14 @@ import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte"; import type { ColorPickerCallbacks, ColorPickerStore } from "/src/stores/color-picker"; - import type { EditorWrapper, FillChoice, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { EditorWrapper, FillChoiceUI, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper"; - const dispatch = createEventDispatcher<{ colorOrGradient: FillChoice; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>(); + const dispatch = createEventDispatcher<{ colorOrGradient: FillChoiceUI; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>(); const editor = getContext("editor"); const colorPickerStore = getContext("colorPicker"); - export let colorOrGradient: FillChoice; + export let colorOrGradient: FillChoiceUI; export let allowNone = false; // export let allowTransparency = false; // TODO: Implement export let disabled = false; diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index cb9c6edd2b..a80bb47f6d 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -12,12 +12,12 @@ import type { DocumentStore } from "/src/stores/document"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; import type { MessageBody } from "/src/subscriptions-router"; - import { fillChoiceColor, createColor } from "/src/utility-functions/colors"; + import { fillChoiceUIColor, createSRgba8 } from "/src/utility-functions/colors"; import { pasteFile } from "/src/utility-functions/files"; import { textInputCleanup } from "/src/utility-functions/keyboard-entry"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; import { setupViewportResizeObserver } from "/src/utility-functions/viewports"; - import type { Color, EditorWrapper, MenuDirection, MouseCursorIcon } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { EditorWrapper, MenuDirection, MouseCursorIcon, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; let rulerHorizontal: RulerInput | undefined; let rulerVertical: RulerInput | undefined; @@ -70,7 +70,7 @@ let cursorEyedropperPreviewColorSecondary = ""; // Gradient stop color picker - let gradientStopPickerColor: Color | undefined = undefined; + let gradientStopPickerColor: SRGBA8 | undefined = undefined; let gradientStopPickerPosition: { x: number; y: number } | undefined = undefined; // Canvas dimensions @@ -223,7 +223,7 @@ mousePosition: [number, number] | undefined, colorPrimary: string, colorSecondary: string, - ): Promise<[number, number, number] | undefined> { + ): Promise { if (mousePosition === undefined) { cursorEyedropper = false; return undefined; @@ -275,14 +275,14 @@ }; })(); const hex = [centerPixel.r, centerPixel.g, centerPixel.b].map((x) => x.toString(16).padStart(2, "0")).join(""); - const rgb: [number, number, number] = [centerPixel.r / 255, centerPixel.g / 255, centerPixel.b / 255]; + const sRgba8: SRGBA8 = { red: centerPixel.r, green: centerPixel.g, blue: centerPixel.b, alpha: 255 }; cursorEyedropperPreviewColorChoice = "#" + hex; cursorEyedropperPreviewColorPrimary = colorPrimary; cursorEyedropperPreviewColorSecondary = colorSecondary; cursorEyedropperPreviewImageData = preview; - return rgb; + return sRgba8; } // Update scrollbars and rulers @@ -477,11 +477,11 @@ const { image, mousePosition, primaryColor, secondaryColor, setColorChoice } = data; const imageData = image !== undefined ? new ImageData(new Uint8ClampedArray(image.data), image.width, image.height) : undefined; - const rgb = await updateEyedropperSamplingState(imageData, mousePosition, primaryColor, secondaryColor); + const sRgba8 = await updateEyedropperSamplingState(imageData, mousePosition, primaryColor, secondaryColor); - if (setColorChoice && rgb) { - if (setColorChoice === "Primary") editor.updatePrimaryColor(...rgb, 1); - if (setColorChoice === "Secondary") editor.updateSecondaryColor(...rgb, 1); + if (setColorChoice && sRgba8) { + if (setColorChoice === "Primary") editor.updatePrimaryColor(sRgba8); + if (setColorChoice === "Secondary") editor.updateSecondaryColor(sRgba8); } }); @@ -668,10 +668,10 @@ gradientStopPickerColor = undefined; } }} - colorOrGradient={{ Solid: gradientStopPickerColor || createColor(0, 0, 0, 1) }} + colorOrGradient={{ Solid: gradientStopPickerColor || createSRgba8(0, 0, 0, 255) }} on:colorOrGradient={({ detail }) => { - const color = fillChoiceColor(detail); - if (color) editor.updateGradientStopColor(color.red, color.green, color.blue, color.alpha); + const color = fillChoiceUIColor(detail); + if (color) editor.updateGradientStopColor(color); }} on:startHistoryTransaction={() => editor.startGradientStopColorTransaction()} on:commitHistoryTransaction={() => editor.commitGradientStopColorTransaction()} diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte index 354b5cf67b..8f74aedeac 100644 --- a/frontend/src/components/widgets/WidgetSpan.svelte +++ b/frontend/src/components/widgets/WidgetSpan.svelte @@ -26,7 +26,7 @@ import ShortcutLabel from "/src/components/widgets/labels/ShortcutLabel.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; import type { ColorPickerStore } from "/src/stores/color-picker"; - import { parseFillChoice } from "/src/utility-functions/colors"; + import { parseFillChoiceUI } from "/src/utility-functions/colors"; import type { EditorWrapper, LayoutTarget, Widget, WidgetInstance } from "/wrapper/pkg/graphite_wasm_wrapper"; // Extract the discriminant key names from the Widget tagged enum union (e.g. "TextButton" | "CheckboxInput" | ...) @@ -134,7 +134,7 @@ component: ColorInput, getProps: (props, index) => ({ ...props, - value: parseFillChoice(props.value), + value: parseFillChoiceUI(props.value), $$events: { value: (e: CustomEvent) => widgetValueUpdate(index, e.detail, false), startHistoryTransaction: () => widgetValueCommit(index, props.value), @@ -146,7 +146,7 @@ getProps: (props, index) => ({ ...props, $$events: { - // The widget dispatches `"None"` or a bare `Color`, wrap the color in `{ Solid: ... }` so the payload matches Rust's `FillChoice` shape (which the `Preset` variant expects). + // The widget dispatches `"None"` or a bare `SRGBA8`, wrap the color in `{ Solid: ... }` so the payload matches Rust's `FillChoiceUI` shape (which the `Preset` variant expects). preset: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { Preset: e.detail === "None" ? "None" : { Solid: e.detail } }, true), eyedropperColorCode: (e: CustomEvent) => widgetValueCommitAndUpdate(index, { EyedropperColorCode: e.detail }, true), }, diff --git a/frontend/src/components/widgets/inputs/ColorComparisonInput.svelte b/frontend/src/components/widgets/inputs/ColorComparisonInput.svelte index 627c432184..7169178f2c 100644 --- a/frontend/src/components/widgets/inputs/ColorComparisonInput.svelte +++ b/frontend/src/components/widgets/inputs/ColorComparisonInput.svelte @@ -4,12 +4,12 @@ import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import IconButton from "/src/components/widgets/buttons/IconButton.svelte"; import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; - import type { Color } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; const dispatch = createEventDispatcher<{ swap: undefined }>(); - export let newColor: Color | undefined; - export let oldColor: Color | undefined; + export let newColor: SRGBA8 | undefined; + export let oldColor: SRGBA8 | undefined; export let newColorCSS: string; export let newColorContrasting: string; export let oldColorCSS: string; @@ -21,7 +21,7 @@ export let outlineAmount: number; $: outlined = outlineAmount > 0.0001; - $: transparency = (newColor?.alpha ?? 1) < 1 || (oldColor?.alpha ?? 1) < 1; + $: transparency = (newColor?.alpha ?? 255) < 255 || (oldColor?.alpha ?? 255) < 255; (); + const dispatch = createEventDispatcher<{ value: FillChoiceUI; startHistoryTransaction: undefined }>(); // Content - export let value: FillChoice; + export let value: FillChoiceUI; export let chosenGradient: string | undefined = undefined; export let allowNone = false; // export let allowTransparency = false; // TODO: Implement @@ -29,10 +29,10 @@ $: outlineFactor = contrastingOutlineFactor(value, "--color-3-darkgray", 0.01); $: outlined = outlineFactor > 0.0001; - $: gradientStops = fillChoiceGradientStops(value); - $: solidColor = fillChoiceColor(value); + $: gradientStops = fillChoiceUIGradientStops(value); + $: solidColor = fillChoiceUIColor(value); $: none = value === "None"; - $: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 1) : solidColor ? solidColor.alpha < 1 : false; + $: transparency = gradientStops ? gradientStops.color.some((color) => color.alpha < 255) : solidColor ? solidColor.alpha < 255 : false; = { Black: [0, 0, 0], - White: [1, 1, 1], - Red: [1, 0, 0], - Yellow: [1, 1, 0], - Green: [0, 1, 0], - Cyan: [0, 1, 1], - Blue: [0, 0, 1], - Magenta: [1, 0, 1], + White: [255, 255, 255], + Red: [255, 0, 0], + Yellow: [255, 255, 0], + Green: [0, 255, 0], + Cyan: [0, 255, 255], + Blue: [0, 0, 255], + Magenta: [255, 0, 255], }; const PURE_COLORS_GRAYABLE: [PresetColor, string, string][] = [ ["Red", "#ff0000", "#4c4c4c"], @@ -28,7 +28,7 @@ ]; const dispatch = createEventDispatcher<{ - preset: Color | "None"; + preset: SRGBA8 | "None"; eyedropperColorCode: string; }>(); @@ -37,7 +37,7 @@ function pickPreset(preset: PresetColor | "None") { if (disabled) return; - dispatch("preset", preset === "None" ? "None" : createColor(...PURE_COLORS[preset], 1)); + dispatch("preset", preset === "None" ? "None" : createSRgba8(...PURE_COLORS[preset], 255)); } // TODO: Replace this temporary usage of the browser eyedropper API, that only works in Chromium-based browsers, with the custom color sampler system used by the Eyedropper tool diff --git a/frontend/src/components/widgets/inputs/VisualColorPickersInput.svelte b/frontend/src/components/widgets/inputs/VisualColorPickersInput.svelte index ebaaec9623..81e1697a83 100644 --- a/frontend/src/components/widgets/inputs/VisualColorPickersInput.svelte +++ b/frontend/src/components/widgets/inputs/VisualColorPickersInput.svelte @@ -3,7 +3,7 @@ import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import type { TooltipStore } from "/src/stores/tooltip"; - import { colorContrastingColor, colorOpaque, colorToHexNoAlpha, colorToRgbCSS, createColor, createColorFromHSVA } from "/src/utility-functions/colors"; + import { sRgba8ContrastingColor, sRgba8Opaque, sRgba8ToHexNoAlpha, sRgba8ToRgbCSS, createSRgba8, createSRgba8FromHsva } from "/src/utility-functions/colors"; const dispatch = createEventDispatcher<{ update: { hue: number; saturation: number; value: number; alpha: number }; @@ -184,20 +184,20 @@ removeEvents(); }); - $: newColor = isNone ? undefined : createColorFromHSVA(hue, saturation, value, alpha); - $: opaqueHueColor = createColorFromHSVA(hue, 1, 1, 1); - $: opaqueColorOnly = newColor ? colorOpaque(newColor) : createColor(0, 0, 0, 1); + $: newColor = isNone ? undefined : createSRgba8FromHsva(hue, saturation, value, alpha); + $: opaqueHueColor = createSRgba8FromHsva(hue, 1, 1, 1); + $: opaqueColorOnly = newColor ? sRgba8Opaque(newColor) : createSRgba8(0, 0, 0, 255); {@const hueDescription = "The shade along the spectrum of the rainbow."} diff --git a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte index a0ff2393eb..da6ec2d09d 100644 --- a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte +++ b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte @@ -3,14 +3,14 @@ import ColorPicker from "/src/components/floating-menus/ColorPicker.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte"; - import { fillChoiceColor, colorToRgbaCSS } from "/src/utility-functions/colors"; - import type { Color, EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; + import { fillChoiceUIColor, sRgba8ToRgbaCSS } from "/src/utility-functions/colors"; + import type { SRGBA8, EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; const editor = getContext("editor"); // Content - export let primary: Color; - export let secondary: Color; + export let primary: SRGBA8; + export let secondary: SRGBA8; let primaryOpen = false; let secondaryOpen = false; @@ -25,37 +25,37 @@ secondaryOpen = true; } - function primaryColorChanged(color: Color) { - editor.updatePrimaryColor(color.red, color.green, color.blue, color.alpha); + function primaryColorChanged(color: SRGBA8) { + editor.updatePrimaryColor(color); } - function secondaryColorChanged(color: Color) { - editor.updateSecondaryColor(color.red, color.green, color.blue, color.alpha); + function secondaryColorChanged(color: SRGBA8) { + editor.updateSecondaryColor(color); } - + (primaryOpen = detail)} colorOrGradient={{ Solid: primary }} on:colorOrGradient={({ detail }) => { - const color = fillChoiceColor(detail); + const color = fillChoiceUIColor(detail); if (color) primaryColorChanged(color); }} direction="Right" /> - + (secondaryOpen = detail)} colorOrGradient={{ Solid: secondary }} on:colorOrGradient={({ detail }) => { - const color = fillChoiceColor(detail); + const color = fillChoiceUIColor(detail); if (color) secondaryColorChanged(color); }} direction="Right" diff --git a/frontend/src/components/window/Panel.svelte b/frontend/src/components/window/Panel.svelte index e7a54fb06a..a9dd17f76e 100644 --- a/frontend/src/components/window/Panel.svelte +++ b/frontend/src/components/window/Panel.svelte @@ -11,7 +11,7 @@ import TextLabel from "/src/components/widgets/labels/TextLabel.svelte"; import { panelDrag, startCrossPanelDrag, endCrossPanelDrag, updateCrossPanelHover, updateDockingHover } from "/src/stores/panel-drag"; import type { DockingEdge } from "/src/stores/panel-drag"; - import type { EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { DockingSplitDirection, EditorWrapper, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; const PANEL_COMPONENTS = { Welcome, @@ -40,7 +40,7 @@ export let emptySpaceAction: (() => void) | undefined = undefined; export let crossPanelDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined; export let groupDropAction: ((sourcePanelId: string, targetPanelId: string, insertIndex: number) => void) | undefined = undefined; - export let splitDropAction: ((targetPanelId: string, direction: DockingEdge, tabs: PanelType[], activeTabIndex: number) => void) | undefined = undefined; + export let splitDropAction: ((targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) => void) | undefined = undefined; let className = ""; export { className as class }; @@ -222,7 +222,7 @@ dropAction?.(panelId, crossPanelState.hoverDockingPanelId, Number.MAX_SAFE_INTEGER); } // Edge docking drop: create a new split adjacent to the target panel - else if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge) { + else if (crossPanelState.active && crossPanelState.hoverDockingPanelId && crossPanelState.hoverDockingEdge && crossPanelState.hoverDockingEdge !== "Center") { splitDropAction?.( crossPanelState.hoverDockingPanelId, crossPanelState.hoverDockingEdge, diff --git a/frontend/src/components/window/PanelSubdivision.svelte b/frontend/src/components/window/PanelSubdivision.svelte index 8a7beeb2cb..ca8e845cff 100644 --- a/frontend/src/components/window/PanelSubdivision.svelte +++ b/frontend/src/components/window/PanelSubdivision.svelte @@ -4,7 +4,7 @@ import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import Panel from "/src/components/window/Panel.svelte"; import type { PortfolioStore } from "/src/stores/portfolio"; - import type { DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { DockingSplitDirection, DocumentInfo, EditorWrapper, PanelGroupState, PanelLayoutSubdivision, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; const MIN_PANEL_SIZE = 100; const DOUBLE_CLICK_MILLISECONDS = 500; @@ -85,7 +85,7 @@ sizeOverrides = sizeOverrides; const allSizes = children.map((child, i) => sizeOverrides[i] ?? child.size); - editor.setPanelGroupSizes(splitPath, allSizes); + editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes)); return; } @@ -140,7 +140,7 @@ // Persist the resized sizes to the backend if ("Split" in subdivision) { const allSizes = subdivision.Split.children.map((child, i) => sizeOverrides[i] ?? child.size); - editor.setPanelGroupSizes(splitPath, allSizes); + editor.setPanelGroupSizes(new Uint32Array(splitPath), new Float64Array(allSizes)); } }; @@ -179,7 +179,7 @@ editor.moveAllPanelTabs(BigInt(sourcePanelId), BigInt(targetPanelId), insertIndex); } - function splitDrop(targetPanelId: string, direction: string, tabs: string[], activeTabIndex: number) { + function splitDrop(targetPanelId: string, direction: DockingSplitDirection, tabs: PanelType[], activeTabIndex: number) { editor.splitPanelGroup(BigInt(targetPanelId), direction, tabs, activeTabIndex); } diff --git a/frontend/src/stores/color-picker.ts b/frontend/src/stores/color-picker.ts index 7fc6059f2b..93dd2ce7f3 100644 --- a/frontend/src/stores/color-picker.ts +++ b/frontend/src/stores/color-picker.ts @@ -2,10 +2,10 @@ import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; import { patchLayout } from "/src/utility-functions/widgets"; -import type { FillChoice, Layout } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { FillChoiceUI, Layout } from "/wrapper/pkg/graphite_wasm_wrapper"; export type ColorPickerCallbacks = { - onColorChanged?: (value: FillChoice) => void; + onColorChanged?: (value: FillChoiceUI) => void; onStartTransaction?: () => void; onCommitTransaction?: () => void; }; @@ -40,9 +40,9 @@ export type ColorPickerStore = { setDragging: (dragging: boolean) => void; }; -// The Rust handler keeps a single shared layout per target, but multiple `` Svelte instances may be mounted across +// The Rust handler keeps a single shared layout per target, but multiple `` Svelte instances may be mounted across // the app (one per `ColorInput`/`WorkingColorsInput`/etc.). Subscribing to the layout target from each instance is destructive, -// only the last-registered callback wins. So we maintain a single global subscription here and let each `` instance +// only the last-registered callback wins. So we maintain a single global subscription here and let each `` instance // read from the resulting store and register its own per-open callbacks for color/transaction events. export function createColorPickerStore(subscriptions: SubscriptionsRouter): ColorPickerStore { destroyColorPickerStore(); diff --git a/frontend/src/stores/panel-drag.ts b/frontend/src/stores/panel-drag.ts index 6a7834f1cc..19981b9570 100644 --- a/frontend/src/stores/panel-drag.ts +++ b/frontend/src/stores/panel-drag.ts @@ -1,8 +1,8 @@ import { writable } from "svelte/store"; import type { Writable } from "svelte/store"; -import type { PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { DockingSplitDirection, PanelType } from "/wrapper/pkg/graphite_wasm_wrapper"; -export type DockingEdge = "Left" | "Right" | "Top" | "Bottom" | "Center"; +export type DockingEdge = DockingSplitDirection | "Center"; export type PanelDragState = { active: boolean; diff --git a/frontend/src/utility-functions/colors.ts b/frontend/src/utility-functions/colors.ts index 97b55f1d2d..cf7ed21bf0 100644 --- a/frontend/src/utility-functions/colors.ts +++ b/frontend/src/utility-functions/colors.ts @@ -1,4 +1,4 @@ -import type { Color, FillChoice, GradientStops } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { FillChoiceUI, GradientStopsUI, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; // Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers export type HSV = { h: number; s: number; v: number }; @@ -6,26 +6,33 @@ export type RGB = { r: number; g: number; b: number }; // COLOR FACTORY FUNCTIONS -export function createColor(red: number, green: number, blue: number, alpha: number): Color { +export function createSRgba8(red: number, green: number, blue: number, alpha: number): SRGBA8 { return { red, green, blue, alpha }; } -export function createColorFromHSVA(h: number, s: number, v: number, a: number): Color { +// Build an `SRGBA8` from HSVA components on the 0..1 range. +export function createSRgba8FromHsva(h: number, s: number, v: number, a: number): SRGBA8 { const convert = (n: number): number => { const k = (n + h * 6) % 6; return v - v * s * Math.max(Math.min(...[k, 4 - k, 1]), 0); }; - return { red: convert(5), green: convert(3), blue: convert(1), alpha: a }; + return { + red: Math.round(convert(5) * 255), + green: Math.round(convert(3) * 255), + blue: Math.round(convert(1) * 255), + alpha: Math.round(a * 255), + }; } // COLOR UTILITY FUNCTIONS -export function isColor(value: unknown): value is Color { +export function isSRgba8(value: unknown): value is SRGBA8 { return typeof value === "object" && value !== null && "red" in value; } -export function colorFromCSS(colorCode: string): Color | undefined { +// Parse a CSS color string into an `SRGBA8`. Uses a canvas to delegate parsing to the browser. +export function sRgba8FromCSS(colorCode: string): SRGBA8 | undefined { // Allow single-digit hex value inputs let colorValue = colorCode.trim(); if (colorValue.length === 2 && colorValue.charAt(0) === "#" && /[0-9a-f]/i.test(colorValue.charAt(1))) { @@ -52,52 +59,40 @@ export function colorFromCSS(colorCode: string): Color | undefined { // Invalid color if (comparisonA !== comparisonB) { // If this color code didn't start with a #, add it and try again - if (colorValue.trim().charAt(0) !== "#") return colorFromCSS(`#${colorValue.trim()}`); + if (colorValue.trim().charAt(0) !== "#") return sRgba8FromCSS(`#${colorValue.trim()}`); return undefined; } context.fillRect(0, 0, 1, 1); const [r, g, b, a] = [...context.getImageData(0, 0, 1, 1).data]; - return createColor(r / 255, g / 255, b / 255, a / 255); + return createSRgba8(r, g, b, a); } -export function colorToHexNoAlpha(color: Color): string { - const r = Math.round(color.red * 255) - .toString(16) - .padStart(2, "0"); - const g = Math.round(color.green * 255) - .toString(16) - .padStart(2, "0"); - const b = Math.round(color.blue * 255) - .toString(16) - .padStart(2, "0"); +export function sRgba8ToHexNoAlpha(color: SRGBA8): string { + const r = color.red.toString(16).padStart(2, "0"); + const g = color.green.toString(16).padStart(2, "0"); + const b = color.blue.toString(16).padStart(2, "0"); return `#${r}${g}${b}`; } -export function colorToRgb255(color: Color): RGB { - return { - r: Math.round(color.red * 255), - g: Math.round(color.green * 255), - b: Math.round(color.blue * 255), - }; +export function sRgba8ToRgb255(color: SRGBA8): RGB { + return { r: color.red, g: color.green, b: color.blue }; } -export function colorToRgbCSS(color: Color): string { - const rgb = colorToRgb255(color); - - return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`; +export function sRgba8ToRgbCSS(color: SRGBA8): string { + return `rgb(${color.red}, ${color.green}, ${color.blue})`; } -export function colorToRgbaCSS(color: Color): string { - const rgb = colorToRgb255(color); - - return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${color.alpha})`; +export function sRgba8ToRgbaCSS(color: SRGBA8): string { + return `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.alpha / 255})`; } -export function colorToHSV(color: Color): HSV { - const { red: r, green: g, blue: b } = color; +export function sRgba8ToHSV(color: SRGBA8): HSV { + const r = color.red / 255; + const g = color.green / 255; + const b = color.blue / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); @@ -126,15 +121,17 @@ export function colorToHSV(color: Color): HSV { return { h, s, v }; } -export function colorOpaque(color: Color): Color { - return createColor(color.red, color.green, color.blue, 1); +export function sRgba8Opaque(color: SRGBA8): SRGBA8 { + return createSRgba8(color.red, color.green, color.blue, 255); } -export function colorLuminance(color: Color): number { +// WCAG-style relative luminance computed from an `SRGBA8` (alpha composited over white). +export function sRgba8Luminance(color: SRGBA8): number { + const a = color.alpha / 255; // Convert alpha into white - const r = color.red * color.alpha + (1 - color.alpha); - const g = color.green * color.alpha + (1 - color.alpha); - const b = color.blue * color.alpha + (1 - color.alpha); + const r = (color.red / 255) * a + (1 - a); + const g = (color.green / 255) * a + (1 - a); + const b = (color.blue / 255) * a + (1 - a); // https://stackoverflow.com/a/3943023/775283 @@ -145,32 +142,32 @@ export function colorLuminance(color: Color): number { return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722; } -export function colorContrastingColor(color: Color | undefined): "black" | "white" { +export function sRgba8ContrastingColor(color: SRGBA8 | undefined): "black" | "white" { if (!color) return "black"; - const luminance = colorLuminance(color); + const luminance = sRgba8Luminance(color); return luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white"; } -export function contrastingOutlineFactor(value: FillChoice, proximityColor: string | [string, string], proximityRange: number): number { +export function contrastingOutlineFactor(value: FillChoiceUI, proximityColor: string | [string, string], proximityRange: number): number { const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor]; - const [range1, range2] = pair.map((color) => colorFromCSS(window.getComputedStyle(document.body).getPropertyValue(color))); + const [range1, range2] = pair.map((color) => sRgba8FromCSS(window.getComputedStyle(document.body).getPropertyValue(color))); - const contrast = (color: Color | undefined): number => { + const contrast = (color: SRGBA8 | undefined): number => { if (!color) return 0; - const lum = colorLuminance(color); - let rangeLuminance1 = range1 ? colorLuminance(range1) : 0; - let rangeLuminance2 = range2 ? colorLuminance(range2) : 0; + const lum = sRgba8Luminance(color); + let rangeLuminance1 = range1 ? sRgba8Luminance(range1) : 0; + let rangeLuminance2 = range2 ? sRgba8Luminance(range2) : 0; [rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)]; const distance = Math.max(0, rangeLuminance1 - lum, lum - rangeLuminance2); - return (1 - Math.min(distance / proximityRange, 1)) * (1 - colorToHSV(color).s); + return (1 - Math.min(distance / proximityRange, 1)) * (1 - sRgba8ToHSV(color).s); }; - const gradientStops = fillChoiceGradientStops(value); + const gradientStops = fillChoiceUIGradientStops(value); if (gradientStops) { if (gradientStops.color.length === 0) return 0; @@ -180,30 +177,30 @@ export function contrastingOutlineFactor(value: FillChoice, proximityColor: stri return Math.min(first, last); } - return contrast(fillChoiceColor(value)); + return contrast(fillChoiceUIColor(value)); } // GRADIENT UTILITY FUNCTIONS -export function isGradientStops(value: unknown): value is GradientStops { +export function isGradientStopsUI(value: unknown): value is GradientStopsUI { return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value; } // FILL CHOICE UTILITY FUNCTIONS -export function fillChoiceColor(value: FillChoice): Color | undefined { +export function fillChoiceUIColor(value: FillChoiceUI): SRGBA8 | undefined { if (typeof value === "object" && "Solid" in value) return value.Solid; return undefined; } -export function fillChoiceGradientStops(value: FillChoice): GradientStops | undefined { +export function fillChoiceUIGradientStops(value: FillChoiceUI): GradientStopsUI | undefined { if (typeof value === "object" && "Gradient" in value) return value.Gradient; return undefined; } -export function parseFillChoice(value: unknown): FillChoice { +export function parseFillChoiceUI(value: unknown): FillChoiceUI { if (value === "None" || value === undefined || value === null) return "None"; - if (typeof value === "object" && value !== null && "Solid" in value && isColor(value.Solid)) return { Solid: value.Solid }; - if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStops(value.Gradient)) return { Gradient: value.Gradient }; + if (typeof value === "object" && value !== null && "Solid" in value && isSRgba8(value.Solid)) return { Solid: value.Solid }; + if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStopsUI(value.Gradient)) return { Gradient: value.Gradient }; return "None"; } diff --git a/frontend/wrapper/Cargo.toml b/frontend/wrapper/Cargo.toml index d0c9945bd2..f69599f664 100644 --- a/frontend/wrapper/Cargo.toml +++ b/frontend/wrapper/Cargo.toml @@ -25,6 +25,7 @@ editor = { path = "../../editor", package = "graphite-editor", features = ["gpu" graphene-std = { workspace = true } # Workspace dependencies +bytemuck = { workspace = true } graph-craft = { workspace = true } log = { workspace = true } serde = { workspace = true } diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index ca5ea09d89..d040ea969b 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -16,14 +16,17 @@ use editor::consts::FILE_EXTENSION; use editor::messages::clipboard::utility_types::ClipboardContentRaw; use editor::messages::input_mapper::utility_types::input_keyboard::ModifierKeys; use editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState, ScrollDelta}; +use editor::messages::layout::utility_types::layout_widget::LayoutTarget; use editor::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use editor::messages::portfolio::document::utility_types::network_interface::ImportOrExport; use editor::messages::portfolio::utility_types::{DockingSplitDirection, FontCatalog, FontCatalogFamily, PanelGroupId, PanelType}; use editor::messages::prelude::*; use editor::messages::tool::tool_messages::tool_prelude::WidgetId; use graph_craft::document::NodeId; +use graphene_std::color::SRGBA8; use graphene_std::graphene_hash::CacheHashWrapper; use graphene_std::raster::color::Color; +use graphene_std::vector::style::{FillChoice, FillChoiceUI}; use serde::Serialize; use serde_wasm_bindgen::{self, from_value}; use std::cell::RefCell; @@ -319,20 +322,20 @@ impl EditorWrapper { /// Update the value of a given UI widget, but don't commit it to the history (unless `commit_layout()` is called, which handles that) #[wasm_bindgen(js_name = widgetValueUpdate)] - pub fn widget_value_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { + pub fn widget_value_update(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { self.widget_value_update_helper(layout_target, widget_id, value, resend_widget) } /// Commit the value of a given UI widget to the history #[wasm_bindgen(js_name = widgetValueCommit)] - pub fn widget_value_commit(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { + pub fn widget_value_commit(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue) -> Result<(), JsValue> { self.widget_value_commit_helper(layout_target, widget_id, value) } /// Update the value of a given UI widget, and commit it to the history #[wasm_bindgen(js_name = widgetValueCommitAndUpdate)] - pub fn widget_value_commit_and_update(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { - self.widget_value_commit_helper(layout_target.clone(), widget_id, value.clone())?; + pub fn widget_value_commit_and_update(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { + self.widget_value_commit_helper(layout_target, widget_id, value.clone())?; self.widget_value_update_helper(layout_target, widget_id, value, resend_widget)?; // Close out a transaction that the widget's `on_commit` opened (if any), so a single click on widgets like the // NumberInput's increment buttons collapses into one history step instead of leaving the transaction in `Modified` @@ -346,34 +349,24 @@ impl EditorWrapper { self.dispatch(DocumentMessage::EndTransaction); } - pub fn widget_value_update_helper(&self, layout_target: JsValue, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { + pub fn widget_value_update_helper(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue, resend_widget: bool) -> Result<(), JsValue> { let widget_id = WidgetId(widget_id); - match (from_value(layout_target), from_value(value)) { - (Ok(layout_target), Ok(value)) => { - let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value }; - self.dispatch(message); - - if resend_widget { - let resend_message = LayoutMessage::ResendActiveWidget { layout_target, widget_id }; - self.dispatch(resend_message); - } - - Ok(()) - } - (target, val) => Err(Error::new(&format!("Could not update UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()), + let value: serde_json::Value = from_value(value).map_err(|e| Error::new(&format!("Could not update UI: {e}")))?; + let message = LayoutMessage::WidgetValueUpdate { layout_target, widget_id, value }; + self.dispatch(message); + if resend_widget { + let resend_message = LayoutMessage::ResendActiveWidget { layout_target, widget_id }; + self.dispatch(resend_message); } + Ok(()) } - pub fn widget_value_commit_helper(&self, layout_target: JsValue, widget_id: u64, value: JsValue) -> Result<(), JsValue> { + pub fn widget_value_commit_helper(&self, layout_target: LayoutTarget, widget_id: u64, value: JsValue) -> Result<(), JsValue> { let widget_id = WidgetId(widget_id); - match (from_value(layout_target), from_value(value)) { - (Ok(layout_target), Ok(value)) => { - let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value }; - self.dispatch(message); - Ok(()) - } - (target, val) => Err(Error::new(&format!("Could not commit UI\nDetails:\nTarget: {target:?}\nValue: {val:?}")).into()), - } + let value: serde_json::Value = from_value(value).map_err(|e| Error::new(&format!("Could not commit UI: {e}")))?; + let message = LayoutMessage::WidgetValueCommit { layout_target, widget_id, value }; + self.dispatch(message); + Ok(()) } #[wasm_bindgen(js_name = loadPreferences)] @@ -389,14 +382,8 @@ impl EditorWrapper { } #[wasm_bindgen(js_name = loadPersistedState)] - pub fn load_persisted_state(&self, state: JsValue) { - let Ok(state) = serde_wasm_bindgen::from_value(state) else { - log::error!("Failed to deserialize persisted state"); - return; - }; - - let message = PersistentStateMessage::LoadState { state }; - self.dispatch(message); + pub fn load_persisted_state(&self, state: editor::messages::frontend::utility_types::PersistedState) { + self.dispatch(PersistentStateMessage::LoadState { state }); } #[wasm_bindgen(js_name = loadDocumentContent)] @@ -494,9 +481,7 @@ impl EditorWrapper { } #[wasm_bindgen(js_name = splitPanelGroup)] - pub fn split_panel_group(&self, target_group: u64, direction: String, tabs: JsValue, active_tab_index: usize) { - let direction: DockingSplitDirection = serde_wasm_bindgen::from_value(JsValue::from_str(&direction)).unwrap(); - let tabs: Vec = serde_wasm_bindgen::from_value(tabs).unwrap(); + pub fn split_panel_group(&self, target_group: u64, direction: DockingSplitDirection, tabs: Vec, active_tab_index: usize) { let message = PortfolioMessage::SplitPanelGroup { target_group: PanelGroupId(target_group), direction, @@ -507,9 +492,8 @@ impl EditorWrapper { } #[wasm_bindgen(js_name = setPanelGroupSizes)] - pub fn set_panel_group_sizes(&self, split_path: JsValue, sizes: JsValue) { - let split_path: Vec = serde_wasm_bindgen::from_value(split_path).unwrap(); - let sizes: Vec = serde_wasm_bindgen::from_value(sizes).unwrap(); + pub fn set_panel_group_sizes(&self, split_path: Vec, sizes: Vec) { + let split_path = split_path.into_iter().map(|i| i as usize).collect(); let message = PortfolioMessage::SetPanelGroupSizes { split_path, sizes }; self.dispatch(message); } @@ -645,13 +629,8 @@ impl EditorWrapper { /// The font catalog has been loaded #[wasm_bindgen(js_name = onFontCatalogLoad)] - pub fn on_font_catalog_load(&self, catalog: JsValue) -> Result<(), JsValue> { - // Deserializing from TS type: `{ name: string; styles: { weight: number, italic: boolean, url: string }[] }[]` - let families = serde_wasm_bindgen::from_value::>(catalog)?; - let message = PortfolioMessage::FontCatalogLoaded { catalog: FontCatalog(families) }; - self.dispatch(message); - - Ok(()) + pub fn on_font_catalog_load(&self, catalog: Vec) { + self.dispatch(PortfolioMessage::FontCatalogLoaded { catalog: FontCatalog(catalog) }); } /// A font has been downloaded @@ -679,44 +658,29 @@ impl EditorWrapper { Ok(()) } - /// Update primary color with values on a scale from 0 to 1. + /// Update primary color from sRGB bytes (the wire format at the JS boundary). #[wasm_bindgen(js_name = updatePrimaryColor)] - pub fn update_primary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { - let Some(primary_color) = Color::from_rgbaf32(red, green, blue, alpha) else { - return Err(Error::new("Invalid color").into()); - }; - - let message = ToolMessage::SelectWorkingColor { - color: primary_color.to_linear_srgb(), + pub fn update_primary_color(&self, color: SRGBA8) { + self.dispatch(ToolMessage::SelectWorkingColor { + color: Color::from(color), primary: true, - }; - self.dispatch(message); - - Ok(()) + }); } - /// Update secondary color with values on a scale from 0 to 1. + /// Update secondary color from sRGB bytes (the wire format at the JS boundary). #[wasm_bindgen(js_name = updateSecondaryColor)] - pub fn update_secondary_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { - let Some(secondary_color) = Color::from_rgbaf32(red, green, blue, alpha) else { - return Err(Error::new("Invalid color").into()); - }; - - let message = ToolMessage::SelectWorkingColor { - color: secondary_color.to_linear_srgb(), + pub fn update_secondary_color(&self, color: SRGBA8) { + self.dispatch(ToolMessage::SelectWorkingColor { + color: Color::from(color), primary: false, - }; - self.dispatch(message); - - Ok(()) + }); } - /// Initialize the Rust color picker handler with a starting value (used when the frontend `` opens). + /// Initialize the Rust color picker handler with a starting value (used when the frontend `` opens). #[wasm_bindgen(js_name = openColorPicker)] - pub fn open_color_picker(&self, initial_value: JsValue, allow_none: bool, disabled: bool) -> Result<(), JsValue> { - let initial_value = serde_wasm_bindgen::from_value(initial_value).map_err(|e| Error::new(&format!("Invalid initial picker value: {e}")))?; + pub fn open_color_picker(&self, initial_value: FillChoiceUI, allow_none: bool, disabled: bool) { + let initial_value = FillChoice::from(&initial_value); self.dispatch(ColorPickerMessage::Open { initial_value, allow_none, disabled }); - Ok(()) } /// Tell the Rust color picker handler that the popover is closing. @@ -725,14 +689,10 @@ impl EditorWrapper { self.dispatch(ColorPickerMessage::Close); } - /// Update the color of the currently-edited gradient stop + /// Update the color of the currently-edited gradient stop, from sRGB bytes (the wire format at the JS boundary). #[wasm_bindgen(js_name = updateGradientStopColor)] - pub fn update_gradient_stop_color(&self, red: f32, green: f32, blue: f32, alpha: f32) -> Result<(), JsValue> { - let Some(color) = Color::from_rgbaf32(red, green, blue, alpha) else { - return Err(Error::new("Invalid color").into()); - }; - self.dispatch(GradientToolMessage::UpdateStopColor { color: color.to_linear_srgb() }); - Ok(()) + pub fn update_gradient_stop_color(&self, color: SRGBA8) { + self.dispatch(GradientToolMessage::UpdateStopColor { color: Color::from(color) }); } /// Start a new undo transaction for gradient stop color editing diff --git a/frontend/wrapper/src/helpers.rs b/frontend/wrapper/src/helpers.rs index c67454a544..9c46b48e29 100644 --- a/frontend/wrapper/src/helpers.rs +++ b/frontend/wrapper/src/helpers.rs @@ -6,8 +6,8 @@ use crate::{EDITOR_HAS_CRASHED, EDITOR_WRAPPER}; use editor::application::Editor; use editor::messages::input_mapper::utility_types::input_keyboard::Key; use editor::messages::prelude::*; +use graphene_std::color::SRGBA8; use graphene_std::raster::Image; -use graphene_std::raster::color::Color; use js_sys::{Object, Reflect}; use std::sync::atomic::Ordering; use std::time::Duration; @@ -119,7 +119,7 @@ pub(crate) fn auto_save_all_documents() { }); } -pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image)]) { +pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image)]) { let window = match window() { Some(window) => window, None => { @@ -167,8 +167,9 @@ pub(crate) fn render_image_data_to_canvases(image_data: &[(u64, Image)]) .expect("2d context was not found") .dyn_into::() .expect("Failed to cast context to CanvasRenderingContext2d"); - let u8_data: Vec = image.data.iter().flat_map(|color| color.to_rgba8_srgb()).collect(); - let clamped_u8_data = wasm_bindgen::Clamped(&u8_data[..]); + // `SRGBA8` is `#[repr(C)]` of four `u8`s, so the data buffer is already the byte format the canvas expects + let u8_data: &[u8] = bytemuck::cast_slice(&image.data); + let clamped_u8_data = wasm_bindgen::Clamped(u8_data); match ImageData::new_with_u8_clamped_array_and_sh(clamped_u8_data, image.width, image.height) { Ok(image_data_obj) => { if context.put_image_data(&image_data_obj, 0., 0.).is_err() { diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 4148d350ed..c8b3c0b262 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -2,6 +2,7 @@ use super::DocumentNode; use crate::application_io::PlatformEditorApi; use crate::proto::{Any as DAny, FutureAny}; use brush_nodes::brush_stroke::BrushStroke; +use core_types::color::SRGBA8; use core_types::list::List; use core_types::transform::Footprint; use core_types::uuid::NodeId; @@ -464,7 +465,7 @@ impl TaggedValue { // String syntax (e.g. "000000ff") if input.starts_with('"') && input.ends_with('"') { let hex = input.trim().trim_matches('"').trim().trim_start_matches('#'); - let color = Color::from_hex_str(hex); + let color = SRGBA8::from_hex_str(hex).map(Color::from); if color.is_none() { log::error!("Invalid default value color string: {input}"); } diff --git a/node-graph/libraries/core-types/Cargo.toml b/node-graph/libraries/core-types/Cargo.toml index 26cd66705d..f0162d6a2b 100644 --- a/node-graph/libraries/core-types/Cargo.toml +++ b/node-graph/libraries/core-types/Cargo.toml @@ -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 } diff --git a/node-graph/libraries/core-types/src/misc.rs b/node-graph/libraries/core-types/src/misc.rs index cf63d03b4c..8fb447662d 100644 --- a/node-graph/libraries/core-types/src/misc.rs +++ b/node-graph/libraries/core-types/src/misc.rs @@ -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 { + 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 = 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)) +} diff --git a/node-graph/libraries/no-std-types/src/color/color_types.rs b/node-graph/libraries/no-std-types/src/color/color_types.rs index 2f2a411ced..2985665f6f 100644 --- a/node-graph/libraries/no-std-types/src/color/color_types.rs +++ b/node-graph/libraries/no-std-types/src/color/color_types.rs @@ -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 for Color`] and [`From 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 { + 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 for [u8; 4] { + #[inline(always)] + fn from(c: SRGBA8) -> Self { + let SRGBA8 { red, green, blue, alpha } = c; + [red, green, blue, alpha] + } +} + +/// Lets `Image` cross the wasm boundary as gamma bytes, since `Color` (linear-light) isn't exposed with Tsify. +impl Pixel for SRGBA8 {} + impl From for SRGBA8 { #[inline(always)] fn from(c: Color) -> Self { @@ -127,53 +222,6 @@ impl From 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`]). +/// +/// 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>, _>` 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(&self, serializer: S) -> Result { + 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>(deserializer: D) -> Result { + // 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 { - 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` 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 { - 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 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 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 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 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.); diff --git a/node-graph/libraries/no-std-types/src/color/mod.rs b/node-graph/libraries/no-std-types/src/color/mod.rs index 0a983229c8..20e5795a3b 100644 --- a/node-graph/libraries/no-std-types/src/color/mod.rs +++ b/node-graph/libraries/no-std-types/src/color/mod.rs @@ -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::*; diff --git a/node-graph/libraries/no-std-types/src/color/transfer.rs b/node-graph/libraries/no-std-types/src/color/transfer.rs new file mode 100644 index 0000000000..c00744c3d7 --- /dev/null +++ b/node-graph/libraries/no-std-types/src/color/transfer.rs @@ -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 } +} diff --git a/node-graph/libraries/raster-types/src/image.rs b/node-graph/libraries/raster-types/src/image.rs index 5fb291d3e6..8ed636270e 100644 --- a/node-graph/libraries/raster-types/src/image.rs +++ b/node-graph/libraries/raster-types/src/image.rs @@ -144,7 +144,14 @@ impl Image

{ impl Image { /// 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` 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 AsRef> for Image

{ } } -impl From> for Image { - fn from(image: Image) -> 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> for Image { - fn from(image: Image) -> 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] diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index df1690afd2..883640d05d 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -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.); } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index c7064992bb..6a6ca7c82b 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -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 { 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 { // 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 { 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 { // 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 { 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 { 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 { 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 { 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 { let mut stop_string = String::new(); for (position, color, original_midpoint) in gradient.interpolated_samples() { - let _ = write!(stop_string, r##" { 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()), }) } diff --git a/node-graph/libraries/rendering/src/to_peniko.rs b/node-graph/libraries/rendering/src/to_peniko.rs index c4dbfad450..1e0e8cb04e 100644 --- a/node-graph/libraries/rendering/src/to_peniko.rs +++ b/node-graph/libraries/rendering/src/to_peniko.rs @@ -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 { diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 20dde564d2..9df066e76e 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -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, } +/// 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, + pub midpoint: Vec, + pub color: Vec, +} + +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::>() + .join(", "); + format!("linear-gradient(to right, {pieces})") + } +} + // TODO: Eventually remove this migration document upgrade code impl<'de> serde::Deserialize<'de> for GradientStops { fn deserialize>(deserializer: D) -> Result { @@ -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::>() .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 `` 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)> { /// 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)>, 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)>, 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::>() .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, }, ]); diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 71c4a9646b..8ef5ddb709 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -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 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 { + 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 { + 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 { 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(), }; diff --git a/node-graph/libraries/wgpu-executor/src/lib.rs b/node-graph/libraries/wgpu-executor/src/lib.rs index 9395ec1bf1..930612bec3 100644 --- a/node-graph/libraries/wgpu-executor/src/lib.rs +++ b/node-graph/libraries/wgpu-executor/src/lib.rs @@ -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, diff --git a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs index a5cc040c99..fef0975299 100644 --- a/node-graph/libraries/wgpu-executor/src/texture_conversion.rs +++ b/node-graph/libraries/wgpu-executor/src/texture_conversion.rs @@ -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` 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.)); } } diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index 8fea295b2c..2b7d084764 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -2,6 +2,7 @@ use base64::Engine; #[cfg(target_family = "wasm")] use canvas_utils::{Canvas, CanvasHandle}; +use core_types::color::SRGBA8; use core_types::list::{Item, List}; #[cfg(target_family = "wasm")] use core_types::math::bbox::Bbox; @@ -123,7 +124,15 @@ fn string_to_bytes(_: impl Ctx, string: String) -> List { #[node_macro::node(category("Web Request"), name("Image to Bytes"))] fn image_to_bytes(_: impl Ctx, image: List>) -> List { let Some(image) = image.element(0) else { return List::new() }; - image.data.iter().flat_map(|color| color.to_rgba8_srgb()).map(Item::new_from_element).collect() + image + .data + .iter() + .flat_map(|color| { + let SRGBA8 { red, green, blue, alpha } = (*color).into(); + [red, green, blue, alpha] + }) + .map(Item::new_from_element) + .collect() } /// Loads binary from URLs and local asset paths. Returns a transparent placeholder if the resource fails to load, allowing rendering to continue. @@ -154,7 +163,11 @@ fn decode_image(_: impl Ctx, data: Arc<[u8]>) -> List> { let image = Image { data: image .chunks(4) - .map(|pixel| Color::from_unassociated_alpha(pixel[0], pixel[1], pixel[2], pixel[3]).to_linear_srgb()) + .map(|pixel| { + // Decoded bytes are unassociated gamma sRGB; premultiply in gamma then lift to linear + let a = pixel[3]; + Color::from_gamma_srgb_channels(pixel[0] * a, pixel[1] * a, pixel[2] * a, a) + }) .collect(), width: image.width(), height: image.height(), diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index c09047856e..3e864ee727 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -823,7 +823,8 @@ fn rgba_to_color(_: impl Ctx, _primary: (), red: Fraction, green: Fraction, blue let blue = (blue as f32).clamp(0., 1.); let alpha = (alpha as f32).clamp(0., 1.); - List::new_from_element(Color::from_rgbaf32_unchecked(red, green, blue, alpha)) + // RGB user inputs are interpreted as sRGB display values; lift to linear-light for the internal `Color` + List::new_from_element(Color::from_gamma_srgb_channels(red, green, blue, alpha)) } /// Constructs a color value from hue, saturation, value, and alpha components given as numbers from 0 to 1. @@ -848,11 +849,11 @@ fn hsla_to_color(_: impl Ctx, _primary: (), hue: Fraction, #[default(1.)] satura List::new_from_element(Color::from_hsla(hue, saturation, lightness, alpha)) } -/// Constructs a color value from an sRGB color code string, such as `#RRGGBB` or `#RRGGBBAA`. Invalid hex code strings produce no color. +/// Constructs a color value from a CSS color string. Accepts hex (`#RRGGBB`, `#RRGGBBAA`, plus bare and shorthand variants), CSS named colors (like `red`), and functional notations (`rgb(...)`, `hsl(...)`, etc.). Invalid inputs produce no color. #[node_macro::node(category("Color"), name("Hex to Color"))] fn hex_to_color(_: impl Ctx, hex_code: String) -> List { - match Color::from_hex_str(&hex_code) { - Some(c) => List::new_from_element(c), + match core_types::misc::parse_css_color(&hex_code) { + Some(color) => List::new_from_element(color), None => List::new(), } } diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index f5d4a31837..34ea4fd4e0 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -5,8 +5,8 @@ use crate::cubic_spline::CubicSplines; use core::fmt::Debug; #[cfg(feature = "std")] use core_types::list::List; -use glam::{Vec3, Vec4}; -use no_std_types::color::Color; +use glam::Vec3; +use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear}; use no_std_types::context::Ctx; use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32}; use node_macro::BufferStruct; @@ -63,7 +63,7 @@ fn luminance>( ) -> T { input.adjust(|color| { let luminance = match luminance_calc { - LuminanceCalculation::SRGB => color.luminance_srgb(), + LuminanceCalculation::SRGB => color.luminance_rec_709(), LuminanceCalculation::Perceptual => color.luminance_perceptual(), LuminanceCalculation::AverageChannels => color.average_rgb_channels(), LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(), @@ -91,7 +91,7 @@ fn gamma_correction>( inverse: bool, ) -> T { let exponent = if inverse { 1. / gamma } else { gamma }; - input.adjust(|color| color.gamma(exponent)); + input.adjust(|color| color.apply_gamma_exponent(exponent)); input } @@ -161,7 +161,7 @@ fn brightness_contrast_classic>( let offset = brightness * contrast + brightness - contrast / 2.; - input.adjust(|color| color.to_gamma_srgb().map_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)).to_linear_srgb()); + input.adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.))); input } @@ -240,7 +240,7 @@ fn brightness_contrast>( }); let lut_max = (combined_lut.len() - 1) as f32; - input.adjust(|color| color.to_gamma_srgb().map_rgb(|c| combined_lut[(c * lut_max).round() as usize]).to_linear_srgb()); + input.adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize])); input } @@ -270,7 +270,8 @@ fn levels>( #[default(100.)] output_maximums: PercentageF32, ) -> T { image.adjust(|color| { - let color = color.to_gamma_srgb(); + // Levels math operates in gamma space + let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels(); // Input Range (Range: 0-1) let input_shadows = shadows / 100.; @@ -301,15 +302,24 @@ fn levels>( // Input levels (Range: 0-1) let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.); - let color = color.map_rgb(|c| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.)); + let input_map = |c: f32| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.); + r = input_map(r); + g = input_map(g); + b = input_map(b); - // Midtones (Range: 0-1) - let color = color.gamma(gamma); + // Midtones gamma curve (Range: 0-1) + let inverse_gamma = 1. / gamma.max(0.0001); + r = r.powf(inverse_gamma); + g = g.powf(inverse_gamma); + b = b.powf(inverse_gamma); // Output levels (Range: 0-1) - let color = color.map_rgb(|c| c * (output_maximums - output_minimums) + output_minimums); + let output_map = |c: f32| c * (output_maximums - output_minimums) + output_minimums; + r = output_map(r); + g = output_map(g); + b = output_map(b); - color.to_linear_srgb() + Color::from_gamma_srgb_channels(r, g, b, a) }); image } @@ -353,7 +363,8 @@ fn black_and_white>( magentas: PercentageF32, ) -> T { image.adjust(|color| { - let color = color.to_gamma_srgb(); + // Black & White channel weights are tuned for gamma-space values + let [r, g, b, alpha_part] = color.to_gamma_srgb_channels(); let reds = reds / 100.; let yellows = yellows / 100.; @@ -362,12 +373,11 @@ fn black_and_white>( let blues = blues / 100.; let magentas = magentas / 100.; - let gray_base = color.r().min(color.g()).min(color.b()); + let gray_base = r.min(g).min(b); - let red_part = color.r() - gray_base; - let green_part = color.g() - gray_base; - let blue_part = color.b() - gray_base; - let alpha_part = color.a(); + let red_part = r - gray_base; + let green_part = g - gray_base; + let blue_part = b - gray_base; let additional = if red_part == 0. { let cyan_part = green_part.min(blue_part); @@ -383,11 +393,15 @@ fn black_and_white>( let luminance = gray_base + additional; // TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close) - let color = tint.with_luminance(luminance); + // Apply luminance substitution in gamma space + let [tr, tg, tb, _] = tint.to_gamma_srgb_channels(); + let tint_luma_rec_601 = 0.3 * tr + 0.59 * tg + 0.11 * tb; + let delta = luminance - tint_luma_rec_601; + let result_r = (tr + delta).clamp(0., 1.); + let result_g = (tg + delta).clamp(0., 1.); + let result_b = (tb + delta).clamp(0., 1.); - let color = Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), alpha_part); - - color.to_linear_srgb() + Color::from_gamma_srgb_channels(result_r, result_g, result_b, alpha_part) }); image } @@ -410,20 +424,17 @@ fn hue_saturation>( lightness_shift: SignedPercentageF32, ) -> T { input.adjust(|color| { - let color = color.to_gamma_srgb(); - + // HSL operates on gamma-space channels let [hue, saturation, lightness, alpha] = color.to_hsla(); - let color = Color::from_hsla( + Color::from_hsla( (hue + hue_shift / 360.) % 1., // TODO: Improve the way saturation works (it's slightly off) (saturation + saturation_shift / 100.).clamp(0., 1.), // TODO: Fix the way lightness works (it's very off) (lightness + lightness_shift / 100.).clamp(0., 1.), alpha, - ); - - color.to_linear_srgb() + ) }); input } @@ -442,11 +453,9 @@ fn invert>( mut input: T, ) -> T { input.adjust(|color| { - let color = color.to_gamma_srgb(); - - let color = color.map_rgb(|c| color.a() - c); - - color.to_linear_srgb() + // Invert in gamma space relative to alpha + let [r, g, b, a] = color.to_gamma_srgb_channels(); + Color::from_gamma_srgb_channels(a - r, a - g, a - b, a) }); input } @@ -468,11 +477,11 @@ fn threshold>( luminance_calc: LuminanceCalculation, ) -> T { image.adjust(|color| { - let min_luminance = Color::srgb_to_linear(min_luminance / 100.); - let max_luminance = Color::srgb_to_linear(max_luminance / 100.); + let min_luminance = srgb_to_linear(min_luminance / 100.); + let max_luminance = srgb_to_linear(max_luminance / 100.); let luminance = match luminance_calc { - LuminanceCalculation::SRGB => color.luminance_srgb(), + LuminanceCalculation::SRGB => color.luminance_rec_709(), LuminanceCalculation::Perceptual => color.luminance_perceptual(), LuminanceCalculation::AverageChannels => color.average_rgb_channels(), LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(), @@ -512,17 +521,22 @@ fn vibrance>( vibrance: SignedPercentageF32, ) -> T { image.adjust(|color| { + let r_raw = color.r(); + let g_raw = color.g(); + let b_raw = color.b(); + let alpha_in = color.a(); + let vibrance = vibrance / 100.; // Slow the effect down by half when it's negative, since artifacts begin appearing past -50%. // So this scales the 0% to -50% range to 0% to -100%. let slowed_vibrance = if vibrance >= 0. { vibrance } else { vibrance * 0.5 }; - let channel_max = color.r().max(color.g()).max(color.b()); - let channel_min = color.r().min(color.g()).min(color.b()); + let channel_max = r_raw.max(g_raw).max(b_raw); + let channel_min = r_raw.min(g_raw).min(b_raw); let channel_difference = channel_max - channel_min; - let scale_multiplier = if channel_max == color.r() { - let green_blue_difference = (color.g() - color.b()).abs(); + let scale_multiplier = if channel_max == r_raw { + let green_blue_difference = (g_raw - b_raw).abs(); let t = (green_blue_difference / channel_difference).min(1.); t * 0.5 + 0.5 } else { @@ -532,30 +546,48 @@ fn vibrance>( let channel_reduction = channel_min * scale; let scale = 1. + scale * (1. - channel_difference); - let luminance_initial = color.to_linear_srgb().luminance_srgb(); - let altered_color = color.map_rgb(|c| c * scale - channel_reduction).to_linear_srgb(); - let luminance = altered_color.luminance_srgb(); - let altered_color = altered_color.map_rgb(|c| c * luminance_initial / luminance); + let r_lin0 = srgb_to_linear(r_raw); + let g_lin0 = srgb_to_linear(g_raw); + let b_lin0 = srgb_to_linear(b_raw); + let luminance_initial = 0.2126 * r_lin0 + 0.7152 * g_lin0 + 0.0722 * b_lin0; - let channel_max = altered_color.r().max(altered_color.g()).max(altered_color.b()); - let altered_color = if Color::linear_to_srgb(channel_max) > 1. { + let mut alt_r = srgb_to_linear(r_raw * scale - channel_reduction); + let mut alt_g = srgb_to_linear(g_raw * scale - channel_reduction); + let mut alt_b = srgb_to_linear(b_raw * scale - channel_reduction); + let luminance = 0.2126 * alt_r + 0.7152 * alt_g + 0.0722 * alt_b; + // Skip the luminance-preservation scaling when the result is black (e.g. black input pixel), avoiding division by zero. + if luminance > 0. { + alt_r *= luminance_initial / luminance; + alt_g *= luminance_initial / luminance; + alt_b *= luminance_initial / luminance; + } + + let channel_max = alt_r.max(alt_g).max(alt_b); + if linear_to_srgb(channel_max) > 1. { let scale = (1. - luminance) / (channel_max - luminance); - altered_color.map_rgb(|c| (c - luminance) * scale + luminance) - } else { - altered_color - }; - let altered_color = altered_color.to_gamma_srgb(); + alt_r = (alt_r - luminance) * scale + luminance; + alt_g = (alt_g - luminance) * scale + luminance; + alt_b = (alt_b - luminance) * scale + luminance; + } + + alt_r = linear_to_srgb(alt_r); + alt_g = linear_to_srgb(alt_g); + alt_b = linear_to_srgb(alt_b); if vibrance >= 0. { - altered_color + Color::from_rgbaf32_unchecked(alt_r, alt_g, alt_b, alpha_in) } else { - // TODO: The result ends up a bit darker than it should be, further investigation is needed - let luminance = color.luminance_rec_601(); - - // Near -0% vibrance we mostly use `altered_color`. - // Near -100% vibrance, we mostly use half the desaturated luminance color and half `altered_color`. + // TODO: The result ends up a bit darker than it should be, further investigation is needed. + // Mix in gamma space (matching `alt_*`), so the luminance is computed from gamma channels too. + let [gr, gg, gb, _] = color.to_gamma_srgb_channels(); + let luminance = 0.299 * gr + 0.587 * gg + 0.114 * gb; let factor = -slowed_vibrance; - altered_color.map_rgb(|c| c * (1. - factor) + luminance * factor) + Color::from_rgbaf32_unchecked( + alt_r * (1. - factor) + luminance * factor, + alt_g * (1. - factor) + luminance * factor, + alt_b * (1. - factor) + luminance * factor, + alpha_in, + ) } }); image @@ -747,16 +779,14 @@ fn channel_mixer>( _output_channel: RedGreenBlue, ) -> T { image.adjust(|color| { - let color = color.to_gamma_srgb(); + let [r, g, b, a] = color.to_gamma_srgb_channels(); - let (r, g, b, a) = color.components(); - - let color = if monochrome { + let (out_r, out_g, out_b) = if monochrome { let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r / 100., monochrome_g / 100., monochrome_b / 100., monochrome_c / 100.); let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.); - Color::from_rgbaf32_unchecked(gray, gray, gray, a) + (gray, gray, gray) } else { let (red_r, red_g, red_b, red_c) = (red_r / 100., red_g / 100., red_b / 100., red_c / 100.); let (green_r, green_g, green_b, green_c) = (green_r / 100., green_g / 100., green_b / 100., green_c / 100.); @@ -766,10 +796,10 @@ fn channel_mixer>( let green = (r * green_r + g * green_g + b * green_b + green_c).clamp(0., 1.); let blue = (r * blue_r + g * blue_g + b * blue_b + blue_c).clamp(0., 1.); - Color::from_rgbaf32_unchecked(red, green, blue, a) + (red, green, blue) }; - color.to_linear_srgb() + Color::from_gamma_srgb_channels(out_r, out_g, out_b, a) }); image } @@ -873,9 +903,7 @@ fn selective_color>( _colors: SelectiveColorChoice, ) -> T { image.adjust(|color| { - let color = color.to_gamma_srgb(); - - let (r, g, b, a) = color.components(); + let [r, g, b, a] = color.to_gamma_srgb_channels(); let min = |a: f32, b: f32, c: f32| a.min(b).min(c); let max = |a: f32, b: f32, c: f32| a.max(b).max(c); @@ -945,9 +973,9 @@ fn selective_color>( } let rgb = Vec3::new(r, g, b); - let color = Color::from_vec4(Vec4::from(((sum + rgb).clamp(Vec3::ZERO, Vec3::ONE), a))); + let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE); - color.to_linear_srgb() + Color::from_gamma_srgb_channels(out.x, out.y, out.z, a) }); image } @@ -973,15 +1001,11 @@ fn posterize>( levels: u32, ) -> T { input.adjust(|color| { - let color = color.to_gamma_srgb(); - - let levels = levels as f32; + // `hard_min(2)` constrains the widget but doesn't bind the data-flow input (a saved doc or upstream node could still feed 0 or 1, producing inf/NaN below). + let levels = (levels as f32).max(2.); let number_of_areas = levels.recip(); let size_of_areas = (levels - 1.).recip(); - let channel = |channel: f32| (channel / number_of_areas).floor() * size_of_areas; - let color = color.map_rgb(channel); - - color.to_linear_srgb() + color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas) }); input } @@ -1016,7 +1040,7 @@ fn exposure>( // Offset .map_rgb(|c: f32| c + offset) // Gamma correction - .gamma(gamma_correction); + .apply_gamma_exponent(gamma_correction); adjusted.map_rgb(|c: f32| c.clamp(0., 1.)) }); diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 6dcfc7c62e..1b6c4f6977 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -99,7 +99,7 @@ pub fn blend_colors(foreground: Color, background: Color, blend_mode: BlendMode, blend_mode => apply_blend_mode(foreground, background, blend_mode), }; - background.alpha_blend(target_color.to_associated_alpha(opacity)) + background.alpha_blend(target_color.apply_opacity(opacity)) } pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendMode) -> Color { diff --git a/node-graph/nodes/raster/src/filter.rs b/node-graph/nodes/raster/src/filter.rs index 7fbf03a11a..2e42ad9b66 100644 --- a/node-graph/nodes/raster/src/filter.rs +++ b/node-graph/nodes/raster/src/filter.rs @@ -1,4 +1,5 @@ -use core_types::color::Color; +use bytemuck::{Pod, Zeroable}; +use core_types::color::{Alpha, Color, Pixel, RGB}; use core_types::context::Ctx; use core_types::list::List; use core_types::registry::types::PixelLength; @@ -6,6 +7,84 @@ use raster_types::Image; use raster_types::{Bitmap, BitmapMut}; use raster_types::{CPU, Raster}; +/// Working-buffer pixel for the blur algorithms' `gamma` mode: premultiplied sRGB-gamma `f32` channels. +/// Only used internally so the working buffer's color space is reflected in the type instead of stuffed into `Color` (which is linear-light by invariant). +#[repr(C)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable)] +struct PremultipliedGammaPixel { + r: f32, + g: f32, + b: f32, + a: f32, +} + +impl Pixel for PremultipliedGammaPixel {} + +impl RGB for PremultipliedGammaPixel { + type ColorChannel = f32; + fn red(&self) -> f32 { + self.r + } + fn green(&self) -> f32 { + self.g + } + fn blue(&self) -> f32 { + self.b + } +} + +impl Alpha for PremultipliedGammaPixel { + type AlphaChannel = f32; + const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. }; + fn alpha(&self) -> f32 { + self.a + } + fn multiplied_alpha(&self, mult: f32) -> Self { + Self { + r: self.r * mult, + g: self.g * mult, + b: self.b * mult, + a: self.a * mult, + } + } +} + +fn premultiply_gamma(buffer: Image) -> Image { + Image { + width: buffer.width, + height: buffer.height, + data: buffer + .data + .into_iter() + .map(|px| { + let [r, g, b, a] = px.to_gamma_srgb_channels(); + PremultipliedGammaPixel { r: r * a, g: g * a, b: b * a, a } + }) + .collect(), + base64_string: None, + } +} + +fn unpremultiply_gamma_to_linear(buffer: Image) -> Image { + Image { + width: buffer.width, + height: buffer.height, + data: buffer + .data + .into_iter() + .map(|px| { + if px.a > 0. { + let inv_a = 1. / px.a; + Color::from_gamma_srgb_channels(px.r * inv_a, px.g * inv_a, px.b * inv_a, px.a) + } else { + Color::TRANSPARENT + } + }) + .collect(), + base64_string: None, + } +} + /// Blurs the image with a Gaussian or box blur kernel filter. #[node_macro::node(category("Raster: Filter"))] async fn blur( @@ -98,26 +177,49 @@ fn gaussian_kernel(radius: f64) -> Vec { gaussian_kernel } -fn gaussian_blur_algorithm(mut original_buffer: Image, radius: f64, gamma: bool) -> Image { - if gamma { - original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a())); - } else { - original_buffer.map_pixels(|px| px.to_associated_alpha(px.a())); - } - - let (width, height) = original_buffer.dimensions(); - - // Create 1D gaussian kernel +fn gaussian_blur_algorithm(buffer: Image, radius: f64, gamma: bool) -> Image { let kernel = gaussian_kernel(radius); + if gamma { + let working = premultiply_gamma(buffer); + let blurred = gaussian_separable(working, &kernel, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a }); + unpremultiply_gamma_to_linear(blurred) + } else { + let mut working = buffer; + working.map_pixels(|px| px.apply_opacity(px.a())); + let mut blurred = gaussian_separable(working, &kernel, Color::from_rgbaf32_unchecked); + blurred.map_pixels(|px| px.to_unassociated_alpha()); + blurred + } +} + +fn box_blur_algorithm(buffer: Image, radius: f64, gamma: bool) -> Image { + if gamma { + let working = premultiply_gamma(buffer); + let blurred = box_separable(working, radius, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a }); + unpremultiply_gamma_to_linear(blurred) + } else { + let mut working = buffer; + working.map_pixels(|px| px.apply_opacity(px.a())); + let mut blurred = box_separable(working, radius, Color::from_rgbaf32_unchecked); + blurred.map_pixels(|px| px.to_unassociated_alpha()); + blurred + } +} + +fn gaussian_separable(buffer: Image

, kernel: &[f64], construct: F) -> Image

+where + P: Pixel + Copy + RGB + Alpha, + F: Fn(f32, f32, f32, f32) -> P, +{ + let (width, height) = buffer.dimensions(); let half_kernel = kernel.len() / 2; - // Intermediate buffer for horizontal and vertical passes - let mut x_axis = Image::new(width, height, Color::TRANSPARENT); - let mut y_axis = Image::new(width, height, Color::TRANSPARENT); + let mut x_axis = Image::new(width, height, P::default()); + let mut y_axis = Image::new(width, height, P::default()); for pass in [false, true] { let (max, old_buffer, current_buffer) = match pass { - false => (width, &original_buffer, &mut x_axis), + false => (width, &buffer, &mut x_axis), true => (height, &x_axis, &mut y_axis), }; let pass = pass as usize; @@ -140,41 +242,32 @@ fn gaussian_blur_algorithm(mut original_buffer: Image, radius: f64, gamma } } - // Normalize let (r, g, b, a) = if weight_sum > 0. { ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32) } else { let px = old_buffer.get_pixel(x, y).unwrap(); (px.r(), px.g(), px.b(), px.a()) }; - current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a)); + current_buffer.set_pixel(x, y, construct(r, g, b, a)); } } } - if gamma { - y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha()); - } else { - y_axis.map_pixels(|px| px.to_unassociated_alpha()); - } - y_axis } -fn box_blur_algorithm(mut original_buffer: Image, radius: f64, gamma: bool) -> Image { - if gamma { - original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a())); - } else { - original_buffer.map_pixels(|px| px.to_associated_alpha(px.a())); - } - - let (width, height) = original_buffer.dimensions(); - let mut x_axis = Image::new(width, height, Color::TRANSPARENT); - let mut y_axis = Image::new(width, height, Color::TRANSPARENT); +fn box_separable(buffer: Image

, radius: f64, construct: F) -> Image

+where + P: Pixel + Copy + RGB + Alpha, + F: Fn(f32, f32, f32, f32) -> P, +{ + let (width, height) = buffer.dimensions(); + let mut x_axis = Image::new(width, height, P::default()); + let mut y_axis = Image::new(width, height, P::default()); for pass in [false, true] { let (max, old_buffer, current_buffer) = match pass { - false => (width, &original_buffer, &mut x_axis), + false => (width, &buffer, &mut x_axis), true => (height, &x_axis, &mut y_axis), }; let pass = pass as usize; @@ -196,17 +289,11 @@ fn box_blur_algorithm(mut original_buffer: Image, radius: f64, gamma: boo } let (r, g, b, a) = ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32); - current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a)); + current_buffer.set_pixel(x, y, construct(r, g, b, a)); } } } - if gamma { - y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha()); - } else { - y_axis.map_pixels(|px| px.to_unassociated_alpha()); - } - y_axis } diff --git a/node-graph/nodes/raster/src/gradient_map.rs b/node-graph/nodes/raster/src/gradient_map.rs index 3fbeb11b13..db3b949447 100644 --- a/node-graph/nodes/raster/src/gradient_map.rs +++ b/node-graph/nodes/raster/src/gradient_map.rs @@ -24,9 +24,9 @@ async fn gradient_map>( let Some(gradient) = gradient.element(0) else { return image }; image.adjust(|color| { - let intensity = color.luminance_srgb(); + let intensity = color.luminance_rec_709(); let intensity = if reverse { 1. - intensity } else { intensity }; - gradient.evaluate(intensity as f64).to_linear_srgb() + gradient.evaluate(intensity as f64) }); image diff --git a/node-graph/nodes/raster/src/image_color_palette.rs b/node-graph/nodes/raster/src/image_color_palette.rs index 937b0e4a7f..01e460f795 100644 --- a/node-graph/nodes/raster/src/image_color_palette.rs +++ b/node-graph/nodes/raster/src/image_color_palette.rs @@ -16,7 +16,8 @@ async fn image_color_palette( let bins = GRID * GRID * GRID; let mut histogram = vec![0; (bins + 1.) as usize]; - let mut color_bins = vec![Vec::new(); (bins + 1.) as usize]; + // Each bin stores `(red, green, blue, alpha)` tuples in sRGB gamma space; averaging in gamma space gives perceptually-uniform binning. + let mut color_bins: Vec> = vec![Vec::new(); (bins + 1.) as usize]; for element in image.iter_element_values() { for pixel in element.data.iter() { @@ -27,7 +28,7 @@ async fn image_color_palette( let bin = (r * GRID + g * GRID + b * GRID) as usize; histogram[bin] += 1; - color_bins[bin].push(pixel.to_gamma_srgb()); + color_bins[bin].push(pixel.to_gamma_srgb_channels()); } } @@ -39,24 +40,21 @@ async fn image_color_palette( .flat_map(|&i| { let list = &color_bins[i]; - let mut r = 0.; - let mut g = 0.; - let mut b = 0.; - let mut a = 0.; + let [mut r, mut g, mut b, mut a] = [0.; 4]; - for color in list.iter() { - r += color.r(); - g += color.g(); - b += color.b(); - a += color.a(); + for &[cr, cg, cb, ca] in list.iter() { + r += cr; + g += cg; + b += cb; + a += ca; } - r /= list.len() as f32; - g /= list.len() as f32; - b /= list.len() as f32; - a /= list.len() as f32; + let len = list.len() as f32; + let [r, g, b, a] = [r / len, g / len, b / len, a / len]; - Color::from_rgbaf32(r, g, b, a).map(Item::new_from_element).into_iter() + // Reject NaN/out-of-range averages, then lift the gamma-space bin centroid to linear-light + let in_gamut = a <= 1. && ![r, g, b, a].iter().any(|c| c.is_sign_negative() || !c.is_finite()); + in_gamut.then(|| Color::from_gamma_srgb_channels(r, g, b, a)).map(Item::new_from_element).into_iter() }) .collect() }