Rework Gradient into a newtype of List<Color> with optional position and midpoint attributes (#4397)

* Rework Gradient into a newtype of List<Color> with optional position and midpoint attributes

* Fix Vello stopless-gradient fallback coverage, empty legacy gradient tables, the node docs gradient swatch, NaN position elision, and wired setter input overwrites
This commit is contained in:
Keavon Chambers
2026-09-14 12:58:16 +02:00
committed by Dennis Kobert
parent b78e4b107e
commit 86d4106592
36 changed files with 1112 additions and 464 deletions
@@ -35,6 +35,14 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier,
stops: Gradient,
},
GradientPositionsSet {
layer: LayerNodeIdentifier,
positions: Vec<f64>,
},
GradientMidpointsSet {
layer: LayerNodeIdentifier,
midpoints: Vec<f64>,
},
GradientTransformSet {
layer: LayerNodeIdentifier,
transform: DAffine2,
@@ -61,6 +61,16 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> 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);
@@ -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<f64>) {
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<f64>) {
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.
@@ -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<LayoutGroup> {
@@ -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<LayoutGroup> {
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,
};
@@ -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");
}
@@ -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
{