From eb8e0c8d042fd2e58618491e54c7a23c2a83c0de Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Fri, 3 Apr 2026 11:40:41 +0900 Subject: [PATCH 1/7] Add Table gradient rendering * Add SVG and Vello renderers for Table * Add thumbnail rendering for Table * Use row transform to map (0,0), (1,0) unit line to document space * Set 100px width for the initially created gradient * Add support of table gradients for the gradient tool --- Cargo.lock | 1 + .../graph_operation_message.rs | 8 +- .../graph_operation_message_handler.rs | 5 + .../document/graph_operation/utility_types.rs | 16 +- .../document/node_graph/node_properties.rs | 42 ++- .../graph_modification_utils.rs | 13 +- .../tool/tool_messages/gradient_tool.rs | 357 ++++++++++++++++-- editor/src/node_graph_executor/runtime.rs | 2 +- node-graph/graph-craft/src/document/value.rs | 8 +- node-graph/libraries/core-types/src/bounds.rs | 9 + node-graph/libraries/core-types/src/table.rs | 20 + .../libraries/graphic-types/src/graphic.rs | 11 + .../raster-types/src/raster_types.rs | 4 + node-graph/libraries/rendering/Cargo.toml | 1 + .../libraries/rendering/src/renderer.rs | 66 +++- .../libraries/vector-types/src/gradient.rs | 11 + .../vector-types/src/vector/vector_types.rs | 4 + 17 files changed, 500 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2805032f17..f46328cf11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4441,6 +4441,7 @@ dependencies = [ "usvg", "vector-types", "vello", + "vello_encoding", ] [[package]] diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index f572c4c1e9..686856e10a 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -10,9 +10,8 @@ use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; -use graphene_std::vector::PointId; -use graphene_std::vector::VectorModificationType; use graphene_std::vector::style::{Fill, Stroke}; +use graphene_std::vector::{GradientStops, PointId, VectorModificationType}; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -25,6 +24,11 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, fill: f64, }, + GradientTableSet { + layer: LayerNodeIdentifier, + stops: GradientStops, + transform: DAffine2, + }, OpacitySet { layer: LayerNodeIdentifier, opacity: f64, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 701ca2b50e..9bbe8797c8 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -45,6 +45,11 @@ impl MessageHandler> for modify_inputs.blending_fill_set(fill); } } + GraphOperationMessage::GradientTableSet { layer, stops, transform } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.gradient_table_set(stops, transform); + } + } GraphOperationMessage::OpacitySet { layer, opacity } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { modify_inputs.opacity_set(opacity); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index c6c2e4ea46..e8ff9dbbe0 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -7,15 +7,15 @@ use glam::{DAffine2, DVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, concrete}; +use graphene_std::ATTR_TRANSFORM; use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; -use graphene_std::table::Table; +use graphene_std::table::{Table, TableRow}; use graphene_std::text::{Font, TypesettingConfig}; -use graphene_std::vector::Vector; use graphene_std::vector::style::{Fill, Stroke}; -use graphene_std::vector::{PointId, VectorModification, VectorModificationType}; +use graphene_std::vector::{GradientStops, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::{Color, Graphic, NodeInputDecleration}; #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] @@ -461,6 +461,16 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(fill * 100.), false), false); } + pub fn gradient_table_set(&mut self, stops: GradientStops, transform: DAffine2) { + let Some(gradient_node_id) = self.existing_proto_node_id(graphene_std::math_nodes::gradient_value::IDENTIFIER, true) else { + return; + }; + + let table = Table::new_from_row(TableRow::new_from_element(stops).with_attribute(ATTR_TRANSFORM, transform)); + let input_connector = InputConnector::node(gradient_node_id, 1); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientTable(table), false), false); + } + pub fn clip_mode_toggle(&mut self, clip_mode: Option) { let clip = !clip_mode.unwrap_or(false); let Some(clip_node_id) = self.existing_proto_node_id(graphene_std::blending_nodes::blending::IDENTIFIER, true) else { diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index c321eb6e8e..59d8734998 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -13,6 +13,7 @@ use glam::{DAffine2, DVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput}; use graph_craft::{Type, concrete}; +use graphene_std::ATTR_TRANSFORM; use graphene_std::NodeInputDecleration; use graphene_std::animation::RealTimeMode; use graphene_std::extract_xy::XY; @@ -1154,20 +1155,33 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button: .on_commit(commit_value) .widget_instance(), ), - TaggedValue::GradientTable(gradient_table) => widgets.push( - color_button - .value(match gradient_table.element(0) { - Some(gradient) => FillChoice::Gradient(gradient.clone()), - None => FillChoice::Gradient(GradientStops::default()), - }) - .on_update(update_value( - |input: &ColorInput| TaggedValue::GradientTable(input.value.as_gradient().iter().map(|&gradient| TableRow::new_from_element(gradient.clone())).collect()), - node_id, - index, - )) - .on_commit(commit_value) - .widget_instance(), - ), + TaggedValue::GradientTable(gradient_table) => { + let existing_transform: DAffine2 = gradient_table.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + + widgets.push( + color_button + .value(match gradient_table.element(0) { + Some(gradient) => FillChoice::Gradient(gradient.clone()), + None => FillChoice::Gradient(GradientStops::default()), + }) + .on_update(update_value( + move |input: &ColorInput| { + TaggedValue::GradientTable( + input + .value + .as_gradient() + .iter() + .map(|&gradient| TableRow::new_from_element(gradient.clone()).with_attribute(ATTR_TRANSFORM, existing_transform)) + .collect(), + ) + }, + node_id, + index, + )) + .on_commit(commit_value) + .widget_instance(), + ) + } x => warn!("Color {x:?}"), } diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 6298b090ad..3e58f51a58 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -16,7 +16,7 @@ use graphene_std::table::Table; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::misc::ManipulatorPointId; use graphene_std::vector::style::{Fill, Gradient}; -use graphene_std::vector::{PointId, SegmentId, VectorModificationType}; +use graphene_std::vector::{GradientStops, PointId, SegmentId, VectorModificationType}; use std::collections::VecDeque; /// Returns the ID of the first Spline node in the horizontal flow which is not followed by a `Path` node, or `None` if none exists. @@ -280,6 +280,17 @@ pub fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkI Some(gradient.clone()) } +/// Get the gradient table of a layer. +pub fn get_gradient_table(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option> { + let gradient_table_index = 1; + + let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER))?; + let TaggedValue::GradientTable(gradient_table) = inputs.get(gradient_table_index)?.as_value()? else { + return None; + }; + Some(gradient_table.clone()) +} + /// Get the current fill of a layer from the closest "Fill" node. pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { let fill_index = 1; diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 3859505e36..3db823a3ad 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -3,12 +3,17 @@ use crate::consts::{ COLOR_OVERLAY_BLUE, DRAG_THRESHOLD, GRADIENT_MIDPOINT_DIAMOND_RADIUS, GRADIENT_MIDPOINT_MAX, GRADIENT_MIDPOINT_MIN, GRADIENT_STOP_MIN_VIEWPORT_GAP, LINE_ROTATE_SNAP_ANGLE, MANIPULATOR_GROUP_MARKER_SIZE, SEGMENT_INSERTION_DISTANCE, SEGMENT_OVERLAY_SIZE, SELECTION_THRESHOLD, }; +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::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::graph_modification_utils::{NodeGraphLayer, get_gradient}; +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::tool_messages::gradient_tool::graph_modification_utils::is_layer_fed_by_node_of_name; +use graphene_std::ATTR_TRANSFORM; use graphene_std::raster::color::Color; +use graphene_std::vector::gradient::{GRADIENT_TABLE_END, GRADIENT_TABLE_START}; use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStops, GradientType}; #[derive(Default, ExtractField)] @@ -125,8 +130,25 @@ impl<'a> MessageHandler> for Grad self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); let has_gradient = has_gradient_on_selected_layers(context.document); + let is_gradient_table = context + .document + .network_interface + .selected_nodes() + .selected_visible_layers(&context.document.network_interface) + .any(|layer| get_gradient_table(layer, &context.document.network_interface).is_some()); + + let mut options_changed = false; + if has_gradient != self.data.has_selected_gradient { self.data.has_selected_gradient = has_gradient; + options_changed = true; + } + if is_gradient_table != self.data.is_gradient_table { + self.data.is_gradient_table = is_gradient_table; + options_changed = true; + } + + if options_changed { responses.add(ToolMessage::RefreshToolOptions); } @@ -161,22 +183,28 @@ impl<'a> MessageHandler> for Grad impl LayoutHolder for GradientTool { fn layout(&self) -> Layout { - let gradient_type = RadioInput::new(vec![ - RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::Type(GradientType::Linear), - } - .into() - }), - RadioEntryData::new("Radial").label("Radial").tooltip_label("Radial Gradient").on_update(move |_| { - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::Type(GradientType::Radial), - } - .into() - }), - ]) - .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) - .widget_instance(); + let mut widgets: Vec = Vec::new(); + + if !self.data.is_gradient_table { + let gradient_type = RadioInput::new(vec![ + RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::Type(GradientType::Linear), + } + .into() + }), + RadioEntryData::new("Radial").label("Radial").tooltip_label("Radial Gradient").on_update(move |_| { + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::Type(GradientType::Radial), + } + .into() + }), + ]) + .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) + .widget_instance(); + + widgets.extend([gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance()]); + } let reverse_stops = IconButton::new("Reverse", 24) .tooltip_label("Reverse Stops") @@ -213,13 +241,7 @@ impl LayoutHolder for GradientTool { .selected_index(Some(self.options.spread_method as u32)) .widget_instance(); - let mut widgets = vec![ - gradient_type, - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - spread_method, - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - reverse_stops, - ]; + widgets.extend([spread_method, Separator::new(SeparatorStyle::Unrelated).widget_instance(), reverse_stops]); if self.options.gradient_type == GradientType::Radial { let orientation = self @@ -273,14 +295,50 @@ impl Default for GradientToolFsmState { /// Computes the transform from gradient space to viewport space (where gradient space is 0..1) fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 { - let bounds = document.metadata().nonzero_bounding_box(layer); - let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + let is_gradient_table = is_layer_fed_by_node_of_name( + layer, + &document.network_interface, + &DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER), + ); - let multiplied = document.metadata().transform_to_viewport(layer); + if is_gradient_table { + // Table layers use the table's row 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 } +// TODO: This conversion is a temporary solution, this should be removed after migration to Table for all gradient use. +// TODO: We only support linear gradient since there is no place to store the gradient type in the table row currently. +fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + match (get_gradient_table(layer, network_interface), graph_modification_utils::get_gradient(layer, network_interface)) { + (Some(gradient_graphic), _) => { + let stops = gradient_graphic.element(0)?.clone(); + let transform: DAffine2 = gradient_graphic.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let gradient = Gradient { + stops, + gradient_type: GradientType::Linear, + spread_method: GradientSpreadMethod::Pad, + start: transform.transform_point2(GRADIENT_TABLE_START), + end: transform.transform_point2(GRADIENT_TABLE_END), + }; + Some(gradient) + } + (None, Some(gradient)) => Some(gradient), + (None, None) => None, + } +} + /// Whether two adjacent stops are too closely packed in viewport space for a midpoint diamond to be shown or interacted with. fn midpoint_hidden_by_proximity(left_stop_pos: f64, right_stop_pos: f64, viewport_line_length: f64) -> bool { (right_stop_pos - left_stop_pos) * viewport_line_length < GRADIENT_STOP_MIN_VIEWPORT_GAP * 2. @@ -304,6 +362,7 @@ struct SelectedGradient { gradient: Gradient, dragging: GradientDragTarget, initial_gradient: Gradient, + is_gradient_table: bool, } fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: DVec2) -> Option { @@ -347,12 +406,14 @@ fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: D impl SelectedGradient { pub fn new(gradient: Gradient, layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> Self { let transform = gradient_space_transform(layer, document); + let is_gradient_table = get_gradient_table(layer, &document.network_interface).is_some(); Self { layer: Some(layer), transform, gradient: gradient.clone(), dragging: GradientDragTarget::End, initial_gradient: gradient, + is_gradient_table, } } @@ -568,10 +629,23 @@ impl SelectedGradient { /// Update the layer fill to the current gradient pub fn render_gradient(&mut self, responses: &mut VecDeque) { if let Some(layer) = self.layer { - responses.add(GraphOperationMessage::FillSet { - layer, - fill: Fill::Gradient(self.gradient.clone()), - }); + if self.is_gradient_table { + let delta = self.gradient.end - self.gradient.start; + // TODO: Apply scale here when we support elliptical gradients + let perp = DVec2::new(-delta.y, delta.x); + let transform = DAffine2::from_cols_array(&[delta.x, delta.y, perp.x, perp.y, self.gradient.start.x, self.gradient.start.y]); + + responses.add(GraphOperationMessage::GradientTableSet { + layer, + stops: self.gradient.stops.clone(), + transform, + }); + } else { + responses.add(GraphOperationMessage::FillSet { + layer, + fill: Fill::Gradient(self.gradient.clone()), + }); + } } } } @@ -605,6 +679,7 @@ struct GradientToolData { has_selected_gradient: bool, color_picker_editing_color_stop: Option, color_picker_transaction_open: bool, + is_gradient_table: bool, } impl Fsm for GradientToolFsmState { @@ -953,7 +1028,9 @@ impl Fsm for GradientToolFsmState { // The gradient has only one point and so should become a fill if selected_gradient.gradient.stops.len() == 1 { - if let Some(layer) = selected_gradient.layer { + if selected_gradient.is_gradient_table { + selected_gradient.render_gradient(responses); + } else if let Some(layer) = selected_gradient.layer { responses.add(GraphOperationMessage::FillSet { layer, fill: Fill::Solid(selected_gradient.gradient.stops.color[0]), @@ -1047,6 +1124,7 @@ impl Fsm for GradientToolFsmState { for layer in document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface) { let Some(gradient) = get_gradient(layer, &document.network_interface) else { continue }; let transform = gradient_space_transform(layer, document); + let is_gradient_table = get_gradient_table(layer, &document.network_interface).is_some(); // Check for dragging a midpoint diamond if drag_hint.is_none() { @@ -1074,6 +1152,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: GradientDragTarget::Midpoint(i), initial_gradient: gradient.clone(), + is_gradient_table, }); break; @@ -1114,6 +1193,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: drag_target, initial_gradient: gradient.clone(), + is_gradient_table, }); } } @@ -1130,6 +1210,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: dragging_target, initial_gradient: gradient.clone(), + is_gradient_table, }) } } @@ -1526,6 +1607,8 @@ fn apply_gradient_update( let mut transaction_started = false; for layer in selected_layers { + let gradient_table_transform = get_gradient_table(layer, &context.document.network_interface).map(|t| t.attribute_cloned_or_default::(ATTR_TRANSFORM, 0)); + if NodeGraphLayer::is_raster_layer(layer, &mut context.document.network_interface) { continue; } @@ -1538,10 +1621,19 @@ fn apply_gradient_update( transaction_started = true; } update(&mut gradient); - responses.add(GraphOperationMessage::FillSet { - layer, - fill: Fill::Gradient(gradient), - }); + + if let Some(transform) = gradient_table_transform { + responses.add(GraphOperationMessage::GradientTableSet { + layer, + stops: gradient.stops.clone(), + transform, + }); + } else { + responses.add(GraphOperationMessage::FillSet { + layer, + fill: Fill::Gradient(gradient), + }); + }; } } @@ -1623,11 +1715,14 @@ mod test_gradient { use crate::messages::input_mapper::utility_types::input_mouse::ScrollDelta; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; + use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, OutputConnector}; pub use crate::test_utils::test_prelude::*; use glam::DAffine2; - use graphene_std::vector::fill; - use graphene_std::vector::style::Fill; - use graphene_std::vector::style::Gradient; + use graph_craft::document::value::TaggedValue; + use graphene_std::ATTR_TRANSFORM; + use graphene_std::table::{Table, TableRow}; + use graphene_std::vector::style::{Fill, Gradient}; + use graphene_std::vector::{GradientStop, GradientStops, fill}; use super::gradient_space_transform; @@ -1672,6 +1767,45 @@ mod test_gradient { } } + async fn create_gradient_table_layer(editor: &mut EditorTestUtils) -> LayerNodeIdentifier { + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let document = editor.active_document(); + let layer = document.metadata().all_layers().next().unwrap(); + + let gradient_node_id = editor.create_node_by_name(DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER)).await; + + editor + .handle_message(NodeGraphMessage::CreateWire { + output_connector: OutputConnector::node(gradient_node_id, 0), + input_connector: InputConnector::node(layer.to_node(), 1), + }) + .await; + + editor + .handle_message(NodeGraphMessage::SetInputValue { + node_id: gradient_node_id, + input_index: 1, + value: TaggedValue::GradientTable(Table::new_from_row( + TableRow::new_from_element(GradientStops::new([ + GradientStop { + position: 0., + midpoint: 0.5, + color: Color::RED, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: Color::BLUE, + }, + ])) + .with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY), + )), + }) + .await; + + layer + } + #[tokio::test] async fn ignore_artboard() { let mut editor = EditorTestUtils::create(); @@ -2037,4 +2171,149 @@ mod test_gradient { let (gradient, _) = get_gradient(&mut editor).await; assert_eq!(gradient.spread_method, GradientSpreadMethod::Reflect); } + + #[tokio::test] + async fn gradient_table_drag_endpoint() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let layer = create_gradient_table_layer(&mut editor).await; + + // Create original transform for the control geometry and apply it + let initial_start = DVec2::new(10., 50.); + let initial_end = DVec2::new(200., 50.); + let delta = initial_end - initial_start; + let perp = DVec2::new(-delta.y, delta.x); + let initial_row_transform = DAffine2::from_cols_array(&[delta.x, delta.y, perp.x, perp.y, initial_start.x, initial_start.y]); + editor + .handle_message(GraphOperationMessage::GradientTableSet { + layer, + stops: GradientStops::new([ + GradientStop { + position: 0., + midpoint: 0.5, + color: Color::RED, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: Color::BLUE, + }, + ]), + transform: initial_row_transform, + }) + .await; + + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + let document = editor.active_document(); + let space_transform = gradient_space_transform(layer, document); + let gradient = super::get_gradient(layer, &document.network_interface).unwrap(); + let viewport_start = space_transform.transform_point2(gradient.start); + let viewport_end = space_transform.transform_point2(gradient.end); + + // Drag target of the end point, move 80px down + let new_viewport_end = viewport_end + DVec2::new(0., 80.); + editor.select_tool(ToolType::Gradient).await; + editor.move_mouse(viewport_end.x, viewport_end.y, ModifierKeys::empty(), MouseKeys::empty()).await; + editor.left_mousedown(viewport_end.x, viewport_end.y, ModifierKeys::empty()).await; + editor.move_mouse(new_viewport_end.x, new_viewport_end.y, ModifierKeys::empty(), MouseKeys::LEFT).await; + editor + .mouseup( + EditorMouseState { + editor_position: new_viewport_end, + mouse_keys: MouseKeys::empty(), + scroll_delta: ScrollDelta::default(), + }, + ModifierKeys::empty(), + ) + .await; + + // Verify if the gradient position is updated correctly + let document = editor.active_document(); + let updated = super::get_gradient(layer, &document.network_interface).expect("Gradient should exist after drag"); + let updated_space_transform = gradient_space_transform(layer, document); + let updated_viewport_start = updated_space_transform.transform_point2(updated.start); + let updated_viewport_end = updated_space_transform.transform_point2(updated.end); + + assert!( + updated_viewport_start.abs_diff_eq(viewport_start, 1.), + "Start should not move. Expected {viewport_start:?}, got {updated_viewport_start:?}" + ); + assert!( + updated_viewport_end.abs_diff_eq(new_viewport_end, 1.), + "End should move to new position. Expected {new_viewport_end:?}, got {updated_viewport_end:?}" + ); + } + + #[tokio::test] + async fn gradient_table_preserves_stops() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let layer = create_gradient_table_layer(&mut editor).await; + + // Set up a 3-stop gradient with distinct colors + let original_stops = GradientStops::new([ + GradientStop { + position: 0., + midpoint: 0.5, + color: Color::RED, + }, + GradientStop { + position: 0.5, + midpoint: 0.5, + color: Color::GREEN, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: Color::BLUE, + }, + ]); + let initial_start = DVec2::new(10., 50.); + let initial_end = DVec2::new(200., 50.); + let delta = initial_end - initial_start; + let perp = DVec2::new(-delta.y, delta.x); + let initial_row_transform = DAffine2::from_cols_array(&[delta.x, delta.y, perp.x, perp.y, initial_start.x, initial_start.y]); + editor + .handle_message(GraphOperationMessage::GradientTableSet { + layer, + stops: original_stops.clone(), + transform: initial_row_transform, + }) + .await; + + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }).await; + + let document = editor.active_document(); + let space_transform = gradient_space_transform(layer, document); + let gradient = super::get_gradient(layer, &document.network_interface).unwrap(); + let viewport_end = space_transform.transform_point2(gradient.end); + + // Drag the end point 80px down + let new_viewport_end = viewport_end + DVec2::new(0., 80.); + editor.select_tool(ToolType::Gradient).await; + editor.move_mouse(viewport_end.x, viewport_end.y, ModifierKeys::empty(), MouseKeys::empty()).await; + editor.left_mousedown(viewport_end.x, viewport_end.y, ModifierKeys::empty()).await; + editor.move_mouse(new_viewport_end.x, new_viewport_end.y, ModifierKeys::empty(), MouseKeys::LEFT).await; + editor + .mouseup( + EditorMouseState { + editor_position: new_viewport_end, + mouse_keys: MouseKeys::empty(), + scroll_delta: ScrollDelta::default(), + }, + ModifierKeys::empty(), + ) + .await; + + // Verify stops are preserved after dragging + let document = editor.active_document(); + let updated = super::get_gradient(layer, &document.network_interface).expect("Gradient should exist after drag"); + + 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!(updated.stops.color[0].to_rgba8_srgb(), Color::RED.to_rgba8_srgb(), "First stop color should be preserved"); + assert_eq!(updated.stops.color[1].to_rgba8_srgb(), Color::GREEN.to_rgba8_srgb(), "Middle stop color should be preserved"); + assert_eq!(updated.stops.color[2].to_rgba8_srgb(), Color::BLUE.to_rgba8_srgb(), "Last stop color should be preserved"); + } } diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 0766ffadce..b52a8c47d6 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -471,7 +471,7 @@ impl NodeRuntime { return; } - let bounds = match graphic.bounding_box(DAffine2::IDENTITY, true) { + let bounds = match graphic.thumbnail_bounding_box(DAffine2::IDENTITY, true) { RenderBoundingBox::None => None, RenderBoundingBox::Infinite => Some([DVec2::ZERO, DVec2::new(300., 200.)]), RenderBoundingBox::Rectangle(bounds) => Some(bounds), diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 503591010e..0826ab819c 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -3,10 +3,10 @@ use crate::application_io::PlatformEditorApi; use crate::proto::{Any as DAny, FutureAny}; use brush_nodes::brush_cache::BrushCache; use brush_nodes::brush_stroke::BrushStroke; -use core_types::table::Table; +use core_types::table::{Table, TableRow}; use core_types::transform::Footprint; use core_types::uuid::NodeId; -use core_types::{CacheHash, Color, ContextFeatures, MemoHash, Node, Type}; +use core_types::{ATTR_TRANSFORM, CacheHash, Color, ContextFeatures, MemoHash, Node, Type}; use dyn_any::DynAny; pub use dyn_any::StaticType; use glam::{Affine2, Vec2}; @@ -118,7 +118,9 @@ macro_rules! tagged_value { x if x == TypeId::of::<()>() => TaggedValue::None, // Table-wrapped types need a single-item default with the element's default, not an empty table x if x == TypeId::of::>() => TaggedValue::Color(Table::new_from_element(Color::default())), - x if x == TypeId::of::>() => TaggedValue::GradientTable(Table::new_from_element(GradientStops::default())), + x if x == TypeId::of::>() => TaggedValue::GradientTable(Table::new_from_row( + TableRow::new_from_element(GradientStops::default()).with_attribute(ATTR_TRANSFORM, DAffine2::from_scale(DVec2::splat(100.))), + )), $( x if x == TypeId::of::<$ty>() => TaggedValue::$identifier(Default::default()), )* _ => return None, }) diff --git a/node-graph/libraries/core-types/src/bounds.rs b/node-graph/libraries/core-types/src/bounds.rs index d59902ff0a..5a7e4762c7 100644 --- a/node-graph/libraries/core-types/src/bounds.rs +++ b/node-graph/libraries/core-types/src/bounds.rs @@ -11,6 +11,7 @@ pub enum RenderBoundingBox { pub trait BoundingBox { fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox; + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox; } macro_rules! none_impl { @@ -19,6 +20,10 @@ macro_rules! none_impl { fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { RenderBoundingBox::None } + + fn thumbnail_bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { + RenderBoundingBox::None + } } }; } @@ -32,4 +37,8 @@ impl BoundingBox for Color { fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { RenderBoundingBox::Infinite } + + fn thumbnail_bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { + RenderBoundingBox::Rectangle([DVec2::ZERO, DVec2::new(300., 200.)]) + } } diff --git a/node-graph/libraries/core-types/src/table.rs b/node-graph/libraries/core-types/src/table.rs index 909b3237cd..d64562b2f9 100644 --- a/node-graph/libraries/core-types/src/table.rs +++ b/node-graph/libraries/core-types/src/table.rs @@ -844,6 +844,26 @@ impl BoundingBox for Table { None => RenderBoundingBox::None, } } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + let mut combined_bounds = None; + + for (element, row_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::(ATTR_TRANSFORM)) { + match element.thumbnail_bounding_box(transform * row_transform, include_stroke) { + RenderBoundingBox::None => continue, + RenderBoundingBox::Infinite => return RenderBoundingBox::Infinite, + RenderBoundingBox::Rectangle(bounds) => match combined_bounds { + Some(existing) => combined_bounds = Some(Quad::combine_bounds(existing, bounds)), + None => combined_bounds = Some(bounds), + }, + } + } + + match combined_bounds { + Some(bounds) => RenderBoundingBox::Rectangle(bounds), + None => RenderBoundingBox::None, + } + } } impl IntoIterator for Table { diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 0cfcd20176..d1354ea250 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -357,6 +357,17 @@ impl BoundingBox for Graphic { Graphic::Gradient(table) => table.bounding_box(transform, include_stroke), } } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + match self { + Graphic::Vector(vector) => vector.thumbnail_bounding_box(transform, include_stroke), + Graphic::RasterCPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke), + Graphic::RasterGPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke), + Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke), + Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke), + Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke), + } + } } impl TableConvert for Vector { diff --git a/node-graph/libraries/raster-types/src/raster_types.rs b/node-graph/libraries/raster-types/src/raster_types.rs index 7372b3b76f..c80ed8710f 100644 --- a/node-graph/libraries/raster-types/src/raster_types.rs +++ b/node-graph/libraries/raster-types/src/raster_types.rs @@ -227,6 +227,10 @@ where let unit_rectangle = Quad::from_box([DVec2::ZERO, DVec2::ONE]); RenderBoundingBox::Rectangle((transform * unit_rectangle).bounding_box()) } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + self.bounding_box(transform, include_stroke) + } } // RenderComplexity trait implementations diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index 4f25fcdd5a..1fdfb0c839 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -26,6 +26,7 @@ kurbo = { workspace = true } vector-types = { workspace = true } graphic-types = { workspace = true } vello = { workspace = true } +vello_encoding = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index db9372743e..b9c1513bde 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -27,7 +27,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::GradientSpreadMethod; +use vector_types::gradient::{GRADIENT_TABLE_END, GRADIENT_TABLE_START, GradientSpreadMethod}; use vello::*; /// Cached 16x16 transparency checkerboard image data (two 8x8 cells of #ffffff and #cccccc). @@ -287,6 +287,10 @@ pub fn to_transform(transform: DAffine2) -> usvg::Transform { usvg::Transform::from_row(cols[0] as f32, cols[1] as f32, cols[2] as f32, cols[3] as f32, cols[4] as f32, cols[5] as f32) } +fn to_point(p: DVec2) -> kurbo::Point { + kurbo::Point::new(p.x, p.y) +} + fn get_outline_styles(render_params: &RenderParams) -> (kurbo::Stroke, peniko::Color) { use core_types::consts::LAYER_OUTLINE_STROKE_WEIGHT; @@ -1088,7 +1092,6 @@ impl Render for Table { } let layer_bounds = element.bounding_box().unwrap_or_default(); - let to_point = |p: DVec2| kurbo::Point::new(p.x, p.y); let mut path = kurbo::BezPath::new(); for mut bezpath in element.stroke_bezpath_iter() { bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); @@ -1749,13 +1752,12 @@ impl Render for Table { } impl Render for Table { - // TODO: Fix infinite gradient rendering fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for index in 0..self.len() { let Some(gradient) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let alpha_blending: AlphaBlending = self.attribute_cloned_or_default(ATTR_ALPHA_BLENDING, index); - render.leaf_tag("rect", |attributes| { + render.leaf_tag("polyline", |attributes| { // Chrome doesn't like drawing centered rectangles bigger than ~20 million so we draw a polyline quad instead let max = u64::MAX; attributes.push("points", format!("{max},{max} -{max},{max} -{max},-{max} {max},-{max}")); @@ -1772,7 +1774,8 @@ impl Render for Table { stop_string.push_str(" />"); } - let gradient_transform = render_params.footprint.transform * transform; + // render_thumbnail already added the footprint transform + let gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform }; let gradient_transform_matrix = format_transform_matrix(gradient_transform); let gradient_transform_attribute = if gradient_transform_matrix.is_empty() { String::new() @@ -1781,10 +1784,11 @@ impl Render for Table { }; let gradient_id = generate_uuid(); - let start = DVec2::ZERO; - let end = DVec2::X; + let start = GRADIENT_TABLE_START; + let end = GRADIENT_TABLE_END; - match GradientType::Radial { + // Linear gradient only for now + match GradientType::Linear { GradientType::Linear => { let (x1, y1) = (start.x, start.y); let (x2, y2) = (end.x, end.y); @@ -1817,28 +1821,60 @@ impl Render for Table { } } - // TODO: Fix infinite gradient rendering - fn render_to_vello(&self, scene: &mut Scene, _parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { use vello::peniko; - for (gradient, alpha_blending) in self.iter_element_values().zip(self.iter_attribute_values_or_default::(ATTR_ALPHA_BLENDING)) { + if let RenderMode::Outline = render_params.render_mode { + return; + } + + for ((gradient, transform), alpha_blending) in self + .iter_element_values() + .zip(self.iter_attribute_values_or_default::(ATTR_TRANSFORM)) + .zip(self.iter_attribute_values_or_default::(ATTR_ALPHA_BLENDING)) + { + let gradient_transform = parent_transform * transform; + let blend_mode = alpha_blending.blend_mode.to_peniko(); let opacity = alpha_blending.opacity(render_params.for_mask); - let color = gradient.color.first().copied().unwrap_or(Color::MAGENTA); - let vello_color = peniko::Color::new([color.r(), color.g(), color.b(), color.a()]); + let mut stops: peniko::ColorStops = peniko::ColorStops::new(); + for (position, color, _) in gradient.interpolated_samples() { + stops.push(peniko::ColorStop { + offset: position as f32, + color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])), + }) + } + let fill = peniko::Brush::Gradient(peniko::Gradient { + kind: peniko::LinearGradientPosition { + start: to_point(GRADIENT_TABLE_START), + end: to_point(GRADIENT_TABLE_END), + } + .into(), + stops, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, + ..Default::default() + }); + let brush_transform = kurbo::Affine::new((gradient_transform).to_cols_array()); let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); let mut layer = false; if opacity < 1. || alpha_blending.blend_mode != BlendMode::default() { let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - // See implemenation in `Table` for more detail + // See implementation in `Table` for more detail scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect); layer = true; } - scene.fill(peniko::Fill::NonZero, kurbo::Affine::scale(f64::INFINITY), vello_color, None, &rect); + // Encode shape and brush manually instead of Scene.fill(), which would multiply brush_transform by the path transform. + scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&kurbo::Affine::scale(f64::INFINITY))); + scene.encoding_mut().encode_fill_style(peniko::Fill::NonZero); + scene.encoding_mut().encode_shape(&rect, true); + + scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&brush_transform)); + scene.encoding_mut().swap_last_path_tags(); + scene.encoding_mut().encode_brush(&fill, 1.0); if layer { scene.pop_layer(); diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 86a04397e0..9d0ab389f2 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -2,6 +2,9 @@ use core_types::{Color, render_complexity::RenderComplexity}; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; +pub const GRADIENT_TABLE_START: DVec2 = DVec2::ZERO; +pub const GRADIENT_TABLE_END: DVec2 = DVec2::X; + #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -488,4 +491,12 @@ impl core_types::bounds::BoundingBox for GradientStops { fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { core_types::bounds::RenderBoundingBox::Infinite } + + fn thumbnail_bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { + let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE].map(|vec| transform.transform_point2(vec)); + let min = corners.iter().fold(DVec2::MAX, |acc, &p| acc.min(p)); + let max = corners.iter().fold(DVec2::MIN, |acc, &p| acc.max(p)); + + core_types::bounds::RenderBoundingBox::Rectangle([min, max]) + } } diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index b418310071..0477f60cc6 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -468,6 +468,10 @@ impl BoundingBox for Vector { None => RenderBoundingBox::None, } } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + BoundingBox::bounding_box(self, transform, include_stroke) + } } impl RenderComplexity for Vector { From bd793746ca92bb22504c430040edf2a97fc4fca1 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Fri, 3 Apr 2026 15:12:32 +0900 Subject: [PATCH 2/7] Fix after review * Thumbnail rendering of artboard with infinite gradient layer * Hide radial gradient's reverse direction button for gradient table * Remove unused imports --- editor/src/messages/tool/tool_messages/gradient_tool.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 3db823a3ad..c7888b108d 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -243,7 +243,7 @@ impl LayoutHolder for GradientTool { widgets.extend([spread_method, Separator::new(SeparatorStyle::Unrelated).widget_instance(), reverse_stops]); - if self.options.gradient_type == GradientType::Radial { + if self.options.gradient_type == GradientType::Radial && !self.data.is_gradient_table { let orientation = self .data .selected_gradient From e3b91a577ddb338df319296dc88d6219dcbcf725 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 14 Apr 2026 16:17:21 +0900 Subject: [PATCH 3/7] Format --- .../document/graph_operation/graph_operation_message.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 686856e10a..e352e17bf4 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -12,6 +12,7 @@ use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{Fill, Stroke}; use graphene_std::vector::{GradientStops, PointId, VectorModificationType}; +use graphene_std::{Artboard, Color}; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] From d75046fa2a854d90d2cada149cccd3533aae86ac Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Tue, 14 Apr 2026 18:17:29 +0900 Subject: [PATCH 4/7] Fix conflict with spread method --- .../graph_operation_message.rs | 1 - .../tool/tool_messages/gradient_tool.rs | 32 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index e352e17bf4..686856e10a 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -12,7 +12,6 @@ use graphene_std::subpath::Subpath; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{Fill, Stroke}; use graphene_std::vector::{GradientStops, PointId, VectorModificationType}; -use graphene_std::{Artboard, Color}; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index c7888b108d..940d37e351 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -203,7 +203,35 @@ impl LayoutHolder for GradientTool { .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) .widget_instance(); - widgets.extend([gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance()]); + let spread_method = RadioInput::new(vec![ + RadioEntryData::new("Pad").label("Pad").tooltip_label("Pad").on_update(move |_| { + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Pad), + } + .into() + }), + RadioEntryData::new("Reflect").label("Reflect").tooltip_label("Reflect").on_update(move |_| { + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect), + } + .into() + }), + RadioEntryData::new("Repeat").label("Repeat").tooltip_label("Repeat").on_update(move |_| { + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat), + } + .into() + }), + ]) + .selected_index(Some(self.options.spread_method as u32)) + .widget_instance(); + + widgets.extend([ + gradient_type, + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + spread_method, + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + ]); } let reverse_stops = IconButton::new("Reverse", 24) @@ -319,7 +347,7 @@ fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessa } // TODO: This conversion is a temporary solution, this should be removed after migration to Table for all gradient use. -// TODO: We only support linear gradient since there is no place to store the gradient type in the table row currently. +// TODO: We only support linear gradient with pad spread method since there is no place to store the gradient type in the table row currently. fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { match (get_gradient_table(layer, network_interface), graph_modification_utils::get_gradient(layer, network_interface)) { (Some(gradient_graphic), _) => { From a2e9c49ee8c8e12af810420c9242a64ba23f1b56 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 29 Apr 2026 00:39:49 -0700 Subject: [PATCH 5/7] Code review --- editor/src/lib.rs | 4 + .../graph_operation_message.rs | 4 +- .../graph_operation_message_handler.rs | 4 +- .../document/graph_operation/utility_types.rs | 10 +- .../graph_modification_utils.rs | 4 +- .../tool/tool_messages/gradient_tool.rs | 140 ++++++++---------- editor/src/node_graph_executor/runtime.rs | 4 +- node-graph/graph-craft/src/document/value.rs | 3 +- node-graph/libraries/core-types/src/bounds.rs | 17 ++- node-graph/libraries/core-types/src/table.rs | 14 +- .../libraries/rendering/src/renderer.rs | 20 +-- .../libraries/vector-types/src/gradient.rs | 6 +- 12 files changed, 111 insertions(+), 119 deletions(-) diff --git a/editor/src/lib.rs b/editor/src/lib.rs index df7382343f..5acc1c2ed1 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -1,3 +1,7 @@ +// Bumped past the default 128 because the deeply-generic message-passing types pull in wgpu/naga +// trait chains that overflow the trait resolver under `--tests`. Set to the same value the compiler suggests. +#![recursion_limit = "256"] + extern crate graphite_proc_macros; // `macro_use` puts these macros into scope for all descendant code files diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 686856e10a..1a92c6c670 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -9,6 +9,7 @@ use graphene_std::color::Color; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; +use graphene_std::table::Table; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{Fill, Stroke}; use graphene_std::vector::{GradientStops, PointId, VectorModificationType}; @@ -26,8 +27,7 @@ pub enum GraphOperationMessage { }, GradientTableSet { layer: LayerNodeIdentifier, - stops: GradientStops, - transform: DAffine2, + gradient_table: Table, }, OpacitySet { layer: LayerNodeIdentifier, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 9bbe8797c8..404c4337e7 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -45,9 +45,9 @@ impl MessageHandler> for modify_inputs.blending_fill_set(fill); } } - GraphOperationMessage::GradientTableSet { layer, stops, transform } => { + GraphOperationMessage::GradientTableSet { layer, gradient_table } => { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { - modify_inputs.gradient_table_set(stops, transform); + modify_inputs.gradient_table_set(gradient_table); } } GraphOperationMessage::OpacitySet { layer, opacity } => { diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index e8ff9dbbe0..a5b9ff49e3 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -7,12 +7,11 @@ use glam::{DAffine2, DVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, concrete}; -use graphene_std::ATTR_TRANSFORM; use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::Image; use graphene_std::subpath::Subpath; -use graphene_std::table::{Table, TableRow}; +use graphene_std::table::Table; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::style::{Fill, Stroke}; use graphene_std::vector::{GradientStops, PointId, Vector, VectorModification, VectorModificationType}; @@ -461,14 +460,13 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(fill * 100.), false), false); } - pub fn gradient_table_set(&mut self, stops: GradientStops, transform: DAffine2) { + pub fn gradient_table_set(&mut self, gradient_table: Table) { let Some(gradient_node_id) = self.existing_proto_node_id(graphene_std::math_nodes::gradient_value::IDENTIFIER, true) else { return; }; - let table = Table::new_from_row(TableRow::new_from_element(stops).with_attribute(ATTR_TRANSFORM, transform)); - let input_connector = InputConnector::node(gradient_node_id, 1); - self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::GradientTable(table), false), false); + 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); } pub fn clip_mode_toggle(&mut self, clip_mode: Option) { diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 3e58f51a58..c9fbedec97 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -282,10 +282,8 @@ pub fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkI /// Get the gradient table of a layer. pub fn get_gradient_table(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option> { - let gradient_table_index = 1; - let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER))?; - let TaggedValue::GradientTable(gradient_table) = inputs.get(gradient_table_index)?.as_value()? else { + let TaggedValue::GradientTable(gradient_table) = inputs.get(graphene_std::math_nodes::gradient_value::GradientInput::INDEX)?.as_value()? else { return None; }; Some(gradient_table.clone()) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 940d37e351..6098db74c3 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -8,12 +8,11 @@ use crate::messages::portfolio::document::overlays::utility_types::{GizmoEmphasi use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::tool::common_functionality::auto_panning::AutoPanning; -use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_gradient_table}; +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::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; -use crate::messages::tool::tool_messages::gradient_tool::graph_modification_utils::is_layer_fed_by_node_of_name; use graphene_std::ATTR_TRANSFORM; use graphene_std::raster::color::Color; -use graphene_std::vector::gradient::{GRADIENT_TABLE_END, GRADIENT_TABLE_START}; +use graphene_std::table::{Table, TableRow}; use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStops, GradientType}; #[derive(Default, ExtractField)] @@ -130,6 +129,7 @@ impl<'a> MessageHandler> for Grad self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); let has_gradient = has_gradient_on_selected_layers(context.document); + // TODO: Drop this detection (and the `is_gradient_table` field) once all gradients are `Table` let is_gradient_table = context .document .network_interface @@ -185,6 +185,7 @@ impl LayoutHolder for GradientTool { fn layout(&self) -> Layout { let mut widgets: Vec = Vec::new(); + // TODO: Drop the `is_gradient_table` guard once `Table` rows can store the gradient type, as currently only legacy `Fill::Gradient` exposes Linear/Radial if !self.data.is_gradient_table { let gradient_type = RadioInput::new(vec![ RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { @@ -203,35 +204,7 @@ impl LayoutHolder for GradientTool { .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) .widget_instance(); - let spread_method = RadioInput::new(vec![ - RadioEntryData::new("Pad").label("Pad").tooltip_label("Pad").on_update(move |_| { - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Pad), - } - .into() - }), - RadioEntryData::new("Reflect").label("Reflect").tooltip_label("Reflect").on_update(move |_| { - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect), - } - .into() - }), - RadioEntryData::new("Repeat").label("Repeat").tooltip_label("Repeat").on_update(move |_| { - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat), - } - .into() - }), - ]) - .selected_index(Some(self.options.spread_method as u32)) - .widget_instance(); - - widgets.extend([ - gradient_type, - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - spread_method, - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - ]); + widgets.extend([gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance()]); } let reverse_stops = IconButton::new("Reverse", 24) @@ -247,19 +220,19 @@ impl LayoutHolder for GradientTool { .widget_instance(); let spread_method = RadioInput::new(vec![ - RadioEntryData::new("Pad").label("Pad").tooltip_label("Pad").on_update(move |_| { + RadioEntryData::new("Pad").label("Pad").tooltip_label("Pad Spread Method").on_update(move |_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Pad), } .into() }), - RadioEntryData::new("Reflect").label("Reflect").tooltip_label("Reflect").on_update(move |_| { + RadioEntryData::new("Reflect").label("Reflect").tooltip_label("Reflect Spread Method").on_update(move |_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Reflect), } .into() }), - RadioEntryData::new("Repeat").label("Repeat").tooltip_label("Repeat").on_update(move |_| { + RadioEntryData::new("Repeat").label("Repeat").tooltip_label("Repeat Spread Method").on_update(move |_| { GradientToolMessage::UpdateOptions { options: GradientOptionsUpdate::SetSpreadMethod(GradientSpreadMethod::Repeat), } @@ -271,6 +244,7 @@ impl LayoutHolder for GradientTool { widgets.extend([spread_method, Separator::new(SeparatorStyle::Unrelated).widget_instance(), reverse_stops]); + // TODO: Drop the `!is_gradient_table` guard once `Table` supports radial gradients if self.options.gradient_type == GradientType::Radial && !self.data.is_gradient_table { let orientation = self .data @@ -323,6 +297,7 @@ impl Default for GradientToolFsmState { /// Computes the transform from gradient space to viewport space (where gradient space is 0..1) fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 { + // TODO: Drop the `is_gradient_table` branch once all gradients are `Table`, only the upstream-footprint path will remain let is_gradient_table = is_layer_fed_by_node_of_name( layer, &document.network_interface, @@ -330,8 +305,8 @@ fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessa ); if is_gradient_table { - // Table layers use the table's row transform from gradient space to document space, - // so we cannot use transform_to_viewport here as it would apply the transform twice. + // Table 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 @@ -346,8 +321,19 @@ fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessa multiplied * bound_transform } -// TODO: This conversion is a temporary solution, this should be removed after migration to Table for all gradient use. -// TODO: We only support linear gradient with pad spread method since there is no place to store the gradient type in the table row currently. +/// 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`, callers will read the table directly +// TODO: Until then, only Linear + `Pad` spread are produced from a table since rows can't carry the gradient type or spread method yet fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { match (get_gradient_table(layer, network_interface), graph_modification_utils::get_gradient(layer, network_interface)) { (Some(gradient_graphic), _) => { @@ -357,8 +343,8 @@ fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter stops, gradient_type: GradientType::Linear, spread_method: GradientSpreadMethod::Pad, - start: transform.transform_point2(GRADIENT_TABLE_START), - end: transform.transform_point2(GRADIENT_TABLE_END), + start: transform.transform_point2(DVec2::ZERO), + end: transform.transform_point2(DVec2::X), }; Some(gradient) } @@ -390,6 +376,7 @@ struct SelectedGradient { gradient: Gradient, dragging: GradientDragTarget, initial_gradient: Gradient, + // TODO: Remove (and the matching branches in `render_gradient` / pointer-up) once `Table` replaces legacy `Fill::Gradient` is_gradient_table: bool, } @@ -657,17 +644,11 @@ impl SelectedGradient { /// Update the layer fill to the current gradient pub fn render_gradient(&mut self, responses: &mut VecDeque) { if let Some(layer) = self.layer { + // TODO: Drop the `Fill::Gradient` branch when all gradients become `Table` if self.is_gradient_table { - let delta = self.gradient.end - self.gradient.start; - // TODO: Apply scale here when we support elliptical gradients - let perp = DVec2::new(-delta.y, delta.x); - let transform = DAffine2::from_cols_array(&[delta.x, delta.y, perp.x, perp.y, self.gradient.start.x, self.gradient.start.y]); - - responses.add(GraphOperationMessage::GradientTableSet { - layer, - stops: self.gradient.stops.clone(), - transform, - }); + let gradient_table = + Table::new_from_row(TableRow::new_from_element(self.gradient.stops.clone()).with_attribute(ATTR_TRANSFORM, gradient_item_transform(self.gradient.start, self.gradient.end))); + responses.add(GraphOperationMessage::GradientTableSet { layer, gradient_table }); } else { responses.add(GraphOperationMessage::FillSet { layer, @@ -707,6 +688,7 @@ struct GradientToolData { has_selected_gradient: bool, color_picker_editing_color_stop: Option, color_picker_transaction_open: bool, + // TODO: Remove (and the conditionals it gates in `LayoutHolder::layout`) once `Table` replaces legacy `Fill::Gradient` is_gradient_table: bool, } @@ -1055,6 +1037,7 @@ impl Fsm for GradientToolFsmState { }; // The gradient has only one point and so should become a fill + // TODO: Drop the legacy `Fill::Solid` branch when all gradients become `Table`, the table just retains the single stop if selected_gradient.gradient.stops.len() == 1 { if selected_gradient.is_gradient_table { selected_gradient.render_gradient(responses); @@ -1635,8 +1618,6 @@ fn apply_gradient_update( let mut transaction_started = false; for layer in selected_layers { - let gradient_table_transform = get_gradient_table(layer, &context.document.network_interface).map(|t| t.attribute_cloned_or_default::(ATTR_TRANSFORM, 0)); - if NodeGraphLayer::is_raster_layer(layer, &mut context.document.network_interface) { continue; } @@ -1650,18 +1631,18 @@ fn apply_gradient_update( } update(&mut gradient); - if let Some(transform) = gradient_table_transform { - responses.add(GraphOperationMessage::GradientTableSet { - layer, - stops: gradient.stops.clone(), - transform, - }); + // 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`, the lookup will then be unconditional + if let Some(existing_table) = get_gradient_table(layer, &context.document.network_interface) { + let transform = existing_table.attribute_cloned_or_default::(ATTR_TRANSFORM, 0); + let gradient_table = Table::new_from_row(TableRow::new_from_element(gradient.stops.clone()).with_attribute(ATTR_TRANSFORM, transform)); + responses.add(GraphOperationMessage::GradientTableSet { layer, gradient_table }); } else { responses.add(GraphOperationMessage::FillSet { layer, fill: Fill::Gradient(gradient), }); - }; + } } } @@ -2209,25 +2190,25 @@ mod test_gradient { // Create original transform for the control geometry and apply it let initial_start = DVec2::new(10., 50.); let initial_end = DVec2::new(200., 50.); - let delta = initial_end - initial_start; - let perp = DVec2::new(-delta.y, delta.x); - let initial_row_transform = DAffine2::from_cols_array(&[delta.x, delta.y, perp.x, perp.y, initial_start.x, initial_start.y]); + let initial_item_transform = super::gradient_item_transform(initial_start, initial_end); editor .handle_message(GraphOperationMessage::GradientTableSet { layer, - stops: GradientStops::new([ - GradientStop { - position: 0., - midpoint: 0.5, - color: Color::RED, - }, - GradientStop { - position: 1., - midpoint: 0.5, - color: Color::BLUE, - }, - ]), - transform: initial_row_transform, + gradient_table: Table::new_from_row( + TableRow::new_from_element(GradientStops::new([ + 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; @@ -2299,14 +2280,11 @@ mod test_gradient { ]); let initial_start = DVec2::new(10., 50.); let initial_end = DVec2::new(200., 50.); - let delta = initial_end - initial_start; - let perp = DVec2::new(-delta.y, delta.x); - let initial_row_transform = DAffine2::from_cols_array(&[delta.x, delta.y, perp.x, perp.y, initial_start.x, initial_start.y]); + let initial_item_transform = super::gradient_item_transform(initial_start, initial_end); editor .handle_message(GraphOperationMessage::GradientTableSet { layer, - stops: original_stops.clone(), - transform: initial_row_transform, + gradient_table: Table::new_from_row(TableRow::new_from_element(original_stops.clone()).with_attribute(ATTR_TRANSFORM, initial_item_transform)), }) .await; diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index b52a8c47d6..c7150badfe 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -8,7 +8,7 @@ use graph_craft::graphene_compiler::Compiler; use graph_craft::proto::GraphErrors; use graph_craft::{ProtoNodeIdentifier, concrete}; use graphene_std::application_io::{ApplicationIo, ExportFormat, ImageTexture, NodeGraphUpdateMessage, NodeGraphUpdateSender, RenderConfig}; -use graphene_std::bounds::RenderBoundingBox; +use graphene_std::bounds::{DEFAULT_THUMBNAIL_BOUNDS, RenderBoundingBox}; use graphene_std::memo::IORecord; use graphene_std::ops::Convert; #[cfg(all(target_family = "wasm", feature = "gpu", feature = "wasm"))] @@ -473,7 +473,7 @@ impl NodeRuntime { let bounds = match graphic.thumbnail_bounding_box(DAffine2::IDENTITY, true) { RenderBoundingBox::None => None, - RenderBoundingBox::Infinite => Some([DVec2::ZERO, DVec2::new(300., 200.)]), + RenderBoundingBox::Infinite => Some(DEFAULT_THUMBNAIL_BOUNDS), RenderBoundingBox::Rectangle(bounds) => Some(bounds), }; let new_thumbnail_svg = if let Some(bounds) = bounds { diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 0826ab819c..23e88f23ac 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -12,6 +12,7 @@ pub use dyn_any::StaticType; use glam::{Affine2, Vec2}; pub use glam::{DAffine2, DVec2, IVec2, UVec2}; 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::{self, ReferencePoint}; use graphic_types::{Graphic, Vector}; @@ -119,7 +120,7 @@ macro_rules! tagged_value { // Table-wrapped types need a single-item default with the element's default, not an empty table x if x == TypeId::of::>() => TaggedValue::Color(Table::new_from_element(Color::default())), x if x == TypeId::of::>() => TaggedValue::GradientTable(Table::new_from_row( - TableRow::new_from_element(GradientStops::default()).with_attribute(ATTR_TRANSFORM, DAffine2::from_scale(DVec2::splat(100.))), + 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()), )* _ => return None, diff --git a/node-graph/libraries/core-types/src/bounds.rs b/node-graph/libraries/core-types/src/bounds.rs index 5a7e4762c7..ca96eb2224 100644 --- a/node-graph/libraries/core-types/src/bounds.rs +++ b/node-graph/libraries/core-types/src/bounds.rs @@ -1,6 +1,13 @@ use crate::Color; use glam::{DAffine2, DVec2}; +/// Fallback rectangle used as the thumbnail bounding box for types whose normal bounding box is +/// `RenderBoundingBox::Infinite` (currently just solid `Color`). Thumbnail rendering needs a finite +/// preview area, so this is what callers substitute when their thumbnail bounding box query returns +/// `Infinite`, either by returning it directly from `thumbnail_bounding_box` or by mapping it from +/// `Infinite` at the call site. +pub const DEFAULT_THUMBNAIL_BOUNDS: [DVec2; 2] = [DVec2::ZERO, DVec2::new(300., 200.)]; + #[derive(Clone, Copy, Default, Debug, PartialEq)] pub enum RenderBoundingBox { #[default] @@ -11,6 +18,12 @@ pub enum RenderBoundingBox { pub trait BoundingBox { fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox; + + /// Returns a finite bounding box suitable for rendering a thumbnail. + /// + /// Differs from `bounding_box` only for types that would otherwise return + /// `RenderBoundingBox::Infinite` (e.g., `Color`, `GradientStops`). + /// Those substitute a finite fallback rectangle so the thumbnail has a defined area to render into. fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox; } @@ -39,6 +52,8 @@ impl BoundingBox for Color { } fn thumbnail_bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { - RenderBoundingBox::Rectangle([DVec2::ZERO, DVec2::new(300., 200.)]) + // A solid color has no intrinsic extent (its `bounding_box` is `Infinite`), + // so we substitute a finite fallback so the thumbnail has a defined area to fill. + RenderBoundingBox::Rectangle(DEFAULT_THUMBNAIL_BOUNDS) } } diff --git a/node-graph/libraries/core-types/src/table.rs b/node-graph/libraries/core-types/src/table.rs index d64562b2f9..ab6d7b1bac 100644 --- a/node-graph/libraries/core-types/src/table.rs +++ b/node-graph/libraries/core-types/src/table.rs @@ -824,12 +824,12 @@ impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Table { } impl BoundingBox for Table { - /// Computes the combined bounding box of all rows, composing each row's transform attribute with the given transform. + /// Computes the combined bounding box of all items, composing each item's transform attribute with the given transform. fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { let mut combined_bounds = None; - for (element, row_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::(ATTR_TRANSFORM)) { - match element.bounding_box(transform * row_transform, include_stroke) { + for (element, item_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::(ATTR_TRANSFORM)) { + match element.bounding_box(transform * item_transform, include_stroke) { RenderBoundingBox::None => continue, RenderBoundingBox::Infinite => return RenderBoundingBox::Infinite, RenderBoundingBox::Rectangle(bounds) => match combined_bounds { @@ -848,8 +848,8 @@ impl BoundingBox for Table { fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { let mut combined_bounds = None; - for (element, row_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::(ATTR_TRANSFORM)) { - match element.thumbnail_bounding_box(transform * row_transform, include_stroke) { + for (element, item_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::(ATTR_TRANSFORM)) { + match element.thumbnail_bounding_box(transform * item_transform, include_stroke) { RenderBoundingBox::None => continue, RenderBoundingBox::Infinite => return RenderBoundingBox::Infinite, RenderBoundingBox::Rectangle(bounds) => match combined_bounds { @@ -917,14 +917,14 @@ impl PartialEq for Table { } impl ApplyTransform for Table { - /// Right-multiplies the modification into each row's transform attribute. + /// Right-multiplies the modification into each item's transform attribute. fn apply_transform(&mut self, modification: &DAffine2) { for transform in self.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { *transform *= *modification; } } - /// Left-multiplies the modification into each row's transform attribute. + /// Left-multiplies the modification into each item's transform attribute. fn left_apply_transform(&mut self, modification: &DAffine2) { for transform in self.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { *transform = *modification * *transform; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index b9c1513bde..8f97904fa4 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -27,7 +27,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt::Write; use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::{GRADIENT_TABLE_END, GRADIENT_TABLE_START, GradientSpreadMethod}; +use vector_types::gradient::GradientSpreadMethod; use vello::*; /// Cached 16x16 transparency checkerboard image data (two 8x8 cells of #ffffff and #cccccc). @@ -1784,25 +1784,20 @@ impl Render for Table { }; let gradient_id = generate_uuid(); - let start = GRADIENT_TABLE_START; - let end = GRADIENT_TABLE_END; - // Linear gradient only for now + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. + // TODO: Currently only linear gradient is hooked up match GradientType::Linear { GradientType::Linear => { - let (x1, y1) = (start.x, start.y); - let (x2, y2) = (end.x, end.y); let _ = write!( &mut attributes.0.svg_defs, - r#"{stop_string}"# + r#"{stop_string}"# ); } GradientType::Radial => { - let (cx, cy) = (start.x, start.y); - let r = start.distance(end); let _ = write!( &mut attributes.0.svg_defs, - r#"{stop_string}"# + r#"{stop_string}"# ); } } @@ -1848,8 +1843,9 @@ impl Render for Table { let fill = peniko::Brush::Gradient(peniko::Gradient { kind: peniko::LinearGradientPosition { - start: to_point(GRADIENT_TABLE_START), - end: to_point(GRADIENT_TABLE_END), + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. + start: to_point(DVec2::ZERO), + end: to_point(DVec2::X), } .into(), stops, diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 9d0ab389f2..2885a5fec7 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -2,8 +2,10 @@ use core_types::{Color, render_complexity::RenderComplexity}; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; -pub const GRADIENT_TABLE_START: DVec2 = DVec2::ZERO; -pub const GRADIENT_TABLE_END: DVec2 = DVec2::X; +/// Default scale applied to a freshly-created `Table` item's transform — places the unit gradient line +/// (the +X unit vector in local space, before the item's transform) inside a 100×100 document-space box so the +/// gradient is visible at a sensible size by default. +pub const GRADIENT_TABLE_DEFAULT_SCALE: f64 = 100.; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] From 5169e40606b4547b8bb4e275acd70bec93396a8b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 29 Apr 2026 02:43:47 -0700 Subject: [PATCH 6/7] Fix thumbnails --- .../tool/tool_messages/gradient_tool.rs | 16 ++--- editor/src/node_graph_executor/runtime.rs | 69 +++++++++++++++---- node-graph/libraries/core-types/src/bounds.rs | 22 +++--- node-graph/libraries/core-types/src/table.rs | 11 +-- .../libraries/rendering/src/renderer.rs | 34 ++++++--- .../libraries/vector-types/src/gradient.rs | 15 ++-- 6 files changed, 112 insertions(+), 55 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index 6098db74c3..dd970b6f50 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -185,7 +185,7 @@ impl LayoutHolder for GradientTool { fn layout(&self) -> Layout { let mut widgets: Vec = Vec::new(); - // TODO: Drop the `is_gradient_table` guard once `Table` rows can store the gradient type, as currently only legacy `Fill::Gradient` exposes Linear/Radial + // TODO: Drop the `is_gradient_table` guard once `Table` rows can store the gradient type if !self.data.is_gradient_table { let gradient_type = RadioInput::new(vec![ RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { @@ -297,7 +297,7 @@ impl Default for GradientToolFsmState { /// Computes the transform from gradient space to viewport space (where gradient space is 0..1) fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessageHandler) -> DAffine2 { - // TODO: Drop the `is_gradient_table` branch once all gradients are `Table`, only the upstream-footprint path will remain + // TODO: Drop the `is_gradient_table` branch once all gradients are `Table` let is_gradient_table = is_layer_fed_by_node_of_name( layer, &document.network_interface, @@ -325,15 +325,15 @@ fn gradient_space_transform(layer: LayerNodeIdentifier, document: &DocumentMessa /// 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. +// 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`, callers will read the table directly -// TODO: Until then, only Linear + `Pad` spread are produced from a table since rows can't carry the gradient type or spread method yet +// TODO: Remove this whole function once all gradients are `Table` +// TODO: Until then, only Linear + `Pad` spread are produced from a table (rows can't carry type/spread yet) fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { match (get_gradient_table(layer, network_interface), graph_modification_utils::get_gradient(layer, network_interface)) { (Some(gradient_graphic), _) => { @@ -1037,7 +1037,7 @@ impl Fsm for GradientToolFsmState { }; // The gradient has only one point and so should become a fill - // TODO: Drop the legacy `Fill::Solid` branch when all gradients become `Table`, the table just retains the single stop + // TODO: Drop the legacy `Fill::Solid` branch when all gradients become `Table` if selected_gradient.gradient.stops.len() == 1 { if selected_gradient.is_gradient_table { selected_gradient.render_gradient(responses); @@ -1631,8 +1631,8 @@ fn apply_gradient_update( } update(&mut gradient); - // 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`, the lookup will then be unconditional + // 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` if let Some(existing_table) = get_gradient_table(layer, &context.document.network_interface) { let transform = existing_table.attribute_cloned_or_default::(ATTR_TRANSFORM, 0); let gradient_table = Table::new_from_row(TableRow::new_from_element(gradient.stops.clone()).with_attribute(ATTR_TRANSFORM, transform)); diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index c7150badfe..df90e4d9ee 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -1,6 +1,6 @@ use super::*; use crate::messages::frontend::utility_types::{ExportBounds, FileType}; -use glam::{DAffine2, UVec2}; +use glam::{DAffine2, DVec2, UVec2}; use graph_craft::application_io::{PlatformApplicationIo, PlatformEditorApi}; use graph_craft::document::value::{RenderOutput, RenderOutputType, TaggedValue}; use graph_craft::document::{NodeId, NodeNetwork}; @@ -8,7 +8,7 @@ use graph_craft::graphene_compiler::Compiler; use graph_craft::proto::GraphErrors; use graph_craft::{ProtoNodeIdentifier, concrete}; use graphene_std::application_io::{ApplicationIo, ExportFormat, ImageTexture, NodeGraphUpdateMessage, NodeGraphUpdateSender, RenderConfig}; -use graphene_std::bounds::{DEFAULT_THUMBNAIL_BOUNDS, RenderBoundingBox}; +use graphene_std::bounds::{BoundingBox, RenderBoundingBox}; use graphene_std::memo::IORecord; use graphene_std::ops::Convert; #[cfg(all(target_family = "wasm", feature = "gpu", feature = "wasm"))] @@ -435,13 +435,16 @@ impl NodeRuntime { // Graphic table: thumbnail if let Some(io) = introspected_data.downcast_ref::>>() { if update_thumbnails { - Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, responses) + let bounds = io.output.thumbnail_bounding_box(DAffine2::IDENTITY, true); + Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, bounds, responses) } } - // Artboard table: thumbnail + // Artboard thumbnail bounds come from the clipping rectangles, not the content union, since the renderer + // clips content to those rectangles so anything outside isn't visible else if let Some(io) = introspected_data.downcast_ref::>>>() { if update_thumbnails { - Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, responses) + let bounds = artboard_clip_bounds(&io.output); + Self::render_thumbnail(&mut self.thumbnail_renders, parent_network_node_id, &io.output, bounds, responses) } } // Vector table: vector modifications @@ -457,7 +460,13 @@ impl NodeRuntime { } /// If this is `Graphic` data, regenerate click targets and thumbnails for the layers in the graph, modifying the state and updating the UI. - fn render_thumbnail(thumbnail_renders: &mut HashMap>, parent_network_node_id: NodeId, graphic: &impl Render, responses: &mut VecDeque) { + fn render_thumbnail( + thumbnail_renders: &mut HashMap>, + parent_network_node_id: NodeId, + graphic: &impl Render, + bounds: RenderBoundingBox, + responses: &mut VecDeque, + ) { // Skip thumbnails if the layer is too complex (for performance) if graphic.render_complexity() > 1000 { let old = thumbnail_renders.insert(parent_network_node_id, Vec::new()); @@ -471,12 +480,13 @@ impl NodeRuntime { return; } - let bounds = match graphic.thumbnail_bounding_box(DAffine2::IDENTITY, true) { - RenderBoundingBox::None => None, - RenderBoundingBox::Infinite => Some(DEFAULT_THUMBNAIL_BOUNDS), - RenderBoundingBox::Rectangle(bounds) => Some(bounds), + // Fall back to a 1×1 rectangle if no caller offered finite bounds, then aspect-correct to the panel's 3:2 ratio + let raw_bounds = match bounds { + RenderBoundingBox::Rectangle(bounds) if (bounds[1] - bounds[0]) != DVec2::ZERO => bounds, + _ => [DVec2::ZERO, DVec2::ONE], }; - let new_thumbnail_svg = if let Some(bounds) = bounds { + let bounds = expand_to_thumbnail_aspect(raw_bounds); + let new_thumbnail_svg = { let footprint = Footprint { transform: DAffine2::from_translation(DVec2::new(bounds[0].x, bounds[0].y)), resolution: UVec2::new((bounds[1].x - bounds[0].x).abs() as u32, (bounds[1].y - bounds[0].y).abs() as u32), @@ -496,8 +506,6 @@ impl NodeRuntime { render.format_svg(bounds[0], bounds[1]); render.svg - } else { - Vec::new() }; // Update frontend thumbnail @@ -512,6 +520,41 @@ impl NodeRuntime { } } +/// Returns the union of the artboards' clipping rectangles, used as the thumbnail bounds for an artboard layer so the +/// framing matches what's actually visible after clipping rather than the unclipped content extents. +fn artboard_clip_bounds(artboards: &Table>) -> RenderBoundingBox { + let mut combined: Option<[DVec2; 2]> = None; + for index in 0..artboards.len() { + let location: DVec2 = artboards.attribute_cloned_or_default(graphene_std::ATTR_LOCATION, index); + let dimensions: DVec2 = artboards.attribute_cloned_or_default(graphene_std::ATTR_DIMENSIONS, index); + let bounds = [location, location + dimensions]; + combined = Some(match combined { + Some(existing) => [existing[0].min(bounds[0]), existing[1].max(bounds[1])], + None => bounds, + }); + } + match combined { + Some(bounds) => RenderBoundingBox::Rectangle(bounds), + None => RenderBoundingBox::None, + } +} + +/// Expands an AABB outward (centered) to match the Layers panel thumbnail's 3:2 aspect ratio, padding the smaller axis +/// so the input's extent is always preserved. +fn expand_to_thumbnail_aspect(bounds: [DVec2; 2]) -> [DVec2; 2] { + const THUMBNAIL_ASPECT_RATIO: f64 = 1.5; + + let size = bounds[1] - bounds[0]; + let center = (bounds[0] + bounds[1]) / 2.; + let (width, height) = if size.x >= size.y * THUMBNAIL_ASPECT_RATIO { + (size.x, size.x / THUMBNAIL_ASPECT_RATIO) + } else { + (size.y * THUMBNAIL_ASPECT_RATIO, size.y) + }; + let half = DVec2::new(width, height) / 2.; + [center - half, center + half] +} + pub async fn introspect_node(path: &[NodeId]) -> Result, IntrospectError> { let runtime = NODE_RUNTIME.lock(); if let Some(ref mut runtime) = runtime.as_ref() { diff --git a/node-graph/libraries/core-types/src/bounds.rs b/node-graph/libraries/core-types/src/bounds.rs index ca96eb2224..0860b83463 100644 --- a/node-graph/libraries/core-types/src/bounds.rs +++ b/node-graph/libraries/core-types/src/bounds.rs @@ -1,13 +1,6 @@ use crate::Color; use glam::{DAffine2, DVec2}; -/// Fallback rectangle used as the thumbnail bounding box for types whose normal bounding box is -/// `RenderBoundingBox::Infinite` (currently just solid `Color`). Thumbnail rendering needs a finite -/// preview area, so this is what callers substitute when their thumbnail bounding box query returns -/// `Infinite`, either by returning it directly from `thumbnail_bounding_box` or by mapping it from -/// `Infinite` at the call site. -pub const DEFAULT_THUMBNAIL_BOUNDS: [DVec2; 2] = [DVec2::ZERO, DVec2::new(300., 200.)]; - #[derive(Clone, Copy, Default, Debug, PartialEq)] pub enum RenderBoundingBox { #[default] @@ -19,11 +12,13 @@ pub enum RenderBoundingBox { pub trait BoundingBox { fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox; - /// Returns a finite bounding box suitable for rendering a thumbnail. + /// Returns the bounding box to use when sizing this value's thumbnail in the Layers panel. /// - /// Differs from `bounding_box` only for types that would otherwise return - /// `RenderBoundingBox::Infinite` (e.g., `Color`, `GradientStops`). - /// Those substitute a finite fallback rectangle so the thumbnail has a defined area to render into. + /// Diverges from `bounding_box` for types where the rendering bounds wouldn't make a useful thumbnail frame. + /// For instance, `GradientStops` is `Infinite` for rendering but returns the line's AABB here, so a `Table` + /// group of a gradient and a vector frames around the vector's geometry rather than infinity. + /// Types with no meaningful contribution (e.g., `Color`) return `Infinite` from both; the runtime substitutes a + /// small fallback rectangle at the end if no finite bounds remain after combining. fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox; } @@ -52,8 +47,7 @@ impl BoundingBox for Color { } fn thumbnail_bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { - // A solid color has no intrinsic extent (its `bounding_box` is `Infinite`), - // so we substitute a finite fallback so the thumbnail has a defined area to fill. - RenderBoundingBox::Rectangle(DEFAULT_THUMBNAIL_BOUNDS) + // A solid color has no intrinsic extent, so its container's other content frames the thumbnail + RenderBoundingBox::Infinite } } diff --git a/node-graph/libraries/core-types/src/table.rs b/node-graph/libraries/core-types/src/table.rs index ab6d7b1bac..620b19abd4 100644 --- a/node-graph/libraries/core-types/src/table.rs +++ b/node-graph/libraries/core-types/src/table.rs @@ -846,12 +846,14 @@ impl BoundingBox for Table { } fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + // `Infinite` items are skipped here (rather than propagating outward as in `bounding_box`) so a finite sibling in a mixed group dictates the framing let mut combined_bounds = None; + let mut any_infinite = false; for (element, item_transform) in self.iter_element_values().zip(self.iter_attribute_values_or_default::(ATTR_TRANSFORM)) { match element.thumbnail_bounding_box(transform * item_transform, include_stroke) { RenderBoundingBox::None => continue, - RenderBoundingBox::Infinite => return RenderBoundingBox::Infinite, + RenderBoundingBox::Infinite => any_infinite = true, RenderBoundingBox::Rectangle(bounds) => match combined_bounds { Some(existing) => combined_bounds = Some(Quad::combine_bounds(existing, bounds)), None => combined_bounds = Some(bounds), @@ -859,9 +861,10 @@ impl BoundingBox for Table { } } - match combined_bounds { - Some(bounds) => RenderBoundingBox::Rectangle(bounds), - None => RenderBoundingBox::None, + match (combined_bounds, any_infinite) { + (Some(bounds), _) => RenderBoundingBox::Rectangle(bounds), + (None, true) => RenderBoundingBox::Infinite, + (None, false) => RenderBoundingBox::None, } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 8f97904fa4..096dce076e 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1753,14 +1753,32 @@ impl Render for Table { impl Render for Table { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + // For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`. + // The viewBox crops the overshoot. Canvas rendering keeps the polyline path since Chrome rejects rects larger than ~20 million. + let thumbnail_rect = if render_params.thumbnail { + let truncated_size = render_params.footprint.resolution.as_dvec2(); + let margin = DVec2::ONE; + Some((render_params.footprint.transform.translation - margin / 2., truncated_size + margin)) + } else { + None + }; + for index in 0..self.len() { let Some(gradient) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let alpha_blending: AlphaBlending = self.attribute_cloned_or_default(ATTR_ALPHA_BLENDING, index); - render.leaf_tag("polyline", |attributes| { - // Chrome doesn't like drawing centered rectangles bigger than ~20 million so we draw a polyline quad instead - let max = u64::MAX; - attributes.push("points", format!("{max},{max} -{max},{max} -{max},-{max} {max},-{max}")); + let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; + render.leaf_tag(tag, |attributes| { + if let Some((min, size)) = thumbnail_rect { + attributes.push("x", min.x.to_string()); + attributes.push("y", min.y.to_string()); + attributes.push("width", size.x.to_string()); + attributes.push("height", size.y.to_string()); + } else { + // Chrome doesn't like drawing centered rectangles bigger than ~20 million so we draw a polyline quad instead + let max = u64::MAX; + attributes.push("points", format!("{max},{max} -{max},{max} -{max},-{max} {max},-{max}")); + } let mut stop_string = String::new(); for (position, color, original_midpoint) in gradient.interpolated_samples() { @@ -1785,7 +1803,7 @@ impl Render for Table { let gradient_id = generate_uuid(); - // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied // TODO: Currently only linear gradient is hooked up match GradientType::Linear { GradientType::Linear => { @@ -1843,7 +1861,7 @@ impl Render for Table { let fill = peniko::Brush::Gradient(peniko::Gradient { kind: peniko::LinearGradientPosition { - // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied start: to_point(DVec2::ZERO), end: to_point(DVec2::X), } @@ -1863,14 +1881,14 @@ impl Render for Table { layer = true; } - // Encode shape and brush manually instead of Scene.fill(), which would multiply brush_transform by the path transform. + // Encode shape and brush manually instead of Scene.fill(), which would multiply brush_transform by the path transform scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&kurbo::Affine::scale(f64::INFINITY))); scene.encoding_mut().encode_fill_style(peniko::Fill::NonZero); scene.encoding_mut().encode_shape(&rect, true); scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&brush_transform)); scene.encoding_mut().swap_last_path_tags(); - scene.encoding_mut().encode_brush(&fill, 1.0); + scene.encoding_mut().encode_brush(&fill, 1.); if layer { scene.pop_layer(); diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 2885a5fec7..0422b028b8 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -2,9 +2,8 @@ use core_types::{Color, render_complexity::RenderComplexity}; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; -/// Default scale applied to a freshly-created `Table` item's transform — places the unit gradient line -/// (the +X unit vector in local space, before the item's transform) inside a 100×100 document-space box so the -/// gradient is visible at a sensible size by default. +/// Default scale applied to a freshly-created `Table` 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))] @@ -495,10 +494,10 @@ impl core_types::bounds::BoundingBox for GradientStops { } fn thumbnail_bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> core_types::bounds::RenderBoundingBox { - let corners = [DVec2::ZERO, DVec2::X, DVec2::Y, DVec2::ONE].map(|vec| transform.transform_point2(vec)); - let min = corners.iter().fold(DVec2::MAX, |acc, &p| acc.min(p)); - let max = corners.iter().fold(DVec2::MIN, |acc, &p| acc.max(p)); - - core_types::bounds::RenderBoundingBox::Rectangle([min, max]) + // AABB of the gradient line itself, leaving aspect padding and sub-pixel fallbacks to the runtime so this stays + // a clean per-item geometric bound that combines naturally with siblings + let start = transform.transform_point2(DVec2::ZERO); + let end = transform.transform_point2(DVec2::X); + core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) } } From 81b348eea67a6a819834f56e173613acf3918540 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 29 Apr 2026 03:43:47 -0700 Subject: [PATCH 7/7] Connect up gradient_type and spread_method to attributes --- .../tool/tool_messages/gradient_tool.rs | 90 ++++++++----------- node-graph/libraries/core-types/src/lib.rs | 3 +- node-graph/libraries/core-types/src/table.rs | 8 ++ .../libraries/rendering/src/renderer.rs | 47 ++++++++-- 4 files changed, 86 insertions(+), 62 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index dd970b6f50..2d1d0f7d22 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -10,10 +10,10 @@ use crate::messages::portfolio::document::utility_types::network_interface::Node 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::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; -use graphene_std::ATTR_TRANSFORM; use graphene_std::raster::color::Color; use graphene_std::table::{Table, TableRow}; use graphene_std::vector::style::{Fill, Gradient, GradientSpreadMethod, GradientStops, GradientType}; +use graphene_std::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM}; #[derive(Default, ExtractField)] pub struct GradientTool { @@ -129,31 +129,13 @@ impl<'a> MessageHandler> for Grad self.fsm_state.process_event(message, &mut self.data, context, &self.options, responses, false); let has_gradient = has_gradient_on_selected_layers(context.document); - // TODO: Drop this detection (and the `is_gradient_table` field) once all gradients are `Table` - let is_gradient_table = context - .document - .network_interface - .selected_nodes() - .selected_visible_layers(&context.document.network_interface) - .any(|layer| get_gradient_table(layer, &context.document.network_interface).is_some()); - - let mut options_changed = false; - if has_gradient != self.data.has_selected_gradient { self.data.has_selected_gradient = has_gradient; - options_changed = true; - } - if is_gradient_table != self.data.is_gradient_table { - self.data.is_gradient_table = is_gradient_table; - options_changed = true; - } - - if options_changed { responses.add(ToolMessage::RefreshToolOptions); } // Sync tool options with the selected layer's gradient - if has_gradient && let Some(gradient) = get_gradient_on_selected_layer(&context.document) { + if has_gradient && let Some(gradient) = get_gradient_on_selected_layer(context.document) { let type_differs = self.options.gradient_type != gradient.gradient_type; let spread_method_differs = self.options.spread_method != gradient.spread_method; @@ -185,27 +167,24 @@ impl LayoutHolder for GradientTool { fn layout(&self) -> Layout { let mut widgets: Vec = Vec::new(); - // TODO: Drop the `is_gradient_table` guard once `Table` rows can store the gradient type - if !self.data.is_gradient_table { - let gradient_type = RadioInput::new(vec![ - RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::Type(GradientType::Linear), - } - .into() - }), - RadioEntryData::new("Radial").label("Radial").tooltip_label("Radial Gradient").on_update(move |_| { - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::Type(GradientType::Radial), - } - .into() - }), - ]) - .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) - .widget_instance(); + let gradient_type = RadioInput::new(vec![ + RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::Type(GradientType::Linear), + } + .into() + }), + RadioEntryData::new("Radial").label("Radial").tooltip_label("Radial Gradient").on_update(move |_| { + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::Type(GradientType::Radial), + } + .into() + }), + ]) + .selected_index(Some((self.options.gradient_type == GradientType::Radial) as u32)) + .widget_instance(); - widgets.extend([gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance()]); - } + widgets.extend([gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance()]); let reverse_stops = IconButton::new("Reverse", 24) .tooltip_label("Reverse Stops") @@ -244,8 +223,7 @@ impl LayoutHolder for GradientTool { widgets.extend([spread_method, Separator::new(SeparatorStyle::Unrelated).widget_instance(), reverse_stops]); - // TODO: Drop the `!is_gradient_table` guard once `Table` supports radial gradients - if self.options.gradient_type == GradientType::Radial && !self.data.is_gradient_table { + if self.options.gradient_type == GradientType::Radial { let orientation = self .data .selected_gradient @@ -333,16 +311,17 @@ fn gradient_item_transform(start: DVec2, end: DVec2) -> DAffine2 { } // TODO: Remove this whole function once all gradients are `Table` -// TODO: Until then, only Linear + `Pad` spread are produced from a table (rows can't carry type/spread yet) fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { match (get_gradient_table(layer, network_interface), graph_modification_utils::get_gradient(layer, network_interface)) { (Some(gradient_graphic), _) => { let stops = gradient_graphic.element(0)?.clone(); let transform: DAffine2 = gradient_graphic.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let spread_method: GradientSpreadMethod = gradient_graphic.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); + let gradient_type: GradientType = gradient_graphic.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); let gradient = Gradient { stops, - gradient_type: GradientType::Linear, - spread_method: GradientSpreadMethod::Pad, + gradient_type, + spread_method, start: transform.transform_point2(DVec2::ZERO), end: transform.transform_point2(DVec2::X), }; @@ -646,8 +625,12 @@ impl SelectedGradient { if let Some(layer) = self.layer { // TODO: Drop the `Fill::Gradient` branch when all gradients become `Table` if self.is_gradient_table { - let gradient_table = - Table::new_from_row(TableRow::new_from_element(self.gradient.stops.clone()).with_attribute(ATTR_TRANSFORM, gradient_item_transform(self.gradient.start, self.gradient.end))); + let gradient_table = Table::new_from_row( + 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 { responses.add(GraphOperationMessage::FillSet { @@ -688,8 +671,6 @@ struct GradientToolData { has_selected_gradient: bool, color_picker_editing_color_stop: Option, color_picker_transaction_open: bool, - // TODO: Remove (and the conditionals it gates in `LayoutHolder::layout`) once `Table` replaces legacy `Fill::Gradient` - is_gradient_table: bool, } impl Fsm for GradientToolFsmState { @@ -1633,9 +1614,14 @@ 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 // TODO: Drop the `Fill::Gradient` branch when all gradients become `Table` - if let Some(existing_table) = get_gradient_table(layer, &context.document.network_interface) { - let transform = existing_table.attribute_cloned_or_default::(ATTR_TRANSFORM, 0); - let gradient_table = Table::new_from_row(TableRow::new_from_element(gradient.stops.clone()).with_attribute(ATTR_TRANSFORM, transform)); + 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 + 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 { responses.add(GraphOperationMessage::FillSet { diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 11b31f32fc..9efa1d3610 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -34,7 +34,8 @@ use std::any::TypeId; use std::future::Future; use std::pin::Pin; pub use table::{ - ATTR_ALPHA_BLENDING, ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_END, ATTR_LOCATION, ATTR_NAME, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE, + ATTR_ALPHA_BLENDING, ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_END, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_NAME, ATTR_SPREAD_METHOD, + ATTR_START, ATTR_TRANSFORM, ATTR_TYPE, }; #[cfg(feature = "wasm")] pub use tsify; diff --git a/node-graph/libraries/core-types/src/table.rs b/node-graph/libraries/core-types/src/table.rs index 620b19abd4..6c3cd9a56b 100644 --- a/node-graph/libraries/core-types/src/table.rs +++ b/node-graph/libraries/core-types/src/table.rs @@ -57,6 +57,14 @@ pub const ATTR_BACKGROUND: &str = "background"; /// Attribute key for an artboard row's `bool` flag indicating whether content is clipped to the artboard bounds. pub const ATTR_CLIP: &str = "clip"; +/// Attribute key for a `Table` row's `GradientSpreadMethod`, controlling the gradient's behavior +/// outside the start/end stops (`Pad` clamps to the boundary colors, `Reflect` mirrors, `Repeat` tiles). +pub const ATTR_SPREAD_METHOD: &str = "spread_method"; + +/// Attribute key for a `Table` row's `GradientType`, choosing between a linear gradient (color +/// transitions along the gradient line) or a radial gradient (color transitions outward from the line's start). +pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; + // ===================== // TRAIT: AttributeValue // ===================== diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 096dce076e..8235f9fad3 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -10,7 +10,9 @@ use core_types::render_complexity::RenderComplexity; use core_types::table::{Table, TableRow}; use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; -use core_types::{ATTR_ALPHA_BLENDING, ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_LOCATION, ATTR_TRANSFORM}; +use core_types::{ + ATTR_ALPHA_BLENDING, ATTR_BACKGROUND, ATTR_CLIP, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_TYPE, ATTR_LOCATION, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, +}; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use graphene_hash::CacheHashWrapper; @@ -1767,6 +1769,8 @@ impl Render for Table { let Some(gradient) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let alpha_blending: AlphaBlending = self.attribute_cloned_or_default(ATTR_ALPHA_BLENDING, index); + let spread_method: GradientSpreadMethod = self.attribute_cloned_or_default(ATTR_SPREAD_METHOD, index); + let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, index); let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; render.leaf_tag(tag, |attributes| { if let Some((min, size)) = thumbnail_rect { @@ -1802,20 +1806,24 @@ impl Render for Table { }; let gradient_id = generate_uuid(); + let spread_method_attribute = if spread_method == GradientSpreadMethod::Pad { + String::new() + } else { + format!(r#" spreadMethod="{}""#, spread_method.svg_name()) + }; // The unit gradient line is the +X unit vector in local space, before the item's transform is applied - // TODO: Currently only linear gradient is hooked up - match GradientType::Linear { + match gradient_type { GradientType::Linear => { let _ = write!( &mut attributes.0.svg_defs, - r#"{stop_string}"# + r#"{stop_string}"# ); } GradientType::Radial => { let _ = write!( &mut attributes.0.svg_defs, - r#"{stop_string}"# + r#"{stop_string}"# ); } } @@ -1841,10 +1849,12 @@ impl Render for Table { return; } - for ((gradient, transform), alpha_blending) in self + for ((((gradient, transform), alpha_blending), spread_method), gradient_type) in self .iter_element_values() .zip(self.iter_attribute_values_or_default::(ATTR_TRANSFORM)) .zip(self.iter_attribute_values_or_default::(ATTR_ALPHA_BLENDING)) + .zip(self.iter_attribute_values_or_default::(ATTR_SPREAD_METHOD)) + .zip(self.iter_attribute_values_or_default::(ATTR_GRADIENT_TYPE)) { let gradient_transform = parent_transform * transform; @@ -1859,14 +1869,33 @@ impl Render for Table { }) } - let fill = peniko::Brush::Gradient(peniko::Gradient { - kind: peniko::LinearGradientPosition { - // The unit gradient line is the +X unit vector in local space, before the item's transform is applied + let extend = match spread_method { + GradientSpreadMethod::Pad => peniko::Extend::Pad, + GradientSpreadMethod::Reflect => peniko::Extend::Reflect, + GradientSpreadMethod::Repeat => peniko::Extend::Repeat, + }; + + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. + // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. + let kind = match gradient_type { + GradientType::Linear => peniko::LinearGradientPosition { start: to_point(DVec2::ZERO), end: to_point(DVec2::X), } .into(), + GradientType::Radial => peniko::RadialGradientPosition { + start_center: to_point(DVec2::ZERO), + start_radius: 0., + end_center: to_point(DVec2::ZERO), + end_radius: 1., + } + .into(), + }; + + let fill = peniko::Brush::Gradient(peniko::Gradient { + kind, stops, + extend, interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, ..Default::default() });