From 871468d27bb137e074fc331c85ed0174ec1bb2af Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Mon, 16 Mar 2026 12:46:06 +0530 Subject: [PATCH 1/7] feat: implemented stroke gradient feature --- .../data_panel/data_panel_message_handler.rs | 2 +- .../graph_operation_message_handler.rs | 2 +- .../document/graph_operation/utility_types.rs | 19 +- .../document/node_graph/node_properties.rs | 228 ++++++++++++++++-- .../messages/tool/tool_messages/fill_tool.rs | 4 +- .../tool/tool_messages/gradient_tool.rs | 2 +- .../libraries/rendering/src/render_ext.rs | 31 +-- .../libraries/rendering/src/renderer.rs | 64 ++++- .../vector-types/src/vector/style.rs | 38 ++- node-graph/nodes/vector/src/vector_nodes.rs | 113 ++++++++- 10 files changed, 431 insertions(+), 72 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index b767b60305..db2ea051ab 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -389,7 +389,7 @@ impl TableRowLayout for Vector { } if let Some(stroke) = self.style.stroke.clone() { - let color = if let Some(color) = stroke.color { FillChoice::Solid(color) } else { FillChoice::None }; + let color = if let Some(color) = stroke.color() { FillChoice::Solid(color) } else { FillChoice::None }; table_rows.push(vec![ TextLabel::new("Stroke").narrow(true).widget_instance(), ColorInput::new(color).disabled(true).menu_direction(Some(MenuDirection::Top)).narrow(true).widget_instance(), 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 1f6ed6fad1..ea591453a4 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 @@ -496,7 +496,7 @@ fn import_usvg_node( fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsContext, transform: DAffine2) { if let usvg::Paint::Color(color) = &stroke.paint() { modify_inputs.stroke_set(Stroke { - color: Some(usvg_color(*color, stroke.opacity().get())), + paint: Fill::Solid(usvg_color(*color, stroke.opacity().get())), weight: stroke.width().get() as f64, dash_lengths: stroke.dasharray().as_ref().map(|lengths| lengths.iter().map(|&length| length as f64).collect()).unwrap_or_default(), dash_offset: stroke.dashoffset() as f64, 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 16c70b119b..60565b0241 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -401,10 +401,23 @@ impl<'a> ModifyInputsContext<'a> { return; }; - let stroke_color = if let Some(color) = stroke.color { Table::new_from_element(color) } else { Table::new() }; + match &stroke.paint { + Fill::None => { + let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::BackupColorInput::INDEX); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(Table::new()), false), true); + } + Fill::Solid(color) => { + let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::BackupColorInput::INDEX); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(Table::new_from_element(*color)), false), true); + } + Fill::Gradient(gradient) => { + let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::BackupGradientInput::INDEX); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Gradient(gradient.clone()), false), true); + } + } - let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::ColorInput::INDEX); - self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(stroke_color), false), true); + let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::ColorInput::::INDEX); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Fill(stroke.paint.clone()), false), true); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::WeightInput::INDEX); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.weight), false), true); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::AlignInput::INDEX); 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 efa508b3ff..085da640c6 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -27,7 +27,7 @@ use graphene_std::text::{Font, TextAlign}; use graphene_std::transform::{Footprint, ReferencePoint, Transform}; use graphene_std::vector::QRCodeErrorCorrectionLevel; use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; -use graphene_std::vector::style::{Fill, FillChoice, FillType, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{Fill, FillChoice, FillType, Gradient, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; pub(crate) fn string_properties(text: &str) -> Vec { let widget = TextLabel::new(text).widget_instance(); @@ -1817,7 +1817,7 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::vector::fill::*; - let mut widgets_first_row = start_widgets(ParameterWidgetsInfo::new(node_id, FillInput::::INDEX, true, context)); + let mut widgets_first_row = start_widgets(ParameterWidgetsInfo::new(node_id, ColorInput::::INDEX, true, context)); let document_node = match get_document_node(node_id, context) { Ok(document_node) => document_node, @@ -1828,7 +1828,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte }; let (fill, backup_color, backup_gradient) = if let (Some(TaggedValue::Fill(fill)), Some(TaggedValue::Color(backup_color)), Some(TaggedValue::Gradient(backup_gradient))) = ( - &document_node.inputs[FillInput::::INDEX].as_value(), + &document_node.inputs[ColorInput::::INDEX].as_value(), &document_node.inputs[BackupColorInput::INDEX].as_value(), &document_node.inputs[BackupGradientInput::INDEX].as_value(), ) { @@ -1842,9 +1842,9 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); widgets_first_row.push( - ColorInput::default() + crate::messages::layout::utility_types::widgets::button_widgets::ColorInput::default() .value(fill.clone().into()) - .on_update(move |x: &ColorInput| Message::Batched { + .on_update(move |x: &crate::messages::layout::utility_types::widgets::button_widgets::ColorInput| Message::Batched { messages: Box::new([ match &fill2 { Fill::None => NodeGraphMessage::SetInputValue { @@ -1868,7 +1868,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte }, NodeGraphMessage::SetInputValue { node_id, - input_index: FillInput::::INDEX, + input_index: ColorInput::::INDEX, value: TaggedValue::Fill(x.value.to_fill(fill2.as_gradient())), } .into(), @@ -1896,7 +1896,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte } }, node_id, - FillInput::::INDEX, + ColorInput::::INDEX, )) .widget_instance(); row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); @@ -1907,11 +1907,11 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte let entries = vec![ RadioEntryData::new("solid") .label("Solid") - .on_update(update_value(move |_| TaggedValue::Fill(backup_color_fill.clone()), node_id, FillInput::::INDEX)) + .on_update(update_value(move |_| TaggedValue::Fill(backup_color_fill.clone()), node_id, ColorInput::::INDEX)) .on_commit(commit_value), RadioEntryData::new("gradient") .label("Gradient") - .on_update(update_value(move |_| TaggedValue::Fill(backup_gradient_fill.clone()), node_id, FillInput::::INDEX)) + .on_update(update_value(move |_| TaggedValue::Fill(backup_gradient_fill.clone()), node_id, ColorInput::::INDEX)) .on_commit(commit_value), ]; @@ -1946,7 +1946,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte } }, node_id, - FillInput::::INDEX, + ColorInput::::INDEX, )) .widget_instance(); row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); @@ -1967,7 +1967,7 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte TaggedValue::Fill(Fill::Gradient(new_gradient)) }, node_id, - FillInput::::INDEX, + ColorInput::::INDEX, ); RadioEntryData::new(format!("{:?}", grad_type)) .label(format!("{:?}", grad_type)) @@ -2009,18 +2009,201 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) - Some(TaggedValue::StrokeJoin(x)) => x, _ => &StrokeJoin::Miter, }; - let dash_lengths_val = match &document_node.inputs[DashLengthsInput::>::INDEX].as_value() { Some(TaggedValue::VecF64(x)) => x, _ => &vec![], }; + let has_dash_lengths = dash_lengths_val.is_empty(); let miter_limit_disabled = join_value != &StrokeJoin::Miter; - let color = color_widget( - ParameterWidgetsInfo::new(node_id, ColorInput::INDEX, true, context), - crate::messages::layout::utility_types::widgets::button_widgets::ColorInput::default(), + let fill = document_node + .inputs + .get(ColorInput::::INDEX) + .and_then(|i| i.as_non_exposed_value()) + .and_then(|t| match t { + TaggedValue::Fill(f) => Some(f.clone()), + _ => None, + }) + .unwrap_or(Fill::None); + let backup_color = document_node + .inputs + .get(BackupColorInput::INDEX) + .and_then(|i| i.as_non_exposed_value()) + .and_then(|t| match t { + TaggedValue::Color(c) => Some(c.clone()), + _ => None, + }) + .unwrap_or(Table::new()); + let backup_gradient = document_node + .inputs + .get(BackupGradientInput::INDEX) + .and_then(|i| i.as_non_exposed_value()) + .and_then(|t| match t { + TaggedValue::Gradient(g) => Some(g.clone()), + _ => None, + }) + .unwrap_or(Gradient::default()); + + let mut widgets = Vec::new(); + + let mut widgets_first_row = start_widgets(ParameterWidgetsInfo::new(node_id, ColorInput::::INDEX, true, context)); + let fill2 = fill.clone(); + let backup_color_fill: Fill = backup_color.clone().into(); + let backup_gradient_fill: Fill = backup_gradient.clone().into(); + + widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets_first_row.push( + crate::messages::layout::utility_types::widgets::button_widgets::ColorInput::default() + .value(fill.clone().into()) + .on_update(move |x: &crate::messages::layout::utility_types::widgets::button_widgets::ColorInput| { + let new_fill = x.value.to_fill(fill2.as_gradient()); + let backup_msg = match &new_fill { + Fill::None => NodeGraphMessage::SetInputValue { + node_id, + input_index: BackupColorInput::INDEX, + value: TaggedValue::Color(Table::new()), + } + .into(), + Fill::Solid(color) => NodeGraphMessage::SetInputValue { + node_id, + input_index: BackupColorInput::INDEX, + value: TaggedValue::Color(Table::new_from_element(*color)), + } + .into(), + Fill::Gradient(gradient) => NodeGraphMessage::SetInputValue { + node_id, + input_index: BackupGradientInput::INDEX, + value: TaggedValue::Gradient(gradient.clone()), + } + .into(), + }; + Message::Batched { + messages: Box::new([ + backup_msg, + NodeGraphMessage::SetInputValue { + node_id, + input_index: ColorInput::::INDEX, + value: TaggedValue::Fill(new_fill), + } + .into(), + ]), + } + }) + .on_commit(commit_value) + .widget_instance(), ); + widgets.push(LayoutGroup::row(widgets_first_row)); + + let mut fill_type_row = vec![TextLabel::new("").widget_instance()]; + match fill { + Fill::Solid(_) | Fill::None => add_blank_assist(&mut fill_type_row), + Fill::Gradient(ref gradient) => { + let reverse_button = IconButton::new("Reverse", 24) + .tooltip_description("Reverse the gradient color stops.") + .on_update(update_value( + { + let gradient = gradient.clone(); + move |_| { + let mut gradient = gradient.clone(); + gradient.stops = gradient.stops.reversed(); + TaggedValue::Fill(Fill::Gradient(gradient)) + } + }, + node_id, + ColorInput::::INDEX, + )) + .widget_instance(); + fill_type_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + fill_type_row.push(reverse_button); + } + } + + let entries = vec![ + RadioEntryData::new("solid") + .label("Solid") + .on_update(update_value(move |_| TaggedValue::Fill(backup_color_fill.clone()), node_id, ColorInput::::INDEX)) + .on_commit(commit_value), + RadioEntryData::new("gradient") + .label("Gradient") + .on_update(update_value(move |_| TaggedValue::Fill(backup_gradient_fill.clone()), node_id, ColorInput::::INDEX)) + .on_commit(commit_value), + ]; + + fill_type_row.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + RadioInput::new(entries).selected_index(Some(if fill.as_gradient().is_some() { 1 } else { 0 })).widget_instance(), + ]); + widgets.push(LayoutGroup::row(fill_type_row)); + + if let Fill::Gradient(gradient) = fill.clone() { + let mut row = vec![TextLabel::new("").widget_instance()]; + match gradient.gradient_type { + GradientType::Linear => add_blank_assist(&mut row), + GradientType::Radial => { + let orientation = if (gradient.end.x - gradient.start.x).abs() > f64::EPSILON * 1e6 { + gradient.end.x > gradient.start.x + } else { + (gradient.start.x + gradient.start.y) < (gradient.end.x + gradient.end.y) + }; + let reverse_radial_gradient_button = IconButton::new(if orientation { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24) + .tooltip_description("Reverse which end the gradient radiates from.") + .on_update(update_value( + { + let gradient = gradient.clone(); + move |_| { + let mut gradient = gradient.clone(); + std::mem::swap(&mut gradient.start, &mut gradient.end); + TaggedValue::Fill(Fill::Gradient(gradient)) + } + }, + node_id, + ColorInput::::INDEX, + )) + .widget_instance(); + row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + row.push(reverse_radial_gradient_button); + } + } + + let gradient_for_closure = gradient.clone(); + + let entries = [GradientType::Linear, GradientType::Radial] + .iter() + .map(|&grad_type| { + let gradient = gradient_for_closure.clone(); + let set_input_value = update_value( + move |_: &()| { + let mut new_gradient = gradient.clone(); + new_gradient.gradient_type = grad_type; + TaggedValue::Fill(Fill::Gradient(new_gradient)) + }, + node_id, + ColorInput::::INDEX, + ); + RadioEntryData::new(format!("{:?}", grad_type)) + .label(format!("{:?}", grad_type)) + .on_update(move |_| Message::Batched { + messages: Box::new([ + set_input_value(&()), + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::Type(grad_type), + } + .into(), + ]), + }) + .on_commit(commit_value) + }) + .collect(); + + row.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + RadioInput::new(entries).selected_index(Some(gradient.gradient_type as u32)).widget_instance(), + ]); + + widgets.push(LayoutGroup::row(row)); + } + let weight = number_widget(ParameterWidgetsInfo::new(node_id, WeightInput::INDEX, true, context), NumberInput::default().unit(" px").min(0.)); let align = enum_choice::() .for_socket(ParameterWidgetsInfo::new(node_id, AlignInput::INDEX, true, context)) @@ -2029,7 +2212,6 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) - let join = enum_choice::() .for_socket(ParameterWidgetsInfo::new(node_id, JoinInput::INDEX, true, context)) .property_row(); - let miter_limit = number_widget( ParameterWidgetsInfo::new(node_id, MiterLimitInput::INDEX, true, context), NumberInput::default().min(0.).disabled(miter_limit_disabled), @@ -2037,16 +2219,16 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) - let paint_order = enum_choice::() .for_socket(ParameterWidgetsInfo::new(node_id, PaintOrderInput::INDEX, true, context)) .property_row(); - let disabled_number_input = NumberInput::default().unit(" px").disabled(has_dash_lengths); let dash_lengths = array_of_number_widget( ParameterWidgetsInfo::new(node_id, DashLengthsInput::>::INDEX, true, context), TextInput::default().centered(true), ); - let number_input = disabled_number_input; - let dash_offset = number_widget(ParameterWidgetsInfo::new(node_id, DashOffsetInput::INDEX, true, context), number_input); + let dash_offset = number_widget( + ParameterWidgetsInfo::new(node_id, DashOffsetInput::INDEX, true, context), + NumberInput::default().unit(" px").disabled(has_dash_lengths), + ); - vec![ - color, + widgets.extend([ LayoutGroup::row(weight), align, cap, @@ -2055,7 +2237,9 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) - paint_order, LayoutGroup::row(dash_lengths), LayoutGroup::row(dash_offset), - ] + ]); + + widgets } pub fn offset_path_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index c1a2a8fe75..5c29b1e2ce 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -180,8 +180,8 @@ mod test_fill { Err(e) => panic!("Failed to evaluate graph: {e}"), }; - instrumented.grab_all_input::>(&editor.runtime).collect() - } + instrumented.grab_all_input::>(&editor.runtime).collect() +} #[tokio::test] async fn ignore_artboard() { diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index f1823618e5..5db416dc8e 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -1579,7 +1579,7 @@ mod test_gradient { let layers = document.metadata().all_layers(); layers .filter_map(|layer| { - let fill = instrumented.grab_input_from_layer::>(layer, &document.network_interface, &editor.runtime)?; + let fill = instrumented.grab_input_from_layer::>(layer, &document.network_interface, &editor.runtime)?; let transform = gradient_space_transform(layer, document); Some((fill, transform)) }) diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index f455f719b1..6d7f497f31 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -97,17 +97,7 @@ impl RenderExt for Stroke { type Output = String; /// Provide the SVG attributes for the stroke. - fn render( - &self, - _svg_defs: &mut String, - _element_transform: DAffine2, - _stroke_transform: DAffine2, - _bounds: DAffine2, - _transformed_bounds: DAffine2, - render_params: &RenderParams, - ) -> Self::Output { - // Don't render a stroke at all if it would be invisible - let Some(color) = self.color else { return String::new() }; + fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, _transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output { if !self.has_renderable_stroke() { return String::new(); } @@ -123,10 +113,21 @@ impl RenderExt for Stroke { let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow); // Render the needed stroke attributes - let mut attributes = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma()); - if color.a() < 1. { - let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.); - } + let mut attributes = match &self.paint { + Fill::None => return String::new(), + Fill::Solid(color) => { + let mut result = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma()); + if color.a() < 1. { + let _ = write!(result, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.); + } + result + } + Fill::Gradient(gradient) => { + let gradient_id = gradient.render(svg_defs, element_transform, stroke_transform, bounds, _transformed_bounds, render_params); + format!(r##" stroke="url('#{gradient_id}')""##) + } + }; + if let Some(mut weight) = weight { if stroke_align.is_some() && render_params.aligned_strokes { weight *= 2.; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index fc51b50093..db73fb3bc0 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1077,9 +1077,61 @@ impl Render for Table { let do_stroke = |scene: &mut Scene, width_scale: f64| { if let Some(stroke) = row.element.style.stroke() { - let color = match stroke.color { - Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]), - None => peniko::Color::TRANSPARENT, + let (brush, brush_transform) = match &stroke.paint { + Fill::Solid(color) => (peniko::Brush::Solid(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])), None), + Fill::Gradient(gradient) => { + let mut stops = peniko::ColorStops::new(); + for (position, color, _) in gradient.stops.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 bounds = row.element.nonzero_bounding_box(); + let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + + let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. { + parent_transform.inverse() + } else { + Default::default() + }; + let mod_points = inverse_parent_transform * multiplied_transform * bound_transform; + + let start = mod_points.transform_point2(gradient.start); + let end = mod_points.transform_point2(gradient.end); + + let fill = peniko::Brush::Gradient(peniko::Gradient { + kind: match gradient.gradient_type { + GradientType::Linear => peniko::LinearGradientPosition { + start: to_point(start), + end: to_point(end), + } + .into(), + GradientType::Radial => { + let radius = start.distance(end); + peniko::RadialGradientPosition { + start_center: to_point(start), + start_radius: 0., + end_center: to_point(start), + end_radius: radius as f32, + } + .into() + } + }, + stops, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, + ..Default::default() + }); + let inverse_element_transform = if element_transform.matrix2.determinant() != 0. { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + (fill, Some(brush_transform)) + } + Fill::None => (peniko::Brush::Solid(peniko::Color::TRANSPARENT), None), }; let cap = match stroke.cap { StrokeCap::Butt => Cap::Butt, @@ -1092,7 +1144,7 @@ impl Render for Table { StrokeJoin::Round => Join::Round, }; let dash_pattern = stroke.dash_lengths.iter().map(|l| l.max(0.)).collect(); - let stroke = kurbo::Stroke { + let kurbo_stroke = kurbo::Stroke { width: stroke.weight * width_scale, miter_limit: stroke.join_miter_limit, join, @@ -1102,8 +1154,8 @@ impl Render for Table { dash_offset: stroke.dash_offset, }; - if stroke.width > 0. { - scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), color, None, &path); + if stroke.weight > 0. { + scene.stroke(&kurbo_stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, brush_transform, &path); } } }; diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 24fe6bd390..0bb7792a03 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -49,12 +49,12 @@ impl Fill { /// Evaluate the color at some point on the fill. Doesn't currently work for Gradient. pub fn color(&self) -> Color { match self { - Self::None => Color::BLACK, + Self::None => Color::TRANSPARENT, Self::Solid(color) => *color, // TODO: Should correctly sample the gradient the equation here: https://svgwg.org/svg2-draft/pservers.html#Gradients Self::Gradient(Gradient { stops, .. }) => { if stops.is_empty() { - Color::BLACK + Color::TRANSPARENT } else { stops.color[0] } @@ -152,6 +152,7 @@ impl From for Fill { } } + /// Describes the fill of a layer, but unlike [`Fill`], this doesn't store a [`Gradient`] directly but just its [`GradientStops`]. /// /// Can be None, a solid [Color], or a linear/radial [Gradient]. @@ -300,8 +301,8 @@ fn daffine2_identity() -> DAffine2 { #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, DynAny)] #[serde(default)] pub struct Stroke { - /// Stroke color - pub color: Option, + /// Stroke paint (solid color or gradient) + pub paint: Fill, /// Line thickness pub weight: f64, pub dash_lengths: Vec, @@ -322,7 +323,7 @@ pub struct Stroke { impl std::hash::Hash for Stroke { fn hash(&self, state: &mut H) { - self.color.hash(state); + self.paint.hash(state); self.weight.to_bits().hash(state); { self.dash_lengths.len().hash(state); @@ -339,9 +340,9 @@ impl std::hash::Hash for Stroke { } impl Stroke { - pub const fn new(color: Option, weight: f64) -> Self { + pub fn new(color: Option, weight: f64) -> Self { Self { - color, + paint: color.map_or(Fill::None, Fill::Solid), weight, dash_lengths: Vec::new(), dash_offset: 0., @@ -356,7 +357,7 @@ impl Stroke { pub fn lerp(&self, other: &Self, time: f64) -> Self { Self { - color: self.color.map(|color| color.lerp(&other.color.unwrap_or(color), time as f32)), + paint: self.paint.lerp(&other.paint, time), weight: self.weight + (other.weight - self.weight) * time, dash_lengths: self.dash_lengths.iter().zip(other.dash_lengths.iter()).map(|(a, b)| a + (b - a) * time).collect(), dash_offset: self.dash_offset + (other.dash_offset - self.dash_offset) * time, @@ -374,7 +375,11 @@ impl Stroke { /// Get the current stroke color. pub fn color(&self) -> Option { - self.color + match &self.paint { + Fill::None => None, + Fill::Solid(color) => Some(*color), + Fill::Gradient(gradient) => gradient.stops.color.first().copied(), + } } /// Get the current stroke weight. @@ -417,7 +422,7 @@ impl Stroke { } pub fn with_color(mut self, color: &Option) -> Option { - self.color = *color; + self.paint = color.map_or(Fill::None, Fill::Solid); Some(self) } @@ -466,7 +471,14 @@ impl Stroke { } pub fn has_renderable_stroke(&self) -> bool { - self.weight > 0. && self.color.is_some_and(|color| color.a() != 0.) + if self.weight <= 0. { + return false; + } + match &self.paint { + Fill::None => false, + Fill::Solid(color) => color.a() != 0., + Fill::Gradient(_) => true, + } } } @@ -475,7 +487,7 @@ impl Default for Stroke { fn default() -> Self { Self { weight: 0., - color: Some(Color::from_rgba8_srgb(0, 0, 0, 255)), + paint: Fill::Solid(Color::from_rgba8_srgb(0, 0, 0, 255)), dash_lengths: Vec::new(), dash_offset: 0., cap: StrokeCap::Butt, @@ -508,7 +520,7 @@ impl std::fmt::Display for PathStyle { let fill = &self.fill; let stroke = match &self.stroke { - Some(stroke) => format!("#{} (Weight: {} px)", stroke.color.map_or("None".to_string(), |c| c.to_rgba_hex_srgb()), stroke.weight), + Some(stroke) => format!("{} (Weight: {} px)", stroke.paint, stroke.weight), None => "None".to_string(), }; diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index dc3194c06d..dd7a16dc13 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -134,11 +134,13 @@ async fn fill + 'n + Send, V: VectorTableIterMut + 'n + Send>( Table, Gradient, )] - fill: F, + color: F, + #[default(Table::new())] _backup_color: Table, + #[default(Gradient::default())] _backup_gradient: Gradient, ) -> V { - let fill: Fill = fill.into(); + let fill: Fill = color.into(); for vector in content.vector_iter_mut() { vector.element.style.set_fill(fill.clone()); } @@ -167,14 +169,65 @@ impl IntoF64Vec for String { /// Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry. #[node_macro::node(category("Vector: Style"), path(graphene_core::vector), properties("stroke_properties"))] -async fn stroke( +async fn stroke + 'n + Send>( _: impl Ctx, /// The content with vector paths to apply the stroke style to. - #[implementations(Table, Table, Table, Table, Table, Table)] + #[implementations( + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + Table, + )] mut content: Table, /// The stroke color. #[default(Color::BLACK)] - color: Table, + #[implementations( + Fill, + Fill, + Fill, + Table, + Table, + Table, + Table, + Table, + Table, + Gradient, + Gradient, + Gradient, + Fill, + Fill, + Fill, + Table, + Table, + Table, + Table, + Table, + Table, + Gradient, + Gradient, + Gradient, + )] + color: F, /// The stroke thickness. #[unit(" px")] #[default(2.)] @@ -192,17 +245,44 @@ async fn stroke( /// The order to paint the stroke on top of the fill, or the fill on top of the stroke. paint_order: PaintOrder, /// The stroke dash lengths. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed. - #[implementations(Vec, f64, String, Vec, f64, String)] + #[implementations( + Vec, + f64, + String, + Vec, + f64, + String, + Vec, + f64, + String, + Vec, + f64, + String, + Vec, + f64, + String, + Vec, + f64, + String, + Vec, + f64, + String, + Vec, + f64, + String, + )] dash_lengths: L, /// The phase offset distance from the starting point of the dash pattern. #[unit(" px")] dash_offset: f64, + #[default(Table::new())] _backup_color: Table, + #[default(Gradient::default())] _backup_gradient: Gradient, ) -> Table where Table: VectorTableIterMut + 'n + Send, { let stroke = Stroke { - color: color.into(), + paint: color.into(), weight, dash_lengths: dash_lengths.into_vec(), dash_offset, @@ -1157,7 +1237,24 @@ async fn solidify_stroke(_: impl Ctx, content: Table) -> Table { // We set our fill to our stroke's color, then clear our stroke. if let Some(stroke) = vector.style.stroke() { - result.style.set_fill(Fill::solid_or_none(stroke.color)); + let mut paint = stroke.paint.clone(); + + // Remap gradient start/end from the original bounding box space to the new solidified bounding box space + if let Fill::Gradient(ref mut gradient) = paint { + let old_bounds = vector.nonzero_bounding_box(); + let new_bounds = result.nonzero_bounding_box(); + + let old_size = old_bounds[1] - old_bounds[0]; + let new_size = new_bounds[1] - new_bounds[0]; + + // Transform: old_bounds normalized → world → new_bounds normalized + // point_world = old_bounds[0] + point_normalized * old_size + // point_new_normalized = (point_world - new_bounds[0]) / new_size + gradient.start = (old_bounds[0] + gradient.start * old_size - new_bounds[0]) / new_size; + gradient.end = (old_bounds[0] + gradient.end * old_size - new_bounds[0]) / new_size; + } + + result.style.set_fill(paint); result.style.set_stroke(Stroke::default()); } From 60a4b7bc74823f183b7d58ac404e53facfa28ef1 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Tue, 17 Mar 2026 15:35:55 +0530 Subject: [PATCH 2/7] feat: deduplicate stroke UI, and upgrade core rendering --- .../data_panel/data_panel_message_handler.rs | 53 ++ .../document/node_graph/node_properties.rs | 573 +++++++----------- .../messages/tool/tool_messages/fill_tool.rs | 2 +- .../libraries/graphic-types/src/graphic.rs | 73 ++- .../libraries/rendering/src/renderer.rs | 144 ++++- .../vector-types/src/vector/style.rs | 22 +- node-graph/nodes/path-bool/src/lib.rs | 5 +- node-graph/nodes/vector/src/vector_nodes.rs | 6 +- 8 files changed, 496 insertions(+), 382 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index db2ea051ab..be5b4f1bf1 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -165,6 +165,7 @@ fn generate_layout(introspected_data: &Arc>, Table>, Table, + Table, Table, GradientStops, f64, @@ -556,6 +557,58 @@ impl TableRowLayout for Color { } } +impl TableRowLayout for Fill { + fn type_name() -> &'static str { + "Fill" + } + fn identifier(&self) -> String { + match self { + Self::None => "None".to_string(), + Self::Solid(color) => format!("Solid (#{})", color.to_gamma_srgb().to_rgba_hex_srgb()), + Self::Gradient(gradient) => format!("{} Gradient ({} stops)", gradient.gradient_type, gradient.stops.len()), + } + } + fn element_widget(&self, _index: usize) -> WidgetInstance { + let choice = match self { + Self::None => FillChoice::None, + Self::Solid(color) => FillChoice::Solid(*color), + Self::Gradient(gradient) => FillChoice::Gradient(gradient.stops.clone()), + }; + ColorInput::new(choice).disabled(true).menu_direction(Some(MenuDirection::Top)).narrow(true).widget_instance() + } + fn element_page(&self, _data: &mut LayoutData) -> Vec { + let mut table_rows = Vec::new(); + table_rows.push(column_headings(&["property", "value"])); + + match self { + Self::None => table_rows.push(vec![TextLabel::new("Type").narrow(true).widget_instance(), TextLabel::new("None").narrow(true).widget_instance()]), + Self::Solid(color) => { + table_rows.push(vec![TextLabel::new("Type").narrow(true).widget_instance(), TextLabel::new("Solid").narrow(true).widget_instance()]); + table_rows.push(vec![ + TextLabel::new("Color").narrow(true).widget_instance(), + TextLabel::new(format!("#{} (Alpha: {}%)", color.to_rgb_hex_srgb(), color.a() * 100.)).narrow(true).widget_instance(), + ]); + } + Self::Gradient(gradient) => { + table_rows.push(vec![ + TextLabel::new("Type").narrow(true).widget_instance(), + TextLabel::new(format!("{} Gradient", gradient.gradient_type)).narrow(true).widget_instance(), + ]); + table_rows.push(vec![ + TextLabel::new("Start").narrow(true).widget_instance(), + TextLabel::new(format_dvec2(gradient.start)).narrow(true).widget_instance(), + ]); + table_rows.push(vec![ + TextLabel::new("End").narrow(true).widget_instance(), + TextLabel::new(format_dvec2(gradient.end)).narrow(true).widget_instance(), + ]); + } + } + + vec![LayoutGroup::table(table_rows, false)] + } +} + impl TableRowLayout for GradientStops { fn type_name() -> &'static str { "Gradient" 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 085da640c6..1653b4e147 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -79,6 +79,212 @@ pub fn add_blank_assist(widgets: &mut Vec) { ]); } +fn fill_gradient_widgets( + node_id: NodeId, + context: &mut NodePropertiesContext, + color_input_index: usize, + backup_color_index: usize, + backup_gradient_index: usize, + commit_value: impl Fn(&()) -> Message + 'static + Send + Sync + Copy, +) -> Vec { + let document_node = match get_document_node(node_id, context) { + Ok(document_node) => document_node, + Err(err) => { + log::error!("Could not get document node in fill_gradient_widgets: {err}"); + return Vec::new(); + } + }; + + let fill = document_node + .inputs + .get(color_input_index) + .and_then(|i| i.as_non_exposed_value()) + .and_then(|t| match t { + TaggedValue::Fill(f) => Some(f.clone()), + _ => None, + }) + .unwrap_or(Fill::None); + let backup_color = document_node + .inputs + .get(backup_color_index) + .and_then(|i| i.as_non_exposed_value()) + .and_then(|t| match t { + TaggedValue::Color(c) => Some(c.clone()), + _ => None, + }) + .unwrap_or(Table::new()); + let backup_gradient = document_node + .inputs + .get(backup_gradient_index) + .and_then(|i| i.as_non_exposed_value()) + .and_then(|t| match t { + TaggedValue::Gradient(g) => Some(g.clone()), + _ => None, + }) + .unwrap_or(Gradient::default()); + + let mut widgets = Vec::new(); + + let mut widgets_first_row = start_widgets(ParameterWidgetsInfo::new(node_id, color_input_index, true, context)); + let fill2 = fill.clone(); + let backup_color_fill: Fill = backup_color.clone().into(); + let backup_gradient_fill: Fill = backup_gradient.clone().into(); + + widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + widgets_first_row.push( + crate::messages::layout::utility_types::widgets::button_widgets::ColorInput::default() + .value(fill.clone().into()) + .on_update(move |x: &crate::messages::layout::utility_types::widgets::button_widgets::ColorInput| { + let new_fill = x.value.to_fill(fill2.as_gradient()); + let backup_msg = match &new_fill { + Fill::None => NodeGraphMessage::SetInputValue { + node_id, + input_index: backup_color_index, + value: TaggedValue::Color(Table::new()), + } + .into(), + Fill::Solid(color) => NodeGraphMessage::SetInputValue { + node_id, + input_index: backup_color_index, + value: TaggedValue::Color(Table::new_from_element(*color)), + } + .into(), + Fill::Gradient(gradient) => NodeGraphMessage::SetInputValue { + node_id, + input_index: backup_gradient_index, + value: TaggedValue::Gradient(gradient.clone()), + } + .into(), + }; + Message::Batched { + messages: Box::new([ + backup_msg, + NodeGraphMessage::SetInputValue { + node_id, + input_index: color_input_index, + value: TaggedValue::Fill(new_fill), + } + .into(), + ]), + } + }) + .on_commit(move |_| commit_value(&())) + .widget_instance(), + ); + widgets.push(LayoutGroup::row(widgets_first_row)); + + let mut fill_type_row = vec![TextLabel::new("").widget_instance()]; + match fill { + Fill::Solid(_) | Fill::None => add_blank_assist(&mut fill_type_row), + Fill::Gradient(ref gradient) => { + let reverse_button = IconButton::new("Reverse", 24) + .tooltip_description("Reverse the gradient color stops.") + .on_update(update_value( + { + let gradient = gradient.clone(); + move |_| { + let mut gradient = gradient.clone(); + gradient.stops = gradient.stops.reversed(); + TaggedValue::Fill(Fill::Gradient(gradient)) + } + }, + node_id, + color_input_index, + )) + .widget_instance(); + fill_type_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + fill_type_row.push(reverse_button); + } + } + + let entries = vec![ + RadioEntryData::new("solid") + .label("Solid") + .on_update(update_value(move |_| TaggedValue::Fill(backup_color_fill.clone()), node_id, color_input_index)) + .on_commit(move |x| commit_value(x)), + RadioEntryData::new("gradient") + .label("Gradient") + .on_update(update_value(move |_| TaggedValue::Fill(backup_gradient_fill.clone()), node_id, color_input_index)) + .on_commit(move |x| commit_value(x)), + ]; + + fill_type_row.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + RadioInput::new(entries).selected_index(Some(if fill.as_gradient().is_some() { 1 } else { 0 })).widget_instance(), + ]); + widgets.push(LayoutGroup::row(fill_type_row)); + + if let Fill::Gradient(gradient) = fill.clone() { + let mut row = vec![TextLabel::new("").widget_instance()]; + match gradient.gradient_type { + GradientType::Linear => add_blank_assist(&mut row), + GradientType::Radial => { + let orientation = if (gradient.end.x - gradient.start.x).abs() > f64::EPSILON * 1e6 { + gradient.end.x > gradient.start.x + } else { + (gradient.start.x + gradient.start.y) < (gradient.end.x + gradient.end.y) + }; + let reverse_radial_gradient_button = IconButton::new(if orientation { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24) + .tooltip_description("Reverse which end the gradient radiates from.") + .on_update(update_value( + { + let gradient = gradient.clone(); + move |_| { + let mut gradient = gradient.clone(); + std::mem::swap(&mut gradient.start, &mut gradient.end); + TaggedValue::Fill(Fill::Gradient(gradient)) + } + }, + node_id, + color_input_index, + )) + .widget_instance(); + row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); + row.push(reverse_radial_gradient_button); + } + } + + let gradient_for_closure = gradient.clone(); + + let entries = [GradientType::Linear, GradientType::Radial] + .iter() + .map(|&grad_type| { + let gradient = gradient_for_closure.clone(); + let set_input_value = update_value( + move |_: &()| { + let mut new_gradient = gradient.clone(); + new_gradient.gradient_type = grad_type; + TaggedValue::Fill(Fill::Gradient(new_gradient)) + }, + node_id, + color_input_index, + ); + RadioEntryData::new(format!("{:?}", grad_type)) + .label(format!("{:?}", grad_type)) + .on_update(move |_| Message::Batched { + messages: Box::new([ + set_input_value(&()), + GradientToolMessage::UpdateOptions { + options: GradientOptionsUpdate::Type(grad_type), + } + .into(), + ]), + }) + .on_commit(move |x| commit_value(x)) + }) + .collect(); + + row.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + RadioInput::new(entries).selected_index(Some(gradient.gradient_type as u32)).widget_instance(), + ]); + + widgets.push(LayoutGroup::row(row)); + } + + widgets +} + pub fn jump_to_source_widget(input: &NodeInput, network_interface: &NodeNetworkInterface, selection_network_path: &[NodeId]) -> WidgetInstance { match input { NodeInput::Node { node_id: source_id, .. } => { @@ -1816,183 +2022,7 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper /// Fill Node Widgets LayoutGroup pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::vector::fill::*; - - let mut widgets_first_row = start_widgets(ParameterWidgetsInfo::new(node_id, ColorInput::::INDEX, true, context)); - - let document_node = match get_document_node(node_id, context) { - Ok(document_node) => document_node, - Err(err) => { - log::error!("Could not get document node in fill_properties: {err}"); - return Vec::new(); - } - }; - - let (fill, backup_color, backup_gradient) = if let (Some(TaggedValue::Fill(fill)), Some(TaggedValue::Color(backup_color)), Some(TaggedValue::Gradient(backup_gradient))) = ( - &document_node.inputs[ColorInput::::INDEX].as_value(), - &document_node.inputs[BackupColorInput::INDEX].as_value(), - &document_node.inputs[BackupGradientInput::INDEX].as_value(), - ) { - (fill, backup_color, backup_gradient) - } else { - return vec![LayoutGroup::row(widgets_first_row)]; - }; - let fill2 = fill.clone(); - let backup_color_fill: Fill = backup_color.clone().into(); - let backup_gradient_fill: Fill = backup_gradient.clone().into(); - - widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - widgets_first_row.push( - crate::messages::layout::utility_types::widgets::button_widgets::ColorInput::default() - .value(fill.clone().into()) - .on_update(move |x: &crate::messages::layout::utility_types::widgets::button_widgets::ColorInput| Message::Batched { - messages: Box::new([ - match &fill2 { - Fill::None => NodeGraphMessage::SetInputValue { - node_id, - input_index: BackupColorInput::INDEX, - value: TaggedValue::Color(Table::new()), - } - .into(), - Fill::Solid(color) => NodeGraphMessage::SetInputValue { - node_id, - input_index: BackupColorInput::INDEX, - value: TaggedValue::Color(Table::new_from_element(*color)), - } - .into(), - Fill::Gradient(gradient) => NodeGraphMessage::SetInputValue { - node_id, - input_index: BackupGradientInput::INDEX, - value: TaggedValue::Gradient(gradient.clone()), - } - .into(), - }, - NodeGraphMessage::SetInputValue { - node_id, - input_index: ColorInput::::INDEX, - value: TaggedValue::Fill(x.value.to_fill(fill2.as_gradient())), - } - .into(), - ]), - }) - .on_commit(commit_value) - .widget_instance(), - ); - let mut widgets = vec![LayoutGroup::row(widgets_first_row)]; - - let fill_type_switch = { - let mut row = vec![TextLabel::new("").widget_instance()]; - match fill { - Fill::Solid(_) | Fill::None => add_blank_assist(&mut row), - Fill::Gradient(gradient) => { - let reverse_button = IconButton::new("Reverse", 24) - .tooltip_description("Reverse the gradient color stops.") - .on_update(update_value( - { - let gradient = gradient.clone(); - move |_| { - let mut gradient = gradient.clone(); - gradient.stops = gradient.stops.reversed(); - TaggedValue::Fill(Fill::Gradient(gradient)) - } - }, - node_id, - ColorInput::::INDEX, - )) - .widget_instance(); - row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - row.push(reverse_button); - } - } - - let entries = vec![ - RadioEntryData::new("solid") - .label("Solid") - .on_update(update_value(move |_| TaggedValue::Fill(backup_color_fill.clone()), node_id, ColorInput::::INDEX)) - .on_commit(commit_value), - RadioEntryData::new("gradient") - .label("Gradient") - .on_update(update_value(move |_| TaggedValue::Fill(backup_gradient_fill.clone()), node_id, ColorInput::::INDEX)) - .on_commit(commit_value), - ]; - - row.extend_from_slice(&[ - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - RadioInput::new(entries).selected_index(Some(if fill.as_gradient().is_some() { 1 } else { 0 })).widget_instance(), - ]); - - LayoutGroup::row(row) - }; - widgets.push(fill_type_switch); - - if let Fill::Gradient(gradient) = fill.clone() { - let mut row = vec![TextLabel::new("").widget_instance()]; - match gradient.gradient_type { - GradientType::Linear => add_blank_assist(&mut row), - GradientType::Radial => { - let orientation = if (gradient.end.x - gradient.start.x).abs() > f64::EPSILON * 1e6 { - gradient.end.x > gradient.start.x - } else { - (gradient.start.x + gradient.start.y) < (gradient.end.x + gradient.end.y) - }; - let reverse_radial_gradient_button = IconButton::new(if orientation { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24) - .tooltip_description("Reverse which end the gradient radiates from.") - .on_update(update_value( - { - let gradient = gradient.clone(); - move |_| { - let mut gradient = gradient.clone(); - std::mem::swap(&mut gradient.start, &mut gradient.end); - TaggedValue::Fill(Fill::Gradient(gradient)) - } - }, - node_id, - ColorInput::::INDEX, - )) - .widget_instance(); - row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - row.push(reverse_radial_gradient_button); - } - } - - let gradient_for_closure = gradient.clone(); - - let entries = [GradientType::Linear, GradientType::Radial] - .iter() - .map(|&grad_type| { - let gradient = gradient_for_closure.clone(); - let set_input_value = update_value( - move |_: &()| { - let mut new_gradient = gradient.clone(); - new_gradient.gradient_type = grad_type; - TaggedValue::Fill(Fill::Gradient(new_gradient)) - }, - node_id, - ColorInput::::INDEX, - ); - RadioEntryData::new(format!("{:?}", grad_type)) - .label(format!("{:?}", grad_type)) - .on_update(move |_| Message::Batched { - messages: Box::new([ - set_input_value(&()), - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::Type(grad_type), - } - .into(), - ]), - }) - .on_commit(commit_value) - }) - .collect(); - - row.extend_from_slice(&[ - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - RadioInput::new(entries).selected_index(Some(gradient.gradient_type as u32)).widget_instance(), - ]); - - widgets.push(LayoutGroup::row(row)); - } - - widgets + fill_gradient_widgets(node_id, context, ColorInput::::INDEX, BackupColorInput::INDEX, BackupGradientInput::INDEX, commit_value) } pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { @@ -2001,7 +2031,7 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) - let document_node = match get_document_node(node_id, context) { Ok(document_node) => document_node, Err(err) => { - log::error!("Could not get document node in fill_properties: {err}"); + log::error!("Could not get document node in stroke_properties: {err}"); return Vec::new(); } }; @@ -2017,192 +2047,7 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) - let has_dash_lengths = dash_lengths_val.is_empty(); let miter_limit_disabled = join_value != &StrokeJoin::Miter; - let fill = document_node - .inputs - .get(ColorInput::::INDEX) - .and_then(|i| i.as_non_exposed_value()) - .and_then(|t| match t { - TaggedValue::Fill(f) => Some(f.clone()), - _ => None, - }) - .unwrap_or(Fill::None); - let backup_color = document_node - .inputs - .get(BackupColorInput::INDEX) - .and_then(|i| i.as_non_exposed_value()) - .and_then(|t| match t { - TaggedValue::Color(c) => Some(c.clone()), - _ => None, - }) - .unwrap_or(Table::new()); - let backup_gradient = document_node - .inputs - .get(BackupGradientInput::INDEX) - .and_then(|i| i.as_non_exposed_value()) - .and_then(|t| match t { - TaggedValue::Gradient(g) => Some(g.clone()), - _ => None, - }) - .unwrap_or(Gradient::default()); - - let mut widgets = Vec::new(); - - let mut widgets_first_row = start_widgets(ParameterWidgetsInfo::new(node_id, ColorInput::::INDEX, true, context)); - let fill2 = fill.clone(); - let backup_color_fill: Fill = backup_color.clone().into(); - let backup_gradient_fill: Fill = backup_gradient.clone().into(); - - widgets_first_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - widgets_first_row.push( - crate::messages::layout::utility_types::widgets::button_widgets::ColorInput::default() - .value(fill.clone().into()) - .on_update(move |x: &crate::messages::layout::utility_types::widgets::button_widgets::ColorInput| { - let new_fill = x.value.to_fill(fill2.as_gradient()); - let backup_msg = match &new_fill { - Fill::None => NodeGraphMessage::SetInputValue { - node_id, - input_index: BackupColorInput::INDEX, - value: TaggedValue::Color(Table::new()), - } - .into(), - Fill::Solid(color) => NodeGraphMessage::SetInputValue { - node_id, - input_index: BackupColorInput::INDEX, - value: TaggedValue::Color(Table::new_from_element(*color)), - } - .into(), - Fill::Gradient(gradient) => NodeGraphMessage::SetInputValue { - node_id, - input_index: BackupGradientInput::INDEX, - value: TaggedValue::Gradient(gradient.clone()), - } - .into(), - }; - Message::Batched { - messages: Box::new([ - backup_msg, - NodeGraphMessage::SetInputValue { - node_id, - input_index: ColorInput::::INDEX, - value: TaggedValue::Fill(new_fill), - } - .into(), - ]), - } - }) - .on_commit(commit_value) - .widget_instance(), - ); - widgets.push(LayoutGroup::row(widgets_first_row)); - - let mut fill_type_row = vec![TextLabel::new("").widget_instance()]; - match fill { - Fill::Solid(_) | Fill::None => add_blank_assist(&mut fill_type_row), - Fill::Gradient(ref gradient) => { - let reverse_button = IconButton::new("Reverse", 24) - .tooltip_description("Reverse the gradient color stops.") - .on_update(update_value( - { - let gradient = gradient.clone(); - move |_| { - let mut gradient = gradient.clone(); - gradient.stops = gradient.stops.reversed(); - TaggedValue::Fill(Fill::Gradient(gradient)) - } - }, - node_id, - ColorInput::::INDEX, - )) - .widget_instance(); - fill_type_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - fill_type_row.push(reverse_button); - } - } - - let entries = vec![ - RadioEntryData::new("solid") - .label("Solid") - .on_update(update_value(move |_| TaggedValue::Fill(backup_color_fill.clone()), node_id, ColorInput::::INDEX)) - .on_commit(commit_value), - RadioEntryData::new("gradient") - .label("Gradient") - .on_update(update_value(move |_| TaggedValue::Fill(backup_gradient_fill.clone()), node_id, ColorInput::::INDEX)) - .on_commit(commit_value), - ]; - - fill_type_row.extend_from_slice(&[ - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - RadioInput::new(entries).selected_index(Some(if fill.as_gradient().is_some() { 1 } else { 0 })).widget_instance(), - ]); - widgets.push(LayoutGroup::row(fill_type_row)); - - if let Fill::Gradient(gradient) = fill.clone() { - let mut row = vec![TextLabel::new("").widget_instance()]; - match gradient.gradient_type { - GradientType::Linear => add_blank_assist(&mut row), - GradientType::Radial => { - let orientation = if (gradient.end.x - gradient.start.x).abs() > f64::EPSILON * 1e6 { - gradient.end.x > gradient.start.x - } else { - (gradient.start.x + gradient.start.y) < (gradient.end.x + gradient.end.y) - }; - let reverse_radial_gradient_button = IconButton::new(if orientation { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24) - .tooltip_description("Reverse which end the gradient radiates from.") - .on_update(update_value( - { - let gradient = gradient.clone(); - move |_| { - let mut gradient = gradient.clone(); - std::mem::swap(&mut gradient.start, &mut gradient.end); - TaggedValue::Fill(Fill::Gradient(gradient)) - } - }, - node_id, - ColorInput::::INDEX, - )) - .widget_instance(); - row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); - row.push(reverse_radial_gradient_button); - } - } - - let gradient_for_closure = gradient.clone(); - - let entries = [GradientType::Linear, GradientType::Radial] - .iter() - .map(|&grad_type| { - let gradient = gradient_for_closure.clone(); - let set_input_value = update_value( - move |_: &()| { - let mut new_gradient = gradient.clone(); - new_gradient.gradient_type = grad_type; - TaggedValue::Fill(Fill::Gradient(new_gradient)) - }, - node_id, - ColorInput::::INDEX, - ); - RadioEntryData::new(format!("{:?}", grad_type)) - .label(format!("{:?}", grad_type)) - .on_update(move |_| Message::Batched { - messages: Box::new([ - set_input_value(&()), - GradientToolMessage::UpdateOptions { - options: GradientOptionsUpdate::Type(grad_type), - } - .into(), - ]), - }) - .on_commit(commit_value) - }) - .collect(); - - row.extend_from_slice(&[ - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - RadioInput::new(entries).selected_index(Some(gradient.gradient_type as u32)).widget_instance(), - ]); - - widgets.push(LayoutGroup::row(row)); - } + let mut widgets = fill_gradient_widgets(node_id, context, ColorInput::::INDEX, BackupColorInput::INDEX, BackupGradientInput::INDEX, commit_value); let weight = number_widget(ParameterWidgetsInfo::new(node_id, WeightInput::INDEX, true, context), NumberInput::default().unit(" px").min(0.)); let align = enum_choice::() diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 5c29b1e2ce..5794e804fc 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -181,7 +181,7 @@ mod test_fill { }; instrumented.grab_all_input::>(&editor.runtime).collect() -} + } #[tokio::test] async fn ignore_artboard() { diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 001c0c33a2..cbe5a40ffe 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -14,7 +14,6 @@ use vector_types::GradientStops; pub type Vector = vector_types::Vector>>; -/// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax. #[derive(Clone, Debug, Hash, PartialEq, DynAny, serde::Serialize, serde::Deserialize)] pub enum Graphic { Graphic(Table), @@ -22,7 +21,7 @@ pub enum Graphic { RasterCPU(Table>), RasterGPU(Table>), Color(Table), - Gradient(Table), + Gradient(Table), } impl Default for Graphic { @@ -92,15 +91,47 @@ impl From> for Graphic { // Note: Table conversions handled by blanket impl in gcore // Note: Table -> Option is in gcore (Color is defined there) -// GradientStops +// Fill +impl From for Graphic { + fn from(fill: vector_types::vector::style::Fill) -> Self { + Graphic::Gradient(Table::new_from_element(fill)) + } +} +impl From> for Graphic { + fn from(fill: Table) -> Self { + Graphic::Gradient(fill) + } +} + +// Gradient (GradientStops) impl From for Graphic { fn from(gradient: GradientStops) -> Self { - Graphic::Gradient(Table::new_from_element(gradient)) + Graphic::Gradient(Table::new_from_element(vector_types::vector::style::Fill::Gradient(vector_types::vector::style::Gradient { + stops: gradient, + gradient_type: vector_types::vector::style::GradientType::Linear, + start: glam::DVec2::new(0., 0.5), + end: glam::DVec2::new(1., 0.5), + }))) } } impl From> for Graphic { fn from(gradient: Table) -> Self { - Graphic::Gradient(gradient) + Graphic::Gradient( + gradient + .into_iter() + .map(|row| TableRow { + element: vector_types::vector::style::Fill::Gradient(vector_types::vector::style::Gradient { + stops: row.element, + gradient_type: vector_types::vector::style::GradientType::Linear, + start: glam::DVec2::new(0., 0.5), + end: glam::DVec2::new(1., 0.5), + }), + transform: row.transform, + alpha_blending: row.alpha_blending, + source_node_id: row.source_node_id, + }) + .collect(), + ) } } @@ -176,12 +207,34 @@ impl TryFromGraphic for Color { } } -impl TryFromGraphic for GradientStops { +impl TryFromGraphic for vector_types::vector::style::Fill { fn try_from_graphic(graphic: Graphic) -> Option> { if let Graphic::Gradient(t) = graphic { Some(t) } else { None } } } +impl TryFromGraphic for GradientStops { + fn try_from_graphic(graphic: Graphic) -> Option> { + if let Graphic::Gradient(t) = graphic { + Some( + t.into_iter() + .filter_map(|row| match row.element { + vector_types::vector::style::Fill::Gradient(g) => Some(TableRow { + element: g.stops, + transform: row.transform, + alpha_blending: row.alpha_blending, + source_node_id: row.source_node_id, + }), + _ => None, + }) + .collect(), + ) + } else { + None + } + } +} + // Local trait to convert types to Table (avoids orphan rule issues) pub trait IntoGraphicTable { fn into_graphic_table(self) -> Table; @@ -225,12 +278,18 @@ impl IntoGraphicTable for Table { } } -impl IntoGraphicTable for Table { +impl IntoGraphicTable for Table { fn into_graphic_table(self) -> Table { Table::new_from_element(Graphic::Gradient(self)) } } +impl IntoGraphicTable for Table { + fn into_graphic_table(self) -> Table { + Table::new_from_element(self.into()) + } +} + impl IntoGraphicTable for DAffine2 { fn into_graphic_table(self) -> Table { Table::new_from_element(Graphic::default()) diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index db73fb3bc0..07a8c073fb 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1607,11 +1607,153 @@ impl Render for Table { } } +impl Render for Table { + fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { + for row in self.iter() { + match &row.element { + vector_types::vector::style::Fill::None => {} + vector_types::vector::style::Fill::Solid(color) => { + 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}")); + + attributes.push("fill", format!("#{}", color.to_rgb_hex_srgb_from_gamma())); + if color.a() < 1. { + attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string()); + } + + let opacity = row.alpha_blending.opacity(render_params.for_mask); + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if row.alpha_blending.blend_mode != BlendMode::default() { + attributes.push("style", row.alpha_blending.blend_mode.render()); + } + }); + } + vector_types::vector::style::Fill::Gradient(gradient) => { + render.leaf_tag("polyline", |attributes| { + // Chrome doesn't like drawing centered rectangles bigger than ~20 million so we draw a polyline quad instead + let max = u64::MAX; + attributes.push("points", format!("{max},{max} -{max},{max} -{max},-{max} {max},-{max}")); + + let mut stop_string = String::new(); + for (position, color, original_midpoint) in gradient.stops.interpolated_samples() { + let _ = write!(stop_string, r##""); + } + + let gradient_transform = 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() + } else { + format!(r#" gradientTransform="{gradient_transform_matrix}""#) + }; + + let gradient_id = generate_uuid(); + let start = gradient.start; + let end = gradient.end; + + match gradient.gradient_type { + vector_types::vector::style::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}"# + ); + } + vector_types::vector::style::GradientType::Radial => { + let (cx, cy) = (start.x, start.y); + let r = start.distance(end); + let _ = write!( + &mut attributes.0.svg_defs, + r#"{stop_string}"# + ); + } + } + + attributes.push("fill", format!("url('#{gradient_id}')")); + + let opacity = row.alpha_blending.opacity(render_params.for_mask); + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if row.alpha_blending.blend_mode != BlendMode::default() { + attributes.push("style", row.alpha_blending.blend_mode.render()); + } + }); + } + } + } + } + + fn render_to_vello(&self, scene: &mut Scene, _parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { + use vello::peniko; + + for row in self.iter() { + 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 = match &row.element { + vector_types::vector::style::Fill::None => continue, + vector_types::vector::style::Fill::Solid(color) => *color, + vector_types::vector::style::Fill::Gradient(gradient) => gradient.stops.color.first().copied().unwrap_or(Color::MAGENTA), + }; + + let vello_color = peniko::Color::new([color.r(), color.g(), color.b(), color.a()]); + + 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); + 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); + + if layer { + scene.pop_layer(); + } + } + } + + fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { + if let Some(element_id) = element_id { + metadata.upstream_footprints.insert(element_id, footprint); + + // TODO: Find a way to handle more than the first row + if let Some(row) = self.iter().next() { + metadata.local_transforms.insert(element_id, *row.transform); + } + } + } + + fn add_upstream_click_targets(&self, _click_targets: &mut Vec) {} + + fn contains_artboard(&self) -> bool { + false + } +} + 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}")); diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 0bb7792a03..07795a4c64 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -2,7 +2,9 @@ pub use crate::gradient::*; use core_types::Color; +use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::color::Alpha; +use core_types::render_complexity::RenderComplexity; use core_types::table::Table; use dyn_any::DynAny; use glam::DAffine2; @@ -32,6 +34,25 @@ impl std::fmt::Display for Fill { } } +impl BoundingBox for Fill { + fn bounding_box(&self, _transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox { + match self { + Self::None => RenderBoundingBox::None, + Self::Solid(_) | Self::Gradient(_) => RenderBoundingBox::Infinite, + } + } +} + +impl RenderComplexity for Fill { + fn render_complexity(&self) -> usize { + match self { + Self::None => 0, + Self::Solid(_) => 1, + Self::Gradient(g) => g.stops.len(), + } + } +} + impl Fill { /// Construct a new [Fill::Solid] from a [Color]. pub fn solid(color: Color) -> Self { @@ -152,7 +173,6 @@ impl From for Fill { } } - /// Describes the fill of a layer, but unlike [`Fill`], this doesn't store a [`Gradient`] directly but just its [`GradientStops`]. /// /// Can be None, a solid [Color], or a linear/radial [Gradient]. diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 49ab3004b4..3793858109 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -306,10 +306,7 @@ fn flatten_vector(graphic_table: &Table) -> Table { .into_iter() .map(|row| { let mut element = Vector::default(); - element.style.set_fill(Fill::Gradient(graphic_types::vector_types::gradient::Gradient { - stops: row.element, - ..Default::default() - })); + element.style.set_fill(row.element.clone()); element.style.set_stroke_transform(DAffine2::IDENTITY); TableRow { diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index dd7a16dc13..845abcf691 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -135,10 +135,8 @@ async fn fill + 'n + Send, V: VectorTableIterMut + 'n + Send>( Gradient, )] color: F, - #[default(Table::new())] - _backup_color: Table, - #[default(Gradient::default())] - _backup_gradient: Gradient, + #[default(Table::new())] _backup_color: Table, + #[default(Gradient::default())] _backup_gradient: Gradient, ) -> V { let fill: Fill = color.into(); for vector in content.vector_iter_mut() { From 3a3f02a70ab096bff7be27bcb21cb5b7f0a4e656 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Wed, 18 Mar 2026 09:35:31 +0530 Subject: [PATCH 3/7] fix: unused parameter prefix and added new_size != 0 guard --- node-graph/libraries/rendering/src/render_ext.rs | 4 ++-- node-graph/nodes/vector/src/vector_nodes.rs | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 6d7f497f31..5a33b18145 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -97,7 +97,7 @@ impl RenderExt for Stroke { type Output = String; /// Provide the SVG attributes for the stroke. - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, _transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output { + fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output { if !self.has_renderable_stroke() { return String::new(); } @@ -123,7 +123,7 @@ impl RenderExt for Stroke { result } Fill::Gradient(gradient) => { - let gradient_id = gradient.render(svg_defs, element_transform, stroke_transform, bounds, _transformed_bounds, render_params); + let gradient_id = gradient.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); format!(r##" stroke="url('#{gradient_id}')""##) } }; diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 845abcf691..ee5d382781 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1245,11 +1245,14 @@ async fn solidify_stroke(_: impl Ctx, content: Table) -> Table { let old_size = old_bounds[1] - old_bounds[0]; let new_size = new_bounds[1] - new_bounds[0]; - // Transform: old_bounds normalized → world → new_bounds normalized - // point_world = old_bounds[0] + point_normalized * old_size - // point_new_normalized = (point_world - new_bounds[0]) / new_size - gradient.start = (old_bounds[0] + gradient.start * old_size - new_bounds[0]) / new_size; - gradient.end = (old_bounds[0] + gradient.end * old_size - new_bounds[0]) / new_size; + // Only remap if new_size is non-zero to avoid division by zero + if new_size != 0. { + // Transform: old_bounds normalized → world → new_bounds normalized + // point_world = old_bounds[0] + point_normalized * old_size + // point_new_normalized = (point_world - new_bounds[0]) / new_size + gradient.start = (old_bounds[0] + gradient.start * old_size - new_bounds[0]) / new_size; + gradient.end = (old_bounds[0] + gradient.end * old_size - new_bounds[0]) / new_size; + } } result.style.set_fill(paint); From 521ad5186ecdde665da55979e34b11079d448e46 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Wed, 18 Mar 2026 10:27:21 +0530 Subject: [PATCH 4/7] fix: compare against DVec2::ZERO --- node-graph/nodes/vector/src/vector_nodes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index ee5d382781..57f0893b79 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1246,7 +1246,7 @@ async fn solidify_stroke(_: impl Ctx, content: Table) -> Table { let new_size = new_bounds[1] - new_bounds[0]; // Only remap if new_size is non-zero to avoid division by zero - if new_size != 0. { + if new_size != DVec2::ZERO { // Transform: old_bounds normalized → world → new_bounds normalized // point_world = old_bounds[0] + point_normalized * old_size // point_new_normalized = (point_world - new_bounds[0]) / new_size From 2ba7f6d96c62f3fbe46e79a308cb3652de703c71 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Fri, 20 Mar 2026 15:45:11 +0530 Subject: [PATCH 5/7] fix: serde migration for old documents --- .../graph_operation_message_handler.rs | 2 + .../messages/portfolio/document_migration.rs | 20 +++++ node-graph/graph-craft/src/document/value.rs | 31 ++++++- .../vector-types/src/vector/style.rs | 90 ++++++++++++++++++- node-graph/nodes/vector/src/vector_nodes.rs | 3 + 5 files changed, 140 insertions(+), 6 deletions(-) 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 ea591453a4..77d4ebbbd2 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 @@ -515,10 +515,12 @@ fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsCont align: StrokeAlign::Center, paint_order: PaintOrder::StrokeAbove, transform, + non_scaling: false, }) } } + fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, bounds_transform: DAffine2, graphite_gradient_stops: &HashMap) { modify_inputs.fill_set(match &fill.paint() { usvg::Paint::Color(color) => Fill::solid(usvg_color(*color, fill.opacity().get())), diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 58c33c55c4..0308e39614 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1093,6 +1093,26 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node(*node_id, 9), old_inputs[4].clone(), network_path); } + // Upgrade Stroke node to add "_backup_color" and "_backup_gradient" (10 -> 12) + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER) && inputs_count == 10 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + for i in 0..10 { + document.network_interface.set_input(&InputConnector::node(*node_id, i), old_inputs[i].clone(), network_path); + } + } + + // Upgrade Fill node to add "_backup_color" and "_backup_gradient" (2 -> 4) + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector_nodes::fill::IDENTIFIER) && inputs_count == 2 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + for i in 0..2 { + document.network_interface.set_input(&InputConnector::node(*node_id, i), old_inputs[i].clone(), network_path); + } + } + + + // Upgrade the old "Spline" node to the new "Spline" node if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::spline::IDENTIFIER) || reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_core::vector::generator_nodes::SplineNode")) diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 86c7b1397e..822fcd450a 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -403,9 +403,34 @@ impl TaggedValue { () if ty == TypeId::of::() => FromStr::from_str(string).map(TaggedValue::Bool).ok()?, // `Color` (not in a table) is still currently needed by `BlackAndWhiteNode` and `ColorOverlayNode` GPU `shader_node(PerPixelAdjust)` variants () if ty == TypeId::of::() => to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color)))?, - () if ty == TypeId::of::>() => to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color)))?, - () if ty == TypeId::of::>() => to_gradient(string).map(|color| TaggedValue::GradientTable(Table::new_from_element(color)))?, - () if ty == TypeId::of::() => to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color)))?, + () if ty == TypeId::of::>() => { + if string.trim().replace(' ', "") == "Table::new()" { + Some(TaggedValue::Color(Table::new())) + } else { + to_color(string).map(|color| TaggedValue::Color(Table::new_from_element(color))) + } + }?, + () if ty == TypeId::of::>() => { + if string.trim().replace(' ', "") == "Table::new()" { + Some(TaggedValue::GradientTable(Table::new())) + } else { + to_gradient(string).map(|color| TaggedValue::GradientTable(Table::new_from_element(color))) + } + }?, + () if ty == TypeId::of::() => { + if string.trim().replace(' ', "") == "Fill::default()" { + Some(TaggedValue::Fill(Fill::default())) + } else { + to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color))) + } + }?, + () if ty == TypeId::of::() => { + if string.trim().replace(' ', "") == "Gradient::default()" { + Some(TaggedValue::Gradient(graphic_types::vector_types::vector::style::Gradient::default())) + } else { + None + } + }?, () if ty == TypeId::of::() => to_reference_point(string).map(TaggedValue::ReferencePoint)?, _ => return None, }; diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 07795a4c64..e6709fbd4e 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -316,10 +316,80 @@ fn daffine2_identity() -> DAffine2 { DAffine2::IDENTITY } +/// Helper module for deserializing the `Stroke::paint` field, supporting both the +/// new `"paint": Fill` format and the legacy `"color": Color` format from old `.graphite` files. +mod stroke_paint_migration { + use super::*; + use serde::Deserialize; + + /// Legacy representation: old `.graphite` files stored a bare `Color` in a field called `"color"`. + #[derive(Deserialize)] + struct OldStroke { + #[serde(default)] + color: Option, + #[serde(default)] + paint: Option, + #[serde(default)] + weight: f64, + #[serde(default)] + dash_lengths: Vec, + #[serde(default)] + dash_offset: f64, + #[serde(default, alias = "line_cap")] + cap: StrokeCap, + #[serde(default, alias = "line_join")] + join: StrokeJoin, + #[serde(default = "default_miter_limit", alias = "line_join_miter_limit")] + join_miter_limit: f64, + #[serde(default)] + align: StrokeAlign, + #[serde(default = "super::daffine2_identity")] + transform: DAffine2, + #[serde(default)] + non_scaling: bool, + #[serde(default)] + paint_order: PaintOrder, + } + + + fn default_miter_limit() -> f64 { + 4. + } + + /// Attempt to deserialize a `Stroke` from either the new or old format. + pub fn deserialize_stroke<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let old = OldStroke::deserialize(deserializer)?; + + // If the new `paint` field is present, use it directly. + // Otherwise, fall back to converting the legacy `color` field into `Fill::Solid`. + let paint = match old.paint { + Some(paint) => paint, + None => match old.color { + Some(color) => Fill::Solid(color), + None => Fill::Solid(Color::from_rgba8_srgb(0, 0, 0, 255)), + }, + }; + + Ok(Stroke { + paint, + weight: old.weight, + dash_lengths: old.dash_lengths, + dash_offset: old.dash_offset, + cap: old.cap, + join: old.join, + join_miter_limit: old.join_miter_limit, + align: old.align, + transform: old.transform, + non_scaling: old.non_scaling, + paint_order: old.paint_order, + }) + } + +} + #[repr(C)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, DynAny)] -#[serde(default)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, DynAny)] pub struct Stroke { /// Stroke paint (solid color or gradient) pub paint: Fill, @@ -337,10 +407,17 @@ pub struct Stroke { pub align: StrokeAlign, #[serde(default = "daffine2_identity")] pub transform: DAffine2, - #[serde(default)] + pub non_scaling: bool, pub paint_order: PaintOrder, } + +impl<'de> serde::Deserialize<'de> for Stroke { + fn deserialize>(deserializer: D) -> Result { + stroke_paint_migration::deserialize_stroke(deserializer) + } +} + impl std::hash::Hash for Stroke { fn hash(&self, state: &mut H) { self.paint.hash(state); @@ -355,8 +432,10 @@ impl std::hash::Hash for Stroke { self.join_miter_limit.to_bits().hash(state); self.align.hash(state); self.transform.to_cols_array().iter().for_each(|x| x.to_bits().hash(state)); + self.non_scaling.hash(state); self.paint_order.hash(state); } + } impl Stroke { @@ -371,10 +450,12 @@ impl Stroke { join_miter_limit: 4., align: StrokeAlign::Center, transform: DAffine2::IDENTITY, + non_scaling: false, paint_order: PaintOrder::StrokeAbove, } } + pub fn lerp(&self, other: &Self, time: f64) -> Self { Self { paint: self.paint.lerp(&other.paint, time), @@ -389,10 +470,12 @@ impl Stroke { time * self.transform.matrix2 + (1. - time) * other.transform.matrix2, self.transform.translation * time + other.transform.translation * (1. - time), ), + non_scaling: if time < 0.5 { self.non_scaling } else { other.non_scaling }, paint_order: if time < 0.5 { self.paint_order } else { other.paint_order }, } } + /// Get the current stroke color. pub fn color(&self) -> Option { match &self.paint { @@ -515,6 +598,7 @@ impl Default for Stroke { join_miter_limit: 4., align: StrokeAlign::Center, transform: DAffine2::IDENTITY, + non_scaling: false, paint_order: PaintOrder::default(), } } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 57f0893b79..dad4c72643 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -241,6 +241,7 @@ async fn stroke + 'n + Send>( miter_limit: f64, // /// The order to paint the stroke on top of the fill, or the fill on top of the stroke. + #[default(PaintOrder::StrokeAbove)] paint_order: PaintOrder, /// The stroke dash lengths. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed. #[implementations( @@ -289,9 +290,11 @@ where join_miter_limit: miter_limit, align, transform: DAffine2::IDENTITY, + non_scaling: false, paint_order, }; + for vector in content.vector_iter_mut() { let mut stroke = stroke.clone(); stroke.transform *= *vector.transform; From 9f683f6760cab176cfca8c17056ba72b478371b0 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Fri, 20 Mar 2026 16:08:49 +0530 Subject: [PATCH 6/7] cargo format check --- .../graph_operation/graph_operation_message_handler.rs | 1 - editor/src/messages/portfolio/document_migration.rs | 2 -- node-graph/libraries/vector-types/src/vector/style.rs | 6 ------ node-graph/nodes/vector/src/vector_nodes.rs | 1 - 4 files changed, 10 deletions(-) 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 77d4ebbbd2..a890abdad6 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 @@ -520,7 +520,6 @@ fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsCont } } - fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, bounds_transform: DAffine2, graphite_gradient_stops: &HashMap) { modify_inputs.fill_set(match &fill.paint() { usvg::Paint::Color(color) => Fill::solid(usvg_color(*color, fill.opacity().get())), diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 0308e39614..d8adadedd7 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1111,8 +1111,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], } } - - // Upgrade the old "Spline" node to the new "Spline" node if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::spline::IDENTIFIER) || reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_core::vector::generator_nodes::SplineNode")) diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index e6709fbd4e..a7df9014c7 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -351,7 +351,6 @@ mod stroke_paint_migration { paint_order: PaintOrder, } - fn default_miter_limit() -> f64 { 4. } @@ -384,7 +383,6 @@ mod stroke_paint_migration { paint_order: old.paint_order, }) } - } #[repr(C)] @@ -411,7 +409,6 @@ pub struct Stroke { pub paint_order: PaintOrder, } - impl<'de> serde::Deserialize<'de> for Stroke { fn deserialize>(deserializer: D) -> Result { stroke_paint_migration::deserialize_stroke(deserializer) @@ -435,7 +432,6 @@ impl std::hash::Hash for Stroke { self.non_scaling.hash(state); self.paint_order.hash(state); } - } impl Stroke { @@ -455,7 +451,6 @@ impl Stroke { } } - pub fn lerp(&self, other: &Self, time: f64) -> Self { Self { paint: self.paint.lerp(&other.paint, time), @@ -475,7 +470,6 @@ impl Stroke { } } - /// Get the current stroke color. pub fn color(&self) -> Option { match &self.paint { diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index dad4c72643..ccbb175867 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -294,7 +294,6 @@ where paint_order, }; - for vector in content.vector_iter_mut() { let mut stroke = stroke.clone(); stroke.transform *= *vector.transform; From f3fc7b88d0d370b3ac9da9f55ddbce14fed67519 Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Fri, 20 Mar 2026 16:24:38 +0530 Subject: [PATCH 7/7] fix: solid fallback to none --- node-graph/libraries/vector-types/src/vector/style.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index a7df9014c7..80e59750f6 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -365,7 +365,7 @@ mod stroke_paint_migration { Some(paint) => paint, None => match old.color { Some(color) => Fill::Solid(color), - None => Fill::Solid(Color::from_rgba8_srgb(0, 0, 0, 255)), + None => Fill::None, }, };