diff --git a/editor/src/messages/color_picker/color_picker_message.rs b/editor/src/messages/color_picker/color_picker_message.rs index a6063078a3..0a969d581b 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, GradientHueDirection, GradientSpace, GradientSpread}; +use graphene_std::vector::style::{FillChoice, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread}; /// Identifies which RGB channel a numeric input change targets. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -55,6 +55,8 @@ pub enum ColorPickerMessage { 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 }, + /// Gradient interpolation choice: the path the stops interpolate along, from the "Intrp." dropdown. + SetGradientInterpolation { gradient_interpolation: GradientInterpolation }, /// 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 b792c1a3c0..b74013a6c6 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, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, GradientStops}; +use graphene_std::vector::style::{FillChoice, Gradient, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops}; /// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops). const MIN_MIDPOINT: f64 = 0.01; @@ -33,6 +33,7 @@ pub struct ColorPickerMessageHandler { gradient_space: GradientSpace, gradient_cyclic: bool, gradient_hue_direction: GradientHueDirection, + gradient_interpolation: GradientInterpolation, active_marker_index: Option, active_marker_is_midpoint: bool, @@ -58,6 +59,7 @@ impl Default for ColorPickerMessageHandler { gradient_space: GradientSpace::default(), gradient_cyclic: false, gradient_hue_direction: GradientHueDirection::default(), + gradient_interpolation: GradientInterpolation::default(), active_marker_index: None, active_marker_is_midpoint: false, allow_none: true, @@ -82,6 +84,7 @@ impl MessageHandler for ColorPickerMessageHandler { self.gradient_space = GradientSpace::default(); self.gradient_cyclic = false; self.gradient_hue_direction = GradientHueDirection::default(); + self.gradient_interpolation = GradientInterpolation::default(); self.active_marker_index = None; self.active_marker_is_midpoint = false; } @@ -91,6 +94,7 @@ impl MessageHandler for ColorPickerMessageHandler { self.gradient_space = GradientSpace::default(); self.gradient_cyclic = false; self.gradient_hue_direction = GradientHueDirection::default(); + self.gradient_interpolation = GradientInterpolation::default(); self.active_marker_index = None; self.active_marker_is_midpoint = false; self.adopt_color(color); @@ -102,6 +106,7 @@ impl MessageHandler for ColorPickerMessageHandler { self.gradient_space = ramp.gradient_space; self.gradient_cyclic = ramp.gradient_cyclic; self.gradient_hue_direction = ramp.gradient_hue_direction; + self.gradient_interpolation = ramp.gradient_interpolation; let gradient = Gradient::from(ramp); let first_color = gradient.color(0).unwrap_or(Color::BLACK); self.gradient = Some(gradient); @@ -212,28 +217,20 @@ impl MessageHandler for ColorPickerMessageHandler { responses.add(FrontendMessage::ColorPickerStartHistoryTransaction); self.gradient_spread = gradient_spread; responses.add(FrontendMessage::ColorPickerColorChanged { - value: FillChoice::Gradient(GradientRamp { - gradient_spread, - gradient_space: self.gradient_space, - gradient_cyclic: self.gradient_cyclic, - gradient_hue_direction: self.gradient_hue_direction, - ..GradientRamp::from(gradient) - }), + value: FillChoice::Gradient(GradientRamp::from(gradient).with_settings(self.gradient_settings())), }); self.send_layouts(responses); } ColorPickerMessage::SetGradientCyclic { gradient_cyclic } => { - let Some(gradient) = &self.gradient else { return }; + let Some(gradient) = &mut self.gradient else { return }; responses.add(FrontendMessage::ColorPickerStartHistoryTransaction); - self.gradient_cyclic = gradient_cyclic; + + let previous_cyclic = std::mem::replace(&mut self.gradient_cyclic, gradient_cyclic); + gradient.hold_positions_across_cyclic_change(previous_cyclic, gradient_cyclic); + + let ramp = GradientRamp::from(&*gradient); responses.add(FrontendMessage::ColorPickerColorChanged { - value: FillChoice::Gradient(GradientRamp { - gradient_spread: self.gradient_spread, - gradient_space: self.gradient_space, - gradient_cyclic, - gradient_hue_direction: self.gradient_hue_direction, - ..GradientRamp::from(gradient) - }), + value: FillChoice::Gradient(ramp.with_settings(self.gradient_settings())), }); self.send_layouts(responses); } @@ -242,13 +239,7 @@ impl MessageHandler for ColorPickerMessageHandler { responses.add(FrontendMessage::ColorPickerStartHistoryTransaction); self.gradient_space = gradient_space; responses.add(FrontendMessage::ColorPickerColorChanged { - value: FillChoice::Gradient(GradientRamp { - gradient_spread: self.gradient_spread, - gradient_space, - gradient_cyclic: self.gradient_cyclic, - gradient_hue_direction: self.gradient_hue_direction, - ..GradientRamp::from(gradient) - }), + value: FillChoice::Gradient(GradientRamp::from(gradient).with_settings(self.gradient_settings())), }); self.send_layouts(responses); } @@ -257,13 +248,16 @@ impl MessageHandler for ColorPickerMessageHandler { 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_cyclic: self.gradient_cyclic, - gradient_hue_direction, - ..GradientRamp::from(gradient) - }), + value: FillChoice::Gradient(GradientRamp::from(gradient).with_settings(self.gradient_settings())), + }); + self.send_layouts(responses); + } + ColorPickerMessage::SetGradientInterpolation { gradient_interpolation } => { + let Some(gradient) = &self.gradient else { return }; + responses.add(FrontendMessage::ColorPickerStartHistoryTransaction); + self.gradient_interpolation = gradient_interpolation; + responses.add(FrontendMessage::ColorPickerColorChanged { + value: FillChoice::Gradient(GradientRamp::from(gradient).with_settings(self.gradient_settings())), }); self.send_layouts(responses); } @@ -315,6 +309,17 @@ impl ColorPickerMessageHandler { self.old_is_none = is_none; } + /// The whole-ramp settings the picker is currently editing with, bundled for sampling and for emitting ramps. + fn gradient_settings(&self) -> GradientSettings { + GradientSettings { + spread: self.gradient_spread, + cyclic: self.gradient_cyclic, + space: self.gradient_space, + hue_direction: self.gradient_hue_direction, + interpolation: self.gradient_interpolation, + } + } + fn snapshot_old(&mut self) { self.old_hue = self.hue; self.old_saturation = self.saturation; @@ -350,14 +355,9 @@ impl ColorPickerMessageHandler { && (active_index as usize) < gradient.len() { gradient.set_color(active_index as usize, color); + let ramp = GradientRamp::from(&*gradient); responses.add(FrontendMessage::ColorPickerColorChanged { - value: FillChoice::Gradient(GradientRamp { - gradient_spread: self.gradient_spread, - gradient_space: self.gradient_space, - gradient_cyclic: self.gradient_cyclic, - gradient_hue_direction: self.gradient_hue_direction, - ..GradientRamp::from(&*gradient) - }), + value: FillChoice::Gradient(ramp.with_settings(self.gradient_settings())), }); } else { responses.add(FrontendMessage::ColorPickerColorChanged { @@ -414,7 +414,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_cyclic, self.gradient_space, self.gradient_hue_direction); + let new_index = gradient.insert_stop(position, self.gradient_settings()); self.active_marker_index = Some(new_index as u32); self.active_marker_is_midpoint = false; if let Some(color) = gradient.color(new_index) { @@ -487,13 +487,7 @@ impl ColorPickerMessageHandler { } responses.add(FrontendMessage::ColorPickerColorChanged { - value: FillChoice::Gradient(GradientRamp { - gradient_spread: self.gradient_spread, - gradient_space: self.gradient_space, - gradient_cyclic: self.gradient_cyclic, - gradient_hue_direction: self.gradient_hue_direction, - ..GradientRamp::from(&gradient) - }), + value: FillChoice::Gradient(GradientRamp::from(&gradient).with_settings(self.gradient_settings())), }); self.gradient = Some(gradient); self.send_layouts(responses); @@ -527,6 +521,7 @@ impl ColorPickerMessageHandler { .track_space(self.gradient_space) .track_cyclic(self.gradient_cyclic) .track_hue_direction(self.gradient_hue_direction) + .track_interpolation(self.gradient_interpolation) .markers(markers) .active_marker_index(self.active_marker_index) .active_marker_is_midpoint(self.active_marker_is_midpoint) @@ -726,6 +721,25 @@ impl ColorPickerMessageHandler { ])); } + // Gradient interpolation (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()); + + groups.push(LayoutGroup::row(vec![ + TextLabel::new("Intrp.") + .tooltip_label("Gradient Interpolation") + .tooltip_description(INTERPOLATION_DESCRIPTION) + .widget_instance(), + Separator::new(SeparatorStyle::Related).widget_instance(), + DropdownInput::new(entries) + .selected_index(Some(self.gradient_interpolation as u32)) + .disabled(self.disabled) + .tooltip_label("Gradient Interpolation") + .tooltip_description(INTERPOLATION_DESCRIPTION) + .widget_instance(), + ])); + } + // 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_space| ColorPickerMessage::SetGradientSpace { gradient_space }.into()); @@ -733,7 +747,12 @@ impl ColorPickerMessageHandler { groups.push(LayoutGroup::row(vec![ 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(), + DropdownInput::new(entries) + .selected_index(Some(self.gradient_space as u32)) + .disabled(self.disabled) + .tooltip_label("Gradient Space") + .tooltip_description(SPACE_DESCRIPTION) + .widget_instance(), ])); } @@ -750,6 +769,8 @@ impl ColorPickerMessageHandler { DropdownInput::new(entries) .selected_index(Some(self.gradient_hue_direction as u32)) .disabled(self.disabled) + .tooltip_label("Gradient Hue Direction") + .tooltip_description(HUE_DIRECTION_DESCRIPTION) .widget_instance(), ])); } @@ -811,6 +832,7 @@ const ENDS_DESCRIPTION: &str = "The method for how the gradient continues beyond const CYCLIC_DESCRIPTION: &str = "Treats the stops as a cycle, interpolating from the last stop back around to the first."; 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."; +const INTERPOLATION_DESCRIPTION: &str = "The path the stops interpolate along, deciding whether the gradient jumps, turns corners, or flows smoothly through them."; /// 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 d19af3b1d9..536ed7caaf 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -531,13 +531,19 @@ 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_cyclic, spectrum_input.track_space, spectrum_input.track_hue_direction); + // The track strip spans exactly 0 to 1, which no spread affects, so the widget carries no spread of its own + let settings = graphene_std::vector::style::GradientSettings { + spread: Default::default(), + cyclic: spectrum_input.track_cyclic, + space: spectrum_input.track_space, + hue_direction: spectrum_input.track_hue_direction, + interpolation: spectrum_input.track_interpolation, + }; + spectrum_input.track_css = spectrum_input.track.to_css_linear_gradient(settings); // The end caps sample the track's boundary colors, which a cyclic wrap makes the wrapped interval's boundary-crossing color rather than the outermost stops' - let track_gradient = graphene_std::vector::style::Gradient::from(&spectrum_input.track); + let track_evaluator = graphene_std::vector::style::Gradient::from(&spectrum_input.track).evaluator(settings); let cap = |t: f64| { - let color = track_gradient.evaluate(t, Default::default(), spectrum_input.track_cyclic, spectrum_input.track_space, spectrum_input.track_hue_direction); + let color = track_evaluator.evaluate(t); SRGBA8::from(color).to_css_hex() }; spectrum_input.track_start_css = cap(0.); 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 e88af1834d..5eb058c697 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, GradientHueDirection, GradientSpace, GradientStops}; +use graphene_std::vector::style::{FillChoice, GradientHueDirection, GradientInterpolation, GradientSpace, GradientStops}; use graphite_proc_macros::WidgetBuilder; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -595,6 +595,9 @@ pub struct SpectrumInput { /// 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, + /// The path the track's stops interpolate along, used to compute `track_css` and by the frontend to suppress the midpoint diamonds when stepped. + #[serde(rename = "trackInterpolation")] + pub track_interpolation: GradientInterpolation, /// 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 d50622b137..1b97c54e87 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 @@ -24,7 +24,9 @@ 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, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{ + DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, +}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::{Artboard, Color, Context, Graphic}; use std::any::Any; @@ -216,6 +218,7 @@ fn generate_layout(introspected_data: &Arc, List, List, + List, List, List, List, @@ -271,6 +274,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, + Item, Item, Item, Item, @@ -1009,6 +1013,7 @@ impl_table_item_layout_for_choice_enum!( GradientSpread, GradientSpace, GradientHueDirection, + GradientInterpolation, StrokeJoin, StrokeAlign, StrokeCap, @@ -1223,6 +1228,7 @@ macro_rules! known_item_types { GradientSpread, GradientSpace, GradientHueDirection, + GradientInterpolation, StrokeJoin, StrokeAlign, StrokeCap, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index de4e6e92a9..f5374fff7c 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -10,7 +10,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, GradientHueDirection, GradientSpace, GradientSpread, Stroke}; +use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; use graphene_std::vector::{Gradient, PointId, VectorModificationType}; #[impl_message(Message, DocumentMessage, GraphOperation)] @@ -29,10 +29,7 @@ pub enum GraphOperationMessage { #[serde(skip)] gradient: Gradient, gradient_form: GradientForm, - gradient_spread: GradientSpread, - gradient_space: GradientSpace, - gradient_cyclic: bool, - gradient_hue_direction: GradientHueDirection, + gradient_settings: GradientSettings, transform: DAffine2, }, BlendingFillSet { @@ -76,6 +73,10 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, gradient_hue_direction: GradientHueDirection, }, + GradientInterpolationSet { + layer: LayerNodeIdentifier, + gradient_interpolation: GradientInterpolation, + }, OpacitySet { layer: LayerNodeIdentifier, opacity: f64, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index b5f7782944..aa2dc553b1 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -13,7 +13,7 @@ use graph_craft::document::{NodeId, NodeInput}; use graph_craft::list; use graphene_std::renderer::convert_usvg_path::convert_usvg_path; use graphene_std::text::{Font, TypesettingConfig}; -use graphene_std::vector::style::{Gradient, GradientForm, GradientSpace, GradientSpread, GradientStop, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{Gradient, GradientForm, GradientSettings, GradientSpace, GradientSpread, GradientStop, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::{Artboard, Color}; #[derive(ExtractField)] @@ -48,14 +48,11 @@ impl MessageHandler> for layer, gradient, gradient_form, - gradient_spread, - gradient_space, - gradient_cyclic, - gradient_hue_direction, + gradient_settings, 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_space, gradient_cyclic, gradient_hue_direction, transform); + modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_settings, transform); } } GraphOperationMessage::BlendingFillSet { layer, fill } => { @@ -108,6 +105,11 @@ impl MessageHandler> for modify_inputs.gradient_hue_direction_set(gradient_hue_direction); } } + GraphOperationMessage::GradientInterpolationSet { layer, gradient_interpolation } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.gradient_interpolation_set(gradient_interpolation); + } + } GraphOperationMessage::OpacitySet { layer, opacity } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.opacity_set(opacity); @@ -727,7 +729,7 @@ fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option { let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.; let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.; let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.; - Some(Color::from_rgbaf32_unchecked(r, g, b, opacity)) + Some(Color::from_gamma_srgb_channels(r, g, b, opacity)) } /// Import a usvg node as the root of an SVG import operation. @@ -980,10 +982,13 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g Gradient::new(stops) } }; - 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_space = gradient_info.spaces.get(linear.id()).copied().unwrap_or(GradientSpace::RgbGamma); - modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, false, Default::default(), transform); + let settings = GradientSettings { + spread: convert_gradient_spread(linear.spread_method()), + space: gradient_info.spaces.get(linear.id()).copied().unwrap_or(GradientSpace::RgbGamma), + ..Default::default() + }; + modify_inputs.fill_gradient_set(gradient, gradient_form, settings, transform); } usvg::Paint::RadialGradient(radial) => { let gradient_transform = usvg_transform(radial.transform()); @@ -1006,10 +1011,12 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g Gradient::new(stops) } }; - let gradient_spread = convert_gradient_spread(radial.spread_method()); - 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_space, false, Default::default(), transform); + let settings = GradientSettings { + spread: convert_gradient_spread(radial.spread_method()), + space: gradient_info.spaces.get(radial.id()).copied().unwrap_or(GradientSpace::RgbGamma), + ..Default::default() + }; + modify_inputs.fill_gradient_set(gradient, gradient_form, settings, transform); } usvg::Paint::Pattern(_) => warn!("SVG patterns are not currently supported"), }; @@ -1041,6 +1048,31 @@ mod tests { ); } + #[test] + fn graphite_stop_extraction_keeps_real_stops_and_linearizes_their_colors() { + let svg = r##" + + + + + + + + + "##; + + let stops = extract_graphite_gradient_stops(svg); + let gradient = stops.get("ramp").expect("the tagged gradient should be recovered"); + + // The untagged stop is baked approximation residue, not authored data + assert_eq!(gradient.len(), 3, "only stops tagged with a midpoint should survive"); + assert_eq!(gradient.positions(false), vec![0., 0.5, 1.]); + assert_eq!(gradient.midpoints(), vec![0.3, 0.5, 0.5]); + + // Hex stop bytes are gamma-encoded, so the recovered color must lift them to linear light + assert_eq!(gradient.color(1), Some(Color::from_gamma_srgb_channels(128. / 255., 128. / 255., 128. / 255., 0.5))); + } + #[test] fn color_interpolation_reads_style_blocks_with_selector_specificity() { let svg = r##" 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 63cfa41e2a..6fbce96bc8 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -18,7 +18,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, GradientHueDirection, GradientSpace, GradientSpread, Stroke}; +use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; use graphene_std::vector::{Gradient, GradientRamp, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic}; @@ -432,30 +432,13 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(fill_value, false), false); } - #[allow(clippy::too_many_arguments)] - pub fn fill_gradient_set( - &mut self, - gradient: Gradient, - gradient_form: GradientForm, - gradient_spread: GradientSpread, - gradient_space: GradientSpace, - gradient_cyclic: bool, - gradient_hue_direction: GradientHueDirection, - transform: DAffine2, - ) { + pub fn fill_gradient_set(&mut self, gradient: Gradient, gradient_form: GradientForm, settings: GradientSettings, transform: DAffine2) { let Some(fill_node_id) = self.existing_proto_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true) else { return; }; let backup_input_connector = InputConnector::node(fill_node_id, graphene_std::vector::fill::BackupGradientInput); - let ramp = GradientRamp::from(gradient); - let ramp = GradientRamp { - gradient_spread, - gradient_space, - gradient_cyclic, - gradient_hue_direction, - ..ramp - }; + let ramp = GradientRamp::from(gradient).with_settings(settings); self.set_input_with_refresh(backup_input_connector, NodeInput::value(TaggedValue::GradientRamp(ramp.clone()), false), true); // Skip the rerender on all but the last input so the whole update triggers a single graph run @@ -794,7 +777,9 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientRamp(ramp), false), false); } - /// Set the cyclic wrap flag on the chain's gradient value, which is where the ramp carries it. + /// Set the cyclic wrap flag on the chain's gradient value, which is where the ramp carries it. This holds the existing + /// stops in place by reading their positions under the old flag, so batch it before any stops write rather than after one, + /// or it would reinterpret incoming stops already authored under the new flag. pub fn gradient_cyclic_set(&mut self, gradient_cyclic: bool) { 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 { @@ -802,7 +787,20 @@ impl<'a> ModifyInputsContext<'a> { }; let Some(ramp) = self.gradient_value_ramp(gradient_value_id) else { return }; - let ramp = GradientRamp { gradient_cyclic, ..ramp }; + let ramp = ramp.with_cyclic(gradient_cyclic); + 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 interpolation on the chain's gradient value, which is where the ramp carries it. + pub fn gradient_interpolation_set(&mut self, gradient_interpolation: GradientInterpolation) { + 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 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 548d711d67..2efeb8b207 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -33,8 +33,8 @@ use graphene_std::vector::misc::{ ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; use graphene_std::vector::style::{ - FillChoice, Gradient, GradientForm, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, GradientStops, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, - build_transform_with_y_preservation, + FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, PaintOrder, StrokeAlign, StrokeCap, + StrokeJoin, build_transform_with_y_preservation, }; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::{NodeParameter, ParameterRef}; @@ -306,6 +306,7 @@ 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(), @@ -2406,10 +2407,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Gradient { gradient: Gradient, gradient_form: GradientForm, - gradient_spread: GradientSpread, - gradient_space: GradientSpace, - gradient_cyclic: bool, - gradient_hue_direction: GradientHueDirection, + settings: GradientSettings, 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, @@ -2439,10 +2437,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte Some(gradient) => ResolvedFill::Gradient { gradient: gradient.stops, gradient_form: gradient.gradient_form, - gradient_spread: gradient.gradient_spread, - gradient_space: gradient.gradient_space, - gradient_cyclic: gradient.gradient_cyclic, - gradient_hue_direction: gradient.gradient_hue_direction, + settings: gradient.settings, transform: gradient.transform, transform_is_value: gradient.transform_is_value, }, @@ -2470,33 +2465,15 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte }; match &fill { - ResolvedFill::Gradient { - gradient: stops, - gradient_spread, - gradient_space, - gradient_cyclic, - gradient_hue_direction, - .. - } => { + ResolvedFill::Gradient { gradient: stops, settings, .. } => { let stops = stops.clone(); - let gradient_spread = *gradient_spread; - let gradient_space = *gradient_space; - let gradient_cyclic = *gradient_cyclic; - let gradient_hue_direction = *gradient_hue_direction; + let settings = *settings; let reverse_button = IconButton::new("Reverse", 24) .tooltip_label("Reverse Stops") .tooltip_description("Reverse the gradient color stops.") .on_update(update_value( - move |_| { - TaggedValue::GradientRamp(GradientRamp { - gradient_spread, - gradient_space, - gradient_cyclic, - gradient_hue_direction, - ..GradientRamp::from(stops.reversed(gradient_cyclic)) - }) - }, + move |_| TaggedValue::GradientRamp(GradientRamp::from(stops.reversed(settings.cyclic)).with_settings(settings)), node_id, FillInput, )) @@ -2515,20 +2492,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte FillChoice::::None } } - ResolvedFill::Gradient { - gradient: stops, - gradient_spread, - gradient_space, - gradient_cyclic, - gradient_hue_direction, - .. - } => FillChoice::::Gradient(GradientRamp { - gradient_spread: *gradient_spread, - gradient_space: *gradient_space, - gradient_cyclic: *gradient_cyclic, - gradient_hue_direction: *gradient_hue_direction, - ..GradientRamp::from(stops) - }), + ResolvedFill::Gradient { gradient: stops, settings, .. } => FillChoice::::Gradient(GradientRamp::from(stops).with_settings(*settings)), ResolvedFill::Other => FillChoice::::None, }; diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 0439b3d439..7b5dfbb0b1 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -175,10 +175,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::graphic::read_attribute_gradient_spread::IDENTIFIER, aliases: &["graphic_nodes::graphic::ReadAttributeSpreadMethodNode"], }, - NodeReplacement { - node: graphene_std::graphic::read_attribute_gradient_space::IDENTIFIER, - aliases: &["graphic_nodes::graphic::ReadAttributeGradientInterpolationNode"], - }, NodeReplacement { node: graphene_std::graphic::remove_at_index::IDENTIFIER, aliases: &["graphic_nodes::graphic::OmitElementNode"], @@ -278,10 +274,6 @@ 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 ced816870c..cd9cff31d0 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, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, PointId, SegmentId, VectorModificationType}; +use graphene_std::vector::{Gradient, GradientForm, GradientRamp, GradientSettings, PointId, SegmentId, VectorModificationType}; use graphene_std::{NodeParameter, ParameterRef}; use std::collections::VecDeque; @@ -389,7 +389,7 @@ pub fn get_fill_input_node_id(layer: LayerNodeIdentifier, network_interface: &No } /// The ramp held by the 'Gradient Value' node feeding a layer's chain, which carries the whole-ramp settings. -fn get_chain_source_gradient_ramp<'a>(layer: LayerNodeIdentifier, network_interface: &'a NodeNetworkInterface) -> Option<&'a GradientRamp> { +fn get_chain_source_gradient_ramp(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<&GradientRamp> { let gradient_value_node = network_interface.document_network().nodes.get(&get_upstream_gradient_value_node_id(layer, network_interface)?)?; let TaggedValue::GradientRamp(ramp) = gradient_value_node.input(graphene_std::math_nodes::gradient_value::GradientInput)?.as_value()? else { return None; @@ -397,24 +397,9 @@ fn get_chain_source_gradient_ramp<'a>(layer: LayerNodeIdentifier, network_interf Some(ramp) } -/// The spread baked into the 'Gradient Value' node feeding a layer's chain. -pub fn get_chain_source_gradient_spread(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { - Some(get_chain_source_gradient_ramp(layer, network_interface)?.gradient_spread) -} - -/// 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 cyclic wrap flag baked into the 'Gradient Value' node feeding a layer's chain. -pub fn get_chain_source_gradient_cyclic(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { - Some(get_chain_source_gradient_ramp(layer, network_interface)?.gradient_cyclic) -} - -/// 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) +/// The whole-ramp settings baked into the 'Gradient Value' node feeding a layer's chain. +pub fn get_chain_source_gradient_settings(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + Some(get_chain_source_gradient_ramp(layer, network_interface)?.into()) } /// Get the gradient stops of a layer, if any. @@ -771,10 +756,7 @@ pub fn set_stroke_weight_for_selected_layers(weight: f64, document: &DocumentMes pub struct FillNodeGradient { pub stops: Gradient, pub gradient_form: GradientForm, - pub gradient_spread: GradientSpread, - pub gradient_space: GradientSpace, - pub gradient_cyclic: bool, - pub gradient_hue_direction: GradientHueDirection, + pub settings: GradientSettings, 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, @@ -787,10 +769,7 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn let TaggedValue::GradientRamp(ramp) = fill_node.input(fill::FillInput)?.as_value()? else { return None; }; - let gradient_spread = ramp.gradient_spread; - let gradient_space = ramp.gradient_space; - let gradient_cyclic = ramp.gradient_cyclic; - let gradient_hue_direction = ramp.gradient_hue_direction; + let settings = GradientSettings::from(ramp); 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, @@ -807,10 +786,7 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn Some(FillNodeGradient { stops, gradient_form, - gradient_spread, - gradient_space, - gradient_cyclic, - gradient_hue_direction, + settings, transform, transform_is_value: transform_input.is_some(), }) @@ -959,10 +935,7 @@ pub fn set_fill_for_selected_layers(fill_choice: FillChoice, document: &Document layer, gradient: Gradient::from(ramp), gradient_form, - gradient_spread: ramp.gradient_spread, - gradient_space: ramp.gradient_space, - gradient_cyclic: ramp.gradient_cyclic, - gradient_hue_direction: ramp.gradient_hue_direction, + gradient_settings: ramp.into(), transform, }); } diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index dfe486882e..a51fab4a2d 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_cyclic, 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, + self, NodeGraphLayer, get_chain_source_gradient_settings, 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, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, GradientStop, build_transform_with_y_preservation}; +use graphene_std::vector::style::{FillChoice, Gradient, GradientForm, GradientInterpolation, GradientRamp, GradientSettings, GradientStop, build_transform_with_y_preservation}; #[derive(Default, ExtractField)] pub struct GradientTool { @@ -29,10 +29,7 @@ pub struct GradientTool { #[derive(Default)] pub struct GradientOptions { gradient_form: GradientForm, - gradient_spread: GradientSpread, - gradient_space: GradientSpace, - gradient_cyclic: bool, - gradient_hue_direction: GradientHueDirection, + settings: GradientSettings, } #[impl_message(Message, ToolMessage, Gradient)] @@ -104,7 +101,7 @@ impl<'a> MessageHandler> for Grad context, responses, |_| true, - |(gradient, appearance)| *gradient = gradient.reversed(appearance.gradient_cyclic), + |(gradient, appearance)| *gradient = gradient.reversed(appearance.settings.cyclic), ); } GradientOptionsUpdate::ReverseDirection => apply_gradient_update( @@ -146,20 +143,8 @@ 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_space = ramp.gradient_space; - self.options.gradient_cyclic = ramp.gradient_cyclic; - 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_cyclic, - ramp.gradient_hue_direction, - ); + self.options.settings = GradientSettings::from(&ramp); + apply_stops_update(&mut self.data, context, responses, Gradient::from(&ramp), self.options.settings); } ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => { if self.data.color_picker_transaction_open { @@ -193,20 +178,8 @@ impl<'a> MessageHandler> for Grad self.options.gradient_form = appearance.gradient_form; needs_refresh = true; } - if self.options.gradient_spread != appearance.gradient_spread { - self.options.gradient_spread = appearance.gradient_spread; - needs_refresh = true; - } - if self.options.gradient_space != appearance.gradient_space { - self.options.gradient_space = appearance.gradient_space; - needs_refresh = true; - } - if self.options.gradient_cyclic != appearance.gradient_cyclic { - self.options.gradient_cyclic = appearance.gradient_cyclic; - needs_refresh = true; - } - if self.options.gradient_hue_direction != appearance.gradient_hue_direction { - self.options.gradient_hue_direction = appearance.gradient_hue_direction; + if self.options.settings != appearance.settings { + self.options.settings = appearance.settings; needs_refresh = true; } } @@ -287,23 +260,17 @@ impl LayoutHolder for GradientTool { }, ]) }); - let stops_widget = ColorInput::new(FillChoice::Gradient(GradientRamp { - gradient_spread: self.options.gradient_spread, - gradient_space: self.options.gradient_space, - gradient_cyclic: self.options.gradient_cyclic, - gradient_hue_direction: self.options.gradient_hue_direction, - ..GradientRamp::from(&stops_value) - })) - .allow_none(false) - .narrow(true) - .tooltip_label("Gradient Stops") - .tooltip_description("Edit the gradient's color stops.") - .on_update(|input: &ColorInput| { - let ramp = input.value.as_gradient().cloned().unwrap_or_default(); - GradientToolMessage::UpdateRamp { ramp }.into() - }) - .on_commit(|_| DocumentMessage::AddTransaction.into()) - .widget_instance(); + let stops_widget = ColorInput::new(FillChoice::Gradient(GradientRamp::from(&stops_value).with_settings(self.options.settings))) + .allow_none(false) + .narrow(true) + .tooltip_label("Gradient Stops") + .tooltip_description("Edit the gradient's color stops.") + .on_update(|input: &ColorInput| { + let ramp = input.value.as_gradient().cloned().unwrap_or_default(); + GradientToolMessage::UpdateRamp { ramp }.into() + }) + .on_commit(|_| DocumentMessage::AddTransaction.into()) + .widget_instance(); let reverse_stops = IconButton::new("Reverse", 24) .tooltip_label("Reverse Stops") @@ -391,10 +358,7 @@ fn resolve_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkI gradient.stops, GradientAppearance { gradient_form: gradient.gradient_form, - gradient_spread: gradient.gradient_spread, - gradient_space: gradient.gradient_space, - gradient_cyclic: gradient.gradient_cyclic, - gradient_hue_direction: gradient.gradient_hue_direction, + settings: gradient.settings, transform: gradient.transform, }, GradientSource::Direct, @@ -413,10 +377,7 @@ fn resolve_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkI struct GradientAppearance { transform: DAffine2, gradient_form: GradientForm, - gradient_spread: GradientSpread, - gradient_space: GradientSpace, - gradient_cyclic: bool, - gradient_hue_direction: GradientHueDirection, + settings: GradientSettings, } /// Resolve the gradient transform, form, and spread by walking the chain feeding the layer. @@ -456,10 +417,7 @@ fn read_gradient_chain_state(layer: LayerNodeIdentifier, network_interface: &Nod GradientAppearance { transform: composed_transform, gradient_form: gradient_form.unwrap_or_default(), - gradient_spread: get_chain_source_gradient_spread(layer, network_interface).unwrap_or_default(), - gradient_space: get_chain_source_gradient_space(layer, network_interface).unwrap_or_default(), - gradient_cyclic: get_chain_source_gradient_cyclic(layer, network_interface).unwrap_or_default(), - gradient_hue_direction: get_chain_source_gradient_hue_direction(layer, network_interface).unwrap_or_default(), + settings: get_chain_source_gradient_settings(layer, network_interface).unwrap_or_default(), } } @@ -504,7 +462,13 @@ fn wrapped_interval_span(gradient: &Gradient) -> (f64, f64) { /// The gradient's visible midpoint diamonds as `(owning stop index, position along the gradient line)`, omitting intervals /// whose stops are too closely packed. A cyclic gradient's wrapped interval adds a final diamond owned by the last stop. -fn midpoint_diamonds(gradient: &Gradient, gradient_cyclic: bool, viewport_line_length: f64) -> Vec<(usize, f64)> { +fn midpoint_diamonds(gradient: &Gradient, settings: GradientSettings, viewport_line_length: f64) -> Vec<(usize, f64)> { + // A stepped ramp jumps at its stops, so no midpoint has anything to bias + if settings.interpolation == GradientInterpolation::Stepped { + return Vec::new(); + } + + let gradient_cyclic = settings.cyclic; let mut diamonds = Vec::with_capacity(gradient.len()); for index in 0..gradient.len().saturating_sub(1) { @@ -585,7 +549,8 @@ struct SelectedGradient { is_gradient_chain: bool, } -fn calculate_insertion(start: DVec2, end: DVec2, stops: &Gradient, gradient_cyclic: bool, mouse: DVec2) -> Option { +fn calculate_insertion(start: DVec2, end: DVec2, stops: &Gradient, settings: GradientSettings, mouse: DVec2) -> Option { + let gradient_cyclic = settings.cyclic; let distance = (end - start).angle_to(mouse - start).sin() * (mouse - start).length(); let projection = ((end - start).angle_to(mouse - start)).cos() * start.distance(mouse) / start.distance(end); @@ -602,7 +567,7 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &Gradient, gradient_cycl // Don't insert when clicking near a (currently visible) midpoint diamond let line_length = start.distance(end); - for (_, midpoint_position) in midpoint_diamonds(stops, gradient_cyclic, line_length) { + for (_, midpoint_position) in midpoint_diamonds(stops, settings, line_length) { let midpoint_viewport = start.lerp(end, midpoint_position); if midpoint_viewport.distance_squared(mouse) < GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2) { return None; @@ -770,7 +735,7 @@ impl SelectedGradient { let min_gap = GRADIENT_STOP_MIN_VIEWPORT_GAP / line_length; let last_index = self.gradient.len() - 1; - let gradient_cyclic = self.appearance.gradient_cyclic; + let gradient_cyclic = self.appearance.settings.cyclic; let has_other_stop_at_zero = stop != 0 && !self.gradient.is_empty() && self.gradient.position(0, gradient_cyclic).abs() < f64::EPSILON * 1000.; let has_other_stop_at_one = stop != last_index && !self.gradient.is_empty() && (1. - self.gradient.position(last_index, gradient_cyclic)).abs() < f64::EPSILON * 1000.; @@ -830,7 +795,7 @@ impl SelectedGradient { } // Convert to a midpoint ratio within the interval owned by the dragged diamond's stop - if let Some(midpoint_ratio) = midpoint_ratio_at(&self.gradient, midpoint_index, self.appearance.gradient_cyclic, full_pos) { + if let Some(midpoint_ratio) = midpoint_ratio_at(&self.gradient, midpoint_index, self.appearance.settings.cyclic, full_pos) { self.gradient.set_midpoint(midpoint_index, midpoint_ratio); } } @@ -848,10 +813,7 @@ impl SelectedGradient { layer, gradient: self.gradient.clone(), gradient_form: self.appearance.gradient_form, - gradient_spread: self.appearance.gradient_spread, - gradient_space: self.appearance.gradient_space, - gradient_cyclic: self.appearance.gradient_cyclic, - gradient_hue_direction: self.appearance.gradient_hue_direction, + gradient_settings: self.appearance.settings, transform: self.appearance.transform, }); } @@ -869,10 +831,14 @@ impl SelectedGradient { /// Send the per-attribute graph operations that mirror the in-memory `Gradient` onto the chain feeding the layer. fn dispatch_gradient_chain_writes(layer: LayerNodeIdentifier, gradient: &Gradient, appearance: GradientAppearance, responses: &mut VecDeque) { + responses.add(GraphOperationMessage::GradientCyclicSet { + layer, + gradient_cyclic: appearance.settings.cyclic, + }); responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: gradient.clone() }); responses.add(GraphOperationMessage::GradientPositionsSet { layer, - positions: gradient.nondefault_positions(appearance.gradient_cyclic).unwrap_or_default(), + positions: gradient.nondefault_positions(appearance.settings.cyclic).unwrap_or_default(), }); responses.add(GraphOperationMessage::GradientMidpointsSet { layer, @@ -888,19 +854,19 @@ fn dispatch_gradient_chain_writes(layer: LayerNodeIdentifier, gradient: &Gradien }); responses.add(GraphOperationMessage::GradientSpreadSet { layer, - gradient_spread: appearance.gradient_spread, + gradient_spread: appearance.settings.spread, }); responses.add(GraphOperationMessage::GradientSpaceSet { layer, - gradient_space: appearance.gradient_space, - }); - responses.add(GraphOperationMessage::GradientCyclicSet { - layer, - gradient_cyclic: appearance.gradient_cyclic, + gradient_space: appearance.settings.space, }); responses.add(GraphOperationMessage::GradientHueDirectionSet { layer, - gradient_hue_direction: appearance.gradient_hue_direction, + gradient_hue_direction: appearance.settings.hue_direction, + }); + responses.add(GraphOperationMessage::GradientInterpolationSet { + layer, + gradient_interpolation: appearance.settings.interpolation, }); } @@ -1005,7 +971,8 @@ impl Fsm for GradientToolFsmState { let end_hex = gradient.color(gradient.len().saturating_sub(1)).map(color_to_hex).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); // Check if the first/last stops are at position ~0/~1 (rendered as the endpoint dots rather than as separate stops) - let gradient_cyclic = appearance.gradient_cyclic; + let settings = appearance.settings; + let gradient_cyclic = settings.cyclic; let first_at_start = !gradient.is_empty() && gradient.position(0, gradient_cyclic).abs() < f64::EPSILON * 1000.; let last_at_end = !gradient.is_empty() && (1. - gradient.position(gradient.len() - 1, gradient_cyclic)).abs() < f64::EPSILON * 1000.; @@ -1100,7 +1067,7 @@ impl Fsm for GradientToolFsmState { let line_angle = (end - start).to_angle(); let line_length = start.distance(end); let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2); - for (index, midpoint_position) in midpoint_diamonds(gradient, gradient_cyclic, line_length) { + for (index, midpoint_position) in midpoint_diamonds(gradient, settings, line_length) { let midpoint_viewport = start.lerp(end, midpoint_position); let emphasis = if dragging == Some(GradientDragTarget::Midpoint(index)) { @@ -1114,7 +1081,7 @@ impl Fsm for GradientToolFsmState { } if !matches!(self, GradientToolFsmState::Drawing { .. }) - && calculate_insertion(start, end, gradient, gradient_cyclic, mouse).is_some() + && calculate_insertion(start, end, gradient, settings, mouse).is_some() && let Some(dir) = (end - start).try_normalize() { let perp = dir.perp(); @@ -1163,7 +1130,7 @@ impl Fsm for GradientToolFsmState { let gradient = &selected_gradient.gradient; if stop_index < gradient.len() { let color = gradient.color(stop_index).unwrap_or(Color::BLACK); - let position = gradient.position(stop_index, selected_gradient.appearance.gradient_cyclic); + let position = gradient.position(stop_index, selected_gradient.appearance.settings.cyclic); let start = transform.transform_point2(DVec2::ZERO); let end = transform.transform_point2(DVec2::X); let position = start.lerp(end, position).into(); @@ -1203,7 +1170,7 @@ impl Fsm for GradientToolFsmState { GradientDragTarget::Start | GradientDragTarget::End | GradientDragTarget::Stop(_) => { // Find the stop index from the drag target let gradient = &selected_gradient.gradient; - let gradient_cyclic = selected_gradient.appearance.gradient_cyclic; + let gradient_cyclic = selected_gradient.appearance.settings.cyclic; let stop_index = match selected_gradient.dragging { GradientDragTarget::Stop(i) => Some(i), GradientDragTarget::Start => (0..gradient.len()).position(|i| gradient.position(i, gradient_cyclic).abs() < f64::EPSILON * 1000.), @@ -1219,7 +1186,7 @@ impl Fsm for GradientToolFsmState { tool_data.color_picker_transaction_open = false; } - let stop_pos = selected_gradient.gradient.position(stop_index, selected_gradient.appearance.gradient_cyclic); + let stop_pos = selected_gradient.gradient.position(stop_index, selected_gradient.appearance.settings.cyclic); let (start, end) = selected_gradient.viewport_handle_positions(); let viewport_pos = start.lerp(end, stop_pos); let position = viewport_pos.into(); @@ -1262,7 +1229,7 @@ impl Fsm for GradientToolFsmState { match selected_gradient.dragging { GradientDragTarget::Start => { // Only delete if there's a real color stop at position ~0 (not the endpoint of the line which isn't itself a color stop) - if !selected_gradient.gradient.is_empty() && selected_gradient.gradient.position(0, selected_gradient.appearance.gradient_cyclic).abs() < f64::EPSILON * 1000. { + if !selected_gradient.gradient.is_empty() && selected_gradient.gradient.position(0, selected_gradient.appearance.settings.cyclic).abs() < f64::EPSILON * 1000. { selected_gradient.gradient.remove(0); } else { responses.add(DocumentMessage::AbortTransaction); @@ -1272,7 +1239,7 @@ impl Fsm for GradientToolFsmState { GradientDragTarget::End => { // Only delete if there's a real color stop at position ~1 (not the endpoint of the line which isn't itself a color stop) if !selected_gradient.gradient.is_empty() - && (1. - selected_gradient.gradient.position(selected_gradient.gradient.len() - 1, selected_gradient.appearance.gradient_cyclic)).abs() < f64::EPSILON * 1000. + && (1. - selected_gradient.gradient.position(selected_gradient.gradient.len() - 1, selected_gradient.appearance.settings.cyclic)).abs() < f64::EPSILON * 1000. { let _ = selected_gradient.gradient.pop(); } else { @@ -1314,7 +1281,7 @@ impl Fsm for GradientToolFsmState { } // Find the minimum and maximum positions - let positions = selected_gradient.gradient.positions(selected_gradient.appearance.gradient_cyclic); + let positions = selected_gradient.gradient.positions(selected_gradient.appearance.settings.cyclic); let min_position = positions.iter().copied().reduce(f64::min).expect("No min"); let max_position = positions.iter().copied().reduce(f64::max).expect("No max"); @@ -1350,14 +1317,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_cyclic, - appearance.gradient_space, - appearance.gradient_hue_direction, - ) { + if let Some(index) = insert_stop_at_point(&mut gradient, mouse, unit_to_viewport, appearance.settings) { responses.add(DocumentMessage::StartTransaction); let mut selected_gradient = SelectedGradient::new(gradient, appearance, source, layer, document); @@ -1410,7 +1370,7 @@ impl Fsm for GradientToolFsmState { if drag_hint.is_none() { let line_length = start.distance(end); let midpoint_tolerance = GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2); - for (index, midpoint_position) in midpoint_diamonds(&gradient, appearance.gradient_cyclic, line_length) { + for (index, midpoint_position) in midpoint_diamonds(&gradient, appearance.settings, line_length) { let midpoint_viewport = start.lerp(end, midpoint_position); if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { @@ -1437,14 +1397,14 @@ impl Fsm for GradientToolFsmState { if drag_hint.is_none() { let mut best: Option<(f64, usize)> = None; for index in 0..gradient.len() { - let pos = start.lerp(end, gradient.position(index, appearance.gradient_cyclic)); + let pos = start.lerp(end, gradient.position(index, appearance.settings.cyclic)); let dist_sq = pos.distance_squared(mouse); if dist_sq < tolerance && best.as_ref().is_none_or(|&(best_dist, _)| dist_sq < best_dist) { best = Some((dist_sq, index)); } } if let Some((_, index)) = best { - let stop_position = gradient.position(index, appearance.gradient_cyclic); + let stop_position = gradient.position(index, appearance.settings.cyclic); // Stops at position 0 or 1 are locked endpoints: dragging moves the // gradient line endpoint geometry (start/end) instead of stop position let drag_target = if stop_position.abs() < f64::EPSILON * 1000. { @@ -1499,14 +1459,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_cyclic, - appearance.gradient_space, - appearance.gradient_hue_direction, - ) { + if let Some(index) = insert_stop_at_point(&mut new_gradient, mouse, unit_to_viewport, appearance.settings) { responses.add(DocumentMessage::StartTransaction); transaction_started = true; @@ -1584,10 +1537,7 @@ impl Fsm for GradientToolFsmState { GradientAppearance { transform: DAffine2::IDENTITY, gradient_form: tool_options.gradient_form, - gradient_spread: tool_options.gradient_spread, - gradient_space: tool_options.gradient_space, - gradient_cyclic: tool_options.gradient_cyclic, - gradient_hue_direction: tool_options.gradient_hue_direction, + settings: tool_options.settings, }, // 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() { @@ -1685,9 +1635,9 @@ impl Fsm for GradientToolFsmState { // Clear the selection if we were dragging an endpoint of the gradient which isn't a stop if tool_data.selected_gradient.as_ref().is_some_and(|selected| match selected.dragging { - GradientDragTarget::Start => selected.gradient.is_empty() || selected.gradient.position(0, selected.appearance.gradient_cyclic).abs() >= f64::EPSILON * 1000., + GradientDragTarget::Start => selected.gradient.is_empty() || selected.gradient.position(0, selected.appearance.settings.cyclic).abs() >= f64::EPSILON * 1000., GradientDragTarget::End => { - selected.gradient.is_empty() || (1. - selected.gradient.position(selected.gradient.len() - 1, selected.appearance.gradient_cyclic)).abs() >= f64::EPSILON * 1000. + selected.gradient.is_empty() || (1. - selected.gradient.position(selected.gradient.len() - 1, selected.appearance.settings.cyclic)).abs() >= f64::EPSILON * 1000. } _ => false, }) { @@ -1821,17 +1771,10 @@ impl Fsm for GradientToolFsmState { } } -fn insert_stop_at_point( - gradient: &mut Gradient, - point: DVec2, - unit_to_viewport: DAffine2, - gradient_cyclic: bool, - gradient_space: GradientSpace, - gradient_hue_direction: GradientHueDirection, -) -> Option { +fn insert_stop_at_point(gradient: &mut Gradient, point: DVec2, unit_to_viewport: DAffine2, settings: GradientSettings) -> 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_cyclic, gradient_space, gradient_hue_direction)) + (0. ..=1.).contains(&t).then(|| gradient.insert_stop(t, settings)) } fn dismiss_color_stop_color_picker(tool_data: &mut GradientToolData, responses: &mut VecDeque) { @@ -1858,7 +1801,7 @@ fn detect_hover_target(mouse: DVec2, document: &DocumentMessageHandler) -> Gradi let line_length = start.distance(end); // Check midpoint diamonds first (smaller hit area, higher priority) - for (index, midpoint_position) in midpoint_diamonds(&gradient, appearance.gradient_cyclic, line_length) { + for (index, midpoint_position) in midpoint_diamonds(&gradient, appearance.settings, line_length) { let midpoint_viewport = start.lerp(end, midpoint_position); if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { @@ -1887,7 +1830,7 @@ fn detect_hover_target(mouse: DVec2, document: &DocumentMessageHandler) -> Gradi } // Check insertion point on line - if calculate_insertion(start, end, &gradient, appearance.gradient_cyclic, mouse).is_some() { + if calculate_insertion(start, end, &gradient, appearance.settings, mouse).is_some() { return GradientHoverTarget::InsertionPoint; } } @@ -1947,10 +1890,7 @@ fn apply_gradient_update( layer, gradient, gradient_form: appearance.gradient_form, - gradient_spread: appearance.gradient_spread, - gradient_space: appearance.gradient_space, - gradient_cyclic: appearance.gradient_cyclic, - gradient_hue_direction: appearance.gradient_hue_direction, + gradient_settings: appearance.settings, transform: appearance.transform, }); } @@ -1974,17 +1914,7 @@ fn apply_gradient_update( /// Set new gradient stops on every selected layer's gradient. Unlike `apply_gradient_update`, this doesn't open its own /// transaction so it can be called repeatedly during a color picker drag and have all the changes coalesced into a /// single undo entry by the surrounding 'on_commit' callback. -#[allow(clippy::too_many_arguments)] -fn apply_stops_update( - data: &mut GradientToolData, - context: &mut ToolActionMessageContext, - responses: &mut VecDeque, - new_gradient: Gradient, - gradient_spread: GradientSpread, - gradient_space: GradientSpace, - gradient_cyclic: bool, - gradient_hue_direction: GradientHueDirection, -) { +fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessageContext, responses: &mut VecDeque, new_gradient: Gradient, settings: GradientSettings) { let selected_layers: Vec<_> = context .document .network_interface @@ -1999,21 +1929,34 @@ fn apply_stops_update( } if get_upstream_gradient_value_node_id(layer, &context.document.network_interface).is_some() { + responses.add(GraphOperationMessage::GradientCyclicSet { + layer, + gradient_cyclic: settings.cyclic, + }); responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: new_gradient.clone() }); - responses.add(GraphOperationMessage::GradientSpreadSet { layer, gradient_spread }); - responses.add(GraphOperationMessage::GradientSpaceSet { layer, gradient_space }); - responses.add(GraphOperationMessage::GradientCyclicSet { layer, gradient_cyclic }); - responses.add(GraphOperationMessage::GradientHueDirectionSet { layer, gradient_hue_direction }); + responses.add(GraphOperationMessage::GradientSpreadSet { + layer, + gradient_spread: settings.spread, + }); + responses.add(GraphOperationMessage::GradientSpaceSet { + layer, + gradient_space: settings.space, + }); + responses.add(GraphOperationMessage::GradientHueDirectionSet { + layer, + gradient_hue_direction: settings.hue_direction, + }); + responses.add(GraphOperationMessage::GradientInterpolationSet { + layer, + gradient_interpolation: settings.interpolation, + }); updated_any_layer = true; } else if let Some((_gradient, appearance, _source)) = resolve_gradient(layer, &context.document.network_interface) { responses.add(GraphOperationMessage::FillGradientSet { layer, gradient: new_gradient.clone(), gradient_form: appearance.gradient_form, - gradient_spread, - gradient_space, - gradient_cyclic, - gradient_hue_direction, + gradient_settings: settings, transform: appearance.transform, }); updated_any_layer = true; @@ -2022,10 +1965,7 @@ 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_space = gradient_space; - selected_gradient.appearance.gradient_cyclic = gradient_cyclic; - selected_gradient.appearance.gradient_hue_direction = gradient_hue_direction; + selected_gradient.appearance.settings = settings; } // When no selected layer had a gradient to update, the user is editing the tool's default gradient instead. @@ -2125,31 +2065,40 @@ mod test_gradient { use graphene_std::NodeParameter; use graphene_std::color::SRGBA8; use graphene_std::vector::style::{GradientForm, GradientSpread, build_transform_with_y_preservation}; + use graphene_std::vector::style::{GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace}; use graphene_std::vector::{Gradient, GradientRamp, GradientStop, fill}; /// A line long enough that no interval in these tests trips the closely-packed-stops hiding rule. const UNCROWDED_LINE_LENGTH: f64 = 10_000.; + const CYCLIC_SETTINGS: GradientSettings = GradientSettings { + spread: GradientSpread::Pad, + cyclic: true, + space: GradientSpace::OkLab, + hue_direction: GradientHueDirection::Shorter, + interpolation: GradientInterpolation::Linear, + }; + #[test] fn cyclic_adds_a_wrap_diamond_owned_by_the_last_stop() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); // The elided cyclic stops sit at 0 and 0.5, so the wrapped interval spans the other half and centers its diamond at 0.75 - assert_eq!(midpoint_diamonds(&gradient, false, UNCROWDED_LINE_LENGTH), vec![(0, 0.5)]); - assert_eq!(midpoint_diamonds(&gradient, true, UNCROWDED_LINE_LENGTH), vec![(0, 0.25), (1, 0.75)]); + assert_eq!(midpoint_diamonds(&gradient, GradientSettings::default(), UNCROWDED_LINE_LENGTH), vec![(0, 0.5)]); + assert_eq!(midpoint_diamonds(&gradient, CYCLIC_SETTINGS, UNCROWDED_LINE_LENGTH), vec![(0, 0.25), (1, 0.75)]); // A wrapped interval crossing the boundary places its diamond on whichever side the midpoint lands let mut offset = Gradient::from(vec![Color::BLACK, Color::WHITE]); offset.set_positions(&[0.25, 0.5]); offset.set_midpoints(&[0.5, 0.9]); - let diamonds = midpoint_diamonds(&offset, true, UNCROWDED_LINE_LENGTH); + let diamonds = midpoint_diamonds(&offset, CYCLIC_SETTINGS, UNCROWDED_LINE_LENGTH); assert_eq!(diamonds[1].0, 1); assert!((diamonds[1].1 - 0.175).abs() < 1e-9, "the late wrap midpoint should land past the boundary, got {}", diamonds[1].1); // Stops pinned to both ends leave the wrapped interval no width, so it contributes no diamond let mut spanning = Gradient::from(vec![Color::BLACK, Color::WHITE]); spanning.set_positions(&[0., 1.]); - assert_eq!(midpoint_diamonds(&spanning, true, UNCROWDED_LINE_LENGTH), vec![(0, 0.5)]); + assert_eq!(midpoint_diamonds(&spanning, CYCLIC_SETTINGS, UNCROWDED_LINE_LENGTH), vec![(0, 0.5)]); } #[test] @@ -2188,7 +2137,7 @@ mod test_gradient { fn new(stops: Gradient, appearance: super::GradientAppearance) -> Self { Self { stops, - gradient_spread: appearance.gradient_spread, + gradient_spread: appearance.settings.spread, transform: appearance.transform, } } @@ -2880,7 +2829,7 @@ mod test_gradient { #[tokio::test] async fn spread_set_from_the_tool_lands_on_the_gradient_value_node() { - use crate::messages::tool::common_functionality::graph_modification_utils::get_chain_source_gradient_spread; + use crate::messages::tool::common_functionality::graph_modification_utils::get_chain_source_gradient_settings; let mut editor = EditorTestUtils::create(); editor.new_document().await; @@ -2898,7 +2847,7 @@ mod test_gradient { // The Properties panel reads the value node's own ramp, so the spread has to be stored there let network_interface = &editor.active_document().network_interface; assert_eq!( - get_chain_source_gradient_spread(layer, network_interface), + get_chain_source_gradient_settings(layer, network_interface).map(|settings| settings.spread), Some(GradientSpread::Reflect), "the spread should be written into the gradient value's ramp" ); diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index d2ad4055ec..d7299ec387 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -3,7 +3,7 @@ import { preventEscapeClosingParentFloatingMenu } from "/src/components/layout/FloatingMenu.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte"; - import type { SpectrumInputUpdate, SpectrumMarker } from "/wrapper/pkg/graphite_wasm_wrapper"; + import type { GradientInterpolation, SpectrumInputUpdate, SpectrumMarker } from "/wrapper/pkg/graphite_wasm_wrapper"; const BUTTON_LEFT = 0; const BUTTON_RIGHT = 2; @@ -14,6 +14,7 @@ export let trackStartCSS: string; export let trackEndCSS: string; export let trackCyclic = false; + export let trackInterpolation: GradientInterpolation = "Linear"; export let markers: SpectrumMarker[]; export let activeMarkerIndex: number | undefined = 0; export let activeMarkerIsMidpoint = false; @@ -363,8 +364,9 @@ // Map midpoint pairs to absolute track positions for rendering the diamond markers. // A rendered diamond's index is the index of the interval's left marker, which for the cyclic wrapped interval's diamond is the last marker. - function diamondPositions(markers: SpectrumMarker[], showMidpoints: boolean, trackCyclic: boolean): number[] { - if (!showMidpoints || markers.length < 2) return []; + function diamondPositions(markers: SpectrumMarker[], showMidpoints: boolean, trackCyclic: boolean, trackInterpolation: GradientInterpolation): number[] { + // A stepped ramp jumps at its stops, so no midpoint has anything to bias + if (!showMidpoints || trackInterpolation === "Stepped" || markers.length < 2) return []; const positions = markers.slice(0, -1).map((marker, i) => marker.position + marker.midpoint * (markers[i + 1].position - marker.position)); // The wrapped interval's diamond may land on either side of the 1|0 boundary @@ -377,7 +379,7 @@ return positions; } - $: midpointPositions = diamondPositions(markers, showMidpoints, trackCyclic); + $: midpointPositions = diamondPositions(markers, showMidpoints, trackCyclic, trackInterpolation); onMount(() => { document.addEventListener("keydown", deleteShortcut); diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 29b217d606..23d2ef25a4 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -555,9 +555,9 @@ tagged_value! { GradientForm(vector::style::GradientForm), #[serde(alias = "GradientSpreadMethod")] // TODO: Eventually remove this document upgrade code GradientSpread(vector::style::GradientSpread), - #[serde(alias = "GradientInterpolation")] // TODO: Eventually remove this document upgrade code GradientSpace(vector::style::GradientSpace), GradientHueDirection(vector::style::GradientHueDirection), + GradientInterpolation(vector::style::GradientInterpolation), ReferencePoint(vector::ReferencePoint), CentroidType(vector::misc::CentroidType), BooleanOperation(vector::misc::BooleanOperation), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index ed41f3d42d..59e276ef31 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -24,7 +24,7 @@ use graphene_std::transform::{Footprint, ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; -use graphene_std::vector::style::{DashPattern, GradientForm, GradientHueDirection, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{DashPattern, GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector, VectorModification}; use graphene_std::{Artboard, Context, Graphic, NodeIO, NodeIOTypes, ProtoNodeIdentifier, concrete, fn_type_fut, future}; use node_registry_macros::async_node; @@ -78,6 +78,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => ListDyn]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item]), @@ -114,6 +115,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), @@ -339,6 +341,7 @@ fn node_registry() -> HashMap HashMap)); @@ -547,6 +551,7 @@ fn node_registry() -> HashMap), attribute_value_node!(Item), attribute_value_node!(Item), + attribute_value_node!(Item), attribute_value_node!(Item), attribute_value_node!(List), attribute_value_node!(List), diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 2d5f61f067..5cf3ffaf66 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -25,8 +25,8 @@ pub use graphene_hash; pub use graphene_hash::CacheHash; pub use list::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_END, - ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, - ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE, + ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_LETTER_SPACING, + ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE, }; pub use memo::MemoHash; pub use no_std_types::AsU32; diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index ce6698d562..6cdfda3920 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -65,6 +65,9 @@ pub const ATTR_GRADIENT_SPACE: &str = "gradient_space"; /// Gradient's `GradientHueDirection` (`Shorter`, `Longer`, `Increasing`, or `Decreasing`), which way around the /// hue wheel the stops interpolate when the gradient space is polar. pub const ATTR_GRADIENT_HUE_DIRECTION: &str = "gradient_hue_direction"; +/// Gradient's `GradientInterpolation` (`Stepped`, `Linear`, or `Smooth`), the path its stops interpolate along +/// and thus whether the ramp jumps, turns corners, or flows smoothly through them. +pub const ATTR_GRADIENT_INTERPOLATION: &str = "gradient_interpolation"; /// Gradient's `bool` (implicit default `false`) for treating the stop list as a cycle, where a wrapped interval /// interpolates from the last stop through the 1|0 boundary back to the first. pub const ATTR_GRADIENT_CYCLIC: &str = "gradient_cyclic"; diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 5ddeb9be05..17934fa83b 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,16 +1,16 @@ -use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, spread_adjusted_samples, transform_is_invertible}; +use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, gradient_settings_at, spread_adjusted_samples, transform_is_invertible}; use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::color::SRGBA8; use core_types::list::List; use core_types::uuid::generate_uuid; -use core_types::{ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_TRANSFORM, Color}; +use core_types::{ATTR_GRADIENT_FORM, ATTR_TRANSFORM, Color}; use glam::{DAffine2, DVec2}; use graphic_types::Graphic; use graphic_types::vector_types::gradient::GradientForm; use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; use vector_types::Gradient; -use vector_types::gradient::{GradientHueDirection, GradientSpace, GradientSpread}; +use vector_types::gradient::GradientSpread; #[derive(Copy, Clone, PartialEq)] pub enum PaintTarget { @@ -95,20 +95,9 @@ impl RenderExt for List { let Some(stops) = self.element(0) else { return 0 }; let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, 0); let local_gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - let gradient_spread: GradientSpread = self.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, 0); - let gradient_space: GradientSpace = self.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, 0); - let gradient_cyclic: bool = self.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC, 0); - let gradient_hue_direction: GradientHueDirection = self.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION, 0); + let settings = gradient_settings_at(self, 0); - let (samples, _) = spread_adjusted_samples( - stops, - gradient_spread, - gradient_form, - gradient_cyclic, - gradient_space, - gradient_hue_direction, - ClearGuardPlacement::SvgStopOrder, - ); + let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); for (position, color, original_midpoint) in samples { stop.push_str(" { format!(r#" gradientTransform="{gradient_transform}""#) }; - let gradient_spread = if matches!(gradient_spread, GradientSpread::Pad | GradientSpread::Clear) { + let gradient_spread = if matches!(settings.spread, GradientSpread::Pad | GradientSpread::Clear) { String::new() } else { - format!(r#" spreadMethod="{}""#, gradient_spread.svg_name()) + format!(r#" spreadMethod="{}""#, settings.spread.svg_name()) }; let gradient_id = generate_uuid(); diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 756aa10dd1..a0b85531e2 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -14,8 +14,8 @@ use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT, - ATTR_FONT_SIZE, ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, - ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, + ATTR_FONT_SIZE, ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_LETTER_SPACING, + ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; @@ -39,7 +39,7 @@ use std::fmt::Write; use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::{GradientHueDirection, GradientSpace, GradientSpread}; +use vector_types::gradient::{GradientSettings, GradientSpread}; use vello::*; #[derive(Clone, Copy, Debug, PartialEq)] @@ -416,17 +416,9 @@ pub(crate) enum ClearGuardPlacement { /// The `Clear` spread brackets the samples with transparent guard stops placed per `guards`: the pad extension then /// paints transparency outward while hard stops cut the paint off exactly at the unit range's boundaries. A radial /// gradient's span still starts at zero, since its sampling distance never goes below the center. -pub(crate) fn spread_adjusted_samples( - gradient: &Gradient, - gradient_spread: GradientSpread, - gradient_form: GradientForm, - gradient_cyclic: bool, - gradient_space: GradientSpace, - gradient_hue_direction: GradientHueDirection, - guards: ClearGuardPlacement, -) -> (GradientSamples, (f64, f64)) { - let samples = gradient.interpolated_samples(gradient_cyclic, gradient_space, gradient_hue_direction); - if gradient_spread != GradientSpread::Clear { +pub(crate) fn spread_adjusted_samples(gradient: &Gradient, settings: GradientSettings, gradient_form: GradientForm, guards: ClearGuardPlacement) -> (GradientSamples, (f64, f64)) { + let samples = gradient.interpolated_samples(settings); + if settings.spread != GradientSpread::Clear { return (samples, (0., 1.)); } @@ -504,25 +496,25 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend { } } +/// The whole-ramp settings attributes carried by the gradient at `index` of the list. +pub(crate) fn gradient_settings_at(list: &List, index: usize) -> GradientSettings { + GradientSettings { + spread: list.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, index), + cyclic: list.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC, index), + space: list.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, index), + hue_direction: list.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION, index), + interpolation: list.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION, index), + } +} + fn create_peniko_gradient_brush(gradient_list: &List, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; let gradient_form: GradientForm = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_FORM, 0); let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - let gradient_spread: GradientSpread = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, 0); - let gradient_space: GradientSpace = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, 0); - let gradient_cyclic: bool = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC, 0); - let gradient_hue_direction: GradientHueDirection = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION, 0); + let settings = gradient_settings_at(gradient_list, 0); - let (samples, span) = spread_adjusted_samples( - stops, - gradient_spread, - gradient_form, - gradient_cyclic, - gradient_space, - gradient_hue_direction, - ClearGuardPlacement::VelloRampTexels, - ); + let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); let peniko_stops = peniko_color_stops(&samples); @@ -544,7 +536,7 @@ fn create_peniko_gradient_brush(gradient_list: &List, multiplied_trans } .into(), }, - extend: peniko_extend(gradient_spread), + extend: peniko_extend(settings.spread), stops: peniko_stops, interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, ..Default::default() @@ -2201,11 +2193,8 @@ impl Render for List { let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); - let gradient_spread: GradientSpread = self.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD, index); let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); - let gradient_space: GradientSpace = self.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, index); - let gradient_cyclic: bool = self.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC, index); - let gradient_hue_direction: GradientHueDirection = self.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION, index); + let settings = gradient_settings_at(self, index); let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; render.leaf_tag(tag, |attributes| { if let Some((min, size)) = thumbnail_rect { @@ -2221,15 +2210,7 @@ impl Render for List { attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); } - let (samples, _) = spread_adjusted_samples( - gradient, - gradient_spread, - gradient_form, - gradient_cyclic, - gradient_space, - gradient_hue_direction, - ClearGuardPlacement::SvgStopOrder, - ); + let (samples, _) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); let mut stop_string = String::new(); for (position, color, original_midpoint) in samples { @@ -2253,10 +2234,10 @@ impl Render for List { }; let gradient_id = generate_uuid(); - let gradient_spread_attribute = if matches!(gradient_spread, GradientSpread::Pad | GradientSpread::Clear) { + let gradient_spread_attribute = if matches!(settings.spread, GradientSpread::Pad | GradientSpread::Clear) { String::new() } else { - format!(r#" spreadMethod="{}""#, gradient_spread.svg_name()) + format!(r#" spreadMethod="{}""#, settings.spread.svg_name()) }; // The unit gradient line is the +X unit vector in local space, before the item's transform is applied @@ -2296,12 +2277,7 @@ impl Render for List { return; } - for (((index, gradient), gradient_spread), gradient_form) in self - .iter_element_values() - .enumerate() - .zip(self.iter_attribute_values_or_default::(ATTR_GRADIENT_SPREAD)) - .zip(self.iter_attribute_values_or_default::(ATTR_GRADIENT_FORM)) - { + for ((index, gradient), gradient_form) in self.iter_element_values().enumerate().zip(self.iter_attribute_values_or_default::(ATTR_GRADIENT_FORM)) { let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); @@ -2311,22 +2287,12 @@ impl Render for List { let blend_mode = blend_mode_attr.to_peniko(); let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let gradient_space: GradientSpace = self.attribute_cloned_or_default(ATTR_GRADIENT_SPACE, index); - let gradient_cyclic: bool = self.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC, index); - let gradient_hue_direction: GradientHueDirection = self.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION, index); - let (samples, span) = spread_adjusted_samples( - gradient, - gradient_spread, - gradient_form, - gradient_cyclic, - gradient_space, - gradient_hue_direction, - ClearGuardPlacement::VelloRampTexels, - ); + let settings = gradient_settings_at(self, index); + let (samples, span) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); let stops = peniko_color_stops(&samples); - let extend = peniko_extend(gradient_spread); + let extend = peniko_extend(settings.spread); // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. @@ -2866,6 +2832,7 @@ impl SvgRenderAttrs<'_> { #[cfg(test)] mod tests { use super::*; + use vector_types::gradient::GradientSpace; #[test] fn spread_adjusted_samples_wraps_clear_in_transparent_guards() { @@ -2873,24 +2840,32 @@ mod tests { let (samples, span) = spread_adjusted_samples( &gradient, - GradientSpread::Repeat, + GradientSettings { + spread: GradientSpread::Repeat, + space: GradientSpace::RgbGamma, + ..Default::default() + }, GradientForm::Linear, - false, - GradientSpace::RgbGamma, - Default::default(), ClearGuardPlacement::SvgStopOrder, ); assert_eq!(span, (0., 1.)); - assert_eq!(samples, gradient.interpolated_samples(false, GradientSpace::RgbGamma, Default::default())); + assert_eq!( + samples, + gradient.interpolated_samples(GradientSettings { + space: 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, + GradientSettings { + spread: GradientSpread::Clear, + space: GradientSpace::RgbGamma, + ..Default::default() + }, GradientForm::Linear, - false, - GradientSpace::RgbGamma, - Default::default(), ClearGuardPlacement::SvgStopOrder, ); assert_eq!(span, (0., 1.)); @@ -2903,11 +2878,12 @@ mod tests { let texel = 1. / (VELLO_GRADIENT_RAMP_TEXELS - 1.); let (samples, span) = spread_adjusted_samples( &gradient, - GradientSpread::Clear, + GradientSettings { + spread: GradientSpread::Clear, + space: GradientSpace::RgbGamma, + ..Default::default() + }, GradientForm::Linear, - false, - GradientSpace::RgbGamma, - Default::default(), ClearGuardPlacement::VelloRampTexels, ); assert_eq!( @@ -2924,11 +2900,12 @@ mod tests { // A radial keeps its stops and span anchored at zero, with no guard below the center let (samples, span) = spread_adjusted_samples( &gradient, - GradientSpread::Clear, + GradientSettings { + spread: GradientSpread::Clear, + space: GradientSpace::RgbGamma, + ..Default::default() + }, GradientForm::Radial, - false, - GradientSpace::RgbGamma, - Default::default(), ClearGuardPlacement::VelloRampTexels, ); assert_eq!(span.0, 0.); @@ -2940,11 +2917,12 @@ mod tests { fn spread_adjusted_samples_keeps_a_stopless_clear_gradient_black_inside_the_range() { let (samples, _) = spread_adjusted_samples( &Gradient::from(Vec::new()), - GradientSpread::Clear, + GradientSettings { + spread: GradientSpread::Clear, + space: GradientSpace::RgbGamma, + ..Default::default() + }, GradientForm::Linear, - false, - GradientSpace::RgbGamma, - Default::default(), ClearGuardPlacement::SvgStopOrder, ); let colors: Vec = samples.iter().map(|&(_, color, _)| color).collect(); diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 1b2cbe5b0d..1251271ead 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -1,6 +1,6 @@ use core_types::Color; use core_types::color::SRGBA8; -use core_types::list::{ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_MIDPOINT, ATTR_POSITION, Item, List}; +use core_types::list::{ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_MIDPOINT, ATTR_POSITION, Item, List}; use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; @@ -92,8 +92,8 @@ 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_cyclic: bool, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> String { - Gradient::from(self).to_css_linear_gradient(gradient_cyclic, gradient_space, gradient_hue_direction) + pub fn to_css_linear_gradient(&self, settings: GradientSettings) -> String { + Gradient::from(self).to_css_linear_gradient(settings) } } @@ -108,8 +108,8 @@ 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` 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"))] + // 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 = "GradientSpace::legacy_gamma"))] pub gradient_space: GradientSpace, #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "std::ops::Not::not"))] #[cfg_attr(feature = "wasm", tsify(optional))] @@ -117,6 +117,9 @@ pub struct GradientRamp { #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientHueDirection::is_default"))] #[cfg_attr(feature = "wasm", tsify(optional))] pub gradient_hue_direction: GradientHueDirection, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientInterpolation::is_default"))] + #[cfg_attr(feature = "wasm", tsify(optional))] + pub gradient_interpolation: GradientInterpolation, } unsafe impl dyn_any::StaticType for GradientRamp { @@ -131,6 +134,7 @@ impl From> for GradientRamp { gradient_space: Default::default(), gradient_cyclic: Default::default(), gradient_hue_direction: Default::default(), + gradient_interpolation: Default::default(), } } } @@ -143,6 +147,7 @@ impl From<&Gradient> for GradientRamp { gradient_space: Default::default(), gradient_cyclic: Default::default(), gradient_hue_direction: Default::default(), + gradient_interpolation: Default::default(), } } } @@ -182,6 +187,9 @@ impl From for Item { if !ramp.gradient_hue_direction.is_default() { item.set_attribute(ATTR_GRADIENT_HUE_DIRECTION, ramp.gradient_hue_direction); } + if !ramp.gradient_interpolation.is_default() { + item.set_attribute(ATTR_GRADIENT_INTERPOLATION, ramp.gradient_interpolation); + } item } } @@ -194,6 +202,7 @@ impl From<&Item> for GradientRamp { gradient_space: item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE), gradient_cyclic: item.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC), gradient_hue_direction: item.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION), + gradient_interpolation: item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION), } } } @@ -222,6 +231,7 @@ impl From<&GradientRamp> for GradientRamp { gradient_space: ramp.gradient_space, gradient_cyclic: ramp.gradient_cyclic, gradient_hue_direction: ramp.gradient_hue_direction, + gradient_interpolation: ramp.gradient_interpolation, } } } @@ -234,6 +244,7 @@ impl From<&Gradient> for GradientRamp { gradient_space: Default::default(), gradient_cyclic: Default::default(), gradient_hue_direction: Default::default(), + gradient_interpolation: Default::default(), } } } @@ -249,15 +260,53 @@ impl From<&GradientRamp> for GradientRamp { gradient_space: ramp.gradient_space, gradient_cyclic: ramp.gradient_cyclic, gradient_hue_direction: ramp.gradient_hue_direction, + gradient_interpolation: ramp.gradient_interpolation, ..Self::from(gradient) } } } +impl GradientRamp { + /// Overwrites the whole-ramp settings fields as one bundle. This writes the cyclic flag without holding the stops in place + /// (unlike [`GradientRamp::with_cyclic`]), so they must already be authored under `settings.cyclic`. + pub fn with_settings(mut self, settings: GradientSettings) -> Self { + let GradientSettings { + spread, + cyclic, + space, + hue_direction, + interpolation, + } = settings; + + self.gradient_spread = spread; + self.gradient_cyclic = cyclic; + self.gradient_space = space; + self.gradient_hue_direction = hue_direction; + self.gradient_interpolation = interpolation; + + self + } +} + impl GradientRamp { pub fn black_to_white() -> Self { Self::from(Gradient::black_to_white()) } + + /// Sets the cyclic flag, holding the stops at the positions they already occupy. + /// The ramp owns this rather than leaving it to the runtime type's elision because `cyclic` determines different default elided positions. + pub fn with_cyclic(mut self, gradient_cyclic: bool) -> Self { + if self.gradient_cyclic == gradient_cyclic { + return self; + } + + let mut gradient = Gradient::from(self.stops); + gradient.hold_positions_across_cyclic_change(self.gradient_cyclic, gradient_cyclic); + + self.stops = (&gradient).into(); + self.gradient_cyclic = gradient_cyclic; + self + } } impl From> for Gradient { @@ -278,6 +327,10 @@ impl RenderComplexity for Gradient { } } +/// 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. +const SAMPLE_THRESHOLD: f64 = 2. / 255.; + /// The effective midpoint domain shared by sampling and rendering: NaN reads as the linear default, and extremes are bounded to `0.01..=0.99` so curves stay finite and cheap to subdivide. fn sanitized_midpoint(midpoint: f64) -> f64 { if midpoint.is_nan() { 0.5 } else { midpoint.clamp(0.01, 0.99) } @@ -299,6 +352,22 @@ fn apply_midpoint(t: f64, midpoint: f64) -> f64 { } } +/// Calls a color-space-generic function with the `color` crate space matching a [`GradientSpace`] variant. +macro_rules! with_space { + ($gradient_space:expr, $function:ident $(, $argument:expr)* $(,)?) => { + match $gradient_space { + GradientSpace::OkLab => $function::($($argument),*), + GradientSpace::OkLCh => $function::($($argument),*), + GradientSpace::Lab => $function::($($argument),*), + GradientSpace::LCh => $function::($($argument),*), + GradientSpace::Hsl => $function::($($argument),*), + GradientSpace::Hsv => $function::($($argument),*), + GradientSpace::RgbLinear => $function::($($argument),*), + GradientSpace::RgbGamma => $function::($($argument),*), + } + }; +} + /// 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 { @@ -317,20 +386,11 @@ pub fn interpolate_stop_colors(color_a: Color, color_b: Color, t: f32, gradient_ /// 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 let Some(hue_index) = space_hue_index::() { + let achromatic = achromatic_chroma_threshold::(); if a[1] < achromatic && b[1] >= achromatic { a[hue_index] = b[hue_index]; } @@ -338,43 +398,8 @@ fn lerp_in_space(color_a: Color, color_b: Color, t: f32, 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; + // The fixup applies to hues that the conversions already place in the 0 to 360 range + b[hue_index] = a[hue_index] + hue_delta((b[hue_index] - a[hue_index]) as f64, gradient_hue_direction) as f32; } let mixed = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; @@ -433,6 +458,397 @@ fn max_gamma_channel_deviation(a: Color, b: Color) -> f64 { (0..4).fold(0_f64, |max, i| max.max((a[i] - b[i]).abs() as f64)) } +/// A color's channels in the color space `CS`, alongside its straight alpha. +fn space_channels(color: Color) -> [f64; 4] { + let [x, y, z] = CS::from_linear_srgb([color.r(), color.g(), color.b()]); + [x as f64, y as f64, z as f64, color.a() as f64] +} + +/// The inverse of [`space_channels`], leaving an out-of-gamut result unclamped so it clips at render encoding. +fn color_from_space_channels(channels: [f64; 4]) -> Color { + let [red, green, blue] = CS::to_linear_srgb([channels[0] as f32, channels[1] as f32, channels[2] as f32]); + Color::from_rgbaf32_unchecked(red, green, blue, channels[3] as f32) +} + +/// The channel carrying hue in a polar space, or `None` for a rectangular one. +fn space_hue_index() -> Option { + match CS::LAYOUT { + color::ColorSpaceLayout::HueFirst => Some(0), + color::ColorSpaceLayout::HueThird => Some(2), + _ => None, + } +} + +/// The chroma (or saturation, channel 1 in both polar layouts) below which a color counts as achromatic and +/// its hue as powerless, scaled to the space's lightness range so conversion noise stays below the threshold. +fn achromatic_chroma_threshold() -> f32 { + 1e-4 * CS::WHITE_COMPONENTS.iter().fold(0_f32, |max, &component| max.max(component)) +} + +/// The CSS Color 4 hue fixup: the signed arc between two hues taking the route the direction asks for. +fn hue_delta(delta: f64, gradient_hue_direction: GradientHueDirection) -> f64 { + 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 + } + } + } +} + +/// Every knot's color in the space `CS`. An achromatic knot borrows its nearest chromatic neighbor's powerless hue, +/// then the whole-hue run accumulates its per-step fixup so the spline sees one continuous sequence rather than values that wrap at 360. +fn knot_channels(knots: &[GradientStop], gradient_hue_direction: GradientHueDirection) -> Vec<[f64; 4]> { + let mut channels: Vec<[f64; 4]> = knots.iter().map(|knot| space_channels::(knot.color)).collect(); + + let Some(hue_index) = space_hue_index::() else { return channels }; + + let achromatic = achromatic_chroma_threshold::() as f64; + let chromatic: Vec = (0..channels.len()).filter(|&index| channels[index][1] >= achromatic).collect(); + if chromatic.is_empty() { + return channels; + } + + for index in 0..channels.len() { + if channels[index][1] < achromatic + && let Some(&nearest) = chromatic.iter().min_by_key(|&&other| other.abs_diff(index)) + { + channels[index][hue_index] = channels[nearest][hue_index]; + } + } + + for index in 1..channels.len() { + let previous = channels[index - 1][hue_index]; + let delta = hue_delta(channels[index][hue_index].rem_euclid(360.) - previous.rem_euclid(360.), gradient_hue_direction); + channels[index][hue_index] = previous + delta; + } + + channels +} + +/// A Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) spline, preserving its samples' monotonicity: +/// it passes through every sample and joins the pieces with matching slopes, while the Fritsch-Carlson +/// limiter keeps each piece bounded by its own two samples, so the curve rises and falls only where its +/// samples do instead of overshooting past them. +/// +/// Construction is O(n) and evaluation is O(log n) in the sample count. +struct MonotonicSpline { + position: Vec, + value: Vec, + tangent: Vec, +} + +impl MonotonicSpline { + fn new(position: Vec, value: Vec) -> Self { + let count = position.len(); + if count < 2 { + let tangent = vec![0.; count]; + return Self { position, value, tangent }; + } + + let secant: Vec = (0..count - 1) + .map(|index| { + let run = position[index + 1] - position[index]; + if run.abs() < f64::EPSILON { 0. } else { (value[index + 1] - value[index]) / run } + }) + .collect(); + + // A sign change or a flat run between neighboring secants pins that tangent to zero, + // which is what stops the curve from bulging past a local extreme + let mut tangent = Vec::with_capacity(count); + tangent.push(secant[0]); + for index in 1..count - 1 { + let (before, after) = (secant[index - 1], secant[index]); + tangent.push(if before * after <= 0. { 0. } else { (before + after) / 2. }); + } + tangent.push(secant[count - 2]); + + // Fritsch-Carlson: pull any tangent pair back inside the radius-3 circle around their shared secant + for index in 0..count - 1 { + if secant[index].abs() < f64::EPSILON { + tangent[index] = 0.; + tangent[index + 1] = 0.; + continue; + } + + let (alpha, beta) = (tangent[index] / secant[index], tangent[index + 1] / secant[index]); + let magnitude = alpha * alpha + beta * beta; + if magnitude > 9. { + let scale = 3. / magnitude.sqrt(); + tangent[index] = scale * alpha * secant[index]; + tangent[index + 1] = scale * beta * secant[index]; + } + } + + Self { position, value, tangent } + } + + fn evaluate(&self, at: f64) -> f64 { + let count = self.position.len(); + if count == 0 { + return 0.; + } + if count == 1 || at <= self.position[0] { + return self.value[0]; + } + if at >= self.position[count - 1] { + return self.value[count - 1]; + } + + let index = self.position.partition_point(|&position| position <= at).clamp(1, count - 1) - 1; + let run = self.position[index + 1] - self.position[index]; + if run.abs() < f64::EPSILON { + return self.value[index + 1]; + } + + let t = (at - self.position[index]) / run; + let (t2, t3) = (t * t, t * t * t); + + (2. * t3 - 3. * t2 + 1.) * self.value[index] + (t3 - 2. * t2 + t) * run * self.tangent[index] + (-2. * t3 + 3. * t2) * self.value[index + 1] + (t3 - t2) * run * self.tangent[index + 1] + } +} + +/// The Smooth path: a monoticity-preserving spline per color channel through every stop, traversed by a second such spline that +/// maps ramp position to spline parameter. Fitting the stop and midpoint constraints into one global warp is what keeps the +/// traversal rate continuous across stops, where independent per-interval curves (what Linear uses) would kink at each one. +struct SmoothPath { + space: GradientSpace, + hue_index: Option, + channel: [MonotonicSpline; 4], + warp: MonotonicSpline, +} + +impl SmoothPath { + fn new(stops: &[GradientStop], settings: GradientSettings) -> Self { + // A cyclic ramp gains two wrapped copies of each end, enough that the tangent estimate on either side of the + // 1|0 boundary sees the same neighborhood and the seam joins smoothly. Stops at both 0 and 1 leave a zero-length + // wrapped interval: that seam is a hard jump, and its copies would land on the real end stops, so it gets none. + let count = stops.len(); + let wrapped_interval = count >= 2 && stops[0].position + 1. - stops[count - 1].position > f64::EPSILON; + let mut knots: Vec = Vec::with_capacity(count + 4); + if settings.cyclic && wrapped_interval { + knots.push(GradientStop { + position: stops[count - 2].position - 1., + ..stops[count - 2] + }); + knots.push(GradientStop { + position: stops[count - 1].position - 1., + ..stops[count - 1] + }); + knots.extend_from_slice(stops); + knots.push(GradientStop { + position: stops[0].position + 1., + ..stops[0] + }); + knots.push(GradientStop { + position: stops[1].position + 1., + ..stops[1] + }); + } else { + knots.extend_from_slice(stops); + } + + let channels = with_space!(settings.space, knot_channels, &knots, settings.hue_direction); + let parameter: Vec = (0..knots.len()).map(|index| index as f64).collect(); + let channel = std::array::from_fn(|component| MonotonicSpline::new(parameter.clone(), channels.iter().map(|values| values[component]).collect())); + + // Each stop pins its own knot parameter and each midpoint the half-parameter between two, + // so one monotonic curve satisfies every midpoint constraint at once + let mut warp_position = Vec::with_capacity(knots.len() * 2); + let mut warp_value = Vec::with_capacity(knots.len() * 2); + for (index, knot) in knots.iter().enumerate() { + warp_position.push(knot.position); + warp_value.push(index as f64); + + if let Some(next) = knots.get(index + 1) { + let midpoint = knot.position + sanitized_midpoint(knot.midpoint) * (next.position - knot.position); + if midpoint > knot.position && midpoint < next.position { + warp_position.push(midpoint); + warp_value.push(index as f64 + 0.5); + } + } + } + + Self { + space: settings.space, + hue_index: with_space!(settings.space, space_hue_index), + channel, + warp: MonotonicSpline::new(warp_position, warp_value), + } + } + + fn evaluate(&self, t: f64) -> Color { + let parameter = self.warp.evaluate(t); + let mut channels: [f64; 4] = std::array::from_fn(|component| self.channel[component].evaluate(parameter)); + if let Some(hue_index) = self.hue_index { + channels[hue_index] = channels[hue_index].rem_euclid(360.); + } + + with_space!(self.space, color_from_space_channels, channels) + } +} + +/// Stepped holds each stop's color the whole way to the next stop, so the ramp jumps at stops and midpoints are inert. +fn stepped_color(stops: &[GradientStop], t: f64, gradient_cyclic: bool) -> Color { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::BLACK }; + + // Before the first stop a cyclic ramp is still inside the wrapped interval, which the last stop's color holds + if t < first.position { + return if gradient_cyclic { last.color } else { first.color }; + } + if t >= last.position { + return last.color; + } + + stops.windows(2).find(|pair| t < pair[1].position).map_or(last.color, |pair| pair[0].color) +} + +/// Linear traces the chord from each stop to the next, turning a corner at every stop, with the midpoint biasing the timing across each interval independently. +fn linear_color(stops: &[GradientStop], t: f64, settings: GradientSettings) -> Color { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::BLACK }; + + if settings.cyclic && (t < first.position || t > last.position) { + let wrap_length = first.position + 1. - last.position; + if wrap_length <= f64::EPSILON { + return first.color; + } + + let local = if t >= last.position { t - last.position } else { t + 1. - last.position }; + let adjusted_t = apply_midpoint(local / wrap_length, last.midpoint); + return interpolate_stop_colors(last.color, first.color, adjusted_t as f32, settings.space, settings.hue_direction); + } + + if t <= first.position { + return first.color; + } + if t >= last.position { + return last.color; + } + + for pair in stops.windows(2) { + let (a, b) = (&pair[0], &pair[1]); + 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, settings.space, settings.hue_direction); + } + } + + Color::BLACK +} + +/// Stepped's bake is exact rather than approximated: each interval emits its color at both ends, +/// leaving the renderer's own interpolation nothing to traverse so the jump lands squarely on the next stop. +fn stepped_samples(stops: &[GradientStop], gradient_cyclic: bool) -> Vec<(f64, Color, Option)> { + let count = stops.len(); + let (first, last) = (&stops[0], &stops[count - 1]); + let mut result = Vec::with_capacity(count * 2 + 2); + + // The wrapped interval holds the last stop's color from the 1|0 boundary through to the first stop + if gradient_cyclic && first.position > 0. { + result.push((0., last.color, None)); + result.push((first.position, last.color, None)); + } + + for pair in stops.windows(2) { + result.push((pair[0].position, pair[0].color, Some(0.5))); + result.push((pair[1].position, pair[0].color, None)); + } + result.push((last.position, last.color, Some(0.5))); + + if gradient_cyclic && last.position < 1. { + result.push((1., last.color, None)); + } + + result +} + +/// Smooth's bake: anchor every stop, then subdivide between anchors until the renderer's gamma segments track the spline. +fn smooth_samples(stops: &[GradientStop], settings: GradientSettings) -> Vec<(f64, Color, Option)> { + fn subdivide(path: &SmoothPath, left: f64, right: f64, color_left: Color, color_right: Color, result: &mut Vec<(f64, Color, Option)>, depth: u32) { + const MAX_DEPTH: u32 = 20; + if depth >= MAX_DEPTH { + return; + } + + // Probe the quarter points as well as the center, since a space with a steep toe peaks its deviation off-center + let deviates = [0.25, 0.5, 0.75].into_iter().any(|fraction| { + let probe = path.evaluate(left + (right - left) * fraction); + max_gamma_channel_deviation(probe, color_left.lerp_gamma_srgb(&color_right, fraction as f32)) > SAMPLE_THRESHOLD + }); + if !deviates { + return; + } + + let mid = (left + right) / 2.; + let color_mid = path.evaluate(mid); + subdivide(path, left, mid, color_left, color_mid, result, depth + 1); + result.push((mid, color_mid, None)); + subdivide(path, mid, right, color_mid, color_right, result, depth + 1); + } + + let path = SmoothPath::new(stops, settings); + let count = stops.len(); + + // A cyclic ramp's baked list runs boundary to boundary so downstream renderers stay unaware of the cycle + let mut anchors: Vec<(f64, Option)> = Vec::with_capacity(count + 2); + if settings.cyclic && stops[0].position > 0. { + anchors.push((0., None)); + } + anchors.extend(stops.iter().map(|stop| (stop.position, Some(sanitized_midpoint(stop.midpoint))))); + let synthetic_end = settings.cyclic && stops[count - 1].position < 1.; + if synthetic_end { + anchors.push((1., None)); + } + + // A synthetic end anchor copies the start's color so the seam closes exactly rather than relying on the + // two wrapped views of the spline agreeing to the last bit; a real stop at 1 keeps its own color + let seam_color = synthetic_end.then(|| path.evaluate(anchors[0].0)); + let anchor_color = |position: f64| match seam_color { + Some(color) if position >= 1. => color, + _ => path.evaluate(position), + }; + + let mut result: Vec<(f64, Color, Option)> = Vec::new(); + for (index, &(position, midpoint)) in anchors.iter().enumerate() { + let color = anchor_color(position); + result.push((position, color, midpoint)); + + if let Some(&(next, _)) = anchors.get(index + 1) { + subdivide(&path, position, next, color, anchor_color(next), &mut result, 0); + } + } + + result +} + #[derive(Debug, Clone, Copy)] pub struct GradientStop { pub position: f64, @@ -607,6 +1023,17 @@ impl Gradient { } } + /// Pins the stops to the positions they currently occupy under `from_cyclic`, then re-elides against `to_cyclic`, + /// so flipping the flag leaves them where they are instead of snapping to the other mode's even distribution. + pub fn hold_positions_across_cyclic_change(&mut self, from_cyclic: bool, to_cyclic: bool) { + if from_cyclic == to_cyclic { + return; + } + + self.materialize_default_positions(from_cyclic); + self.elide_default_attributes(to_cyclic); + } + /// Writes the whole `position` attribute from the effective values, since the even-distribution default is index-dependent and can't be produced by cell-wise padding. fn materialize_default_positions(&mut self, gradient_cyclic: bool) { if self.has_position_attribute() { @@ -704,8 +1131,16 @@ 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 of a non-cyclic gradient). /// Returns the index where the new stop was inserted. - pub fn insert_stop(&mut self, position: f64, gradient_cyclic: bool, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> usize { - let color = self.evaluate(position, Default::default(), gradient_cyclic, gradient_space, gradient_hue_direction); + pub fn insert_stop(&mut self, position: f64, settings: GradientSettings) -> usize { + let gradient_cyclic = settings.cyclic; + // The sampled position is inside the ramp, where the spread must act as Pad (Repeat would wrap an exact 1 onto the first stop) + let color = self.evaluate( + position, + GradientSettings { + spread: Default::default(), + ..settings + }, + ); let index = (0..self.len()).position(|i| self.position(i, gradient_cyclic) > position).unwrap_or(self.len()); // Inserting before the first stop of a cyclic gradient splits the wrapped interval, so its handle is inherited @@ -794,53 +1229,20 @@ impl Gradient { stops } - /// 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_cyclic: bool, 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.), - GradientSpread::Reflect => { - let cycle = t.rem_euclid(2.); - if cycle > 1. { 2. - cycle } else { cycle } - } - GradientSpread::Clear => { - if !(0. ..=1.).contains(&t) { - return Color::TRANSPARENT; - } - t - } - }; + /// Samples the gradient's color at `t`. Given a `t` outside the 0 to 1 range, the spread determines how the gradient extends. + /// + /// Each call rebuilds the sampling state, so loops over many `t` values should hold a [`Gradient::evaluator`] instead. + pub fn evaluate(&self, t: f64, settings: GradientSettings) -> Color { + self.evaluator(settings).evaluate(t) + } - let stops = self.normalized_stops(gradient_cyclic); - let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::BLACK }; - - if gradient_cyclic && (t < first.position || t > last.position) { - let wrap_length = first.position + 1. - last.position; - if wrap_length <= f64::EPSILON { - return first.color; - } - let local = if t >= last.position { t - last.position } else { t + 1. - last.position }; - let adjusted_t = apply_midpoint(local / wrap_length, last.midpoint); - return interpolate_stop_colors(last.color, first.color, adjusted_t as f32, gradient_space, gradient_hue_direction); - } - - if t <= first.position { - return first.color; - } - if t >= last.position { - return last.color; - } - - for pair in stops.windows(2) { - let (a, b) = (&pair[0], &pair[1]); - 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_space, gradient_hue_direction); - } - } - - Color::BLACK + /// Prepares the gradient for repeated sampling: the stop normalization and any Smooth spline construction, together + /// O(n log n) in the stop count, happen once here rather than on every [`GradientEvaluator::evaluate`] call. + pub fn evaluator(&self, settings: GradientSettings) -> GradientEvaluator { + let stops = self.normalized_stops(settings.cyclic); + // A spline through fewer than two stops has nothing to curve between, leaving those ramps to linear evaluation + let smooth_path = (settings.interpolation == GradientInterpolation::Smooth && stops.len() >= 2).then(|| SmoothPath::new(&stops, settings)); + GradientEvaluator { stops, settings, smooth_path } } pub fn sort(&mut self, gradient_cyclic: bool) { @@ -894,13 +1296,13 @@ impl Gradient { } /// 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_cyclic: bool, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> String { + pub fn to_css_linear_gradient(&self, settings: GradientSettings) -> 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_cyclic, gradient_space, gradient_hue_direction) + .interpolated_samples(settings) .into_iter() .map(|(position, color, _)| { let percent = ((position * 100.) * 1e2).round() / 1e2; @@ -911,20 +1313,16 @@ impl Gradient { format!("linear-gradient(to right, {pieces})") } - /// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves - /// and color space. + /// Produce a set of linearly-interpolated color samples that approximate the gradient's true curve. /// /// 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 color space when it is not gamma itself. - pub fn interpolated_samples(&self, gradient_cyclic: bool, 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.; - + /// subdivision emits enough samples that the gamma-drawn segments match the ramp's true curve: the midpoint bias, the + /// color space when it is not gamma itself, and the spline path when the ramp interpolates smoothly. Stepped is exact + /// without any subdivision, since a pair of samples per interval reproduces its jumps. + pub fn interpolated_samples(&self, settings: GradientSettings) -> Vec<(f64, Color, Option)> { #[allow(clippy::too_many_arguments)] fn subdivide( left: f64, @@ -955,14 +1353,14 @@ impl Gradient { // 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 midpoint_deviates = (y_actual - y_linear).abs() > SAMPLE_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 + max_gamma_channel_deviation(color_target, color_left.lerp_gamma_srgb(&color_right, fraction as f32)) > SAMPLE_THRESHOLD }) }; @@ -977,7 +1375,89 @@ impl Gradient { } } - let stops = self.normalized_stops(gradient_cyclic); + fn linear_samples(stops: &[GradientStop], settings: GradientSettings) -> Vec<(f64, Color, Option)> { + let (gradient_space, gradient_hue_direction) = (settings.space, settings.hue_direction); + let count = stops.len(); + let mut result = Vec::new(); + + for i in 0..count - 1 { + let pos_a = stops[i].position; + let pos_b = stops[i + 1].position; + let color_a = stops[i].color; + let color_b = stops[i + 1].color; + let midpoint = sanitized_midpoint(stops[i].midpoint); + let next_midpoint = sanitized_midpoint(stops[i + 1].midpoint); + + // Add the start stop (subsequent intervals share the previous end stop) + if i == 0 { + result.push((pos_a, color_a, Some(midpoint))); + } + + // 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_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 + result.push((pos_b, color_b, Some(next_midpoint))); + } + + // Bake the wrapped interval into the flat list: the piece from the last stop to the 1|0 boundary, and the piece + // continuing from the boundary to the first stop, so both ends of the emitted list share the boundary-crossing + // color and downstream renderers stay unaware of the cycle + if settings.cyclic { + let (first, last) = (&stops[0], &stops[count - 1]); + let wrap_length = first.position + 1. - last.position; + if wrap_length > f64::EPSILON { + let wrap_midpoint = sanitized_midpoint(last.midpoint); + let boundary_fraction = (1. - last.position) / wrap_length; + let y_boundary = apply_midpoint(boundary_fraction, wrap_midpoint); + let boundary_color = interpolate_stop_colors(last.color, first.color, y_boundary as f32, gradient_space, gradient_hue_direction); + + if last.position < 1. { + let virtual_end = last.position + wrap_length; + subdivide( + 0., + boundary_fraction, + wrap_midpoint, + last.position, + virtual_end, + last.color, + first.color, + gradient_space, + gradient_hue_direction, + &mut result, + 0, + ); + result.push((1., boundary_color, None)); + } + + if first.position > 0. { + let virtual_start = last.position - 1.; + let mut leading = vec![(0., boundary_color, None)]; + subdivide( + boundary_fraction, + 1., + wrap_midpoint, + virtual_start, + first.position, + last.color, + first.color, + gradient_space, + gradient_hue_direction, + &mut leading, + 0, + ); + leading.append(&mut result); + result = leading; + } + } + } + + result + } + + let stops = self.normalized_stops(settings.cyclic); let count = stops.len(); if count == 0 { return vec![]; @@ -987,81 +1467,11 @@ impl Gradient { return vec![(stops[0].position, stops[0].color, Some(sanitized_midpoint(stops[0].midpoint)))]; } - let mut result = Vec::new(); - - for i in 0..count - 1 { - let pos_a = stops[i].position; - let pos_b = stops[i + 1].position; - let color_a = stops[i].color; - let color_b = stops[i + 1].color; - let midpoint = sanitized_midpoint(stops[i].midpoint); - let next_midpoint = sanitized_midpoint(stops[i + 1].midpoint); - - // Add the start stop (subsequent intervals share the previous end stop) - if i == 0 { - result.push((pos_a, color_a, Some(midpoint))); - } - - // 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_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 - result.push((pos_b, color_b, Some(next_midpoint))); - } - - // Bake the wrapped interval into the flat list: the piece from the last stop to the 1|0 boundary, and the piece - // continuing from the boundary to the first stop, so both ends of the emitted list share the boundary-crossing - // color and downstream renderers stay unaware of the cycle - if gradient_cyclic { - let (first, last) = (&stops[0], &stops[count - 1]); - let wrap_length = first.position + 1. - last.position; - if wrap_length > f64::EPSILON { - let wrap_midpoint = sanitized_midpoint(last.midpoint); - let boundary_fraction = (1. - last.position) / wrap_length; - let y_boundary = apply_midpoint(boundary_fraction, wrap_midpoint); - let boundary_color = interpolate_stop_colors(last.color, first.color, y_boundary as f32, gradient_space, gradient_hue_direction); - - if last.position < 1. { - let virtual_end = last.position + wrap_length; - subdivide( - 0., - boundary_fraction, - wrap_midpoint, - last.position, - virtual_end, - last.color, - first.color, - gradient_space, - gradient_hue_direction, - &mut result, - 0, - ); - result.push((1., boundary_color, None)); - } - - if first.position > 0. { - let virtual_start = last.position - 1.; - let mut leading = vec![(0., boundary_color, None)]; - subdivide( - boundary_fraction, - 1., - wrap_midpoint, - virtual_start, - first.position, - last.color, - first.color, - gradient_space, - gradient_hue_direction, - &mut leading, - 0, - ); - leading.append(&mut result); - result = leading; - } - } - } + let mut result = match settings.interpolation { + GradientInterpolation::Stepped => stepped_samples(&stops, settings.cyclic), + GradientInterpolation::Linear => linear_samples(&stops, settings), + GradientInterpolation::Smooth => smooth_samples(&stops, settings), + }; // If every midpoint is 0.5 (or within epsilon), turn all midpoints to None if result.iter().all(|(_, _, midpoint)| matches!(midpoint, Some(m) if (m - 0.5).abs() < 1e-6)) { @@ -1081,6 +1491,40 @@ impl Gradient { } } +/// A gradient prepared for repeated sampling by [`Gradient::evaluator`], holding the normalized stops and any +/// prebuilt Smooth spline so each sample pays only the per-evaluation cost. +pub struct GradientEvaluator { + stops: Vec, + settings: GradientSettings, + smooth_path: Option, +} + +impl GradientEvaluator { + /// Samples the gradient's color at `t`. Given a `t` outside the 0 to 1 range, the spread determines how the gradient extends. + pub fn evaluate(&self, t: f64) -> Color { + let t = match self.settings.spread { + GradientSpread::Pad => t.clamp(0., 1.), + GradientSpread::Repeat => t.rem_euclid(1.), + GradientSpread::Reflect => { + let cycle = t.rem_euclid(2.); + if cycle > 1. { 2. - cycle } else { cycle } + } + GradientSpread::Clear => { + if !(0. ..=1.).contains(&t) { + return Color::TRANSPARENT; + } + t + } + }; + + match &self.smooth_path { + Some(path) => path.evaluate(t), + None if self.settings.interpolation == GradientInterpolation::Stepped => stepped_color(&self.stops, t, self.settings.cyclic), + None => linear_color(&self.stops, t, self.settings), + } + } +} + #[repr(C)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] @@ -1139,11 +1583,9 @@ pub enum GradientSpace { LCh, /// Interpolates between stops in linear light, keeping transitions uniformly 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/value cylinder, keeping tints at full brightness. @@ -1193,6 +1635,65 @@ impl GradientHueDirection { } } +#[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 GradientInterpolation { + /// Holds each stop's color all the way to the next stop, jumping between them instead of transitioning. + Stepped, + /// Transitions straight from each stop to the next, turning a corner at every stop. + #[default] + Linear, + /// Transitions along a curve that flows through the stops without corners. + /// + /// The rate of color change carries smoothly through each stop (C1 continuity) and never overshoots beyond the stop colors, properties of its spline: a Piecewise Cubic Hermite Interpolating Polynomial (PCHIP) with Fritsch-Carlson tangent limiting. + Smooth, +} + +impl GradientInterpolation { + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + +/// The whole-ramp attributes governing how a gradient plays back, read together off a gradient item so the +/// sampling entry points take one argument instead of a widening list of same-typed positional ones. +#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GradientSettings { + pub spread: GradientSpread, + pub cyclic: bool, + pub space: GradientSpace, + pub hue_direction: GradientHueDirection, + pub interpolation: GradientInterpolation, +} + +impl From<&Item> for GradientSettings { + fn from(item: &Item) -> Self { + Self { + spread: item.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD), + cyclic: item.attribute_cloned_or_default(ATTR_GRADIENT_CYCLIC), + space: item.attribute_cloned_or_default(ATTR_GRADIENT_SPACE), + hue_direction: item.attribute_cloned_or_default(ATTR_GRADIENT_HUE_DIRECTION), + interpolation: item.attribute_cloned_or_default(ATTR_GRADIENT_INTERPOLATION), + } + } +} + +impl From<&GradientRamp> for GradientSettings { + fn from(ramp: &GradientRamp) -> Self { + Self { + spread: ramp.gradient_spread, + cyclic: ramp.gradient_cyclic, + space: ramp.gradient_space, + hue_direction: ramp.gradient_hue_direction, + interpolation: ramp.gradient_interpolation, + } + } +} + /// Rebuild the y-axis so its (parallel, perpendicular) components in the x-axis-aligned frame stay constant, both /// rescaled by `|new_x| / |old_x|`. This holds the (x, y) parallelogram's aspect ratio and skew fixed across an endpoint /// drag, so a radial ellipse stays the same shape (just rotated and resized) instead of distorting as x grows or shrinks. @@ -1263,7 +1764,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(false), vec![0., 1.]); - assert_eq!(Gradient::default().evaluate(0.5, Default::default(), false, Default::default(), Default::default()), Color::BLACK); + assert_eq!(Gradient::default().evaluate(0.5, Default::default()), Color::BLACK); } #[test] @@ -1336,11 +1837,6 @@ mod tests { ); 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_space: GradientSpace::RgbGamma, ..default_space.clone() @@ -1372,23 +1868,244 @@ mod tests { ); assert_eq!(GradientRamp::from(&item), ramp); + let oklab = Item::::from(GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE]))); + assert!( + oklab.attribute::(ATTR_GRADIENT_SPACE).is_none(), + "the default OkLab must stay absent rather than materialize" + ); + } + + #[test] + fn gradient_hue_direction_round_trips_through_the_item_attribute() { + let ramp = GradientRamp { + gradient_hue_direction: GradientHueDirection::Longer, + ..GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE])) + }; + + let item = Item::::from(ramp.clone()); + assert_eq!( + item.attribute_cloned_or_default::(ATTR_GRADIENT_HUE_DIRECTION), + GradientHueDirection::Longer, + "the runtime item should carry the hue direction as its attribute" + ); + assert_eq!(GradientRamp::from(&item), ramp); + + let shorter = Item::::from(GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE]))); + assert!( + shorter.attribute::(ATTR_GRADIENT_HUE_DIRECTION).is_none(), + "the default Shorter must stay absent rather than materialize" + ); + } + + #[test] + fn gradient_interpolation_round_trips_through_the_item_attribute() { + let ramp = GradientRamp { + gradient_interpolation: GradientInterpolation::Smooth, + ..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::Smooth, + "the runtime item should carry the interpolation 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_SPACE).is_none(), + linear.attribute::(ATTR_GRADIENT_INTERPOLATION).is_none(), "the default Linear must stay absent rather than materialize" ); } + #[test] + fn toggling_cyclic_leaves_the_stops_where_they_are() { + // A ramp still riding the elided default is the case that would otherwise snap, since the two modes + // spread the same stop count differently + let elided = GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED])); + assert_eq!(Gradient::from(&elided).positions(false), vec![0., 0.5, 1.]); + + let cyclic = elided.clone().with_cyclic(true); + assert!(cyclic.gradient_cyclic); + assert_eq!(Gradient::from(&cyclic).positions(true), vec![0., 0.5, 1.], "toggling cyclic on must not move the stops"); + + // And back again, landing on the original elided form rather than the cyclic distribution + let restored = cyclic.with_cyclic(false); + assert_eq!(Gradient::from(&restored).positions(false), vec![0., 0.5, 1.], "toggling cyclic off must not move the stops"); + assert_eq!(restored, elided, "returning to the original mode should restore the canonical elided form"); + + // A ramp already sitting on the cyclic even distribution keeps it, and elides once the flag agrees + let mut thirds = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); + thirds.set_positions(&[0., 1. / 3., 2. / 3.]); + let ramp = GradientRamp::from(thirds).with_cyclic(true); + assert_eq!(Gradient::from(&ramp).positions(true), vec![0., 1. / 3., 2. / 3.]); + assert!( + !Gradient::from(&ramp).has_position_attribute(), + "matching the new mode's distribution should elide back to the canonical form" + ); + } + + #[test] + fn stepped_holds_each_color_to_the_next_stop_and_ignores_midpoints() { + let stepped = GradientSettings { + interpolation: GradientInterpolation::Stepped, + ..Default::default() + }; + + let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); + gradient.set_midpoints(&[0.1, 0.9, 0.5]); + + // Each stop's color fills its whole interval, jumping only once the next stop is reached + for (t, expected) in [(0., Color::BLACK), (0.49, Color::BLACK), (0.5, Color::WHITE), (0.99, Color::WHITE), (1., Color::RED)] { + assert_eq!(gradient.evaluate(t, stepped), expected, "stepped should hold the left stop's color at {t}"); + } + + // The bake reproduces the jumps exactly rather than approximating them, so each interval emits both of its ends + let samples = gradient.interpolated_samples(stepped); + assert_eq!( + samples.iter().map(|&(position, color, _)| (position, color)).collect::>(), + vec![(0., Color::BLACK), (0.5, Color::BLACK), (0.5, Color::WHITE), (1., Color::WHITE), (1., Color::RED)] + ); + } + + #[test] + fn smooth_passes_through_every_stop_without_overshooting_between_them() { + let smooth = GradientSettings { + space: GradientSpace::RgbLinear, + interpolation: GradientInterpolation::Smooth, + ..Default::default() + }; + + let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::BLACK]); + + // A monotonic spline is pinned to its stops, unlike an overshooting one such as Catmull-Rom + for (index, expected) in [(0., Color::BLACK), (0.5, Color::WHITE), (1., Color::BLACK)] { + let sampled = gradient.evaluate(index, smooth); + assert!((sampled.r() - expected.r()).abs() < 1e-4, "the spline must pass through the stop at {index}, got {sampled:?}"); + } + + // Every sample between two stops stays bounded by them, so no channel manufactures an out-of-gamut excursion + for step in 0..=100 { + let red = gradient.evaluate(step as f64 / 100., smooth).r(); + assert!((-1e-4..=1. + 1e-4).contains(&red), "the monotonic spline must not overshoot its stops, got {red} at step {step}"); + } + } + + #[test] + fn smooth_removes_the_rate_kink_that_linear_leaves_at_a_stop() { + let mut gradient = Gradient::from(vec![Color::BLACK, Color::from_rgbaf32_unchecked(0.25, 0.25, 0.25, 1.), Color::WHITE]); + gradient.set_positions(&[0., 0.5, 1.]); + + // Sample the slope just either side of the middle stop, where Linear's independent chords change rate abruptly. + // The step stays small so the one-sided differences approximate the slopes at the stop rather than averaging in curvature. + let slope_around_middle = |settings: GradientSettings| { + const STEP: f64 = 1e-3; + let sample = |t: f64| gradient.evaluate(t, settings).r() as f64; + let before = (sample(0.5) - sample(0.5 - STEP)) / STEP; + let after = (sample(0.5 + STEP) - sample(0.5)) / STEP; + (after - before).abs() + }; + + let linear = GradientSettings { + space: GradientSpace::RgbLinear, + ..Default::default() + }; + let smooth = GradientSettings { + interpolation: GradientInterpolation::Smooth, + ..linear + }; + + assert!( + slope_around_middle(smooth) < slope_around_middle(linear) / 10., + "smooth should carry its rate through the stop where linear turns a corner" + ); + } + + #[test] + fn smooth_closes_a_cyclic_loop_across_the_boundary() { + let smooth_cyclic = GradientSettings { + space: GradientSpace::RgbLinear, + cyclic: true, + interpolation: GradientInterpolation::Smooth, + ..Default::default() + }; + + let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::from_rgbaf32_unchecked(0.5, 0.5, 0.5, 1.)]); + + // The two wrapped views of the boundary must agree, or the seam shows as a visible discontinuity + let (before, after) = (gradient.evaluate(0.999, smooth_cyclic), gradient.evaluate(0.001, smooth_cyclic)); + assert!((before.r() - after.r()).abs() < 2. / 255., "the loop must join across the 1|0 boundary, got {before:?} then {after:?}"); + + // The bake's ends share that crossing color exactly, so downstream renderers see a closed ramp + let samples = gradient.interpolated_samples(smooth_cyclic); + let (first, last) = (samples.first().expect("a baked ramp has samples"), samples.last().expect("a baked ramp has samples")); + assert_eq!((first.0, first.1), (0., last.1), "the baked ends must share the boundary-crossing color"); + assert_eq!(last.0, 1.); + } + + #[test] + fn smooth_bake_keeps_the_end_stops_their_own_colors() { + let smooth = GradientSettings { + space: GradientSpace::RgbLinear, + interpolation: GradientInterpolation::Smooth, + ..Default::default() + }; + + let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); + + let samples = gradient.interpolated_samples(smooth); + let (first, last) = (samples.first().expect("a baked ramp has samples"), samples.last().expect("a baked ramp has samples")); + assert_eq!((first.0, first.1), (0., Color::BLACK)); + assert_eq!((last.0, last.1), (1., Color::WHITE)); + } + + #[test] + fn smooth_cyclic_stops_on_both_boundaries_keep_a_hard_seam() { + let open = GradientSettings { + space: GradientSpace::RgbLinear, + interpolation: GradientInterpolation::Smooth, + ..Default::default() + }; + let cyclic = GradientSettings { cyclic: true, ..open }; + + let mut gradient = Gradient::from(vec![Color::BLACK, Color::from_rgbaf32_unchecked(0.25, 0.25, 0.25, 1.), Color::WHITE]); + gradient.set_positions(&[0., 0.5, 1.]); + + // With no wrapped interval to cross, the cycle's seam is the jump between the two end stops and the + // spline matches its open form everywhere + for step in 0..=100 { + let t = step as f64 / 100.; + assert_eq!(gradient.evaluate(t, cyclic), gradient.evaluate(t, open), "cyclic and open must agree at {t}"); + } + + let samples = gradient.interpolated_samples(cyclic); + let (first, last) = (samples.first().expect("a baked ramp has samples"), samples.last().expect("a baked ramp has samples")); + assert_eq!((first.0, first.1), (0., Color::BLACK)); + assert_eq!((last.0, last.1), (1., Color::WHITE)); + } + #[test] 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(false, GradientSpace::RgbGamma, Default::default()).len(), 2); + assert_eq!( + gradient + .interpolated_samples(GradientSettings { + space: 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(false, GradientSpace::RgbLinear, Default::default()); + let samples = gradient.interpolated_samples(GradientSettings { + space: 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.); @@ -1402,7 +2119,14 @@ mod tests { // Identical end colors leave nothing to densify let flat = Gradient::from(vec![Color::WHITE, Color::WHITE]); - assert_eq!(flat.interpolated_samples(false, GradientSpace::RgbLinear, Default::default()).len(), 2); + assert_eq!( + flat.interpolated_samples(GradientSettings { + space: GradientSpace::RgbLinear, + ..Default::default() + }) + .len(), + 2 + ); } #[test] @@ -1433,7 +2157,11 @@ mod tests { let mut gradient = Gradient::from(vec![color_a, color_b]); gradient.set_midpoints(&[midpoint, 0.5]); - let samples = gradient.interpolated_samples(false, gradient_space, gradient_hue_direction); + let samples = gradient.interpolated_samples(GradientSettings { + space: gradient_space, + hue_direction: gradient_hue_direction, + ..Default::default() + }); for probe in 0..=1000 { let t = probe as f64 / 1000.; @@ -1469,13 +2197,43 @@ 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, false, Default::default(), Default::default()), Color::TRANSPARENT); - assert_eq!(gradient.evaluate(1.25, GradientSpread::Clear, false, Default::default(), Default::default()), Color::TRANSPARENT); + assert_eq!( + gradient.evaluate( + -0.25, + GradientSettings { + spread: GradientSpread::Clear, + ..Default::default() + } + ), + Color::TRANSPARENT + ); + assert_eq!( + gradient.evaluate( + 1.25, + GradientSettings { + spread: GradientSpread::Clear, + ..Default::default() + } + ), + Color::TRANSPARENT + ); for t in [0., 0.25, 1.] { assert_eq!( - gradient.evaluate(t, GradientSpread::Clear, false, Default::default(), Default::default()), - gradient.evaluate(t, GradientSpread::Pad, false, Default::default(), Default::default()), + gradient.evaluate( + t, + GradientSettings { + spread: GradientSpread::Clear, + ..Default::default() + } + ), + gradient.evaluate( + t, + GradientSettings { + spread: GradientSpread::Pad, + ..Default::default() + } + ), "inside the range Clear must match Pad at t = {t}" ); } @@ -1485,9 +2243,27 @@ mod tests { fn evaluate_follows_the_gradient_space() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); - let oklab = gradient.evaluate(0.5, Default::default(), false, GradientSpace::OkLab, Default::default()); - let linear = gradient.evaluate(0.5, Default::default(), false, GradientSpace::RgbLinear, Default::default()); - let gamma = gradient.evaluate(0.5, Default::default(), false, GradientSpace::RgbGamma, Default::default()); + let oklab = gradient.evaluate( + 0.5, + GradientSettings { + space: GradientSpace::OkLab, + ..Default::default() + }, + ); + let linear = gradient.evaluate( + 0.5, + GradientSettings { + space: GradientSpace::RgbLinear, + ..Default::default() + }, + ); + let gamma = gradient.evaluate( + 0.5, + GradientSettings { + space: 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)); @@ -1509,28 +2285,52 @@ mod tests { // 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(), false, GradientSpace::Hsl, Default::default()); + let magenta = red_to_blue.evaluate( + 0.5, + GradientSettings { + space: 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(), false, GradientSpace::OkLCh, Default::default()); + let pink = red_to_white.evaluate( + 0.5, + GradientSettings { + space: 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(), false, GradientSpace::Hsv, Default::default()); + let tint = red_to_white.evaluate( + 0.5, + GradientSettings { + space: 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(), false, GradientSpace::Hsv, Default::default()); + let shade = red_to_black.evaluate( + 0.5, + GradientSettings { + space: 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:?}"); } @@ -1548,7 +2348,14 @@ mod tests { (GradientHueDirection::Decreasing, [1., 0., 1.]), ]; for (gradient_hue_direction, expected_rgb) in expectations { - let mid = red_to_blue.evaluate(0.5, Default::default(), false, GradientSpace::Hsl, gradient_hue_direction); + let mid = red_to_blue.evaluate( + 0.5, + GradientSettings { + space: GradientSpace::Hsl, + hue_direction: gradient_hue_direction, + ..Default::default() + }, + ); for (channel, target) in [mid.r(), mid.g(), mid.b()].into_iter().zip(expected_rgb) { assert!( (channel - target).abs() < 1e-3, @@ -1559,7 +2366,14 @@ mod tests { // 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(), false, GradientSpace::Hsl, GradientHueDirection::Longer); + let mid = red_to_red.evaluate( + 0.5, + GradientSettings { + space: GradientSpace::Hsl, + hue_direction: GradientHueDirection::Longer, + ..Default::default() + }, + ); 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:?}"); } @@ -1618,7 +2432,10 @@ mod tests { assert_eq!(gradient.positions(false), vec![1.5, 0.4, -0.5]); let sample_positions: Vec = gradient - .interpolated_samples(false, GradientSpace::RgbGamma, Default::default()) + .interpolated_samples(GradientSettings { + space: GradientSpace::RgbGamma, + ..Default::default() + }) .iter() .map(|(position, ..)| *position) .collect(); @@ -1626,8 +2443,8 @@ mod tests { assert_eq!(sample_positions.first(), Some(&0.)); assert_eq!(sample_positions.last(), Some(&1.)); - assert_eq!(gradient.evaluate(0., Default::default(), false, Default::default(), Default::default()), Color::RED); - assert_eq!(gradient.evaluate(1., Default::default(), false, Default::default(), Default::default()), Color::WHITE); + assert_eq!(gradient.evaluate(0., Default::default()), Color::RED); + assert_eq!(gradient.evaluate(1., Default::default()), Color::WHITE); } #[test] @@ -1636,13 +2453,16 @@ mod tests { gradient.set_positions(&[f64::INFINITY, f64::NEG_INFINITY]); let sample_positions: Vec = gradient - .interpolated_samples(false, GradientSpace::RgbGamma, Default::default()) + .interpolated_samples(GradientSettings { + space: GradientSpace::RgbGamma, + ..Default::default() + }) .iter() .map(|(position, ..)| *position) .collect(); assert_eq!(sample_positions, vec![0., 1.]); - assert_eq!(gradient.evaluate(0., Default::default(), false, Default::default(), Default::default()), Color::BLACK); - assert_eq!(gradient.evaluate(1., Default::default(), false, Default::default(), Default::default()), Color::WHITE); + assert_eq!(gradient.evaluate(0., Default::default()), Color::BLACK); + assert_eq!(gradient.evaluate(1., Default::default()), Color::WHITE); } #[test] @@ -1651,13 +2471,22 @@ mod tests { gradient.set_positions(&[0., f64::NAN, 1.]); let sample_positions: Vec = gradient - .interpolated_samples(false, GradientSpace::RgbGamma, Default::default()) + .interpolated_samples(GradientSettings { + space: GradientSpace::RgbGamma, + ..Default::default() + }) .iter() .map(|(position, ..)| *position) .collect(); assert_eq!(sample_positions, vec![0., 1.]); assert_eq!( - gradient.evaluate(0.5, Default::default(), false, GradientSpace::RgbLinear, Default::default()), + gradient.evaluate( + 0.5, + GradientSettings { + space: GradientSpace::RgbLinear, + ..Default::default() + } + ), Color::WHITE.lerp(&Color::RED, 0.5) ); @@ -1667,8 +2496,15 @@ 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(false, GradientSpace::RgbGamma, Default::default()).is_empty()); - assert_eq!(gradient.evaluate(0.5, Default::default(), false, Default::default(), Default::default()), Color::BLACK); + assert!( + gradient + .interpolated_samples(GradientSettings { + space: GradientSpace::RgbGamma, + ..Default::default() + }) + .is_empty() + ); + assert_eq!(gradient.evaluate(0.5, Default::default()), Color::BLACK); } #[test] @@ -1676,19 +2512,25 @@ mod tests { let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]); gradient.set_positions(&[0.3, 1.]); - let samples = gradient.interpolated_samples(false, GradientSpace::RgbGamma, Default::default()); + let samples = gradient.interpolated_samples(GradientSettings { + space: 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(), false, Default::default(), Default::default()); + let linear_result = gradient.evaluate(0.25, Default::default()); gradient.set_midpoints(&[f64::NAN, f64::NAN]); - assert_eq!(gradient.evaluate(0.25, Default::default(), false, Default::default(), Default::default()), linear_result); + assert_eq!(gradient.evaluate(0.25, Default::default()), linear_result); let no_nan_annotations = gradient - .interpolated_samples(false, GradientSpace::RgbGamma, Default::default()) + .interpolated_samples(GradientSettings { + space: 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"); @@ -1725,16 +2567,44 @@ mod tests { fn cyclic_evaluate_wraps_from_the_last_stop_back_to_the_first() { // Elided cyclic positions put the stops at 0 and 0.5, so the wrapped interval spans the other half let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); - let quarter = gradient.evaluate(0.25, Default::default(), true, GradientSpace::RgbLinear, Default::default()); - let wrap_quarter = gradient.evaluate(0.75, Default::default(), true, GradientSpace::RgbLinear, Default::default()); + let quarter = gradient.evaluate( + 0.25, + GradientSettings { + cyclic: true, + space: GradientSpace::RgbLinear, + ..Default::default() + }, + ); + let wrap_quarter = gradient.evaluate( + 0.75, + GradientSettings { + cyclic: true, + space: GradientSpace::RgbLinear, + ..Default::default() + }, + ); assert_eq!(quarter, Color::BLACK.lerp(&Color::WHITE, 0.5)); assert_eq!(wrap_quarter, Color::WHITE.lerp(&Color::BLACK, 0.5)); // A wrapped interval crossing the 1|0 boundary reads as one continuous span, so its two sides agree at the seam let mut offset = Gradient::from(vec![Color::BLACK, Color::WHITE]); offset.set_positions(&[0.25, 0.5]); - let at_end = offset.evaluate(1., Default::default(), true, GradientSpace::RgbLinear, Default::default()); - let at_start = offset.evaluate(0., Default::default(), true, GradientSpace::RgbLinear, Default::default()); + let at_end = offset.evaluate( + 1., + GradientSettings { + cyclic: true, + space: GradientSpace::RgbLinear, + ..Default::default() + }, + ); + let at_start = offset.evaluate( + 0., + GradientSettings { + cyclic: true, + space: GradientSpace::RgbLinear, + ..Default::default() + }, + ); assert_eq!(at_end, at_start, "the 1|0 boundary must be seamless"); assert_eq!(at_end, Color::WHITE.lerp(&Color::BLACK, 2. / 3.)); } @@ -1745,7 +2615,14 @@ mod tests { gradient.set_midpoints(&[0.5, 0.25]); let expected_t = apply_midpoint(0.5, 0.25); - let mid = gradient.evaluate(0.75, Default::default(), true, GradientSpace::RgbLinear, Default::default()); + let mid = gradient.evaluate( + 0.75, + GradientSettings { + cyclic: true, + space: GradientSpace::RgbLinear, + ..Default::default() + }, + ); assert_eq!(mid, Color::WHITE.lerp(&Color::BLACK, expected_t as f32)); } @@ -1755,7 +2632,11 @@ mod tests { gradient.set_positions(&[0.25, 0.5]); gradient.set_midpoints(&[0.5, 0.3]); - let samples = gradient.interpolated_samples(true, GradientSpace::OkLab, Default::default()); + let samples = gradient.interpolated_samples(GradientSettings { + cyclic: true, + space: GradientSpace::OkLab, + ..Default::default() + }); assert_eq!(samples.first().unwrap().0, 0.); assert_eq!(samples.last().unwrap().0, 1.); assert_eq!(samples.first().unwrap().1, samples.last().unwrap().1, "both ends must share the boundary-crossing color"); @@ -1779,7 +2660,14 @@ mod tests { } }; - let true_color = gradient.evaluate(t, Default::default(), true, GradientSpace::OkLab, Default::default()); + let true_color = gradient.evaluate( + t, + GradientSettings { + cyclic: true, + space: GradientSpace::OkLab, + ..Default::default() + }, + ); let deviation = max_gamma_channel_deviation(playback, true_color); assert!(deviation <= 4. / 255., "playback deviates {:.1}/255 at t={t}", deviation * 255.); } @@ -1802,7 +2690,14 @@ mod tests { gradient.set_midpoints(&[0.5, 0.3]); // The elided cyclic stops sit at 0 and 0.5, so 0.75 lands mid-wrap - let index = gradient.insert_stop(0.75, true, GradientSpace::RgbLinear, Default::default()); + let index = gradient.insert_stop( + 0.75, + GradientSettings { + cyclic: true, + space: GradientSpace::RgbLinear, + ..Default::default() + }, + ); assert_eq!(index, 2); assert_eq!(gradient.midpoint(2), 0.3, "the wrap handle should be inherited by the split"); assert_eq!(gradient.color(2), Some(Color::WHITE.lerp(&Color::BLACK, apply_midpoint(0.5, 0.3) as f32))); diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index 46efc65e8d..d15d4b6a73 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -8,7 +8,7 @@ pub mod vector; // Re-export commonly used types at the crate root pub use core_types as gcore; -pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, GradientStop}; +pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop}; pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; pub use vector::Vector; diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 878da81897..3b4e18770c 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_cyclic, ramp.gradient_space, ramp.gradient_hue_direction)), + Self::Gradient(ramp) => Some(ramp.stops.to_css_linear_gradient(ramp.into())), } } } @@ -361,19 +361,6 @@ impl Stroke { self } - pub fn with_dash_lengths(mut self, dash_lengths: &str) -> Option { - dash_lengths - .split(&[',', ' ']) - .filter(|x| !x.is_empty()) - .map(str::parse::) - .collect::, _>>() - .ok() - .map(|lengths| { - self.dash_lengths = lengths; - self - }) - } - pub fn with_dash_offset(mut self, dash_offset: f64) -> Self { self.dash_offset = dash_offset; self diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 87c787b847..8a7d1833f2 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -9,7 +9,7 @@ use rand::SeedableRng; use rand::seq::SliceRandom; use raster_types::{CPU, GPU, Raster}; use std::cmp::Ordering; -use vector_types::gradient::{GradientForm, GradientHueDirection, GradientSpace, GradientSpread}; +use vector_types::gradient::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread}; use vector_types::{Gradient, ReferencePoint}; /// Returns the list with the item at the specified index removed. @@ -764,6 +764,23 @@ fn read_attribute_gradient_space( result } +/// Reads a named `GradientInterpolation` attribute from the input list, outputting each value as an element of a new `GradientInterpolation[]`. +#[node_macro::node(category("Attributes: Read"))] +fn read_attribute_gradient_interpolation( + _: impl Ctx, + content: ListDyn, + /// The attribute name (key) to read. + name: Item, +) -> List { + let name = name.into_element(); + let mut result = List::with_capacity(content.len()); + for index in 0..content.len() { + let Some(value) = content.attribute::(&name, index) else { continue }; + result.push(Item::new_from_element(*value)); + } + result +} + /// Reads a named `GradientHueDirection` attribute from the input list, outputting each value as an element of a new `GradientHueDirection[]`. #[node_macro::node(category("Attributes: Read"))] fn read_attribute_gradient_hue_direction( diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 9550c09592..184eb55a5a 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1401,6 +1401,14 @@ fn gradient_space(_: impl Ctx, gradient: Item, space: Item, interpolation: Item) -> Item { + let mut gradient = gradient; + gradient.set_attribute(core_types::ATTR_GRADIENT_INTERPOLATION, *interpolation.element()); + gradient +} + /// Sets whether each gradient in the input list treats its stops as a cycle, interpolating from the last stop back around to the first. #[node_macro::node(category("Gradient"))] fn gradient_cyclic(_: impl Ctx, gradient: Item, cyclic: Item) -> Item { @@ -1444,13 +1452,8 @@ fn gradient_midpoints(_: impl Ctx, gradient: Item, midpoints: List, position: Item) -> Item { - let gradient_spread = gradient.attribute_cloned_or_default::(core_types::ATTR_GRADIENT_SPREAD); - let gradient_space = gradient.attribute_cloned_or_default::(core_types::ATTR_GRADIENT_SPACE); - let gradient_cyclic = gradient.attribute_cloned_or_default::(core_types::ATTR_GRADIENT_CYCLIC); - let gradient_hue_direction = gradient.attribute_cloned_or_default::(core_types::ATTR_GRADIENT_HUE_DIRECTION); - let color = gradient - .element() - .evaluate(*position.element(), gradient_spread, gradient_cyclic, gradient_space, gradient_hue_direction); + let settings = vector_types::GradientSettings::from(&gradient); + let color = gradient.element().evaluate(*position.element(), settings); Item::new_from_element(color) } diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 7e07757056..d80b1ce8dc 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -48,10 +48,10 @@ mod blend_std { let mut combined_stops = self.positions(false).into_iter().chain(under.positions(false)).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 over_evaluator = self.evaluator(Default::default()); + let under_evaluator = under.evaluator(Default::default()); let stops = combined_stops.into_iter().map(|position| { - let over_color = self.evaluate(position, Default::default(), false, Default::default(), Default::default()); - let under_color = under.evaluate(position, Default::default(), false, Default::default(), Default::default()); - let color = blend_fn(over_color, under_color); + let color = blend_fn(over_evaluator.evaluate(position), under_evaluator.evaluate(position)); 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 3635454739..51ed59ad82 100644 --- a/node-graph/nodes/raster/src/gradient_map.rs +++ b/node-graph/nodes/raster/src/gradient_map.rs @@ -22,17 +22,14 @@ async fn gradient_map + Send>( reverse: Item, ) -> Item { let mut image = image; - let gradient_spread = gradient.attribute_cloned_or_default::(core_types::ATTR_GRADIENT_SPREAD); - let gradient_space = gradient.attribute_cloned_or_default::(core_types::ATTR_GRADIENT_SPACE); - let gradient_cyclic = gradient.attribute_cloned_or_default::(core_types::ATTR_GRADIENT_CYCLIC); - let gradient_hue_direction = gradient.attribute_cloned_or_default::(core_types::ATTR_GRADIENT_HUE_DIRECTION); - let gradient = gradient.into_element(); + let settings = vector_types::GradientSettings::from(&gradient); + let evaluator = gradient.into_element().evaluator(settings); let reverse = reverse.into_element(); image.element_mut().adjust(|color| { let intensity = color.luminance_rec_709(); let intensity = if reverse { 1. - intensity } else { intensity }; - gradient.evaluate(intensity as f64, gradient_spread, gradient_cyclic, gradient_space, gradient_hue_direction) + evaluator.evaluate(intensity as f64) }); image diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 85fa22c510..77d5a3d192 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -3,7 +3,7 @@ use core::f64::consts::{PI, TAU}; use core::hash::{Hash, Hasher}; use core_types::blending::BlendMode; use core_types::bounds::{BoundingBox, RenderBoundingBox}; -use core_types::list::{ATTR_FILL, ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_SPACE, ATTR_STROKE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath}; +use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::transform::{Footprint, Transform}; use core_types::uuid::NodeId; @@ -32,7 +32,7 @@ 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, GradientHueDirection, GradientSpace, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; use vector_types::vector::{PointDomain, RegionDomain}; @@ -141,11 +141,14 @@ where let mut content = content; let length = content.vector_count(); - let gradient_space = gradient.attribute_cloned_or_default::(ATTR_GRADIENT_SPACE); - let gradient_cyclic = gradient.attribute_cloned_or_default::(ATTR_GRADIENT_CYCLIC); - let gradient_hue_direction = gradient.attribute_cloned_or_default::(ATTR_GRADIENT_HUE_DIRECTION); + // The factor spans 0..=1, so the spread deliberately stays Pad (Repeat would wrap the final element onto the first stop's color) + let settings = GradientSettings { + spread: Default::default(), + ..GradientSettings::from(&gradient) + }; let element = gradient.into_element(); - let gradient = if reverse { element.reversed(gradient_cyclic) } else { element }; + let gradient = if reverse { element.reversed(settings.cyclic) } else { element }; + let evaluator = gradient.evaluator(settings); let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into()); @@ -161,8 +164,7 @@ where }, }; - // The factor spans 0..=1 inclusively, so the spread deliberately stays Pad (Repeat would wrap the final element onto the first stop's color) - let color = gradient.evaluate(factor, Default::default(), gradient_cyclic, gradient_space, gradient_hue_direction); + let color = evaluator.evaluate(factor); let paint = List::new_from_element(color).into_graphic_list(); if fill {