From 7ddab438d1473bb41c406d5bf4a36b7396c22c8c Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 20 Jul 2026 16:13:58 -0700 Subject: [PATCH] Split Fill's optional transform into a has-transform toggle and add the DashPattern and BoxCorners value types --- .../document/graph_operation/utility_types.rs | 11 +- .../document/node_graph/node_properties.rs | 98 +++++++++---- .../storage_tests/round_trip_tests.rs | 43 ++++++ .../messages/portfolio/document_migration.rs | 131 +++++++++++++++--- .../graph_modification_utils.rs | 18 +-- .../common_functionality/stroke_options.rs | 2 +- .../tool/tool_messages/gradient_tool.rs | 3 +- editor/src/node_graph_executor.rs | 12 +- node-graph/graph-craft/src/document/value.rs | 21 ++- .../libraries/vector-types/src/vector/misc.rs | 72 ++++++++++ .../vector-types/src/vector/style.rs | 63 ++++++++- .../nodes/vector/src/generator_nodes.rs | 75 +++++----- node-graph/nodes/vector/src/vector_nodes.rs | 22 +-- 13 files changed, 449 insertions(+), 122 deletions(-) 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 ae2097cdf5..ca23aa18bc 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -492,9 +492,14 @@ impl<'a> ModifyInputsContext<'a> { .and_then(|node| node.inputs.get(graphene_std::vector::fill::TransformInput::INDEX)) .is_some_and(|input| input.as_value().is_some()); if transform_is_value { + self.set_input_with_refresh( + InputConnector::node(fill_node_id, graphene_std::vector::fill::HasTransformInput::INDEX), + NodeInput::value(TaggedValue::Bool(true), false), + true, + ); self.set_input_with_refresh( InputConnector::node(fill_node_id, graphene_std::vector::fill::TransformInput::INDEX), - NodeInput::value(TaggedValue::OptionalDAffine2(Some(transform)), false), + NodeInput::value(TaggedValue::DAffine2(transform), false), true, ); } @@ -733,8 +738,8 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.join_miter_limit), false), false); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::PaintOrderInput::INDEX); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::PaintOrder(stroke.paint_order), false), false); - let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashLengthsInput::INDEX); - self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64Array(stroke.dash_lengths), false), true); + let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashPatternInput::INDEX); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::DashPattern(stroke.dash_lengths.into()), false), true); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashOffsetInput::INDEX); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.dash_offset), false), true); } 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 de43b8567f..5e035f6e6d 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -31,8 +31,12 @@ use graphene_std::text::{Font, TextAlign}; use graphene_std::text_nodes::StringCapitalization; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::misc::BooleanOperation; -use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; -use graphene_std::vector::style::{FillChoiceUI, Gradient, GradientSpreadMethod, GradientType, GradientUI, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation}; +use graphene_std::vector::misc::{ + ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, +}; +use graphene_std::vector::style::{ + DashPattern, FillChoiceUI, Gradient, GradientSpreadMethod, GradientType, GradientUI, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation, +}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; pub(crate) fn string_properties(text: &str) -> Vec { @@ -861,6 +865,32 @@ pub fn array_of_number_widget(parameter_widgets_info: ParameterWidgetsInfo, text widgets } +pub fn dash_pattern_widget(parameter_widgets_info: ParameterWidgetsInfo, text_input: TextInput) -> Vec { + let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info; + + let mut widgets = start_widgets(parameter_widgets_info); + + let Some(document_node) = document_node else { return Vec::new() }; + let Some(input) = document_node.inputs.get(index) else { + log::warn!("A widget failed to be built because its node's input index is invalid."); + return vec![]; + }; + if let Some(TaggedValue::DashPattern(pattern)) = &input.as_non_exposed_value() { + widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + text_input + .value(pattern.0.iter_element_values().map(|length| length.to_string()).collect::>().join(", ")) + .on_update(optionally_update_value( + move |input: &TextInput| Some(TaggedValue::DashPattern(DashPattern::from(input.value.as_str()))), + node_id, + index, + )) + .widget_instance(), + ]) + } + widgets +} + pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec, Option>) { pub fn assign_font_message(node_id: NodeId, font: Font) -> Message { let resource_id = ResourceId::new(); @@ -2205,16 +2235,11 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties log::warn!("A widget failed to be built because its node's input index is invalid."); return vec![]; }; - let uniform_val = match input.as_non_exposed_value() { - Some(TaggedValue::F64(x)) => *x, - Some(TaggedValue::F64Array(values)) => values.first().copied().unwrap_or(0.), - _ => 0., - }; - let individual_val = match input.as_non_exposed_value() { - Some(&TaggedValue::F64(x)) => vec![x; 4], - Some(TaggedValue::F64Array(values)) => values.clone(), - _ => vec![0.; 4], + let corner_values = match input.as_non_exposed_value() { + Some(TaggedValue::BoxCorners(corners)) => corners.to_corner_values(), + _ => [0.; 4], }; + let uniform_val = corner_values[0]; // Uniform/individual radio input widget let uniform = RadioEntryData::new("Uniform") @@ -2230,13 +2255,12 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties NodeGraphMessage::SetInputValue { node_id, input_index: CornerRadiusInput::INDEX, - value: Box::new(TaggedValue::F64(uniform_val)), + value: Box::new(TaggedValue::BoxCorners(BoxCorners::from(uniform_val))), } .into(), ]), }) .on_commit(commit_value); - let individual_val_for_switch = individual_val.clone(); let individual = RadioEntryData::new("Individual") .label("Individual") .on_update(move |_| Message::Batched { @@ -2250,7 +2274,7 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties NodeGraphMessage::SetInputValue { node_id, input_index: CornerRadiusInput::INDEX, - value: Box::new(TaggedValue::F64Array(individual_val_for_switch.clone())), + value: Box::new(TaggedValue::BoxCorners(BoxCorners::from(corner_values.to_vec()))), } .into(), ]), @@ -2261,24 +2285,23 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties // Radius value input widget let input_widget = if is_individual { - let from_string = |string: &str| { - string - .split(&[',', ' ']) - .filter(|x| !x.is_empty()) - .map(str::parse::) - .collect::, _>>() - .ok() - .map(|values| TaggedValue::F64Array(values.into_iter().take(4).collect())) - }; TextInput::default() - .value(individual_val.iter().map(|v| v.to_string()).collect::>().join(", ")) - .on_update(optionally_update_value(move |x: &TextInput| from_string(&x.value), node_id, CornerRadiusInput::INDEX)) + .value(corner_values.iter().map(|v| v.to_string()).collect::>().join(", ")) + .on_update(optionally_update_value( + move |x: &TextInput| Some(TaggedValue::BoxCorners(BoxCorners::from(x.value.as_str()))), + node_id, + CornerRadiusInput::INDEX, + )) .widget_instance() } else { NumberInput::default() .value(Some(uniform_val)) .unit(" px") - .on_update(update_value(move |x: &NumberInput| TaggedValue::F64(x.value.unwrap()), node_id, CornerRadiusInput::INDEX)) + .on_update(update_value( + move |x: &NumberInput| TaggedValue::BoxCorners(BoxCorners::from(x.value.unwrap())), + node_id, + CornerRadiusInput::INDEX, + )) .on_commit(commit_value) .widget_instance() }; @@ -2675,7 +2698,22 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte } else { "Swap the start and end points of the gradient line." }) - .on_update(update_value(move |_| TaggedValue::OptionalDAffine2(Some(new_transform)), node_id, TransformInput::INDEX)) + .on_update(move |_| Message::Batched { + messages: Box::new([ + NodeGraphMessage::SetInputValue { + node_id, + input_index: HasTransformInput::INDEX, + value: Box::new(TaggedValue::Bool(true)), + } + .into(), + NodeGraphMessage::SetInputValue { + node_id, + input_index: TransformInput::INDEX, + value: Box::new(TaggedValue::DAffine2(new_transform)), + } + .into(), + ]), + }) .widget_instance(); spread_methods_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance()); spread_methods_row.push(reverse_direction_button); @@ -2719,8 +2757,8 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) - _ => &StrokeJoin::Miter, }; - let has_dash_lengths = match &document_node.inputs[DashLengthsInput::INDEX].as_value() { - Some(TaggedValue::F64Array(values)) => values.is_empty(), + let has_dash_lengths = match &document_node.inputs[DashPatternInput::INDEX].as_value() { + Some(TaggedValue::DashPattern(pattern)) => pattern.0.is_empty(), _ => true, }; let miter_limit_disabled = join_value != &StrokeJoin::Miter; @@ -2746,7 +2784,7 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) - .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 dash_lengths = dash_pattern_widget(ParameterWidgetsInfo::new(node_id, DashPatternInput::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); diff --git a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs index 3fc5d80b8d..5c7974a77b 100644 --- a/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs +++ b/editor/src/messages/portfolio/document/storage_tests/round_trip_tests.rs @@ -765,3 +765,46 @@ async fn none_fill_survives_document_reopen() { let reopened_paint = fill_paint_value(editor.active_document()); assert!(reopened_paint.is_no_paint(), "a none fill should survive reopening, but the stored paint became {reopened_paint:?}"); } + +#[tokio::test] +async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() { + use graph_craft::document::value::TaggedValue; + use graphene_std::NodeInputDecleration as _; + + // A minimal master-era document: a 4-input Fill (content, fill: wired, backup color, backup gradient) fed by another node + const LEGACY_DOCUMENT: &str = r#"{"network_interface":{"network":{"exports":[{"Node":{"node_id":1,"output_index":0,"lambda":false}}],"nodes":[[1,{"inputs":[{"Value":{"tagged_value":{"GraphicGroup":{"instance":[],"transform":[],"alpha_blending":[],"source_node_id":[]}},"exposed":true}},{"Node":{"node_id":2,"output_index":0,"lambda":false}},{"Value":{"tagged_value":{"OptionalColor":null},"exposed":false}},{"Value":{"tagged_value":{"Gradient":{"stops":[[0.0,{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0}],[1.0,{"red":1.0,"green":1.0,"blue":1.0,"alpha":1.0}]],"gradient_type":"Linear","start":[0.0,0.5],"end":[1.0,0.5],"transform":[1.0,0.0,0.0,1.0,0.0,0.0]}},"exposed":false}}],"manual_composition":{"Concrete":{"name":"core::option::Option>","alias":null}},"implementation":{"ProtoNode":{"name":"graphene_core::vector::FillNode"}},"visible":true,"skip_deduplication":false}],[2,{"inputs":[{"Value":{"tagged_value":"None","exposed":false}},{"Value":{"tagged_value":{"GradientStops":[[0.0,{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0}],[1.0,{"red":1.0,"green":1.0,"blue":1.0,"alpha":1.0}]]},"exposed":false}},{"Value":{"tagged_value":{"F64":0.5},"exposed":false}}],"manual_composition":{"Concrete":{"name":"core::option::Option>","alias":null}},"implementation":{"ProtoNode":{"name":"graphene_core::ops::SampleGradientNode"}},"visible":true,"skip_deduplication":false}]],"scope_injections":[]},"network_metadata":{"persistent_metadata":{"node_metadata":[[1,{"persistent_metadata":{"reference":"Fill","display_name":"","input_properties":[{"input_data":{"input_name":"Vector Data"},"widget_override":null},{"input_data":{"input_name":"Fill"},"widget_override":null},{"input_data":{"input_name":"Backup Color"},"widget_override":null},{"input_data":{"input_name":"Backup Gradient"},"widget_override":null}],"output_names":["Future>"],"has_primary_output":true,"locked":false,"pinned":false,"node_type_metadata":{"Node":{"position":{"Absolute":[0,0]}}},"network_metadata":null}}],[2,{"persistent_metadata":{"reference":"Sample Gradient","display_name":"","input_properties":[{"input_data":{"input_name":"Primary"},"widget_override":null},{"input_data":{"input_name":"Gradient"},"widget_override":null},{"input_data":{"input_name":"Position"},"widget_override":null}],"output_names":["Future"],"has_primary_output":true,"locked":false,"pinned":false,"node_type_metadata":{"Node":{"position":{"Absolute":[-20,0]}}},"network_metadata":null}}]],"previewing":"No","navigation_metadata":{"node_graph_ptz":{"pan":[0.0,0.0],"tilt":0.0,"zoom":1.0,"flip":false},"node_graph_to_viewport":[1.0,0.0,0.0,1.0,0.0,0.0],"node_graph_top_right":[0.0,0.0]},"selection_undo_history":[],"selection_redo_history":[]}}},"collapsed":[],"name":"legacy_fill.graphite","commit_hash":"0000000000000000000000000000000000000000","document_ptz":{"pan":[0.0,0.0],"tilt":0.0,"zoom":1.0,"flip":false},"document_mode":"DesignMode","view_mode":"Normal","overlays_visibility_settings":{"all":true,"artboard_name":true,"compass_rose":true,"quick_measurement":true,"transform_measurement":true,"transform_cage":true,"hover_outline":true,"selection_outline":true,"pivot":true,"path":true,"anchors":true,"handles":true},"rulers_visible":true,"snapping_state":{"snapping_enabled":true,"grid_snapping":false,"artboards":true,"tolerance":8.0,"bounding_box":{"center_point":true,"corner_point":true,"edge_midpoint":true,"align_with_edges":true,"distribute_evenly":true},"path":{"anchor_point":true,"line_midpoint":true,"along_path":true,"normal_to_path":true,"tangent_to_path":true,"path_intersection_point":true,"align_with_anchor_point":true,"perpendicular_from_endpoint":true},"grid":{"origin":[0.0,0.0],"grid_type":{"Rectangular":{"spacing":[1.0,1.0]}},"grid_color":{"red":0.6,"green":0.6,"blue":0.6,"alpha":1.0},"dot_display":false}},"graph_view_overlay_open":false,"graph_fade_artwork_percentage":80.0}"#; + + // Deserializing alone must succeed, so a failure below is attributable to the migrations + DocumentMessageHandler::deserialize_document(LEGACY_DOCUMENT).expect("the legacy document should deserialize"); + + let mut editor = EditorTestUtils::create(); + editor + .handle_message(PortfolioMessage::OpenDocumentFile { + document_name: None, + document_path: None, + document_serialized_content: LEGACY_DOCUMENT.to_string(), + }) + .await; + + let document = editor.active_document(); + let (network_path, node_id) = find_fill_node(document); + let network = document.network_interface.nested_network(&network_path).expect("the found network path should resolve"); + let inputs = &network.nodes[&node_id].inputs; + + assert_eq!(inputs.len(), 8, "the legacy Fill should upgrade to the 8-input shape"); + let paint = &inputs[graphene_std::vector::fill::FillInput::INDEX]; + assert!( + matches!(paint, graph_craft::document::NodeInput::Node { .. }), + "the wired legacy fill should keep its connection, but became {paint:?}" + ); + let has_transform = inputs[graphene_std::vector::fill::HasTransformInput::INDEX].as_value(); + assert!( + matches!(has_transform, Some(TaggedValue::Bool(_))), + "the has-transform input should hold a bool, but became {has_transform:?}" + ); + let transform = inputs[graphene_std::vector::fill::TransformInput::INDEX].as_value(); + assert!( + matches!(transform, Some(TaggedValue::DAffine2(_))), + "the transform input should hold a matrix, but became {transform:?}" + ); +} diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 83622851e7..aa44760650 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -17,7 +17,8 @@ use graphene_std::text::{TextAlign, TypesettingConfig}; use graphene_std::transform::ScaleType; use graphene_std::uuid::NodeId; use graphene_std::vector::graphic_types; -use graphene_std::vector::style::{PaintOrder, StrokeAlign}; +use graphene_std::vector::misc::BoxCorners; +use graphene_std::vector::style::{DashPattern, PaintOrder, StrokeAlign}; use std::collections::HashMap; use std::f64::consts::PI; use std::ops::Range; @@ -1376,6 +1377,29 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ } } +/// Converts a legacy stroke dash input (a `List`, single `f64`, or comma/space separated `String`) to the `DashPattern` value type. +fn migrate_dash_input(input: &NodeInput) -> Option { + let NodeInput::Value { tagged_value, exposed } = input else { return None }; + let pattern = match &*tagged_value.clone().into_inner() { + TaggedValue::F64Array(lengths) => DashPattern::from(lengths.clone()), + TaggedValue::F64(length) => DashPattern::from(*length), + TaggedValue::String(text) => DashPattern::from(text.as_str()), + _ => return None, + }; + Some(NodeInput::value(TaggedValue::DashPattern(pattern), *exposed)) +} + +/// Converts a legacy rectangle corner radius input (a single `f64` or a `List` of up to four values) to the `BoxCorners` value type. +fn migrate_corner_radius_input(input: &NodeInput) -> Option { + let NodeInput::Value { tagged_value, exposed } = input else { return None }; + let corners = match &*tagged_value.clone().into_inner() { + TaggedValue::F64Array(values) => BoxCorners::from(values.clone()), + TaggedValue::F64(value) => BoxCorners::from(*value), + _ => return None, + }; + Some(NodeInput::value(TaggedValue::BoxCorners(corners), *exposed)) +} + fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) -> Option<()> { // Must run before the reset block below: a node referencing a removed catalog entry would otherwise abort // `migrate_node` via the `?` on `resolve_document_node_type`, preventing subsequent migration blocks from running. @@ -1640,7 +1664,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .network_interface .set_input(&InputConnector::node(*node_id, 1), NodeInput::value(fill_value, exposed), network_path); - // Gradient metadata (4, 5, 6): applies only to a literal gradient, solids/none keep the template defaults + // Gradient metadata (4, 5, 6, 7): applies only to a literal gradient, solids/none keep the template defaults if let graphic_types::migrations::legacy::LegacyFill::Gradient(gradient) = old_fill { document.network_interface.set_input( &InputConnector::node(*node_id, 4), @@ -1653,16 +1677,19 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], network_path, ); - let transform = if gradient.absolute { - Some(gradient.transform * gradient.to_transform()) + if gradient.absolute { + let transform = gradient.transform * gradient.to_transform(); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path); } else { - // Baking a legacy bounding-box-relative gradient is deferred until the measurement pre-pass can supply the paint target's bounds + // Baking a legacy bounding-box-relative gradient is deferred until the measurement pre-pass can supply the paint + // target's bounds, so the template's unbaked `_has_transform = false` stands until the bake lands document.pending_gradient_bbox_bake.push((network_path.to_vec(), *node_id, gradient.clone())); - None - }; - document - .network_interface - .set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::OptionalDAffine2(transform), false), network_path); + } } } // Wired/exposed fill keeps the connection. @@ -1697,19 +1724,54 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], network_path, ); - let transform = if g.absolute { - Some(g.transform * g.to_transform()) + if g.absolute { + let transform = g.transform * g.to_transform(); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path); } else { document.pending_gradient_bbox_bake.push((network_path.to_vec(), *node_id, g.clone())); - None - }; - document - .network_interface - .set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::OptionalDAffine2(transform), false), network_path); + } } } - inputs_count = 7; + inputs_count = 8; + } + + // Fill split its `Option` placement into a `_has_transform` bool immediately before the `_transform` matrix + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER) && inputs_count == 7 { + 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 (index, input) in old_inputs.iter().enumerate().take(6) { + document.network_interface.set_input(&InputConnector::node(*node_id, index), input.clone(), network_path); + } + + match old_inputs.get(6).and_then(|input| input.as_value()) { + Some(TaggedValue::LegacyOptionalDAffine2(value)) => { + let has_transform = value.is_some(); + let transform = value.unwrap_or(glam::DAffine2::IDENTITY); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::Bool(has_transform), false), network_path); + document + .network_interface + .set_input(&InputConnector::node(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path); + } + // A wired (or otherwise non-value) transform keeps its connection and is treated as present + _ => { + document + .network_interface + .set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path); + let transform_input = old_inputs.get(6).cloned().unwrap_or_else(|| NodeInput::value(TaggedValue::DAffine2(glam::DAffine2::IDENTITY), false)); + document.network_interface.set_input(&InputConnector::node(*node_id, 7), transform_input, network_path); + } + } + + inputs_count = 8; } // Upgrade Stroke node to reorder parameters and add "Align" and "Paint Order" (#2644) @@ -1728,7 +1790,8 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node(*node_id, 5), old_inputs[6].clone(), network_path); document.network_interface.set_input(&InputConnector::node(*node_id, 6), old_inputs[7].clone(), network_path); document.network_interface.set_input(&InputConnector::node(*node_id, 7), paint_order_input, network_path); - document.network_interface.set_input(&InputConnector::node(*node_id, 8), old_inputs[3].clone(), network_path); + let dash_input = migrate_dash_input(&old_inputs[3]).unwrap_or_else(|| old_inputs[3].clone()); + document.network_interface.set_input(&InputConnector::node(*node_id, 8), dash_input, network_path); document.network_interface.set_input(&InputConnector::node(*node_id, 9), old_inputs[4].clone(), network_path); } @@ -1783,6 +1846,36 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], } } + // The stroke dash sequence became the `DashPattern` value type; convert any already-shaped stroke that still stores a legacy dash input + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER) + && let Some(dash_input) = node.inputs.get(graphene_std::vector::stroke::DashPatternInput::INDEX) + && let Some(migrated) = migrate_dash_input(dash_input) + { + document + .network_interface + .set_input(&InputConnector::node(*node_id, graphene_std::vector::stroke::DashPatternInput::INDEX), migrated, network_path); + } + + // The rectangle's corner radius became the `BoxCorners` value type and its hidden individual-radii toggle moved after the + // user-visible inputs. A legacy rectangle stores that toggle (a plain `bool`) at index 3, where the new shape stores the corner + // radius, so a `bool` value there identifies the old input order: [width, height, individual, corner_radius, clamped]. + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER) + && let Some(toggle_input) = node.inputs.get(3) + && matches!(toggle_input.as_value(), Some(TaggedValue::Bool(_))) + { + 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)?; + + let corner_radius = migrate_corner_radius_input(&old_inputs[4]).unwrap_or_else(|| old_inputs[4].clone()); + + document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + document.network_interface.set_input(&InputConnector::node(*node_id, 2), old_inputs[2].clone(), network_path); + document.network_interface.set_input(&InputConnector::node(*node_id, 3), corner_radius, network_path); + document.network_interface.set_input(&InputConnector::node(*node_id, 4), old_inputs[5].clone(), network_path); + document.network_interface.set_input(&InputConnector::node(*node_id, 5), old_inputs[3].clone(), network_path); + } + // Upgrade Text node to include line height and character spacing, which were previously hardcoded to 1, from https://github.com/GraphiteEditor/Graphite/pull/2016 if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && inputs_count == 8 { let mut template: NodeTemplate = legacy_text_node_template()?; diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index f8c22639f4..fd6a057894 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -569,8 +569,8 @@ pub fn get_stroke_options(layer: LayerNodeIdentifier, network_interface: &NodeNe Some(TaggedValue::PaintOrder(value)) => *value, _ => PaintOrder::default(), }; - let dash_lengths = match read(graphene_std::vector::stroke::DashLengthsInput::INDEX) { - Some(TaggedValue::F64Array(value)) => value.clone(), + let dash_lengths = match read(graphene_std::vector::stroke::DashPatternInput::INDEX) { + Some(TaggedValue::DashPattern(value)) => value.0.iter_element_values().copied().collect(), _ => Vec::new(), }; let dash_offset = match read(graphene_std::vector::stroke::DashOffsetInput::INDEX) { @@ -648,9 +648,11 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn Some(&TaggedValue::GradientSpreadMethod(value)) => value, _ => GradientSpreadMethod::default(), }; + let has_transform = matches!(fill_node.inputs.get(fill::HasTransformInput::INDEX).and_then(|input| input.as_value()), Some(&TaggedValue::Bool(true))); let transform_input = fill_node.inputs.get(fill::TransformInput::INDEX).and_then(|input| input.as_value()); - let transform = match transform_input { - Some(&TaggedValue::OptionalDAffine2(value)) => value.unwrap_or_else(|| initial_gradient_transform_for_bounding_box(bounding_box())), + let transform = match (has_transform, transform_input) { + (true, Some(&TaggedValue::DAffine2(value))) => value, + (false, _) => initial_gradient_transform_for_bounding_box(bounding_box()), _ => DAffine2::IDENTITY, }; @@ -803,10 +805,10 @@ pub fn set_fill_for_selected_layers(fill_choice: FillChoice, document: &Document Some(TaggedValue::GradientSpreadMethod(value)) => *value, _ => GradientSpreadMethod::default(), }; - let transform = match read(graphene_std::vector::fill::TransformInput::INDEX) { - Some(TaggedValue::OptionalDAffine2(value)) => { - value.unwrap_or_else(|| initial_gradient_transform_for_bounding_box(document.network_interface.document_metadata().nonzero_bounding_box(layer))) - } + let has_transform = matches!(read(graphene_std::vector::fill::HasTransformInput::INDEX), Some(TaggedValue::Bool(true))); + let transform = match (has_transform, read(graphene_std::vector::fill::TransformInput::INDEX)) { + (true, Some(TaggedValue::DAffine2(value))) => *value, + (false, _) => initial_gradient_transform_for_bounding_box(document.network_interface.document_metadata().nonzero_bounding_box(layer)), _ => DAffine2::IDENTITY, }; diff --git a/editor/src/messages/tool/common_functionality/stroke_options.rs b/editor/src/messages/tool/common_functionality/stroke_options.rs index a6b9e7744c..cc7572bbad 100644 --- a/editor/src/messages/tool/common_functionality/stroke_options.rs +++ b/editor/src/messages/tool/common_functionality/stroke_options.rs @@ -214,7 +214,7 @@ pub fn apply_paint_order(drawing: &mut DrawingToolState, order: PaintOrder, docu pub fn apply_dash_lengths(drawing: &mut DrawingToolState, lengths: Vec, document: &DocumentMessageHandler, responses: &mut VecDeque) { drawing.dash_lengths = Some(lengths.clone()); - set_stroke_input_for_selected(document, graphene_std::vector::stroke::DashLengthsInput::INDEX, TaggedValue::F64Array(lengths), responses); + set_stroke_input_for_selected(document, graphene_std::vector::stroke::DashPatternInput::INDEX, TaggedValue::DashPattern(lengths.into()), responses); } pub fn apply_dash_offset(drawing: &mut DrawingToolState, offset: f64, document: &DocumentMessageHandler, responses: &mut VecDeque) { diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs index c76179e1eb..73e2c4cad8 100644 --- a/editor/src/messages/tool/tool_messages/gradient_tool.rs +++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs @@ -2067,8 +2067,9 @@ mod test_gradient { _ => GradientSpreadMethod::default(), }; + let has_transform = matches!(fill_node.inputs.get(fill::HasTransformInput::INDEX).and_then(|input| input.as_value()), Some(&TaggedValue::Bool(true))); let local_transform = match fill_node.inputs.get(fill::TransformInput::INDEX).and_then(|input| input.as_value()) { - Some(&TaggedValue::OptionalDAffine2(Some(value))) => value, + Some(&TaggedValue::DAffine2(value)) if has_transform => value, _ => DAffine2::IDENTITY, }; diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index f7662e3836..1ff3fbd028 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -625,10 +625,14 @@ impl NodeGraphExecutor { if fill_transform_unbaked(document, &network_path, fill_node_id) { let absolute_gradient = gradient.to_absolute(bounding_box, item_transform); let gradient_transform = absolute_gradient.transform * absolute_gradient.to_transform(); - let input = InputConnector::node(fill_node_id, graphene_std::vector::fill::TransformInput::INDEX); + let has_transform_input = InputConnector::node(fill_node_id, graphene_std::vector::fill::HasTransformInput::INDEX); + let transform_input = InputConnector::node(fill_node_id, graphene_std::vector::fill::TransformInput::INDEX); document .network_interface - .set_input(&input, NodeInput::value(TaggedValue::OptionalDAffine2(Some(gradient_transform)), false), &network_path); + .set_input(&has_transform_input, NodeInput::value(TaggedValue::Bool(true), false), &network_path); + document + .network_interface + .set_input(&transform_input, NodeInput::value(TaggedValue::DAffine2(gradient_transform), false), &network_path); } // The transform is settled, so its entry no longer needs to persist for a retry on the next open @@ -840,8 +844,8 @@ fn fill_transform_unbaked(document: &DocumentMessageHandler, network_path: &[Nod }; let Some(node) = network.nodes.get(&fill_node_id) else { return false }; matches!( - node.inputs.get(graphene_std::vector::fill::TransformInput::INDEX).and_then(|input| input.as_value()), - Some(TaggedValue::OptionalDAffine2(None)) + node.inputs.get(graphene_std::vector::fill::HasTransformInput::INDEX).and_then(|input| input.as_value()), + Some(TaggedValue::Bool(false)) ) } diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 35dba2b229..df6223fe56 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -16,7 +16,10 @@ use dyn_any::DynAny; pub use dyn_any::StaticType; pub use glam::{DAffine2, DVec2, IVec2, UVec2}; use graphene_application_io::resource::ResourceHash; +use graphene_application_io::resource::ResourceId; use graphic_types::raster_types::{CPU, Image, Raster}; +use graphic_types::vector_types::vector::misc::BoxCorners; +use graphic_types::vector_types::vector::style::DashPattern; use graphic_types::vector_types::vector::style::Gradient; use graphic_types::vector_types::vector::{self, ReferencePoint}; use graphic_types::{Artboard, Graphic, Vector}; @@ -533,19 +536,21 @@ tagged_value! { DVec2(DVec2), #[serde(alias = "Affine2")] DAffine2(DAffine2), - OptionalDAffine2(Option), - #[serde(alias = "FillGradient")] - LegacyGradient(graphic_types::migrations::legacy::LegacyGradient), Font(Font), Footprint(Footprint), VectorModification(Box), ImageData(Image), - Resource(graphene_application_io::resource::ResourceId), + Resource(ResourceId), + // Legacy + #[serde(alias = "OptionalDAffine2")] + LegacyOptionalDAffine2(Option), + #[serde(alias = "FillGradient")] + LegacyGradient(graphic_types::migrations::legacy::LegacyGradient), + #[serde(alias = "Fill")] + LegacyFill(graphic_types::migrations::legacy::LegacyFill), // ========== // ENUM TYPES // ========== - #[serde(alias = "Fill")] - LegacyFill(graphic_types::migrations::legacy::LegacyFill), BlendMode(core_types::blending::BlendMode), LuminanceCalculation(raster_nodes::adjustments::LuminanceCalculation), QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel), @@ -575,6 +580,8 @@ tagged_value! { StrokeJoin(vector::style::StrokeJoin), StrokeAlign(vector::style::StrokeAlign), PaintOrder(vector::style::PaintOrder), + DashPattern(vector::style::DashPattern), + BoxCorners(vector::misc::BoxCorners), GradientType(vector::style::GradientType), GradientSpreadMethod(vector::style::GradientSpreadMethod), ReferencePoint(vector::ReferencePoint), @@ -723,6 +730,8 @@ impl TaggedValue { () if ty == TypeId::of::>() => to_color(string).map(TaggedValue::Color)?, () if ty == TypeId::of::>() => to_gradient(string).map(TaggedValue::Gradient)?, () if ty == TypeId::of::() => to_reference_point(string).map(TaggedValue::ReferencePoint)?, + () if ty == TypeId::of::() => TaggedValue::DashPattern(DashPattern::from(string)), + () if ty == TypeId::of::() => TaggedValue::BoxCorners(BoxCorners::from(string)), _ => return None, }; Some(ty) diff --git a/node-graph/libraries/vector-types/src/vector/misc.rs b/node-graph/libraries/vector-types/src/vector/misc.rs index a7564fd855..ea0ba630bf 100644 --- a/node-graph/libraries/vector-types/src/vector/misc.rs +++ b/node-graph/libraries/vector-types/src/vector/misc.rs @@ -2,6 +2,7 @@ use super::PointId; use super::algorithms::offset_subpath::MAX_ABSOLUTE_DIFFERENCE; use crate::subpath::{BezierHandles, ManipulatorGroup}; use crate::vector::{SegmentId, Vector}; +use core_types::list::{Item, List}; use dyn_any::DynAny; use glam::DVec2; use kurbo::{BezPath, CubicBez, Line, ParamCurve, ParamCurveDeriv, PathSeg, Point, QuadBez}; @@ -49,6 +50,77 @@ pub enum RowsOrColumns { Columns, } +/// A box's four corner values, such as a rectangle's corner radii, expanded on read from any number of stored +/// values by the CSS `border-radius` shorthand rules. +/// +/// Wraps a `List` so the Data panel can introspect its values, mirroring how `DashPattern` wraps its lengths, +/// while remaining a single rank-0 value on the wire. +#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +pub struct BoxCorners(pub List); + +impl BoxCorners { + /// Expands the stored values to the four corners, clockwise from the top-left, by the CSS `border-radius` shorthand rules. + /// - `[]` → `[0, 0, 0, 0]` + /// - `[a]` → `[a, a, a, a]` + /// - `[a, b]` → `[a, b, a, b]` + /// - `[a, b, c]` → `[a, b, c, b]` + /// - `[a, b, c, d, …]` → `[a, b, c, d]` + pub fn to_corner_values(&self) -> [f64; 4] { + let values: Vec = self.0.iter_element_values().copied().collect(); + match values.as_slice() { + [] => [0., 0., 0., 0.], + &[a] => [a, a, a, a], + &[a, b] => [a, b, a, b], + &[a, b, c] => [a, b, c, b], + &[a, b, c, d, ..] => [a, b, c, d], + } + } +} + +// `List` is a runtime-only wire type, so serialize the corners as their bare values to keep documents stable +#[cfg(feature = "serde")] +impl serde::Serialize for BoxCorners { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(self.0.iter_element_values()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for BoxCorners { + fn deserialize>(deserializer: D) -> Result { + Ok(Self::from( as serde::Deserialize>::deserialize(deserializer)?)) + } +} + +impl From for BoxCorners { + fn from(value: f64) -> Self { + Self(List::new_from_element(value)) + } +} + +impl From> for BoxCorners { + fn from(values: Vec) -> Self { + Self(values.into_iter().map(Item::new_from_element).collect()) + } +} + +impl From<&str> for BoxCorners { + fn from(text: &str) -> Self { + Self::from( + text.split([',', ' ']) + .filter(|piece| !piece.is_empty()) + .filter_map(|piece| piece.parse::().ok()) + .collect::>(), + ) + } +} + +impl From for BoxCorners { + fn from(text: String) -> Self { + Self::from(text.as_str()) + } +} + pub trait AsU64 { fn as_u64(&self) -> u64; } diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index c3cf6d6caf..34bdcef8c3 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -3,12 +3,14 @@ pub use crate::gradient::*; use core_types::Color; use core_types::color::SRGBA8; +use core_types::list::{Item, List}; use core_types::transform::Transform; use dyn_any::DynAny; use glam::DAffine2; use std::f64::consts::{PI, TAU}; -/// Describes an editable fill choice, storing color or gradient stops without gradient placement metadata. +/// The editor's in-memory paint picker state, storing color or gradient stops without gradient placement metadata. +/// Not stored in documents: paint inputs hold the picked value as a plain color, gradient, or no-paint type default. /// /// Can be None, a solid [Color], or a linear/radial [Gradient]. /// @@ -201,6 +203,65 @@ fn daffine2_identity() -> DAffine2 { DAffine2::IDENTITY } +/// A stroke's dash pattern: a sequence of lengths that alternate dash, gap, dash, gap, and so on. An odd-length +/// sequence repeats with the dash and gap roles swapped. +/// +/// Wraps a `List` so the Data panel can introspect its lengths, mirroring how `Artboard` wraps a `List`, +/// while remaining a single rank-0 value on the wire. +#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] +pub struct DashPattern(pub List); + +impl DashPattern { + /// Returns the dash lengths with any negative values clamped to zero. + pub fn clamped_lengths(&self) -> Vec { + self.0.iter_element_values().map(|length| length.max(0.)).collect() + } +} + +// `List` is a runtime-only wire type, so serialize the pattern as its bare lengths to keep documents stable +#[cfg(feature = "serde")] +impl serde::Serialize for DashPattern { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_seq(self.0.iter_element_values()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for DashPattern { + fn deserialize>(deserializer: D) -> Result { + Ok(Self::from( as serde::Deserialize>::deserialize(deserializer)?)) + } +} + +impl From for DashPattern { + fn from(length: f64) -> Self { + Self(List::new_from_element(length)) + } +} + +impl From> for DashPattern { + fn from(lengths: Vec) -> Self { + Self(lengths.into_iter().map(Item::new_from_element).collect()) + } +} + +impl From<&str> for DashPattern { + fn from(text: &str) -> Self { + Self::from( + text.split([',', ' ']) + .filter(|piece| !piece.is_empty()) + .filter_map(|piece| piece.parse::().ok()) + .collect::>(), + ) + } +} + +impl From for DashPattern { + fn from(text: String) -> Self { + Self::from(text.as_str()) + } +} + #[repr(C)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)] diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index 6de2a06b20..46663a9aee 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -4,44 +4,10 @@ use dyn_any::DynAny; use glam::DVec2; use graphic_types::Vector; use vector_types::subpath; -use vector_types::vector::misc::{ArcType, AsU64, GridType}; +use vector_types::vector::misc::{ArcType, AsU64, BoxCorners, GridType}; use vector_types::vector::misc::{HandleId, SpiralType}; use vector_types::vector::{PointId, SegmentId, StrokeId}; -/// Expands the corner-radius lanes to four corners using the CSS -/// `border-radius` shorthand rules, then builds the rounded rectangle. -/// - `[a]` (also a plain scalar radius) expands to `[a, a, a, a]` -/// - `[a, b]` expands to `[a, b, a, b]` -/// - `[a, b, c]` expands to `[a, b, c, b]` -/// - `[a, b, c, d, …]` truncates to `[a, b, c, d]` -/// - `[]` expands to `[0, 0, 0, 0]` -fn rounded_rectangle(values: &[f64], size: DVec2, clamped: bool) -> Vector { - let radii: [f64; 4] = match values { - [] => [0., 0., 0., 0.], - &[a] => [a, a, a, a], - &[a, b] => [a, b, a, b], - &[a, b, c] => [a, b, c, b], - &[a, b, c, d, ..] => [a, b, c, d], - }; - - let clamped_radius = if clamped { - // Algorithm follows the CSS spec: - - let mut scale_factor: f64 = 1.; - for i in 0..4 { - let side_length = if i % 2 == 0 { size.x } else { size.y }; - let adjacent_corner_radius_sum = radii[i] + radii[(i + 1) % 4]; - if side_length < adjacent_corner_radius_sum { - scale_factor = scale_factor.min(side_length / adjacent_corner_radius_sum); - } - } - radii.map(|x| x * scale_factor) - } else { - radii - }; - Vector::from_subpath(subpath::Subpath::new_rounded_rectangle(size / -2., size / 2., clamped_radius)) -} - /// Generates a circle shape with a chosen radius. #[node_macro::node(category("Vector: Shape"))] fn circle( @@ -143,12 +109,43 @@ fn rectangle( #[unit(" px")] #[default(100)] height: f64, - _individual_corner_radii: bool, // TODO: Move this to the bottom once we have a migration capability - corner_radius: IList, + corner_radius: BoxCorners, #[default(true)] clamped: bool, + _individual_corner_radii: bool, ) -> Vector { - let values: Vec = (0..corner_radius.len()).map(|index| corner_radius.get(index)).collect(); - rounded_rectangle(&values, DVec2::new(width, height), clamped) + let size = DVec2::new(width, height); + let radii = corner_radius.to_corner_values(); + + // Scale down overlapping adjacent radii to fit, following the CSS spec: + let radii = if clamped { + let radii = radii.map(|radius| radius.max(0.)); + + let mut scale_factor: f64 = 1.; + for i in 0..4 { + let side_length = if i % 2 == 0 { size.x } else { size.y }; + let adjacent_corner_radius_sum = radii[i] + radii[(i + 1) % 4]; + if side_length < adjacent_corner_radius_sum { + scale_factor = scale_factor.min((side_length / adjacent_corner_radius_sum).max(0.)); + } + } + + radii.map(|radius| radius * scale_factor) + } else { + radii + }; + + Vector::from_subpath(subpath::Subpath::new_rounded_rectangle(size / -2., size / 2., radii)) +} + +/// Builds a set of four corner values, such as a rectangle's corner radii, from a list of one, two, three, or four values. +#[node_macro::node(category("Vector: Shape"))] +fn box_corners( + _: impl Ctx, + /// The corner values, filling the four corners clockwise from the top-left. Give one value for all corners, two for opposite pairs, three for top-left, the two sides, then bottom-right, or four for each corner. + values: IList, +) -> BoxCorners { + let values: Vec = (0..values.len()).map(|index| values.get(index)).collect(); + BoxCorners::from(values) } /// Generates an regular polygon shape like a triangle, square, pentagon, hexagon, heptagon, octagon, or any higher n-gon. diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index fea8d6796a..99505b89b6 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -37,7 +37,7 @@ use vector_types::vector::misc::{ CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups, bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, }; -use vector_types::vector::style::{Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; use vector_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD}; use vector_types::{GradientSpreadMethod, GradientType}; @@ -325,10 +325,11 @@ fn fill<'e>( _backup_gradient: IList, _gradient_type: GradientType, _spread_method: GradientSpreadMethod, - _transform: Option, + _has_transform: bool, + _transform: DAffine2, ) -> Result<(Vector, Attr<'e, Fill>), Interrupt> { let mut paint = paint_table(fill); - default_gradient_paint(&mut paint, element.bounding_box(), _gradient_type, _spread_method, _transform); + default_gradient_paint(&mut paint, element.bounding_box(), _gradient_type, _spread_method, _has_transform.then_some(_transform)); let parked = park_paint(ctx.arena(), paint)?; Ok((element, Attr(Some(parked)))) } @@ -345,14 +346,15 @@ fn fill_graphic_leveled<'e>( _backup_gradient: IList, _gradient_type: GradientType, _spread_method: GradientSpreadMethod, - _transform: Option, + _has_transform: bool, + _transform: DAffine2, ) -> Result<(Graphic<'static>, Attr<'e, Fill>), Interrupt> { let bounds = match BoundingBox::bounding_box(&element, DAffine2::IDENTITY, false) { RenderBoundingBox::Rectangle(bounds) => Some(bounds), _ => None, }; let mut paint = paint_table(fill); - default_gradient_paint(&mut paint, bounds, _gradient_type, _spread_method, _transform); + default_gradient_paint(&mut paint, bounds, _gradient_type, _spread_method, _has_transform.then_some(_transform)); let parked = park_paint(ctx.arena(), paint)?; Ok((element, Attr(Some(parked)))) } @@ -381,13 +383,13 @@ fn stroke<'e>( miter_limit: f64, /// 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. - dash_lengths: IList, + /// The stroke dash pattern. 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. + dash_pattern: DashPattern, /// The phase offset distance from the starting point of the dash pattern. #[unit(" px")] dash_offset: f64, ) -> Result<(Vector, Attr, Attr<'e, StrokeAttr>), Interrupt> { - let dash_lengths = (0..dash_lengths.len()).map(|index| dash_lengths.get(index).max(0.)).collect(); + let dash_lengths = dash_pattern.clamped_lengths(); let mut stroke = Stroke { weight, dash_lengths, @@ -442,10 +444,10 @@ fn stroke_graphic_leveled<'e>( join: StrokeJoin, #[default(4.)] miter_limit: f64, paint_order: PaintOrder, - dash_lengths: IList, + dash_pattern: DashPattern, #[unit(" px")] dash_offset: f64, ) -> Result<(Graphic<'static>, Attr, Attr<'e, StrokeAttr>), Interrupt> { - let dash_lengths = (0..dash_lengths.len()).map(|index| dash_lengths.get(index).max(0.)).collect(); + let dash_lengths = dash_pattern.clamped_lengths(); let stroke = Stroke { weight, dash_lengths,