diff --git a/editor/src/messages/color_picker/color_picker_message.rs b/editor/src/messages/color_picker/color_picker_message.rs index dcaef4a00a..a6063078a3 100644 --- a/editor/src/messages/color_picker/color_picker_message.rs +++ b/editor/src/messages/color_picker/color_picker_message.rs @@ -49,6 +49,8 @@ pub enum ColorPickerMessage { GradientUpdate { update: SpectrumInputUpdate }, /// Gradient spread choice from the gradient "Ends" selection. SetGradientSpread { gradient_spread: GradientSpread }, + /// Gradient cyclic choice: whether the stops wrap as a cycle, from the "Cyclic" checkbox. + SetGradientCyclic { gradient_cyclic: bool }, /// Gradient space choice: the color space the stops interpolate in, from the "Space" dropdown. SetGradientSpace { gradient_space: GradientSpace }, /// Gradient hue direction choice: which way around the hue wheel the stops interpolate in a polar space, from the "Arc" dropdown. 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 772a60eecc..b792c1a3c0 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -31,6 +31,7 @@ pub struct ColorPickerMessageHandler { gradient: Option, gradient_spread: GradientSpread, gradient_space: GradientSpace, + gradient_cyclic: bool, gradient_hue_direction: GradientHueDirection, active_marker_index: Option, active_marker_is_midpoint: bool, @@ -55,6 +56,7 @@ impl Default for ColorPickerMessageHandler { gradient: None, gradient_spread: GradientSpread::default(), gradient_space: GradientSpace::default(), + gradient_cyclic: false, gradient_hue_direction: GradientHueDirection::default(), active_marker_index: None, active_marker_is_midpoint: false, @@ -78,6 +80,7 @@ impl MessageHandler for ColorPickerMessageHandler { self.gradient = None; self.gradient_spread = GradientSpread::default(); self.gradient_space = GradientSpace::default(); + self.gradient_cyclic = false; self.gradient_hue_direction = GradientHueDirection::default(); self.active_marker_index = None; self.active_marker_is_midpoint = false; @@ -86,6 +89,7 @@ impl MessageHandler for ColorPickerMessageHandler { self.gradient = None; self.gradient_spread = GradientSpread::default(); self.gradient_space = GradientSpace::default(); + self.gradient_cyclic = false; self.gradient_hue_direction = GradientHueDirection::default(); self.active_marker_index = None; self.active_marker_is_midpoint = false; @@ -96,6 +100,7 @@ impl MessageHandler for ColorPickerMessageHandler { self.active_marker_is_midpoint = false; self.gradient_spread = ramp.gradient_spread; self.gradient_space = ramp.gradient_space; + self.gradient_cyclic = ramp.gradient_cyclic; self.gradient_hue_direction = ramp.gradient_hue_direction; let gradient = Gradient::from(ramp); let first_color = gradient.color(0).unwrap_or(Color::BLACK); @@ -210,6 +215,22 @@ impl MessageHandler for ColorPickerMessageHandler { 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) + }), + }); + self.send_layouts(responses); + } + ColorPickerMessage::SetGradientCyclic { gradient_cyclic } => { + let Some(gradient) = &self.gradient else { return }; + responses.add(FrontendMessage::ColorPickerStartHistoryTransaction); + self.gradient_cyclic = gradient_cyclic; + 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) }), @@ -224,6 +245,7 @@ impl MessageHandler for ColorPickerMessageHandler { 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) }), @@ -238,6 +260,7 @@ impl MessageHandler for ColorPickerMessageHandler { 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) }), @@ -331,6 +354,7 @@ impl ColorPickerMessageHandler { 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) }), @@ -378,7 +402,7 @@ impl ColorPickerMessageHandler { match update { SpectrumInputUpdate::MoveMarker { index, position } => { - let new_index = gradient.move_stop(index as usize, position); + let new_index = gradient.move_stop(index as usize, position, self.gradient_cyclic); if Some(index) == self.active_marker_index { self.active_marker_index = Some(new_index as u32); } @@ -390,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_space, self.gradient_hue_direction); + let new_index = gradient.insert_stop(position, self.gradient_cyclic, self.gradient_space, self.gradient_hue_direction); self.active_marker_index = Some(new_index as u32); self.active_marker_is_midpoint = false; if let Some(color) = gradient.color(new_index) { @@ -400,7 +424,9 @@ impl ColorPickerMessageHandler { } SpectrumInputUpdate::InsertDuplicate { index, position } => { let source = index as usize; - let Some(insert_index) = gradient.duplicate_stop(source, position) else { return }; + let Some(insert_index) = gradient.duplicate_stop(source, position, self.gradient_cyclic) else { + return; + }; // The dragged stop (the duplication source) stays active. Its index shifts up if the frozen copy landed at or before it. let dragged_index = if insert_index <= source { source + 1 } else { source }; self.active_marker_index = Some(dragged_index as u32); @@ -446,12 +472,13 @@ impl ColorPickerMessageHandler { if i >= count { return; } - // Each stop's "natural" position is its evenly-spaced fraction along 0..1, e.g., for 5 stops: 0, 0.25, 0.5, 0.75, 1. Falls back to the midpoint between neighbors when the natural position would push the stop past another. - let left = if i == 0 { 0. } else { gradient.position(i - 1) }; - let right = if i + 1 < count { gradient.position(i + 1) } else { 1. }; - let natural = if count <= 1 { 0. } else { i as f64 / (count - 1) as f64 }; + // Each stop's "natural" position is its evenly-spaced fraction along 0..1, e.g., for 5 stops: 0, 0.25, 0.5, 0.75, 1 (or fifths when cyclic, leaving the wrapped interval its share). Falls back to the midpoint between neighbors when the natural position would push the stop past another. + let left = if i == 0 { 0. } else { gradient.position(i - 1, self.gradient_cyclic) }; + let right = if i + 1 < count { gradient.position(i + 1, self.gradient_cyclic) } else { 1. }; + let denominator = if self.gradient_cyclic { count } else { count - 1 }; + let natural = if count <= 1 { 0. } else { i as f64 / denominator as f64 }; let new_position = if (left..=right).contains(&natural) { natural } else { (left + right) / 2. }; - let new_index = gradient.move_stop(i, new_position); + let new_index = gradient.move_stop(i, new_position, self.gradient_cyclic); if Some(index) == self.active_marker_index { self.active_marker_index = Some(new_index as u32); } @@ -463,6 +490,7 @@ impl ColorPickerMessageHandler { 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) }), @@ -491,10 +519,13 @@ impl ColorPickerMessageHandler { // Gradient editor (only present when the picker is in gradient mode) if let Some(gradient) = &self.gradient { // For gradient editing, the markers' handle colors mirror their gradient stop colors - let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect(); + let markers = (0..gradient.len()) + .filter_map(|i| Some(SpectrumMarker::new(gradient.position(i, self.gradient_cyclic), gradient.midpoint(i), gradient.color(i)?))) + .collect(); let mut row_widgets = vec![ SpectrumInput::new(GradientStops::from(gradient)) .track_space(self.gradient_space) + .track_cyclic(self.gradient_cyclic) .track_hue_direction(self.gradient_hue_direction) .markers(markers) .active_marker_index(self.active_marker_index) @@ -513,7 +544,7 @@ impl ColorPickerMessageHandler { let position_value = match (self.active_marker_is_midpoint, active_index < gradient.len()) { (_, false) => 0., (true, true) => gradient.midpoint(active_index), - (false, true) => gradient.position(active_index), + (false, true) => gradient.position(active_index, self.gradient_cyclic), }; let is_midpoint = self.active_marker_is_midpoint; let captured_index = active; @@ -667,14 +698,31 @@ impl ColorPickerMessageHandler { .widget_instance(), ])); - // Gradient spread (only present when the picker is in gradient mode) + // Gradient spread, trailed by the cyclic wrap toggle (only present when the picker is in gradient mode) if self.gradient.is_some() { let entries = RadioEntryData::list_from_choice_type(|gradient_spread| ColorPickerMessage::SetGradientSpread { gradient_spread }.into()); groups.push(LayoutGroup::row(vec![ - TextLabel::new("Ends").tooltip_label("Gradient Spread").tooltip_description(ENDS_DESCRIPTION).widget_instance(), + TextLabel::new("Ends").tooltip_label("Gradient Spread / Cyclic").tooltip_description(ENDS_DESCRIPTION).widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), - RadioInput::new(entries).selected_index(Some(self.gradient_spread as u32)).disabled(self.disabled).widget_instance(), + RadioInput::new(entries) + .narrow(true) + .selected_index(Some(self.gradient_spread as u32)) + .disabled(self.disabled) + .widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + CheckboxInput::new(self.gradient_cyclic) + .icon("Link") + .disabled(self.disabled) + .tooltip_label("Cyclic") + .tooltip_description(CYCLIC_DESCRIPTION) + .on_update(|checkbox_input: &CheckboxInput| { + ColorPickerMessage::SetGradientCyclic { + gradient_cyclic: checkbox_input.checked, + } + .into() + }) + .widget_instance(), ])); } @@ -759,7 +807,8 @@ const HUE_DESCRIPTION: &str = "The shade along the spectrum of the rainbow."; const SATURATION_DESCRIPTION: &str = "The vividness from grayscale to full color."; const VALUE_DESCRIPTION: &str = "The brightness from black to full color."; const ALPHA_DESCRIPTION: &str = "The level of translucency, from transparent (0%) to opaque (100%)."; -const ENDS_DESCRIPTION: &str = "The method for how the gradient continues beyond its ends."; +const ENDS_DESCRIPTION: &str = "The method for how the gradient continues beyond its ends, and whether its stops cycle back around from last to first."; +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."; diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs index 54c1f1d967..d19af3b1d9 100644 --- a/editor/src/messages/layout/layout_message_handler.rs +++ b/editor/src/messages/layout/layout_message_handler.rs @@ -531,9 +531,17 @@ 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_space, spectrum_input.track_hue_direction); - spectrum_input.track_start_css = spectrum_input.track.color.first().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string()); - spectrum_input.track_end_css = spectrum_input.track.color.last().map(|color| color.to_css_hex()).unwrap_or_else(|| "black".to_string()); + 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 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 cap = |t: f64| { + let color = track_gradient.evaluate(t, Default::default(), spectrum_input.track_cyclic, spectrum_input.track_space, spectrum_input.track_hue_direction); + SRGBA8::from(color).to_css_hex() + }; + spectrum_input.track_start_css = cap(0.); + spectrum_input.track_end_css = cap(1.); } Widget::ColorComparisonInput(comparison) => { let contrasting = |color: Option| color.map_or(SRGBA8::BLACK, |color| color.contrasting_text_color()).to_css_hex(); 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 fdd320e885..e88af1834d 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -589,6 +589,9 @@ pub struct SpectrumInput { /// The color space the track's stops interpolate in, used to compute `track_css`. Not sent to the frontend. #[serde(skip)] pub track_space: GradientSpace, + /// Whether the track's stops wrap as a cycle, used to compute `track_css` and by the frontend to draw the wrapped interval's midpoint diamond. + #[serde(rename = "trackCyclic")] + pub track_cyclic: bool, /// 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, @@ -596,11 +599,11 @@ pub struct SpectrumInput { #[serde(rename = "trackCSS")] #[widget_builder(skip)] pub track_css: String, - /// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated from `track`'s first stop. + /// Hex string for the track strip's leftmost solid-color end-cap. Auto-populated by evaluating `track` at position 0. #[serde(rename = "trackStartCSS")] #[widget_builder(skip)] pub track_start_css: String, - /// Hex string for the track strip's rightmost solid-color end-cap. Auto-populated from `track`'s last stop. + /// Hex string for the track strip's rightmost solid-color end-cap. Auto-populated by evaluating `track` at position 1. #[serde(rename = "trackEndCSS")] #[widget_builder(skip)] pub track_end_css: String, @@ -641,7 +644,8 @@ pub struct SpectrumInput { pub struct SpectrumMarker { /// Position (0..1) of the marker along the spectrum track. position: f64, - /// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true. The last marker's value is ignored. + /// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true. + /// The last marker's value controls the wrapped interval when `track_cyclic` is set, and is otherwise ignored. midpoint: f64, /// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`]. #[serde(rename = "handleColorCSS")] 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 12389f84f8..e4e913e023 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -23,7 +23,7 @@ use graphene_std::transform::{ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; -use graphene_std::vector::style::{DashPattern, FillChoice, GradientForm, GradientRamp, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientRamp, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::{Artboard, Color, Graphic}; use std::any::Any; @@ -219,6 +219,7 @@ fn generate_layout(introspected_data: &Arc, List, List, + List, List, List, List, @@ -1012,6 +1013,7 @@ impl_table_item_layout_for_choice_enum!( GradientForm, GradientSpread, GradientSpace, + GradientHueDirection, StrokeJoin, StrokeAlign, StrokeCap, @@ -1225,6 +1227,7 @@ macro_rules! known_item_types { GradientForm, GradientSpread, GradientSpace, + GradientHueDirection, 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 bf8815b1b1..de4e6e92a9 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 @@ -31,6 +31,7 @@ pub enum GraphOperationMessage { gradient_form: GradientForm, gradient_spread: GradientSpread, gradient_space: GradientSpace, + gradient_cyclic: bool, gradient_hue_direction: GradientHueDirection, transform: DAffine2, }, @@ -67,6 +68,10 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, gradient_space: GradientSpace, }, + GradientCyclicSet { + layer: LayerNodeIdentifier, + gradient_cyclic: bool, + }, GradientHueDirectionSet { layer: LayerNodeIdentifier, gradient_hue_direction: GradientHueDirection, 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 4d71a71959..3d3e992516 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 @@ -51,11 +51,12 @@ impl MessageHandler> for gradient_form, gradient_spread, gradient_space, + gradient_cyclic, gradient_hue_direction, transform, } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { - modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, gradient_hue_direction, transform); + modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, gradient_cyclic, gradient_hue_direction, transform); } } GraphOperationMessage::BlendingFillSet { layer, fill } => { @@ -98,6 +99,11 @@ impl MessageHandler> for modify_inputs.gradient_space_set(gradient_space); } } + GraphOperationMessage::GradientCyclicSet { layer, gradient_cyclic } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.gradient_cyclic_set(gradient_cyclic); + } + } GraphOperationMessage::GradientHueDirectionSet { layer, gradient_hue_direction } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.gradient_hue_direction_set(gradient_hue_direction); @@ -978,7 +984,7 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g let gradient_spread = convert_gradient_spread(linear.spread_method()); // SVG interpolates between stops in gamma sRGB unless `color-interpolation` opts into linearRGB, carried explicitly rather than as the linear default let gradient_space = gradient_info.spaces.get(linear.id()).copied().unwrap_or(GradientSpace::RgbGamma); - modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, Default::default(), transform); + modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, false, Default::default(), transform); } usvg::Paint::RadialGradient(radial) => { let gradient_transform = usvg_transform(radial.transform()); @@ -1004,7 +1010,7 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g 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, Default::default(), transform); + modify_inputs.fill_gradient_set(gradient, gradient_form, gradient_spread, gradient_space, false, Default::default(), transform); } usvg::Paint::Pattern(_) => warn!("SVG patterns are not currently supported"), }; 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 79358ca27a..a4c606fe30 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -433,12 +433,14 @@ 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, ) { @@ -451,6 +453,7 @@ impl<'a> ModifyInputsContext<'a> { let ramp = GradientRamp { gradient_spread, gradient_space, + gradient_cyclic, gradient_hue_direction, ..ramp }; @@ -792,6 +795,19 @@ 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. + 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 { + return; + }; + let Some(ramp) = self.gradient_value_ramp(gradient_value_id) else { return }; + + let ramp = GradientRamp { gradient_cyclic, ..ramp }; + let input_connector = InputConnector::node(gradient_value_id, graphene_std::math_nodes::gradient_value::GradientInput); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientRamp(ramp), false), false); + } + /// Set the hue direction on the chain's gradient value, which is where the ramp carries it. pub fn gradient_hue_direction_set(&mut self, gradient_hue_direction: GradientHueDirection) { let Some(output_layer) = self.get_output_layer() else { return }; 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 7292b3314b..aae5da9951 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -2410,6 +2410,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte gradient_form: GradientForm, gradient_spread: GradientSpread, gradient_space: GradientSpace, + gradient_cyclic: bool, gradient_hue_direction: GradientHueDirection, transform: DAffine2, /// Whether the transform input holds a plain value (so the "Reverse Direction" button may write to it) rather than a wire. @@ -2442,6 +2443,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte 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, transform: gradient.transform, transform_is_value: gradient.transform_is_value, @@ -2474,12 +2476,14 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte gradient: stops, gradient_spread, gradient_space, + gradient_cyclic, gradient_hue_direction, .. } => { 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 reverse_button = IconButton::new("Reverse", 24) @@ -2490,8 +2494,9 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte TaggedValue::GradientRamp(GradientRamp { gradient_spread, gradient_space, + gradient_cyclic, gradient_hue_direction, - ..GradientRamp::from(stops.reversed()) + ..GradientRamp::from(stops.reversed(gradient_cyclic)) }) }, node_id, @@ -2516,11 +2521,13 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte 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) }), 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 85dd0f8a71..dd4ec052f1 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -407,6 +407,11 @@ pub fn get_chain_source_gradient_space(layer: LayerNodeIdentifier, network_inter 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) @@ -768,6 +773,7 @@ pub struct FillNodeGradient { pub gradient_form: GradientForm, pub gradient_spread: GradientSpread, pub gradient_space: GradientSpace, + pub gradient_cyclic: bool, pub gradient_hue_direction: GradientHueDirection, pub transform: DAffine2, /// Whether the transform input holds a plain value (so it may be written to) rather than a wire. @@ -783,6 +789,7 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn }; 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 stops = Gradient::from(ramp); let gradient_form = match fill_node.input(fill::GradientFormInput).and_then(|input| input.as_value()) { @@ -802,6 +809,7 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn gradient_form, gradient_spread, gradient_space, + gradient_cyclic, gradient_hue_direction, transform, transform_is_value: transform_input.is_some(), @@ -953,6 +961,7 @@ pub fn set_fill_for_selected_layers(fill_choice: FillChoice, document: &Document gradient_form, gradient_spread: ramp.gradient_spread, gradient_space: ramp.gradient_space, + gradient_cyclic: ramp.gradient_cyclic, gradient_hue_direction: ramp.gradient_hue_direction, transform, }); diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index e91ea0e919..7ce3d8b365 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -9,8 +9,8 @@ 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_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_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, }; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; use glam::DMat2; @@ -31,6 +31,7 @@ pub struct GradientOptions { gradient_form: GradientForm, gradient_spread: GradientSpread, gradient_space: GradientSpace, + gradient_cyclic: bool, gradient_hue_direction: GradientHueDirection, } @@ -98,7 +99,13 @@ impl<'a> MessageHandler> for Grad responses.add(ToolMessage::UpdateCursor); } GradientOptionsUpdate::ReverseStops => { - apply_gradient_update(&mut self.data, context, responses, |_| true, |(gradient, _appearance)| *gradient = gradient.reversed()); + apply_gradient_update( + &mut self.data, + context, + responses, + |_| true, + |(gradient, appearance)| *gradient = gradient.reversed(appearance.gradient_cyclic), + ); } GradientOptionsUpdate::ReverseDirection => apply_gradient_update( &mut self.data, @@ -141,6 +148,7 @@ impl<'a> MessageHandler> for Grad 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, @@ -149,6 +157,7 @@ impl<'a> MessageHandler> for Grad Gradient::from(&ramp), ramp.gradient_spread, ramp.gradient_space, + ramp.gradient_cyclic, ramp.gradient_hue_direction, ); } @@ -192,6 +201,10 @@ impl<'a> MessageHandler> for Grad 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; needs_refresh = true; @@ -277,6 +290,7 @@ 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) })) @@ -379,6 +393,7 @@ fn resolve_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkI 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, transform: gradient.transform, }, @@ -400,6 +415,7 @@ struct GradientAppearance { gradient_form: GradientForm, gradient_spread: GradientSpread, gradient_space: GradientSpace, + gradient_cyclic: bool, gradient_hue_direction: GradientHueDirection, } @@ -442,6 +458,7 @@ fn read_gradient_chain_state(layer: LayerNodeIdentifier, network_interface: &Nod 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(), } } @@ -479,6 +496,70 @@ fn midpoint_hidden_by_proximity(left_stop_pos: f64, right_stop_pos: f64, viewpor (right_stop_pos - left_stop_pos) * viewport_line_length < GRADIENT_STOP_MIN_VIEWPORT_GAP * 2. } +/// A cyclic gradient's wrapped interval as `(start, end)` along the gradient line, where `end` runs past 1 by however far +/// the interval continues beyond the boundary to reach the first stop. +fn wrapped_interval_span(gradient: &Gradient) -> (f64, f64) { + (gradient.position(gradient.len() - 1, true), gradient.position(0, true) + 1.) +} + +/// 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)> { + let mut diamonds = Vec::with_capacity(gradient.len()); + + for index in 0..gradient.len().saturating_sub(1) { + let left = gradient.position(index, gradient_cyclic); + let right = gradient.position(index + 1, gradient_cyclic); + if midpoint_hidden_by_proximity(left, right, viewport_line_length) { + continue; + } + + diamonds.push((index, left + gradient.midpoint(index) * (right - left))); + } + + if gradient_cyclic && gradient.len() >= 2 { + let last = gradient.len() - 1; + let (left, right) = wrapped_interval_span(gradient); + if !midpoint_hidden_by_proximity(left, right, viewport_line_length) { + diamonds.push((last, (left + gradient.midpoint(last) * (right - left)).rem_euclid(1.))); + } + } + + diamonds +} + +/// Maps a pointer's unclamped position along the gradient line to a midpoint ratio within the interval owned by the +/// stop at `index`, or `None` when that interval has no width. The wrapped interval continues past the line's end, +/// so overdragging keeps tracking with the line's length as an offset. +fn midpoint_ratio_at(gradient: &Gradient, index: usize, gradient_cyclic: bool, position_along_line: f64) -> Option { + let is_wrapped_interval = gradient_cyclic && index + 1 == gradient.len(); + let (left, right) = if is_wrapped_interval { + wrapped_interval_span(gradient) + } else { + (gradient.position(index, gradient_cyclic), gradient.position(index + 1, gradient_cyclic)) + }; + + let span = right - left; + if span <= 0. { + return None; + } + + let first = gradient.position(0, gradient_cyclic); + let dead_zone_split = match (is_wrapped_interval, left >= 1., first > 0.) { + (true, true, _) => f64::INFINITY, + (true, false, true) => (first + left) / 2., + _ => f64::NEG_INFINITY, + }; + + let local = if position_along_line < dead_zone_split { + position_along_line + 1. - left + } else { + position_along_line - left + }; + + Some((local / span).clamp(GRADIENT_MIDPOINT_MIN, GRADIENT_MIDPOINT_MAX)) +} + #[derive(PartialEq, Eq, Clone, Copy, Debug, Default)] pub enum GradientDragTarget { Start, @@ -504,13 +585,13 @@ struct SelectedGradient { is_gradient_chain: bool, } -fn calculate_insertion(start: DVec2, end: DVec2, stops: &Gradient, mouse: DVec2) -> Option { +fn calculate_insertion(start: DVec2, end: DVec2, stops: &Gradient, gradient_cyclic: bool, mouse: DVec2) -> Option { 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); if distance.abs() < SEGMENT_INSERTION_DISTANCE && (0. ..=1.).contains(&projection) { - for stop in stops { - let stop_pos = start.lerp(end, stop.position); + for i in 0..stops.len() { + let stop_pos = start.lerp(end, stops.position(i, gradient_cyclic)); if stop_pos.distance_squared(mouse) < (MANIPULATOR_GROUP_MARKER_SIZE * 2.).powi(2) { return None; } @@ -521,16 +602,8 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &Gradient, mouse: DVec2) // Don't insert when clicking near a (currently visible) midpoint diamond let line_length = start.distance(end); - for i in 0..stops.len().saturating_sub(1) { - let left = stops.position(i); - let right = stops.position(i + 1); - - if midpoint_hidden_by_proximity(left, right, line_length) { - continue; - } - - let midpoint_pos = left + stops.midpoint(i) * (right - left); - let midpoint_viewport = start.lerp(end, midpoint_pos); + for (_, midpoint_position) in midpoint_diamonds(stops, gradient_cyclic, line_length) { + let midpoint_viewport = start.lerp(end, midpoint_position); if midpoint_viewport.distance_squared(mouse) < GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2) { return None; } @@ -697,18 +770,19 @@ impl SelectedGradient { let min_gap = GRADIENT_STOP_MIN_VIEWPORT_GAP / line_length; let last_index = self.gradient.len() - 1; - let has_other_stop_at_zero = stop != 0 && !self.gradient.is_empty() && self.gradient.position(0).abs() < f64::EPSILON * 1000.; - let has_other_stop_at_one = stop != last_index && !self.gradient.is_empty() && (1. - self.gradient.position(last_index)).abs() < f64::EPSILON * 1000.; + let gradient_cyclic = self.appearance.gradient_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.; let left_bound = if has_other_stop_at_zero { min_gap } else { 0. }; let right_bound = if has_other_stop_at_one { 1. - min_gap } else { 1. }; let clamped = new_pos.clamp(left_bound, right_bound); - self.gradient.set_position(stop, clamped); + self.gradient.set_position(stop, clamped, gradient_cyclic); let new_position = clamped; let new_color = self.gradient.color(stop).unwrap_or(Color::BLACK); - self.gradient.sort(); + self.gradient.sort(gradient_cyclic); if let Some(new_index) = self.gradient.iter().position(|s| s.position == new_position && s.color == new_color) { self.dragging = GradientDragTarget::Stop(new_index); } @@ -755,12 +829,8 @@ impl SelectedGradient { return; } - // Convert to a midpoint ratio within the interval between the two surrounding stops - let left_stop = self.gradient.position(midpoint_index); - let right_stop = self.gradient.position(midpoint_index + 1); - let range = right_stop - left_stop; - if range > 0. { - let midpoint_ratio = ((full_pos - left_stop) / range).clamp(GRADIENT_MIDPOINT_MIN, GRADIENT_MIDPOINT_MAX); + // 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) { self.gradient.set_midpoint(midpoint_index, midpoint_ratio); } } @@ -780,6 +850,7 @@ impl SelectedGradient { 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, transform: self.appearance.transform, }); @@ -801,7 +872,7 @@ fn dispatch_gradient_chain_writes(layer: LayerNodeIdentifier, gradient: &Gradien responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: gradient.clone() }); responses.add(GraphOperationMessage::GradientPositionsSet { layer, - positions: gradient.nondefault_positions().unwrap_or_default(), + positions: gradient.nondefault_positions(appearance.gradient_cyclic).unwrap_or_default(), }); responses.add(GraphOperationMessage::GradientMidpointsSet { layer, @@ -823,6 +894,10 @@ fn dispatch_gradient_chain_writes(layer: LayerNodeIdentifier, gradient: &Gradien layer, gradient_space: appearance.gradient_space, }); + responses.add(GraphOperationMessage::GradientCyclicSet { + layer, + gradient_cyclic: appearance.gradient_cyclic, + }); responses.add(GraphOperationMessage::GradientHueDirectionSet { layer, gradient_hue_direction: appearance.gradient_hue_direction, @@ -930,8 +1005,9 @@ 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 first_at_start = !gradient.is_empty() && gradient.position(0).abs() < f64::EPSILON * 1000.; - let last_at_end = !gradient.is_empty() && (1. - gradient.position(gradient.len() - 1)).abs() < f64::EPSILON * 1000.; + let gradient_cyclic = appearance.gradient_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.; overlay_context.line(start, end, None, None); @@ -956,11 +1032,12 @@ impl Fsm for GradientToolFsmState { }; check(start.distance_squared(mouse), StopId::Start); check(end.distance_squared(mouse), StopId::End); - for (index, stop) in gradient.iter().enumerate() { - if stop.position.abs() < f64::EPSILON * 1000. || (1. - stop.position).abs() < f64::EPSILON * 1000. { + for index in 0..gradient.len() { + let position = gradient.position(index, gradient_cyclic); + if position.abs() < f64::EPSILON * 1000. || (1. - position).abs() < f64::EPSILON * 1000. { continue; } - check(start.lerp(end, stop.position).distance_squared(mouse), StopId::Middle(index)); + check(start.lerp(end, position).distance_squared(mouse), StopId::Middle(index)); } best.map(|(_, id)| id) } else { @@ -982,8 +1059,8 @@ impl Fsm for GradientToolFsmState { StopId::Start => overlay_context.gradient_color_stop(start, emphasis, &start_hex, !first_at_start), StopId::End => overlay_context.gradient_color_stop(end, emphasis, &end_hex, !last_at_end), StopId::Middle(i) => { - if let Some(stop) = gradient.iter().nth(i) { - overlay_context.gradient_color_stop(start.lerp(end, stop.position), emphasis, &color_to_hex(stop.color), false); + if let Some(color) = gradient.color(i) { + overlay_context.gradient_color_stop(start.lerp(end, gradient.position(i, gradient_cyclic)), emphasis, &color_to_hex(color), false); } } }; @@ -995,8 +1072,9 @@ impl Fsm for GradientToolFsmState { if !is_deferred(StopId::End) { draw_stop(StopId::End, emphasis_for(StopId::End)); } - for (index, stop) in gradient.iter().enumerate() { - if stop.position.abs() < f64::EPSILON * 1000. || (1. - stop.position).abs() < f64::EPSILON * 1000. { + for index in 0..gradient.len() { + let position = gradient.position(index, gradient_cyclic); + if position.abs() < f64::EPSILON * 1000. || (1. - position).abs() < f64::EPSILON * 1000. { continue; } let id = StopId::Middle(index); @@ -1022,18 +1100,10 @@ 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 i in 0..gradient.len().saturating_sub(1) { - let left = gradient.position(i); - let right = gradient.position(i + 1); + for (index, midpoint_position) in midpoint_diamonds(gradient, gradient_cyclic, line_length) { + let midpoint_viewport = start.lerp(end, midpoint_position); - if midpoint_hidden_by_proximity(left, right, line_length) { - continue; - } - - let midpoint_pos = left + gradient.midpoint(i) * (right - left); - let midpoint_viewport = start.lerp(end, midpoint_pos); - - let emphasis = if dragging == Some(GradientDragTarget::Midpoint(i)) { + let emphasis = if dragging == Some(GradientDragTarget::Midpoint(index)) { GizmoEmphasis::Active } else if !matches!(self, GradientToolFsmState::Drawing { .. }) && midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { GizmoEmphasis::Hovered @@ -1044,7 +1114,7 @@ impl Fsm for GradientToolFsmState { } if !matches!(self, GradientToolFsmState::Drawing { .. }) - && calculate_insertion(start, end, gradient, mouse).is_some() + && calculate_insertion(start, end, gradient, gradient_cyclic, mouse).is_some() && let Some(dir) = (end - start).try_normalize() { let perp = dir.perp(); @@ -1093,7 +1163,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); + let position = gradient.position(stop_index, selected_gradient.appearance.gradient_cyclic); let start = transform.transform_point2(DVec2::ZERO); let end = transform.transform_point2(DVec2::X); let position = start.lerp(end, position).into(); @@ -1133,10 +1203,11 @@ 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 stop_index = match selected_gradient.dragging { GradientDragTarget::Stop(i) => Some(i), - GradientDragTarget::Start => (0..gradient.len()).position(|i| gradient.position(i).abs() < f64::EPSILON * 1000.), - GradientDragTarget::End => (0..gradient.len()).position(|i| (1. - gradient.position(i)).abs() < f64::EPSILON * 1000.), + GradientDragTarget::Start => (0..gradient.len()).position(|i| gradient.position(i, gradient_cyclic).abs() < f64::EPSILON * 1000.), + GradientDragTarget::End => (0..gradient.len()).position(|i| (1. - gradient.position(i, gradient_cyclic)).abs() < f64::EPSILON * 1000.), _ => None, }; if let Some(stop_index) = stop_index @@ -1148,7 +1219,7 @@ impl Fsm for GradientToolFsmState { tool_data.color_picker_transaction_open = false; } - let stop_pos = selected_gradient.gradient.position(stop_index); + let stop_pos = selected_gradient.gradient.position(stop_index, selected_gradient.appearance.gradient_cyclic); let (start, end) = selected_gradient.viewport_handle_positions(); let viewport_pos = start.lerp(end, stop_pos); let position = viewport_pos.into(); @@ -1191,7 +1262,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).abs() < f64::EPSILON * 1000. { + if !selected_gradient.gradient.is_empty() && selected_gradient.gradient.position(0, selected_gradient.appearance.gradient_cyclic).abs() < f64::EPSILON * 1000. { selected_gradient.gradient.remove(0); } else { responses.add(DocumentMessage::AbortTransaction); @@ -1200,7 +1271,9 @@ 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)).abs() < f64::EPSILON * 1000. { + 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. + { let _ = selected_gradient.gradient.pop(); } else { responses.add(DocumentMessage::AbortTransaction); @@ -1241,7 +1314,7 @@ impl Fsm for GradientToolFsmState { } // Find the minimum and maximum positions - let positions = selected_gradient.gradient.positions(); + let positions = selected_gradient.gradient.positions(selected_gradient.appearance.gradient_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"); @@ -1277,7 +1350,14 @@ 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_space, appearance.gradient_hue_direction) { + if let Some(index) = insert_stop_at_point( + &mut gradient, + mouse, + unit_to_viewport, + appearance.gradient_cyclic, + appearance.gradient_space, + appearance.gradient_hue_direction, + ) { responses.add(DocumentMessage::StartTransaction); let mut selected_gradient = SelectedGradient::new(gradient, appearance, source, layer, document); @@ -1330,19 +1410,11 @@ 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 i in 0..gradient.len().saturating_sub(1) { - let left = gradient.position(i); - let right = gradient.position(i + 1); - - if midpoint_hidden_by_proximity(left, right, line_length) { - continue; - } - - let midpoint_pos = left + gradient.midpoint(i) * (right - left); - let midpoint_viewport = start.lerp(end, midpoint_pos); + for (index, midpoint_position) in midpoint_diamonds(&gradient, appearance.gradient_cyclic, line_length) { + let midpoint_viewport = start.lerp(end, midpoint_position); if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { - let resettable = midpoint_is_resettable(gradient.midpoint(i)); + let resettable = midpoint_is_resettable(gradient.midpoint(index)); drag_hint = Some(GradientDragHintState::Midpoint { resettable }); tool_data.selected_gradient = Some(SelectedGradient { @@ -1351,7 +1423,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), appearance, initial_gradient_transform: appearance.transform, - dragging: GradientDragTarget::Midpoint(i), + dragging: GradientDragTarget::Midpoint(index), initial_gradient: gradient.clone(), is_gradient_chain, }); @@ -1364,15 +1436,15 @@ impl Fsm for GradientToolFsmState { // Check for dragging the closest stop to the mouse pointer if drag_hint.is_none() { let mut best: Option<(f64, usize)> = None; - for (index, stop) in gradient.iter().enumerate() { - let pos = start.lerp(end, stop.position); + for index in 0..gradient.len() { + let pos = start.lerp(end, gradient.position(index, appearance.gradient_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); + let stop_position = gradient.position(index, appearance.gradient_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. { @@ -1427,7 +1499,14 @@ 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_space, appearance.gradient_hue_direction) { + 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, + ) { responses.add(DocumentMessage::StartTransaction); transaction_started = true; @@ -1507,6 +1586,7 @@ impl Fsm for GradientToolFsmState { 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, }, // 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 @@ -1605,8 +1685,10 @@ 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).abs() >= f64::EPSILON * 1000., - GradientDragTarget::End => selected.gradient.is_empty() || (1. - selected.gradient.position(selected.gradient.len() - 1)).abs() >= f64::EPSILON * 1000., + GradientDragTarget::Start => selected.gradient.is_empty() || selected.gradient.position(0, selected.appearance.gradient_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. + } _ => false, }) { tool_data.selected_gradient = None; @@ -1739,10 +1821,17 @@ impl Fsm for GradientToolFsmState { } } -fn insert_stop_at_point(gradient: &mut Gradient, point: DVec2, unit_to_viewport: DAffine2, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> Option { +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 { 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_space, gradient_hue_direction)) + (0. ..=1.).contains(&t).then(|| gradient.insert_stop(t, gradient_cyclic, gradient_space, gradient_hue_direction)) } fn dismiss_color_stop_color_picker(tool_data: &mut GradientToolData, responses: &mut VecDeque) { @@ -1769,18 +1858,11 @@ 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 i in 0..gradient.len().saturating_sub(1) { - let left = gradient.position(i); - let right = gradient.position(i + 1); - if midpoint_hidden_by_proximity(left, right, line_length) { - continue; - } - - let midpoint_position = left + gradient.midpoint(i) * (right - left); + for (index, midpoint_position) in midpoint_diamonds(&gradient, appearance.gradient_cyclic, line_length) { let midpoint_viewport = start.lerp(end, midpoint_position); if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { - let resettable = midpoint_is_resettable(gradient.midpoint(i)); + let resettable = midpoint_is_resettable(gradient.midpoint(index)); return GradientHoverTarget::Midpoint { resettable }; } } @@ -1805,7 +1887,7 @@ fn detect_hover_target(mouse: DVec2, document: &DocumentMessageHandler) -> Gradi } // Check insertion point on line - if calculate_insertion(start, end, &gradient, mouse).is_some() { + if calculate_insertion(start, end, &gradient, appearance.gradient_cyclic, mouse).is_some() { return GradientHoverTarget::InsertionPoint; } } @@ -1867,6 +1949,7 @@ fn apply_gradient_update( 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, transform: appearance.transform, }); @@ -1891,6 +1974,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, @@ -1898,6 +1982,7 @@ fn apply_stops_update( new_gradient: Gradient, gradient_spread: GradientSpread, gradient_space: GradientSpace, + gradient_cyclic: bool, gradient_hue_direction: GradientHueDirection, ) { let selected_layers: Vec<_> = context @@ -1917,6 +2002,7 @@ fn apply_stops_update( 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 }); updated_any_layer = true; } else if let Some((_gradient, appearance, _source)) = resolve_gradient(layer, &context.document.network_interface) { @@ -1926,6 +2012,7 @@ fn apply_stops_update( gradient_form: appearance.gradient_form, gradient_spread, gradient_space, + gradient_cyclic, gradient_hue_direction, transform: appearance.transform, }); @@ -1937,6 +2024,7 @@ fn apply_stops_update( 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; } @@ -2021,6 +2109,8 @@ enum GradientDragHintState { #[cfg(test)] mod test_gradient { + use super::{gradient_to_viewport_transform, midpoint_diamonds, midpoint_ratio_at}; + use crate::consts::{GRADIENT_MIDPOINT_MAX, GRADIENT_MIDPOINT_MIN}; use crate::messages::input_mapper::utility_types::input_mouse::EditorMouseState; use crate::messages::input_mapper::utility_types::input_mouse::ScrollDelta; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; @@ -2037,7 +2127,56 @@ mod test_gradient { use graphene_std::vector::style::{GradientForm, GradientSpread, build_transform_with_y_preservation}; use graphene_std::vector::{Gradient, GradientRamp, GradientStop, fill}; - use super::gradient_to_viewport_transform; + /// A line long enough that no interval in these tests trips the closely-packed-stops hiding rule. + const UNCROWDED_LINE_LENGTH: f64 = 10_000.; + + #[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)]); + + // 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); + 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)]); + } + + #[test] + fn wrap_midpoint_drag_tracks_past_the_ends_and_saturates_across_the_dead_zone() { + let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); + gradient.set_positions(&[0.25, 0.5]); + + // Within the interval the ratio maps linearly, and overdragging past the line's end keeps tracking + assert_eq!(midpoint_ratio_at(&gradient, 1, true, 0.875), Some(0.5)); + assert_eq!(midpoint_ratio_at(&gradient, 1, true, 1.0625), Some(0.75)); + + // The dead zone between the outermost stops splits at its midpoint (0.375), each half saturating against the nearer end + assert_eq!(midpoint_ratio_at(&gradient, 1, true, 0.45), Some(GRADIENT_MIDPOINT_MIN)); + assert_eq!(midpoint_ratio_at(&gradient, 1, true, 0.3), Some(GRADIENT_MIDPOINT_MAX)); + + // A first stop at 0 leaves no room before the boundary, so the one-sided interval saturates like any other + let mut one_sided = Gradient::from(vec![Color::BLACK, Color::WHITE]); + one_sided.set_positions(&[0., 0.5]); + assert_eq!(midpoint_ratio_at(&one_sided, 1, true, 0.4), Some(GRADIENT_MIDPOINT_MIN)); + assert_eq!(midpoint_ratio_at(&one_sided, 1, true, 0.75), Some(0.5)); + + // Ordinary intervals never wrap, and a zero-width interval has no ratio to give + assert_eq!(midpoint_ratio_at(&gradient, 0, true, 0.375), Some(0.5)); + let mut degenerate = Gradient::from(vec![Color::BLACK, Color::WHITE]); + degenerate.set_positions(&[0.5, 0.5]); + assert_eq!(midpoint_ratio_at(°enerate, 0, false, 0.5), None); + } struct ResolvedGradient { stops: Gradient, @@ -2199,7 +2338,11 @@ mod test_gradient { let Some(TaggedValue::GradientRamp(ramp)) = stops else { panic!("expected a gradient default, got {stops:?}") }; - assert_eq!(Gradient::from(ramp).positions(), vec![0., 1.], "the parameter default should be the black-to-white starting gradient"); + assert_eq!( + Gradient::from(ramp).positions(false), + vec![0., 1.], + "the parameter default should be the black-to-white starting gradient" + ); } async fn create_fill_gradient_chain_layer(editor: &mut EditorTestUtils) -> LayerNodeIdentifier { @@ -2474,7 +2617,7 @@ mod test_gradient { // Verify initial stop positions and colors let mut stops = initial_gradient.stops.clone(); - stops.sort(); + stops.sort(false); let positions: Vec = stops.iter().map(|stop| stop.position).collect(); assert_stops_at_positions(&positions, &[0., 0.25, 1.], 0.1); @@ -2513,7 +2656,7 @@ mod test_gradient { // Verify updated stop positions and colors let mut updated_stops = updated_gradient.stops.clone(); - updated_stops.sort(); + updated_stops.sort(false); // Check positions are now correctly ordered let updated_positions: Vec = updated_stops.iter().map(|stop| stop.position).collect(); @@ -2905,7 +3048,7 @@ mod test_gradient { let updated = ResolvedGradient::new(updated, appearance); assert_eq!(updated.stops.len(), 3, "Stop count should be preserved"); - assert_stops_at_positions(&updated.stops.positions(), &[0., 0.5, 1.], 1e-10); + assert_stops_at_positions(&updated.stops.positions(false), &[0., 0.5, 1.], 1e-10); assert_eq!(SRGBA8::from(updated.stops.color(0).unwrap()), SRGBA8::from(Color::RED), "First stop color should be preserved"); assert_eq!(SRGBA8::from(updated.stops.color(1).unwrap()), SRGBA8::from(Color::GREEN), "Middle stop color should be preserved"); assert_eq!(SRGBA8::from(updated.stops.color(2).unwrap()), SRGBA8::from(Color::BLUE), "Last stop color should be preserved"); @@ -3109,14 +3252,14 @@ mod test_gradient { editor.handle_message(GraphOperationMessage::GradientMidpointsSet { layer, midpoints: vec![0.7, 0.5] }).await; let document = editor.active_document(); let stops = get_gradient_stops(layer, &document.network_interface).expect("the chain should resolve stops"); - assert_stops_at_positions(&stops.positions(), &[0., 0.25], 1e-10); + assert_stops_at_positions(&stops.positions(false), &[0., 0.25], 1e-10); assert_eq!(stops.midpoints(), vec![0.7, 0.5]); // An empty update restores the default placement, overriding the value's own explicit placement at runtime editor.handle_message(GraphOperationMessage::GradientPositionsSet { layer, positions: vec![] }).await; let document = editor.active_document(); let stops = get_gradient_stops(layer, &document.network_interface).expect("the chain should resolve stops"); - assert_stops_at_positions(&stops.positions(), &[0., 1.], 1e-10); + assert_stops_at_positions(&stops.positions(false), &[0., 1.], 1e-10); assert!(!stops.has_position_attribute(), "the empty setter value should clear the attribute"); // A wired setter input is procedural authorship, which the update must leave untouched rather than bake over diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 0f31e5e782..d2ad4055ec 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -13,6 +13,7 @@ export let trackCSS: string; export let trackStartCSS: string; export let trackEndCSS: string; + export let trackCyclic = false; export let markers: SpectrumMarker[]; export let activeMarkerIndex: number | undefined = 0; export let activeMarkerIsMidpoint = false; @@ -54,11 +55,11 @@ emit({ ActiveMarker: { activeMarkerIndex: index, activeMarkerIsMidpoint: isMidpoint } }); } - function pointerPosition(e: MouseEvent): number | undefined { + function pointerPosition(e: MouseEvent, clamp = true): number | undefined { const rect = markerTrackElement?.div()?.getBoundingClientRect(); if (!rect) return undefined; const ratio = (e.clientX - rect.left) / rect.width; - return Math.max(0, Math.min(1, ratio)); + return clamp ? Math.max(0, Math.min(1, ratio)) : ratio; } function clampToNeighbors(index: number, position: number): number { @@ -237,18 +238,32 @@ return; } - const absolute = pointerPosition(e); + // The wrapped interval's diamond (cyclic only) belongs to the last marker and spans through the 1|0 boundary to the first. + // Its pointer ratio stays unclamped so overdragging past the strip's right or left edge keeps tracking after the 1|0 wrap. + const isWrappedInterval = trackCyclic && activeMarkerIndex === markers.length - 1; + const absolute = pointerPosition(e, !isWrappedInterval); if (absolute === undefined) return; const left = markers[activeMarkerIndex]?.position; - const right = markers[activeMarkerIndex + 1]?.position; + const right = isWrappedInterval ? markers[0].position + 1 : markers[activeMarkerIndex + 1]?.position; if (left === undefined || right === undefined) return; const range = right - left; if (range <= 0) return; + // The dead zone between the first and last stops splits so each half saturates against the wrapped interval's nearer end, + // except when an outermost stop leaves no room on its side of the boundary, where the one-sided interval skips the split + // and saturates like any other + let deadZoneSplit = Number.NEGATIVE_INFINITY; + if (isWrappedInterval) { + const first = markers[0].position; + if (left >= 1) deadZoneSplit = Number.POSITIVE_INFINITY; + else if (first > 0) deadZoneSplit = (first + left) / 2; + } + const local = absolute < deadZoneSplit ? absolute + 1 - left : absolute - left; + midpointDragged = true; dispatch("dragging", true); - emit({ MoveMidpoint: { index: activeMarkerIndex, position: (absolute - left) / range } }); + emit({ MoveMidpoint: { index: activeMarkerIndex, position: local / range } }); } function abortDrag() { @@ -347,7 +362,22 @@ } // Map midpoint pairs to absolute track positions for rendering the diamond markers. - $: midpointPositions = !showMidpoints || markers.length < 2 ? [] : markers.slice(0, -1).map((marker, i) => marker.position + marker.midpoint * (markers[i + 1].position - marker.position)); + // 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 []; + 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 + if (trackCyclic) { + const first = markers[0]; + const last = markers[markers.length - 1]; + const wrapLength = first.position + 1 - last.position; + if (wrapLength > 1e-9) positions.push((last.position + last.midpoint * wrapLength) % 1); + } + + return positions; + } + $: midpointPositions = diamondPositions(markers, showMidpoints, trackCyclic); 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 9b5512c27d..9840bd9339 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -1151,7 +1151,7 @@ mod gradient_shape_migration { assert_eq!(ramp.gradient_space, GradientSpace::RgbGamma, "the pre-ramp flat form should carry the era's gamma"); let gradient = Gradient::from(ramp); - assert_eq!(gradient.positions(), vec![0., 0.25]); + assert_eq!(gradient.positions(false), vec![0., 0.25]); assert!(gradient.has_midpoint_attribute(), "the flat form must parse faithfully"); } @@ -1165,7 +1165,7 @@ mod gradient_shape_migration { assert_eq!(ramp.gradient_space, GradientSpace::RgbGamma, "the pre-ramp tuple form should carry the era's gamma"); let gradient = Gradient::from(ramp); - assert_eq!(gradient.positions(), vec![0., 1.]); + assert_eq!(gradient.positions(false), vec![0., 1.]); assert!(!gradient.has_position_attribute(), "even legacy tuple positions should elide"); } @@ -1187,6 +1187,10 @@ mod gradient_shape_migration { let TaggedValue::LegacyGradient(legacy) = load(json) else { panic!("the ancient full struct should become a legacy gradient value") }; - assert_eq!(Gradient::from(legacy.stops).positions(), vec![0., 1.], "the nested tuple stops should parse through the field adapter"); + assert_eq!( + Gradient::from(legacy.stops).positions(false), + vec![0., 1.], + "the nested tuple stops should parse through the field adapter" + ); } } diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index dcfb540645..e62910141b 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -174,7 +174,7 @@ pub mod migrations { let position: Vec = stops.iter().map(|(position, _)| *position).collect(); let mut gradient = Gradient::from(stops.into_iter().map(|(_, color)| color).collect::>()); gradient.set_positions(&position); - gradient.elide_default_attributes(); + gradient.elide_default_attributes(false); GradientRamp { gradient_space: GradientSpace::RgbGamma, diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 2830e9c10c..608aa9496c 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -9,7 +9,7 @@ use glam::{DAffine2, DVec2}; use graphic_types::Graphic; use graphic_types::vector_types::gradient::GradientForm; use graphic_types::vector_types::markers::{ - GradientForm as GradientFormAttr, GradientHueDirection as GradientHueDirectionAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr, + GradientCyclic as GradientCyclicAttr, GradientForm as GradientFormAttr, GradientHueDirection as GradientHueDirectionAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr, }; use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; @@ -115,8 +115,17 @@ pub fn render_gradient_paint let gradient_spread: GradientSpread = source.attr::(0); let gradient_space: GradientSpace = source.attr::(0); let gradient_hue_direction: GradientHueDirection = source.attr::(0); + let gradient_cyclic: bool = source.attr::(0); - let (samples, _) = spread_adjusted_samples(stops, gradient_spread, gradient_form, gradient_space, gradient_hue_direction, ClearGuardPlacement::SvgStopOrder); + let (samples, _) = spread_adjusted_samples( + stops, + gradient_spread, + gradient_form, + gradient_cyclic, + gradient_space, + gradient_hue_direction, + ClearGuardPlacement::SvgStopOrder, + ); for (position, color, original_midpoint) in samples { stop.push_str(" (GradientSamples, (f64, f64)) { - let samples = gradient.interpolated_samples(gradient_space, gradient_hue_direction); + let samples = gradient.interpolated_samples(gradient_cyclic, gradient_space, gradient_hue_direction); if gradient_spread != GradientSpread::Clear { return (samples, (0., 1.)); } @@ -519,8 +520,17 @@ fn create_peniko_gradient_brush>(gradient_list let gradient_spread: GradientSpread = gradient_list.attr::(0); let gradient_space: GradientSpace = gradient_list.attr::(0); let gradient_hue_direction: GradientHueDirection = gradient_list.attr::(0); + let gradient_cyclic: bool = gradient_list.attr::(0); - let (samples, span) = spread_adjusted_samples(stops, gradient_spread, gradient_form, gradient_space, gradient_hue_direction, ClearGuardPlacement::VelloRampTexels); + let (samples, span) = spread_adjusted_samples( + stops, + gradient_spread, + gradient_form, + gradient_cyclic, + gradient_space, + gradient_hue_direction, + ClearGuardPlacement::VelloRampTexels, + ); let peniko_stops = peniko_color_stops(&samples); @@ -2437,6 +2447,7 @@ fn render_gradient_svg>(source: &S, render: &m let gradient_form: GradientForm = source.attr::(index); let gradient_space: GradientSpace = source.attr::(index); let gradient_hue_direction: GradientHueDirection = source.attr::(index); + let gradient_cyclic: bool = source.attr::(index); let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; render.leaf_tag(tag, |attributes| { if let Some((min, size)) = thumbnail_rect { @@ -2452,7 +2463,15 @@ fn render_gradient_svg>(source: &S, render: &m attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); } - let (samples, _) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, gradient_space, gradient_hue_direction, ClearGuardPlacement::SvgStopOrder); + let (samples, _) = spread_adjusted_samples( + gradient, + gradient_spread, + gradient_form, + gradient_cyclic, + gradient_space, + gradient_hue_direction, + ClearGuardPlacement::SvgStopOrder, + ); let mut stop_string = String::new(); for (position, color, original_midpoint) in samples { @@ -2525,6 +2544,7 @@ fn render_gradient_vello>(source: &S, scene: & let gradient_form: GradientForm = source.attr::(index); let gradient_space: GradientSpace = source.attr::(index); let gradient_hue_direction: GradientHueDirection = source.attr::(index); + let gradient_cyclic: bool = source.attr::(index); let transform: DAffine2 = source.attr::(index); let blend_mode_attr: BlendMode = source.attr::(index); let opacity_attr: f64 = source.attr::(index); @@ -2534,7 +2554,15 @@ fn render_gradient_vello>(source: &S, scene: & let blend_mode = blend_mode_attr.to_peniko(); let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let (samples, span) = spread_adjusted_samples(gradient, gradient_spread, gradient_form, gradient_space, gradient_hue_direction, ClearGuardPlacement::VelloRampTexels); + let (samples, span) = spread_adjusted_samples( + gradient, + gradient_spread, + gradient_form, + gradient_cyclic, + gradient_space, + gradient_hue_direction, + ClearGuardPlacement::VelloRampTexels, + ); let stops = peniko_color_stops(&samples); let extend = peniko_extend(gradient_spread); @@ -3349,18 +3377,20 @@ mod spread_tests { &gradient, GradientSpread::Repeat, GradientForm::Linear, + false, GradientSpace::RgbGamma, Default::default(), ClearGuardPlacement::SvgStopOrder, ); assert_eq!(span, (0., 1.)); - assert_eq!(samples, gradient.interpolated_samples(GradientSpace::RgbGamma, Default::default())); + assert_eq!(samples, gradient.interpolated_samples(false, GradientSpace::RgbGamma, Default::default())); // SVG guards share the range ends' exact offsets, ordered so the pad extension resolves to the transparent outer stops let (samples, span) = spread_adjusted_samples( &gradient, GradientSpread::Clear, GradientForm::Linear, + false, GradientSpace::RgbGamma, Default::default(), ClearGuardPlacement::SvgStopOrder, @@ -3377,6 +3407,7 @@ mod spread_tests { &gradient, GradientSpread::Clear, GradientForm::Linear, + false, GradientSpace::RgbGamma, Default::default(), ClearGuardPlacement::VelloRampTexels, @@ -3397,6 +3428,7 @@ mod spread_tests { &gradient, GradientSpread::Clear, GradientForm::Radial, + false, GradientSpace::RgbGamma, Default::default(), ClearGuardPlacement::VelloRampTexels, @@ -3412,6 +3444,7 @@ mod spread_tests { &Gradient::from(Vec::new()), GradientSpread::Clear, GradientForm::Linear, + false, GradientSpace::RgbGamma, Default::default(), ClearGuardPlacement::SvgStopOrder, diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 9659dda00c..d7f20312a2 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -1,4 +1,4 @@ -use crate::markers::{ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD}; +use crate::markers::{ATTR_GRADIENT_CYCLIC, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD}; use core_types::Color; use core_types::color::SRGBA8; use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List}; @@ -77,7 +77,7 @@ impl From> for Gradient { } } -// Color picker round-trip: attributes that merely restate the defaults are elided to keep the canonical absence-as-default form +// Color picker round-trip: faithful, since eliding default-restating attributes needs the cyclic flag that only the ramp conversion holds impl From<&GradientStops> for Gradient { fn from(stops: &GradientStops) -> Self { let mut gradient = Gradient::from(stops.color.iter().map(|&color| Color::from(color)).collect::>()); @@ -87,15 +87,14 @@ impl From<&GradientStops> for Gradient { if let Some(midpoint) = &stops.midpoint { gradient.set_midpoints(midpoint); } - gradient.elide_default_attributes(); 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_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> String { - Gradient::from(self).to_css_linear_gradient(gradient_space, gradient_hue_direction) + 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) } } @@ -113,6 +112,9 @@ pub struct GradientRamp { // TODO: Elide the default again (removing `legacy_gamma` and the serde aliases) when switching to the new document format and Ctrl-C node serialization format #[cfg_attr(feature = "serde", serde(default = "GradientSpace::legacy_gamma", alias = "gradient_interpolation"))] pub gradient_space: GradientSpace, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "std::ops::Not::not"))] + #[cfg_attr(feature = "wasm", tsify(optional))] + pub gradient_cyclic: bool, #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "GradientHueDirection::is_default"))] #[cfg_attr(feature = "wasm", tsify(optional))] pub gradient_hue_direction: GradientHueDirection, @@ -128,6 +130,7 @@ impl From> for GradientRamp { stops, gradient_spread: Default::default(), gradient_space: Default::default(), + gradient_cyclic: Default::default(), gradient_hue_direction: Default::default(), } } @@ -139,6 +142,7 @@ impl From<&Gradient> for GradientRamp { stops: gradient.into(), gradient_spread: Default::default(), gradient_space: Default::default(), + gradient_cyclic: Default::default(), gradient_hue_direction: Default::default(), } } @@ -173,6 +177,9 @@ impl From for Item { if !ramp.gradient_space.is_default() { item.set_attribute(ATTR_GRADIENT_SPACE, ramp.gradient_space); } + if ramp.gradient_cyclic { + item.set_attribute(ATTR_GRADIENT_CYCLIC, ramp.gradient_cyclic); + } if !ramp.gradient_hue_direction.is_default() { item.set_attribute(ATTR_GRADIENT_HUE_DIRECTION, ramp.gradient_hue_direction); } @@ -186,6 +193,7 @@ impl From<&Item> for GradientRamp { stops: item.element().into(), gradient_spread: item.attribute_cloned_or_default(ATTR_GRADIENT_SPREAD), 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), } } @@ -201,7 +209,6 @@ impl From<&GradientRamp> for GradientStops { } } -// Color picker round-trip: routes through the runtime type so default-restating attributes elide impl From<&GradientStops> for GradientRamp { fn from(stops: &GradientStops) -> Self { Self::from(Gradient::from(stops)) @@ -214,6 +221,7 @@ impl From<&GradientRamp> for GradientRamp { stops: ramp.into(), gradient_spread: ramp.gradient_spread, gradient_space: ramp.gradient_space, + gradient_cyclic: ramp.gradient_cyclic, gradient_hue_direction: ramp.gradient_hue_direction, } } @@ -225,18 +233,24 @@ impl From<&Gradient> for GradientRamp { stops: gradient.into(), gradient_spread: Default::default(), gradient_space: Default::default(), + gradient_cyclic: Default::default(), gradient_hue_direction: Default::default(), } } } +// Color picker round-trip: the picker sends every position explicitly, so elide the ones restating the even distribution the cyclic flag selects impl From<&GradientRamp> for GradientRamp { fn from(ramp: &GradientRamp) -> Self { + let mut gradient = Gradient::from(&ramp.stops); + gradient.elide_default_attributes(ramp.gradient_cyclic); + Self { gradient_spread: ramp.gradient_spread, gradient_space: ramp.gradient_space, + gradient_cyclic: ramp.gradient_cyclic, gradient_hue_direction: ramp.gradient_hue_direction, - ..Self::from(&ramp.stops) + ..Self::from(gradient) } } } @@ -436,8 +450,9 @@ impl Iterator for GradientStopsIter<'_> { type Item = GradientStop; fn next(&mut self) -> Option { + // Elided positions fall back to the non-cyclic even distribution; cyclic-aware callers should read `position(index, gradient_cyclic)` instead let stop = GradientStop { - position: self.stops.position(self.index), + position: self.stops.position(self.index, false), midpoint: self.stops.midpoint(self.index), color: self.stops.color(self.index)?, }; @@ -472,8 +487,13 @@ impl IntoIterator for Gradient { } /// The fallback position of the gradient stop at `index` when no `position` attribute exists, where all `count` stops are spaced evenly from 0 to 1. -fn even_position(index: usize, count: usize) -> f64 { - if count <= 1 { 0. } else { index as f64 / (count - 1) as f64 } +fn even_position(index: usize, count: usize, gradient_cyclic: bool) -> f64 { + if count <= 1 { + return 0.; + } + // A cyclic gradient reserves the same span for the wrapped interval as for each interval between stops + let denominator = if gradient_cyclic { count } else { count - 1 }; + index as f64 / denominator as f64 } impl Gradient { @@ -519,8 +539,11 @@ impl Gradient { } /// The effective position of the stop at the given index: its `position` attribute value, or its share of an even distribution when the attribute is absent. - pub fn position(&self, index: usize) -> f64 { - self.0.attribute::(ATTR_POSITION, index).copied().unwrap_or_else(|| even_position(index, self.len())) + pub fn position(&self, index: usize, gradient_cyclic: bool) -> f64 { + self.0 + .attribute::(ATTR_POSITION, index) + .copied() + .unwrap_or_else(|| even_position(index, self.len(), gradient_cyclic)) } /// The effective midpoint of the stop at the given index: its `midpoint` attribute value, or the linear interpolation default of `0.5` when the attribute is absent. @@ -529,8 +552,8 @@ impl Gradient { } /// The effective positions of all stops. - pub fn positions(&self) -> Vec { - (0..self.len()).map(|index| self.position(index)).collect() + pub fn positions(&self, gradient_cyclic: bool) -> Vec { + (0..self.len()).map(|index| self.position(index, gradient_cyclic)).collect() } /// The effective midpoints of all stops. @@ -559,13 +582,13 @@ impl Gradient { } /// The `position` attribute when present and meaningfully different from the even distribution, which is the form worth persisting in the graph. - pub fn nondefault_positions(&self) -> Option> { + pub fn nondefault_positions(&self, gradient_cyclic: bool) -> Option> { let positions = self.position_attribute()?; let count = self.len(); positions .iter() .enumerate() - .any(|(index, &position)| !position.is_finite() || (position - even_position(index, count)).abs() > 1e-6) + .any(|(index, &position)| !position.is_finite() || (position - even_position(index, count, gradient_cyclic)).abs() > 1e-6) .then_some(positions) } @@ -576,8 +599,8 @@ impl Gradient { } /// Removes the `position`/`midpoint` attributes when they merely restate the defaults, restoring the canonical absence-as-default form. - pub fn elide_default_attributes(&mut self) { - if self.has_position_attribute() && self.nondefault_positions().is_none() { + pub fn elide_default_attributes(&mut self, gradient_cyclic: bool) { + if self.has_position_attribute() && self.nondefault_positions(gradient_cyclic).is_none() { self.0.remove_attribute(ATTR_POSITION); } if self.has_midpoint_attribute() && self.nondefault_midpoints().is_none() { @@ -586,14 +609,14 @@ impl Gradient { } /// 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) { + fn materialize_default_positions(&mut self, gradient_cyclic: bool) { if self.has_position_attribute() { return; } let count = self.len(); for index in 0..count { - self.0.set_attribute(ATTR_POSITION, index, even_position(index, count)); + self.0.set_attribute(ATTR_POSITION, index, even_position(index, count, gradient_cyclic)); } } @@ -605,11 +628,11 @@ impl Gradient { } /// Sets the position of the stop at `index`, if it exists, materializing the whole `position` attribute so the other stops keep their effective placements. - pub fn set_position(&mut self, index: usize, position: f64) { + pub fn set_position(&mut self, index: usize, position: f64, gradient_cyclic: bool) { if index >= self.len() { return; } - self.materialize_default_positions(); + self.materialize_default_positions(gradient_cyclic); self.0.set_attribute(ATTR_POSITION, index, position); } @@ -671,37 +694,46 @@ impl Gradient { } /// Move the stop at `index` to a new position, re-sorting the stops by position. Returns the new index of the moved stop. - pub fn move_stop(&mut self, index: usize, position: f64) -> usize { + pub fn move_stop(&mut self, index: usize, position: f64, gradient_cyclic: bool) -> usize { if index >= self.len() { return index; } - self.set_position(index, position); - self.sort_returning_new_index(index) + self.set_position(index, position, gradient_cyclic); + self.sort_returning_new_index(index, gradient_cyclic) } /// 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). + /// 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_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> usize { - let color = self.evaluate(position, Default::default(), gradient_space, gradient_hue_direction); - let index = (0..self.len()).position(|i| self.position(i) > position).unwrap_or(self.len()); - let midpoint = if index > 0 { self.midpoint(index - 1) } else { 0.5 }; - self.insert_stop_values(position, midpoint, color) + 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); + 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 + let midpoint = if index > 0 { + self.midpoint(index - 1) + } else if gradient_cyclic && !self.is_empty() { + self.midpoint(self.len() - 1) + } else { + 0.5 + }; + + self.insert_stop_values(position, midpoint, color, gradient_cyclic) } /// Insert a copy of the stop at `source_index` (same color and midpoint) at `position`, keeping the stops sorted by position. /// Returns the index where the copy was inserted, or `None` if `source_index` is out of range. - pub fn duplicate_stop(&mut self, source_index: usize, position: f64) -> Option { + pub fn duplicate_stop(&mut self, source_index: usize, position: f64, gradient_cyclic: bool) -> Option { let color = self.color(source_index)?; let midpoint = self.midpoint(source_index); - Some(self.insert_stop_values(position, midpoint, color)) + Some(self.insert_stop_values(position, midpoint, color, gradient_cyclic)) } /// Splices a new stop into the sorted position, materializing explicit positions (an arbitrary insertion breaks even distribution) /// while giving the new stop a midpoint cell only if the attribute already exists. - fn insert_stop_values(&mut self, position: f64, midpoint: f64, color: Color) -> usize { - self.materialize_default_positions(); - let index = (0..self.len()).position(|i| self.position(i) > position).unwrap_or(self.len()); + fn insert_stop_values(&mut self, position: f64, midpoint: f64, color: Color, gradient_cyclic: bool) -> usize { + self.materialize_default_positions(gradient_cyclic); + let index = (0..self.len()).position(|i| self.position(i, gradient_cyclic) > position).unwrap_or(self.len()); let mut item = Item::new_from_element(color).with_attribute(ATTR_POSITION, position); if self.has_midpoint_attribute() { @@ -728,14 +760,14 @@ impl Gradient { } /// Sort the stops in place by position; returns the new index of the stop that was at `previous_index` before sorting. - fn sort_returning_new_index(&mut self, previous_index: usize) -> usize { + fn sort_returning_new_index(&mut self, previous_index: usize, gradient_cyclic: bool) -> usize { // An absent position attribute is an even distribution, which is already sorted if !self.has_position_attribute() { return previous_index; } let mut indices: Vec = (0..self.len()).collect(); - indices.sort_by(|&a, &b| self.position(a).total_cmp(&self.position(b))); + indices.sort_by(|&a, &b| self.position(a, gradient_cyclic).total_cmp(&self.position(b, gradient_cyclic))); let new_index = indices.iter().position(|&i| i == previous_index).unwrap_or(previous_index); self.0 = self.reordered(indices); new_index @@ -744,10 +776,10 @@ impl Gradient { /// Gradient stops as evaluation and rendering should see them: positions clamped to the 0 to 1 range /// (infinities landing at the ends, a NaN dropping its stop from sampling since it has no defined placement) /// and sorted ascending, so the sampler and every renderer agree on how non-compliant authored data behaves. - fn normalized_stops(&self) -> Vec { + fn normalized_stops(&self, gradient_cyclic: bool) -> Vec { let mut stops: Vec = (0..self.len()) .filter_map(|index| { - let position = self.position(index).clamp(0., 1.); + let position = self.position(index, gradient_cyclic).clamp(0., 1.); if position.is_nan() { return None; } @@ -764,7 +796,7 @@ impl Gradient { } /// Samples the gradient's color at `t`. Given a `t` outside the 0 to 1 range, the `gradient_spread` determines how the gradient extends. - pub fn evaluate(&self, t: f64, gradient_spread: GradientSpread, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> Color { + 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.), @@ -780,8 +812,19 @@ impl Gradient { } }; - let stops = self.normalized_stops(); + 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; } @@ -801,11 +844,11 @@ impl Gradient { Color::BLACK } - pub fn sort(&mut self) { - self.sort_returning_new_index(0); + pub fn sort(&mut self, gradient_cyclic: bool) { + self.sort_returning_new_index(0, gradient_cyclic); } - pub fn reversed(&self) -> Self { + pub fn reversed(&self, gradient_cyclic: bool) -> Self { let count = self.len(); let mut list = self.reordered((0..count).rev()); @@ -816,11 +859,27 @@ impl Gradient { for position in positions { *position = 1. - *position; } + } else if gradient_cyclic && !self.has_position_attribute() { + // The cyclic even distribution starts at 0 rather than being symmetric across the range, so its flip must materialize + for index in 0..count { + list.set_attribute(ATTR_POSITION, index, 1. - even_position(count - 1 - index, count, true)); + } } - // Midpoints belong to the interval to a stop's right, so they shift by one stop as well as flipping + // Midpoints belong to the interval to a stop's right, so they shift by one stop as well as flipping; + // a cyclic gradient's final midpoint is its wrap handle, which flips in place if self.has_midpoint_attribute() { - let midpoints: Vec = (0..count).map(|i| if i + 1 < count { 1. - self.midpoint(count - 2 - i) } else { 0.5 }).collect(); + let midpoints: Vec = (0..count) + .map(|i| { + if i + 1 < count { + 1. - self.midpoint(count - 2 - i) + } else if gradient_cyclic { + 1. - self.midpoint(count - 1) + } else { + 0.5 + } + }) + .collect(); for (index, midpoint) in midpoints.into_iter().enumerate() { list.set_attribute(ATTR_MIDPOINT, index, midpoint); } @@ -836,13 +895,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_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> String { + pub fn to_css_linear_gradient(&self, gradient_cyclic: bool, gradient_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> String { if self.len() <= 1 { let hex = self.color(0).map(|c| SRGBA8::from(c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string()); return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)"); } let pieces = self - .interpolated_samples(gradient_space, gradient_hue_direction) + .interpolated_samples(gradient_cyclic, gradient_space, gradient_hue_direction) .into_iter() .map(|(position, color, _)| { let percent = ((position * 100.) * 1e2).round() / 1e2; @@ -862,7 +921,7 @@ impl Gradient { /// 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_space: GradientSpace, gradient_hue_direction: GradientHueDirection) -> Vec<(f64, Color, Option)> { + 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.; @@ -919,7 +978,7 @@ impl Gradient { } } - let stops = self.normalized_stops(); + let stops = self.normalized_stops(gradient_cyclic); let count = stops.len(); if count == 0 { return vec![]; @@ -939,7 +998,7 @@ impl Gradient { let midpoint = sanitized_midpoint(stops[i].midpoint); let next_midpoint = sanitized_midpoint(stops[i + 1].midpoint); - // Add the start stop (subsequent segments share the previous end stop) + // Add the start stop (subsequent intervals share the previous end stop) if i == 0 { result.push((pos_a, color_a, Some(midpoint))); } @@ -953,6 +1012,58 @@ impl Gradient { 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; + } + } + } + // 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)) { result.iter_mut().for_each(|(_, _, midpoint)| *midpoint = None); @@ -1014,11 +1125,11 @@ impl GradientSpread { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[widget(Dropdown)] pub enum GradientSpace { - /// Interpolates between stops in the OkLab perceptual color space, keeping transitions visually even. + /// Interpolates between stops in the OkLab color space, the modern perceptual standard. #[default] #[label("Perceptual (OkLab)")] OkLab, - /// Interpolates between stops in the CIE Lab color space, the longtime perceptual standard. + /// Interpolates between stops in the CIE Lab color space, the traditional perceptual standard. #[label("Perceptual (Lab)")] Lab, /// Interpolates between stops in the polar form of OkLab, arcing through hue instead of fading through gray. @@ -1027,7 +1138,7 @@ pub enum GradientSpace { /// Interpolates between stops in the polar form of CIE Lab, arcing through hue instead of fading through gray. #[label("Perceptual Hue (LCh)")] LCh, - /// Interpolates between stops in linear light, keeping transitions evenly bright. + /// Interpolates between stops in linear light, keeping transitions uniformly bright. #[menu_separator] #[cfg_attr(feature = "serde", serde(alias = "SrgbLinear"))] #[label("Linear (RGB)")] @@ -1036,10 +1147,10 @@ pub enum GradientSpace { #[cfg_attr(feature = "serde", serde(alias = "SrgbGamma"))] #[label("Classic (RGB)")] RgbGamma, - /// Interpolates between stops in the hue, saturation, and value cylinder, keeping tints at full brightness. + /// Interpolates between stops in the hue/saturation/value cylinder, keeping tints at full brightness. #[label("Classic Hue (HSV)")] Hsv, - /// Interpolates between stops in the hue, saturation, and lightness cylinder. + /// Interpolates between stops in the hue/saturation/lightness cylinder. #[label("Classic Hue (HSL)")] Hsl, } @@ -1152,14 +1263,14 @@ mod tests { #[test] fn default_is_empty_and_black_to_white_is_the_artist_starting_gradient() { assert!(Gradient::default().is_empty()); - assert_eq!(Gradient::black_to_white().positions(), vec![0., 1.]); - assert_eq!(Gradient::default().evaluate(0.5, Default::default(), Default::default(), Default::default()), Color::BLACK); + 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); } #[test] fn absent_attributes_default_to_even_positions_and_linear_midpoints() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); - assert_eq!(gradient.positions(), vec![0., 0.5, 1.]); + assert_eq!(gradient.positions(false), vec![0., 0.5, 1.]); assert_eq!(gradient.midpoints(), vec![0.5, 0.5, 0.5]); } @@ -1274,11 +1385,11 @@ mod tests { 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(GradientSpace::RgbGamma, Default::default()).len(), 2); + assert_eq!(gradient.interpolated_samples(false, 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(GradientSpace::RgbLinear, Default::default()); + let samples = gradient.interpolated_samples(false, 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.); @@ -1292,7 +1403,7 @@ mod tests { // Identical end colors leave nothing to densify let flat = Gradient::from(vec![Color::WHITE, Color::WHITE]); - assert_eq!(flat.interpolated_samples(GradientSpace::RgbLinear, Default::default()).len(), 2); + assert_eq!(flat.interpolated_samples(false, GradientSpace::RgbLinear, Default::default()).len(), 2); } #[test] @@ -1323,7 +1434,7 @@ mod tests { let mut gradient = Gradient::from(vec![color_a, color_b]); gradient.set_midpoints(&[midpoint, 0.5]); - let samples = gradient.interpolated_samples(gradient_space, gradient_hue_direction); + let samples = gradient.interpolated_samples(false, gradient_space, gradient_hue_direction); for probe in 0..=1000 { let t = probe as f64 / 1000.; @@ -1359,13 +1470,13 @@ mod tests { fn clear_spread_evaluates_to_transparency_outside_the_unit_range() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); - assert_eq!(gradient.evaluate(-0.25, GradientSpread::Clear, Default::default(), Default::default()), Color::TRANSPARENT); - assert_eq!(gradient.evaluate(1.25, GradientSpread::Clear, Default::default(), Default::default()), Color::TRANSPARENT); + 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); for t in [0., 0.25, 1.] { assert_eq!( - gradient.evaluate(t, GradientSpread::Clear, Default::default(), Default::default()), - gradient.evaluate(t, GradientSpread::Pad, Default::default(), Default::default()), + gradient.evaluate(t, GradientSpread::Clear, false, Default::default(), Default::default()), + gradient.evaluate(t, GradientSpread::Pad, false, Default::default(), Default::default()), "inside the range Clear must match Pad at t = {t}" ); } @@ -1375,9 +1486,9 @@ 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(), GradientSpace::OkLab, Default::default()); - let linear = gradient.evaluate(0.5, Default::default(), GradientSpace::RgbLinear, Default::default()); - let gamma = gradient.evaluate(0.5, Default::default(), GradientSpace::RgbGamma, Default::default()); + 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()); assert_eq!(linear, Color::BLACK.lerp(&Color::WHITE, 0.5)); assert_eq!(gamma, Color::BLACK.lerp_gamma_srgb(&Color::WHITE, 0.5)); @@ -1399,28 +1510,28 @@ 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(), GradientSpace::Hsl, Default::default()); + let magenta = red_to_blue.evaluate(0.5, Default::default(), false, GradientSpace::Hsl, Default::default()); for (channel, expected) in [(magenta.r(), 1.), (magenta.g(), 0.), (magenta.b(), 1.)] { assert!((channel - expected).abs() < 1e-3, "the HSL mid color of red and blue should be magenta, got {magenta:?}"); } // White's hue is powerless, so an OkLCh interpolation toward it keeps red's hue instead of drifting toward white's arbitrary hue let red_to_white = Gradient::from(vec![Color::RED, Color::WHITE]); - let pink = red_to_white.evaluate(0.5, Default::default(), GradientSpace::OkLCh, Default::default()); + let pink = red_to_white.evaluate(0.5, Default::default(), false, GradientSpace::OkLCh, Default::default()); let [_, _, red_hue] = color::Oklch::from_linear_srgb([Color::RED.r(), Color::RED.g(), Color::RED.b()]); let [_, pink_chroma, pink_hue] = color::Oklch::from_linear_srgb([pink.r(), pink.g(), pink.b()]); assert!(pink_chroma > 0.05, "the mid color should stay chromatic, got {pink:?}"); assert!((pink_hue - red_hue).abs() < 0.5, "the mid hue should hold red's {red_hue} degrees, got {pink_hue}"); // HSV rides the cube's top face toward white, keeping the mid tint at full brightness where HSL dips - let tint = red_to_white.evaluate(0.5, Default::default(), GradientSpace::Hsv, Default::default()); + let tint = red_to_white.evaluate(0.5, Default::default(), false, GradientSpace::Hsv, Default::default()); for (channel, target) in tint.to_gamma_srgb_channels().into_iter().zip([1., 0.5, 0.5, 1.]) { assert!((channel - target).abs() < 1e-3, "the HSV mid tint of red and white should be gamma (1, 0.5, 0.5), got {tint:?}"); } // Toward black both saturation and value halve, the classic HSV shade that neither HSL nor HWB produces let red_to_black = Gradient::from(vec![Color::RED, Color::BLACK]); - let shade = red_to_black.evaluate(0.5, Default::default(), GradientSpace::Hsv, Default::default()); + let shade = red_to_black.evaluate(0.5, Default::default(), false, 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:?}"); } @@ -1438,7 +1549,7 @@ 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(), GradientSpace::Hsl, gradient_hue_direction); + let mid = red_to_blue.evaluate(0.5, Default::default(), false, GradientSpace::Hsl, gradient_hue_direction); for (channel, target) in [mid.r(), mid.g(), mid.b()].into_iter().zip(expected_rgb) { assert!( (channel - target).abs() < 1e-3, @@ -1449,7 +1560,7 @@ 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(), GradientSpace::Hsl, GradientHueDirection::Longer); + let mid = red_to_red.evaluate(0.5, Default::default(), false, GradientSpace::Hsl, GradientHueDirection::Longer); for (channel, target) in [mid.r(), mid.g(), mid.b()].into_iter().zip([0., 1., 1.]) { assert!((channel - target).abs() < 1e-3, "the full-turn mid of red and red should be cyan, got {mid:?}"); } @@ -1457,29 +1568,46 @@ mod tests { #[test] fn gradient_ui_write_back_elides_default_attributes() { + // The picker always sends explicit positions, so the write-back is what restores the canonical absence-as-default form + let write_back = |gradient: &Gradient, gradient_cyclic: bool| { + let sent = GradientRamp:: { gradient_cyclic, ..gradient.into() }; + Gradient::from(&GradientRamp::from(&sent)) + }; + let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); + gradient.set_positions(&[0., 0.5, 1.]); gradient.set_midpoints(&[0.7, 0.5, 0.5]); - let round_tripped = Gradient::from(&GradientStops::::from(&gradient)); - assert!(!round_tripped.has_position_attribute(), "materialized even positions should elide on write-back"); - assert_eq!(round_tripped.midpoints(), vec![0.7, 0.5, 0.5]); + let written_back = write_back(&gradient, false); + assert!(!written_back.has_position_attribute(), "the even distribution should elide on write-back"); + assert_eq!(written_back.midpoints(), vec![0.7, 0.5, 0.5]); + + // Cyclic ramps spread over one more interval, so thirds are the elidable distribution and halves are not + let mut cyclic = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); + cyclic.set_positions(&[0., 1. / 3., 2. / 3.]); + assert!(!write_back(&cyclic, true).has_position_attribute(), "the cyclic even distribution should elide on write-back"); + assert_eq!( + write_back(&gradient, true).positions(true), + vec![0., 0.5, 1.], + "positions that only look default when non-cyclic must survive" + ); } #[test] fn nondefault_attributes_elide_default_values() { let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); - assert_eq!(gradient.nondefault_positions(), None); + assert_eq!(gradient.nondefault_positions(false), None); assert_eq!(gradient.nondefault_midpoints(), None); // Explicit attributes that merely restate the defaults still elide gradient.set_positions(&[0., 0.5, 1.]); gradient.set_midpoints(&[0.5, 0.5, 0.5]); - assert_eq!(gradient.nondefault_positions(), None); + assert_eq!(gradient.nondefault_positions(false), None); assert_eq!(gradient.nondefault_midpoints(), None); gradient.set_positions(&[0., 0.25, 1.]); gradient.set_midpoints(&[0.5, 0.7, 0.5]); - assert_eq!(gradient.nondefault_positions(), Some(vec![0., 0.25, 1.])); + assert_eq!(gradient.nondefault_positions(false), Some(vec![0., 0.25, 1.])); assert_eq!(gradient.nondefault_midpoints(), Some(vec![0.5, 0.7, 0.5])); } @@ -1488,10 +1616,10 @@ mod tests { // Stored positions stay as authored, but consumers see them clamped to the 0 to 1 range and sorted let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK, Color::RED]); gradient.set_positions(&[1.5, 0.4, -0.5]); - assert_eq!(gradient.positions(), vec![1.5, 0.4, -0.5]); + assert_eq!(gradient.positions(false), vec![1.5, 0.4, -0.5]); let sample_positions: Vec = gradient - .interpolated_samples(GradientSpace::RgbGamma, Default::default()) + .interpolated_samples(false, GradientSpace::RgbGamma, Default::default()) .iter() .map(|(position, ..)| *position) .collect(); @@ -1499,8 +1627,8 @@ mod tests { assert_eq!(sample_positions.first(), Some(&0.)); assert_eq!(sample_positions.last(), Some(&1.)); - assert_eq!(gradient.evaluate(0., Default::default(), Default::default(), Default::default()), Color::RED); - assert_eq!(gradient.evaluate(1., Default::default(), Default::default(), Default::default()), Color::WHITE); + 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); } #[test] @@ -1509,13 +1637,13 @@ mod tests { gradient.set_positions(&[f64::INFINITY, f64::NEG_INFINITY]); let sample_positions: Vec = gradient - .interpolated_samples(GradientSpace::RgbGamma, Default::default()) + .interpolated_samples(false, GradientSpace::RgbGamma, Default::default()) .iter() .map(|(position, ..)| *position) .collect(); assert_eq!(sample_positions, vec![0., 1.]); - assert_eq!(gradient.evaluate(0., Default::default(), Default::default(), Default::default()), Color::BLACK); - assert_eq!(gradient.evaluate(1., Default::default(), Default::default(), Default::default()), Color::WHITE); + 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); } #[test] @@ -1524,24 +1652,24 @@ mod tests { gradient.set_positions(&[0., f64::NAN, 1.]); let sample_positions: Vec = gradient - .interpolated_samples(GradientSpace::RgbGamma, Default::default()) + .interpolated_samples(false, GradientSpace::RgbGamma, Default::default()) .iter() .map(|(position, ..)| *position) .collect(); assert_eq!(sample_positions, vec![0., 1.]); assert_eq!( - gradient.evaluate(0.5, Default::default(), GradientSpace::RgbLinear, Default::default()), + gradient.evaluate(0.5, Default::default(), false, GradientSpace::RgbLinear, Default::default()), Color::WHITE.lerp(&Color::RED, 0.5) ); // A non-finite position is preserved as nondefault so write-back elision cannot resurrect the dropped stop - assert!(gradient.nondefault_positions().is_some()); + assert!(gradient.nondefault_positions(false).is_some()); // 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(GradientSpace::RgbGamma, Default::default()).is_empty()); - assert_eq!(gradient.evaluate(0.5, Default::default(), Default::default(), Default::default()), Color::BLACK); + 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); } #[test] @@ -1549,21 +1677,152 @@ mod tests { let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]); gradient.set_positions(&[0.3, 1.]); - let samples = gradient.interpolated_samples(GradientSpace::RgbGamma, Default::default()); + let samples = gradient.interpolated_samples(false, GradientSpace::RgbGamma, Default::default()); assert_eq!(samples[0], (0.3, Color::WHITE, None), "renderers that need a flat lead-in before the first stop add it themselves"); } #[test] fn nan_midpoints_read_as_linear() { let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); - let linear_result = gradient.evaluate(0.25, Default::default(), Default::default(), Default::default()); + let linear_result = gradient.evaluate(0.25, Default::default(), false, Default::default(), Default::default()); gradient.set_midpoints(&[f64::NAN, f64::NAN]); - assert_eq!(gradient.evaluate(0.25, Default::default(), Default::default(), Default::default()), linear_result); + assert_eq!(gradient.evaluate(0.25, Default::default(), false, Default::default(), Default::default()), linear_result); let no_nan_annotations = gradient - .interpolated_samples(GradientSpace::RgbGamma, Default::default()) + .interpolated_samples(false, 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"); } + + #[test] + fn gradient_cyclic_serializes_only_when_set_and_rides_the_item_attribute() { + let ramp = GradientRamp::from(Gradient::from(vec![Color::BLACK, Color::WHITE])); + let json = serde_json::to_string(&ramp).unwrap(); + assert!(!json.contains("gradient_cyclic"), "the default non-cyclic flag must not serialize: {json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), ramp); + + let cyclic = GradientRamp { gradient_cyclic: true, ..ramp }; + let json = serde_json::to_string(&cyclic).unwrap(); + assert!(json.contains(r#""gradient_cyclic":true"#), "an enabled cyclic flag must serialize: {json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), cyclic); + + let item = Item::::from(cyclic.clone()); + assert!( + item.attribute_cloned_or_default::(ATTR_GRADIENT_CYCLIC), + "the runtime item should carry the cyclic flag as its attribute" + ); + assert_eq!(GradientRamp::from(&item), cyclic); + } + + #[test] + fn cyclic_reserves_the_wrapped_interval_in_the_even_distribution() { + let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED, Color::BLUE]); + assert_eq!(gradient.positions(false), vec![0., 1. / 3., 2. / 3., 1.]); + assert_eq!(gradient.positions(true), vec![0., 0.25, 0.5, 0.75]); + } + + #[test] + 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()); + 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()); + assert_eq!(at_end, at_start, "the 1|0 boundary must be seamless"); + assert_eq!(at_end, Color::WHITE.lerp(&Color::BLACK, 2. / 3.)); + } + + #[test] + fn cyclic_wrapped_interval_times_with_the_last_stops_midpoint() { + let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); + 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()); + assert_eq!(mid, Color::WHITE.lerp(&Color::BLACK, expected_t as f32)); + } + + #[test] + fn cyclic_samples_bake_the_wrap_and_play_back_within_tolerance() { + let mut gradient = Gradient::from(vec![Color::RED, Color::WHITE]); + gradient.set_positions(&[0.25, 0.5]); + gradient.set_midpoints(&[0.5, 0.3]); + + let samples = gradient.interpolated_samples(true, 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"); + assert!(samples.windows(2).all(|pair| pair[0].0 <= pair[1].0), "samples must ascend"); + + // The flat samples' gamma playback must track the true cyclic curve, wrapped interval included + for probe in 0..=400 { + let t = probe as f64 / 400.; + + let after = samples.iter().position(|&(position, ..)| position >= t).unwrap_or(samples.len() - 1); + let playback = if after == 0 { + samples[0].1 + } else { + let (left_position, left_color, _) = samples[after - 1]; + let (right_position, right_color, _) = samples[after]; + let span = right_position - left_position; + if span < 1e-12 { + right_color + } else { + left_color.lerp_gamma_srgb(&right_color, ((t - left_position) / span) as f32) + } + }; + + let true_color = gradient.evaluate(t, Default::default(), true, 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.); + } + } + + #[test] + fn cyclic_even_positions_elide_against_their_own_distribution() { + let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); + gradient.set_positions(&[0., 1. / 3., 2. / 3.]); + assert!(gradient.nondefault_positions(true).is_none()); + assert!(gradient.nondefault_positions(false).is_some()); + + gradient.elide_default_attributes(true); + assert!(!gradient.has_position_attribute(), "cyclic-even positions should elide under the cyclic default"); + } + + #[test] + fn inserting_into_the_wrapped_interval_inherits_the_wrap_handle_and_samples_its_color() { + let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]); + 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()); + 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))); + } + + #[test] + fn cyclic_reversal_materializes_the_even_distribution_and_flips_the_wrap_handle() { + let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); + gradient.set_midpoints(&[0.5, 0.5, 0.25]); + + let reversed = gradient.reversed(true); + let positions = reversed.positions(true); + for (actual, expected) in positions.iter().zip([1. / 3., 2. / 3., 1.]) { + assert!((actual - expected).abs() < 1e-12, "reversed positions should be thirds ending at 1, got {positions:?}"); + } + assert_eq!( + reversed.midpoints(), + vec![0.5, 0.5, 0.75], + "the wrap handle should flip in place while interval midpoints shift and flip" + ); + } } diff --git a/node-graph/libraries/vector-types/src/markers.rs b/node-graph/libraries/vector-types/src/markers.rs index 659418819f..0f479e6454 100644 --- a/node-graph/libraries/vector-types/src/markers.rs +++ b/node-graph/libraries/vector-types/src/markers.rs @@ -10,6 +10,9 @@ core_types::attribute! { pub GradientSpace("gradient_space"): crate::gradient::GradientSpace; /// Gradient's `GradientHueDirection`, the hue path polar spaces interpolate along. pub GradientHueDirection("gradient_hue_direction"): crate::gradient::GradientHueDirection; + /// Gradient's `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 GradientCyclic("gradient_cyclic"): bool; /// Gradient's shape (`Linear` or `Radial`). pub GradientForm("gradient_form"): crate::gradient::GradientForm; /// Optional `Vector` that overrides the item's own geometry for click-target generation. @@ -30,6 +33,7 @@ core_types::named_value! { pub const ATTR_GRADIENT_SPREAD: &str = GradientSpread::NAME; pub const ATTR_GRADIENT_FORM: &str = GradientForm::NAME; +pub const ATTR_GRADIENT_CYCLIC: &str = GradientCyclic::NAME; pub const ATTR_GRADIENT_SPACE: &str = GradientSpace::NAME; pub const ATTR_GRADIENT_HUE_DIRECTION: &str = GradientHueDirection::NAME; pub const ATTR_EDITOR_CLICK_TARGET: &str = EditorClickTarget::NAME; diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 8d2e80dc55..878da81897 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_space, ramp.gradient_hue_direction)), + Self::Gradient(ramp) => Some(ramp.stops.to_css_linear_gradient(ramp.gradient_cyclic, ramp.gradient_space, ramp.gradient_hue_direction)), } } } diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index df95af693a..024478edf6 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -11,7 +11,9 @@ use math_parser::value::{Number, Value}; use rand::{Rng, SeedableRng}; use std::ops::{Add, Mul, Rem, Sub}; use vector_types::Gradient; -use vector_types::markers::{GradientForm as GradientFormAttr, GradientHueDirection as GradientHueDirectionAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr}; +use vector_types::markers::{ + GradientCyclic as GradientCyclicAttr, GradientForm as GradientFormAttr, GradientHueDirection as GradientHueDirectionAttr, GradientSpace as GradientSpaceAttr, GradientSpread as GradientSpreadAttr, +}; /// The struct that stores the context for the maths parser. /// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs. @@ -1238,7 +1240,7 @@ fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: IList /// Sets the interpolation midpoint for each interval between gradient stops, a factor from 0 to 1 where the 0.5 default means linear interpolation and another value skews the transition speed toward one stop or the other. /// -/// The final stop belongs to no interval so its midpoint is ignored. +/// The final stop's midpoint controls the wrap back around to the first stop when the gradient is cyclic, and is otherwise ignored. /// /// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each midpoint to its default of 0.5. #[node_macro::node(category("Gradient"))] @@ -1264,7 +1266,8 @@ fn sample_gradient( let gradient_spread = gradient.lane(0).attr::(); let gradient_space = gradient.lane(0).attr::(); let gradient_hue_direction = gradient.lane(0).attr::(); - Ok(gradient.element_ref(0).evaluate(position, gradient_spread, gradient_space, gradient_hue_direction)) + let gradient_cyclic = gradient.lane(0).attr::(); + Ok(gradient.element_ref(0).evaluate(position, gradient_spread, gradient_cyclic, gradient_space, gradient_hue_direction)) } /// Constructs a footprint value which may be set to any transformation of a unit square describing a render area, and a render resolution at least 1x1 integer pixels. diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 55f90d3724..82e1018f4b 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -38,22 +38,21 @@ mod blend_std { impl Blend for Gradient { // TODO: This joining is unfaithful in several ways: it samples only at stop positions so midpoint curves flatten away; - // it evaluates both sources with the default spread and space rather than their own attributes (which this - // element-level impl cannot read); and the output keeps over's attributes despite being sampled in the default space + // TODO: it evaluates both sources with default whole-ramp attributes rather than their own (which this element-level impl cannot read); + // TODO: and the output keeps over's attributes despite being sampled with defaults fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { - let mut combined_stops = self.positions().into_iter().chain(under.positions()).collect::>(); + 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 stops = combined_stops.into_iter().map(|position| { - let over_color = self.evaluate(position, Default::default(), Default::default(), Default::default()); - let under_color = under.evaluate(position, Default::default(), Default::default(), Default::default()); + let 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); GradientStop { position, midpoint: 0.5, color } }); - let mut gradient = Gradient::new(stops); - gradient.elide_default_attributes(); - gradient + // Positions stay explicit because eliding them needs the cyclic flag this impl can't read, and a wrong guess would relocate the stops + Gradient::new(stops) } } } diff --git a/node-graph/nodes/raster/src/gradient_map.rs b/node-graph/nodes/raster/src/gradient_map.rs index e89ed1f146..5b487f04cc 100644 --- a/node-graph/nodes/raster/src/gradient_map.rs +++ b/node-graph/nodes/raster/src/gradient_map.rs @@ -26,12 +26,13 @@ fn gradient_map + Clone + Send + Sync + core_types::CacheHash + let gradient_spread = gradient.lane(0).attr::(); let gradient_space = gradient.lane(0).attr::(); let gradient_hue_direction = gradient.lane(0).attr::(); + let gradient_cyclic = gradient.lane(0).attr::(); let gradient = gradient.element_ref(0); image.adjust(|color| { let intensity = color.luminance_rec_709(); let intensity = if reverse { 1. - intensity } else { intensity }; - gradient.evaluate(intensity as f64, gradient_spread, gradient_space, gradient_hue_direction) + gradient.evaluate(intensity as f64, gradient_spread, gradient_cyclic, gradient_space, gradient_hue_direction) }); image diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 7315855dca..83ab247dad 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -47,6 +47,7 @@ use vector_types::vector::{PointDomain, RegionDomain}; /// randomized draws up to it. fn assign_color_at( gradient: &Gradient, + gradient_cyclic: bool, gradient_space: vector_types::GradientSpace, gradient_hue_direction: vector_types::GradientHueDirection, position: usize, @@ -70,7 +71,7 @@ fn assign_color_at( _ => position as f64 % repeat_every as f64 / (repeat_every - 1) as f64, }, }; - gradient.evaluate(factor, Default::default(), gradient_space, gradient_hue_direction) + gradient.evaluate(factor, Default::default(), gradient_cyclic, gradient_space, gradient_hue_direction) } /// Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient. @@ -113,6 +114,7 @@ fn assign_colors<'e>( if gradient.is_empty() { return Ok((content.lane(lane).map_element(element), Attr(existing_fill), Attr(existing_stroke))); } + let gradient_cyclic = gradient.lane(0).attr::(); let gradient_space = gradient.lane(0).attr::(); let gradient_hue_direction = gradient.lane(0).attr::(); let gradient_element = gradient.element_ref(0); @@ -125,7 +127,17 @@ fn assign_colors<'e>( false => gradient_element, }; - let color = assign_color_at(gradient_element, gradient_space, gradient_hue_direction, lane, content.len(), randomize, seed, repeat_every); + let color = assign_color_at( + gradient_element, + gradient_cyclic, + gradient_space, + gradient_hue_direction, + lane, + content.len(), + randomize, + seed, + repeat_every, + ); let paint = List::new_from_element(color).into_graphic_list(); let parked = park_paint(ctx.arena(), paint)?; @@ -183,6 +195,7 @@ fn assign_colors_graphic<'e>( if gradient.is_empty() { return Ok(content.lane(lane).map_element(original.clone())); } + let gradient_cyclic = gradient.lane(0).attr::(); let gradient_space = gradient.lane(0).attr::(); let gradient_hue_direction = gradient.lane(0).attr::(); let gradient_element = gradient.element_ref(0); @@ -230,7 +243,17 @@ fn assign_colors_graphic<'e>( Some(mut rows) => { for row in 0..rows.len() { let has_stroke = rows.element(row).is_some_and(|vector| vector.stroke.is_some()); - let color = assign_color_at(gradient_element, gradient_space, gradient_hue_direction, position + row, length, randomize, seed, repeat_every); + let color = assign_color_at( + gradient_element, + gradient_cyclic, + gradient_space, + gradient_hue_direction, + position + row, + length, + randomize, + seed, + repeat_every, + ); let paint = List::new_from_element(color).into_graphic_list(); if fill { set_paint_attribute_at(&mut rows, row, ATTR_FILL, paint.clone());