diff --git a/Cargo.lock b/Cargo.lock index 69e738f0fb..bdc9405709 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6614,6 +6614,7 @@ dependencies = [ "polycool", "rustc-hash 2.1.1", "serde", + "serde_json", "tinyvec", "tsify", "wasm-bindgen", 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 7e03956b02..7d86c738cf 100644 --- a/editor/src/messages/color_picker/color_picker_message_handler.rs +++ b/editor/src/messages/color_picker/color_picker_message_handler.rs @@ -5,7 +5,7 @@ use crate::messages::prelude::*; use graphene_std::Color; use graphene_std::color::SRGBA8; use graphene_std::core_types::misc::parse_css_color; -use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientUI}; +use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientStops}; /// Bounds for a midpoint position (relative to the interval between two adjacent gradient stops). const MIN_MIDPOINT: f64 = 0.01; @@ -82,7 +82,7 @@ impl MessageHandler for ColorPickerMessageHandler { FillChoice::Gradient(stops) => { self.active_marker_index = Some(0); self.active_marker_is_midpoint = false; - let first_color = stops.color.first().copied().unwrap_or(Color::BLACK); + let first_color = stops.color(0).unwrap_or(Color::BLACK); self.gradient = Some(stops); self.adopt_color(first_color); } @@ -266,9 +266,9 @@ impl ColorPickerMessageHandler { if let Some(gradient) = &mut self.gradient && let Some(active_index) = self.active_marker_index - && let Some(stop_color) = gradient.color.get_mut(active_index as usize) + && (active_index as usize) < gradient.len() { - *stop_color = color; + gradient.set_color(active_index as usize, color); let stops = gradient.clone(); let fill_choice = FillChoice::Gradient(stops); responses.add(FrontendMessage::ColorPickerColorChanged { @@ -305,7 +305,7 @@ impl ColorPickerMessageHandler { self.active_marker_is_midpoint = active_marker_is_midpoint; if let Some(index) = active_marker_index && let Some(gradient) = &self.gradient - && let Some(color) = gradient.color.get(index as usize).copied() + && let Some(color) = gradient.color(index as usize) { self.adopt_color(color); self.snapshot_old(); @@ -324,17 +324,16 @@ impl ColorPickerMessageHandler { } } SpectrumInputUpdate::MoveMidpoint { index, position } => { - if let Some(midpoint) = gradient.midpoint.get_mut(index as usize) { - *midpoint = position.clamp(MIN_MIDPOINT, MAX_MIDPOINT); - } else { + if (index as usize) >= gradient.len() { return; } + gradient.set_midpoint(index as usize, position.clamp(MIN_MIDPOINT, MAX_MIDPOINT)); } SpectrumInputUpdate::InsertMarker { position } => { let new_index = gradient.insert_stop(position); self.active_marker_index = Some(new_index as u32); self.active_marker_is_midpoint = false; - if let Some(color) = gradient.color.get(new_index).copied() { + if let Some(color) = gradient.color(new_index) { self.adopt_color(color); self.snapshot_old(); } @@ -349,7 +348,7 @@ impl ColorPickerMessageHandler { } SpectrumInputUpdate::RemoveDuplicate { index } => { let anchor = index as usize; - if anchor >= gradient.position.len() || gradient.position.len() <= 2 { + if anchor >= gradient.len() || gradient.len() <= 2 { return; } // Never remove the active (dragged) stop itself, this should only ever target the frozen copy. @@ -366,14 +365,14 @@ impl ColorPickerMessageHandler { } SpectrumInputUpdate::DeleteMarker { index } => { // Enforce minimum stop count. The gradient editor needs at least 2 stops to remain meaningful. - if gradient.position.len() <= 2 || (index as usize) >= gradient.position.len() { + if gradient.len() <= 2 || (index as usize) >= gradient.len() { return; } gradient.remove(index as usize); - let new_active = (index as usize).min(gradient.position.len() - 1); + let new_active = (index as usize).min(gradient.len() - 1); self.active_marker_index = Some(new_active as u32); self.active_marker_is_midpoint = false; - if let Some(color) = gradient.color.get(new_active).copied() { + if let Some(color) = gradient.color(new_active) { self.adopt_color(color); self.snapshot_old(); } @@ -383,13 +382,13 @@ impl ColorPickerMessageHandler { } SpectrumInputUpdate::ResetMarker { index } => { let i = index as usize; - let count = gradient.position.len(); + let count = gradient.len(); 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 = gradient.position.get(i + 1).copied().unwrap_or(1.); + 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 }; let new_position = if (left..=right).contains(&natural) { natural } else { (left + right) / 2. }; let new_index = gradient.move_stop(i, new_position); @@ -430,7 +429,7 @@ impl ColorPickerMessageHandler { // For gradient editing, the markers' handle colors mirror their gradient stop colors let markers = gradient.iter().map(|stop| SpectrumMarker::new(stop.position, stop.midpoint, stop.color)).collect(); let mut row_widgets = vec![ - SpectrumInput::new(GradientUI::from(gradient)) + SpectrumInput::new(GradientStops::from(gradient)) .markers(markers) .active_marker_index(self.active_marker_index) .active_marker_is_midpoint(self.active_marker_is_midpoint) @@ -445,10 +444,10 @@ impl ColorPickerMessageHandler { if let Some(active) = self.active_marker_index { let active_index = active as usize; - let position_value = if self.active_marker_is_midpoint { - gradient.midpoint.get(active_index).copied().unwrap_or(0.) - } else { - gradient.position.get(active_index).copied().unwrap_or(0.) + 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), }; let is_midpoint = self.active_marker_is_midpoint; let captured_index = active; 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 12d17eb422..96abe66ff3 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -6,7 +6,7 @@ use derivative::*; use graphene_std::Color; use graphene_std::color::SRGBA8; use graphene_std::transform::ReferencePoint; -use graphene_std::vector::style::{FillChoiceUI, GradientUI}; +use graphene_std::vector::style::{FillChoiceUI, GradientStops}; use graphite_proc_macros::WidgetBuilder; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -531,7 +531,7 @@ pub struct SpectrumInput { // Content /// The colored gradient drawn behind the markers (display-only, caller-owned). #[widget_builder(constructor)] - pub track: GradientUI, + pub track: GradientStops, /// CSS `linear-gradient(...)` string for the track strip's `background-image`. Auto-populated from `track` at layout-send time. #[serde(rename = "trackCSS")] #[widget_builder(skip)] diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index dc02447cc3..ace7d9a928 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 @@ -35,6 +35,14 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, stops: Gradient, }, + GradientPositionsSet { + layer: LayerNodeIdentifier, + positions: Vec, + }, + GradientMidpointsSet { + layer: LayerNodeIdentifier, + midpoints: Vec, + }, GradientTransformSet { layer: LayerNodeIdentifier, transform: DAffine2, 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 443ad9fb24..7711987f45 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 @@ -61,6 +61,16 @@ impl MessageHandler> for modify_inputs.gradient_stops_set(stops); } } + GraphOperationMessage::GradientPositionsSet { layer, positions } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.gradient_positions_set(positions); + } + } + GraphOperationMessage::GradientMidpointsSet { layer, midpoints } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.gradient_midpoints_set(midpoints); + } + } GraphOperationMessage::GradientTransformSet { layer, transform } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.gradient_transform_set(transform); 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 5ae169e2a5..4872f6a812 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -552,6 +552,57 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Gradient(stops), false), false); } + /// Update the last 'Gradient Positions' node in the chain when one exists, so on-canvas stop drags stay live even + /// though that node would otherwise override the stops value's own placement. Never inserts one: the stops value + /// carries placement itself, and these setter nodes are user-authored procedural overrides. A wired input is + /// procedural authorship too, so it is likewise left untouched. + pub fn gradient_positions_set(&mut self, positions: Vec) { + let Some(output_layer) = self.get_output_layer() else { return }; + + let target_input = gradient_chain_target_input(output_layer, self.network_interface); + let identifier = graphene_std::math_nodes::gradient_positions::IDENTIFIER; + let Some(node_id) = self.existing_proto_node_id_at(&target_input, identifier, false) else { + return; + }; + + let current_input = self + .network_interface + .document_network() + .nodes + .get(&node_id) + .and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput)); + if !current_input.is_some_and(|input| input.as_value().is_some()) { + return; + } + + let input_connector = InputConnector::node(node_id, graphene_std::math_nodes::gradient_positions::PositionsInput); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64Array(positions), false), false); + } + + /// The 'Gradient Midpoints' counterpart of [`Self::gradient_positions_set`], likewise update-only. + pub fn gradient_midpoints_set(&mut self, midpoints: Vec) { + let Some(output_layer) = self.get_output_layer() else { return }; + + let target_input = gradient_chain_target_input(output_layer, self.network_interface); + let identifier = graphene_std::math_nodes::gradient_midpoints::IDENTIFIER; + let Some(node_id) = self.existing_proto_node_id_at(&target_input, identifier, false) else { + return; + }; + + let current_input = self + .network_interface + .document_network() + .nodes + .get(&node_id) + .and_then(|node| node.input(graphene_std::math_nodes::gradient_midpoints::MidpointsInput)); + if !current_input.is_some_and(|input| input.as_value().is_some()) { + return; + } + + let input_connector = InputConnector::node(node_id, graphene_std::math_nodes::gradient_midpoints::MidpointsInput); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64Array(midpoints), false), false); + } + /// Update the transform to map the unit gradient ((0,0), (1, 0)) to the geometry's local space. /// With multiple `Transform` nodes the last one (closest to the layer) is modified so the chain still composes to the target. /// With none, one is inserted unless the target is the identity. 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 8c0434fb6f..4b14fd9837 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -33,7 +33,7 @@ use graphene_std::vector::misc::BooleanOperation; use graphene_std::vector::misc::{ ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, }; -use graphene_std::vector::style::{FillChoiceUI, Gradient, GradientSpreadMethod, GradientType, GradientUI, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation}; +use graphene_std::vector::style::{FillChoiceUI, Gradient, GradientSpreadMethod, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::{NodeParameter, ParameterRef}; @@ -1158,7 +1158,7 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button: // Add the color input let widget_value = match &**tagged_value { TaggedValue::Color(color) => FillChoiceUI::Solid(SRGBA8::from(*color)), - TaggedValue::Gradient(stops) => FillChoiceUI::Gradient(GradientUI::from(stops)), + TaggedValue::Gradient(stops) => FillChoiceUI::Gradient(GradientStops::from(stops)), value if value.is_no_paint() => FillChoiceUI::None, x => { warn!("Color {x:?}"); @@ -1175,7 +1175,7 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button: FillChoiceUI::Gradient(gradient_ui) => TaggedValue::Gradient(Gradient::from(gradient_ui)), } } else if matches!(&**tagged_value, TaggedValue::Gradient(_)) { - |input| TaggedValue::Gradient(input.value.as_gradient().map(Gradient::from).unwrap_or_default()) + |input| TaggedValue::Gradient(input.value.as_gradient().map(Gradient::from).unwrap_or_else(Gradient::black_to_white)) } else { |input| TaggedValue::Color(input.value.as_solid().map(Color::from).unwrap_or(Color::TRANSPARENT)) }; @@ -1256,20 +1256,12 @@ pub fn query_assign_colors_randomize(node_id: NodeId, context: &NodePropertiesCo /// 2-stop black-to-white gradient track for spectrum sliders that map a value to a grayscale axis. fn bw_track() -> Gradient { - Gradient { - position: vec![0., 1.], - midpoint: vec![0.5, 0.5], - color: vec![Color::BLACK, Color::WHITE], - } + Gradient::from(vec![Color::BLACK, Color::WHITE]) } /// 3-stop black-to-color-to-white gradient track for spectrum sliders that map a value to a hue's full luminance range. fn color_track(color: Color) -> Gradient { - Gradient { - position: vec![0., 0.5, 1.], - midpoint: vec![0.5; 3], - color: vec![Color::BLACK, color, Color::WHITE], - } + Gradient::from(vec![Color::BLACK, color, Color::WHITE]) } pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { @@ -1300,11 +1292,8 @@ pub(crate) fn brightness_contrast_properties(node_id: NodeId, context: &mut Node let contrast_min = if use_classic_value { -100. } else { -50. }; let zero_position = -contrast_min / (100. - contrast_min); - let contrast_track = Gradient { - position: vec![0., zero_position, 1.], - midpoint: vec![0.5; 3], - color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), Color::BLACK, Color::from_rgbf32_unchecked(0.5, 0.5, 0.5)], - }; + let mut contrast_track = Gradient::from(vec![Color::MIDDLE_GRAY, Color::BLACK, Color::MIDDLE_GRAY]); + contrast_track.set_positions(&[0., zero_position, 1.]); let contrast = spectrum_slider_row( node_id, context, @@ -1333,7 +1322,7 @@ pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesCon // (parameter, marker handle color, default percentage for double-click reset) let input_range_params = [ (ShadowsInput.into(), Color::BLACK, 0.), - (MidtonesInput.into(), Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), 50.), + (MidtonesInput.into(), Color::MIDDLE_GRAY, 50.), (HighlightsInput.into(), Color::WHITE, 100.), ]; let output_range_params = [(OutputMinimumsInput.into(), Color::BLACK, 0.), (OutputMaximumsInput.into(), Color::WHITE, 100.)]; @@ -1397,7 +1386,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo // Build the shared spectrum widget (placed on the first non-exposed row) let spectrum_widget = (!spectrum_markers.is_empty()).then(|| { - SpectrumInput::new(GradientUI::from(&bw_track())) + SpectrumInput::new(GradientStops::from(&bw_track())) .markers(spectrum_markers) .show_midpoints(false) .allow_insert(false) @@ -1495,17 +1484,9 @@ pub(crate) fn hue_saturation_properties(node_id: NodeId, context: &mut NodePrope let saturated_current_hue = Color::from_hsva(marker_hue, 1., 1., 1.); // Hue: cyclic rainbow - let hue_track = Gradient { - position: vec![0., 1. / 6., 2. / 6., 3. / 6., 4. / 6., 5. / 6., 1.], - midpoint: vec![0.5; 7], - color: vec![Color::RED, Color::YELLOW, Color::GREEN, Color::CYAN, Color::BLUE, Color::MAGENTA, Color::RED], - }; + let hue_track = Gradient::from(vec![Color::RED, Color::YELLOW, Color::GREEN, Color::CYAN, Color::BLUE, Color::MAGENTA, Color::RED]); // Saturation: gray to the fully saturated current hue - let saturation_track = Gradient { - position: vec![0., 1.], - midpoint: vec![0.5, 0.5], - color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), saturated_current_hue], - }; + let saturation_track = Gradient::from(vec![Color::MIDDLE_GRAY, saturated_current_hue]); // Lightness: black to white let lightness_track = bw_track(); @@ -1577,7 +1558,7 @@ fn spectrum_slider_row( let position_to_value = move |position: f64| value_min + position * value_range; row.push( - SpectrumInput::new(GradientUI::from(&track)) + SpectrumInput::new(GradientStops::from(&track)) .markers(vec![SpectrumMarker::new(position, 0.5, handle_color)]) .show_midpoints(false) .allow_insert(false) @@ -1641,11 +1622,7 @@ pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodeProperties pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::vibrance::*; - let track = Gradient { - position: vec![0., 1.], - midpoint: vec![0.5, 0.5], - color: vec![Color::from_rgbf32_unchecked(0.5, 0.5, 0.5), Color::RED], - }; + let track = Gradient::from(vec![Color::MIDDLE_GRAY, Color::RED]); vec![spectrum_slider_row( node_id, context, @@ -2474,11 +2451,11 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte }; let backup_stops = match document_node.input_value(BackupGradientInput) { Some(TaggedValue::Gradient(stops)) => stops.clone(), - _ => Gradient::default(), + _ => Gradient::black_to_white(), }; (backup_color, backup_stops) } - Err(_) => (None, Gradient::default()), + Err(_) => (None, Gradient::black_to_white()), }; match &fill { @@ -2504,7 +2481,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte FillChoiceUI::None } } - ResolvedFill::Gradient { gradient: stops, .. } => FillChoiceUI::Gradient(GradientUI::from(stops)), + ResolvedFill::Gradient { gradient: stops, .. } => FillChoiceUI::Gradient(GradientStops::from(stops)), ResolvedFill::Other => FillChoiceUI::None, }; diff --git a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs index 91e0ad90ac..df01cca7a0 100644 --- a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs +++ b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs @@ -800,4 +800,13 @@ async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() { matches!(transform, Some(TaggedValue::DAffine2(_))), "the transform input should hold a matrix, but became {transform:?}" ); + + // The Sample Gradient parameter held the tuple-form stops, which parse as the stops value with even positions elided + let sample_gradient_node = &network.nodes[&graph_craft::document::NodeId(2)]; + let stops = sample_gradient_node.input_value(graphene_std::math_nodes::sample_gradient::GradientInput); + let Some(TaggedValue::Gradient(stops)) = stops else { + panic!("the legacy stops parameter should become a gradient stops value, but became {stops:?}"); + }; + assert_eq!(stops.len(), 2); + assert!(!stops.has_position_attribute(), "even legacy tuple positions should elide rather than materialize"); } diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 9e32c6aec2..27f4eb9219 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1814,7 +1814,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 9), old_inputs[4].clone(), network_path); } - // TODO: Eventually remove this migration document upgrade code + // TODO: Eventually remove this document upgrade code // A legacy "no color" on a plain color connector (`TaggedValue::no_paint()` restored by the deserializer) becomes a color, // since only paint connectors keep the no-paint choice { 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 a5dc3f034d..47947fd002 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -323,7 +323,43 @@ pub fn get_gradient_stops(layer: LayerNodeIdentifier, network_interface: &NodeNe let TaggedValue::Gradient(stops) = gradient_value_node.input(graphene_std::math_nodes::gradient_value::GradientInput)?.as_value()? else { return None; }; - Some(stops.clone()) + let mut stops = stops.clone(); + + // The chain's stop placement comes from the closest-to-layer 'Gradient Positions'/'Gradient Midpoints' nodes, + // matching the runtime where each later node overwrites the whole attribute + let target_input = gradient_chain_target_input(layer, network_interface); + let walk_from = network_interface.upstream_output_connector(&target_input, &[])?.node_id()?; + let positions_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_positions::IDENTIFIER); + let midpoints_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_midpoints::IDENTIFIER); + + let (mut positions_pending, mut midpoints_pending) = (true, true); + for node_id in network_interface + .upstream_flow_back_from_nodes(vec![walk_from], &[], FlowType::HorizontalFlow) + .take_while(|node_id| !network_interface.is_layer(node_id, &[])) + { + let Some(reference) = network_interface.reference(&node_id, &[]) else { continue }; + let node = network_interface.document_network().nodes.get(&node_id); + + if positions_pending && reference == positions_reference { + positions_pending = false; + if let Some(TaggedValue::F64Array(positions)) = node + .and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput)) + .and_then(|input| input.as_value()) + { + stops.set_positions(positions); + } + } else if midpoints_pending && reference == midpoints_reference { + midpoints_pending = false; + if let Some(TaggedValue::F64Array(midpoints)) = node + .and_then(|node| node.input(graphene_std::math_nodes::gradient_midpoints::MidpointsInput)) + .and_then(|input| input.as_value()) + { + stops.set_midpoints(midpoints); + } + } + } + + Some(stops) } /// Compute the transform from a gradient's local space to viewport space for the given layer. For a `List` @@ -629,6 +665,7 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn let TaggedValue::Gradient(stops) = fill_node.input(fill::FillInput)?.as_value()? else { return None; }; + let stops = stops.clone(); let gradient_type = match fill_node.input(fill::GradientTypeInput).and_then(|input| input.as_value()) { Some(&TaggedValue::GradientType(value)) => value, _ => GradientType::default(), @@ -646,7 +683,7 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn }; Some(FillNodeGradient { - stops: stops.clone(), + stops, gradient_type, spread_method, transform, diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 46ca7c5a8a..9e3e3f2c72 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -16,7 +16,7 @@ use glam::DMat2; use graph_craft::document::value::TaggedValue; use graphene_std::color::SRGBA8; use graphene_std::raster::color::Color; -use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientSpreadMethod, GradientStop, GradientType, GradientUI, build_transform_with_y_preservation}; +use graphene_std::vector::style::{FillChoice, FillChoiceUI, Gradient, GradientSpreadMethod, GradientStop, GradientStops, GradientType, build_transform_with_y_preservation}; #[derive(Default, ExtractField)] pub struct GradientTool { @@ -53,7 +53,7 @@ pub enum GradientToolMessage { CommitTransactionForColorStop, CloseStopColorPicker, UpdateStopColor { color: Color }, - UpdateStops { stops: GradientUI }, + UpdateStops { stops: GradientStops }, UpdateOptions { options: GradientOptionsUpdate }, } @@ -138,9 +138,9 @@ impl<'a> MessageHandler> for Grad ToolMessage::Gradient(GradientToolMessage::UpdateStopColor { color }) => { if let Some(stop_index) = self.data.color_picker_editing_color_stop && let Some(selected_gradient) = &mut self.data.selected_gradient - && stop_index < selected_gradient.gradient.color.len() + && stop_index < selected_gradient.gradient.len() { - selected_gradient.gradient.color[stop_index] = color; + selected_gradient.gradient.set_color(stop_index, color); selected_gradient.render_gradient(responses); responses.add(PropertiesPanelMessage::Refresh); } @@ -546,15 +546,15 @@ 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.position.len().saturating_sub(1) { - let left = stops.position[i]; - let right = stops.position[i + 1]; + 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_pos = left + stops.midpoint(i) * (right - left); let midpoint_viewport = start.lerp(end, midpoint_pos); if midpoint_viewport.distance_squared(mouse) < GRADIENT_MIDPOINT_DIAMOND_RADIUS.powi(2) { return None; @@ -675,7 +675,7 @@ impl SelectedGradient { GradientDragTarget::New => { self.appearance.transform = create_new_gradient_transform(self.gradient_space_transform.inverse().transform_point2(drag_start), local_mouse); } - GradientDragTarget::Stop(s) => { + GradientDragTarget::Stop(stop) => { let document_to_viewport = snap_data.document.metadata().document_to_viewport; let (viewport_start, viewport_end) = self.viewport_handle_positions(); @@ -722,16 +722,16 @@ 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 = s != 0 && self.gradient.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.); - let has_other_stop_at_one = s != last_index && self.gradient.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.); + 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 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.position[s] = clamped; - let new_position = self.gradient.position[s]; - let new_color = self.gradient.color[s]; + self.gradient.set_position(stop, clamped); + let new_position = clamped; + let new_color = self.gradient.color(stop).unwrap_or(Color::BLACK); self.gradient.sort(); if let Some(new_index) = self.gradient.iter().position(|s| s.position == new_position && s.color == new_color) { @@ -781,12 +781,12 @@ impl SelectedGradient { } // 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 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); - self.gradient.midpoint[midpoint_index] = midpoint_ratio; + self.gradient.set_midpoint(midpoint_index, midpoint_ratio); } } } @@ -819,9 +819,17 @@ impl SelectedGradient { } } -/// Send the four per-attribute graph operations that mirror the in-memory `Gradient` onto the chain feeding the layer. +/// Send the per-attribute graph operations that mirror the in-memory `Gradient` onto the chain feeding the layer. fn dispatch_gradient_chain_writes(layer: LayerNodeIdentifier, gradient: &Gradient, appearance: GradientAppearance, responses: &mut VecDeque) { responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: gradient.clone() }); + responses.add(GraphOperationMessage::GradientPositionsSet { + layer, + positions: gradient.nondefault_positions().unwrap_or_default(), + }); + responses.add(GraphOperationMessage::GradientMidpointsSet { + layer, + midpoints: gradient.nondefault_midpoints().unwrap_or_default(), + }); responses.add(GraphOperationMessage::GradientTransformSet { layer, transform: appearance.transform, @@ -933,12 +941,12 @@ impl Fsm for GradientToolFsmState { SRGBA8::from(color).to_css_hex() } - let start_hex = gradient.color.first().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); - let end_hex = gradient.color.last().map(|&c| color_to_hex(c)).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); + let start_hex = gradient.color(0).map(color_to_hex).unwrap_or(String::from(COLOR_OVERLAY_BLUE)); + 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.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.); - let last_at_end = gradient.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.); + 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.; overlay_context.line(start, end, None, None); @@ -1029,15 +1037,15 @@ 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.position.len().saturating_sub(1) { - let left = gradient.position[i]; - let right = gradient.position[i + 1]; + 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_pos = left + gradient.midpoint(i) * (right - left); let midpoint_viewport = start.lerp(end, midpoint_pos); let emphasis = if dragging == Some(GradientDragTarget::Midpoint(i)) { @@ -1098,9 +1106,9 @@ impl Fsm for GradientToolFsmState { // The gradient space transform has be recalculated as the saved transform in SelectedGradient may become stale by panning/zooming during the rendering of the overlay. let transform = gradient_space_transform(layer, document) * selected_gradient.appearance.transform; let gradient = &selected_gradient.gradient; - if stop_index < gradient.position.len() { - let color = gradient.color[stop_index]; - let position = gradient.position[stop_index]; + if stop_index < gradient.len() { + let color = gradient.color(stop_index).unwrap_or(Color::BLACK); + let position = gradient.position(stop_index); let start = transform.transform_point2(DVec2::ZERO); let end = transform.transform_point2(DVec2::X); let position = start.lerp(end, position).into(); @@ -1133,20 +1141,21 @@ impl Fsm for GradientToolFsmState { { match selected_gradient.dragging { GradientDragTarget::Midpoint(index) => { - selected_gradient.gradient.midpoint[index] = 0.5; + selected_gradient.gradient.reset_midpoint(index); selected_gradient.render_gradient(responses); responses.add(PropertiesPanelMessage::Refresh); } GradientDragTarget::Start | GradientDragTarget::End | GradientDragTarget::Stop(_) => { // Find the stop index from the drag target + let gradient = &selected_gradient.gradient; let stop_index = match selected_gradient.dragging { GradientDragTarget::Stop(i) => Some(i), - GradientDragTarget::Start => selected_gradient.gradient.position.iter().position(|&p| p.abs() < f64::EPSILON * 1000.), - GradientDragTarget::End => selected_gradient.gradient.position.iter().position(|&p| (1. - p).abs() < f64::EPSILON * 1000.), + 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.), _ => None, }; if let Some(stop_index) = stop_index - && stop_index < selected_gradient.gradient.color.len() + && stop_index < selected_gradient.gradient.len() { // Dismiss any existing color picker first if tool_data.color_picker_editing_color_stop.is_some() && tool_data.color_picker_transaction_open { @@ -1154,11 +1163,11 @@ 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); let (start, end) = selected_gradient.viewport_handle_positions(); let viewport_pos = start.lerp(end, stop_pos); let position = viewport_pos.into(); - let color = selected_gradient.gradient.color[stop_index]; + let color = selected_gradient.gradient.color(stop_index).unwrap_or(Color::BLACK); tool_data.color_picker_editing_color_stop = Some(stop_index); responses.add(FrontendMessage::UpdateGradientStopColorPickerPosition { color: color.into(), position }); } @@ -1197,7 +1206,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.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.) { + if !selected_gradient.gradient.is_empty() && selected_gradient.gradient.position(0).abs() < f64::EPSILON * 1000. { selected_gradient.gradient.remove(0); } else { responses.add(DocumentMessage::AbortTransaction); @@ -1206,7 +1215,7 @@ impl Fsm for GradientToolFsmState { } GradientDragTarget::End => { // Only delete if there's a real color stop at position ~1 (not the endpoint of the line which isn't itself a color stop) - if selected_gradient.gradient.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.) { + if !selected_gradient.gradient.is_empty() && (1. - selected_gradient.gradient.position(selected_gradient.gradient.len() - 1)).abs() < f64::EPSILON * 1000. { let _ = selected_gradient.gradient.pop(); } else { responses.add(DocumentMessage::AbortTransaction); @@ -1221,7 +1230,7 @@ impl Fsm for GradientToolFsmState { selected_gradient.gradient.remove(index); } GradientDragTarget::Midpoint(index) => { - selected_gradient.gradient.midpoint[index] = 0.5; + selected_gradient.gradient.reset_midpoint(index); selected_gradient.render_gradient(responses); responses.add(DocumentMessage::CommitTransaction); @@ -1238,7 +1247,7 @@ impl Fsm for GradientToolFsmState { } else if let Some(layer) = selected_gradient.layer { responses.add(GraphOperationMessage::FillColorSet { layer, - color: Some(selected_gradient.gradient.color[0]), + color: Some(selected_gradient.gradient.color(0).unwrap_or(Color::BLACK)), }); } responses.add(DocumentMessage::CommitTransaction); @@ -1247,17 +1256,17 @@ impl Fsm for GradientToolFsmState { } // Find the minimum and maximum positions - let min_position = selected_gradient.gradient.position.iter().copied().reduce(f64::min).expect("No min"); - let max_position = selected_gradient.gradient.position.iter().copied().reduce(f64::max).expect("No max"); + let positions = selected_gradient.gradient.positions(); + let min_position = positions.iter().copied().reduce(f64::min).expect("No min"); + let max_position = positions.iter().copied().reduce(f64::max).expect("No max"); let gradient_transform = selected_gradient.appearance.transform; let (local_start, local_end) = (gradient_transform.transform_point2(DVec2::ZERO), gradient_transform.transform_point2(DVec2::X)); selected_gradient.appearance.transform = build_transform_with_y_preservation(gradient_transform, local_start.lerp(local_end, min_position), local_start.lerp(local_end, max_position)); // Remap the positions - for position in selected_gradient.gradient.position.iter_mut() { - *position = (*position - min_position) / (max_position - min_position); - } + let remapped: Vec = positions.into_iter().map(|position| (position - min_position) / (max_position - min_position)).collect(); + selected_gradient.gradient.set_positions(&remapped); // Render the new gradient selected_gradient.render_gradient(responses); @@ -1336,19 +1345,19 @@ 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.position.len().saturating_sub(1) { - let left = gradient.position[i]; - let right = gradient.position[i + 1]; + 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_pos = left + gradient.midpoint(i) * (right - left); let midpoint_viewport = start.lerp(end, midpoint_pos); if midpoint_viewport.distance_squared(mouse) < midpoint_tolerance { - let resettable = midpoint_is_resettable(gradient.midpoint[i]); + let resettable = midpoint_is_resettable(gradient.midpoint(i)); drag_hint = Some(GradientDragHintState::Midpoint { resettable }); tool_data.selected_gradient = Some(SelectedGradient { @@ -1378,7 +1387,7 @@ impl Fsm for GradientToolFsmState { } } if let Some((_, index)) = best { - let stop_position = gradient.position[index]; + let stop_position = gradient.position(index); // 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. { @@ -1603,9 +1612,9 @@ impl Fsm for GradientToolFsmState { tool_data.snap_manager.cleanup(responses); // 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(|s| match s.dragging { - GradientDragTarget::Start => !s.gradient.position.first().is_some_and(|&p| p.abs() < f64::EPSILON * 1000.), - GradientDragTarget::End => !s.gradient.position.last().is_some_and(|&p| (1. - p).abs() < f64::EPSILON * 1000.), + 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., _ => false, }) { tool_data.selected_gradient = None; @@ -1768,18 +1777,18 @@ 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.position.len().saturating_sub(1) { - let left = gradient.position[i]; - let right = gradient.position[i + 1]; + 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); + let midpoint_position = left + gradient.midpoint(i) * (right - left); 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(i)); return GradientHoverTarget::Midpoint { resettable }; } } @@ -1820,7 +1829,7 @@ fn compute_selected_target(tool_data: &GradientToolData) -> GradientSelectedTarg match selected_gradient.dragging { GradientDragTarget::Stop(_) | GradientDragTarget::Start | GradientDragTarget::End => GradientSelectedTarget::Stop, GradientDragTarget::Midpoint(i) => { - let resettable = selected_gradient.gradient.midpoint.get(i).is_some_and(|&midpoint_value| midpoint_is_resettable(midpoint_value)); + let resettable = i < selected_gradient.gradient.len() && midpoint_is_resettable(selected_gradient.gradient.midpoint(i)); GradientSelectedTarget::Midpoint { resettable } } GradientDragTarget::New => GradientSelectedTarget::None, @@ -2011,6 +2020,7 @@ mod test_gradient { use crate::messages::tool::common_functionality::graph_modification_utils::get_upstream_gradient_value_node_id; pub use crate::test_utils::test_prelude::*; use glam::DAffine2; + use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; use graphene_std::color::SRGBA8; use graphene_std::vector::style::{GradientSpreadMethod, build_transform_with_y_preservation}; @@ -2164,6 +2174,28 @@ mod test_gradient { layer } + // Locks the parameter-level `#[default(Color::BLACK, Color::WHITE)]` machinery, since `Gradient::default()` is the empty list + #[tokio::test] + async fn gradient_value_node_defaults_to_black_to_white() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let node_id = editor.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER)).await; + + let document = editor.active_document(); + let stops = document + .network_interface + .document_network() + .nodes + .get(&node_id) + .and_then(|node| node.input(graphene_std::math_nodes::gradient_value::GradientInput)) + .and_then(|input| input.as_value()) + .cloned(); + let Some(TaggedValue::Gradient(stops)) = stops else { + panic!("expected a gradient default, got {stops:?}") + }; + assert_eq!(stops.positions(), 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 { editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; let document = editor.active_document(); @@ -2441,7 +2473,7 @@ mod test_gradient { let positions: Vec = stops.iter().map(|stop| stop.position).collect(); assert_stops_at_positions(&positions, &[0., 0.25, 1.], 0.1); - let middle_color = SRGBA8::from(stops.color[1]); + let middle_color = SRGBA8::from(stops.color(1).unwrap()); // Simulate dragging the middle stop to position 0.8 let click_position = DVec2::new(25., 0.); @@ -2482,9 +2514,9 @@ mod test_gradient { assert_stops_at_positions(&updated_positions, &[0., 0.8, 1.], 0.1); // Colors should maintain their associations with the stop points - assert_eq!(SRGBA8::from(updated_stops.color[0]), SRGBA8::from(Color::GREEN)); - assert_eq!(SRGBA8::from(updated_stops.color[1]), middle_color); - assert_eq!(SRGBA8::from(updated_stops.color[2]), SRGBA8::from(Color::BLUE)); + assert_eq!(SRGBA8::from(updated_stops.color(0).unwrap()), SRGBA8::from(Color::GREEN)); + assert_eq!(SRGBA8::from(updated_stops.color(1).unwrap()), middle_color); + assert_eq!(SRGBA8::from(updated_stops.color(2).unwrap()), SRGBA8::from(Color::BLUE)); } #[tokio::test] @@ -2783,10 +2815,10 @@ 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.position, &[0., 0.5, 1.], 1e-10); - assert_eq!(SRGBA8::from(updated.stops.color[0]), SRGBA8::from(Color::RED), "First stop color should be preserved"); - assert_eq!(SRGBA8::from(updated.stops.color[1]), SRGBA8::from(Color::GREEN), "Middle stop color should be preserved"); - assert_eq!(SRGBA8::from(updated.stops.color[2]), SRGBA8::from(Color::BLUE), "Last stop color should be preserved"); + assert_stops_at_positions(&updated.stops.positions(), &[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"); } // When the gradient chain feeds a 'Fill' node's secondary input it's an unencapsulated side-branch (no layer @@ -2882,4 +2914,93 @@ mod test_gradient { "the feeder's branch should shift one chain-width left to make room" ); } + + #[tokio::test] + async fn chain_stop_placement_composes_through_setter_nodes() { + use crate::messages::tool::common_functionality::graph_modification_utils::get_gradient_stops; + + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let layer = create_gradient_list_layer(&mut editor).await; + + let count_nodes = |editor: &mut EditorTestUtils, reference: &DefinitionIdentifier| { + let document = editor.active_document(); + let network_interface = &document.network_interface; + network_interface + .document_network() + .nodes + .keys() + .filter(|&node_id| network_interface.reference(node_id, &[]).as_ref() == Some(reference)) + .count() + }; + let positions_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_positions::IDENTIFIER); + + // Setter ops are update-only: without a user-authored setter node they are no-ops, since the stops value carries placement + editor.handle_message(GraphOperationMessage::GradientPositionsSet { layer, positions: vec![0., 0.25] }).await; + assert_eq!(count_nodes(&mut editor, &positions_reference), 0, "the op must not insert a Gradient Positions node"); + + // Wire Gradient Positions and Gradient Midpoints nodes into the chain like a user would + let gradient_value_id = { + let document = editor.active_document(); + get_upstream_gradient_value_node_id(layer, &document.network_interface).expect("Gradient Value node should exist") + }; + let positions_node_id = editor + .create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_positions::IDENTIFIER)) + .await; + let midpoints_node_id = editor + .create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_midpoints::IDENTIFIER)) + .await; + editor + .handle_message(NodeGraphMessage::CreateWire { + output_connector: OutputConnector::primary_output(gradient_value_id), + input_connector: InputConnector::node_at_index(positions_node_id, 0), + }) + .await; + editor + .handle_message(NodeGraphMessage::CreateWire { + output_connector: OutputConnector::primary_output(positions_node_id), + input_connector: InputConnector::node_at_index(midpoints_node_id, 0), + }) + .await; + editor + .handle_message(NodeGraphMessage::CreateWire { + output_connector: OutputConnector::primary_output(midpoints_node_id), + input_connector: InputConnector::layer_secondary_input(layer.to_node()), + }) + .await; + + // Now the ops update the existing nodes, and the chain read composes their attributes over the stops value + editor.handle_message(GraphOperationMessage::GradientPositionsSet { layer, positions: vec![0., 0.25] }).await; + 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_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.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 + let number_node_id = editor.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::number_value::IDENTIFIER)).await; + editor + .handle_message(NodeGraphMessage::CreateWire { + output_connector: OutputConnector::primary_output(number_node_id), + input_connector: InputConnector::node(positions_node_id, graphene_std::math_nodes::gradient_positions::PositionsInput), + }) + .await; + editor.handle_message(GraphOperationMessage::GradientPositionsSet { layer, positions: vec![0., 0.5] }).await; + let document = editor.active_document(); + let positions_input = document + .network_interface + .document_network() + .nodes + .get(&positions_node_id) + .and_then(|node| node.input(graphene_std::math_nodes::gradient_positions::PositionsInput)) + .expect("the positions input should exist"); + assert!(matches!(positions_input, NodeInput::Node { .. }), "the wired positions input must survive the baked write"); + } } diff --git a/frontend/src/utility-functions/colors.ts b/frontend/src/utility-functions/colors.ts index b2db99eb14..67b27602b9 100644 --- a/frontend/src/utility-functions/colors.ts +++ b/frontend/src/utility-functions/colors.ts @@ -1,4 +1,4 @@ -import type { FillChoiceUI, GradientUI, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; +import type { FillChoiceUI, GradientStops, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; // Channels can have any range (0-1, 0-255, 0-100, 0-360) in the context they are being used in, these are just containers for the numbers export type HSV = { h: number; s: number; v: number }; @@ -182,8 +182,8 @@ export function contrastingOutlineFactor(value: FillChoiceUI, proximityColor: st // GRADIENT UTILITY FUNCTIONS -export function isGradientUI(value: unknown): value is GradientUI { - return typeof value === "object" && value !== null && "position" in value && "midpoint" in value && "color" in value; +export function isGradientStops(value: unknown): value is GradientStops { + return typeof value === "object" && value !== null && "color" in value && Array.isArray(value.color); } // FILL CHOICE UTILITY FUNCTIONS @@ -193,7 +193,7 @@ export function fillChoiceUIColor(value: FillChoiceUI): SRGBA8 | undefined { return undefined; } -export function fillChoiceUIGradient(value: FillChoiceUI): GradientUI | undefined { +export function fillChoiceUIGradient(value: FillChoiceUI): GradientStops | undefined { if (typeof value === "object" && "Gradient" in value) return value.Gradient; return undefined; } @@ -201,6 +201,6 @@ export function fillChoiceUIGradient(value: FillChoiceUI): GradientUI | undefine export function parseFillChoiceUI(value: unknown): FillChoiceUI { if (value === "None" || value === undefined || value === null) return "None"; if (typeof value === "object" && value !== null && "Solid" in value && isSRgba8(value.Solid)) return { Solid: value.Solid }; - if (typeof value === "object" && value !== null && "Gradient" in value && isGradientUI(value.Gradient)) return { Gradient: value.Gradient }; + if (typeof value === "object" && value !== null && "Gradient" in value && isGradientStops(value.Gradient)) return { Gradient: value.Gradient }; return "None"; } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index f74914775b..3b08690fa9 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -2,7 +2,7 @@ use super::DocumentNode; use crate::application_io::PlatformEditorApi; use crate::application_io::resource::Resource; use crate::proto::Any as DAny; -use brush_nodes::brush_stroke::{BrushStroke, BrushTrace}; +use brush_nodes::brush_stroke::BrushStroke; use core_types::color::SRGBA8; use core_types::context::Context; use core_types::gpoll::GPoll; @@ -29,7 +29,6 @@ use std::hash::Hash; use std::str::FromStr; pub use std::sync::Arc; use text_nodes::Font; -use text_nodes::vector_types::GradientStop; use vector::VectorModification; pub struct TaggedValueTypeError; @@ -63,25 +62,24 @@ macro_rules! tagged_value { /// Example: `TaggedValue::TypeDefault(descriptor!(String))` stores the type `String` but no specific string value. TypeDefault(TypeDescriptor), /// Stored compactly as a `Vec`, materializes as `List` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. - #[serde(deserialize_with = "core_types::misc::migrate_to_f64_array")] // TODO: Eventually remove this migration document upgrade code + #[serde(deserialize_with = "core_types::misc::migrate_to_f64_array")] // TODO: Eventually remove this document upgrade code #[serde(alias = "F64Table", alias = "VecF64", alias = "VecF32", alias = "F64Array4")] F64Array(Vec), - /// Stored compactly as a `Vec` of dash lengths, materializes as an `Item` at runtime via `to_dynany`/`to_any`. + /// Stored compactly as a `Vec` of dash lengths, materializes as a `DashPattern` at runtime via `to_dynany`/`to_any`. DashPattern(Vec), - /// Stored compactly as a `Vec` of corner values, materializes as an `Item` at runtime via `to_dynany`/`to_any`. + /// Stored compactly as a `Vec` of corner values, materializes as a `BoxCorners` at runtime via `to_dynany`/`to_any`. BoxCorners(Vec), /// A plain, always-present color. Aliases recover legacy on-disk shapes; a legacy `null` payload (the old "no color") /// is routed to [`TaggedValue::no_paint`] by `deserialize_tagged_value_with_legacy_migration`. - #[serde(deserialize_with = "core_types::misc::migrate_to_color")] // TODO: Eventually remove this migration document upgrade code + #[serde(deserialize_with = "core_types::misc::migrate_to_color")] // TODO: Eventually remove this document upgrade code #[serde(alias = "ColorTable", alias = "OptionalColor", alias = "ColorNotInTable")] Color(Color), - /// Stored compactly as a `Gradient`, materializes as a single-row `List` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. + /// Stored as the `{ color, position?, midpoint? }` stops struct, materializes as a single-row `List` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. /// (Old documents that stored a full `Gradient` struct under this same `"Gradient"` tag are routed to `LegacyGradient` by `deserialize_tagged_value_with_legacy_migration`.) - #[serde(deserialize_with = "graphic_types::vector_types::gradient::migrate_to_gradient")] // TODO: Eventually remove this migration document upgrade code #[serde(alias = "GradientTable", alias = "GradientPositions", alias = "GradientStops")] Gradient(Gradient), /// Stored compactly as a `Vec`, materializes as `List` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes. - #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this migration document upgrade code + #[serde(deserialize_with = "brush_nodes::migrations::migrate_to_brush_strokes")] // TODO: Eventually remove this document upgrade code #[serde(alias = "BrushStrokeTable")] BrushStrokes(Vec), // ======================= @@ -413,15 +411,10 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::F64Array(*downcast(input).unwrap())), x if x == TypeId::of::>() => Ok(TaggedValue::F64Array(downcast::>(input).unwrap().iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::DashPattern(downcast::(input).unwrap().0.iter_element_values().copied().collect())), - x if x == TypeId::of::>() => Ok(TaggedValue::DashPattern(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::BoxCorners(downcast::(input).unwrap().0.iter_element_values().copied().collect())), - x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(downcast::>(input).unwrap().into_element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::Color(*downcast(input).unwrap())), - x if x == TypeId::of::>() => Ok(TaggedValue::Color(downcast::>(input).unwrap().into_element())), x if x == TypeId::of::() => Ok(TaggedValue::Gradient(*downcast(input).unwrap())), - x if x == TypeId::of::>() => Ok(TaggedValue::Gradient(downcast::>(input).unwrap().into_element())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(*downcast(input).unwrap())), - x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(downcast::>(input).unwrap().into_element().0.iter_element_values().cloned().collect())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -448,15 +441,10 @@ macro_rules! tagged_value { x if x == TypeId::of::>() => Ok(TaggedValue::F64Array(input.downcast_ref::>().unwrap().clone())), x if x == TypeId::of::>() => Ok(TaggedValue::F64Array(input.downcast_ref::>().unwrap().iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::DashPattern(input.downcast_ref::().unwrap().0.iter_element_values().copied().collect())), - x if x == TypeId::of::>() => Ok(TaggedValue::DashPattern(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::BoxCorners(input.downcast_ref::().unwrap().0.iter_element_values().copied().collect())), - x if x == TypeId::of::>() => Ok(TaggedValue::BoxCorners(input.downcast_ref::>().unwrap().element().0.iter_element_values().copied().collect())), x if x == TypeId::of::() => Ok(TaggedValue::Color(*input.downcast_ref::().unwrap())), - x if x == TypeId::of::>() => Ok(TaggedValue::Color(*input.downcast_ref::>().unwrap().element())), x if x == TypeId::of::() => Ok(TaggedValue::Gradient(input.downcast_ref::().unwrap().clone())), - x if x == TypeId::of::>() => Ok(TaggedValue::Gradient(input.downcast_ref::>().unwrap().element().clone())), x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().clone())), - x if x == TypeId::of::>() => Ok(TaggedValue::BrushStrokes(input.downcast_ref::>().unwrap().element().0.iter_element_values().cloned().collect())), // ======================= // AUTO-GENERATED VARIANTS // ======================= @@ -698,29 +686,13 @@ impl TaggedValue { fn to_gradient(input: &str) -> Option { // String syntax: (e.g. "000000ff, ff0000ff") let stops = input.split(',').filter_map(|s| to_color(s.trim())).collect::>(); - if stops.len() == 1 { - Some(Gradient::new(vec![ - GradientStop { - position: 0., - midpoint: 0.5, - color: stops[0], - }, - GradientStop { - position: 1., - midpoint: 0.5, - color: stops[0], - }, - ])) - } else if stops.len() >= 2 { - let step = 1. / (stops.len() - 1) as f64; - Some(Gradient::new(stops.into_iter().enumerate().map(|(i, color)| GradientStop { - position: i as f64 * step, - midpoint: 0.5, - color, - }))) - } else { - log::error!("Invalid default value gradient string: {input}"); - None + match stops.len() { + 0 => { + log::error!("Invalid default value gradient string: {input}"); + None + } + 1 => Some(Gradient::from(vec![stops[0], stops[0]])), + _ => Some(Gradient::from(stops)), } } @@ -823,7 +795,7 @@ impl TaggedValue { /// - `FillChoice` → `TaggedValue::Color` (solid), `TaggedValue::Gradient` (gradient), or `TaggedValue::no_paint()` (none) /// /// All other tags (including ones with the modern shape) fall through to the standard derived `Deserialize` for `TaggedValue`. -// TODO: Eventually remove this migration document upgrade code +// TODO: Eventually remove this document upgrade code #[cfg(feature = "loading")] pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { use serde::Deserialize; @@ -882,11 +854,30 @@ pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserialize } return Ok(MemoHash::new(TaggedValue::no_paint())); } - // The `Gradient` tag was reused: it used to carry a full `Gradient` struct (now `LegacyGradient`), and now carries an `Option`. - // Disambiguate by payload shape: a Gradient struct has `start`/`end` keys; a `Gradient` has none of those (it has `position`/`midpoint`/`color`). - "Gradient" if content.as_object().is_some_and(|c| c.contains_key("start") && c.contains_key("end")) => { - let gradient: graphic_types::migrations::legacy::LegacyGradient = serde_json::from_value(content.clone()).map_err(serde::de::Error::custom)?; - return Ok(MemoHash::new(TaggedValue::LegacyGradient(gradient))); + // The gradient tags carried several shapes over time, disambiguated here: the ancient full struct (`start`/`end` keys) becomes `LegacyGradient`, + // while the current stops struct, the old tuple list, and the legacy one-element table wrapper all parse as the stops value directly + "Gradient" | "GradientTable" | "GradientPositions" | "GradientStops" => { + let table_element = content + .as_object() + .and_then(|c| c.get("element").or_else(|| c.get("instance")).or_else(|| c.get("instances"))) + .and_then(|element| element.as_array()); + + // An empty legacy table wrapper carries no gradient, degrading to the default rather than failing the document load + if let Some(array) = table_element + && array.is_empty() + { + return Ok(MemoHash::new(TaggedValue::Gradient(Gradient::default()))); + } + + let payload = table_element.and_then(|array| array.first()).unwrap_or(content); + + if payload.as_object().is_some_and(|c| c.contains_key("start") && c.contains_key("end")) { + let gradient: graphic_types::migrations::legacy::LegacyGradient = serde_json::from_value(payload.clone()).map_err(serde::de::Error::custom)?; + return Ok(MemoHash::new(TaggedValue::LegacyGradient(gradient))); + } + + let gradient: Gradient = serde_json::from_value(payload.clone()).map_err(serde::de::Error::custom)?; + return Ok(MemoHash::new(TaggedValue::Gradient(gradient))); } _ => {} } diff --git a/node-graph/libraries/core-types/src/attribute.rs b/node-graph/libraries/core-types/src/attribute.rs index d531f72a88..05fdab1cd4 100644 --- a/node-graph/libraries/core-types/src/attribute.rs +++ b/node-graph/libraries/core-types/src/attribute.rs @@ -699,6 +699,12 @@ attribute! { /// glyph origin so it survives 'Index Elements' filtering. The Text tool reads this to /// position its drag cage. pub EditorTextFrame("editor:text_frame"): DAffine2; + /// Gradient stop's position from 0 to 1 along the gradient, on the `List` inside a `Gradient`. + /// When the attribute is absent, stops distribute evenly across the 0 to 1 range. + pub Position("position"): f64; + /// Gradient stop's midpoint, a factor from 0 to 1 across the distance to the next stop, + /// on the `List` inside a `Gradient`. The final stop's midpoint is ignored. + pub Midpoint("midpoint"): f64 = 0.5; /// Byte offset where a regex match begins ('Regex Find All' and 'Regex Capture' text nodes). pub Start("start"): u64; /// Byte offset where a regex match ends ('Regex Find All' and 'Regex Capture' text nodes). diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 790690ca9d..1e5bd838f6 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -22,6 +22,8 @@ pub const ATTR_OPACITY_FILL: &str = crate::attribute::OpacityFill::NAME; pub const ATTR_CLIPPING_MASK: &str = crate::attribute::ClippingMask::NAME; pub const ATTR_EDITOR_LAYER_PATH: &str = crate::attribute::EditorLayerPath::NAME; pub const ATTR_EDITOR_TEXT_FRAME: &str = crate::attribute::EditorTextFrame::NAME; +pub const ATTR_POSITION: &str = crate::attribute::Position::NAME; +pub const ATTR_MIDPOINT: &str = crate::attribute::Midpoint::NAME; pub const ATTR_START: &str = crate::attribute::Start::NAME; pub const ATTR_END: &str = crate::attribute::End::NAME; pub const ATTR_NAME: &str = crate::attribute::Name::NAME; diff --git a/node-graph/libraries/core-types/src/misc.rs b/node-graph/libraries/core-types/src/misc.rs index ca85291508..a83994d9cd 100644 --- a/node-graph/libraries/core-types/src/misc.rs +++ b/node-graph/libraries/core-types/src/misc.rs @@ -89,7 +89,7 @@ struct LegacyTable { element: Vec, } -// TODO: Eventually remove this migration document upgrade code +// TODO: Eventually remove this document upgrade code pub fn migrate_to_color<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { use no_std_types::color::Color; use serde::Deserialize; @@ -107,7 +107,7 @@ pub fn migrate_to_color<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Re }) } -// TODO: Eventually remove this migration document upgrade code +// TODO: Eventually remove this document upgrade code pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { use serde::Deserialize; diff --git a/node-graph/libraries/core-types/src/registry.rs b/node-graph/libraries/core-types/src/registry.rs index bcd268954a..64747bd92d 100644 --- a/node-graph/libraries/core-types/src/registry.rs +++ b/node-graph/libraries/core-types/src/registry.rs @@ -1,7 +1,7 @@ use crate::concrete; use crate::context::{Context, ContextImpl}; use crate::node::Node; -use crate::{ContextFeature, ProtoNodeIdentifier, Type, WasmNotSend, WasmNotSync}; +use crate::{Color, ContextFeature, ProtoNodeIdentifier, Type, WasmNotSend, WasmNotSync}; use dyn_any::DynAny; use graphene_hash::CacheHash; pub use no_std_types::registry::types; @@ -35,6 +35,8 @@ pub struct FieldMetadata { pub exposed: bool, pub widget_override: RegistryWidgetOverride, pub value_source: RegistryValueSource, + /// The default expression's colors, resolved by the macro when the expression consists solely of `Color::*` constants. + pub default_colors: Option<&'static [Color]>, pub default_type: Option, /// The slider's suggested extent, from `#[soft(a..b)]`. Typed values may exceed it. pub number_soft_min: Option, diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index d5e00f2619..0dda0cbe43 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -17,7 +17,7 @@ pub mod migrations { use crate::Vector; // Storing legacy structs that are only used in document migration. - // TODO: Eventually remove this migration document upgrade code + // TODO: Eventually remove this document upgrade code pub mod legacy { use core_types::Color; use dyn_any::DynAny; @@ -116,7 +116,7 @@ pub mod migrations { } } - // TODO: Eventually remove this migration document upgrade code + // TODO: Eventually remove this document upgrade code /// Returns the first `Vector` recovered from any of the legacy on-disk shapes (the legacy `VectorData` flat struct, a single `Vector`, or any of the historical `List` variants). pub fn migrate_to_optional_vector<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { use serde::Deserialize; diff --git a/node-graph/libraries/no-std-types/src/color/color_types.rs b/node-graph/libraries/no-std-types/src/color/color_types.rs index 56ba5d958e..02bd9081a9 100644 --- a/node-graph/libraries/no-std-types/src/color/color_types.rs +++ b/node-graph/libraries/no-std-types/src/color/color_types.rs @@ -273,7 +273,7 @@ pub struct Color { // `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper. impl Eq for Color {} -// TODO: Eventually remove this migration document upgrade code +// TODO: Eventually remove this document upgrade code #[cfg(feature = "std")] impl serde::Serialize for Color { fn serialize(&self, serializer: S) -> Result { @@ -290,7 +290,7 @@ impl serde::Serialize for Color { } } -// TODO: Eventually remove this migration document upgrade code +// TODO: Eventually remove this document upgrade code #[cfg(feature = "std")] impl<'de> serde::Deserialize<'de> for Color { fn deserialize>(deserializer: D) -> Result { @@ -413,6 +413,7 @@ impl Color { pub const YELLOW: Color = Color::from_rgbf32_unchecked(1., 1., 0.); pub const CYAN: Color = Color::from_rgbf32_unchecked(0., 1., 1.); pub const MAGENTA: Color = Color::from_rgbf32_unchecked(1., 0., 1.); + pub const MIDDLE_GRAY: Color = Color::from_rgbf32_unchecked(0.5, 0.5, 0.5); pub const TRANSPARENT: Color = Self { red: 0., green: 0., diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 62caad4d3c..d6074c0e89 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -127,6 +127,11 @@ pub fn render_gradient_paint stop.push_str(" />") } + // A gradient with no stops paints as solid black, matching `Gradient::evaluate` (a stopless def would otherwise render as no paint per the SVG spec) + if stop.is_empty() { + stop.push_str(r##""##); + } + // Need to cancel out the element's transform as it is already applied to the path itself. let element_transform_inverse = if transform_is_invertible(element_transform) { element_transform.inverse() diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 03c64d5f2d..9b3438c3d4 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -400,6 +400,31 @@ pub(crate) fn gradient_placement(transform: DAffine2, gradient_type: GradientTyp } } +/// Converts a gradient's renderer samples to peniko color stops, duplicating an off-zero first stop at position 0 since Vello ignores the first stop's position and always treats it as 0. +fn peniko_color_stops(gradient: &Gradient) -> peniko::ColorStops { + let mut peniko_stops = peniko::ColorStops::new(); + + for (position, color, _) in gradient.interpolated_samples() { + let color = peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()); + + if peniko_stops.is_empty() && position > 0. { + peniko_stops.push(peniko::ColorStop { offset: 0., color }); + } + + peniko_stops.push(peniko::ColorStop { offset: position as f32, color }); + } + + // A gradient with no stops paints as solid black, matching `Gradient::evaluate` + if peniko_stops.is_empty() { + peniko_stops.push(peniko::ColorStop { + offset: 0., + color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(Color::BLACK).to_peniko_color()), + }); + } + + peniko_stops +} + fn create_peniko_gradient_brush>(gradient_list: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; @@ -407,13 +432,7 @@ fn create_peniko_gradient_brush>(gradient_list let gradient_transform: DAffine2 = gradient_list.attr::(0); let spread_method: GradientSpreadMethod = gradient_list.attr::(0); - let mut peniko_stops = peniko::ColorStops::new(); - for (position, color, _) in stops.interpolated_samples() { - peniko_stops.push(peniko::ColorStop { - offset: position as f32, - color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), - }); - } + let peniko_stops = peniko_color_stops(stops); // The unit gradient is placed by the desheared frame so a non-uniform transform produces the intended ellipse let (start, end, gradient_to_device) = (DVec2::ZERO, DVec2::X, gradient_placement(multiplied_transform * gradient_transform, gradient_type)); @@ -2403,13 +2422,7 @@ fn render_gradient_vello>(source: &S, scene: & let blend_mode = blend_mode_attr.to_peniko(); let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let mut stops: peniko::ColorStops = peniko::ColorStops::new(); - for (position, color, _) in gradient.interpolated_samples() { - stops.push(peniko::ColorStop { - offset: position as f32, - color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), - }) - } + let stops = peniko_color_stops(gradient); let extend = match spread_method { GradientSpreadMethod::Pad => peniko::Extend::Pad, diff --git a/node-graph/libraries/vector-types/Cargo.toml b/node-graph/libraries/vector-types/Cargo.toml index c3c376d6e5..603648cdf5 100644 --- a/node-graph/libraries/vector-types/Cargo.toml +++ b/node-graph/libraries/vector-types/Cargo.toml @@ -36,3 +36,7 @@ serde = { workspace = true, optional = true } tsify = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } fixedbitset = "0.5.7" + +[dev-dependencies] +# Workspace dependencies +serde_json = { workspace = true } diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 0a9807d0aa..1bf4c4d680 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -1,5 +1,6 @@ use core_types::Color; use core_types::color::SRGBA8; +use core_types::list::{ATTR_MIDPOINT, ATTR_POSITION, Item, List}; use core_types::render_complexity::RenderComplexity; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; @@ -14,116 +15,127 @@ pub enum GradientType { Radial, } -// TODO: Someday we could switch this to a Box[T] to avoid over-allocation -/// A list of colors (linear, unassociated alpha) associated with positions (in the range 0 to 1) along a gradient. -/// -/// Not exposed via Tsify; use [`GradientUI`] at the JS boundary. -#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] -#[cfg_attr(feature = "serde", derive(serde::Serialize))] -pub struct Gradient { - /// The position of this stop, a factor from 0-1 along the length of the full gradient. - pub position: Vec, - /// The midpoint to the right of this stop, a factor from 0-1 along the distance to the next stop. The final stop's midpoint is ignored. - pub midpoint: Vec, - /// The color at this stop. - pub color: Vec, -} +/// A gradient's stops: a list of colors (linear, unassociated alpha) whose optional `position` and `midpoint` +/// attributes place each stop along the 0 to 1 range. Stops lacking the `position` attribute distribute evenly, +/// and stops lacking the `midpoint` attribute interpolate linearly (`0.5`). +#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +pub struct Gradient(List); -/// JS-boundary version of [`Gradient`] where stop colors are [`SRGBA8`] byte triples instead of linear-light [`Color`]. -#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] -#[derive(Debug, Clone, PartialEq, Default, DynAny)] +/// A gradient's per-stop parallel arrays, generic over color format: `GradientStops` is the document serialization +/// of `TaggedValue::Gradient`, while `GradientStops` is the JS-boundary shape used by the color picker UI. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Debug, Clone, PartialEq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct GradientUI { - pub position: Vec, - pub midpoint: Vec, - pub color: Vec, +pub struct GradientStops { + pub color: Vec, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + #[cfg_attr(feature = "wasm", tsify(optional))] + pub position: Option>, + #[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))] + #[cfg_attr(feature = "wasm", tsify(optional))] + pub midpoint: Option>, } -impl From<&Gradient> for GradientUI { - fn from(s: &Gradient) -> Self { +unsafe impl dyn_any::StaticType for GradientStops { + type Static = GradientStops; +} + +impl From<&Gradient> for GradientStops { + fn from(gradient: &Gradient) -> Self { Self { - position: s.position.clone(), - midpoint: s.midpoint.clone(), - color: s.color.iter().map(|c| SRGBA8::from(*c)).collect(), + position: gradient.position_attribute(), + midpoint: gradient.midpoint_attribute(), + color: gradient.0.iter_element_values().copied().collect(), } } } -impl From<&GradientUI> for Gradient { - fn from(s: &GradientUI) -> Self { +impl From<&Gradient> for GradientStops { + fn from(gradient: &Gradient) -> Self { Self { - position: s.position.clone(), - midpoint: s.midpoint.clone(), - color: s.color.iter().map(|c| Color::from(*c)).collect(), + position: gradient.position_attribute(), + midpoint: gradient.midpoint_attribute(), + color: gradient.0.iter_element_values().map(|&color| SRGBA8::from(color)).collect(), } } } -impl GradientUI { +// The document path: faithful (no elision) so serialization stays a bijection under round-trip checks +impl From> for Gradient { + fn from(stops: GradientStops) -> Self { + let mut gradient = Gradient::from(stops.color); + if let Some(position) = &stops.position { + gradient.set_positions(position); + } + if let Some(midpoint) = &stops.midpoint { + gradient.set_midpoints(midpoint); + } + gradient + } +} + +// Color picker round-trip: attributes that merely restate the defaults are elided to keep the canonical absence-as-default form +impl From<&GradientStops> for Gradient { + fn from(stops: &GradientStops) -> Self { + let mut gradient = Gradient::from(stops.color.iter().map(|&color| Color::from(color)).collect::>()); + if let Some(position) = &stops.position { + gradient.set_positions(position); + } + 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) -> String { - if self.position.len() <= 1 { - let hex = self.color.first().map(|c| c.to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string()); - return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)"); - } - // Sample via the midpoint-aware subdivision used for SVG/Vello stops so browser interpolation matches - let stops: Gradient = self.into(); - let pieces = stops - .interpolated_samples() - .into_iter() - .map(|(position, color, _)| { - let percent = ((position * 100.) * 1e2).round() / 1e2; - let hex = SRGBA8::from(color).to_rgba_hex(); - format!("#{hex} {percent}%") - }) - .collect::>() - .join(", "); - format!("linear-gradient(to right, {pieces})") + Gradient::from(self).to_css_linear_gradient() } } -// TODO: Eventually remove this migration document upgrade code +#[cfg(feature = "serde")] +impl serde::Serialize for Gradient { + fn serialize(&self, serializer: S) -> Result { + GradientStops::::from(self).serialize(serializer) + } +} + +// TODO: Eventually remove this document upgrade code +#[cfg(feature = "serde")] impl<'de> serde::Deserialize<'de> for Gradient { fn deserialize>(deserializer: D) -> Result { #[derive(serde::Deserialize)] - struct NewFormat { - position: Vec, - midpoint: Vec, - color: Vec, - } - - #[derive(serde::Deserialize)] - #[cfg_attr(feature = "serde", serde(untagged))] + #[serde(untagged)] enum GradientStopsFormat { - New(NewFormat), - Old(Vec<(f64, Color)>), + Struct(GradientStops), + Tuples(Vec<(f64, Color)>), } Ok(match GradientStopsFormat::deserialize(deserializer)? { - GradientStopsFormat::New(new) => Self { - position: new.position, - midpoint: new.midpoint, - color: new.color, - }, - GradientStopsFormat::Old(stops) => { - let count = stops.len(); - Self { - position: stops.iter().map(|(p, _)| *p).collect(), - midpoint: vec![0.5; count], - color: stops.into_iter().map(|(_, c)| c).collect(), - } + GradientStopsFormat::Struct(stops) => Gradient::from(stops), + GradientStopsFormat::Tuples(stops) => { + let position: Vec = stops.iter().map(|(p, _)| *p).collect(); + let mut gradient = Gradient::from(stops.into_iter().map(|(_, c)| c).collect::>()); + gradient.set_positions(&position); + gradient.elide_default_attributes(); + gradient } }) } } -impl Default for Gradient { - fn default() -> Self { - Self { - position: vec![0., 1.], - midpoint: vec![0.5, 0.5], - color: vec![Color::BLACK, Color::WHITE], - } +impl From> for Gradient { + fn from(colors: List) -> Self { + Self(colors) + } +} + +impl From> for Gradient { + fn from(colors: Vec) -> Self { + Self(colors.into_iter().map(Item::new_from_element).collect()) } } @@ -133,14 +145,18 @@ impl RenderComplexity for Gradient { } } +/// The effective midpoint domain shared by sampling and rendering: NaN reads as the linear default, and extremes are bounded to `0.01..=0.99` so curves stay finite and cheap to subdivide. +fn sanitized_midpoint(midpoint: f64) -> f64 { + if midpoint.is_nan() { 0.5 } else { midpoint.clamp(0.01, 0.99) } +} + /// Apply the midpoint curve to a normalized parameter `t` (0 to 1) given a `midpoint` (0 to 1, where 0.5 is linear). fn apply_midpoint(t: f64, midpoint: f64) -> f64 { + let midpoint = sanitized_midpoint(midpoint); if (midpoint - 0.5).abs() < 1e-6 { return t; } - let midpoint = midpoint.clamp(f64::EPSILON, 1. - f64::EPSILON); - if midpoint < 0.5 { let q = -1. / (1. - midpoint).log2(); 1. - (1. - t).powf(q) @@ -162,25 +178,21 @@ pub struct GradientStopsIter<'a> { index: usize, } -impl<'a> Iterator for GradientStopsIter<'a> { +impl Iterator for GradientStopsIter<'_> { type Item = GradientStop; fn next(&mut self) -> Option { - if self.index >= self.stops.position.len() { - return None; - } - let stop = GradientStop { - position: self.stops.position[self.index], - midpoint: self.stops.midpoint[self.index], - color: self.stops.color[self.index], + position: self.stops.position(self.index), + midpoint: self.stops.midpoint(self.index), + color: self.stops.color(self.index)?, }; self.index += 1; Some(stop) } fn size_hint(&self) -> (usize, Option) { - let remaining = self.stops.position.len() - self.index; + let remaining = self.stops.len().saturating_sub(self.index); (remaining, Some(remaining)) } } @@ -201,63 +213,215 @@ impl IntoIterator for Gradient { type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { - self.position - .into_iter() - .zip(self.midpoint) - .zip(self.color) - .map(|((position, midpoint), color)| GradientStop { position, midpoint, color }) - .collect::>() - .into_iter() + self.iter().collect::>().into_iter() } } +/// 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 } +} + impl Gradient { pub fn new(stops: impl IntoIterator) -> Self { - let mut position = Vec::new(); - let mut midpoint = Vec::new(); - let mut color = Vec::new(); + let stops: Vec = stops.into_iter().collect(); + let mut list: List = stops.iter().map(|stop| Item::new_from_element(stop.color)).collect(); - for stop in stops { - position.push(stop.position); - midpoint.push(stop.midpoint); - color.push(stop.color); + for (index, stop) in stops.iter().enumerate() { + list.set_attribute(ATTR_POSITION, index, stop.position); + list.set_attribute(ATTR_MIDPOINT, index, stop.midpoint); } - Self { position, midpoint, color } + Self(list) + } + + pub fn black_to_white() -> Self { + Self::from(vec![Color::BLACK, Color::WHITE]) + } + + pub fn as_color_list(&self) -> &List { + &self.0 + } + + pub fn into_color_list(self) -> List { + self.0 } pub fn len(&self) -> usize { - self.position.len() + self.0.len() } pub fn is_empty(&self) -> bool { - self.position.is_empty() + self.0.is_empty() } pub fn iter(&self) -> GradientStopsIter<'_> { self.into_iter() } + /// The color of the stop at the given index, if in bounds. + pub fn color(&self, index: usize) -> Option { + self.0.element(index).copied() + } + + /// 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())) + } + + /// 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. + pub fn midpoint(&self, index: usize) -> f64 { + self.0.attribute::(ATTR_MIDPOINT, index).copied().unwrap_or(0.5) + } + + /// The effective positions of all stops. + pub fn positions(&self) -> Vec { + (0..self.len()).map(|index| self.position(index)).collect() + } + + /// The effective midpoints of all stops. + pub fn midpoints(&self) -> Vec { + (0..self.len()).map(|index| self.midpoint(index)).collect() + } + + /// Whether the `position` attribute is explicitly present rather than falling back to the even distribution. + pub fn has_position_attribute(&self) -> bool { + self.0.iter_attribute_values::(ATTR_POSITION).is_some() + } + + /// Whether the `midpoint` attribute is explicitly present rather than falling back to the linear interpolation default. + pub fn has_midpoint_attribute(&self) -> bool { + self.0.iter_attribute_values::(ATTR_MIDPOINT).is_some() + } + + /// The `position` attribute's values when present, or `None` when the stops fall back to the even distribution. + fn position_attribute(&self) -> Option> { + self.0.iter_attribute_values::(ATTR_POSITION).map(|values| values.copied().collect()) + } + + /// The `midpoint` attribute's values when present, or `None` when the stops fall back to the linear interpolation default. + fn midpoint_attribute(&self) -> Option> { + self.0.iter_attribute_values::(ATTR_MIDPOINT).map(|values| values.copied().collect()) + } + + /// 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> { + 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) + .then_some(positions) + } + + /// The `midpoint` attribute when present and meaningfully different from the linear interpolation default of `0.5`. + pub fn nondefault_midpoints(&self) -> Option> { + let midpoints = self.midpoint_attribute()?; + midpoints.iter().any(|&midpoint| (midpoint - 0.5).abs() > 1e-6).then_some(midpoints) + } + + /// 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() { + self.0.remove_attribute(ATTR_POSITION); + } + if self.has_midpoint_attribute() && self.nondefault_midpoints().is_none() { + self.0.remove_attribute(ATTR_MIDPOINT); + } + } + + /// 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) { + 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)); + } + } + + /// Replaces the color of the stop at `index`, if it exists. + pub fn set_color(&mut self, index: usize, color: Color) { + if let Some(element) = self.0.element_mut(index) { + *element = color; + } + } + + /// 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) { + if index >= self.len() { + return; + } + self.materialize_default_positions(); + self.0.set_attribute(ATTR_POSITION, index, position); + } + + /// Sets the midpoint of the stop at `index`, if it exists. + pub fn set_midpoint(&mut self, index: usize, midpoint: f64) { + if index >= self.len() { + return; + } + self.0.set_attribute(ATTR_MIDPOINT, index, midpoint); + } + + /// Replaces the `position` attribute with the given values, padding with the final value if fewer than the stop count and ignoring any extras. + /// An empty list removes the attribute, restoring even distribution. + pub fn set_positions(&mut self, positions: &[f64]) { + let Some(&last) = positions.last() else { + self.0.remove_attribute(ATTR_POSITION); + return; + }; + + for index in 0..self.len() { + self.0.set_attribute(ATTR_POSITION, index, positions.get(index).copied().unwrap_or(last)); + } + } + + /// Replaces the `midpoint` attribute with the given values, padding with the final value if fewer than the stop count and ignoring any extras. + /// An empty list removes the attribute, restoring the linear interpolation default of `0.5` for every stop. + pub fn set_midpoints(&mut self, midpoints: &[f64]) { + let Some(&last) = midpoints.last() else { + self.0.remove_attribute(ATTR_MIDPOINT); + return; + }; + + for index in 0..self.len() { + self.0.set_attribute(ATTR_MIDPOINT, index, midpoints.get(index).copied().unwrap_or(last)); + } + } + + /// Rebuilds the stop list from the given stop indices, preserving every attribute. + fn reordered(&self, indices: impl IntoIterator) -> List { + let mut list = List::new(); + for index in indices { + if let Some(item) = self.0.clone_item(index) { + list.push(item); + } + } + list + } + /// Remove a stop at the given index. pub fn remove(&mut self, index: usize) { - self.position.remove(index); - self.midpoint.remove(index); - self.color.remove(index); + self.0 = self.reordered((0..self.len()).filter(|&i| i != index)); } /// Remove and return the last stop's color, or `None` if empty. pub fn pop(&mut self) -> Option { - self.position.pop(); - self.midpoint.pop(); - self.color.pop() + let color = self.color(self.len().checked_sub(1)?); + self.0 = self.reordered(0..self.len() - 1); + color } /// 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 { - if index >= self.position.len() { + if index >= self.len() { return index; } - self.position[index] = position; + self.set_position(index, position); self.sort_returning_new_index(index) } @@ -265,66 +429,112 @@ impl Gradient { /// The new stop's midpoint is inherited from the interval it splits (or `0.5` if inserting at the very start). /// Returns the index where the new stop was inserted. pub fn insert_stop(&mut self, position: f64) -> usize { - let color = self.evaluate(position); - let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len()); - let midpoint = index.checked_sub(1).and_then(|i| self.midpoint.get(i).copied()).unwrap_or(0.5); - self.position.insert(index, position); - self.midpoint.insert(index, midpoint); - self.color.insert(index, color); - index + let color = self.evaluate(position, Default::default()); + 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) } /// 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 { - let color = *self.color.get(source_index)?; - let midpoint = *self.midpoint.get(source_index)?; - let index = self.position.iter().position(|p| *p > position).unwrap_or(self.position.len()); - self.position.insert(index, position); - self.midpoint.insert(index, midpoint); - self.color.insert(index, color); - Some(index) + let color = self.color(source_index)?; + let midpoint = self.midpoint(source_index); + Some(self.insert_stop_values(position, midpoint, color)) + } + + /// 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()); + + let mut item = Item::new_from_element(color).with_attribute(ATTR_POSITION, position); + if self.has_midpoint_attribute() { + item = item.with_attribute(ATTR_MIDPOINT, midpoint); + } + + let mut list = self.reordered(0..index); + list.push(item); + for i in index..self.len() { + if let Some(existing) = self.0.clone_item(i) { + list.push(existing); + } + } + + self.0 = list; + index } /// Reset the midpoint for the interval starting at `index` to its default `0.5`. pub fn reset_midpoint(&mut self, index: usize) { - if let Some(midpoint) = self.midpoint.get_mut(index) { - *midpoint = 0.5; + if self.has_midpoint_attribute() && index < self.len() { + self.0.set_attribute(ATTR_MIDPOINT, index, 0.5); } } /// 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 { - let len = self.position.len(); - let mut indices: Vec = (0..len).collect(); - indices.sort_by(|&a, &b| self.position[a].total_cmp(&self.position[b])); + // 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))); let new_index = indices.iter().position(|&i| i == previous_index).unwrap_or(previous_index); - self.position = indices.iter().map(|&i| self.position[i]).collect(); - self.midpoint = indices.iter().map(|&i| self.midpoint[i]).collect(); - self.color = indices.iter().map(|&i| self.color[i]).collect(); + self.0 = self.reordered(indices); new_index } - pub fn evaluate(&self, t: f64) -> Color { - if self.position.is_empty() { - return Color::BLACK; + /// 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 { + let mut stops: Vec = (0..self.len()) + .filter_map(|index| { + let position = self.position(index).clamp(0., 1.); + if position.is_nan() { + return None; + } + + let midpoint = self.midpoint(index); + let color = self.color(index)?; + + Some(GradientStop { position, midpoint, color }) + }) + .collect(); + + stops.sort_by(|a, b| a.position.total_cmp(&b.position)); + stops + } + + /// Samples the gradient's color at `t`. Given a `t` outside the 0 to 1 range, the `spread_method` determines how the gradient extends. + pub fn evaluate(&self, t: f64, spread_method: GradientSpreadMethod) -> Color { + let t = match spread_method { + GradientSpreadMethod::Pad => t.clamp(0., 1.), + GradientSpreadMethod::Repeat => t.rem_euclid(1.), + GradientSpreadMethod::Reflect => { + let cycle = t.rem_euclid(2.); + if cycle > 1. { 2. - cycle } else { cycle } + } + }; + + let stops = self.normalized_stops(); + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::BLACK }; + if t <= first.position { + return first.color; + } + if t >= last.position { + return last.color; } - if t <= self.position[0] { - return self.color[0]; - } - let last = self.position.len() - 1; - if t >= self.position[last] { - return self.color[last]; - } - - for i in 0..self.position.len() - 1 { - let (t1, c1) = (self.position[i], self.color[i]); - let (t2, c2) = (self.position[i + 1], self.color[i + 1]); - if t >= t1 && t <= t2 { - let normalized_t = (t - t1) / (t2 - t1); - let adjusted_t = apply_midpoint(normalized_t, self.midpoint[i]); - return c1.lerp(&c2, adjusted_t as f32); + for pair in stops.windows(2) { + let (a, b) = (&pair[0], &pair[1]); + if t >= a.position && t <= b.position { + let normalized_t = (t - a.position) / (b.position - a.position); + let adjusted_t = apply_midpoint(normalized_t, a.midpoint); + return a.color.lerp(&b.color, adjusted_t as f32); } } @@ -332,36 +542,43 @@ impl Gradient { } pub fn sort(&mut self) { - let mut indices: Vec = (0..self.position.len()).collect(); - indices.sort_unstable_by(|&a, &b| self.position[a].total_cmp(&self.position[b])); - self.position = indices.iter().map(|&i| self.position[i]).collect(); - self.midpoint = indices.iter().map(|&i| self.midpoint[i]).collect(); - self.color = indices.iter().map(|&i| self.color[i]).collect(); + self.sort_returning_new_index(0); } pub fn reversed(&self) -> Self { - let position: Vec = self.position.iter().rev().map(|&p| 1. - p).collect(); + let count = self.len(); + let mut list = self.reordered((0..count).rev()); - let count = self.midpoint.len(); - let midpoint = (0..count).map(|i| if i < count - 1 { 1. - self.midpoint[count - 2 - i] } else { 0.5 }).collect::>(); + // Row reversal already reversed the position cells' order, each also flips across the range + if self.has_position_attribute() + && let Some(positions) = list.iter_attribute_values_mut::(ATTR_POSITION) + { + for position in positions { + *position = 1. - *position; + } + } - let color: Vec = self.color.iter().rev().cloned().collect(); + // Midpoints belong to the interval to a stop's right, so they shift by one stop as well as flipping + 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(); + for (index, midpoint) in midpoints.into_iter().enumerate() { + list.set_attribute(ATTR_MIDPOINT, index, midpoint); + } + } - Self { position, midpoint, color } + Self(list) } pub fn map_colors Color>(&self, f: F) -> Self { - Self { - position: self.position.clone(), - midpoint: self.midpoint.clone(), - color: self.color.iter().map(f).collect(), - } + let mut mapped = self.clone(); + mapped.0.iter_element_values_mut().for_each(|color| *color = f(color)); + mapped } /// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves so the rendered gradient matches Graphite's interpolation rather than browser defaults. pub fn to_css_linear_gradient(&self) -> String { - if self.position.len() <= 1 { - let hex = self.color.first().map(|c| SRGBA8::from(*c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_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 @@ -379,7 +596,7 @@ impl Gradient { /// Produce a set of linearly-interpolated color samples that approximate the gradient's midpoint curves. /// /// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding - /// midpoint for actual gradient stops, and `None` for interpolated samples added to approximate midpoint curves. + /// midpoint for actual gradient stops, and `None` for synthesized midpoint-curve approximation samples. /// /// Interpolation is performed in sRGB gamma space (then lifted back to linear-light for output) because the downstream SVG/CSS /// renderer interpolates between adjacent `` colors in gamma space; doing the subdivision math in the same space ensures @@ -419,23 +636,25 @@ impl Gradient { } } - if self.position.is_empty() { + let stops = self.normalized_stops(); + let count = stops.len(); + if count == 0 { return vec![]; } - if self.position.len() == 1 { - return vec![(self.position[0], self.color[0], Some(self.midpoint[0]))]; + if count == 1 { + return vec![(stops[0].position, stops[0].color, Some(sanitized_midpoint(stops[0].midpoint)))]; } let mut result = Vec::new(); - for i in 0..self.position.len() - 1 { - let pos_a = self.position[i]; - let pos_b = self.position[i + 1]; - let color_a = self.color[i]; - let color_b = self.color[i + 1]; - let midpoint = self.midpoint[i].clamp(0.01, 0.99); - let next_midpoint = self.midpoint[i + 1].clamp(0.01, 0.99); + for i in 0..count - 1 { + let pos_a = stops[i].position; + let pos_b = stops[i + 1].position; + let color_a = stops[i].color; + let color_b = stops[i + 1].color; + let midpoint = sanitized_midpoint(stops[i].midpoint); + let next_midpoint = sanitized_midpoint(stops[i + 1].midpoint); // Add the start stop (subsequent segments share the previous end stop) if i == 0 { @@ -479,6 +698,7 @@ pub enum GradientSpreadMethod { Pad, Reflect, Repeat, + // TODO: Add a "Clear" variant that returns transparent black outside the gradient's range } impl GradientSpreadMethod { @@ -539,29 +759,6 @@ pub fn initial_gradient_transform_for_bounding_box(bounds: [DVec2; 2]) -> DAffin } } -// TODO: Eventually remove this migration document upgrade code -pub fn migrate_to_gradient<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { - use serde::Deserialize; - - #[derive(serde::Deserialize)] - struct LegacyTable { - #[serde(alias = "instances", alias = "instance")] - element: Vec, - } - - #[derive(serde::Deserialize)] - #[cfg_attr(feature = "serde", serde(untagged))] - enum GradientStopsFormat { - Stops(Gradient), - List(LegacyTable), - } - - Ok(match GradientStopsFormat::deserialize(deserializer)? { - GradientStopsFormat::Stops(stops) => stops, - GradientStopsFormat::List(list) => list.element.into_iter().next().unwrap_or_default(), - }) -} - impl core_types::bounds::BoundingBox for Gradient { fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { core_types::bounds::RenderBoundingBox::Infinite @@ -575,3 +772,148 @@ impl core_types::bounds::BoundingBox for Gradient { core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[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()), 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.midpoints(), vec![0.5, 0.5, 0.5]); + } + + #[test] + fn serde_round_trip_preserves_attribute_absence() { + let implicit = Gradient::from(vec![Color::BLACK, Color::WHITE]); + let json = serde_json::to_string(&implicit).unwrap(); + assert!(!json.contains("position") && !json.contains("midpoint"), "absent attributes must not serialize: {json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), implicit); + + let mut explicit = implicit.clone(); + explicit.set_positions(&[0.2, 0.9]); + explicit.set_midpoints(&[0.3, 0.5]); + let json = serde_json::to_string(&explicit).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), explicit); + } + + #[test] + fn legacy_tuple_format_deserializes_with_defaults_elided() { + let color = serde_json::to_value(Color::WHITE).unwrap(); + + let struct_format = serde_json::json!({ "position": [0., 0.25], "midpoint": [0.5, 0.5], "color": [color, color] }); + let gradient: Gradient = serde_json::from_value(struct_format).unwrap(); + assert_eq!(gradient.positions(), vec![0., 0.25]); + assert!(gradient.has_midpoint_attribute(), "the struct form must parse faithfully"); + + let tuple_format = serde_json::json!([[0., color], [1., color]]); + let gradient: Gradient = serde_json::from_value(tuple_format).unwrap(); + assert_eq!(gradient.positions(), vec![0., 1.]); + assert!(!gradient.has_position_attribute(), "even legacy tuple positions should elide"); + } + + #[test] + fn gradient_ui_write_back_elides_default_attributes() { + let mut gradient = Gradient::from(vec![Color::BLACK, Color::WHITE, Color::RED]); + 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]); + } + + #[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_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_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_midpoints(), Some(vec![0.5, 0.7, 0.5])); + } + + #[test] + fn non_compliant_positions_normalize_for_sampling_and_rendering() { + // 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]); + + let sample_positions: Vec = gradient.interpolated_samples().iter().map(|(position, ..)| *position).collect(); + assert!(sample_positions.windows(2).all(|pair| pair[0] <= pair[1]), "samples must ascend: {sample_positions:?}"); + assert_eq!(sample_positions.first(), Some(&0.)); + assert_eq!(sample_positions.last(), Some(&1.)); + + assert_eq!(gradient.evaluate(0., Default::default()), Color::RED); + assert_eq!(gradient.evaluate(1., Default::default()), Color::WHITE); + } + + #[test] + fn infinite_positions_clamp_to_the_range_ends() { + let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]); + gradient.set_positions(&[f64::INFINITY, f64::NEG_INFINITY]); + + let sample_positions: Vec = gradient.interpolated_samples().iter().map(|(position, ..)| *position).collect(); + assert_eq!(sample_positions, vec![0., 1.]); + assert_eq!(gradient.evaluate(0., Default::default()), Color::BLACK); + assert_eq!(gradient.evaluate(1., Default::default()), Color::WHITE); + } + + #[test] + fn nan_positions_drop_their_stops_from_sampling() { + let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK, Color::RED]); + gradient.set_positions(&[0., f64::NAN, 1.]); + + let sample_positions: Vec = gradient.interpolated_samples().iter().map(|(position, ..)| *position).collect(); + assert_eq!(sample_positions, vec![0., 1.]); + assert_eq!(gradient.evaluate(0.5, 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()); + + // 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().is_empty()); + assert_eq!(gradient.evaluate(0.5, Default::default()), Color::BLACK); + } + + #[test] + fn samples_start_at_the_first_stop_without_synthetic_lead_in() { + let mut gradient = Gradient::from(vec![Color::WHITE, Color::BLACK]); + gradient.set_positions(&[0.3, 1.]); + + let samples = gradient.interpolated_samples(); + 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()); + + gradient.set_midpoints(&[f64::NAN, f64::NAN]); + assert_eq!(gradient.evaluate(0.25, Default::default()), linear_result); + let no_nan_annotations = gradient + .interpolated_samples() + .iter() + .all(|(position, _, midpoint)| position.is_finite() && !midpoint.is_some_and(|midpoint| midpoint.is_nan())); + assert!(no_nan_annotations, "NaN must not escape into rendered sample annotations"); + } +} diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 8a3b3ed13c..09ba107a3c 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -28,7 +28,7 @@ pub enum FillChoice { } // TODO: Deprecate [`FillChoice`] and keep this, renamed, as the main widget-controlling type -/// JS-boundary version of [`FillChoice`] where the solid color is [`SRGBA8`] and the gradient is [`GradientUI`]. +/// JS-boundary version of [`FillChoice`] where the solid color is [`SRGBA8`] and the gradient is its [`GradientStops`] exchange form. #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))] #[derive(Default, Debug, Clone, PartialEq, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -36,7 +36,7 @@ pub enum FillChoiceUI { #[default] None, Solid(SRGBA8), - Gradient(GradientUI), + Gradient(GradientStops), } impl From<&FillChoice> for FillChoiceUI { @@ -44,7 +44,7 @@ impl From<&FillChoice> for FillChoiceUI { match value { FillChoice::None => Self::None, FillChoice::Solid(color) => Self::Solid(SRGBA8::from(*color)), - FillChoice::Gradient(stops) => Self::Gradient(GradientUI::from(stops)), + FillChoice::Gradient(stops) => Self::Gradient(stops.into()), } } } @@ -65,7 +65,7 @@ impl FillChoiceUI { Some(*c) } - pub fn as_gradient(&self) -> Option<&GradientUI> { + pub fn as_gradient(&self) -> Option<&GradientStops> { let Self::Gradient(g) = self else { return None }; Some(g) } diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 3da1a0bfe6..f4f471590a 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -8,7 +8,7 @@ use std::sync::atomic::AtomicU64; use syn::punctuated::Punctuated; use syn::visit::Visit; use syn::visit_mut::VisitMut; -use syn::{GenericArgument, GenericParam, Ident, Lifetime, PatIdent, PathArguments, Type, TypeParam, TypeParamBound}; +use syn::{Expr, ExprPath, GenericArgument, GenericParam, Ident, Lifetime, PatIdent, PathArguments, Token, Type, TypeParam, TypeParamBound}; pub(crate) mod classify; mod entries; @@ -348,6 +348,20 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }) .collect(); + let default_colors: Vec<_> = regular_fields + .iter() + .map(|field| match field.ty.regular() { + Some(RegularParsedField { + value_source: ParsedValueSource::Default(data), + .. + }) => match color_constant_paths(data) { + Some(paths) => quote!(Some(&[#(#paths),*])), + None => quote!(None), + }, + _ => quote!(None), + }) + .collect(); + let default_types: Vec<_> = regular_fields .iter() .map(|field| match &field.ty { @@ -682,6 +696,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn hidden: #input_hidden, exposed: #exposed, value_source: #value_sources, + default_colors: #default_colors, default_type: #default_types, number_soft_min: #number_soft_min_values, number_soft_max: #number_soft_max_values, @@ -2955,3 +2970,22 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn ..Default::default() }) } + +fn color_constant_paths(tokens: &TokenStream2) -> Option> { + use syn::parse::Parser; + + let expressions = Punctuated::::parse_terminated.parse2(tokens.clone()).ok()?; + if expressions.is_empty() { + return None; + } + + expressions + .into_iter() + .map(|expression| { + let Expr::Path(path) = expression else { return None }; + let segments = &path.path.segments; + let is_color_constant = path.qself.is_none() && segments.len() == 2 && segments[0].ident == "Color" && segments.iter().all(|segment| segment.arguments.is_none()); + is_color_constant.then_some(path) + }) + .collect() +} diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index 0751a5271c..0af13dfba1 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -267,6 +267,16 @@ pub enum ParsedFieldType { Node(NodeParsedField), } +impl ParsedFieldType { + /// The shared value-field data, present for every value field but not a lazy `Node`. + pub fn regular(&self) -> Option<&RegularParsedField> { + match self { + ParsedFieldType::Regular(field) => Some(field), + ParsedFieldType::Node(_) => None, + } + } +} + /// A single numeric endpoint within a `#[soft(..)]` or `#[hard(..)]` bounds range. /// Accepts both integer literals (e.g. `1`, `-1`) and float literals (e.g. `1.`, `-500.`). #[derive(Clone, Debug)] diff --git a/node-graph/nodes/brush/src/lib.rs b/node-graph/nodes/brush/src/lib.rs index d9eae7b368..cc69608117 100644 --- a/node-graph/nodes/brush/src/lib.rs +++ b/node-graph/nodes/brush/src/lib.rs @@ -5,7 +5,7 @@ pub mod brush_stroke; pub mod migrations { use crate::brush_stroke::BrushStroke; - // TODO: Eventually remove this migration document upgrade code + // TODO: Eventually remove this document upgrade code pub fn migrate_to_brush_strokes<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { use serde::Deserialize; diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 4c5b7926cf..1363e3ee99 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -15,7 +15,7 @@ use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr}; use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector}; use raster_types::{CPU, GPU, Raster}; use vector_types::gradient::{GradientSpreadMethod, GradientType as GradientTypeValue}; -use vector_types::{Gradient, GradientStop, ReferencePoint}; +use vector_types::{Gradient, ReferencePoint}; fn arena_exhausted() -> Interrupt { GraphError { @@ -555,21 +555,10 @@ pub fn flatten_gradient<'e>( flatten_leaf_lane(content, ctx.index() as usize) } -/// A gradient with `colors` as evenly spaced stops from 0 to 1; none makes a -/// black gradient and one repeats at both ends. -fn evenly_spaced_gradient(colors: &[Color]) -> Gradient { - let stop = |position: f64, color: Color| GradientStop { position, midpoint: 0.5, color }; - match colors { - [] => Gradient::new(vec![stop(0., Color::BLACK), stop(1., Color::BLACK)]), - [color] => Gradient::new(vec![stop(0., *color), stop(1., *color)]), - colors => Gradient::new(colors.iter().enumerate().map(|(index, color)| stop(index as f64 / (colors.len() - 1) as f64, *color))), - } -} - /// Constructs a gradient from a `Color[]`, where the colors are evenly distributed as gradient stops across the range from 0 to 1. #[node_macro::node(category("Color"), name("Colors to Gradient"))] pub fn colors_to_gradient(_: impl Ctx, colors: IList) -> Gradient { - evenly_spaced_gradient(&colors.iter().collect::>()) + Gradient::from(colors.iter().collect::>()) } /// The gradient over a graphic level's color leaves, as [`colors_to_gradient`]. @@ -583,7 +572,7 @@ pub fn colors_to_gradient_graphic(_: impl Ctx, colors: IList>) RowStep::Continue }); } - evenly_spaced_gradient(&leaves) + Gradient::from(leaves) } pub use _colors_to_gradient_graphic_mod::colors_to_gradient_graphic_entries; diff --git a/node-graph/nodes/graphic/src/record.rs b/node-graph/nodes/graphic/src/record.rs index 563cff3bb6..840d269863 100644 --- a/node-graph/nodes/graphic/src/record.rs +++ b/node-graph/nodes/graphic/src/record.rs @@ -796,11 +796,12 @@ mod tests { assert_eq!(three.iter().map(|stop| stop.position).collect::>(), vec![0., 0.5, 1.]); assert_eq!(three.iter().map(|stop| stop.color).collect::>(), vec![Color::BLACK, Color::WHITE, Color::BLACK]); + // A lone color is a one-stop gradient and no colors a stopless one; neither is padded let single = stops_of(vec![Color::WHITE]); - assert_eq!(single.iter().map(|stop| (stop.position, stop.color)).collect::>(), vec![(0., Color::WHITE), (1., Color::WHITE)]); + assert_eq!(single.iter().map(|stop| (stop.position, stop.color)).collect::>(), vec![(0., Color::WHITE)]); let empty = stops_of(Vec::new()); - assert_eq!(empty.iter().map(|stop| (stop.position, stop.color)).collect::>(), vec![(0., Color::BLACK), (1., Color::BLACK)]); + assert!(empty.iter().next().is_none()); } #[test] diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 3bbf36c1b0..d433432b7f 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1198,7 +1198,7 @@ fn hex_to_color(ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, hex_code: Str /// Constructs a gradient value which may be set to any sequence of color stops to represent the transition between colors. #[node_macro::node(category("Value"))] -fn gradient_value(_: impl Ctx, _primary: (), gradient: Gradient) -> Gradient { +fn gradient_value(_: impl Ctx, _primary: (), #[default(Color::BLACK, Color::WHITE)] gradient: Gradient) -> Gradient { gradient } @@ -1214,16 +1214,43 @@ fn spread_method(_: impl Ctx, gradient: Gradient, spread_method: vector_types::G (gradient, Attr(spread_method)) } -/// Gets the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). +/// Sets the position of each of a gradient's stops, a factor from 0 to 1 along the gradient. +/// +/// A list shorter than the stop count repeats its last value, a longer list is truncated, and an empty list sets each stop to its default evenly spaced position. #[node_macro::node(category("Color"))] -fn sample_gradient(ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, _primary: (), gradient: IList, position: Fraction) -> Result, Interrupt> { +fn gradient_positions(_: impl Ctx, mut gradient: Gradient, positions: IList) -> Gradient { + let positions: Vec = positions.iter().collect(); + gradient.set_positions(&positions); + gradient +} + +/// 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. +/// +/// 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("Color"))] +fn gradient_midpoints(_: impl Ctx, mut gradient: Gradient, midpoints: IList) -> Gradient { + let midpoints: Vec = midpoints.iter().collect(); + gradient.set_midpoints(&midpoints); + gradient +} + +/// Evaluates the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). Positions beyond that range follow the gradient's `spread_method` attribute: Pad (default), Reflect, or Repeat. +#[node_macro::node(category("Color"))] +fn sample_gradient( + ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, + _primary: (), + #[default(Color::BLACK, Color::WHITE)] gradient: IList, + position: Fraction, +) -> Result, Interrupt> { // An unwired gradient serves an empty level: no color if gradient.is_empty() || ctx.index() != 0 { return Err(GraphError::past_end().into()); } - let position = position.clamp(0., 1.); - Ok(gradient.element_ref(0).evaluate(position)) + let spread_method = gradient.lane(0).attr::(); + Ok(gradient.element_ref(0).evaluate(position, spread_method)) } /// 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/adjust.rs b/node-graph/nodes/raster/src/adjust.rs index 52f372bcae..650b81d1fd 100644 --- a/node-graph/nodes/raster/src/adjust.rs +++ b/node-graph/nodes/raster/src/adjust.rs @@ -24,9 +24,7 @@ mod adjust_std { } impl Adjust for Gradient { fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) { - for color in self.color.iter_mut() { - *color = map_fn(color); - } + *self = self.map_colors(map_fn); } } } diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 69db82bf32..ecc6058786 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -38,16 +38,19 @@ mod blend_std { impl Blend for Gradient { fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self { - let mut combined_stops = self.position.iter().chain(under.position.iter()).copied().collect::>(); - combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let mut combined_stops = self.positions().into_iter().chain(under.positions()).collect::>(); combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6); let stops = combined_stops.into_iter().map(|position| { - let over_color = self.evaluate(position); - let under_color = under.evaluate(position); + let over_color = self.evaluate(position, Default::default()); + let under_color = under.evaluate(position, Default::default()); let color = blend_fn(over_color, under_color); GradientStop { position, midpoint: 0.5, color } }); - Gradient::new(stops) + + let mut gradient = Gradient::new(stops); + gradient.elide_default_attributes(); + gradient } } } diff --git a/node-graph/nodes/raster/src/gradient_map.rs b/node-graph/nodes/raster/src/gradient_map.rs index a51a6e9c6b..ee33e02872 100644 --- a/node-graph/nodes/raster/src/gradient_map.rs +++ b/node-graph/nodes/raster/src/gradient_map.rs @@ -17,18 +17,19 @@ fn gradient_map + Clone + Send + Sync + core_types::CacheHash + Gradient, )] mut image: T, - gradient: IList, + #[default(Color::BLACK, Color::WHITE)] gradient: IList, reverse: bool, ) -> T { if gradient.is_empty() { return image; } + let spread_method = 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.evaluate(intensity as f64, spread_method) }); image diff --git a/node-graph/nodes/text/src/font.rs b/node-graph/nodes/text/src/font.rs index 74024b8096..425994a5b4 100644 --- a/node-graph/nodes/text/src/font.rs +++ b/node-graph/nodes/text/src/font.rs @@ -64,7 +64,7 @@ impl Default for Font { } } -// TODO: Eventually remove this migration document upgrade code +// TODO: Eventually remove this document upgrade code fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { use serde::Deserialize; String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name }) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 2df68600fa..a977a85879 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -61,7 +61,7 @@ fn assign_color_at(gradient: &Gradient, position: usize, length: usize, randomiz _ => position as f64 % repeat_every as f64 / (repeat_every - 1) as f64, }, }; - gradient.evaluate(factor) + gradient.evaluate(factor, Default::default()) } /// Uniquely sets the fill and/or stroke style of every vector element to individual colors sampled along a chosen gradient. @@ -77,6 +77,7 @@ fn assign_colors<'e>( /// Whether to style the stroke. stroke: bool, /// The range of colors to select from. + #[default(Color::BLACK, Color::WHITE)] #[widget(ParsedWidgetOverride::Custom = "assign_colors_gradient")] gradient: IList, /// Whether to reverse the gradient. @@ -323,7 +324,7 @@ fn fill<'e>( #[default(Color::BLACK)] fill: IList>, _backup_color: IList, - _backup_gradient: IList, + #[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList, _gradient_type: GradientType, _spread_method: GradientSpreadMethod, _has_transform: bool, @@ -344,7 +345,7 @@ fn fill_graphic_leveled<'e>( (element, _content_fill): (Graphic<'static>, Attr), #[default(Color::BLACK)] fill: IList>, _backup_color: IList, - _backup_gradient: IList, + #[default(Color::BLACK, Color::WHITE)] _backup_gradient: IList, _gradient_type: GradientType, _spread_method: GradientSpreadMethod, _has_transform: bool, @@ -2950,14 +2951,12 @@ fn morph_core(flattened: List, snapshot: List>, progres match (a.element(0), b.element(0)) { (Some(Graphic::Color(color_a)), Some(Graphic::Color(color_b))) => Some(List::new_from_element(Graphic::from(color_a.lerp(color_b, time as f32)))), (Some(Graphic::Color(color_a)), Some(Graphic::Gradient(stops_b))) => { - let mut solid_to_gradient = stops_b.clone(); - solid_to_gradient.color.iter_mut().for_each(|color| *color = *color_a); + let solid_to_gradient = stops_b.map_colors(|_| *color_a); let stops = solid_to_gradient.lerp(stops_b, time); Some(gradient_paint(b, stops, None)) } (Some(Graphic::Gradient(stops_a)), Some(Graphic::Color(color_b))) => { - let mut gradient_to_solid = stops_a.clone(); - gradient_to_solid.color.iter_mut().for_each(|color| *color = *color_b); + let gradient_to_solid = stops_a.map_colors(|_| *color_b); let stops = stops_a.lerp(&gradient_to_solid, time); Some(gradient_paint(a, stops, None)) } diff --git a/tools/node-docs/src/page_node.rs b/tools/node-docs/src/page_node.rs index d14028f81f..05bd0a0b5d 100644 --- a/tools/node-docs/src/page_node.rs +++ b/tools/node-docs/src/page_node.rs @@ -177,13 +177,9 @@ fn write_inputs(page: &mut std::fs::File, valid_input_types: &[Vec"#); - // Compare against the typed default's debug form so the swatch tracks the `Gradient` representation - let black_to_white_gradient = value::TaggedValue::Gradient(Default::default()).to_debug_string(); - let default_value = match default_value { - "Color::BLACK" => render_color("black"), - gradient if gradient == black_to_white_gradient => render_color("linear-gradient(to right, black, white)"), - _ => format!("`{default_value}{}`", field.unit.unwrap_or_default()), + let default_value = match field.default_colors { + Some(colors) => color_swatch(colors), + None => format!("`{default_value}{}`", field.unit.unwrap_or_default()), }; details.push(format!("

*Default:* {default_value}

")); @@ -210,6 +206,17 @@ fn write_inputs(page: &mut std::fs::File, valid_input_types: &[Vec String { + let hex: Vec = colors.iter().map(|&color| format!("#{}", core_types::color::SRGBA8::from(color).to_rgba_hex())).collect(); + let background = match hex.as_slice() { + [single] => single.clone(), + multiple => format!("linear-gradient(to right, {})", multiple.join(", ")), + }; + + format!(r#""#) +} + fn write_outputs(page: &mut std::fs::File, valid_primary_outputs: &[core_types::Type]) { // Product let product = "Result";