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/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 f572c4c1e9..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,10 +9,10 @@ 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::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 +25,10 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, fill: f64, }, + GradientTableSet { + layer: LayerNodeIdentifier, + gradient_table: Table, + }, 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..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,6 +45,11 @@ impl MessageHandler> for modify_inputs.blending_fill_set(fill); } } + GraphOperationMessage::GradientTableSet { layer, gradient_table } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.gradient_table_set(gradient_table); + } + } 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..a5b9ff49e3 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -13,9 +13,8 @@ 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::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 +460,15 @@ 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, gradient_table: Table) { + let Some(gradient_node_id) = self.existing_proto_node_id(graphene_std::math_nodes::gradient_value::IDENTIFIER, true) else { + return; + }; + + 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) { 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..c9fbedec97 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,15 @@ 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 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(graphene_std::math_nodes::gradient_value::GradientInput::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..2d1d0f7d22 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -3,13 +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, is_layer_fed_by_node_of_name}; use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapConstraint, SnapData, SnapManager, SnapTypeConfiguration}; 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 { @@ -131,7 +135,7 @@ impl<'a> MessageHandler> for Grad } // 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; @@ -161,6 +165,8 @@ impl<'a> MessageHandler> for Grad impl LayoutHolder for GradientTool { fn layout(&self) -> Layout { + let mut widgets: Vec = Vec::new(); + let gradient_type = RadioInput::new(vec![ RadioEntryData::new("Linear").label("Linear").tooltip_label("Linear Gradient").on_update(move |_| { GradientToolMessage::UpdateOptions { @@ -178,6 +184,8 @@ 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 reverse_stops = IconButton::new("Reverse", 24) .tooltip_label("Reverse Stops") .tooltip_description("Reverse the gradient color stops.") @@ -191,19 +199,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), } @@ -213,13 +221,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,12 +275,61 @@ 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` + let is_gradient_table = is_layer_fed_by_node_of_name( + layer, + &document.network_interface, + &DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::gradient_value::IDENTIFIER), + ); + + if is_gradient_table { + // Table layers use the item's transform from gradient space to document space, + // so we cannot use `transform_to_viewport` here as it would apply the transform twice. + return document + .metadata() + .upstream_footprints + .get(&layer.to_node()) + .map(|footprint| footprint.transform) + .unwrap_or(document.metadata().document_to_viewport); + } + + let multiplied = document.metadata().transform_to_viewport(layer); let bounds = document.metadata().nonzero_bounding_box(layer); let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + multiplied * bound_transform +} - let multiplied = document.metadata().transform_to_viewport(layer); +/// 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]) +} - multiplied * bound_transform +// TODO: Remove this whole function once all gradients are `Table` +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, + spread_method, + start: transform.transform_point2(DVec2::ZERO), + end: transform.transform_point2(DVec2::X), + }; + 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. @@ -304,6 +355,8 @@ 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, } fn calculate_insertion(start: DVec2, end: DVec2, stops: &GradientStops, mouse: DVec2) -> Option { @@ -347,12 +400,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 +623,21 @@ 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()), - }); + // 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)) + .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 { + layer, + fill: Fill::Gradient(self.gradient.clone()), + }); + } } } } @@ -952,8 +1018,11 @@ 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` 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 +1116,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 +1144,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: GradientDragTarget::Midpoint(i), initial_gradient: gradient.clone(), + is_gradient_table, }); break; @@ -1114,6 +1185,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: drag_target, initial_gradient: gradient.clone(), + is_gradient_table, }); } } @@ -1130,6 +1202,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: dragging_target, initial_gradient: gradient.clone(), + is_gradient_table, }) } } @@ -1538,10 +1611,24 @@ fn apply_gradient_update( transaction_started = true; } update(&mut gradient); - responses.add(GraphOperationMessage::FillSet { - layer, - fill: Fill::Gradient(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` + 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 { + layer, + fill: Fill::Gradient(gradient), + }); + } } } @@ -1623,11 +1710,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 +1762,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 +2166,146 @@ 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 initial_item_transform = super::gradient_item_transform(initial_start, initial_end); + editor + .handle_message(GraphOperationMessage::GradientTableSet { + layer, + 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; + + 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 initial_item_transform = super::gradient_item_transform(initial_start, initial_end); + editor + .handle_message(GraphOperationMessage::GradientTableSet { + layer, + gradient_table: Table::new_from_row(TableRow::new_from_element(original_stops.clone()).with_attribute(ATTR_TRANSFORM, initial_item_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..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::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.bounding_box(DAffine2::IDENTITY, true) { - RenderBoundingBox::None => None, - RenderBoundingBox::Infinite => Some([DVec2::ZERO, DVec2::new(300., 200.)]), - 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/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 503591010e..23e88f23ac 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -3,15 +3,16 @@ 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}; 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}; @@ -118,7 +119,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(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 d59902ff0a..0860b83463 100644 --- a/node-graph/libraries/core-types/src/bounds.rs +++ b/node-graph/libraries/core-types/src/bounds.rs @@ -11,6 +11,15 @@ pub enum RenderBoundingBox { pub trait BoundingBox { fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox; + + /// Returns the bounding box to use when sizing this value's thumbnail in the Layers panel. + /// + /// 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; } macro_rules! none_impl { @@ -19,6 +28,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 +45,9 @@ 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 { + // 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/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 909b3237cd..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 // ===================== @@ -824,12 +832,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 { @@ -844,6 +852,29 @@ impl BoundingBox for Table { None => RenderBoundingBox::None, } } + + 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 => any_infinite = true, + RenderBoundingBox::Rectangle(bounds) => match combined_bounds { + Some(existing) => combined_bounds = Some(Quad::combine_bounds(existing, bounds)), + None => combined_bounds = Some(bounds), + }, + } + } + + match (combined_bounds, any_infinite) { + (Some(bounds), _) => RenderBoundingBox::Rectangle(bounds), + (None, true) => RenderBoundingBox::Infinite, + (None, false) => RenderBoundingBox::None, + } + } } impl IntoIterator for Table { @@ -897,14 +928,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/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..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; @@ -287,6 +289,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 +1094,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,16 +1754,35 @@ impl Render for Table { } impl Render for Table { - // TODO: Fix infinite gradient rendering 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("rect", |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 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 { + 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() { @@ -1772,7 +1796,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,24 +1806,24 @@ impl Render for Table { }; let gradient_id = generate_uuid(); - let start = DVec2::ZERO; - let end = DVec2::X; + let spread_method_attribute = if spread_method == GradientSpreadMethod::Pad { + String::new() + } else { + format!(r#" spreadMethod="{}""#, spread_method.svg_name()) + }; - match GradientType::Radial { + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied + match gradient_type { 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}"# ); } } @@ -1817,28 +1842,82 @@ 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), 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; + 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 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() + }); + 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.); 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..0422b028b8 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -2,6 +2,10 @@ 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) inside a 100×100 document-space box. +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)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -488,4 +492,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 { + // 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)]) + } } 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 {