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); 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/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 // ================================== 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..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,7 +324,108 @@ pub enum VectorModificationType { ApplyEndDelta { segment: SegmentId, delta: DVec2 }, } +/// 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 + } + + /// 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(); + + // 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 counts = self.category_counts(); + let [additions, removals, modifications] = counts.totals(); + + 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 counts = self.category_counts(); + + let mut lines = Vec::new(); + + 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") } + } + /// 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);