From a35143586a62607c0eee10f865e1663c0f22ed7d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 6 Aug 2026 02:40:25 -0700 Subject: [PATCH] Add the "Perceptual" and "Classic" families of gradient spaces with OkLab as the new default (#4415) * Add an OkLab gradient interpolation space and make it the new default * Fix the Properties panel fill row resetting the gradient interpolation space to the default * Add OkLch, Lab, LCh, and HSL gradient interpolation spaces * Add a hue direction attribute and picker choice for polar gradient interpolation spaces * Add an HSV gradient interpolation space * Rename the sRGB Linear and sRGB Gamma gradient spaces to RGB Linear and RGB Gamma * Divide the gradient interpolation dropdown between absolute and relative color spaces * Rename the OkLch gradient interpolation space to OkLCh for channel-notation capitalization * Shorten the color picker popover's hue direction row label to Arc * Call the Gradient Interpolation node's parameter Space and extend the picker tooltip title to match * Rename the gradient interpolation attribute and type family to gradient space * Rename the gradient tool's gradient space transform to gradient to viewport transform * Add serde aliases and a node replacement covering the gradient space renames * Give the gradient space choices artist-facing labels and reorder the dropdown sections * Add a Gradient Hue Direction node and its attribute read node * Say interpolate instead of blend for gradient stop color traversal in docs and tooltips * Label the polar perceptual gradient spaces as Perceptual Hue and group the dropdown sections by geometry * Add a migration alias covering the gradient space attribute reader rename * Carry the gradient hue direction attribute through boolean operations * Register GradientHueDirection wire types in the node registry --- Cargo.lock | 1 + .../color_picker/color_picker_message.rs | 8 +- .../color_picker_message_handler.rs | 77 ++- .../messages/layout/layout_message_handler.rs | 2 +- .../utility_types/widgets/input_widgets.rs | 9 +- .../data_panel/data_panel_message_handler.rs | 10 +- .../graph_operation_message.rs | 13 +- .../graph_operation_message_handler.rs | 108 ++-- .../document/graph_operation/utility_types.rs | 36 +- .../document/node_graph/node_properties.rs | 38 +- .../storage_tests/round_trip_tests.rs | 12 +- .../messages/portfolio/document_migration.rs | 11 + .../graph_modification_utils.rs | 27 +- .../tool/tool_messages/gradient_tool.rs | 145 +++--- node-graph/graph-craft/src/document/value.rs | 28 +- node-graph/libraries/graphic-types/src/lib.rs | 8 +- .../libraries/rendering/src/render_ext.rs | 11 +- .../libraries/rendering/src/renderer.rs | 170 +++--- node-graph/libraries/vector-types/Cargo.toml | 1 + .../libraries/vector-types/src/gradient.rs | 490 ++++++++++++++---- node-graph/libraries/vector-types/src/lib.rs | 2 +- .../libraries/vector-types/src/markers.rs | 12 +- .../vector-types/src/vector/style.rs | 2 +- node-graph/nodes/graphic/src/graphic.rs | 6 +- node-graph/nodes/math/src/lib.rs | 29 +- node-graph/nodes/raster/src/blending_nodes.rs | 6 +- node-graph/nodes/raster/src/gradient_map.rs | 5 +- node-graph/nodes/vector/src/vector_nodes.rs | 25 +- node-graph/preprocessor/src/lib.rs | 4 +- 29 files changed, 886 insertions(+), 410 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0fa504c53..f2d8864844 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6601,6 +6601,7 @@ version = "0.1.0" dependencies = [ "bitflags 2.11.0", "bytemuck", + "color", "core-types", "dyn-any", "fixedbitset", diff --git a/editor/src/messages/color_picker/color_picker_message.rs b/editor/src/messages/color_picker/color_picker_message.rs index 782a15a0c3..dcaef4a00a 100644 --- a/editor/src/messages/color_picker/color_picker_message.rs +++ b/editor/src/messages/color_picker/color_picker_message.rs @@ -1,6 +1,6 @@ use crate::messages::layout::utility_types::widgets::input_widgets::{SpectrumInputUpdate, VisualColorPickersInputUpdate}; use crate::messages::prelude::*; -use graphene_std::vector::style::{FillChoice, GradientInterpolation, GradientSpread}; +use graphene_std::vector::style::{FillChoice, GradientHueDirection, GradientSpace, GradientSpread}; /// Identifies which RGB channel a numeric input change targets. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -49,8 +49,10 @@ pub enum ColorPickerMessage { GradientUpdate { update: SpectrumInputUpdate }, /// Gradient spread choice from the gradient "Ends" selection. SetGradientSpread { gradient_spread: GradientSpread }, - /// Gradient interpolation choice: the color space the stops blend in, from the "Space" dropdown. - SetGradientInterpolation { gradient_interpolation: GradientInterpolation }, + /// Gradient space choice: the color space the stops interpolate in, from the "Space" dropdown. + SetGradientSpace { gradient_space: GradientSpace }, + /// Gradient hue direction choice: which way around the hue wheel the stops interpolate in a polar space, from the "Arc" dropdown. + SetGradientHueDirection { gradient_hue_direction: GradientHueDirection }, /// Tell the frontend to start an undo transaction (forwarded as a `FrontendMessage` it bridges out to the picker's parent). StartTransaction, 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 76b8fc306d..772a60eecc 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -5,7 +5,7 @@ use crate::messages::prelude::*; use graphene_std::Color; use graphene_std::color::SRGBA8; use graphene_std::core_types::misc::parse_css_color; -use graphene_std::vector::style::{FillChoice, Gradient, GradientInterpolation, GradientRamp, GradientSpread, GradientStops}; +use graphene_std::vector::style::{FillChoice, Gradient, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, GradientStops}; /// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops). const MIN_MIDPOINT: f64 = 0.01; @@ -30,7 +30,8 @@ pub struct ColorPickerMessageHandler { // When set, the picker is editing a gradient: the visual pickers and inputs target the active stop's color. gradient: Option, gradient_spread: GradientSpread, - gradient_interpolation: GradientInterpolation, + gradient_space: GradientSpace, + gradient_hue_direction: GradientHueDirection, active_marker_index: Option, active_marker_is_midpoint: bool, @@ -53,7 +54,8 @@ impl Default for ColorPickerMessageHandler { old_is_none: true, gradient: None, gradient_spread: GradientSpread::default(), - gradient_interpolation: GradientInterpolation::default(), + gradient_space: GradientSpace::default(), + gradient_hue_direction: GradientHueDirection::default(), active_marker_index: None, active_marker_is_midpoint: false, allow_none: true, @@ -75,14 +77,16 @@ impl MessageHandler for ColorPickerMessageHandler { self.set_new_hsva(0., 0., 0., 1., true); self.gradient = None; self.gradient_spread = GradientSpread::default(); - self.gradient_interpolation = GradientInterpolation::default(); + self.gradient_space = GradientSpace::default(); + self.gradient_hue_direction = GradientHueDirection::default(); self.active_marker_index = None; self.active_marker_is_midpoint = false; } FillChoice::Solid(color) => { self.gradient = None; self.gradient_spread = GradientSpread::default(); - self.gradient_interpolation = GradientInterpolation::default(); + self.gradient_space = GradientSpace::default(); + self.gradient_hue_direction = GradientHueDirection::default(); self.active_marker_index = None; self.active_marker_is_midpoint = false; self.adopt_color(color); @@ -91,7 +95,8 @@ impl MessageHandler for ColorPickerMessageHandler { self.active_marker_index = Some(0); self.active_marker_is_midpoint = false; self.gradient_spread = ramp.gradient_spread; - self.gradient_interpolation = ramp.gradient_interpolation; + self.gradient_space = ramp.gradient_space; + self.gradient_hue_direction = ramp.gradient_hue_direction; let gradient = Gradient::from(ramp); let first_color = gradient.color(0).unwrap_or(Color::BLACK); self.gradient = Some(gradient); @@ -204,20 +209,36 @@ impl MessageHandler for ColorPickerMessageHandler { responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(GradientRamp { gradient_spread, - gradient_interpolation: self.gradient_interpolation, + gradient_space: self.gradient_space, + gradient_hue_direction: self.gradient_hue_direction, ..GradientRamp::from(gradient) }), }); self.send_layouts(responses); } - ColorPickerMessage::SetGradientInterpolation { gradient_interpolation } => { + ColorPickerMessage::SetGradientSpace { gradient_space } => { let Some(gradient) = &self.gradient else { return }; responses.add(FrontendMessage::ColorPickerStartHistoryTransaction); - self.gradient_interpolation = gradient_interpolation; + self.gradient_space = gradient_space; responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(GradientRamp { gradient_spread: self.gradient_spread, - gradient_interpolation, + gradient_space, + gradient_hue_direction: self.gradient_hue_direction, + ..GradientRamp::from(gradient) + }), + }); + self.send_layouts(responses); + } + ColorPickerMessage::SetGradientHueDirection { gradient_hue_direction } => { + let Some(gradient) = &self.gradient else { return }; + responses.add(FrontendMessage::ColorPickerStartHistoryTransaction); + self.gradient_hue_direction = gradient_hue_direction; + responses.add(FrontendMessage::ColorPickerColorChanged { + value: FillChoice::Gradient(GradientRamp { + gradient_spread: self.gradient_spread, + gradient_space: self.gradient_space, + gradient_hue_direction, ..GradientRamp::from(gradient) }), }); @@ -309,7 +330,8 @@ impl ColorPickerMessageHandler { responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(GradientRamp { gradient_spread: self.gradient_spread, - gradient_interpolation: self.gradient_interpolation, + gradient_space: self.gradient_space, + gradient_hue_direction: self.gradient_hue_direction, ..GradientRamp::from(&*gradient) }), }); @@ -368,7 +390,7 @@ impl ColorPickerMessageHandler { gradient.set_midpoint(index as usize, position.clamp(MIN_MIDPOINT, MAX_MIDPOINT)); } SpectrumInputUpdate::InsertMarker { position } => { - let new_index = gradient.insert_stop(position, self.gradient_interpolation); + let new_index = gradient.insert_stop(position, self.gradient_space, self.gradient_hue_direction); self.active_marker_index = Some(new_index as u32); self.active_marker_is_midpoint = false; if let Some(color) = gradient.color(new_index) { @@ -440,7 +462,8 @@ impl ColorPickerMessageHandler { responses.add(FrontendMessage::ColorPickerColorChanged { value: FillChoice::Gradient(GradientRamp { gradient_spread: self.gradient_spread, - gradient_interpolation: self.gradient_interpolation, + gradient_space: self.gradient_space, + gradient_hue_direction: self.gradient_hue_direction, ..GradientRamp::from(&gradient) }), }); @@ -471,7 +494,8 @@ impl ColorPickerMessageHandler { let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect(); let mut row_widgets = vec![ SpectrumInput::new(GradientStops::from(gradient)) - .track_interpolation(self.gradient_interpolation) + .track_space(self.gradient_space) + .track_hue_direction(self.gradient_hue_direction) .markers(markers) .active_marker_index(self.active_marker_index) .active_marker_is_midpoint(self.active_marker_is_midpoint) @@ -654,15 +678,29 @@ impl ColorPickerMessageHandler { ])); } - // Gradient interpolation color space (only present when the picker is in gradient mode) + // Gradient color space (only present when the picker is in gradient mode) if self.gradient.is_some() { - let entries = MenuListEntry::sections_from_choice_type(|gradient_interpolation| ColorPickerMessage::SetGradientInterpolation { gradient_interpolation }.into()); + let entries = MenuListEntry::sections_from_choice_type(|gradient_space| ColorPickerMessage::SetGradientSpace { gradient_space }.into()); groups.push(LayoutGroup::row(vec![ - TextLabel::new("Space").tooltip_label("Gradient Interpolation").tooltip_description(SPACE_DESCRIPTION).widget_instance(), + TextLabel::new("Space").tooltip_label("Gradient Space").tooltip_description(SPACE_DESCRIPTION).widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + DropdownInput::new(entries).selected_index(Some(self.gradient_space as u32)).disabled(self.disabled).widget_instance(), + ])); + } + + // Gradient hue direction (only present when the chosen space is polar) + if self.gradient.is_some() && self.gradient_space.is_polar() { + let entries = MenuListEntry::sections_from_choice_type(|gradient_hue_direction| ColorPickerMessage::SetGradientHueDirection { gradient_hue_direction }.into()); + + groups.push(LayoutGroup::row(vec![ + TextLabel::new("Arc") + .tooltip_label("Gradient Hue Direction") + .tooltip_description(HUE_DIRECTION_DESCRIPTION) + .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), DropdownInput::new(entries) - .selected_index(Some(self.gradient_interpolation as u32)) + .selected_index(Some(self.gradient_hue_direction as u32)) .disabled(self.disabled) .widget_instance(), ])); @@ -722,7 +760,8 @@ 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%)."; const ENDS_DESCRIPTION: &str = "The method for how the gradient continues beyond its ends."; -const SPACE_DESCRIPTION: &str = "The color space where stops blend into their neighbors."; +const SPACE_DESCRIPTION: &str = "The color space where stops interpolate toward their neighbors."; +const HUE_DIRECTION_DESCRIPTION: &str = "Which way around the hue wheel the stops interpolate."; /// 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. diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 3c3687516a..54c1f1d967 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -531,7 +531,7 @@ fn populate_computed_display_fields(layout: &mut Layout) { color_input.chosen_gradient = color_input.value.to_css_background_image(); } Widget::SpectrumInput(spectrum_input) => { - spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient(spectrum_input.track_interpolation); + spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient(spectrum_input.track_space, spectrum_input.track_hue_direction); 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()); } 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 58bb26f1c9..fdd320e885 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -7,7 +7,7 @@ use derivative::*; use graphene_std::Color; use graphene_std::color::SRGBA8; use graphene_std::transform::ReferencePoint; -use graphene_std::vector::style::{FillChoice, GradientInterpolation, GradientStops}; +use graphene_std::vector::style::{FillChoice, GradientHueDirection, GradientSpace, GradientStops}; use graphite_proc_macros::WidgetBuilder; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -586,9 +586,12 @@ pub struct SpectrumInput { /// The colored gradient drawn behind the markers (display-only, caller-owned). #[widget_builder(constructor)] pub track: GradientStops, - /// The interpolation color space the track's stops blend in, used to compute `track_css`. Not sent to the frontend. + /// The color space the track's stops interpolate in, used to compute `track_css`. Not sent to the frontend. #[serde(skip)] - pub track_interpolation: GradientInterpolation, + pub track_space: GradientSpace, + /// The hue direction the track's stops interpolate with in a polar space, used to compute `track_css`. Not sent to the frontend. + #[serde(skip)] + pub track_hue_direction: GradientHueDirection, /// 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)] 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 df3bcb5505..12389f84f8 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 @@ -23,7 +23,7 @@ use graphene_std::transform::{ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; -use graphene_std::vector::style::{DashPattern, FillChoice, GradientForm, GradientInterpolation, GradientRamp, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{DashPattern, FillChoice, GradientForm, GradientRamp, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::{Artboard, Color, Graphic}; use std::any::Any; @@ -218,7 +218,7 @@ fn generate_layout(introspected_data: &Arc, List, List, - List, + List, List, List, List, @@ -271,7 +271,7 @@ fn generate_layout(introspected_data: &Arc> for gradient, gradient_form, gradient_spread, - gradient_interpolation, + gradient_space, + gradient_hue_direction, transform, } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { - modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_interpolation, transform); + modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, gradient_hue_direction, transform); } } GraphOperationMessage::BlendingFillSet { layer, fill } => { @@ -92,9 +93,14 @@ impl MessageHandler> for modify_inputs.gradient_spread_set(gradient_spread); } } - GraphOperationMessage::GradientInterpolationSet { layer, gradient_interpolation } => { + GraphOperationMessage::GradientSpaceSet { layer, gradient_space } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { - modify_inputs.gradient_interpolation_set(gradient_interpolation); + modify_inputs.gradient_space_set(gradient_space); + } + } + GraphOperationMessage::GradientHueDirectionSet { layer, gradient_hue_direction } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.gradient_hue_direction_set(gradient_hue_direction); } } GraphOperationMessage::OpacitySet { layer, opacity } => { @@ -489,7 +495,7 @@ impl MessageHandler> for let gradient_info = SvgGradientInfo { graphite_stops: extract_graphite_gradient_stops(&svg), - interpolations: extract_gradient_interpolations(&svg), + spaces: extract_gradient_spaces(&svg), }; // Pass identity so each leaf layer receives only its SVG-native transform from `abs_transform`. @@ -530,14 +536,14 @@ const GRAPHITE_NAMESPACE: &str = "https://graphite.art"; struct SvgGradientInfo { /// Real stops, keyed by gradient element `id`, for gradients Graphite exported with midpoint curve data. graphite_stops: HashMap, - /// Interpolation spaces, keyed by gradient element `id`, resolved from the `color-interpolation` property. - interpolations: HashMap, + /// Gradient spaces, keyed by gradient element `id`, resolved from the `color-interpolation` property. + spaces: HashMap, } /// Pre-parses the raw SVG XML to resolve each gradient's inherited `color-interpolation` property, which usvg's -/// tree does not carry. Only `linearRGB` selects linear interpolation; `auto` and `sRGB` (browsers treat the +/// tree does not carry. Only `linearRGB` selects the linear space; `auto` and `sRGB` (browsers treat the /// user-agent-defined `auto` as `sRGB`) mean gamma, as does any unrecognized value. -fn extract_gradient_interpolations(svg: &str) -> HashMap { +fn extract_gradient_spaces(svg: &str) -> HashMap { let mut result = HashMap::new(); // Quick check: gradients in an SVG that never mentions `color-interpolation` all take the sRGB default @@ -568,9 +574,9 @@ fn extract_gradient_interpolations(svg: &str) -> HashMap HashMap Option { +fn resolve_color_interpolation(element: usvg::roxmltree::Node, stylesheet: &simplecss::StyleSheet) -> Option { let mut next = Some(element); while let Some(element) = next { match declared_color_interpolation(element, stylesheet) { - Some("linearRGB") => return Some(GradientInterpolation::SrgbLinear), + Some("linearRGB") => return Some(GradientSpace::RgbLinear), // `inherit` defers to the ancestors like an undeclared element Some("inherit") | None => {} - Some(_) => return Some(GradientInterpolation::SrgbGamma), + Some(_) => return Some(GradientSpace::RgbGamma), } next = element.parent_element(); @@ -971,8 +977,8 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g }; let gradient_spread = convert_gradient_spread(linear.spread_method()); // SVG interpolates between stops in gamma sRGB unless `color-interpolation` opts into linearRGB, carried explicitly rather than as the linear default - let gradient_interpolation = gradient_info.interpolations.get(linear.id()).copied().unwrap_or(GradientInterpolation::SrgbGamma); - modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_interpolation, transform); + let gradient_space = gradient_info.spaces.get(linear.id()).copied().unwrap_or(GradientSpace::RgbGamma); + modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, Default::default(), transform); } usvg::Paint::RadialGradient(radial) => { let gradient_transform = usvg_transform(radial.transform()); @@ -996,9 +1002,9 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g } }; let gradient_spread = convert_gradient_spread(radial.spread_method()); - let gradient_interpolation = gradient_info.interpolations.get(radial.id()).copied().unwrap_or(GradientInterpolation::SrgbGamma); + let gradient_space = gradient_info.spaces.get(radial.id()).copied().unwrap_or(GradientSpace::RgbGamma); - modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_interpolation, transform); + modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, Default::default(), transform); } usvg::Paint::Pattern(_) => warn!("SVG patterns are not currently supported"), }; @@ -1019,25 +1025,13 @@ mod tests { "##; - let interpolations = extract_gradient_interpolations(svg); + let spaces = extract_gradient_spaces(svg); + assert_eq!(spaces.get("inherited"), Some(&GradientSpace::RgbLinear), "an undeclared gradient should inherit from its ancestors"); + assert_eq!(spaces.get("attribute"), Some(&GradientSpace::RgbGamma), "an sRGB declaration should beat the inherited linearRGB"); + assert_eq!(spaces.get("styled"), Some(&GradientSpace::RgbLinear), "the inline style should beat the presentation attribute"); assert_eq!( - interpolations.get("inherited"), - Some(&GradientInterpolation::SrgbLinear), - "an undeclared gradient should inherit from its ancestors" - ); - assert_eq!( - interpolations.get("attribute"), - Some(&GradientInterpolation::SrgbGamma), - "an sRGB declaration should beat the inherited linearRGB" - ); - assert_eq!( - interpolations.get("styled"), - Some(&GradientInterpolation::SrgbLinear), - "the inline style should beat the presentation attribute" - ); - assert_eq!( - interpolations.get("auto"), - Some(&GradientInterpolation::SrgbGamma), + spaces.get("auto"), + Some(&GradientSpace::RgbGamma), "auto should mean gamma like browsers treat it, not defer to ancestors" ); } @@ -1059,26 +1053,18 @@ mod tests { "##; - let interpolations = extract_gradient_interpolations(svg); + let spaces = extract_gradient_spaces(svg); + assert_eq!(spaces.get("from-type-rule"), Some(&GradientSpace::RgbLinear), "a type rule in a style block should reach the gradient"); assert_eq!( - interpolations.get("from-type-rule"), - Some(&GradientInterpolation::SrgbLinear), - "a type rule in a style block should reach the gradient" - ); - assert_eq!( - interpolations.get("from-class-rule"), - Some(&GradientInterpolation::SrgbGamma), + spaces.get("from-class-rule"), + Some(&GradientSpace::RgbGamma), "the class rule should outrank the type rule by specificity" ); - assert_eq!(interpolations.get("exact"), Some(&GradientInterpolation::SrgbLinear), "the ID rule should outrank the class rule"); + assert_eq!(spaces.get("exact"), Some(&GradientSpace::RgbLinear), "the ID rule should outrank the class rule"); + assert_eq!(spaces.get("inline-beats-rules"), Some(&GradientSpace::RgbLinear), "the inline style should beat every style block rule"); assert_eq!( - interpolations.get("inline-beats-rules"), - Some(&GradientInterpolation::SrgbLinear), - "the inline style should beat every style block rule" - ); - assert_eq!( - interpolations.get("rule-beats-attribute"), - Some(&GradientInterpolation::SrgbGamma), + spaces.get("rule-beats-attribute"), + Some(&GradientSpace::RgbGamma), "a style block rule should beat the presentation attribute" ); } @@ -1092,20 +1078,20 @@ mod tests { "##; - let interpolations = extract_gradient_interpolations(svg); + let spaces = extract_gradient_spaces(svg); assert_eq!( - interpolations.get("last-declaration-wins"), - Some(&GradientInterpolation::SrgbLinear), + spaces.get("last-declaration-wins"), + Some(&GradientSpace::RgbLinear), "the last of repeated inline declarations should win" ); assert_eq!( - interpolations.get("important-beats-later"), - Some(&GradientInterpolation::SrgbLinear), + spaces.get("important-beats-later"), + Some(&GradientSpace::RgbLinear), "an `!important` declaration should beat a later normal one" ); assert_eq!( - interpolations.get("important-rule-beats-inline"), - Some(&GradientInterpolation::SrgbLinear), + spaces.get("important-rule-beats-inline"), + Some(&GradientSpace::RgbLinear), "an `!important` style block rule should beat the inline style" ); } @@ -1115,7 +1101,7 @@ mod tests { let svg = r##""##; assert!( - extract_gradient_interpolations(svg).is_empty(), + extract_gradient_spaces(svg).is_empty(), "gradients without any declaration should fall back to the caller's gamma default" ); } diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index f9f2bf6d61..79358ca27a 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -19,7 +19,7 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; -use graphene_std::vector::style::{GradientForm, GradientInterpolation, GradientSpread, Stroke}; +use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientSpace, GradientSpread, Stroke}; use graphene_std::vector::{Gradient, GradientRamp, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; @@ -433,7 +433,15 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(fill_value, false), false); } - pub fn fill_gradient_set(&mut self, gradient: Gradient, gradient_form: GradientForm, gradient_spread: GradientSpread, gradient_interpolation: GradientInterpolation, transform: DAffine2) { + pub fn fill_gradient_set( + &mut self, + gradient: Gradient, + gradient_form: GradientForm, + gradient_spread: GradientSpread, + gradient_space: GradientSpace, + gradient_hue_direction: GradientHueDirection, + transform: DAffine2, + ) { let Some(fill_node_id) = self.existing_proto_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true) else { return; }; @@ -442,7 +450,8 @@ impl<'a> ModifyInputsContext<'a> { let ramp = GradientRamp::from(gradient); let ramp = GradientRamp { gradient_spread, - gradient_interpolation, + gradient_space, + gradient_hue_direction, ..ramp }; self.set_input_with_refresh(backup_input_connector, NodeInput::value(TaggedValue::GradientRamp(ramp.clone()), false), true); @@ -769,16 +778,29 @@ impl<'a> ModifyInputsContext<'a> { Some(ramp.clone()) } - /// Set the interpolation on the chain's gradient value, which is where the ramp carries it. Never touches a - /// 'Gradient Interpolation' node: that one is a user-authored procedural override, not something the tools manage. - pub fn gradient_interpolation_set(&mut self, gradient_interpolation: GradientInterpolation) { + /// Set the space on the chain's gradient value, which is where the ramp carries it. Never touches a + /// 'Gradient Space' node: that one is a user-authored procedural override, not something the tools manage. + pub fn gradient_space_set(&mut self, gradient_space: GradientSpace) { let Some(output_layer) = self.get_output_layer() else { return }; let Some(gradient_value_id) = get_upstream_gradient_value_node_id(output_layer, self.network_interface) else { return; }; let Some(ramp) = self.gradient_value_ramp(gradient_value_id) else { return }; - let ramp = GradientRamp { gradient_interpolation, ..ramp }; + let ramp = GradientRamp { gradient_space, ..ramp }; + let input_connector = InputConnector::node(gradient_value_id, graphene_std::math_nodes::gradient_value::GradientInput); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientRamp(ramp), false), false); + } + + /// Set the hue direction on the chain's gradient value, which is where the ramp carries it. + pub fn gradient_hue_direction_set(&mut self, gradient_hue_direction: GradientHueDirection) { + let Some(output_layer) = self.get_output_layer() else { return }; + let Some(gradient_value_id) = get_upstream_gradient_value_node_id(output_layer, self.network_interface) else { + return; + }; + let Some(ramp) = self.gradient_value_ramp(gradient_value_id) else { return }; + + let ramp = GradientRamp { gradient_hue_direction, ..ramp }; let input_connector = InputConnector::node(gradient_value_id, graphene_std::math_nodes::gradient_value::GradientInput); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientRamp(ramp), false), false); } 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 f503f1884b..7292b3314b 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -34,7 +34,8 @@ use graphene_std::vector::misc::{ ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; use graphene_std::vector::style::{ - FillChoice, Gradient, GradientForm, GradientInterpolation, GradientRamp, GradientSpread, GradientStops, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation, + FillChoice, Gradient, GradientForm, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, GradientStops, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, + build_transform_with_y_preservation, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::{NodeParameter, ParameterRef}; @@ -294,7 +295,8 @@ pub(crate) fn property_from_type( // ========================= Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), - Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), @@ -1390,7 +1392,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(GradientStops::from(&bw_track())) - .track_interpolation(GradientInterpolation::SrgbGamma) + .track_space(GradientSpace::RgbGamma) .markers(spectrum_markers) .show_midpoints(false) .allow_insert(false) @@ -1563,7 +1565,7 @@ fn spectrum_slider_row( let position_to_value = move |position: f64| value_min + position * value_range; row.push( SpectrumInput::new(GradientStops::from(&track)) - .track_interpolation(GradientInterpolation::SrgbGamma) + .track_space(GradientSpace::RgbGamma) .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) .show_midpoints(false) .allow_insert(false) @@ -2392,7 +2394,7 @@ fn root_layer_for_chain_node(node_id: NodeId, context: &mut NodePropertiesContex /// and reusing the same helper the Gradient tool uses, so canvas tilt and layer transforms behave identically. fn gradient_orientation_in_fill_node(node_id: NodeId, gradient_transform: DAffine2, context: &mut NodePropertiesContext) -> Option { let layer = root_layer_for_chain_node(node_id, context)?; - let transform = graph_modification_utils::gradient_space_transform(layer, context.network_interface); + let transform = graph_modification_utils::gradient_to_viewport_transform(layer, context.network_interface); Some(graph_modification_utils::gradient_orientation_rightward(transform * gradient_transform)) } @@ -2407,6 +2409,8 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte gradient: Gradient, gradient_form: GradientForm, gradient_spread: GradientSpread, + gradient_space: GradientSpace, + gradient_hue_direction: GradientHueDirection, transform: DAffine2, /// Whether the transform input holds a plain value (so the "Reverse Direction" button may write to it) rather than a wire. transform_is_value: bool, @@ -2437,6 +2441,8 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte gradient: gradient.stops, gradient_form: gradient.gradient_form, gradient_spread: gradient.gradient_spread, + gradient_space: gradient.gradient_space, + gradient_hue_direction: gradient.gradient_hue_direction, transform: gradient.transform, transform_is_value: gradient.transform_is_value, }, @@ -2464,9 +2470,17 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte }; match &fill { - ResolvedFill::Gradient { gradient: stops, gradient_spread, .. } => { + ResolvedFill::Gradient { + gradient: stops, + gradient_spread, + gradient_space, + gradient_hue_direction, + .. + } => { let stops = stops.clone(); let gradient_spread = *gradient_spread; + let gradient_space = *gradient_space; + let gradient_hue_direction = *gradient_hue_direction; let reverse_button = IconButton::new("Reverse", 24) .tooltip_label("Reverse Stops") @@ -2475,6 +2489,8 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte move |_| { TaggedValue::GradientRamp(GradientRamp { gradient_spread, + gradient_space, + gradient_hue_direction, ..GradientRamp::from(stops.reversed()) }) }, @@ -2496,8 +2512,16 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte FillChoice::::None } } - ResolvedFill::Gradient { gradient: stops, gradient_spread, .. } => FillChoice::::Gradient(GradientRamp { + ResolvedFill::Gradient { + gradient: stops, + gradient_spread, + gradient_space, + gradient_hue_direction, + .. + } => FillChoice::::Gradient(GradientRamp { gradient_spread: *gradient_spread, + gradient_space: *gradient_space, + gradient_hue_direction: *gradient_hue_direction, ..GradientRamp::from(stops) }), ResolvedFill::Other => FillChoice::::None, diff --git a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs index 1f5157d537..8d0c9b4700 100644 --- a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs +++ b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs @@ -808,8 +808,8 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() { panic!("the legacy stops parameter should become a gradient ramp value, but became {stops:?}"); }; assert_eq!( - ramp.gradient_interpolation, - graphene_std::vector::style::GradientInterpolation::SrgbGamma, + ramp.gradient_space, + graphene_std::vector::style::GradientSpace::RgbGamma, "a legacy document's ramps should deserialize with the explicit gamma interpolation" ); let stops = graphene_std::vector::Gradient::from(ramp); @@ -850,8 +850,8 @@ async fn eight_input_fill_migrates_the_spread_input_into_the_ramp() { }; assert_eq!(ramp.gradient_spread, GradientSpread::Repeat, "the spread input's value should fold into the fill ramp"); assert_eq!( - ramp.gradient_interpolation, - graphene_std::vector::style::GradientInterpolation::SrgbGamma, + ramp.gradient_space, + graphene_std::vector::style::GradientSpace::RgbGamma, "a legacy document's ramps should deserialize with the explicit gamma interpolation" ); @@ -861,8 +861,8 @@ async fn eight_input_fill_migrates_the_spread_input_into_the_ramp() { }; assert_eq!(backup_ramp.gradient_spread, GradientSpread::Repeat, "the spread input's value should fold into the backup ramp"); assert_eq!( - backup_ramp.gradient_interpolation, - graphene_std::vector::style::GradientInterpolation::SrgbGamma, + backup_ramp.gradient_space, + graphene_std::vector::style::GradientSpace::RgbGamma, "the backup ramp should carry the explicit gamma interpolation too" ); diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index c59ef1b2ac..a0eccb3b01 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -172,6 +172,13 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::graphic::read_gradient_spread_attribute::IDENTIFIER, aliases: &["graphic_nodes::graphic::ReadAttributeSpreadMethodNode", "graphic_nodes::graphic::ReadSpreadMethodAttributeNode"], }, + NodeReplacement { + node: graphene_std::graphic::read_gradient_space_attribute::IDENTIFIER, + aliases: &[ + "graphic_nodes::graphic::ReadAttributeGradientInterpolationNode", + "graphic_nodes::graphic::ReadGradientInterpolationAttributeNode", + ], + }, NodeReplacement { node: graphene_std::list::remove_at_index::IDENTIFIER, aliases: &["graphic_nodes::graphic::OmitElementNode", "graphic_nodes::graphic::RemoveAtIndexNode"], @@ -273,6 +280,10 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::math_nodes::gradient_form::IDENTIFIER, aliases: &["math_nodes::GradientTypeNode"], }, + NodeReplacement { + node: graphene_std::math_nodes::gradient_space::IDENTIFIER, + aliases: &["math_nodes::GradientInterpolationNode"], + }, NodeReplacement { node: graphene_std::math_nodes::gradient_spread::IDENTIFIER, aliases: &["math_nodes::SpreadMethodNode"], diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 3585ce7b37..85dd0f8a71 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -14,7 +14,7 @@ use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::misc::ManipulatorPointId; use graphene_std::vector::style::{FillChoice, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, initial_gradient_transform_for_bounding_box}; -use graphene_std::vector::{Gradient, GradientForm, GradientInterpolation, GradientRamp, GradientSpread, PointId, SegmentId, VectorModificationType}; +use graphene_std::vector::{Gradient, GradientForm, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, PointId, SegmentId, VectorModificationType}; use graphene_std::{NodeParameter, ParameterRef}; use std::collections::VecDeque; @@ -402,9 +402,14 @@ pub fn get_chain_source_gradient_spread(layer: LayerNodeIdentifier, network_inte Some(get_chain_source_gradient_ramp(layer, network_interface)?.gradient_spread) } -/// The interpolation baked into the 'Gradient Value' node feeding a layer's chain. -pub fn get_chain_source_gradient_interpolation(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { - Some(get_chain_source_gradient_ramp(layer, network_interface)?.gradient_interpolation) +/// The space baked into the 'Gradient Value' node feeding a layer's chain. +pub fn get_chain_source_gradient_space(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + Some(get_chain_source_gradient_ramp(layer, network_interface)?.gradient_space) +} + +/// The hue direction baked into the 'Gradient Value' node feeding a layer's chain. +pub fn get_chain_source_gradient_hue_direction(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + Some(get_chain_source_gradient_ramp(layer, network_interface)?.gradient_hue_direction) } /// Get the gradient stops of a layer, if any. @@ -466,7 +471,7 @@ pub fn get_gradient_stops(layer: LayerNodeIdentifier, network_interface: &NodeNe /// Compute the transform from a gradient's local space to viewport space for the given layer. For a `List` /// layer this is the layer's incoming footprint transform; for a Fill-owned gradient value it composes the layer's viewport /// transform with the [0,1]² → bounding-box mapping. -pub fn gradient_space_transform(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> glam::DAffine2 { +pub fn gradient_to_viewport_transform(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> glam::DAffine2 { use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; let metadata = network_interface.document_metadata(); @@ -762,7 +767,8 @@ pub struct FillNodeGradient { pub stops: Gradient, pub gradient_form: GradientForm, pub gradient_spread: GradientSpread, - pub gradient_interpolation: GradientInterpolation, + pub gradient_space: GradientSpace, + pub gradient_hue_direction: GradientHueDirection, pub transform: DAffine2, /// Whether the transform input holds a plain value (so it may be written to) rather than a wire. pub transform_is_value: bool, @@ -776,7 +782,8 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn return None; }; let gradient_spread = ramp.gradient_spread; - let gradient_interpolation = ramp.gradient_interpolation; + let gradient_space = ramp.gradient_space; + let gradient_hue_direction = ramp.gradient_hue_direction; let stops = Gradient::from(ramp); let gradient_form = match fill_node.input(fill::GradientFormInput).and_then(|input| input.as_value()) { Some(&TaggedValue::GradientForm(value)) => value, @@ -794,7 +801,8 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn stops, gradient_form, gradient_spread, - gradient_interpolation, + gradient_space, + gradient_hue_direction, transform, transform_is_value: transform_input.is_some(), }) @@ -944,7 +952,8 @@ pub fn set_fill_for_selected_layers(fill_choice: FillChoice, document: &Document gradient: Gradient::from(ramp), gradient_form, gradient_spread: ramp.gradient_spread, - gradient_interpolation: ramp.gradient_interpolation, + gradient_space: ramp.gradient_space, + gradient_hue_direction: ramp.gradient_hue_direction, transform, }); } diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 7a5a545aea..e91ea0e919 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -9,15 +9,15 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, NodeNetworkInterface}; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::graph_modification_utils::{ - self, NodeGraphLayer, get_chain_source_gradient_interpolation, get_chain_source_gradient_spread, get_fill_node_id_with_direct_fill_input, get_gradient_stops, get_upstream_gradient_value_node_id, - gradient_chain_target_input, replaceable_paint_chain, reverse_direction_tooltip_description, + self, NodeGraphLayer, get_chain_source_gradient_hue_direction, get_chain_source_gradient_space, get_chain_source_gradient_spread, get_fill_node_id_with_direct_fill_input, get_gradient_stops, + get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, reverse_direction_tooltip_description, }; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; use glam::DMat2; use graph_craft::document::value::TaggedValue; use graphene_std::color::SRGBA8; use graphene_std::raster::color::Color; -use graphene_std::vector::style::{FillChoice, Gradient, GradientForm, GradientInterpolation, GradientRamp, GradientSpread, GradientStop, build_transform_with_y_preservation}; +use graphene_std::vector::style::{FillChoice, Gradient, GradientForm, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, GradientStop, build_transform_with_y_preservation}; #[derive(Default, ExtractField)] pub struct GradientTool { @@ -30,7 +30,8 @@ pub struct GradientTool { pub struct GradientOptions { gradient_form: GradientForm, gradient_spread: GradientSpread, - gradient_interpolation: GradientInterpolation, + gradient_space: GradientSpace, + gradient_hue_direction: GradientHueDirection, } #[impl_message(Message, ToolMessage, Gradient)] @@ -139,8 +140,17 @@ impl<'a> MessageHandler> for Grad ToolMessage::Gradient(GradientToolMessage::UpdateRamp { ramp }) => { let ramp = GradientRamp::from(&ramp); self.options.gradient_spread = ramp.gradient_spread; - self.options.gradient_interpolation = ramp.gradient_interpolation; - apply_stops_update(&mut self.data, context, responses, Gradient::from(&ramp), ramp.gradient_spread, ramp.gradient_interpolation); + self.options.gradient_space = ramp.gradient_space; + self.options.gradient_hue_direction = ramp.gradient_hue_direction; + apply_stops_update( + &mut self.data, + context, + responses, + Gradient::from(&ramp), + ramp.gradient_spread, + ramp.gradient_space, + ramp.gradient_hue_direction, + ); } ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => { if self.data.color_picker_transaction_open { @@ -178,8 +188,12 @@ impl<'a> MessageHandler> for Grad self.options.gradient_spread = appearance.gradient_spread; needs_refresh = true; } - if self.options.gradient_interpolation != appearance.gradient_interpolation { - self.options.gradient_interpolation = appearance.gradient_interpolation; + if self.options.gradient_space != appearance.gradient_space { + self.options.gradient_space = appearance.gradient_space; + needs_refresh = true; + } + if self.options.gradient_hue_direction != appearance.gradient_hue_direction { + self.options.gradient_hue_direction = appearance.gradient_hue_direction; needs_refresh = true; } } @@ -198,7 +212,7 @@ impl<'a> MessageHandler> for Grad let new_orientation = match (current_layer, ¤t_gradient) { (Some(layer), Some((_gradient, appearance))) => { - let transform = gradient_space_transform(layer, context.document) * appearance.transform; + let transform = gradient_to_viewport_transform(layer, context.document) * appearance.transform; graph_modification_utils::gradient_orientation_rightward(transform) } _ => true, @@ -262,7 +276,8 @@ impl LayoutHolder for GradientTool { }); let stops_widget = ColorInput::new(FillChoice::Gradient(GradientRamp { gradient_spread: self.options.gradient_spread, - gradient_interpolation: self.options.gradient_interpolation, + gradient_space: self.options.gradient_space, + gradient_hue_direction: self.options.gradient_hue_direction, ..GradientRamp::from(&stops_value) })) .allow_none(false) @@ -335,8 +350,8 @@ impl Default for GradientToolFsmState { } /// Computes the transform from gradient space to viewport space. -fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 { - graph_modification_utils::gradient_space_transform(layer, &document.network_interface) +fn gradient_to_viewport_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 { + graph_modification_utils::gradient_to_viewport_transform(layer, &document.network_interface) } /// Viewport positions of the gradient's start (unit param 0) and end (unit param 1) handles. @@ -363,7 +378,8 @@ fn resolve_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkI GradientAppearance { gradient_form: gradient.gradient_form, gradient_spread: gradient.gradient_spread, - gradient_interpolation: gradient.gradient_interpolation, + gradient_space: gradient.gradient_space, + gradient_hue_direction: gradient.gradient_hue_direction, transform: gradient.transform, }, GradientSource::Direct, @@ -383,7 +399,8 @@ struct GradientAppearance { transform: DAffine2, gradient_form: GradientForm, gradient_spread: GradientSpread, - gradient_interpolation: GradientInterpolation, + gradient_space: GradientSpace, + gradient_hue_direction: GradientHueDirection, } /// Resolve the gradient transform, form, and spread by walking the chain feeding the layer. @@ -424,7 +441,8 @@ fn read_gradient_chain_state(layer: LayerNodeIdentifier, network_interface: &Nod transform: composed_transform, gradient_form: gradient_form.unwrap_or_default(), gradient_spread: get_chain_source_gradient_spread(layer, network_interface).unwrap_or_default(), - gradient_interpolation: get_chain_source_gradient_interpolation(layer, network_interface).unwrap_or_default(), + gradient_space: get_chain_source_gradient_space(layer, network_interface).unwrap_or_default(), + gradient_hue_direction: get_chain_source_gradient_hue_direction(layer, network_interface).unwrap_or_default(), } } @@ -477,7 +495,7 @@ struct SelectedGradient { layer: Option, dragging: GradientDragTarget, /// Transform from the geometry's local gradient space to viewport space. - gradient_space_transform: DAffine2, + gradient_to_viewport_transform: DAffine2, gradient: Gradient, appearance: GradientAppearance, initial_gradient: Gradient, @@ -526,10 +544,10 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &Gradient, mouse: DVec2) impl SelectedGradient { pub fn new(gradient: Gradient, appearance: GradientAppearance, source: GradientSource, layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> Self { - let gradient_space_transform = gradient_space_transform(layer, document); + let gradient_to_viewport_transform = gradient_to_viewport_transform(layer, document); Self { layer: Some(layer), - gradient_space_transform, + gradient_to_viewport_transform, gradient: gradient.clone(), appearance, dragging: GradientDragTarget::End, @@ -615,7 +633,7 @@ impl SelectedGradient { snap_manager.update_indicator(snapped); } - let local_mouse = self.gradient_space_transform.inverse().transform_point2(mouse); + let local_mouse = self.gradient_to_viewport_transform.inverse().transform_point2(mouse); let local_start = self.appearance.transform.transform_point2(DVec2::ZERO); let local_end = self.appearance.transform.transform_point2(DVec2::X); @@ -630,7 +648,7 @@ impl SelectedGradient { self.appearance.transform = create_new_gradient_transform(local_start, local_mouse); } GradientDragTarget::New => { - self.appearance.transform = create_new_gradient_transform(self.gradient_space_transform.inverse().transform_point2(drag_start), local_mouse); + self.appearance.transform = create_new_gradient_transform(self.gradient_to_viewport_transform.inverse().transform_point2(drag_start), local_mouse); } GradientDragTarget::Stop(stop) => { let document_to_viewport = snap_data.document.metadata().document_to_viewport; @@ -761,7 +779,8 @@ impl SelectedGradient { gradient: self.gradient.clone(), gradient_form: self.appearance.gradient_form, gradient_spread: self.appearance.gradient_spread, - gradient_interpolation: self.appearance.gradient_interpolation, + gradient_space: self.appearance.gradient_space, + gradient_hue_direction: self.appearance.gradient_hue_direction, transform: self.appearance.transform, }); } @@ -769,7 +788,7 @@ impl SelectedGradient { } fn unit_to_viewport_transform(&self) -> DAffine2 { - self.gradient_space_transform * self.appearance.transform + self.gradient_to_viewport_transform * self.appearance.transform } fn viewport_handle_positions(&self) -> (DVec2, DVec2) { @@ -800,9 +819,13 @@ fn dispatch_gradient_chain_writes(layer: LayerNodeIdentifier, gradient: &Gradien layer, gradient_spread: appearance.gradient_spread, }); - responses.add(GraphOperationMessage::GradientInterpolationSet { + responses.add(GraphOperationMessage::GradientSpaceSet { layer, - gradient_interpolation: appearance.gradient_interpolation, + gradient_space: appearance.gradient_space, + }); + responses.add(GraphOperationMessage::GradientHueDirectionSet { + layer, + gradient_hue_direction: appearance.gradient_hue_direction, }); } @@ -883,7 +906,7 @@ impl Fsm for GradientToolFsmState { let Some((gradient, appearance, _source)) = resolve_gradient(layer, &document.network_interface) else { continue; }; - let unit_to_viewport = gradient_space_transform(layer, document) * appearance.transform; + let unit_to_viewport = gradient_to_viewport_transform(layer, document) * appearance.transform; let dragging = selected .filter(|selected| selected.layer.is_some_and(|selected_layer| selected_layer == layer)) .map(|selected| selected.dragging); @@ -1065,8 +1088,8 @@ impl Fsm for GradientToolFsmState { && let Some(selected_gradient) = tool_data.selected_gradient.as_ref() && let Some(layer) = selected_gradient.layer { - // The gradient space transform has be recalculated as the saved transform in SelectedGradient may become stale by panning/zooming during the rendering of the overlay. - let transform = gradient_space_transform(layer, document) * selected_gradient.appearance.transform; + // The gradient-to-viewport transform has to be recalculated as the saved transform in SelectedGradient may become stale by panning/zooming during the rendering of the overlay. + let transform = gradient_to_viewport_transform(layer, document) * selected_gradient.appearance.transform; let gradient = &selected_gradient.gradient; if stop_index < gradient.len() { let color = gradient.color(stop_index).unwrap_or(Color::BLACK); @@ -1244,7 +1267,7 @@ impl Fsm for GradientToolFsmState { continue; }; // TODO: This transform is incorrect. I think this is since it is based on the Footprint which has not been updated yet - let unit_to_viewport = gradient_space_transform(layer, document) * appearance.transform; + let unit_to_viewport = gradient_to_viewport_transform(layer, document) * appearance.transform; let mouse = input.mouse.position; let (start, end) = gradient_handle_positions(unit_to_viewport); @@ -1254,7 +1277,7 @@ impl Fsm for GradientToolFsmState { // If click is on the line then insert point if distance < (SELECTION_THRESHOLD * 2.) { // Try and insert the new stop - if let Some(index) = insert_stop_at_point(&mut gradient, mouse, unit_to_viewport, appearance.gradient_interpolation) { + if let Some(index) = insert_stop_at_point(&mut gradient, mouse, unit_to_viewport, appearance.gradient_space, appearance.gradient_hue_direction) { responses.add(DocumentMessage::StartTransaction); let mut selected_gradient = SelectedGradient::new(gradient, appearance, source, layer, document); @@ -1298,8 +1321,8 @@ impl Fsm for GradientToolFsmState { let Some((gradient, appearance, source)) = resolve_gradient(layer, &document.network_interface) else { continue; }; - let gradient_space_transform = gradient_space_transform(layer, document); - let unit_to_viewport = gradient_space_transform * appearance.transform; + let gradient_to_viewport_transform = gradient_to_viewport_transform(layer, document); + let unit_to_viewport = gradient_to_viewport_transform * appearance.transform; let is_gradient_chain = source == GradientSource::Chain; let (start, end) = gradient_handle_positions(unit_to_viewport); @@ -1324,7 +1347,7 @@ impl Fsm for GradientToolFsmState { tool_data.selected_gradient = Some(SelectedGradient { layer: Some(layer), - gradient_space_transform, + gradient_to_viewport_transform, gradient: gradient.clone(), appearance, initial_gradient_transform: appearance.transform, @@ -1368,7 +1391,7 @@ impl Fsm for GradientToolFsmState { tool_data.selected_gradient = Some(SelectedGradient { layer: Some(layer), dragging: drag_target, - gradient_space_transform, + gradient_to_viewport_transform, gradient: gradient.clone(), appearance, initial_gradient: gradient.clone(), @@ -1386,7 +1409,7 @@ impl Fsm for GradientToolFsmState { tool_data.selected_gradient = Some(SelectedGradient { layer: Some(layer), dragging: dragging_target, - gradient_space_transform, + gradient_to_viewport_transform, gradient: gradient.clone(), appearance, initial_gradient: gradient.clone(), @@ -1404,7 +1427,7 @@ impl Fsm for GradientToolFsmState { if distance.abs() < SEGMENT_INSERTION_DISTANCE && (0. ..=1.).contains(&projection) { let mut new_gradient = gradient.clone(); - if let Some(index) = insert_stop_at_point(&mut new_gradient, mouse, unit_to_viewport, appearance.gradient_interpolation) { + if let Some(index) = insert_stop_at_point(&mut new_gradient, mouse, unit_to_viewport, appearance.gradient_space, appearance.gradient_hue_direction) { responses.add(DocumentMessage::StartTransaction); transaction_started = true; @@ -1483,7 +1506,8 @@ impl Fsm for GradientToolFsmState { transform: DAffine2::IDENTITY, gradient_form: tool_options.gradient_form, gradient_spread: tool_options.gradient_spread, - gradient_interpolation: tool_options.gradient_interpolation, + gradient_space: tool_options.gradient_space, + gradient_hue_direction: tool_options.gradient_hue_direction, }, // A blank layer, or one holding only the other tool's paint, starts a whole-expanse gradient chain; a layer with content gets its Fill painted if replaceable_paint_chain(layer, &document.network_interface).is_some() { @@ -1524,8 +1548,8 @@ impl Fsm for GradientToolFsmState { // Recompute the gradient-to-viewport transform fresh each frame so zoom/pan mid-drag works correctly if let Some(layer) = selected_gradient.layer { - selected_gradient.gradient_space_transform = gradient_space_transform(layer, document); - selected_gradient.gradient_space_transform.translation += tool_data.auto_pan_shift; + selected_gradient.gradient_to_viewport_transform = gradient_to_viewport_transform(layer, document); + selected_gradient.gradient_to_viewport_transform.translation += tool_data.auto_pan_shift; } // Convert drag_start from document space to effective viewport space @@ -1715,10 +1739,10 @@ impl Fsm for GradientToolFsmState { } } -fn insert_stop_at_point(gradient: &mut Gradient, point: DVec2, unit_to_viewport: DAffine2, gradient_interpolation: GradientInterpolation) -> Option { +fn insert_stop_at_point(gradient: &mut Gradient, point: DVec2, unit_to_viewport: DAffine2, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> Option { let (start, end) = gradient_handle_positions(unit_to_viewport); let t = ((end - start).angle_to(point - start)).cos() * start.distance(point) / start.distance(end); - (0. ..=1.).contains(&t).then(|| gradient.insert_stop(t, gradient_interpolation)) + (0. ..=1.).contains(&t).then(|| gradient.insert_stop(t, gradient_space, gradient_hue_direction)) } fn dismiss_color_stop_color_picker(tool_data: &mut GradientToolData, responses: &mut VecDeque) { @@ -1739,8 +1763,8 @@ fn detect_hover_target(mouse: DVec2, document: &DocumentMessageHandler) -> Gradi let Some((gradient, appearance, _source)) = resolve_gradient(layer, &document.network_interface) else { continue; }; - let gradient_space_transform = gradient_space_transform(layer, document); - let unit_to_viewport = gradient_space_transform * appearance.transform; + let gradient_to_viewport_transform = gradient_to_viewport_transform(layer, document); + let unit_to_viewport = gradient_to_viewport_transform * appearance.transform; let (start, end) = gradient_handle_positions(unit_to_viewport); let line_length = start.distance(end); @@ -1842,7 +1866,8 @@ fn apply_gradient_update( gradient, gradient_form: appearance.gradient_form, gradient_spread: appearance.gradient_spread, - gradient_interpolation: appearance.gradient_interpolation, + gradient_space: appearance.gradient_space, + gradient_hue_direction: appearance.gradient_hue_direction, transform: appearance.transform, }); } @@ -1872,7 +1897,8 @@ fn apply_stops_update( responses: &mut VecDeque, new_gradient: Gradient, gradient_spread: GradientSpread, - gradient_interpolation: GradientInterpolation, + gradient_space: GradientSpace, + gradient_hue_direction: GradientHueDirection, ) { let selected_layers: Vec<_> = context .document @@ -1890,7 +1916,8 @@ fn apply_stops_update( if get_upstream_gradient_value_node_id(layer, &context.document.network_interface).is_some() { responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: new_gradient.clone() }); responses.add(GraphOperationMessage::GradientSpreadSet { layer, gradient_spread }); - responses.add(GraphOperationMessage::GradientInterpolationSet { layer, gradient_interpolation }); + responses.add(GraphOperationMessage::GradientSpaceSet { layer, gradient_space }); + responses.add(GraphOperationMessage::GradientHueDirectionSet { layer, gradient_hue_direction }); updated_any_layer = true; } else if let Some((_gradient, appearance, _source)) = resolve_gradient(layer, &context.document.network_interface) { responses.add(GraphOperationMessage::FillGradientSet { @@ -1898,7 +1925,8 @@ fn apply_stops_update( gradient: new_gradient.clone(), gradient_form: appearance.gradient_form, gradient_spread, - gradient_interpolation, + gradient_space, + gradient_hue_direction, transform: appearance.transform, }); updated_any_layer = true; @@ -1908,7 +1936,8 @@ fn apply_stops_update( if let Some(selected_gradient) = &mut data.selected_gradient { selected_gradient.gradient = new_gradient.clone(); selected_gradient.appearance.gradient_spread = gradient_spread; - selected_gradient.appearance.gradient_interpolation = gradient_interpolation; + selected_gradient.appearance.gradient_space = gradient_space; + selected_gradient.appearance.gradient_hue_direction = gradient_hue_direction; } // When no selected layer had a gradient to update, the user is editing the tool's default gradient instead. @@ -2008,7 +2037,7 @@ mod test_gradient { use graphene_std::vector::style::{GradientForm, GradientSpread, build_transform_with_y_preservation}; use graphene_std::vector::{Gradient, GradientRamp, GradientStop, fill}; - use super::gradient_space_transform; + use super::gradient_to_viewport_transform; struct ResolvedGradient { stops: Gradient, @@ -2065,7 +2094,7 @@ mod test_gradient { transform: local_transform, }; - let transform = gradient_space_transform(layer, document); + let transform = gradient_to_viewport_transform(layer, document); Some((gradient, transform)) }) .collect() @@ -2082,7 +2111,7 @@ mod test_gradient { let (gradient, appearance, _) = super::resolve_gradient(layer, &document.network_interface)?; let gradient = ResolvedGradient::new(gradient, appearance); - let transform = gradient_space_transform(layer, document); + let transform = gradient_to_viewport_transform(layer, document); Some((gradient, transform)) }) .collect() @@ -2770,11 +2799,11 @@ mod test_gradient { editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; let document = editor.active_document(); - let space_transform = gradient_space_transform(layer, document); + let to_viewport_transform = gradient_to_viewport_transform(layer, document); let (gradient, appearance, _) = super::resolve_gradient(layer, &document.network_interface).unwrap(); let gradient = ResolvedGradient::new(gradient, appearance); - let viewport_start = space_transform.transform_point2(gradient.start()); - let viewport_end = space_transform.transform_point2(gradient.end()); + let viewport_start = to_viewport_transform.transform_point2(gradient.start()); + let viewport_end = to_viewport_transform.transform_point2(gradient.end()); // Drag target of the end point, move 80px down let new_viewport_end = viewport_end + DVec2::new(0., 80.); @@ -2797,9 +2826,9 @@ mod test_gradient { let document = editor.active_document(); let (updated, appearance, _) = super::resolve_gradient(layer, &document.network_interface).expect("Gradient should exist after drag"); let updated = ResolvedGradient::new(updated, appearance); - let updated_space_transform = gradient_space_transform(layer, document); - let updated_viewport_start = updated_space_transform.transform_point2(updated.start()); - let updated_viewport_end = updated_space_transform.transform_point2(updated.end()); + let updated_to_viewport_transform = gradient_to_viewport_transform(layer, document); + let updated_viewport_start = updated_to_viewport_transform.transform_point2(updated.start()); + let updated_viewport_end = updated_to_viewport_transform.transform_point2(updated.end()); assert!( updated_viewport_start.abs_diff_eq(viewport_start, 1.), @@ -2848,10 +2877,10 @@ mod test_gradient { editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; let document = editor.active_document(); - let space_transform = gradient_space_transform(layer, document); + let to_viewport_transform = gradient_to_viewport_transform(layer, document); let (gradient, appearance, _) = super::resolve_gradient(layer, &document.network_interface).unwrap(); let gradient = ResolvedGradient::new(gradient, appearance); - let viewport_end = space_transform.transform_point2(gradient.end()); + let viewport_end = to_viewport_transform.transform_point2(gradient.end()); // Drag the end point 80px down let new_viewport_end = viewport_end + DVec2::new(0., 80.); diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index ada243f3d2..9b5512c27d 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -614,7 +614,9 @@ tagged_value! { GradientForm(vector::style::GradientForm), #[serde(alias = "GradientSpreadMethod")] // TODO: Eventually remove this document upgrade code GradientSpread(vector::style::GradientSpread), - GradientInterpolation(vector::style::GradientInterpolation), + #[serde(alias = "GradientInterpolation")] // TODO: Eventually remove this document upgrade code + GradientSpace(vector::style::GradientSpace), + GradientHueDirection(vector::style::GradientHueDirection), ReferencePoint(vector::ReferencePoint), CentroidType(vector::misc::CentroidType), BooleanOperation(vector::misc::BooleanOperation), @@ -868,7 +870,7 @@ pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserialize && array.is_empty() { let ramp = GradientRamp { - gradient_interpolation: vector::style::GradientInterpolation::SrgbGamma, + gradient_space: vector::style::GradientSpace::RgbGamma, ..Default::default() }; return Ok(MemoHash::new(TaggedValue::GradientRamp(ramp))); @@ -1093,7 +1095,7 @@ mod paint_default_parsing { #[cfg(test)] mod gradient_shape_migration { - use graphic_types::vector_types::{GradientInterpolation, GradientSpread}; + use graphic_types::vector_types::{GradientSpace, GradientSpread}; use super::*; @@ -1121,26 +1123,22 @@ mod gradient_shape_migration { let json = serde_json::to_value(&value).unwrap(); assert!(json.get("GradientRamp").and_then(|payload| payload.get("stops")).is_some(), "the payload should nest its stops: {json}"); assert_eq!( - json.get("GradientRamp").and_then(|payload| payload.get("gradient_interpolation")), - Some(&serde_json::json!("SrgbLinear")), - "the interpolation should serialize even at its default, marking the ramp as post-legacy: {json}" + json.get("GradientRamp").and_then(|payload| payload.get("gradient_space")), + Some(&serde_json::json!("OkLab")), + "the space should serialize even at its default, marking the ramp as post-legacy: {json}" ); assert_eq!(load(json), value); } // TODO: Eventually remove this document upgrade code #[test] - fn ramp_without_interpolation_field_reads_as_legacy_gamma() { + fn ramp_without_space_field_reads_as_legacy_gamma() { let json = serde_json::json!({ "GradientRamp": { "stops": { "color": [white(), white()] } } }); let TaggedValue::GradientRamp(ramp) = load(json) else { panic!("the ramp payload should become a gradient ramp value") }; - assert_eq!( - ramp.gradient_interpolation, - GradientInterpolation::SrgbGamma, - "a ramp saved before the field existed should read as gamma" - ); + assert_eq!(ramp.gradient_space, GradientSpace::RgbGamma, "a ramp saved before the field existed should read as gamma"); } // TODO: Eventually remove this document upgrade code @@ -1150,7 +1148,7 @@ mod gradient_shape_migration { let TaggedValue::GradientRamp(ramp) = load(json) else { panic!("the flat stops should become a gradient ramp value") }; - assert_eq!(ramp.gradient_interpolation, GradientInterpolation::SrgbGamma, "the pre-ramp flat form should carry the era's gamma"); + assert_eq!(ramp.gradient_space, GradientSpace::RgbGamma, "the pre-ramp flat form should carry the era's gamma"); let gradient = Gradient::from(ramp); assert_eq!(gradient.positions(), vec![0., 0.25]); @@ -1164,7 +1162,7 @@ mod gradient_shape_migration { let TaggedValue::GradientRamp(ramp) = load(json) else { panic!("the tuple stops should become a gradient ramp value") }; - assert_eq!(ramp.gradient_interpolation, GradientInterpolation::SrgbGamma, "the pre-ramp tuple form should carry the era's gamma"); + assert_eq!(ramp.gradient_space, GradientSpace::RgbGamma, "the pre-ramp tuple form should carry the era's gamma"); let gradient = Gradient::from(ramp); assert_eq!(gradient.positions(), vec![0., 1.]); @@ -1176,7 +1174,7 @@ mod gradient_shape_migration { fn empty_legacy_gradient_table_degrades_to_the_default() { let json = serde_json::json!({ "GradientTable": { "element": [] } }); let expected = GradientRamp { - gradient_interpolation: GradientInterpolation::SrgbGamma, + gradient_space: GradientSpace::RgbGamma, ..Default::default() }; assert_eq!(load(json), TaggedValue::GradientRamp(expected)); diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index d4fe7f7a49..dcfb540645 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -17,7 +17,7 @@ pub mod migrations { use crate::Vector; use core_types::Color; use vector_types::gradient::GradientStops; - use vector_types::{Gradient, GradientInterpolation, GradientRamp}; + use vector_types::{Gradient, GradientRamp, GradientSpace}; // Storing legacy structs that are only used in document migration. // TODO: Eventually remove this document upgrade code @@ -152,7 +152,7 @@ pub mod migrations { // TODO: Eventually remove this document upgrade code /// Recovers a [`GradientRamp`] from any of its on-disk shapes: the current nested form, the flat stops struct /// that preceded it, or the ancient position-color tuple list (whose even positions elide back to absence). - /// The pre-ramp shapes come from documents that rendered in gamma, so they carry that interpolation explicitly. + /// The pre-ramp shapes come from documents that rendered in gamma, so they carry that space explicitly. pub fn migrate_to_gradient_ramp<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { use serde::Deserialize; @@ -167,7 +167,7 @@ pub mod migrations { Ok(match GradientRampFormat::deserialize(deserializer)? { GradientRampFormat::Ramp(ramp) => ramp, GradientRampFormat::FlatStops(stops) => GradientRamp { - gradient_interpolation: GradientInterpolation::SrgbGamma, + gradient_space: GradientSpace::RgbGamma, ..GradientRamp::from(stops) }, GradientRampFormat::Tuples(stops) => { @@ -177,7 +177,7 @@ pub mod migrations { gradient.elide_default_attributes(); GradientRamp { - gradient_interpolation: GradientInterpolation::SrgbGamma, + gradient_space: GradientSpace::RgbGamma, ..GradientRamp::from(gradient) } } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index b83e15cae4..2830e9c10c 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -8,11 +8,13 @@ use core_types::uuid::generate_uuid; use glam::{DAffine2, DVec2}; use graphic_types::Graphic; use graphic_types::vector_types::gradient::GradientForm; -use graphic_types::vector_types::markers::{GradientForm as GradientFormAttr, GradientInterpolation as GradientInterpolationAttr, GradientSpread as GradientSpreadAttr}; +use graphic_types::vector_types::markers::{ + GradientForm as GradientFormAttr, GradientHueDirection as GradientHueDirectionAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr, +}; use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; use vector_types::Gradient; -use vector_types::gradient::{GradientInterpolation, GradientSpread}; +use vector_types::gradient::{GradientHueDirection, GradientSpace, GradientSpread}; #[derive(Copy, Clone, PartialEq)] pub enum PaintTarget { @@ -111,9 +113,10 @@ pub fn render_gradient_paint let gradient_form: GradientForm = source.attr::(0); let local_gradient_transform: DAffine2 = source.attr::(0); let gradient_spread: GradientSpread = source.attr::(0); - let gradient_interpolation: GradientInterpolation = source.attr::(0); + let gradient_space: GradientSpace = source.attr::(0); + let gradient_hue_direction: GradientHueDirection = source.attr::(0); - let (samples, _) = spread_adjusted_samples(stops, gradient_spread, gradient_form, gradient_interpolation, ClearGuardPlacement::SvgStopOrder); + let (samples, _) = spread_adjusted_samples(stops, gradient_spread, gradient_form, gradient_space, gradient_hue_direction, ClearGuardPlacement::SvgStopOrder); for (position, color, original_midpoint) in samples { stop.push_str(" (GradientSamples, (f64, f64)) { - let samples = gradient.interpolated_samples(gradient_interpolation); + let samples = gradient.interpolated_samples(gradient_space, gradient_hue_direction); if gradient_spread != GradientSpread::Clear { return (samples, (0., 1.)); } @@ -514,9 +517,10 @@ fn create_peniko_gradient_brush>(gradient_list let gradient_form: GradientForm = gradient_list.attr::(0); let gradient_transform: DAffine2 = gradient_list.attr::(0); let gradient_spread: GradientSpread = gradient_list.attr::(0); - let gradient_interpolation: GradientInterpolation = gradient_list.attr::(0); + let gradient_space: GradientSpace = gradient_list.attr::(0); + let gradient_hue_direction: GradientHueDirection = gradient_list.attr::(0); - let (samples, span) = spread_adjusted_samples(stops, gradient_spread, gradient_form, gradient_interpolation, ClearGuardPlacement::VelloRampTexels); + let (samples, span) = spread_adjusted_samples(stops, gradient_spread, gradient_form, gradient_space, gradient_hue_direction, ClearGuardPlacement::VelloRampTexels); let peniko_stops = peniko_color_stops(&samples); @@ -772,7 +776,7 @@ fn collect_element_metadata<'a>( Graphic::RasterCPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id), Graphic::RasterGPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id), Graphic::Color(_) => {} - Graphic::Gradient(_) => {} + Graphic::Gradient(gradient) => collect_gradient_metadata(&Single(gradient), metadata, element_id), Graphic::Text(text) => collect_text_metadata(&Single(text), metadata, footprint, element_id), Graphic::Group(group) => collect_group_metadata(group, reach, metadata, footprint, element_id), } @@ -814,7 +818,8 @@ fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReac Graphic::Vector(vector) if reach.applies() => add_vector_upstream_click_targets(&PaintOverlay::new(&Single(vector), reach.paint), click_targets), Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), click_targets), Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(click_targets), - Graphic::Color(_) | Graphic::Gradient(_) => {} + Graphic::Color(_) => {} + Graphic::Gradient(gradient) => click_targets.extend(gradient_control_targets(&Single(gradient), |transform| transform, true)), Graphic::Text(text) => add_text_upstream_click_targets(&Single(text), click_targets), Graphic::Group(group) => add_group_upstream_click_targets(group, reach, click_targets), } @@ -827,7 +832,8 @@ fn add_element_upstream_outline_targets<'a>(element: &'a Graphic, reach: PaintRe Graphic::Vector(vector) if reach.applies() => add_vector_upstream_outline_targets(&PaintOverlay::new(&Single(vector), reach.paint), outlines), Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), outlines), Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(outlines), - Graphic::Color(_) | Graphic::Gradient(_) => {} + Graphic::Color(_) => {} + Graphic::Gradient(gradient) => outlines.extend(gradient_control_targets(&Single(gradient), |transform| transform, false)), Graphic::Text(text) => add_text_upstream_click_targets(&Single(text), outlines), Graphic::Group(group) => add_group_upstream_outline_targets(group, reach, outlines), } @@ -894,7 +900,9 @@ fn collect_group_metadata<'a>(group: &'a Group, reach: PaintReach<'a>, metadata: collect_raster_metadata(&run, metadata, footprint, element_id) } else if let Some(run) = RunView::>::new(item) { collect_raster_metadata(&run, metadata, footprint, element_id) - } else if item.typed_lanes::().is_some() || item.typed_lanes::().is_some() { + } else if let Some(run) = RunView::::new(item) { + collect_gradient_metadata(&run, metadata, element_id) + } else if item.typed_lanes::().is_some() { } else if let Some(run) = RunView::::new(item) { collect_text_metadata(&run, metadata, footprint, element_id) } @@ -911,6 +919,8 @@ fn add_group_upstream_click_targets<'a>(group: &'a Group, reach: PaintReach<'a>, } } else if item.typed_lanes::>().is_some() || item.typed_lanes::>().is_some() { add_raster_upstream_click_targets(click_targets) + } else if let Some(run) = RunView::::new(item) { + click_targets.extend(gradient_control_targets(&run, |transform| transform, true)) } else if let Some(run) = RunView::::new(item) { add_text_upstream_click_targets(&run, click_targets) } @@ -927,6 +937,8 @@ fn add_group_upstream_outline_targets<'a>(group: &'a Group, reach: PaintReach<'a } } else if item.typed_lanes::>().is_some() || item.typed_lanes::>().is_some() { add_raster_upstream_click_targets(outlines) + } else if let Some(run) = RunView::::new(item) { + outlines.extend(gradient_control_targets(&run, |transform| transform, false)) } else if let Some(run) = RunView::::new(item) { add_text_upstream_click_targets(&run, outlines) } @@ -2381,7 +2393,6 @@ fn render_color_vello>(source: &S, scene: &mut Sc } } } - /// A gradient's control geometry in its local space: the unit circle a radial gradient's transform carries to its drawn ellipse, or the (0,0) to (1,0) gradient line for a linear one. fn gradient_control_outline(gradient_form: GradientForm) -> Subpath { match gradient_form { @@ -2424,7 +2435,8 @@ fn render_gradient_svg>(source: &S, render: &m let opacity_fill_attr: f64 = source.attr::(index); let gradient_spread: GradientSpread = source.attr::(index); let gradient_form: GradientForm = source.attr::(index); - let gradient_interpolation: GradientInterpolation = source.attr::(index); + let gradient_space: GradientSpace = source.attr::(index); + let gradient_hue_direction: GradientHueDirection = source.attr::(index); let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; render.leaf_tag(tag, |attributes| { if let Some((min, size)) = thumbnail_rect { @@ -2440,7 +2452,7 @@ fn render_gradient_svg>(source: &S, render: &m attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); } - let (samples, _) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, gradient_interpolation, ClearGuardPlacement::SvgStopOrder); + let (samples, _) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, gradient_space, gradient_hue_direction, ClearGuardPlacement::SvgStopOrder); let mut stop_string = String::new(); for (position, color, original_midpoint) in samples { @@ -2511,7 +2523,8 @@ fn render_gradient_vello>(source: &S, scene: & let Some(gradient) = source.element(index) else { continue }; let gradient_spread: GradientSpread = source.attr::(index); let gradient_form: GradientForm = source.attr::(index); - let gradient_interpolation: GradientInterpolation = source.attr::(index); + let gradient_space: GradientSpace = source.attr::(index); + let gradient_hue_direction: GradientHueDirection = source.attr::(index); let transform: DAffine2 = source.attr::(index); let blend_mode_attr: BlendMode = source.attr::(index); let opacity_attr: f64 = source.attr::(index); @@ -2521,7 +2534,7 @@ fn render_gradient_vello>(source: &S, scene: & 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 (samples, span) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, gradient_interpolation, ClearGuardPlacement::VelloRampTexels); + let (samples, span) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, gradient_space, gradient_hue_direction, ClearGuardPlacement::VelloRampTexels); let stops = peniko_color_stops(&samples); let extend = peniko_extend(gradient_spread); @@ -2576,6 +2589,49 @@ fn render_gradient_vello>(source: &S, scene: & } } +/// The control geometry of each gradient lane, transformed by `lane_transform`. +fn gradient_control_targets(source: &S, lane_transform: impl Fn(DAffine2) -> DAffine2, clickable_only: bool) -> Vec { + (0..source.lane_count()) + .filter_map(|index| { + let gradient_form = source.attr::(index); + if clickable_only && !gradient_control_interior_is_clickable(gradient_form) { + return None; + } + let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); + target.apply_transform(lane_transform(source.attr::(index))); + Some(target) + }) + .collect() +} + +fn collect_gradient_metadata(source: &S, metadata: &mut RenderMetadata, element_id: Option) { + let Some(element_id) = element_id else { return }; + if source.lane_count() == 0 { + return; + } + + // Targets are baked relative to lane 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]` + let lane_zero_transform = source.attr::(0); + let lane_zero_inverse = if transform_is_invertible(lane_zero_transform) { + lane_zero_transform.inverse() + } else { + DAffine2::IDENTITY + }; + + let outline_targets: Vec> = gradient_control_targets(source, |transform| lane_zero_inverse * transform, false).into_iter().map(Arc::new).collect(); + let click_targets: Vec> = outline_targets + .iter() + .enumerate() + .filter(|(index, _)| gradient_control_interior_is_clickable(source.attr::(*index))) + .map(|(_, target)| target.clone()) + .collect(); + + metadata.outlines.insert(element_id, outline_targets); + if !click_targets.is_empty() { + metadata.click_targets.insert(element_id, click_targets); + } +} + impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { render_gradient_svg(self, render, render_params) @@ -2586,64 +2642,15 @@ impl Render for List { } fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option) { - let Some(element_id) = element_id else { return }; - if self.is_empty() { - return; - } - - // Targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]` - let item_zero_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - let item_zero_inverse = if transform_is_invertible(item_zero_transform) { - item_zero_transform.inverse() - } else { - DAffine2::IDENTITY - }; - - let mut outline_targets = Vec::new(); - let mut click_targets = Vec::new(); - for index in 0..self.len() { - let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - - let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); - target.apply_transform(item_zero_inverse * item_transform); - let target = Arc::new(target); - - if gradient_control_interior_is_clickable(gradient_form) { - click_targets.push(target.clone()); - } - outline_targets.push(target); - } - - metadata.outlines.insert(element_id, outline_targets); - if !click_targets.is_empty() { - metadata.click_targets.insert(element_id, click_targets); - } + collect_gradient_metadata(self, metadata, element_id) } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - for index in 0..self.len() { - let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); - if !gradient_control_interior_is_clickable(gradient_form) { - continue; - } - - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); - target.apply_transform(transform); - click_targets.push(target); - } + click_targets.extend(gradient_control_targets(self, |transform| transform, true)); } fn add_upstream_outline_targets(&self, outlines: &mut Vec) { - for index in 0..self.len() { - let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - - let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); - target.apply_transform(transform); - outlines.push(target); - } + outlines.extend(gradient_control_targets(self, |transform| transform, false)); } } @@ -3113,6 +3120,18 @@ impl Render for RunView<'_, Gradient> { fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { render_gradient_vello(self, scene, parent_transform, render_params) } + + fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option) { + collect_gradient_metadata(self, metadata, element_id) + } + + fn add_upstream_click_targets(&self, click_targets: &mut Vec) { + click_targets.extend(gradient_control_targets(self, |transform| transform, true)); + } + + fn add_upstream_outline_targets(&self, outlines: &mut Vec) { + outlines.extend(gradient_control_targets(self, |transform| transform, false)); + } } impl Render for RunView<'_, Artboard<'_>> { @@ -3330,18 +3349,20 @@ mod spread_tests { &gradient, GradientSpread::Repeat, GradientForm::Linear, - GradientInterpolation::SrgbGamma, + GradientSpace::RgbGamma, + Default::default(), ClearGuardPlacement::SvgStopOrder, ); assert_eq!(span, (0., 1.)); - assert_eq!(samples, gradient.interpolated_samples(GradientInterpolation::SrgbGamma)); + assert_eq!(samples, gradient.interpolated_samples(GradientSpace::RgbGamma, Default::default())); // SVG guards share the range ends' exact offsets, ordered so the pad extension resolves to the transparent outer stops let (samples, span) = spread_adjusted_samples( &gradient, GradientSpread::Clear, GradientForm::Linear, - GradientInterpolation::SrgbGamma, + GradientSpace::RgbGamma, + Default::default(), ClearGuardPlacement::SvgStopOrder, ); assert_eq!(span, (0., 1.)); @@ -3356,7 +3377,8 @@ mod spread_tests { &gradient, GradientSpread::Clear, GradientForm::Linear, - GradientInterpolation::SrgbGamma, + GradientSpace::RgbGamma, + Default::default(), ClearGuardPlacement::VelloRampTexels, ); assert_eq!( @@ -3375,7 +3397,8 @@ mod spread_tests { &gradient, GradientSpread::Clear, GradientForm::Radial, - GradientInterpolation::SrgbGamma, + GradientSpace::RgbGamma, + Default::default(), ClearGuardPlacement::VelloRampTexels, ); assert_eq!(span.0, 0.); @@ -3389,7 +3412,8 @@ mod spread_tests { &Gradient::from(Vec::new()), GradientSpread::Clear, GradientForm::Linear, - GradientInterpolation::SrgbGamma, + GradientSpace::RgbGamma, + Default::default(), ClearGuardPlacement::SvgStopOrder, ); let colors: Vec = samples.iter().map(|&(_, color, _)| color).collect(); diff --git a/node-graph/libraries/vector-types/Cargo.toml b/node-graph/libraries/vector-types/Cargo.toml index 603648cdf5..0e8784b584 100644 --- a/node-graph/libraries/vector-types/Cargo.toml +++ b/node-graph/libraries/vector-types/Cargo.toml @@ -20,6 +20,7 @@ node-macro = { workspace = true } # Workspace dependencies bitflags = { workspace = true } bytemuck = { workspace = true } +color = { workspace = true } num-traits = { workspace = true } glam = { workspace = true } kurbo = { workspace = true } diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index f794e2f11a..9659dda00c 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -1,4 +1,4 @@ -use crate::markers::{ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPREAD}; +use crate::markers::{ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD}; use core_types::Color; use core_types::color::SRGBA8; use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List}; @@ -94,13 +94,13 @@ impl From<&GradientStops> for Gradient { impl GradientStops { /// CSS `linear-gradient(...)` string. Stops are emitted as `#rrggbbaa` hex (already gamma-encoded bytes). - pub fn to_css_linear_gradient(&self, gradient_interpolation: GradientInterpolation) -> String { - Gradient::from(self).to_css_linear_gradient(gradient_interpolation) + pub fn to_css_linear_gradient(&self, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> String { + Gradient::from(self).to_css_linear_gradient(gradient_space, gradient_hue_direction) } } /// The serialized exchange form of a gradient: its stops, with whole-ramp settings as sibling fields serialized -/// only when non-default. The interpolation is the exception: it always serializes, so its absence marks a ramp +/// only when non-default. The space is the exception: it always serializes, so its absence marks a ramp /// from before the field existed, which deserializes as the gamma those documents rendered with. #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash)] @@ -110,9 +110,12 @@ pub struct GradientRamp { #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientSpread::is_default"))] #[cfg_attr(feature = "wasm", tsify(optional))] pub gradient_spread: GradientSpread, - // TODO: Elide the default again (removing `legacy_gamma`) when switching to the new document format and Ctrl-C node serialization format - #[cfg_attr(feature = "serde", serde(default = "GradientInterpolation::legacy_gamma"))] - pub gradient_interpolation: GradientInterpolation, + // TODO: Elide the default again (removing `legacy_gamma` and the serde aliases) when switching to the new document format and Ctrl-C node serialization format + #[cfg_attr(feature = "serde", serde(default = "GradientSpace::legacy_gamma", alias = "gradient_interpolation"))] + pub gradient_space: GradientSpace, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientHueDirection::is_default"))] + #[cfg_attr(feature = "wasm", tsify(optional))] + pub gradient_hue_direction: GradientHueDirection, } unsafe impl dyn_any::StaticType for GradientRamp { @@ -124,7 +127,8 @@ impl From> for GradientRamp { Self { stops, gradient_spread: Default::default(), - gradient_interpolation: Default::default(), + gradient_space: Default::default(), + gradient_hue_direction: Default::default(), } } } @@ -134,7 +138,8 @@ impl From<&Gradient> for GradientRamp { Self { stops: gradient.into(), gradient_spread: Default::default(), - gradient_interpolation: Default::default(), + gradient_space: Default::default(), + gradient_hue_direction: Default::default(), } } } @@ -165,8 +170,11 @@ impl From for Item { if !ramp.gradient_spread.is_default() { item.set_attribute(ATTR_GRADIENT_SPREAD, ramp.gradient_spread); } - if !ramp.gradient_interpolation.is_default() { - item.set_attribute(ATTR_GRADIENT_INTERPOLATION, ramp.gradient_interpolation); + if !ramp.gradient_space.is_default() { + item.set_attribute(ATTR_GRADIENT_SPACE, ramp.gradient_space); + } + if !ramp.gradient_hue_direction.is_default() { + item.set_attribute(ATTR_GRADIENT_HUE_DIRECTION, ramp.gradient_hue_direction); } item } @@ -177,7 +185,8 @@ impl From<&Item> for GradientRamp { Self { stops: item.element().into(), gradient_spread: item.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD), - gradient_interpolation: item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION), + gradient_space: item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE), + gradient_hue_direction: item.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION), } } } @@ -204,7 +213,8 @@ impl From<&GradientRamp> for GradientRamp { Self { stops: ramp.into(), gradient_spread: ramp.gradient_spread, - gradient_interpolation: ramp.gradient_interpolation, + gradient_space: ramp.gradient_space, + gradient_hue_direction: ramp.gradient_hue_direction, } } } @@ -214,7 +224,8 @@ impl From<&Gradient> for GradientRamp { Self { stops: gradient.into(), gradient_spread: Default::default(), - gradient_interpolation: Default::default(), + gradient_space: Default::default(), + gradient_hue_direction: Default::default(), } } } @@ -223,7 +234,8 @@ impl From<&GradientRamp> for GradientRamp { fn from(ramp: &GradientRamp) -> Self { Self { gradient_spread: ramp.gradient_spread, - gradient_interpolation: ramp.gradient_interpolation, + gradient_space: ramp.gradient_space, + gradient_hue_direction: ramp.gradient_hue_direction, ..Self::from(&ramp.stops) } } @@ -274,11 +286,131 @@ fn apply_midpoint(t: f64, midpoint: f64) -> f64 { } } -/// Interpolates between two adjacent stops' colors at `t` across their interval, in the gradient's interpolation color space. -pub fn interpolate_stop_colors(color_a: Color, color_b: Color, t: f32, gradient_interpolation: GradientInterpolation) -> Color { - match gradient_interpolation { - GradientInterpolation::SrgbLinear => color_a.lerp(&color_b, t), - GradientInterpolation::SrgbGamma => color_a.lerp_gamma_srgb(&color_b, t), +/// Interpolates between two adjacent stops' colors at `t` across their interval, in the gradient's chosen color space. +pub fn interpolate_stop_colors(color_a: Color, color_b: Color, t: f32, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> Color { + match gradient_space { + GradientSpace::OkLab => lerp_in_space::(color_a, color_b, t, gradient_hue_direction), + GradientSpace::OkLCh => lerp_in_space::(color_a, color_b, t, gradient_hue_direction), + GradientSpace::Lab => lerp_in_space::(color_a, color_b, t, gradient_hue_direction), + GradientSpace::LCh => lerp_in_space::(color_a, color_b, t, gradient_hue_direction), + GradientSpace::Hsl => lerp_in_space::(color_a, color_b, t, gradient_hue_direction), + GradientSpace::Hsv => lerp_in_space::(color_a, color_b, t, gradient_hue_direction), + GradientSpace::RgbLinear => color_a.lerp(&color_b, t), + GradientSpace::RgbGamma => color_a.lerp_gamma_srgb(&color_b, t), + } +} + +/// Mix two colors in the color space `CS`, with alpha interpolating linearly like the sRGB spaces. +/// Polar spaces arc through hue per the direction, with an achromatic endpoint's powerless hue adopting the other's per CSS. +/// The mix can land slightly outside the sRGB gamut; it stays unclamped here and clips at render encoding. +fn lerp_in_space(color_a: Color, color_b: Color, t: f32, gradient_hue_direction: GradientHueDirection) -> Color { + use color::ColorSpaceLayout; + + let mut a = CS::from_linear_srgb([color_a.r(), color_a.g(), color_a.b()]); + let mut b = CS::from_linear_srgb([color_b.r(), color_b.g(), color_b.b()]); + + let hue_index = match CS::LAYOUT { + ColorSpaceLayout::HueFirst => Some(0), + ColorSpaceLayout::HueThird => Some(2), + _ => None, + }; + if let Some(hue_index) = hue_index { + // Chroma (or saturation) is channel 1 in both polar layouts; the threshold scales to the space's + // lightness range so conversion noise on achromatic colors stays below it + let achromatic = 1e-4 * CS::WHITE_COMPONENTS.iter().fold(0_f32, |max, &component| max.max(component)); + if a[1] < achromatic && b[1] >= achromatic { + a[hue_index] = b[hue_index]; + } + if b[1] < achromatic && a[1] >= achromatic { + b[hue_index] = a[hue_index]; + } + + // The CSS Color 4 hue fixup, on hues the conversions already place in the 0 to 360 range + let delta = b[hue_index] - a[hue_index]; + let delta = match gradient_hue_direction { + GradientHueDirection::Shorter => { + if delta > 180. { + delta - 360. + } else if delta < -180. { + delta + 360. + } else { + delta + } + } + GradientHueDirection::Longer => { + if 0. < delta && delta < 180. { + delta - 360. + } else if -180. < delta && delta <= 0. { + delta + 360. + } else { + delta + } + } + GradientHueDirection::Increasing => { + if delta < 0. { + delta + 360. + } else { + delta + } + } + GradientHueDirection::Decreasing => { + if delta > 0. { + delta - 360. + } else { + delta + } + } + }; + b[hue_index] = a[hue_index] + delta; + } + + let mixed = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; + + let [red, green, blue] = CS::to_linear_srgb(mixed); + Color::from_rgbaf32_unchecked(red, green, blue, color_a.a() + (color_b.a() - color_a.a()) * t) +} + +/// The classic HSV cylinder as `[hue in degrees, saturation 0-100, value 0-100]`, implemented locally +/// since CSS Color 4 (and therefore the `color` crate) offers only its HWB reparameterization, which +/// mixes along different paths through shades and tones. +#[derive(Clone, Copy, Debug)] +struct Hsv; + +impl color::ColorSpace for Hsv { + const LAYOUT: color::ColorSpaceLayout = color::ColorSpaceLayout::HueFirst; + + const WHITE_COMPONENTS: [f32; 3] = [0., 0., 100.]; + + fn to_linear_srgb([hue, saturation, value]: [f32; 3]) -> [f32; 3] { + let (saturation, value) = (saturation / 100., value / 100.); + let channel = |n: f32| { + let k = (n + hue / 60.).rem_euclid(6.); + value - value * saturation * k.min(4. - k).clamp(0., 1.) + }; + color::Srgb::to_linear_srgb([channel(5.), channel(3.), channel(1.)]) + } + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + let [red, green, blue] = color::Srgb::from_linear_srgb(src); + let max = red.max(green).max(blue); + let delta = max - red.min(green).min(blue); + + let hue = if delta <= 0. { + 0. + } else if max == red { + 60. * ((green - blue) / delta).rem_euclid(6.) + } else if max == green { + 60. * ((blue - red) / delta + 2.) + } else { + 60. * ((red - green) / delta + 4.) + }; + let saturation = if max <= 0. { 0. } else { delta / max }; + + [hue, saturation * 100., max * 100.] + } + + fn clip([hue, saturation, value]: [f32; 3]) -> [f32; 3] { + [hue, saturation.clamp(0., 100.), value.clamp(0., 100.)] } } @@ -550,8 +682,8 @@ impl Gradient { /// Insert a new stop at the given position, sampling the gradient at that position to determine the new stop's color. /// The new stop's midpoint is inherited from the interval it splits (or `0.5` if inserting at the very start). /// Returns the index where the new stop was inserted. - pub fn insert_stop(&mut self, position: f64, gradient_interpolation: GradientInterpolation) -> usize { - let color = self.evaluate(position, Default::default(), gradient_interpolation); + pub fn insert_stop(&mut self, position: f64, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> usize { + let color = self.evaluate(position, Default::default(), gradient_space, gradient_hue_direction); let index = (0..self.len()).position(|i| self.position(i) > position).unwrap_or(self.len()); let midpoint = if index > 0 { self.midpoint(index - 1) } else { 0.5 }; self.insert_stop_values(position, midpoint, color) @@ -632,7 +764,7 @@ impl Gradient { } /// Samples the gradient's color at `t`. Given a `t` outside the 0 to 1 range, the `gradient_spread` determines how the gradient extends. - pub fn evaluate(&self, t: f64, gradient_spread: GradientSpread, gradient_interpolation: GradientInterpolation) -> Color { + pub fn evaluate(&self, t: f64, gradient_spread: GradientSpread, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> Color { let t = match gradient_spread { GradientSpread::Pad => t.clamp(0., 1.), GradientSpread::Repeat => t.rem_euclid(1.), @@ -662,7 +794,7 @@ impl Gradient { if t >= a.position && t <= b.position { let normalized_t = (t - a.position) / (b.position - a.position); let adjusted_t = apply_midpoint(normalized_t, a.midpoint); - return interpolate_stop_colors(a.color, b.color, adjusted_t as f32, gradient_interpolation); + return interpolate_stop_colors(a.color, b.color, adjusted_t as f32, gradient_space, gradient_hue_direction); } } @@ -703,14 +835,14 @@ impl Gradient { mapped } - /// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves and interpolation color space so the rendered gradient matches Graphite's interpolation rather than browser defaults. - pub fn to_css_linear_gradient(&self, gradient_interpolation: GradientInterpolation) -> String { + /// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves and color space so the rendered gradient matches Graphite's interpolation rather than browser defaults. + pub fn to_css_linear_gradient(&self, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> String { if self.len() <= 1 { let hex = self.color(0).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 - .interpolated_samples(gradient_interpolation) + .interpolated_samples(gradient_space, gradient_hue_direction) .into_iter() .map(|(position, color, _)| { let percent = ((position * 100.) * 1e2).round() / 1e2; @@ -722,15 +854,15 @@ impl Gradient { } /// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves - /// and interpolation color space. + /// and color space. /// /// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding /// midpoint for actual gradient stops, and `None` for synthesized curve approximation samples. /// /// The downstream SVG/CSS and Vello renderers interpolate between adjacent emitted stops in gamma sRGB space, so the /// subdivision emits enough samples that the gamma-drawn segments match the ramp's true curve: the midpoint bias, and - /// the interpolation color space when it is not gamma itself. - pub fn interpolated_samples(&self, gradient_interpolation: GradientInterpolation) -> Vec<(f64, Color, Option)> { + /// the color space when it is not gamma itself. + pub fn interpolated_samples(&self, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> 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.; @@ -744,7 +876,8 @@ impl Gradient { pos_b: f64, color_a: Color, color_b: Color, - gradient_interpolation: GradientInterpolation, + gradient_space: GradientSpace, + gradient_hue_direction: GradientHueDirection, result: &mut Vec<(f64, Color, Option)>, depth: u32, ) { @@ -761,23 +894,28 @@ impl Gradient { let y_linear = (y_left + y_right) / 2.; // A sample is needed wherever the renderer's gamma segment between the flanking samples would stray - // from the ramp's true curve: from the midpoint bias, or from a non-gamma space's own curvature + // from the ramp's true curve: from the midpoint bias, or from a non-gamma space's own curvature. + // The space check probes the quarter points as well as the center, since spaces with a steep toe + // (like CIE Lab near black) peak their deviation off-center let midpoint_deviates = (y_actual - y_linear).abs() > THRESHOLD; - let space_deviates = gradient_interpolation != GradientInterpolation::SrgbGamma && { - let color_target = interpolate_stop_colors(color_a, color_b, y_actual as f32, gradient_interpolation); - let color_left = interpolate_stop_colors(color_a, color_b, y_left as f32, gradient_interpolation); - let color_right = interpolate_stop_colors(color_a, color_b, y_right as f32, gradient_interpolation); - max_gamma_channel_deviation(color_target, color_left.lerp_gamma_srgb(&color_right, 0.5)) > THRESHOLD + let space_deviates = gradient_space != GradientSpace::RgbGamma && { + let color_left = interpolate_stop_colors(color_a, color_b, y_left as f32, gradient_space, gradient_hue_direction); + let color_right = interpolate_stop_colors(color_a, color_b, y_right as f32, gradient_space, gradient_hue_direction); + [0.25, 0.5, 0.75].into_iter().any(|fraction| { + let y_probe = apply_midpoint(left + (right - left) * fraction, midpoint); + let color_target = interpolate_stop_colors(color_a, color_b, y_probe as f32, gradient_space, gradient_hue_direction); + max_gamma_channel_deviation(color_target, color_left.lerp_gamma_srgb(&color_right, fraction as f32)) > THRESHOLD + }) }; if midpoint_deviates || space_deviates { - subdivide(left, mid, midpoint, pos_a, pos_b, color_a, color_b, gradient_interpolation, result, depth + 1); + subdivide(left, mid, midpoint, pos_a, pos_b, color_a, color_b, gradient_space, gradient_hue_direction, result, depth + 1); let global_pos = pos_a + mid * (pos_b - pos_a); - let color = interpolate_stop_colors(color_a, color_b, y_actual as f32, gradient_interpolation); + let color = interpolate_stop_colors(color_a, color_b, y_actual as f32, gradient_space, gradient_hue_direction); result.push((global_pos, color, None)); - subdivide(mid, right, midpoint, pos_a, pos_b, color_a, color_b, gradient_interpolation, result, depth + 1); + subdivide(mid, right, midpoint, pos_a, pos_b, color_a, color_b, gradient_space, gradient_hue_direction, result, depth + 1); } } @@ -807,8 +945,8 @@ impl Gradient { } // Only subdivide if the midpoint deviates from linear (0.5) or a non-gamma space may curve away from the drawn gamma segment - if (midpoint - 0.5).abs() >= 1e-6 || gradient_interpolation != GradientInterpolation::SrgbGamma { - subdivide(0., 1., midpoint, pos_a, pos_b, color_a, color_b, gradient_interpolation, &mut result, 0); + if (midpoint - 0.5).abs() >= 1e-6 || gradient_space != GradientSpace::RgbGamma { + subdivide(0., 1., midpoint, pos_a, pos_b, color_a, color_b, gradient_space, gradient_hue_direction, &mut result, 0); } // Add the end stop @@ -875,24 +1013,73 @@ impl GradientSpread { #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[widget(Dropdown)] -pub enum GradientInterpolation { - /// Blends stops in linear light, keeping transitions evenly bright. +pub enum GradientSpace { + /// Interpolates between stops in the OkLab perceptual color space, keeping transitions visually even. #[default] - #[label("sRGB Linear")] - SrgbLinear, - /// Blends stops in gamma-encoded sRGB, the classic SVG and CSS look. - #[label("sRGB Gamma")] - SrgbGamma, + #[label("Perceptual (OkLab)")] + OkLab, + /// Interpolates between stops in the CIE Lab color space, the longtime perceptual standard. + #[label("Perceptual (Lab)")] + Lab, + /// Interpolates between stops in the polar form of OkLab, arcing through hue instead of fading through gray. + #[label("Perceptual Hue (OkLCh)")] + OkLCh, + /// Interpolates between stops in the polar form of CIE Lab, arcing through hue instead of fading through gray. + #[label("Perceptual Hue (LCh)")] + LCh, + /// Interpolates between stops in linear light, keeping transitions evenly bright. + #[menu_separator] + #[cfg_attr(feature = "serde", serde(alias = "SrgbLinear"))] + #[label("Linear (RGB)")] + RgbLinear, + /// Interpolates between stops in gamma-encoded RGB, matching classic SVG and CSS gradients. + #[cfg_attr(feature = "serde", serde(alias = "SrgbGamma"))] + #[label("Classic (RGB)")] + RgbGamma, + /// Interpolates between stops in the hue, saturation, and value cylinder, keeping tints at full brightness. + #[label("Classic Hue (HSV)")] + Hsv, + /// Interpolates between stops in the hue, saturation, and lightness cylinder. + #[label("Classic Hue (HSL)")] + Hsl, } -impl GradientInterpolation { +impl GradientSpace { pub fn is_default(&self) -> bool { *self == Self::default() } + /// Whether the space is polar (cylindrical), making the hue direction option meaningful. + pub fn is_polar(&self) -> bool { + matches!(self, Self::OkLCh | Self::LCh | Self::Hsl | Self::Hsv) + } + // TODO: Remove when switching to the new document format and Ctrl-C node serialization format fn legacy_gamma() -> Self { - Self::SrgbGamma + Self::RgbGamma + } +} + +#[repr(C)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[widget(Dropdown)] +pub enum GradientHueDirection { + /// Interpolates across the shorter arc around the hue wheel. + #[default] + Shorter, + /// Interpolates across the longer arc around the hue wheel. + Longer, + /// Interpolates with the hue angle always increasing. + Increasing, + /// Interpolates with the hue angle always decreasing. + Decreasing, +} + +impl GradientHueDirection { + pub fn is_default(&self) -> bool { + *self == Self::default() } } @@ -966,7 +1153,7 @@ mod tests { fn default_is_empty_and_black_to_white_is_the_artist_starting_gradient() { assert!(Gradient::default().is_empty()); assert_eq!(Gradient::black_to_white().positions(), vec![0., 1.]); - assert_eq!(Gradient::default().evaluate(0.5, Default::default(), Default::default()), Color::BLACK); + assert_eq!(Gradient::default().evaluate(0.5, Default::default(), Default::default(), Default::default()), Color::BLACK); } #[test] @@ -1030,24 +1217,29 @@ mod tests { } #[test] - fn gradient_interpolation_always_serializes_and_its_absence_reads_as_legacy_gamma() { - let default_interpolation = GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE])); - let json = serde_json::to_string(&default_interpolation).unwrap(); + fn gradient_space_always_serializes_and_its_absence_reads_as_legacy_gamma() { + let default_space = GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE])); + let json = serde_json::to_string(&default_space).unwrap(); assert!( - json.contains(r#""gradient_interpolation":"SrgbLinear""#), - "the interpolation must serialize even at its default, marking the ramp as post-legacy: {json}" + json.contains(r#""gradient_space":"OkLab""#), + "the space must serialize even at its default, marking the ramp as post-legacy: {json}" ); - assert_eq!(serde_json::from_str::(&json).unwrap(), default_interpolation); + assert_eq!(serde_json::from_str::(&json).unwrap(), default_space); + + // The pre-rename field key and variant names from the interim format alias to the current ones + let renamed_away = json.replace(r#""gradient_space""#, r#""gradient_interpolation""#).replace(r#""OkLab""#, r#""SrgbLinear""#); + let recovered = serde_json::from_str::(&renamed_away).unwrap(); + assert_eq!(recovered.gradient_space, GradientSpace::RgbLinear, "the old key and variant names should decode via their aliases"); let gamma = GradientRamp { - gradient_interpolation: GradientInterpolation::SrgbGamma, - ..default_interpolation.clone() + gradient_space: GradientSpace::RgbGamma, + ..default_space.clone() }; let json = serde_json::to_string(&gamma).unwrap(); - assert!(json.contains(r#""gradient_interpolation":"SrgbGamma""#), "a non-default interpolation must serialize: {json}"); + assert!(json.contains(r#""gradient_space":"RgbGamma""#), "a non-default space must serialize: {json}"); assert_eq!(serde_json::from_str::(&json).unwrap(), gamma); - let legacy_json = json.replace(r#","gradient_interpolation":"SrgbGamma""#, ""); + let legacy_json = json.replace(r#","gradient_space":"RgbGamma""#, ""); assert_eq!( serde_json::from_str::(&legacy_json).unwrap(), gamma, @@ -1056,38 +1248,38 @@ mod tests { } #[test] - fn gradient_interpolation_round_trips_through_the_item_attribute() { + fn gradient_space_round_trips_through_the_item_attribute() { let ramp = GradientRamp { - gradient_interpolation: GradientInterpolation::SrgbGamma, + gradient_space: GradientSpace::RgbGamma, ..GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE])) }; let item = Item::::from(ramp.clone()); assert_eq!( - item.attribute_cloned_or_default::(ATTR_GRADIENT_INTERPOLATION), - GradientInterpolation::SrgbGamma, - "the runtime item should carry the interpolation as its attribute" + item.attribute_cloned_or_default::(ATTR_GRADIENT_SPACE), + GradientSpace::RgbGamma, + "the runtime item should carry the space as its attribute" ); assert_eq!(GradientRamp::from(&item), ramp); let linear = Item::::from(GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE]))); assert!( - linear.attribute::(ATTR_GRADIENT_INTERPOLATION).is_none(), + linear.attribute::(ATTR_GRADIENT_SPACE).is_none(), "the default Linear must stay absent rather than materialize" ); } #[test] - fn linear_interpolation_densifies_samples_where_gamma_segments_deviate() { + fn linear_space_densifies_samples_where_gamma_segments_deviate() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); // Gamma needs no synthesized samples since the renderers already draw gamma segments - assert_eq!(gradient.interpolated_samples(GradientInterpolation::SrgbGamma).len(), 2); + assert_eq!(gradient.interpolated_samples(GradientSpace::RgbGamma, Default::default()).len(), 2); // A linear black-to-white ramp curves away from any single gamma segment, so samples must densify, // keeping the end stops in place and every synthesized color on the linear-light line - let samples = gradient.interpolated_samples(GradientInterpolation::SrgbLinear); - assert!(samples.len() > 2, "linear interpolation should synthesize samples, got {}", samples.len()); + let samples = gradient.interpolated_samples(GradientSpace::RgbLinear, Default::default()); + assert!(samples.len() > 2, "the linear space should synthesize samples, got {}", samples.len()); assert_eq!(samples.first().unwrap().0, 0.); assert_eq!(samples.last().unwrap().0, 1.); for &(position, color, _) in &samples { @@ -1100,11 +1292,11 @@ mod tests { // Identical end colors leave nothing to densify let flat = Gradient::from(vec![Color::WHITE, Color::WHITE]); - assert_eq!(flat.interpolated_samples(GradientInterpolation::SrgbLinear).len(), 2); + assert_eq!(flat.interpolated_samples(GradientSpace::RgbLinear, Default::default()).len(), 2); } #[test] - fn midpoint_bias_and_interpolation_space_compose_within_playback_tolerance() { + fn midpoint_bias_and_gradient_space_compose_within_playback_tolerance() { let color_pairs = [ (Color::BLACK, Color::WHITE), (Color::RED, Color::WHITE), @@ -1113,14 +1305,25 @@ mod tests { // Sweep the midpoint against each space so its bias and the space's curvature also oppose each other, // asserting the emitted samples' gamma playback tracks the composed midpoint-then-space theoretical curve - for gradient_interpolation in [GradientInterpolation::SrgbLinear, GradientInterpolation::SrgbGamma] { + for (gradient_space, gradient_hue_direction) in [ + (GradientSpace::OkLab, GradientHueDirection::Shorter), + (GradientSpace::OkLCh, GradientHueDirection::Shorter), + (GradientSpace::OkLCh, GradientHueDirection::Longer), + (GradientSpace::Lab, GradientHueDirection::Shorter), + (GradientSpace::LCh, GradientHueDirection::Shorter), + (GradientSpace::Hsl, GradientHueDirection::Shorter), + (GradientSpace::Hsl, GradientHueDirection::Longer), + (GradientSpace::Hsv, GradientHueDirection::Shorter), + (GradientSpace::RgbLinear, GradientHueDirection::Shorter), + (GradientSpace::RgbGamma, GradientHueDirection::Shorter), + ] { for &(color_a, color_b) in &color_pairs { for midpoint_step in 1..40 { let midpoint = midpoint_step as f64 / 40.; let mut gradient = Gradient::from(vec![color_a, color_b]); gradient.set_midpoints(&[midpoint, 0.5]); - let samples = gradient.interpolated_samples(gradient_interpolation); + let samples = gradient.interpolated_samples(gradient_space, gradient_hue_direction); for probe in 0..=1000 { let t = probe as f64 / 1000.; @@ -1139,11 +1342,11 @@ mod tests { } }; - let true_color = interpolate_stop_colors(color_a, color_b, apply_midpoint(t, midpoint) as f32, gradient_interpolation); + let true_color = interpolate_stop_colors(color_a, color_b, apply_midpoint(t, midpoint) as f32, gradient_space, gradient_hue_direction); let deviation = max_gamma_channel_deviation(playback, true_color); assert!( deviation <= 4. / 255., - "playback deviates {:.1}/255 at t={t} with midpoint {midpoint} in {gradient_interpolation:?} between {color_a:?} and {color_b:?}", + "playback deviates {:.1}/255 at t={t} with midpoint {midpoint} in {gradient_space:?} ({gradient_hue_direction:?}) between {color_a:?} and {color_b:?}", deviation * 255. ); } @@ -1156,28 +1359,100 @@ mod tests { fn clear_spread_evaluates_to_transparency_outside_the_unit_range() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); - assert_eq!(gradient.evaluate(-0.25, GradientSpread::Clear, Default::default()), Color::TRANSPARENT); - assert_eq!(gradient.evaluate(1.25, GradientSpread::Clear, Default::default()), Color::TRANSPARENT); + assert_eq!(gradient.evaluate(-0.25, GradientSpread::Clear, Default::default(), Default::default()), Color::TRANSPARENT); + assert_eq!(gradient.evaluate(1.25, GradientSpread::Clear, Default::default(), Default::default()), Color::TRANSPARENT); for t in [0., 0.25, 1.] { assert_eq!( - gradient.evaluate(t, GradientSpread::Clear, Default::default()), - gradient.evaluate(t, GradientSpread::Pad, Default::default()), + gradient.evaluate(t, GradientSpread::Clear, Default::default(), Default::default()), + gradient.evaluate(t, GradientSpread::Pad, Default::default(), Default::default()), "inside the range Clear must match Pad at t = {t}" ); } } #[test] - fn evaluate_follows_the_interpolation_space() { + fn evaluate_follows_the_gradient_space() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); - let linear = gradient.evaluate(0.5, Default::default(), GradientInterpolation::SrgbLinear); - let gamma = gradient.evaluate(0.5, Default::default(), GradientInterpolation::SrgbGamma); + let oklab = gradient.evaluate(0.5, Default::default(), GradientSpace::OkLab, Default::default()); + let linear = gradient.evaluate(0.5, Default::default(), GradientSpace::RgbLinear, Default::default()); + let gamma = gradient.evaluate(0.5, Default::default(), GradientSpace::RgbGamma, Default::default()); assert_eq!(linear, Color::BLACK.lerp(&Color::WHITE, 0.5)); assert_eq!(gamma, Color::BLACK.lerp_gamma_srgb(&Color::WHITE, 0.5)); - assert_ne!(linear, gamma, "the two spaces must produce different mid colors between black and white"); + + // Halfway in OkLab between black and white is lightness 0.5, which cubes to 1/8 linear light in every channel + for channel in [oklab.r(), oklab.g(), oklab.b()] { + assert!((channel - 0.125).abs() < 1e-3, "the OkLab mid color of black and white should be 1/8 linear light, got {channel}"); + } + assert_eq!(oklab.a(), 1.); + + assert_ne!(linear, gamma, "each space must produce a different mid color between black and white"); + assert_ne!(oklab, linear, "each space must produce a different mid color between black and white"); + assert_ne!(oklab, gamma, "each space must produce a different mid color between black and white"); + } + + #[test] + fn polar_spaces_take_the_shorter_hue_arc_and_ignore_powerless_hues() { + use color::ColorSpace; + + // Red to blue in HSL crosses through magenta on the shorter arc (300 degrees), not through green (120 degrees) + let red_to_blue = Gradient::from(vec![Color::RED, Color::BLUE]); + let magenta = red_to_blue.evaluate(0.5, Default::default(), GradientSpace::Hsl, Default::default()); + for (channel, expected) in [(magenta.r(), 1.), (magenta.g(), 0.), (magenta.b(), 1.)] { + assert!((channel - expected).abs() < 1e-3, "the HSL mid color of red and blue should be magenta, got {magenta:?}"); + } + + // White's hue is powerless, so an OkLCh interpolation toward it keeps red's hue instead of drifting toward white's arbitrary hue + let red_to_white = Gradient::from(vec![Color::RED, Color::WHITE]); + let pink = red_to_white.evaluate(0.5, Default::default(), GradientSpace::OkLCh, Default::default()); + let [_, _, red_hue] = color::Oklch::from_linear_srgb([Color::RED.r(), Color::RED.g(), Color::RED.b()]); + let [_, pink_chroma, pink_hue] = color::Oklch::from_linear_srgb([pink.r(), pink.g(), pink.b()]); + assert!(pink_chroma > 0.05, "the mid color should stay chromatic, got {pink:?}"); + assert!((pink_hue - red_hue).abs() < 0.5, "the mid hue should hold red's {red_hue} degrees, got {pink_hue}"); + + // HSV rides the cube's top face toward white, keeping the mid tint at full brightness where HSL dips + let tint = red_to_white.evaluate(0.5, Default::default(), GradientSpace::Hsv, Default::default()); + for (channel, target) in tint.to_gamma_srgb_channels().into_iter().zip([1., 0.5, 0.5, 1.]) { + assert!((channel - target).abs() < 1e-3, "the HSV mid tint of red and white should be gamma (1, 0.5, 0.5), got {tint:?}"); + } + + // Toward black both saturation and value halve, the classic HSV shade that neither HSL nor HWB produces + let red_to_black = Gradient::from(vec![Color::RED, Color::BLACK]); + let shade = red_to_black.evaluate(0.5, Default::default(), GradientSpace::Hsv, Default::default()); + for (channel, target) in shade.to_gamma_srgb_channels().into_iter().zip([0.5, 0.25, 0.25, 1.]) { + assert!((channel - target).abs() < 1e-3, "the HSV mid shade of red and black should be gamma (0.5, 0.25, 0.25), got {shade:?}"); + } + } + + #[test] + fn hue_direction_chooses_the_arc_around_the_hue_wheel() { + // Red to blue spans 240 degrees upward, so Longer and Increasing agree on the green route + // while Shorter and Decreasing cross through magenta + let red_to_blue = Gradient::from(vec![Color::RED, Color::BLUE]); + let expectations = [ + (GradientHueDirection::Shorter, [1., 0., 1.]), + (GradientHueDirection::Longer, [0., 1., 0.]), + (GradientHueDirection::Increasing, [0., 1., 0.]), + (GradientHueDirection::Decreasing, [1., 0., 1.]), + ]; + for (gradient_hue_direction, expected_rgb) in expectations { + let mid = red_to_blue.evaluate(0.5, Default::default(), GradientSpace::Hsl, gradient_hue_direction); + for (channel, target) in [mid.r(), mid.g(), mid.b()].into_iter().zip(expected_rgb) { + assert!( + (channel - target).abs() < 1e-3, + "the {gradient_hue_direction:?} mid of red and blue should be {expected_rgb:?}, got {mid:?}" + ); + } + } + + // Identical hues under Longer take a full turn around the wheel, passing through cyan halfway + let red_to_red = Gradient::from(vec![Color::RED, Color::RED]); + let mid = red_to_red.evaluate(0.5, Default::default(), GradientSpace::Hsl, GradientHueDirection::Longer); + for (channel, target) in [mid.r(), mid.g(), mid.b()].into_iter().zip([0., 1., 1.]) { + assert!((channel - target).abs() < 1e-3, "the full-turn mid of red and red should be cyan, got {mid:?}"); + } } #[test] @@ -1215,13 +1490,17 @@ mod tests { gradient.set_positions(&[1.5, 0.4, -0.5]); assert_eq!(gradient.positions(), vec![1.5, 0.4, -0.5]); - let sample_positions: Vec = gradient.interpolated_samples(GradientInterpolation::SrgbGamma).iter().map(|(position, ..)| *position).collect(); + let sample_positions: Vec = gradient + .interpolated_samples(GradientSpace::RgbGamma, Default::default()) + .iter() + .map(|(position, ..)| *position) + .collect(); assert!(sample_positions.windows(2).all(|pair| pair[0] <= pair[1]), "samples must ascend: {sample_positions:?}"); assert_eq!(sample_positions.first(), Some(&0.)); assert_eq!(sample_positions.last(), Some(&1.)); - assert_eq!(gradient.evaluate(0., Default::default(), Default::default()), Color::RED); - assert_eq!(gradient.evaluate(1., Default::default(), Default::default()), Color::WHITE); + assert_eq!(gradient.evaluate(0., Default::default(), Default::default(), Default::default()), Color::RED); + assert_eq!(gradient.evaluate(1., Default::default(), Default::default(), Default::default()), Color::WHITE); } #[test] @@ -1229,10 +1508,14 @@ mod tests { let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]); gradient.set_positions(&[f64::INFINITY, f64::NEG_INFINITY]); - let sample_positions: Vec = gradient.interpolated_samples(GradientInterpolation::SrgbGamma).iter().map(|(position, ..)| *position).collect(); + let sample_positions: Vec = gradient + .interpolated_samples(GradientSpace::RgbGamma, Default::default()) + .iter() + .map(|(position, ..)| *position) + .collect(); assert_eq!(sample_positions, vec![0., 1.]); - assert_eq!(gradient.evaluate(0., Default::default(), Default::default()), Color::BLACK); - assert_eq!(gradient.evaluate(1., Default::default(), Default::default()), Color::WHITE); + assert_eq!(gradient.evaluate(0., Default::default(), Default::default(), Default::default()), Color::BLACK); + assert_eq!(gradient.evaluate(1., Default::default(), Default::default(), Default::default()), Color::WHITE); } #[test] @@ -1240,9 +1523,16 @@ mod tests { let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK, Color::RED]); gradient.set_positions(&[0., f64::NAN, 1.]); - let sample_positions: Vec = gradient.interpolated_samples(GradientInterpolation::SrgbGamma).iter().map(|(position, ..)| *position).collect(); + let sample_positions: Vec = gradient + .interpolated_samples(GradientSpace::RgbGamma, Default::default()) + .iter() + .map(|(position, ..)| *position) + .collect(); assert_eq!(sample_positions, vec![0., 1.]); - assert_eq!(gradient.evaluate(0.5, Default::default(), Default::default()), Color::WHITE.lerp(&Color::RED, 0.5)); + assert_eq!( + gradient.evaluate(0.5, Default::default(), GradientSpace::RgbLinear, Default::default()), + Color::WHITE.lerp(&Color::RED, 0.5) + ); // A non-finite position is preserved as nondefault so write-back elision cannot resurrect the dropped stop assert!(gradient.nondefault_positions().is_some()); @@ -1250,8 +1540,8 @@ mod tests { // With every position NaN the gradient samples as stopless, painting solid black to signal the upstream bug let mut gradient = Gradient::from(vec![Color::WHITE, Color::RED]); gradient.set_positions(&[f64::NAN, f64::NAN]); - assert!(gradient.interpolated_samples(GradientInterpolation::SrgbGamma).is_empty()); - assert_eq!(gradient.evaluate(0.5, Default::default(), Default::default()), Color::BLACK); + assert!(gradient.interpolated_samples(GradientSpace::RgbGamma, Default::default()).is_empty()); + assert_eq!(gradient.evaluate(0.5, Default::default(), Default::default(), Default::default()), Color::BLACK); } #[test] @@ -1259,19 +1549,19 @@ mod tests { let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]); gradient.set_positions(&[0.3, 1.]); - let samples = gradient.interpolated_samples(GradientInterpolation::SrgbGamma); + let samples = gradient.interpolated_samples(GradientSpace::RgbGamma, Default::default()); assert_eq!(samples[0], (0.3, Color::WHITE, None), "renderers that need a flat lead-in before the first stop add it themselves"); } #[test] fn nan_midpoints_read_as_linear() { let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); - let linear_result = gradient.evaluate(0.25, Default::default(), Default::default()); + let linear_result = gradient.evaluate(0.25, Default::default(), Default::default(), Default::default()); gradient.set_midpoints(&[f64::NAN, f64::NAN]); - assert_eq!(gradient.evaluate(0.25, Default::default(), Default::default()), linear_result); + assert_eq!(gradient.evaluate(0.25, Default::default(), Default::default(), Default::default()), linear_result); let no_nan_annotations = gradient - .interpolated_samples(GradientInterpolation::SrgbGamma) + .interpolated_samples(GradientSpace::RgbGamma, Default::default()) .iter() .all(|(position, _, midpoint)| position.is_finite() && !midpoint.is_some_and(|midpoint| midpoint.is_nan())); assert!(no_nan_annotations, "NaN must not escape into rendered sample annotations"); diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index fec24d41d9..26da3ba132 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -9,7 +9,7 @@ pub mod vector; // Re-export commonly used types at the crate root pub use core_types as gcore; -pub use gradient::{Gradient, GradientForm, GradientInterpolation, GradientRamp, GradientSpread, GradientStop}; +pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, GradientStop}; pub use markers::{ATTR_EDITOR_CLICK_TARGET, ATTR_GRADIENT_FORM, ATTR_GRADIENT_SPREAD}; pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; diff --git a/node-graph/libraries/vector-types/src/markers.rs b/node-graph/libraries/vector-types/src/markers.rs index 19435d1167..659418819f 100644 --- a/node-graph/libraries/vector-types/src/markers.rs +++ b/node-graph/libraries/vector-types/src/markers.rs @@ -6,8 +6,10 @@ use core_types::attribute::Attribute; core_types::attribute! { /// Gradient's spread behavior past its endpoints (`Pad`, `Reflect`, `Repeat`, or `Clear`). pub GradientSpread("gradient_spread"): crate::gradient::GradientSpread; - /// Gradient's `GradientInterpolation` (`SrgbLinear` or `SrgbGamma`), the color space its stops blend in. - pub GradientInterpolation("gradient_interpolation"): crate::gradient::GradientInterpolation; + /// Gradient's `GradientSpace`, the color space its stops blend in. + pub GradientSpace("gradient_space"): crate::gradient::GradientSpace; + /// Gradient's `GradientHueDirection`, the hue path polar spaces interpolate along. + pub GradientHueDirection("gradient_hue_direction"): crate::gradient::GradientHueDirection; /// Gradient's shape (`Linear` or `Radial`). pub GradientForm("gradient_form"): crate::gradient::GradientForm; /// Optional `Vector` that overrides the item's own geometry for click-target generation. @@ -22,12 +24,14 @@ core_types::attribute! { core_types::named_value! { for crate::gradient::GradientSpread; for crate::gradient::GradientForm; - for crate::gradient::GradientInterpolation; + for crate::gradient::GradientSpace; + for crate::gradient::GradientHueDirection; } pub const ATTR_GRADIENT_SPREAD: &str = GradientSpread::NAME; pub const ATTR_GRADIENT_FORM: &str = GradientForm::NAME; -pub const ATTR_GRADIENT_INTERPOLATION: &str = GradientInterpolation::NAME; +pub const ATTR_GRADIENT_SPACE: &str = GradientSpace::NAME; +pub const ATTR_GRADIENT_HUE_DIRECTION: &str = GradientHueDirection::NAME; pub const ATTR_EDITOR_CLICK_TARGET: &str = EditorClickTarget::NAME; #[cfg(test)] diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index eb3291fc09..8d2e80dc55 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -76,7 +76,7 @@ impl FillChoice { let hex = srgba.to_rgba_hex(); Some(format!("linear-gradient(#{hex}, #{hex})")) } - Self::Gradient(ramp) => Some(ramp.stops.to_css_linear_gradient(ramp.gradient_interpolation)), + Self::Gradient(ramp) => Some(ramp.stops.to_css_linear_gradient(ramp.gradient_space, ramp.gradient_hue_direction)), } } } diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index c56f04d01c..7ade088f5f 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -14,7 +14,7 @@ use graphic_types::graphic::{Graphic, GraphicLevel, RowStep, TryFromGraphic, wal use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr}; use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector}; use raster_types::{CPU, GPU, Raster}; -use vector_types::gradient::{GradientForm as GradientFormValue, GradientSpread}; +use vector_types::gradient::{GradientForm as GradientFormValue, GradientHueDirection, GradientSpace, GradientSpread}; use vector_types::{Gradient, ReferencePoint}; fn arena_exhausted() -> Interrupt { @@ -257,6 +257,10 @@ attribute_reads! { read_gradient_form_attribute: GradientFormValue => GradientFormValue; /// Reads a named gradient-spread attribute, such as `gradient_spread`. read_gradient_spread_attribute: GradientSpread => GradientSpread; + /// Reads a named gradient-space attribute, such as `gradient_space`. + read_gradient_space_attribute: GradientSpace => GradientSpace; + /// Reads a named gradient-hue-direction attribute, such as `gradient_hue_direction`. + read_gradient_hue_direction_attribute: GradientHueDirection => GradientHueDirection; } /// Nests the input graphical content in a wrapper graphic. This essentially "groups" the input. diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 55c9235a5e..df95af693a 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -11,7 +11,7 @@ use math_parser::value::{Number, Value}; use rand::{Rng, SeedableRng}; use std::ops::{Add, Mul, Rem, Sub}; use vector_types::Gradient; -use vector_types::markers::{GradientForm as GradientFormAttr, GradientInterpolation as GradientInterpolationAttr, GradientSpread as GradientSpreadAttr}; +use vector_types::markers::{GradientForm as GradientFormAttr, GradientHueDirection as GradientHueDirectionAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr}; /// The struct that stores the context for the maths parser. /// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs. @@ -1214,18 +1214,24 @@ fn gradient_spread(_: impl Ctx, gradient: Gradient, gradient_spread: vector_type (gradient, Attr(gradient_spread)) } -/// Sets the color space each gradient in the input list blends between its stops with: linear light or gamma-encoded sRGB. +/// Sets the color space in which each gradient in the input list interpolates between its stops. #[node_macro::node(category("Gradient"))] -fn gradient_interpolation(_: impl Ctx, gradient: Gradient, gradient_interpolation: vector_types::GradientInterpolation) -> (Gradient, Attr) { - (gradient, Attr(gradient_interpolation)) +fn gradient_space(_: impl Ctx, gradient: Gradient, gradient_space: vector_types::GradientSpace) -> (Gradient, Attr) { + (gradient, Attr(gradient_space)) +} + +/// Sets the hue path each gradient in the input list interpolates along in polar color spaces. +#[node_macro::node(category("Gradient"))] +fn gradient_hue_direction(_: impl Ctx, gradient: Gradient, gradient_hue_direction: vector_types::GradientHueDirection) -> (Gradient, Attr) { + (gradient, Attr(gradient_hue_direction)) } /// Sets the position of each of a gradient's stops, a factor from 0 to 1 along the gradient. /// /// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each stop to its default evenly spaced position. #[node_macro::node(category("Gradient"))] -fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: List) -> Gradient { - let positions: Vec = positions.iter_element_values().copied().collect(); +fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: IList) -> Gradient { + let positions: Vec = positions.iter().collect(); gradient.set_positions(&positions); gradient } @@ -1236,13 +1242,13 @@ fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: List) /// /// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each midpoint to its default of 0.5. #[node_macro::node(category("Gradient"))] -fn gradient_midpoints(_: impl Ctx, mut gradient: Gradient, midpoints: List) -> Gradient { - let midpoints: Vec = midpoints.iter_element_values().copied().collect(); +fn gradient_midpoints(_: impl Ctx, mut gradient: Gradient, midpoints: IList) -> Gradient { + let midpoints: Vec = midpoints.iter().collect(); gradient.set_midpoints(&midpoints); gradient } -/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, Repeat, or Clear. Colors between stops blend in the gradient's `gradient_interpolation` color space. +/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `gradient_spread` attribute: Pad (default), Reflect, Repeat, or Clear. Colors between stops interpolate in the gradient's `gradient_space` color space. #[node_macro::node(category("Color"))] fn sample_gradient( ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, @@ -1256,8 +1262,9 @@ fn sample_gradient( } let gradient_spread = gradient.lane(0).attr::(); - let gradient_interpolation = gradient.lane(0).attr::(); - Ok(gradient.element_ref(0).evaluate(position, gradient_spread, gradient_interpolation)) + let gradient_space = gradient.lane(0).attr::(); + let gradient_hue_direction = gradient.lane(0).attr::(); + Ok(gradient.element_ref(0).evaluate(position, gradient_spread, gradient_space, gradient_hue_direction)) } /// Constructs a footprint value which may be set to any transformation of a unit square describing a render area, and a render resolution at least 1x1 integer pixels. diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 5683db3faf..55f90d3724 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -38,15 +38,15 @@ mod blend_std { impl Blend for Gradient { // TODO: This joining is unfaithful in several ways: it samples only at stop positions so midpoint curves flatten away; - // it evaluates both sources with the default spread and interpolation rather than their own attributes (which this + // it evaluates both sources with the default spread and space rather than their own attributes (which this // element-level impl cannot read); and the output keeps over's attributes despite being sampled in the default space fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { let mut combined_stops = self.positions().into_iter().chain(under.positions()).collect::>(); combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6); let stops = combined_stops.into_iter().map(|position| { - let over_color = self.evaluate(position, Default::default(), Default::default()); - let under_color = under.evaluate(position, Default::default(), Default::default()); + let over_color = self.evaluate(position, Default::default(), Default::default(), Default::default()); + let under_color = under.evaluate(position, Default::default(), Default::default(), Default::default()); let color = blend_fn(over_color, under_color); GradientStop { position, midpoint: 0.5, color } }); diff --git a/node-graph/nodes/raster/src/gradient_map.rs b/node-graph/nodes/raster/src/gradient_map.rs index 0b4a644519..e89ed1f146 100644 --- a/node-graph/nodes/raster/src/gradient_map.rs +++ b/node-graph/nodes/raster/src/gradient_map.rs @@ -24,13 +24,14 @@ fn gradient_map + Clone + Send + Sync + core_types::CacheHash + return image; } let gradient_spread = gradient.lane(0).attr::(); - let gradient_interpolation = gradient.lane(0).attr::(); + let gradient_space = gradient.lane(0).attr::(); + let gradient_hue_direction = gradient.lane(0).attr::(); let gradient = gradient.element_ref(0); image.adjust(|color| { let intensity = color.luminance_rec_709(); let intensity = if reverse { 1. - intensity } else { intensity }; - gradient.evaluate(intensity as f64, gradient_spread, gradient_interpolation) + gradient.evaluate(intensity as f64, gradient_spread, gradient_space, gradient_hue_direction) }); image diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index db31353413..7315855dca 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -39,13 +39,22 @@ use vector_types::vector::misc::{ CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups, bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, }; -use vector_types::vector::style::{DashPattern, Gradient, GradientInterpolation, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; use vector_types::vector::{PointDomain, RegionDomain}; /// The gradient color for one assign-colors position, replaying the /// randomized draws up to it. -fn assign_color_at(gradient: &Gradient, gradient_interpolation: vector_types::GradientInterpolation, position: usize, length: usize, randomize: bool, seed: SeedValue, repeat_every: u32) -> Color { +fn assign_color_at( + gradient: &Gradient, + gradient_space: vector_types::GradientSpace, + gradient_hue_direction: vector_types::GradientHueDirection, + position: usize, + length: usize, + randomize: bool, + seed: SeedValue, + repeat_every: u32, +) -> Color { let factor = match randomize { true => { let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); @@ -61,7 +70,7 @@ fn assign_color_at(gradient: &Gradient, gradient_interpolation: vector_types::Gr _ => position as f64 % repeat_every as f64 / (repeat_every - 1) as f64, }, }; - gradient.evaluate(factor, Default::default(), gradient_interpolation) + gradient.evaluate(factor, Default::default(), gradient_space, gradient_hue_direction) } /// Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient. @@ -104,7 +113,8 @@ fn assign_colors<'e>( if gradient.is_empty() { return Ok((content.lane(lane).map_element(element), Attr(existing_fill), Attr(existing_stroke))); } - let gradient_interpolation = gradient.lane(0).attr::(); + let gradient_space = gradient.lane(0).attr::(); + let gradient_hue_direction = gradient.lane(0).attr::(); let gradient_element = gradient.element_ref(0); let reversed; let gradient_element = match reverse { @@ -115,7 +125,7 @@ fn assign_colors<'e>( false => gradient_element, }; - let color = assign_color_at(gradient_element, gradient_interpolation, lane, content.len(), randomize, seed, repeat_every); + let color = assign_color_at(gradient_element, gradient_space, gradient_hue_direction, lane, content.len(), randomize, seed, repeat_every); let paint = List::new_from_element(color).into_graphic_list(); let parked = park_paint(ctx.arena(), paint)?; @@ -173,7 +183,8 @@ fn assign_colors_graphic<'e>( if gradient.is_empty() { return Ok(content.lane(lane).map_element(original.clone())); } - let gradient_interpolation = gradient.lane(0).attr::(); + let gradient_space = gradient.lane(0).attr::(); + let gradient_hue_direction = gradient.lane(0).attr::(); let gradient_element = gradient.element_ref(0); let reversed; let gradient_element = match reverse { @@ -219,7 +230,7 @@ fn assign_colors_graphic<'e>( Some(mut rows) => { for row in 0..rows.len() { let has_stroke = rows.element(row).is_some_and(|vector| vector.stroke.is_some()); - let color = assign_color_at(gradient_element, gradient_interpolation, position + row, length, randomize, seed, repeat_every); + let color = assign_color_at(gradient_element, gradient_space, gradient_hue_direction, position + row, length, randomize, seed, repeat_every); let paint = List::new_from_element(color).into_graphic_list(); if fill { set_paint_attribute_at(&mut rows, row, ATTR_FILL, paint.clone()); diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 54e3ac3e34..89ae91362b 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -286,7 +286,9 @@ pub fn node_inputs(fields: &[registry::FieldMetadata], first_node_io: &NodeIOTyp let Some(ty) = field.default_type.as_ref().or_else(|| first_node_io.inputs.get(index)) else { return NodeInput::value(TaggedValue::None, true); }; - let exposed = if index == 0 { *ty != fn_type_fut!(Context, ()) } else { field.exposed }; + // A unit primary is a placeholder, so it stays hidden in either of its spellings + let unit_primary = *ty == fn_type_fut!(Context, ()) || *ty == registry::record_source_type::<()>(); + let exposed = if index == 0 { !unit_primary } else { field.exposed }; match &field.value_source { RegistryValueSource::None => {}