From 871468d27bb137e074fc331c85ed0174ec1bb2af Mon Sep 17 00:00:00 2001 From: mohan-bee Date: Mon, 16 Mar 2026 12:46:06 +0530 Subject: [PATCH 1/2] 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/2] 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() {