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-08-03 04:05:02 -07:00
committed by Dennis Kobert
parent 8504564f5d
commit f6b4ce71e6
36 changed files with 1112 additions and 464 deletions

View File

@@ -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<ColorPickerMessage, ()> 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;

View File

@@ -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<SRGBA8>,
/// 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)]

View File

@@ -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,

View File

@@ -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);

View File

@@ -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.

View File

@@ -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,
};

View File

@@ -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");
}

View File

@@ -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
{

View File

@@ -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<Gradient>`
@@ -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,

View File

@@ -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<SRGBA8> },
UpdateOptions { options: GradientOptionsUpdate },
}
@@ -138,9 +138,9 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> 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<Message>) {
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<f64> = 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<f64> = 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");
}
}