New nodes: 'Gradient Type' and 'Spread Method', and add Gradient tool support for controlling these nodes (#4084)

* Use 'Transform', 'Gradient Type', and 'Spread Method' nodes for table gradients

* Add gradient widget to the tool's control bar and update where the two swap buttons go

* Fix gradient rendering

* Format

* Code review
This commit is contained in:
Keavon Chambers 2026-04-29 19:27:44 -07:00 committed by GitHub
parent e686ee9f42
commit 4b2430290c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 584 additions and 224 deletions

View File

@ -70,5 +70,6 @@
"*.graphite": "json" "*.graphite": "json"
}, },
"editor.renderWhitespace": "boundary", "editor.renderWhitespace": "boundary",
"editor.minimap.markSectionHeaderRegex": "// ===+\\n\\s*//\\s*(?<label>[^\\n]{1,18})[^\\n]*(\\n\\s*//[^\\n]*)*\\n\\s*// ===+" "editor.minimap.markSectionHeaderRegex": "// ===+\\n\\s*//\\s*(?<label>[^\\n]{1,18})[^\\n]*(\\n\\s*//[^\\n]*)*\\n\\s*// ===+",
"git.addAICoAuthor": "off"
} }

View File

@ -9,9 +9,8 @@ use graphene_std::color::Color;
use graphene_std::raster::BlendMode; use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image; use graphene_std::raster_types::Image;
use graphene_std::subpath::Subpath; use graphene_std::subpath::Subpath;
use graphene_std::table::Table;
use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Fill, Stroke}; use graphene_std::vector::style::{Fill, GradientSpreadMethod, GradientType, Stroke};
use graphene_std::vector::{GradientStops, PointId, VectorModificationType}; use graphene_std::vector::{GradientStops, PointId, VectorModificationType};
#[impl_message(Message, DocumentMessage, GraphOperation)] #[impl_message(Message, DocumentMessage, GraphOperation)]
@ -25,9 +24,22 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
fill: f64, fill: f64,
}, },
GradientTableSet { GradientStopsSet {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
gradient_table: Table<GradientStops>, stops: GradientStops,
},
GradientLineSet {
layer: LayerNodeIdentifier,
start: DVec2,
end: DVec2,
},
GradientTypeSet {
layer: LayerNodeIdentifier,
gradient_type: GradientType,
},
GradientSpreadMethodSet {
layer: LayerNodeIdentifier,
spread_method: GradientSpreadMethod,
}, },
OpacitySet { OpacitySet {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,

View File

@ -45,9 +45,24 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
modify_inputs.blending_fill_set(fill); modify_inputs.blending_fill_set(fill);
} }
} }
GraphOperationMessage::GradientTableSet { layer, gradient_table } => { GraphOperationMessage::GradientStopsSet { layer, stops } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.gradient_table_set(gradient_table); modify_inputs.gradient_stops_set(stops);
}
}
GraphOperationMessage::GradientLineSet { layer, start, end } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.gradient_line_set(start, end);
}
}
GraphOperationMessage::GradientTypeSet { layer, gradient_type } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.gradient_type_set(gradient_type);
}
}
GraphOperationMessage::GradientSpreadMethodSet { layer, spread_method } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.gradient_spread_method_set(spread_method);
} }
} }
GraphOperationMessage::OpacitySet { layer, opacity } => { GraphOperationMessage::OpacitySet { layer, opacity } => {

View File

@ -13,7 +13,7 @@ use graphene_std::raster_types::Image;
use graphene_std::subpath::Subpath; use graphene_std::subpath::Subpath;
use graphene_std::table::Table; use graphene_std::table::Table;
use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Fill, Stroke}; use graphene_std::vector::style::{Fill, GradientSpreadMethod, GradientType, Stroke};
use graphene_std::vector::{GradientStops, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::vector::{GradientStops, PointId, Vector, VectorModification, VectorModificationType};
use graphene_std::{Color, Graphic, NodeInputDecleration}; use graphene_std::{Color, Graphic, NodeInputDecleration};
@ -460,13 +460,98 @@ impl<'a> ModifyInputsContext<'a> {
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(fill * 100.), false), false); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(fill * 100.), false), false);
} }
pub fn gradient_table_set(&mut self, gradient_table: Table<GradientStops>) { /// Set the stops table on the 'Gradient Value' node, creating it if necessary.
pub fn gradient_stops_set(&mut self, stops: GradientStops) {
let Some(gradient_node_id) = self.existing_proto_node_id(graphene_std::math_nodes::gradient_value::IDENTIFIER, true) else { let Some(gradient_node_id) = self.existing_proto_node_id(graphene_std::math_nodes::gradient_value::IDENTIFIER, true) else {
return; return;
}; };
let input_connector = InputConnector::node(gradient_node_id, graphene_std::math_nodes::gradient_value::GradientInput::INDEX); let input_connector = InputConnector::node(gradient_node_id, graphene_std::math_nodes::gradient_value::GradientInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientTable(gradient_table), false), false); let stops_table = Table::new_from_element(stops);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientTable(stops_table), false), false);
}
/// Update the gradient line so its endpoints are at `new_start` and `new_end`.
/// 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.
pub fn gradient_line_set(&mut self, new_start: DVec2, new_end: DVec2) {
let Some(output_layer) = self.get_output_layer() else { return };
let transform_reference = DefinitionIdentifier::Network("Transform".into());
let upstream_transforms: Vec<NodeId> = self
.network_interface
.upstream_flow_back_from_nodes(vec![output_layer.to_node()], &[], network_interface::FlowType::HorizontalFlow)
.skip(1)
.take_while(|node_id| !self.network_interface.is_layer(node_id, &[]))
.filter(|node_id| self.network_interface.reference(node_id, &[]).as_ref() == Some(&transform_reference))
.collect();
// Upstream walk yields downstream-to-upstream order, so the first hit is the chain's last `Transform`
let (last_transform_node_id, prior_transforms) = match upstream_transforms.split_first() {
Some((last, prior)) => (Some(*last), prior),
None => (None, [].as_slice()),
};
// `composed_old` = T_n * T_{n-1} * ... * T_1, `prior_combined` = same product without T_n
let compose = |ids: &[_]| {
ids.iter().fold(DAffine2::IDENTITY, |acc, transform_id| {
self.network_interface
.document_network()
.nodes
.get(transform_id)
.map_or(acc, |document_node| acc * transform_utils::get_current_transform(&document_node.inputs))
})
};
let composed_old = compose(&upstream_transforms);
let prior_combined = compose(prior_transforms);
// Rebuild the y-axis from the new x-axis using the old (parallel, perpendicular) decomposition and length ratio,
// so the gradient's aspect ratio and skew survive an endpoint drag (so an ellipse stays the same ellipse) instead of
// the old y-axis vector remaining fixed while x changes
let new_x_axis = new_end - new_start;
let preserved_y_axis = scale_y_axis_to_match_new_x(composed_old.matrix2.x_axis, composed_old.matrix2.y_axis, new_x_axis);
let new_composed = DAffine2 {
matrix2: glam::DMat2::from_cols(new_x_axis, preserved_y_axis),
translation: new_start,
};
let last_transform_value = new_composed * prior_combined.inverse();
let transform_node_id = if let Some(id) = last_transform_node_id {
id
} else {
// Don't pollute the graph with an identity 'Transform' node
if last_transform_value.abs_diff_eq(DAffine2::IDENTITY, 1e-6) {
return;
}
let Some(id) = self.existing_network_node_id("Transform", true) else { return };
id
};
transform_utils::update_transform(self.network_interface, &transform_node_id, last_transform_value);
self.responses.add(PropertiesPanelMessage::Refresh);
self.responses.add(NodeGraphMessage::RunDocumentGraph);
}
/// Write the gradient type to the last 'Gradient Type' node in the chain, inserting one only when the value differs
/// from the default (`Linear`).
pub fn gradient_type_set(&mut self, gradient_type: GradientType) {
let identifier = graphene_std::math_nodes::gradient_type::IDENTIFIER;
let create_if_nonexistent = gradient_type != GradientType::default();
let Some(node_id) = self.existing_proto_node_id(identifier, create_if_nonexistent) else { return };
let input_connector = InputConnector::node(node_id, graphene_std::math_nodes::gradient_type::GradientTypeInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientType(gradient_type), false), false);
}
/// Write the spread method to the last 'Spread Method' node in the chain, inserting one only when the value differs
/// from the default (`Pad`).
pub fn gradient_spread_method_set(&mut self, spread_method: GradientSpreadMethod) {
let identifier = graphene_std::math_nodes::spread_method::IDENTIFIER;
let create_if_nonexistent = spread_method != GradientSpreadMethod::default();
let Some(node_id) = self.existing_proto_node_id(identifier, create_if_nonexistent) else { return };
let input_connector = InputConnector::node(node_id, graphene_std::math_nodes::spread_method::SpreadMethodInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientSpreadMethod(spread_method), false), false);
} }
pub fn clip_mode_toggle(&mut self, clip_mode: Option<bool>) { pub fn clip_mode_toggle(&mut self, clip_mode: Option<bool>) {
@ -621,3 +706,29 @@ impl<'a> ModifyInputsContext<'a> {
} }
} }
} }
/// Rebuild the y-axis so its (parallel, perpendicular) components in the x-axis-aligned frame stay constant, both
/// rescaled by `|new_x| / |old_x|`. This holds the (x, y) parallelogram's aspect ratio and skew fixed across an endpoint
/// drag, so a radial ellipse stays the same shape (just rotated and resized) instead of distorting as x grows or shrinks.
/// Falls back to a +90° rotation of `new_x` when `old_x` is degenerate.
fn scale_y_axis_to_match_new_x(old_x: DVec2, old_y: DVec2, new_x: DVec2) -> DVec2 {
let old_x_length = old_x.length();
if old_x_length < 1e-9 {
return DVec2::new(-new_x.y, new_x.x);
}
let ex_old = old_x / old_x_length;
let ey_old = DVec2::new(-ex_old.y, ex_old.x);
let new_x_length = new_x.length();
if new_x_length < 1e-9 {
return DVec2::ZERO;
}
let ex_new = new_x / new_x_length;
let ey_new = DVec2::new(-ex_new.y, ex_new.x);
let parallel = old_y.dot(ex_old);
let perpendicular = old_y.dot(ey_old);
let scale = new_x_length / old_x_length;
scale * (parallel * ex_new + perpendicular * ey_new)
}

