From 1ac0b37252cbf4f2ffef5e0eb6541a269d615bb2 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Fri, 3 Apr 2026 11:40:41 +0900 Subject: [PATCH 1/2] 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 | 19 +- .../document/node_graph/node_properties.rs | 45 ++- .../graph_modification_utils.rs | 13 +- .../tool/tool_messages/gradient_tool.rs | 350 ++++++++++++++++-- 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/artboard.rs | 4 + .../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 | 63 +++- .../libraries/vector-types/src/gradient.rs | 11 + .../vector-types/src/vector/vector_types.rs | 4 + node-graph/nodes/math/src/lib.rs | 4 +- 19 files changed, 511 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4aae673311..8c8ef90604 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4772,6 +4772,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 4d94f713ba..6f5c06102a 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 @@ -11,9 +11,8 @@ use graphene_std::raster_types::{CPU, Raster}; 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)] @@ -26,6 +25,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 03fc166dd4..3e3329d84c 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 ca92e7134d..8e7f36f336 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -12,11 +12,10 @@ use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::{CPU, Raster}; 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, VectorModificationType}; +use graphene_std::vector::{GradientStops, PointId, Vector, VectorModificationType}; use graphene_std::{Graphic, NodeInputDecleration}; #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] @@ -390,6 +389,20 @@ 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 { + element: stops, + transform, + ..Default::default() + }); + 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 8f428a7286..96275f6f63 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1152,20 +1152,37 @@ 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.iter().next() { - Some(row) => FillChoice::Gradient(row.element.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 = gradient_table.iter().next().map(|row| *row.transform).unwrap_or_default(); + + widgets.push( + color_button + .value(match gradient_table.iter().next() { + Some(row) => FillChoice::Gradient(row.element.clone()), + None => FillChoice::Gradient(GradientStops::default()), + }) + .on_update(update_value( + move |input: &ColorInput| { + TaggedValue::GradientTable( + input + .value + .as_gradient() + .iter() + .map(|&gradient| TableRow { + element: gradient.clone(), + transform: existing_transform, + ..Default::default() + }) + .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 51ec764b0c..1184223cff 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. @@ -285,6 +285,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 f1823618e5..ddf4c5a050 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -3,12 +3,16 @@ 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::raster::color::Color; +use graphene_std::vector::gradient::{GRADIENT_TABLE_END, GRADIENT_TABLE_START}; use graphene_std::vector::style::{Fill, Gradient, GradientStops, GradientType}; #[derive(Default, ExtractField)] @@ -119,8 +123,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); } } @@ -139,22 +160,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") @@ -168,7 +195,7 @@ impl LayoutHolder for GradientTool { }) .widget_instance(); - let mut widgets = vec![gradient_type, Separator::new(SeparatorStyle::Unrelated).widget_instance(), reverse_stops]; + widgets.push(reverse_stops); if self.options.gradient_type == GradientType::Radial { let orientation = self @@ -222,14 +249,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 row = gradient_graphic.get(0)?; + let stops = row.element.clone(); + let transform = *(row.transform); + let gradient = Gradient { + stops, + gradient_type: GradientType::Linear, + 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. @@ -253,6 +316,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 { @@ -296,12 +360,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, } } @@ -517,10 +583,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()), + }); + } } } } @@ -554,6 +633,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 { @@ -902,7 +982,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]), @@ -996,6 +1078,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() { @@ -1023,6 +1106,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: GradientDragTarget::Midpoint(i), initial_gradient: gradient.clone(), + is_gradient_table, }); break; @@ -1063,6 +1147,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: drag_target, initial_gradient: gradient.clone(), + is_gradient_table, }); } } @@ -1079,6 +1164,7 @@ impl Fsm for GradientToolFsmState { gradient: gradient.clone(), dragging: dragging_target, initial_gradient: gradient.clone(), + is_gradient_table, }) } } @@ -1468,6 +1554,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).and_then(|t| t.get(0).map(|row| *row.transform)); + if NodeGraphLayer::is_raster_layer(layer, &mut context.document.network_interface) { continue; } @@ -1480,10 +1568,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), + }); + }; } } @@ -1561,11 +1658,13 @@ 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::table::{Table, TableRow}; + use graphene_std::vector::style::{Fill, Gradient}; + use graphene_std::vector::{GradientStop, GradientStops, fill}; use super::gradient_space_transform; @@ -1610,6 +1709,46 @@ 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 { + element: GradientStops::new([ + GradientStop { + position: 0., + midpoint: 0.5, + color: Color::RED, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: Color::BLUE, + }, + ]), + transform: DAffine2::IDENTITY, + ..Default::default() + })), + }) + .await; + + layer + } + #[tokio::test] async fn ignore_artboard() { let mut editor = EditorTestUtils::create(); @@ -1941,4 +2080,149 @@ mod test_gradient { // Additional verification that 0.75 stop is gone assert!(!final_positions.iter().any(|pos| (pos - 0.75).abs() < 0.05), "Stop at position 0.75 should have been deleted"); } + + #[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 26102cda07..53fd3676b1 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -531,7 +531,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 5ea1d42e72..2b6de42b6b 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -3,7 +3,7 @@ use crate::proto::{Any as DAny, FutureAny}; use crate::wasm_application_io::WasmEditorApi; 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::uuid::NodeId; use core_types::{Color, ContextFeatures, MemoHash, Node, Type}; use dyn_any::DynAny; @@ -129,7 +129,11 @@ macro_rules! tagged_value { x if x == TypeId::of::<()>() => TaggedValue::None, // Table-wrapped types need a single-row 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 { + element: GradientStops::default(), + transform: DAffine2::from_scale(DVec2::splat(100.)), + ..Default::default() + })), $( 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 4a814aaf58..4d423aaa0f 100644 --- a/node-graph/libraries/core-types/src/table.rs +++ b/node-graph/libraries/core-types/src/table.rs @@ -146,6 +146,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 row in self.iter() { + match row.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/artboard.rs b/node-graph/libraries/graphic-types/src/artboard.rs index f3c057d82a..73b49a5437 100644 --- a/node-graph/libraries/graphic-types/src/artboard.rs +++ b/node-graph/libraries/graphic-types/src/artboard.rs @@ -54,6 +54,10 @@ impl BoundingBox for Artboard { other => other, } } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + self.bounding_box(transform, include_stroke) + } } impl RenderComplexity for Artboard { diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 001c0c33a2..44106363d2 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -322,6 +322,17 @@ impl BoundingBox for Graphic { Graphic::Gradient(gradient) => gradient.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 918df0cec8..2b306a9f43 100644 --- a/node-graph/libraries/raster-types/src/raster_types.rs +++ b/node-graph/libraries/raster-types/src/raster_types.rs @@ -210,6 +210,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 e33ce052fd..00284257a0 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -25,3 +25,4 @@ graphic-types = { workspace = true } # Workspace dependencies vello = { workspace = true } +vello_encoding = { workspace = true } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index c589e6177d..ba9535bc6c 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -25,6 +25,7 @@ use std::fmt::Write; use std::hash::{Hash, Hasher}; use std::ops::Deref; use std::sync::{Arc, LazyLock}; +use vector_types::gradient::{GRADIENT_TABLE_END, GRADIENT_TABLE_START}; use vello::*; #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -261,6 +262,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; @@ -997,7 +1002,6 @@ impl Render for Table { } let layer_bounds = row.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 row.element.stroke_bezpath_iter() { bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); @@ -1618,10 +1622,9 @@ impl Render for Table { } impl Render for Table { - // TODO: Fix infinite gradient rendering fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for row in self.iter() { - 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}")); @@ -1638,7 +1641,12 @@ impl Render for Table { stop_string.push_str(" />"); } - let gradient_transform = render_params.footprint.transform * *row.transform; + // render_thumbnail already added the footprint transform + let gradient_transform = if render_params.thumbnail { + *row.transform + } else { + render_params.footprint.transform * *row.transform + }; let gradient_transform_matrix = format_transform_matrix(gradient_transform); let gradient_transform_attribute = if gradient_transform_matrix.is_empty() { String::new() @@ -1647,10 +1655,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); @@ -1683,29 +1692,57 @@ 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; + if let RenderMode::Outline = render_params.render_mode { + return; + } + for row in self.iter() { + let gradient_transform = parent_transform * *row.transform; + let alpha_blending = *row.alpha_blending; let blend_mode = alpha_blending.blend_mode.to_peniko(); let opacity = alpha_blending.opacity(render_params.for_mask); - let color = row.element.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 row.element.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 653ca6bc5d..f3ed48d2c0 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, serde::Serialize, serde::Deserialize, DynAny, node_macro::ChoiceType)] #[widget(Radio)] @@ -472,4 +475,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 e9b8bbb670..3acc05c011 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -493,6 +493,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 { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index a5d92ec1ac..862d54bdfa 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1,7 +1,7 @@ use core_types::registry::types::{Fraction, Percentage, PixelSize, TextArea}; -use core_types::table::Table; +use core_types::table::{Table, TableRow}; use core_types::transform::Footprint; -use core_types::{Color, Ctx, num_traits}; +use core_types::{Color, Ctx, ExtractFootprint, num_traits}; use glam::{DAffine2, DVec2}; use log::warn; use math_parser::ast; From ff5d76b513d43de36edd6e316b9de4978d60d4f1 Mon Sep 17 00:00:00 2001 From: YohYamasaki Date: Fri, 3 Apr 2026 15:12:32 +0900 Subject: [PATCH 2/2] 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 +- node-graph/libraries/graphic-types/src/artboard.rs | 5 +++-- node-graph/nodes/math/src/lib.rs | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index ddf4c5a050..604b8328eb 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -197,7 +197,7 @@ impl LayoutHolder for GradientTool { widgets.push(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 diff --git a/node-graph/libraries/graphic-types/src/artboard.rs b/node-graph/libraries/graphic-types/src/artboard.rs index 73b49a5437..2ea8c232a2 100644 --- a/node-graph/libraries/graphic-types/src/artboard.rs +++ b/node-graph/libraries/graphic-types/src/artboard.rs @@ -55,8 +55,9 @@ impl BoundingBox for Artboard { } } - fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { - self.bounding_box(transform, include_stroke) + fn thumbnail_bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { + let artboard_bounds = (transform * Quad::from_box([self.location.as_dvec2(), self.location.as_dvec2() + self.dimensions.as_dvec2()])).bounding_box(); + RenderBoundingBox::Rectangle(artboard_bounds) } } diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 862d54bdfa..a5d92ec1ac 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1,7 +1,7 @@ use core_types::registry::types::{Fraction, Percentage, PixelSize, TextArea}; -use core_types::table::{Table, TableRow}; +use core_types::table::Table; use core_types::transform::Footprint; -use core_types::{Color, Ctx, ExtractFootprint, num_traits}; +use core_types::{Color, Ctx, num_traits}; use glam::{DAffine2, DVec2}; use log::warn; use math_parser::ast;