From a346f0c87ea603fd65980d00814a7c01e790b789 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 22 Apr 2026 11:26:06 -0700 Subject: [PATCH 1/4] Make paste/import SVG store as the Path node's VectorModification not Table --- .../portfolio/document/graph_operation/utility_types.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 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 e64f8ed625..388903c125 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -15,7 +15,7 @@ use graphene_std::table::Table; use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::Vector; use graphene_std::vector::style::{Fill, Stroke}; -use graphene_std::vector::{PointId, VectorModificationType}; +use graphene_std::vector::{PointId, VectorModification, VectorModificationType}; use graphene_std::{Artboard, Color, Graphic, NodeInputDecleration}; #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] @@ -211,11 +211,13 @@ impl<'a> ModifyInputsContext<'a> { } pub fn insert_vector(&mut self, subpaths: Vec>, layer: LayerNodeIdentifier, include_transform: bool, include_fill: bool, include_stroke: bool) { - let vector = Table::new_from_element(Vector::from_subpaths(subpaths, true)); + // Build a VectorModification that reproduces the geometry (same format the Pen tool uses) + let vector = Vector::from_subpaths(subpaths, true); + let modification = Box::new(VectorModification::create_from_vector(&vector)); let shape = resolve_network_node_type("Path") .expect("Path node does not exist") - .node_template_input_override([Some(NodeInput::value(TaggedValue::Vector(vector), false))]); + .node_template_input_override([None, Some(NodeInput::value(TaggedValue::VectorModification(modification), false))]); let shape_id = NodeId::new(); self.network_interface.insert_node(shape_id, shape, &[]); self.network_interface.move_node_to_chain_start(&shape_id, layer, &[], self.import); From 6cfc92eb5551b1c1738c24dafc06b97ab8f628f3 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 22 Apr 2026 11:26:31 -0700 Subject: [PATCH 2/4] Add a migration from Table to VectorModification for existing documents --- .../messages/portfolio/document_migration.rs | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 4d2e81a9c8..1aa3e543cd 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -10,7 +10,6 @@ use graph_craft::document::DocumentNode; use graph_craft::document::{DocumentNodeImplementation, NodeInput, value::TaggedValue}; use graphene_std::ProtoNodeIdentifier; use graphene_std::subpath::Subpath; -use graphene_std::table::Table; use graphene_std::text::{TextAlign, TypesettingConfig}; use graphene_std::transform::ScaleType; use graphene_std::uuid::NodeId; @@ -1140,10 +1139,8 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], log::error!("Path node does not exist."); return None; }; - let path_node = path_node_type.node_template_input_override([ - Some(NodeInput::value(TaggedValue::Vector(Table::new_from_element(vector)), true)), - Some(NodeInput::value(TaggedValue::VectorModification(Default::default()), false)), - ]); + let modification = Box::new(graphene_std::vector::VectorModification::create_from_vector(&vector)); + let path_node = path_node_type.node_template_input_override([None, Some(NodeInput::value(TaggedValue::VectorModification(modification), false))]); // Get the "Spline" node definition and wire it up with the "Path" node as input let Some(spline_node_type) = resolve_proto_node_type(graphene_std::vector::spline::IDENTIFIER) else { @@ -1952,6 +1949,29 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .set_input(&InputConnector::node(*node_id, 3), NodeInput::value(TaggedValue::Bool(false), false), network_path); } + // Migrate Path nodes that stored geometry directly in input 0 (as a Table) to instead use a VectorModification in input 1 + if reference == DefinitionIdentifier::Network("Path".into()) { + let input_0 = node.inputs.first()?; + if let NodeInput::Value { tagged_value, exposed } = input_0 + && !exposed + && let TaggedValue::Vector(vector_table) = &**tagged_value + && !vector_table.is_empty() + { + let vector = vector_table.iter().next()?.element; + let modification = Box::new(graphene_std::vector::VectorModification::create_from_vector(vector)); + + // Reset input 0 to the default exposed state + document + .network_interface + .set_input(&InputConnector::node(*node_id, 0), NodeInput::value(TaggedValue::Vector(Default::default()), true), network_path); + + // Store the converted VectorModification in input 1 + document + .network_interface + .set_input(&InputConnector::node(*node_id, 1), NodeInput::value(TaggedValue::VectorModification(modification), false), network_path); + } + } + // ================================== // PUT ALL MIGRATIONS ABOVE THIS LINE // ================================== From 45a21bf73831938b823d47a3dd6b3840388de9c4 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 22 Apr 2026 11:26:44 -0700 Subject: [PATCH 3/4] Add a VectorModification widget to visualize change counts --- .../document/node_graph/node_properties.rs | 24 ++- .../src/vector/vector_modification.rs | 139 ++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) 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 3a2c6a778b..ad09378939 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -24,10 +24,10 @@ use graphene_std::raster::{ use graphene_std::table::{Table, TableRow}; use graphene_std::text::{Font, TextAlign}; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; -use graphene_std::vector::QRCodeErrorCorrectionLevel; 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::{Fill, FillChoice, FillType, GradientSpreadMethod, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; pub(crate) fn string_properties(text: &str) -> Vec { let widget = TextLabel::new(text).widget_instance(); @@ -230,6 +230,7 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => font_widget(default_info), Some(x) if x == TypeId::of::() => curve_widget(default_info), Some(x) if x == TypeId::of::() => footprint_widget(default_info, &mut extra_widgets), + Some(x) if x == TypeId::of::>() => vector_modification_widget(default_info).into(), // =============================== // MANUALLY IMPLEMENTED ENUM TYPES // =============================== @@ -398,6 +399,27 @@ pub fn reference_point_widget(parameter_widgets_info: ParameterWidgetsInfo, disa widgets } +pub fn vector_modification_widget(parameter_widgets_info: ParameterWidgetsInfo) -> 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 widgets }; + let Some(input) = document_node.inputs.get(index) else { return widgets }; + + if let Some(TaggedValue::VectorModification(modification)) = input.as_non_exposed_value() { + let label = modification.summary_label(); + let tooltip = modification.summary_tooltip(); + + widgets.extend_from_slice(&[ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + TextLabel::new(label).tooltip_label("Summary of Differential Edits").tooltip_description(tooltip).widget_instance(), + ]); + } + + widgets +} + pub fn footprint_widget(parameter_widgets_info: ParameterWidgetsInfo, extra_widgets: &mut Vec) -> LayoutGroup { let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info; diff --git a/node-graph/libraries/vector-types/src/vector/vector_modification.rs b/node-graph/libraries/vector-types/src/vector/vector_modification.rs index 8af9cd8b9e..06d6548eae 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_modification.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_modification.rs @@ -325,6 +325,145 @@ pub enum VectorModificationType { } impl VectorModification { + /// Returns `(additions, removals, modifications)` counts summarizing all changes. + pub fn summary_counts(&self) -> (usize, usize, usize) { + let additions = self.points.add.len() + self.segments.add.len() + self.regions.add.len() + self.add_g1_continuous.len(); + let removals = self.points.remove.len() + self.segments.remove.len() + self.regions.remove.len() + self.remove_g1_continuous.len(); + + // Modifications are delta/change entries for items that aren't being added (since add entries use those maps for initial values) + let add_points: HashSet<_> = self.points.add.iter().copied().collect(); + let add_segments: HashSet<_> = self.segments.add.iter().copied().collect(); + let add_regions: HashSet<_> = self.regions.add.iter().copied().collect(); + + let point_modifications = self.points.delta.keys().filter(|id| !add_points.contains(id)).count(); + let segment_modifications = self + .segments + .start_point + .keys() + .chain(self.segments.end_point.keys()) + .chain(self.segments.handle_primary.keys()) + .chain(self.segments.handle_end.keys()) + .chain(self.segments.stroke.keys()) + .filter(|id| !add_segments.contains(id)) + .collect::>() + .len(); + let region_modifications = self + .regions + .segment_range + .keys() + .chain(self.regions.fill.keys()) + .filter(|id| !add_regions.contains(id)) + .collect::>() + .len(); + + (additions, removals, point_modifications + segment_modifications + region_modifications) + } + + /// Returns a short human-readable summary string like "+12 / \u{2212}3 / \u{0394}2". + pub fn summary_label(&self) -> String { + let (additions, removals, modifications) = self.summary_counts(); + let mut parts = Vec::new(); + if additions > 0 { + parts.push(format!("+{additions}")); + } + if removals > 0 { + parts.push(format!("\u{2212}{removals}")); + } + if modifications > 0 { + parts.push(format!("\u{0394}{modifications}")); + } + if parts.is_empty() { "No Differential Edits".to_string() } else { parts.join(" / ") } + } + + /// Returns a detailed multi-line tooltip describing all the changes. + pub fn summary_tooltip(&self) -> String { + let add_points: HashSet<_> = self.points.add.iter().copied().collect(); + let add_segments: HashSet<_> = self.segments.add.iter().copied().collect(); + let add_regions: HashSet<_> = self.regions.add.iter().copied().collect(); + + let point_deltas = self.points.delta.keys().filter(|id| !add_points.contains(id)).count(); + let segment_changes = self + .segments + .start_point + .keys() + .chain(self.segments.end_point.keys()) + .chain(self.segments.handle_primary.keys()) + .chain(self.segments.handle_end.keys()) + .chain(self.segments.stroke.keys()) + .filter(|id| !add_segments.contains(id)) + .collect::>() + .len(); + let region_changes = self + .regions + .segment_range + .keys() + .chain(self.regions.fill.keys()) + .filter(|id| !add_regions.contains(id)) + .collect::>() + .len(); + + let mut lines = Vec::new(); + + // Points + let mut point_parts = Vec::new(); + if !self.points.add.is_empty() { + point_parts.push(format!("+{}", self.points.add.len())); + } + if !self.points.remove.is_empty() { + point_parts.push(format!("\u{2212}{}", self.points.remove.len())); + } + if point_deltas > 0 { + point_parts.push(format!("\u{0394}{point_deltas}")); + } + if !point_parts.is_empty() { + lines.push(format!("Points: {}", point_parts.join(" / "))); + } + + // Segments + let mut segment_parts = Vec::new(); + if !self.segments.add.is_empty() { + segment_parts.push(format!("+{}", self.segments.add.len())); + } + if !self.segments.remove.is_empty() { + segment_parts.push(format!("\u{2212}{}", self.segments.remove.len())); + } + if segment_changes > 0 { + segment_parts.push(format!("\u{0394}{segment_changes}")); + } + if !segment_parts.is_empty() { + lines.push(format!("Segments: {}", segment_parts.join(" / "))); + } + + // Regions + let mut region_parts = Vec::new(); + if !self.regions.add.is_empty() { + region_parts.push(format!("+{}", self.regions.add.len())); + } + if !self.regions.remove.is_empty() { + region_parts.push(format!("\u{2212}{}", self.regions.remove.len())); + } + if region_changes > 0 { + region_parts.push(format!("\u{0394}{region_changes}")); + } + if !region_parts.is_empty() { + lines.push(format!("Regions: {}", region_parts.join(" / "))); + } + + // G1 continuous + let mut g1_parts = Vec::new(); + if !self.add_g1_continuous.is_empty() { + g1_parts.push(format!("+{}", self.add_g1_continuous.len())); + } + if !self.remove_g1_continuous.is_empty() { + g1_parts.push(format!("\u{2212}{}", self.remove_g1_continuous.len())); + } + if !g1_parts.is_empty() { + lines.push(format!("Smooth Handles: {}", g1_parts.join(" / "))); + } + + if lines.is_empty() { "None".to_string() } else { lines.join("\n") } + } + /// Apply this modification to the specified [`Vector`]. pub fn apply(&self, vector: &mut Vector) { self.points.apply(&mut vector.point_domain, &mut vector.segment_domain); From 5ee55fd3f52046d0fff61169b8f499643c3d4b0e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 22 Apr 2026 11:56:06 -0700 Subject: [PATCH 4/4] Refactor VectorModification to compute per-category counts for additions, removals, and modifications --- .../src/vector/vector_modification.rs | 182 +++++++----------- 1 file changed, 72 insertions(+), 110 deletions(-) diff --git a/node-graph/libraries/vector-types/src/vector/vector_modification.rs b/node-graph/libraries/vector-types/src/vector/vector_modification.rs index 06d6548eae..f9d094223f 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_modification.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_modification.rs @@ -324,44 +324,70 @@ pub enum VectorModificationType { ApplyEndDelta { segment: SegmentId, delta: DVec2 }, } -impl VectorModification { - /// Returns `(additions, removals, modifications)` counts summarizing all changes. - pub fn summary_counts(&self) -> (usize, usize, usize) { - let additions = self.points.add.len() + self.segments.add.len() + self.regions.add.len() + self.add_g1_continuous.len(); - let removals = self.points.remove.len() + self.segments.remove.len() + self.regions.remove.len() + self.remove_g1_continuous.len(); +/// Per-category `[added, removed, modified]` counts for a [`VectorModification`]. +struct ModificationCategoryCounts { + points: [usize; 3], + segments: [usize; 3], + regions: [usize; 3], + smooth_handles: [usize; 3], +} + +impl ModificationCategoryCounts { + /// Returns the `[added, removed, modified]` totals across all categories. + fn totals(&self) -> [usize; 3] { + let mut totals = [0; 3]; + for [a, r, m] in [self.points, self.segments, self.regions, self.smooth_handles] { + totals[0] += a; + totals[1] += r; + totals[2] += m; + } + totals + } - // Modifications are delta/change entries for items that aren't being added (since add entries use those maps for initial values) + /// Iterates over each named category and its `[added, removed, modified]` counts. + fn iter_categories(&self) -> impl Iterator { + [("Points", self.points), ("Segments", self.segments), ("Regions", self.regions), ("Smooth Handles", self.smooth_handles)].into_iter() + } +} + +impl VectorModification { + /// Computes per-category counts of additions, removals, and modifications. + fn category_counts(&self) -> ModificationCategoryCounts { + // Build sets of added IDs so we can distinguish true modifications from initial values stored for newly added items let add_points: HashSet<_> = self.points.add.iter().copied().collect(); let add_segments: HashSet<_> = self.segments.add.iter().copied().collect(); let add_regions: HashSet<_> = self.regions.add.iter().copied().collect(); let point_modifications = self.points.delta.keys().filter(|id| !add_points.contains(id)).count(); - let segment_modifications = self - .segments - .start_point - .keys() - .chain(self.segments.end_point.keys()) - .chain(self.segments.handle_primary.keys()) - .chain(self.segments.handle_end.keys()) - .chain(self.segments.stroke.keys()) - .filter(|id| !add_segments.contains(id)) - .collect::>() - .len(); - let region_modifications = self - .regions - .segment_range - .keys() - .chain(self.regions.fill.keys()) - .filter(|id| !add_regions.contains(id)) - .collect::>() - .len(); - - (additions, removals, point_modifications + segment_modifications + region_modifications) - } - - /// Returns a short human-readable summary string like "+12 / \u{2212}3 / \u{0394}2". + + // Count unique modified segment IDs across all field maps + let mut modified_segments: HashSet<&SegmentId> = HashSet::with_capacity(self.segments.start_point.len()); + let not_added_segment = |id: &&SegmentId| !add_segments.contains(id); + modified_segments.extend(self.segments.start_point.keys().filter(not_added_segment)); + modified_segments.extend(self.segments.end_point.keys().filter(not_added_segment)); + modified_segments.extend(self.segments.handle_primary.keys().filter(not_added_segment)); + modified_segments.extend(self.segments.handle_end.keys().filter(not_added_segment)); + modified_segments.extend(self.segments.stroke.keys().filter(not_added_segment)); + + // Count unique modified region IDs across all field maps + let mut modified_regions: HashSet<&RegionId> = HashSet::with_capacity(self.regions.segment_range.len()); + let not_added_region = |id: &&RegionId| !add_regions.contains(id); + modified_regions.extend(self.regions.segment_range.keys().filter(not_added_region)); + modified_regions.extend(self.regions.fill.keys().filter(not_added_region)); + + ModificationCategoryCounts { + points: [self.points.add.len(), self.points.remove.len(), point_modifications], + segments: [self.segments.add.len(), self.segments.remove.len(), modified_segments.len()], + regions: [self.regions.add.len(), self.regions.remove.len(), modified_regions.len()], + smooth_handles: [self.add_g1_continuous.len(), self.remove_g1_continuous.len(), 0], + } + } + + /// Returns a short human-readable summary string like "+6 / −1 / Δ1". pub fn summary_label(&self) -> String { - let (additions, removals, modifications) = self.summary_counts(); + let counts = self.category_counts(); + let [additions, removals, modifications] = counts.totals(); + let mut parts = Vec::new(); if additions > 0 { parts.push(format!("+{additions}")); @@ -377,88 +403,24 @@ impl VectorModification { /// Returns a detailed multi-line tooltip describing all the changes. pub fn summary_tooltip(&self) -> String { - let add_points: HashSet<_> = self.points.add.iter().copied().collect(); - let add_segments: HashSet<_> = self.segments.add.iter().copied().collect(); - let add_regions: HashSet<_> = self.regions.add.iter().copied().collect(); - - let point_deltas = self.points.delta.keys().filter(|id| !add_points.contains(id)).count(); - let segment_changes = self - .segments - .start_point - .keys() - .chain(self.segments.end_point.keys()) - .chain(self.segments.handle_primary.keys()) - .chain(self.segments.handle_end.keys()) - .chain(self.segments.stroke.keys()) - .filter(|id| !add_segments.contains(id)) - .collect::>() - .len(); - let region_changes = self - .regions - .segment_range - .keys() - .chain(self.regions.fill.keys()) - .filter(|id| !add_regions.contains(id)) - .collect::>() - .len(); + let counts = self.category_counts(); let mut lines = Vec::new(); - // Points - let mut point_parts = Vec::new(); - if !self.points.add.is_empty() { - point_parts.push(format!("+{}", self.points.add.len())); - } - if !self.points.remove.is_empty() { - point_parts.push(format!("\u{2212}{}", self.points.remove.len())); - } - if point_deltas > 0 { - point_parts.push(format!("\u{0394}{point_deltas}")); - } - if !point_parts.is_empty() { - lines.push(format!("Points: {}", point_parts.join(" / "))); - } - - // Segments - let mut segment_parts = Vec::new(); - if !self.segments.add.is_empty() { - segment_parts.push(format!("+{}", self.segments.add.len())); - } - if !self.segments.remove.is_empty() { - segment_parts.push(format!("\u{2212}{}", self.segments.remove.len())); - } - if segment_changes > 0 { - segment_parts.push(format!("\u{0394}{segment_changes}")); - } - if !segment_parts.is_empty() { - lines.push(format!("Segments: {}", segment_parts.join(" / "))); - } - - // Regions - let mut region_parts = Vec::new(); - if !self.regions.add.is_empty() { - region_parts.push(format!("+{}", self.regions.add.len())); - } - if !self.regions.remove.is_empty() { - region_parts.push(format!("\u{2212}{}", self.regions.remove.len())); - } - if region_changes > 0 { - region_parts.push(format!("\u{0394}{region_changes}")); - } - if !region_parts.is_empty() { - lines.push(format!("Regions: {}", region_parts.join(" / "))); - } - - // G1 continuous - let mut g1_parts = Vec::new(); - if !self.add_g1_continuous.is_empty() { - g1_parts.push(format!("+{}", self.add_g1_continuous.len())); - } - if !self.remove_g1_continuous.is_empty() { - g1_parts.push(format!("\u{2212}{}", self.remove_g1_continuous.len())); - } - if !g1_parts.is_empty() { - lines.push(format!("Smooth Handles: {}", g1_parts.join(" / "))); + for (name, [added, removed, modified]) in counts.iter_categories() { + let mut parts = Vec::new(); + if added > 0 { + parts.push(format!("+{added}")); + } + if removed > 0 { + parts.push(format!("\u{2212}{removed}")); + } + if modified > 0 { + parts.push(format!("\u{0394}{modified}")); + } + if !parts.is_empty() { + lines.push(format!("{name}: {}", parts.join(" / "))); + } } if lines.is_empty() { "None".to_string() } else { lines.join("\n") }