View File

@ -4,9 +4,11 @@ use super::document_node_definitions::{NODE_OVERRIDES, NodePropertiesContext};
use super::utility_types::FrontendGraphDataType; use super::utility_types::FrontendGraphDataType;
use crate::messages::layout::utility_types::widget_prelude::*; use crate::messages::layout::utility_types::widget_prelude::*;
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type; use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface}; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface};
use crate::messages::portfolio::utility_types::{CachedData, FontCatalogStyle}; use crate::messages::portfolio::utility_types::{CachedData, FontCatalogStyle};
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils;
use choice::enum_choice; use choice::enum_choice;
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
@ -235,6 +237,7 @@ pub(crate) fn property_from_type(
// ========================= // =========================
Some(x) if x == TypeId::of::<FillType>() => enum_choice::<FillType>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<FillType>() => enum_choice::<FillType>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<GradientType>() => enum_choice::<GradientType>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<GradientType>() => enum_choice::<GradientType>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<GradientSpreadMethod>() => enum_choice::<GradientSpreadMethod>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<RealTimeMode>() => enum_choice::<RealTimeMode>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<RealTimeMode>() => enum_choice::<RealTimeMode>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<RedGreenBlue>() => enum_choice::<RedGreenBlue>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<RedGreenBlue>() => enum_choice::<RedGreenBlue>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<RedGreenBlueAlpha>() => enum_choice::<RedGreenBlueAlpha>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<RedGreenBlueAlpha>() => enum_choice::<RedGreenBlueAlpha>().for_socket(default_info).property_row(),
@ -2025,11 +2028,21 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper
LayoutGroup::section(name, description, visible, pinned, node_id.0, Layout(layout)) LayoutGroup::section(name, description, visible, pinned, node_id.0, Layout(layout))
} }
/// Resolve the viewport-space orientation of a Fill node's gradient by walking downstream to its owning layer
/// and reusing the same helper the Gradient tool uses, so canvas tilt and layer transforms behave identically.
fn gradient_orientation_in_fill_node(node_id: NodeId, gradient: &graphene_std::vector::style::Gradient, context: &mut NodePropertiesContext) -> Option<bool> {
let layer_node = context.network_interface.downstream_layer_for_chain_node(&node_id, context.selection_network_path)?;
let layer = LayerNodeIdentifier::new(layer_node, context.network_interface);
let transform = graph_modification_utils::gradient_space_transform(layer, context.network_interface);
Some(graph_modification_utils::gradient_orientation_rightward(gradient.start, gradient.end, transform))
}
/// Fill Node Widgets LayoutGroup /// Fill Node Widgets LayoutGroup
pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> { pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::vector::fill::*; use graphene_std::vector::fill::*;
let mut widgets_first_row = start_widgets(ParameterWidgetsInfo::new(node_id, FillInput::<Color>::INDEX, true, context)); // Pass blank_assist=false because the assist slot is filled below ("Reverse Stops" button when in gradient mode)
let mut widgets_first_row = start_widgets(ParameterWidgetsInfo::new(node_id, FillInput::<Color>::INDEX, false, context));
let document_node = match get_document_node(node_id, context) { let document_node = match get_document_node(node_id, context) {
Ok(document_node) => document_node, Ok(document_node) => document_node,
@ -2052,6 +2065,30 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
let backup_color_fill: Fill = backup_color.clone().into(); let backup_color_fill: Fill = backup_color.clone().into();
let backup_gradient_fill: Fill = backup_gradient.clone().into(); let backup_gradient_fill: Fill = backup_gradient.clone().into();
match fill {
Fill::Gradient(gradient) => {
let reverse_button = IconButton::new("Reverse", 24)
.tooltip_label("Reverse Stops")
.tooltip_description("Reverse the gradient color stops.")
.on_update(update_value(
{
let gradient = gradient.clone();
move |_| {
let mut gradient = gradient.clone();
gradient.stops = gradient.stops.reversed();
TaggedValue::Fill(Fill::Gradient(gradient))
}
},
node_id,
FillInput::<Color>::INDEX,
))
.widget_instance();
widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
widgets_first_row.push(reverse_button);
}
_ => add_blank_assist(&mut widgets_first_row),
}
widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
widgets_first_row.push( widgets_first_row.push(
ColorInput::default() ColorInput::default()
@ -2089,32 +2126,12 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
.on_commit(commit_value) .on_commit(commit_value)
.widget_instance(), .widget_instance(),
); );
let mut widgets = vec![LayoutGroup::row(widgets_first_row)]; let mut widgets = vec![LayoutGroup::row(widgets_first_row)];
let fill_type_switch = { let fill_type_switch = {
let mut row = vec![TextLabel::new("").widget_instance()]; let mut row = vec![TextLabel::new("").widget_instance()];
match fill { add_blank_assist(&mut row);
Fill::Solid(_) | Fill::None => add_blank_assist(&mut row),
Fill::Gradient(gradient) => {
let reverse_button = IconButton::new("Reverse", 24)
.tooltip_description("Reverse the gradient color stops.")
.on_update(update_value(
{
let gradient = gradient.clone();
move |_| {
let mut gradient = gradient.clone();
gradient.stops = gradient.stops.reversed();
TaggedValue::Fill(Fill::Gradient(gradient))
}
},
node_id,
FillInput::<Color>::INDEX,
))
.widget_instance();
row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
row.push(reverse_button);
}
}
let entries = vec![ let entries = vec![
RadioEntryData::new("solid") RadioEntryData::new("solid")
@ -2137,34 +2154,9 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
widgets.push(fill_type_switch); widgets.push(fill_type_switch);
if let Fill::Gradient(gradient) = fill.clone() { if let Fill::Gradient(gradient) = fill.clone() {
// Linear/Radial radio: blank assist (the "Reverse Direction" button has been moved down to the spread method row)
let mut row = vec![TextLabel::new("").widget_instance()]; let mut row = vec![TextLabel::new("").widget_instance()];
match gradient.gradient_type { add_blank_assist(&mut row);
GradientType::Linear => add_blank_assist(&mut row),
GradientType::Radial => {
let orientation = if (gradient.end.x - gradient.start.x).abs() > f64::EPSILON * 1e6 {
gradient.end.x > gradient.start.x
} else {
(gradient.start.x + gradient.start.y) < (gradient.end.x + gradient.end.y)
};
let reverse_radial_gradient_button = IconButton::new(if orientation { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24)
.tooltip_description("Reverse which end the gradient radiates from.")
.on_update(update_value(
{
let gradient = gradient.clone();
move |_| {
let mut gradient = gradient.clone();
std::mem::swap(&mut gradient.start, &mut gradient.end);
TaggedValue::Fill(Fill::Gradient(gradient))
}
},
node_id,
FillInput::<Color>::INDEX,
))
.widget_instance();
row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
row.push(reverse_radial_gradient_button);
}
}
let gradient_for_closure = gradient.clone(); let gradient_for_closure = gradient.clone();
@ -2203,7 +2195,33 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
widgets.push(LayoutGroup::row(row)); widgets.push(LayoutGroup::row(row));
let mut spread_methods_row: Vec<WidgetInstance> = vec![TextLabel::new("").widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance()]; // "Reverse Direction" button (assist) plus the Pad/Reflect/Repeat radio. Icon orientation is resolved in viewport
// space so canvas tilt and layer transforms behave the same as in the Gradient tool's control bar.
let mut spread_methods_row = vec![TextLabel::new("").widget_instance()];
let orientation_rightward = gradient_orientation_in_fill_node(node_id, &gradient, context).unwrap_or(true);
let reverse_direction_button = IconButton::new(if orientation_rightward { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24)
.tooltip_label("Reverse Direction")
.tooltip_description(if gradient.gradient_type == GradientType::Radial {
"Reverse which end the gradient radiates from."
} else {
"Swap the start and end points of the gradient line."
})
.on_update(update_value(
{
let gradient = gradient.clone();
move |_| {
let mut gradient = gradient.clone();
std::mem::swap(&mut gradient.start, &mut gradient.end);
TaggedValue::Fill(Fill::Gradient(gradient))
}
},
node_id,
FillInput::<Color>::INDEX,
))
.widget_instance();
spread_methods_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
spread_methods_row.push(reverse_direction_button);
let spread_method_entries = [GradientSpreadMethod::Pad, GradientSpreadMethod::Reflect, GradientSpreadMethod::Repeat] let spread_method_entries = [GradientSpreadMethod::Pad, GradientSpreadMethod::Reflect, GradientSpreadMethod::Repeat]
.iter() .iter()
@ -2247,8 +2265,10 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
}) })
.collect(); .collect();
add_blank_assist(&mut spread_methods_row); spread_methods_row.extend_from_slice(&[
spread_methods_row.extend_from_slice(&[RadioInput::new(spread_method_entries).selected_index(Some(gradient.spread_method as u32)).widget_instance()]); Separator::new(SeparatorStyle::Unrelated).widget_instance(),
RadioInput::new(spread_method_entries).selected_index(Some(gradient.spread_method as u32)).widget_instance(),
]);
widgets.push(LayoutGroup::row(spread_methods_row)); widgets.push(LayoutGroup::row(spread_methods_row));
} }

View File

@ -289,6 +289,39 @@ pub fn get_gradient_table(layer: LayerNodeIdentifier, network_interface: &NodeNe
Some(gradient_table.clone()) Some(gradient_table.clone())
} }
/// Compute the transform from a gradient's local space to viewport space for the given layer. For a `Table<GradientStops>`
/// layer this is the layer's incoming footprint transform; for the legacy `Fill::Gradient` path it composes the layer's
/// viewport transform with the [0,1]² → bounding-box mapping.
pub fn gradient_space_transform(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> glam::DAffine2 {
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
let metadata = network_interface.document_metadata();
let is_gradient_table = is_layer_fed_by_node_of_name(layer, network_interface, &DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER));
if is_gradient_table {
return metadata
.upstream_footprints
.get(&layer.to_node())
.map(|footprint| footprint.transform)
.unwrap_or(metadata.document_to_viewport);
}
let multiplied = metadata.transform_to_viewport(layer);
let bounds = metadata.nonzero_bounding_box(layer);
let bound_transform = glam::DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
multiplied * bound_transform
}
/// True when start→end (mapped through `transform` into viewport space) points predominantly rightward. For purely
/// vertical lines we fall back to a stable tiebreaker on (x + y) so the choice doesn't flicker between equal alternatives.
pub fn gradient_orientation_rightward(start: glam::DVec2, end: glam::DVec2, transform: glam::DAffine2) -> bool {
let viewport_start = transform.transform_point2(start);
let viewport_end = transform.transform_point2(end);
if (viewport_end.x - viewport_start.x).abs() > f64::EPSILON * 1e6 {
viewport_end.x > viewport_start.x
} else {
(viewport_start.x + viewport_start.y) < (viewport_end.x + viewport_end.y)
}
}
/// Get the current fill of a layer from the closest "Fill" node. /// Get the current fill of a layer from the closest "Fill" node.
pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Color> { pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Color> {
let fill_index = 1; let fill_index = 1;

View File

@ -6,14 +6,13 @@ use crate::consts::{
use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier;
use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasis, OverlayContext}; use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasis, OverlayContext};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::network_interface::{FlowType, NodeNetworkInterface};
use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_gradient_table, is_layer_fed_by_node_of_name}; use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_gradient_table};
use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration};
use graph_craft::document::value::TaggedValue;
use graphene_std::raster::color::Color; use graphene_std::raster::color::Color;
use graphene_std::table::{Table, TableRow}; use graphene_std::vector::style::{Fill, FillChoice, Gradient, GradientSpreadMethod, GradientStops, GradientType};
use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStops, GradientType};
use graphene_std::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM};
#[derive(Default, ExtractField)] #[derive(Default, ExtractField)]
pub struct GradientTool { pub struct GradientTool {
@ -49,6 +48,7 @@ pub enum GradientToolMessage {
CommitTransactionForColorStop, CommitTransactionForColorStop,
CloseStopColorPicker, CloseStopColorPicker,
UpdateStopColor { color: Color }, UpdateStopColor { color: Color },
UpdateStops { stops: GradientStops },
UpdateOptions { options: GradientOptionsUpdate }, UpdateOptions { options: GradientOptionsUpdate },
} }
@ -118,6 +118,9 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
responses.add(PropertiesPanelMessage::Refresh); responses.add(PropertiesPanelMessage::Refresh);
} }
} }
ToolMessage::Gradient(GradientToolMessage::UpdateStops { stops }) => {
apply_stops_update(&mut self.data, context, responses, stops);
}
ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => { ToolMessage::Gradient(GradientToolMessage::CloseStopColorPicker) => {
if self.data.color_picker_transaction_open { if self.data.color_picker_transaction_open {
responses.add(DocumentMessage::EndTransaction); responses.add(DocumentMessage::EndTransaction);
@ -128,27 +131,48 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Grad
_ => { _ => {
self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false);
let has_gradient = has_gradient_on_selected_layers(context.document); // Reading from the layer (not from the in-progress drag state) keeps the control bar widgets current across selection changes, not just drags
if has_gradient != self.data.has_selected_gradient { let (current_layer, current_gradient) = current_layer_and_gradient(context.document);
self.data.has_selected_gradient = has_gradient;
responses.add(ToolMessage::RefreshToolOptions); let mut needs_refresh = false;
if let Some(gradient) = &current_gradient {
if self.options.gradient_type != gradient.gradient_type {
self.options.gradient_type = gradient.gradient_type;
needs_refresh = true;
}
if self.options.spread_method != gradient.spread_method {
self.options.spread_method = gradient.spread_method;
needs_refresh = true;
}
} }
// Sync tool options with the selected layer's gradient let has_gradient = current_gradient.is_some();
if has_gradient && let Some(gradient) = get_gradient_on_selected_layer(context.document) { if has_gradient != self.data.has_selected_gradient {
let type_differs = self.options.gradient_type != gradient.gradient_type; self.data.has_selected_gradient = has_gradient;
let spread_method_differs = self.options.spread_method != gradient.spread_method; needs_refresh = true;
}
if type_differs { let new_stops = current_gradient.as_ref().map(|gradient| gradient.stops.clone());
self.options.gradient_type = gradient.gradient_type; if self.data.current_gradient_stops != new_stops {
} self.data.current_gradient_stops = new_stops;
if spread_method_differs { needs_refresh = true;
self.options.spread_method = gradient.spread_method; }
}
if type_differs || spread_method_differs { let new_orientation = match (current_layer, &current_gradient) {
responses.add(ToolMessage::RefreshToolOptions); (Some(layer), Some(gradient)) => {
let transform = gradient_space_transform(layer, context.document);
graph_modification_utils::gradient_orientation_rightward(gradient.start, gradient.end, transform)
} }
_ => true,
}; };
if new_orientation != self.data.gradient_orientation_rightward {
self.data.gradient_orientation_rightward = new_orientation;
needs_refresh = true;
}
if needs_refresh {
responses.add(ToolMessage::RefreshToolOptions);
}
} }
} }
} }
@ -184,7 +208,23 @@ impl LayoutHolder for GradientTool {
.selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32))
.widget_instance(); .widget_instance();
widgets.extend([gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance()]); let stops_value = self
.data
.current_gradient_stops
.clone()
.map(FillChoice::Gradient)
.unwrap_or(FillChoice::Gradient(GradientStops::default()));
let stops_widget = ColorInput::new(stops_value)
.allow_none(false)
.disabled(!self.data.has_selected_gradient)
.tooltip_label("Gradient Stops")
.tooltip_description("Edit the gradient's color stops.")
.on_update(|input: &ColorInput| {
let stops = input.value.as_gradient().cloned().unwrap_or_default();
GradientToolMessage::UpdateStops { stops }.into()
})
.on_commit(|_| DocumentMessage::AddTransaction.into())
.widget_instance();
let reverse_stops = IconButton::new("Reverse", 24) let reverse_stops = IconButton::new("Reverse", 24)
.tooltip_label("Reverse Stops") .tooltip_label("Reverse Stops")
@ -221,38 +261,34 @@ impl LayoutHolder for GradientTool {
.selected_index(Some(self.options.spread_method as u32)) .selected_index(Some(self.options.spread_method as u32))
.widget_instance(); .widget_instance();
widgets.extend([spread_method, Separator::new(SeparatorStyle::Unrelated).widget_instance(), reverse_stops]); let reverse_direction_icon = if self.data.gradient_orientation_rightward {
"ReverseRadialGradientToRight"
} else {
"ReverseRadialGradientToLeft"
};
let reverse_direction = IconButton::new(reverse_direction_icon, 24)
.tooltip_label("Reverse Direction")
.tooltip_description("Reverse which end the gradient radiates from.")
.disabled(!self.data.has_selected_gradient)
.on_update(|_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::ReverseDirection,
}
.into()
})
.widget_instance();
if self.options.gradient_type == GradientType::Radial { widgets.extend([
let orientation = self stops_widget,
.data Separator::new(SeparatorStyle::Related).widget_instance(),
.selected_gradient reverse_stops,
.as_ref() Separator::new(SeparatorStyle::Unrelated).widget_instance(),
.map(|selected_gradient| { gradient_type,
let (start, end) = (selected_gradient.gradient.start, selected_gradient.gradient.end); Separator::new(SeparatorStyle::Unrelated).widget_instance(),
if (end.x - start.x).abs() > f64::EPSILON * 1e6 { spread_method,
end.x > start.x Separator::new(SeparatorStyle::Related).widget_instance(),
} else { reverse_direction,
(start.x + start.y) < (end.x + end.y) ]);
}
})
.unwrap_or(true);
let reverse_direction = IconButton::new(if orientation { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24)
.tooltip_label("Reverse Direction")
.tooltip_description("Reverse which end the gradient radiates from.")
.disabled(!self.data.has_selected_gradient)
.on_update(|_| {
GradientToolMessage::UpdateOptions {
options: GradientOptionsUpdate::ReverseDirection,
}
.into()
})
.widget_instance();
widgets.push(Separator::new(SeparatorStyle::Related).widget_instance());
widgets.push(reverse_direction);
}
Layout(vec![LayoutGroup::row(widgets)]) Layout(vec![LayoutGroup::row(widgets)])
} }
@ -273,63 +309,110 @@ impl Default for GradientToolFsmState {
} }
} }
/// Computes the transform from gradient space to viewport space (where gradient space is 0..1) /// Computes the transform from gradient space to viewport space (where gradient space is 0..1).
fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 { fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 {
// TODO: Drop the `is_gradient_table` branch once all gradients are `Table<GradientStops>` graph_modification_utils::gradient_space_transform(layer, &document.network_interface)
let is_gradient_table = is_layer_fed_by_node_of_name(
layer,
&document.network_interface,
&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER),
);
if is_gradient_table {
// Table<GradientStops> layers use the item's transform from gradient space to document space,
// so we cannot use `transform_to_viewport` here as it would apply the transform twice.
return document
.metadata()
.upstream_footprints
.get(&layer.to_node())
.map(|footprint| footprint.transform)
.unwrap_or(document.metadata().document_to_viewport);
}
let multiplied = document.metadata().transform_to_viewport(layer);
let bounds = document.metadata().nonzero_bounding_box(layer);
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
multiplied * bound_transform
}
/// Builds the item transform that maps the unit gradient line (the +X unit vector in local space) to
/// the segment from `start` to `end` in document space. The perpendicular column is forced to the same magnitude
/// as the `start`..`end` direction so the matrix stays invertible (linear gradients ignore the perpendicular axis,
/// but click detection uses the full inverse).
// TODO: Apply a separate scale on the perpendicular axis when we support elliptical gradients
fn gradient_item_transform(start: DVec2, end: DVec2) -> DAffine2 {
let delta = end - start;
let perp = DVec2::new(-delta.y, delta.x);
DAffine2::from_cols_array(&[delta.x, delta.y, perp.x, perp.y, start.x, start.y])
} }
// TODO: Remove this whole function once all gradients are `Table<GradientStops>` // TODO: Remove this whole function once all gradients are `Table<GradientStops>`
fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Gradient> { fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Gradient> {
match (get_gradient_table(layer, network_interface), graph_modification_utils::get_gradient(layer, network_interface)) { if let Some(stops_table) = get_gradient_table(layer, network_interface) {
(Some(gradient_graphic), _) => { let stops = stops_table.element(0).cloned().unwrap_or_default();
let stops = gradient_graphic.element(0)?.clone(); let GradientChainState {
let transform: DAffine2 = gradient_graphic.attribute_cloned_or_default(ATTR_TRANSFORM, 0); transform,
let spread_method: GradientSpreadMethod = gradient_graphic.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); gradient_type,
let gradient_type: GradientType = gradient_graphic.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); spread_method,
let gradient = Gradient { } = read_gradient_chain_state(layer, network_interface);
stops, return Some(Gradient {
gradient_type, stops,
spread_method, gradient_type,
start: transform.transform_point2(DVec2::ZERO), spread_method,
end: transform.transform_point2(DVec2::X), start: transform.transform_point2(DVec2::ZERO),
}; end: transform.transform_point2(DVec2::X),
Some(gradient) });
}
(None, Some(gradient)) => Some(gradient),
(None, None) => None,
} }
graph_modification_utils::get_gradient(layer, network_interface)
}
#[derive(Clone, Copy, Debug)]
struct GradientChainState {
transform: DAffine2,
gradient_type: GradientType,
spread_method: GradientSpreadMethod,
}
/// Resolve the gradient transform, type, and spread method by walking the chain feeding the layer. Transform composes all
/// 'Transform' nodes. Type and spread method come from the closest-to-layer node of each kind, or the type default.
fn read_gradient_chain_state(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> GradientChainState {
let transform_reference = DefinitionIdentifier::Network("Transform".into());
let gradient_type_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_type::IDENTIFIER);
let spread_method_reference = DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::spread_method::IDENTIFIER);
let mut transforms_downstream_to_upstream: Vec<DAffine2> = Vec::new();
let mut gradient_type: Option<GradientType> = None;
let mut spread_method: Option<GradientSpreadMethod> = None;
for node_id in network_interface
.upstream_flow_back_from_nodes(vec![layer.to_node()], &[], FlowType::HorizontalFlow)
.skip(1)
.take_while(|node_id| !network_interface.is_layer(node_id, &[]))
{
let Some(reference) = network_interface.reference(&node_id, &[]) else { continue };
let Some(document_node) = network_interface.document_network().nodes.get(&node_id) else {
continue;
};
if reference == transform_reference {
transforms_downstream_to_upstream.push(read_transform_node_value(&document_node.inputs));
} else if reference == gradient_type_reference
&& gradient_type.is_none()
&& let Some(TaggedValue::GradientType(value)) = document_node.inputs.get(1).and_then(|input| input.as_value())
{
gradient_type = Some(*value);
} else if reference == spread_method_reference
&& spread_method.is_none()
&& let Some(TaggedValue::GradientSpreadMethod(value)) = document_node.inputs.get(1).and_then(|input| input.as_value())
{
spread_method = Some(*value);
}
}
// Iteration order [T_n, ..., T_1] is the matrix-product order, so the fold yields T_n * ... * T_1
let composed_transform = transforms_downstream_to_upstream.into_iter().fold(DAffine2::IDENTITY, |acc, matrix| acc * matrix);
GradientChainState {
transform: composed_transform,
gradient_type: gradient_type.unwrap_or_default(),
spread_method: spread_method.unwrap_or_default(),
}
}
/// Reconstruct the `DAffine2` produced by a 'Transform' node from its translation, rotation, scale, and skew inputs.
fn read_transform_node_value(inputs: &[graph_craft::document::NodeInput]) -> DAffine2 {
let translation = inputs
.get(1)
.and_then(|input| input.as_value())
.and_then(|value| if let TaggedValue::DVec2(v) = value { Some(*v) } else { None })
.unwrap_or(DVec2::ZERO);
let rotation_degrees = inputs
.get(2)
.and_then(|input| input.as_value())
.and_then(|value| if let TaggedValue::F64(v) = value { Some(*v) } else { None })
.unwrap_or(0.);
let scale = inputs
.get(3)
.and_then(|input| input.as_value())
.and_then(|value| if let TaggedValue::DVec2(v) = value { Some(*v) } else { None })
.unwrap_or(DVec2::ONE);
let skew = inputs
.get(4)
.and_then(|input| input.as_value())
.and_then(|value| if let TaggedValue::DVec2(v) = value { Some(*v) } else { None })
.unwrap_or(DVec2::ZERO);
let trs = DAffine2::from_scale_angle_translation(scale, rotation_degrees.to_radians(), translation);
let skew_matrix = DAffine2::from_cols_array(&[1., skew.y.to_radians().tan(), skew.x.to_radians().tan(), 1., 0., 0.]);
trs * skew_matrix
} }
/// Whether two adjacent stops are too closely packed in viewport space for a midpoint diamond to be shown or interacted with. /// Whether two adjacent stops are too closely packed in viewport space for a midpoint diamond to be shown or interacted with.
@ -625,13 +708,7 @@ impl SelectedGradient {
if let Some(layer) = self.layer { if let Some(layer) = self.layer {
// TODO: Drop the `Fill::Gradient` branch when all gradients become `Table<GradientStops>` // TODO: Drop the `Fill::Gradient` branch when all gradients become `Table<GradientStops>`
if self.is_gradient_table { if self.is_gradient_table {
let gradient_table = Table::new_from_row( dispatch_gradient_writes(layer, &self.gradient, responses);
TableRow::new_from_element(self.gradient.stops.clone())
.with_attribute(ATTR_TRANSFORM, gradient_item_transform(self.gradient.start, self.gradient.end))
.with_attribute(ATTR_SPREAD_METHOD, self.gradient.spread_method)
.with_attribute(ATTR_GRADIENT_TYPE, self.gradient.gradient_type),
);
responses.add(GraphOperationMessage::GradientTableSet { layer, gradient_table });
} else { } else {
responses.add(GraphOperationMessage::FillSet { responses.add(GraphOperationMessage::FillSet {
layer, layer,
@ -642,6 +719,24 @@ impl SelectedGradient {
} }
} }
/// Send the four per-attribute graph operations that mirror the in-memory `Gradient` onto the chain feeding the layer.
fn dispatch_gradient_writes(layer: LayerNodeIdentifier, gradient: &Gradient, responses: &mut VecDeque<Message>) {
responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: gradient.stops.clone() });
responses.add(GraphOperationMessage::GradientLineSet {
layer,
start: gradient.start,
end: gradient.end,
});
responses.add(GraphOperationMessage::GradientTypeSet {
layer,
gradient_type: gradient.gradient_type,
});
responses.add(GraphOperationMessage::GradientSpreadMethodSet {
layer,
spread_method: gradient.spread_method,
});
}
impl GradientTool { impl GradientTool {
/// Get the gradient type of the selected gradient (if it exists) /// Get the gradient type of the selected gradient (if it exists)
pub fn selected_gradient(&self) -> Option<GradientType> { pub fn selected_gradient(&self) -> Option<GradientType> {
@ -669,6 +764,12 @@ struct GradientToolData {
auto_pan_shift: DVec2, auto_pan_shift: DVec2,
gradient_angle: f64, gradient_angle: f64,
has_selected_gradient: bool, has_selected_gradient: bool,
/// Cached stops of the currently selected layer's gradient, mirrored into the control-bar widget. Independent of any
/// in-progress drag (which uses `selected_gradient`) so it stays current after selection changes too.
current_gradient_stops: Option<GradientStops>,
/// Cached viewport-space orientation (true = predominantly rightward) of the selected gradient line.
/// Used to refresh the control bar's "Reverse Direction" icon only when the line's apparent direction flips.
gradient_orientation_rightward: bool,
color_picker_editing_color_stop: Option<usize>, color_picker_editing_color_stop: Option<usize>,
color_picker_transaction_open: bool, color_picker_transaction_open: bool,
} }
@ -1251,7 +1352,15 @@ impl Fsm for GradientToolFsmState {
GradientToolFsmState::Drawing { drag_hint: hint } GradientToolFsmState::Drawing { drag_hint: hint }
} else { } else {
let document_mouse = document.metadata().document_to_viewport.inverse().transform_point2(mouse); let document_mouse = document.metadata().document_to_viewport.inverse().transform_point2(mouse);
let selected_layer = document.click_based_on_position(document_mouse); // Table-based gradients render no geometry, so a click on empty canvas yields no layer. Fall back to a
// selected gradient-table layer so the user can drag a fresh gradient line anywhere.
let selected_layer = document.click_based_on_position(document_mouse).or_else(|| {
document
.network_interface
.selected_nodes()
.selected_visible_layers(&document.network_interface)
.find(|&layer| get_gradient_table(layer, &document.network_interface).is_some())
});
// Apply the gradient to the selected layer // Apply the gradient to the selected layer
if let Some(layer) = selected_layer { if let Some(layer) = selected_layer {
@ -1615,14 +1724,7 @@ fn apply_gradient_update(
// Only check for the gradient table once we know we'll write back, since this is a graph traversal per layer // Only check for the gradient table once we know we'll write back, since this is a graph traversal per layer
// TODO: Drop the `Fill::Gradient` branch when all gradients become `Table<GradientStops>` // TODO: Drop the `Fill::Gradient` branch when all gradients become `Table<GradientStops>`
if get_gradient_table(layer, &context.document.network_interface).is_some() { if get_gradient_table(layer, &context.document.network_interface).is_some() {
// Rebuild the item transform from the (possibly mutated) start/end so updates like `ReverseDirection` that only swap endpoints are reflected in the stored attribute dispatch_gradient_writes(layer, &gradient, responses);
let gradient_table = Table::new_from_row(
TableRow::new_from_element(gradient.stops.clone())
.with_attribute(ATTR_TRANSFORM, gradient_item_transform(gradient.start, gradient.end))
.with_attribute(ATTR_SPREAD_METHOD, gradient.spread_method)
.with_attribute(ATTR_GRADIENT_TYPE, gradient.gradient_type),
);
responses.add(GraphOperationMessage::GradientTableSet { layer, gradient_table });
} else { } else {
responses.add(GraphOperationMessage::FillSet { responses.add(GraphOperationMessage::FillSet {
layer, layer,
@ -1646,6 +1748,50 @@ fn apply_gradient_update(
responses.add(ToolMessage::RefreshToolOptions); responses.add(ToolMessage::RefreshToolOptions);
} }
/// Set new gradient stops on every selected layer's gradient. Unlike `apply_gradient_update`, this doesn't open its own
/// transaction so it can be called repeatedly during a color picker drag and have all the changes coalesced into a
/// single undo entry by the surrounding 'on_commit' callback.
fn apply_stops_update(data: &mut GradientToolData, context: &mut ToolActionMessageContext, responses: &mut VecDeque<Message>, stops: GradientStops) {
let selected_layers: Vec<_> = context
.document
.network_interface
.selected_nodes()
.selected_visible_layers(&context.document.network_interface)
.collect();
for layer in selected_layers {
if NodeGraphLayer::is_raster_layer(layer, &mut context.document.network_interface) {
continue;
}
if get_gradient_table(layer, &context.document.network_interface).is_some() {
responses.add(GraphOperationMessage::GradientStopsSet { layer, stops: stops.clone() });
} else if let Some(mut gradient) = get_gradient(layer, &context.document.network_interface) {
gradient.stops = stops.clone();
responses.add(GraphOperationMessage::FillSet {
layer,
fill: Fill::Gradient(gradient),
});
}
}
if let Some(selected_gradient) = &mut data.selected_gradient {
selected_gradient.gradient.stops = stops;
}
responses.add(PropertiesPanelMessage::Refresh);
}
/// Find the first selected visible layer that has a gradient and return both the layer ID and its resolved gradient.
fn current_layer_and_gradient(document: &DocumentMessageHandler) -> (Option<LayerNodeIdentifier>, Option<Gradient>) {
for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) {
if let Some(gradient) = get_gradient(layer, &document.network_interface) {
return (Some(layer), Some(gradient));
}
}
(None, None)
}
fn get_gradient_on_selected_layer(document: &DocumentMessageHandler) -> Option<Gradient> { fn get_gradient_on_selected_layer(document: &DocumentMessageHandler) -> Option<Gradient> {
document document
.network_interface .network_interface
@ -2176,25 +2322,24 @@ mod test_gradient {
// Create original transform for the control geometry and apply it // Create original transform for the control geometry and apply it
let initial_start = DVec2::new(10., 50.); let initial_start = DVec2::new(10., 50.);
let initial_end = DVec2::new(200., 50.); let initial_end = DVec2::new(200., 50.);
let initial_item_transform = super::gradient_item_transform(initial_start, initial_end); let stops = GradientStops::new([
GradientStop {
position: 0.,
midpoint: 0.5,
color: Color::RED,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: Color::BLUE,
},
]);
editor.handle_message(GraphOperationMessage::GradientStopsSet { layer, stops }).await;
editor editor
.handle_message(GraphOperationMessage::GradientTableSet { .handle_message(GraphOperationMessage::GradientLineSet {
layer, layer,
gradient_table: Table::new_from_row( start: initial_start,
TableRow::new_from_element(GradientStops::new([ end: initial_end,
GradientStop {
position: 0.,
midpoint: 0.5,
color: Color::RED,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: Color::BLUE,
},
]))
.with_attribute(ATTR_TRANSFORM, initial_item_transform),
),
}) })
.await; .await;
@ -2266,11 +2411,12 @@ mod test_gradient {
]); ]);
let initial_start = DVec2::new(10., 50.); let initial_start = DVec2::new(10., 50.);
let initial_end = DVec2::new(200., 50.); let initial_end = DVec2::new(200., 50.);
let initial_item_transform = super::gradient_item_transform(initial_start, initial_end); editor.handle_message(GraphOperationMessage::GradientStopsSet { layer, stops: original_stops.clone() }).await;
editor editor
.handle_message(GraphOperationMessage::GradientTableSet { .handle_message(GraphOperationMessage::GradientLineSet {
layer, layer,
gradient_table: Table::new_from_row(TableRow::new_from_element(original_stops.clone()).with_attribute(ATTR_TRANSFORM, initial_item_transform)), start: initial_start,
end: initial_end,
}) })
.await; .await;

View File

@ -3,16 +3,15 @@ use crate::application_io::PlatformEditorApi;
use crate::proto::{Any as DAny, FutureAny}; use crate::proto::{Any as DAny, FutureAny};
use brush_nodes::brush_cache::BrushCache; use brush_nodes::brush_cache::BrushCache;
use brush_nodes::brush_stroke::BrushStroke; use brush_nodes::brush_stroke::BrushStroke;
use core_types::table::{Table, TableRow}; use core_types::table::Table;
use core_types::transform::Footprint; use core_types::transform::Footprint;
use core_types::uuid::NodeId; use core_types::uuid::NodeId;
use core_types::{ATTR_TRANSFORM, CacheHash, Color, ContextFeatures, MemoHash, Node, Type}; use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type};
use dyn_any::DynAny; use dyn_any::DynAny;
pub use dyn_any::StaticType; pub use dyn_any::StaticType;
use glam::{Affine2, Vec2}; use glam::{Affine2, Vec2};
pub use glam::{DAffine2, DVec2, IVec2, UVec2}; pub use glam::{DAffine2, DVec2, IVec2, UVec2};
use graphic_types::raster_types::{CPU, Image, Raster}; use graphic_types::raster_types::{CPU, Image, Raster};
use graphic_types::vector_types::gradient::GRADIENT_TABLE_DEFAULT_SCALE;
use graphic_types::vector_types::vector::style::{Fill, Gradient, GradientStops, Stroke}; use graphic_types::vector_types::vector::style::{Fill, Gradient, GradientStops, Stroke};
use graphic_types::vector_types::vector::{self, ReferencePoint}; use graphic_types::vector_types::vector::{self, ReferencePoint};
use graphic_types::{Graphic, Vector}; use graphic_types::{Graphic, Vector};
@ -119,9 +118,7 @@ macro_rules! tagged_value {
x if x == TypeId::of::<()>() => TaggedValue::None, x if x == TypeId::of::<()>() => TaggedValue::None,
// Table-wrapped types need a single-item default with the element's default, not an empty table // Table-wrapped types need a single-item default with the element's default, not an empty table
x if x == TypeId::of::<Table<Color>>() => TaggedValue::Color(Table::new_from_element(Color::default())), x if x == TypeId::of::<Table<Color>>() => TaggedValue::Color(Table::new_from_element(Color::default())),
x if x == TypeId::of::<Table<GradientStops>>() => TaggedValue::GradientTable(Table::new_from_row( x if x == TypeId::of::<Table<GradientStops>>() => TaggedValue::GradientTable(Table::new_from_element(GradientStops::default())),
TableRow::new_from_element(GradientStops::default()).with_attribute(ATTR_TRANSFORM, DAffine2::from_scale(DVec2::splat(GRADIENT_TABLE_DEFAULT_SCALE))),
)),
$( x if x == TypeId::of::<$ty>() => TaggedValue::$identifier(Default::default()), )* $( x if x == TypeId::of::<$ty>() => TaggedValue::$identifier(Default::default()), )*
_ => return None, _ => return None,
}) })
@ -256,6 +253,7 @@ tagged_value! {
PaintOrder(vector::style::PaintOrder), PaintOrder(vector::style::PaintOrder),
FillType(vector::style::FillType), FillType(vector::style::FillType),
GradientType(vector::style::GradientType), GradientType(vector::style::GradientType),
GradientSpreadMethod(vector::style::GradientSpreadMethod),
ReferencePoint(vector::ReferencePoint), ReferencePoint(vector::ReferencePoint),
CentroidType(vector::misc::CentroidType), CentroidType(vector::misc::CentroidType),
BooleanOperation(vector::misc::BooleanOperation), BooleanOperation(vector::misc::BooleanOperation),

View File

@ -1705,9 +1705,11 @@ impl Render for Table<Color> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
for (color, alpha_blending) in self.iter_element_values().zip(self.iter_attribute_values_or_default::<AlphaBlending>(ATTR_ALPHA_BLENDING)) { for (color, alpha_blending) in self.iter_element_values().zip(self.iter_attribute_values_or_default::<AlphaBlending>(ATTR_ALPHA_BLENDING)) {
render.leaf_tag("polyline", |attributes| { render.leaf_tag("polyline", |attributes| {
// Chrome doesn't like drawing centered rectangles bigger than ~20 million so we draw a polyline quad instead // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses
let max = u64::MAX; // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning.
attributes.push("points", format!("{max},{max} -{max},{max} -{max},-{max} {max},-{max}")); // 1e7 stays under that limit while still being far larger than any practical document extent.
const MAX: f64 = 1e7;
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
attributes.push("fill", format!("#{}", color.to_rgb_hex_srgb_from_gamma())); attributes.push("fill", format!("#{}", color.to_rgb_hex_srgb_from_gamma()));
if color.a() < 1. { if color.a() < 1. {
@ -1779,9 +1781,11 @@ impl Render for Table<GradientStops> {
attributes.push("width", size.x.to_string()); attributes.push("width", size.x.to_string());
attributes.push("height", size.y.to_string()); attributes.push("height", size.y.to_string());
} else { } else {
// Chrome doesn't like drawing centered rectangles bigger than ~20 million so we draw a polyline quad instead // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses
let max = u64::MAX; // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning.
attributes.push("points", format!("{max},{max} -{max},{max} -{max},-{max} {max},-{max}")); // 1e7 stays under that limit while still being far larger than any practical document extent.
const MAX: f64 = 1e7;
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
} }
let mut stop_string = String::new(); let mut stop_string = String::new();

View File

@ -2,10 +2,6 @@ use core_types::{Color, render_complexity::RenderComplexity};
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
/// Default scale applied to a freshly-created `Table<GradientStops>` item's transform.
/// Places the unit gradient line (the +X unit vector in local space) inside a 100×100 document-space box.
pub const GRADIENT_TABLE_DEFAULT_SCALE: f64 = 100.;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]

View File

@ -8,7 +8,7 @@ pub mod vector;
// Re-export commonly used types at the crate root // Re-export commonly used types at the crate root
pub use core_types as gcore; pub use core_types as gcore;
pub use gradient::{GradientStop, GradientStops, GradientType}; pub use gradient::{GradientSpreadMethod, GradientStop, GradientStops, GradientType};
pub use math::{QuadExt, RectExt}; pub use math::{QuadExt, RectExt};
pub use subpath::Subpath; pub use subpath::Subpath;
pub use vector::Vector; pub use vector::Vector;

View File

@ -230,11 +230,17 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito
scene.append(child, Some(footprint_transform_vello)); scene.append(child, Some(footprint_transform_vello));
// We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport.
// See <https://xi.zulipchat.com/#narrow/channel/197075-vello/topic/Full.20screen.20color.2Fgradients/near/538435044> for more detail // See <https://xi.zulipchat.com/#narrow/channel/197075-vello/topic/Full.20screen.20color.2Fgradients/near/538435044> for more detail.
//
// `!is_finite()` rather than `== f32::INFINITY`: `scene.append` composes the child's `Affine::scale(INFINITY)` with
// the viewport rotation, leaving `matrix[0] = cos(θ) * INFINITY`. In the (90°, 270°) tilt range cos is negative so
// the result is `-INFINITY`, which the old equality check missed; Vello then rasterized a unit rect with non-finite
// vertices, dropping the gradient and tanking performance. `!is_finite()` also covers NaN as a guard against future
// code paths where `matrix[0]` could land on `0 * INFINITY`.
let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64); let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64);
for transform in scene.encoding_mut().transforms.iter_mut() { for transform in scene.encoding_mut().transforms.iter_mut() {
if transform.matrix[0] == f32::INFINITY { if !transform.matrix[0].is_finite() {
*transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform);
} }
} }

View File

@ -863,6 +863,24 @@ fn gradient_value(_: impl Ctx, _primary: (), gradient: Table<GradientStops>) ->
gradient gradient
} }
/// Sets the type (linear or radial) of each gradient in the input table.
#[node_macro::node(category("Color"))]
fn gradient_type(_: impl Ctx, mut gradient: Table<GradientStops>, gradient_type: vector_types::GradientType) -> Table<GradientStops> {
for value in gradient.iter_attribute_values_mut_or_default::<vector_types::GradientType>(core_types::ATTR_GRADIENT_TYPE) {
*value = gradient_type;
}
gradient
}
/// Sets how each gradient in the input table extends past its endpoints: Pad, Reflect, or Repeat.
#[node_macro::node(category("Color"))]
fn spread_method(_: impl Ctx, mut gradient: Table<GradientStops>, spread_method: vector_types::GradientSpreadMethod) -> Table<GradientStops> {
for value in gradient.iter_attribute_values_mut_or_default::<vector_types::GradientSpreadMethod>(core_types::ATTR_SPREAD_METHOD) {
*value = spread_method;
}
gradient
}
/// Gets the color at the specified position along the gradient, given a position from 0 (left) to 1 (right). /// Gets the color at the specified position along the gradient, given a position from 0 (left) to 1 (right).
#[node_macro::node(category("Color"))] #[node_macro::node(category("Color"))]
fn sample_gradient(_: impl Ctx, _primary: (), gradient: Table<GradientStops>, position: Fraction) -> Table<Color> { fn sample_gradient(_: impl Ctx, _primary: (), gradient: Table<GradientStops>, position: Fraction) -> Table<Color> {