From 79359694bbc47cac9d0fd325656ec8621bace04f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 16 Oct 2026 23:49:55 -0700 Subject: [PATCH 1/3] Rename Merge by Distance to Weld and add a Flow Ends method that joins only path flow ends --- .branding | 4 +- .../data_panel/data_panel_message_handler.rs | 10 +- .../document/node_graph/node_inspector.rs | 6 +- .../messages/portfolio/document_migration.rs | 116 ++- frontend/src/icons.ts | 4 +- node-graph/graph-craft/src/document/value.rs | 3 +- .../interpreted-executor/src/node_registry.rs | 4 +- .../vector/algorithms/merge_by_distance.rs | 336 --------- .../vector-types/src/vector/algorithms/mod.rs | 2 +- .../src/vector/algorithms/weld.rs | 696 ++++++++++++++++++ .../libraries/vector-types/src/vector/misc.rs | 14 +- node-graph/nodes/path-bool/src/lib.rs | 4 +- node-graph/nodes/vector/src/vector_nodes.rs | 33 +- 13 files changed, 855 insertions(+), 377 deletions(-) delete mode 100644 node-graph/libraries/vector-types/src/vector/algorithms/merge_by_distance.rs create mode 100644 node-graph/libraries/vector-types/src/vector/algorithms/weld.rs diff --git a/.branding b/.branding index fdc087ad6b5..ab01808d9d4 100644 --- a/.branding +++ b/.branding @@ -1,2 +1,2 @@ -https://github.com/Keavon/graphite-branded-assets/archive/ca7dd793caba8999ec33fde99bb721a69c3d221f.tar.gz -77e8a6b68d67a8d5fcfd6268a6b7938ac9322d877378c6563e933a57df6da821 +https://github.com/Keavon/graphite-branded-assets/archive/a40077842627afb0c90858b351112a62b340881d.tar.gz +cd454d750d3f47fb2f41abceeacfc0ae59629ca00229dc56df53e3f4db0f6ee7 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 cdd04693568..c75d634da0f 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 @@ -24,7 +24,7 @@ use graphene_std::text_nodes::{StringCapitalization, TextDenomination}; use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ - ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, + ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, WeldMethod, }; use graphene_std::vector::style::{ DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin, @@ -209,7 +209,7 @@ fn generate_layout(introspected_data: &Arc, List, List, - List, + List, List, List, List, @@ -263,7 +263,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, - Item, + Item, Item, Item, Item, @@ -981,7 +981,7 @@ impl_table_item_layout_for_choice_enum!( StrokeJoin, StrokeAlign, StrokeCap, - MergeByDistanceAlgorithm, + WeldMethod, ExtrudeJoiningAlgorithm, PointSpacingType, StringCapitalization, @@ -1153,7 +1153,7 @@ macro_rules! known_item_types { StrokeJoin, StrokeAlign, StrokeCap, - MergeByDistanceAlgorithm, + WeldMethod, ExtrudeJoiningAlgorithm, PointSpacingType, StringCapitalization, diff --git a/editor/src/messages/portfolio/document/node_graph/node_inspector.rs b/editor/src/messages/portfolio/document/node_graph/node_inspector.rs index c6c874a9e7c..db5ff4e8f74 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_inspector.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_inspector.rs @@ -32,9 +32,7 @@ use graphene_std::text_nodes::{StringCapitalization, TextDenomination}; use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::misc::BooleanOperation; -use graphene_std::vector::misc::{ - ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, -}; +use graphene_std::vector::misc::{ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, WeldMethod}; use graphene_std::vector::style::{ FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation, @@ -430,7 +428,7 @@ pub(crate) fn inspector_row_from_type(node_id: NodeId, index: usize, ty: &Type, Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), - Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 08536927852..66292e518b4 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -901,8 +901,8 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &["graphene_core::vector::JitterPointsNode"], }, NodeReplacement { - node: graphene_std::vector::merge_by_distance::IDENTIFIER, - aliases: &["graphene_core::vector::MergeByDistanceNode"], + node: graphene_std::vector::weld::IDENTIFIER, + aliases: &["graphene_core::vector::MergeByDistanceNode", "core_types::vector::MergeByDistanceNode"], }, NodeReplacement { node: graphene_std::graphic::mirror::IDENTIFIER, @@ -2774,7 +2774,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .set_input(&InputConnector::node_at_index(*node_id, 2), NodeInput::value(TaggedValue::Bool(true), false), network_path); } - if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::merge_by_distance::IDENTIFIER) && inputs_count == 2 { + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::weld::IDENTIFIER) && inputs_count == 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); @@ -2784,13 +2784,28 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path); document.network_interface.set_input( &InputConnector::node_at_index(*node_id, 2), - NodeInput::value(TaggedValue::MergeByDistanceAlgorithm(graphene_std::vector::misc::MergeByDistanceAlgorithm::Topological), false), + NodeInput::value(TaggedValue::WeldMethod(graphene_std::vector::misc::WeldMethod::Connected), false), network_path, ); + reset_default_display_name(document, node_id, network_path, "Merge by Distance"); + } + + // A Merge by Distance node saved with its "Algorithm" input takes Weld's input names and descriptions, keeping its inputs + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::weld::IDENTIFIER) + && inputs_count == 3 + && document + .network_interface + .persistent_input_metadata(node_id, 2, network_path) + .is_some_and(|metadata| metadata.input_name == "Algorithm") + { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + node_template.inputs = node.inputs.clone(); + document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + reset_default_display_name(document, node_id, network_path, "Merge by Distance"); } if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_core::vector::SpatialMergeByDistanceNode")) { - let mut node_template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::merge_by_distance::IDENTIFIER))?.default_node_template(); + let mut node_template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::weld::IDENTIFIER))?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; @@ -2799,9 +2814,10 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path); document.network_interface.set_input( &InputConnector::node_at_index(*node_id, 2), - NodeInput::value(TaggedValue::MergeByDistanceAlgorithm(graphene_std::vector::misc::MergeByDistanceAlgorithm::Spatial), false), + NodeInput::value(TaggedValue::WeldMethod(graphene_std::vector::misc::WeldMethod::All), false), network_path, ); + reset_default_display_name(document, node_id, network_path, "Spatial Merge by Distance"); } if reference == DefinitionIdentifier::Network("Sample Points".into()) && inputs_count == 5 { @@ -4314,4 +4330,92 @@ mod tests { assert!(rewritten_at(reads_turned)); assert!(!rewritten_at(reads_other)); } + + // Values saved for Merge by Distance load as the methods of the Weld node it became + #[test] + fn merge_by_distance_values_load_as_weld_methods() { + use graphene_std::vector::misc::WeldMethod; + + let load = |json: &str| serde_json::from_str::(json).ok(); + assert_eq!(load(r#"{"MergeByDistanceAlgorithm":"Spatial"}"#), Some(TaggedValue::WeldMethod(WeldMethod::All))); + assert_eq!(load(r#"{"MergeByDistanceAlgorithm":"Topological"}"#), Some(TaggedValue::WeldMethod(WeldMethod::Connected))); + assert_eq!(load(r#"{"WeldMethod":"FlowEnds"}"#), Some(TaggedValue::WeldMethod(WeldMethod::FlowEnds))); + } + + // Every kind of Merge by Distance node older editors saved opens as a Weld node, with the method it had and Weld's own title and inputs + #[test] + fn merge_by_distance_nodes_open_as_weld_nodes() { + use crate::messages::portfolio::document_storage_io::load_legacy_document; + use crate::test_utils::capture_logged_errors; + use graph_craft::application_io::resource::HashMapResourceStorage; + use graphene_std::vector::misc::WeldMethod; + + let weld = graphene_std::vector::weld::IDENTIFIER; + let definition = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(weld.clone())).expect("the Weld definition should exist"); + let content = definition.default_node_template().inputs[0].clone(); + let distance = NodeInput::value(TaggedValue::Number(2.), false); + + // Older nodes stored their title and input names, with any "Algorithm" choice saved here as a placeholder method + let legacy_node = |identifier: &'static str, display_name: &str, placeholder_method: Option| { + let mut inputs = vec![content.clone(), distance.clone()]; + inputs.extend(placeholder_method.map(|method| NodeInput::value(TaggedValue::WeldMethod(method), false))); + let input_names: &[&str] = if placeholder_method.is_some() { + &["Content", "Distance", "Algorithm"] + } else { + &["Source", "Distance"] + }; + + NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(ProtoNodeIdentifier::new(identifier)), + inputs, + display_name: display_name.to_string(), + input_metadata: input_names.iter().map(|name| (*name, "").into()).collect(), + ..Default::default() + } + }; + let legacy_nodes = [ + legacy_node("core_types::vector::MergeByDistanceNode", "Merge by Distance", Some(WeldMethod::All)), + legacy_node("graphene_core::vector::MergeByDistanceNode", "Merge by Distance", Some(WeldMethod::Connected)), + legacy_node("graphene_core::vector::MergeByDistanceNode", "Merge by Distance", None), + legacy_node("graphene_core::vector::SpatialMergeByDistanceNode", "Spatial Merge by Distance", None), + ]; + let mut document = DocumentMessageHandler::default(); + for (index, node_template) in legacy_nodes.into_iter().enumerate() { + document.network_interface.insert_node(NodeId(index as u64 + 1), node_template, &[]); + } + + // The placeholder methods are saved under the old value names, which load as the methods they became + let mut serialized = document.serialize_document(); + assert!(!serialized.contains("WeldNode"), "the saved document should name only the old node identifiers"); + for (placeholder, saved) in [("All", "Topological"), ("Connected", "Spatial")] { + let placeholder = format!(r#"{{"WeldMethod":"{placeholder}"}}"#); + assert_eq!(serialized.matches(&placeholder).count(), 1); + serialized = serialized.replace(&placeholder, &format!(r#"{{"MergeByDistanceAlgorithm":"{saved}"}}"#)); + } + + let (opened, errors) = capture_logged_errors(|| load_legacy_document(serialized, &HashMapResourceStorage::new(), false)); + let opened = opened.expect("the document should open"); + assert!(errors.is_empty(), "opening the document logged errors: {errors:?}"); + + let definition_inputs: Vec<(String, String)> = definition + .node_template + .input_metadata + .iter() + .map(|metadata| (metadata.persistent_metadata.input_name.clone(), metadata.persistent_metadata.input_description.clone())) + .collect(); + assert_eq!(definition_inputs.iter().map(|(name, _)| name.as_str()).collect::>(), ["Content", "Distance", "Method"]); + + for (node_id, method) in [(1, WeldMethod::Connected), (2, WeldMethod::All), (3, WeldMethod::Connected), (4, WeldMethod::All)].map(|(id, method)| (NodeId(id), method)) { + let interface = &opened.network_interface; + let node = interface.document_node(&node_id, &[]).expect("the node should still exist"); + assert_eq!(node.implementation, DocumentNodeImplementation::ProtoNode(weld.clone()), "node {node_id}"); + assert_eq!(node.inputs[1].as_value(), Some(&TaggedValue::Number(2.)), "node {node_id}"); + assert_eq!(node.inputs[2].as_value(), Some(&TaggedValue::WeldMethod(method)), "node {node_id}"); + assert_eq!(interface.display_name(&node_id, &[]), "Weld", "node {node_id}"); + + let inputs: Vec<(String, String)> = (0..node.inputs.len()).map(|index| interface.displayed_input_name_and_description(&node_id, index, &[])).collect(); + assert_eq!(inputs, definition_inputs, "node {node_id}"); + } + } + } diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 068051c23ae..3d2b9803478 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -260,7 +260,6 @@ import NodeMax from "/../branding/assets/16x16-nodes/node-max.svg"; import NodeMaximum from "/../branding/assets/16x16-nodes/node-maximum.svg"; import NodeMedianFilter from "/../branding/assets/16x16-nodes/node-median-filter.svg"; import NodeMemoize from "/../branding/assets/16x16-nodes/node-memoize.svg"; -import NodeMergeByDistance from "/../branding/assets/16x16-nodes/node-merge-by-distance.svg"; import NodeMerge from "/../branding/assets/16x16-nodes/node-merge.svg"; import NodeMin from "/../branding/assets/16x16-nodes/node-min.svg"; import NodeMinimum from "/../branding/assets/16x16-nodes/node-minimum.svg"; @@ -382,6 +381,7 @@ import NodeVec2ToNumbers from "/../branding/assets/16x16-nodes/node-vec2-to-numb import NodeVec2Value from "/../branding/assets/16x16-nodes/node-vec2-value.svg"; import NodeVibrance from "/../branding/assets/16x16-nodes/node-vibrance.svg"; import NodeVoronoiCells from "/../branding/assets/16x16-nodes/node-voronoi-cells.svg"; +import NodeWeld from "/../branding/assets/16x16-nodes/node-weld.svg"; import NodeWriteAttribute from "/../branding/assets/16x16-nodes/node-write-attribute.svg"; export const ICON_16X16_NODES = { @@ -542,7 +542,6 @@ export const ICON_16X16_NODES = { NodeMedianFilter: { svg: NodeMedianFilter, size: 16 }, NodeMemoize: { svg: NodeMemoize, size: 16 }, NodeMerge: { svg: NodeMerge, size: 16 }, - NodeMergeByDistance: { svg: NodeMergeByDistance, size: 16 }, NodeMin: { svg: NodeMin, size: 16 }, NodeMinimum: { svg: NodeMinimum, size: 16 }, NodeMirror: { svg: NodeMirror, size: 16 }, @@ -663,6 +662,7 @@ export const ICON_16X16_NODES = { NodeVec2Value: { svg: NodeVec2Value, size: 16 }, NodeVibrance: { svg: NodeVibrance, size: 16 }, NodeVoronoiCells: { svg: NodeVoronoiCells, size: 16 }, + NodeWeld: { svg: NodeWeld, size: 16 }, NodeWriteAttribute: { svg: NodeWriteAttribute, size: 16 }, } as const; diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 27b6b437e00..04355810217 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -580,7 +580,8 @@ tagged_value! { GridType(vector::misc::GridType), ArcType(vector::misc::ArcType), RowsOrColumns(vector::misc::RowsOrColumns), - MergeByDistanceAlgorithm(vector::misc::MergeByDistanceAlgorithm), + #[serde(alias = "MergeByDistanceAlgorithm")] + WeldMethod(vector::misc::WeldMethod), ExtrudeJoiningAlgorithm(vector::misc::ExtrudeJoiningAlgorithm), PointSpacingType(vector::misc::PointSpacingType), SpiralType(vector::misc::SpiralType), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index aff56cb4f3d..c1f4a5abcfb 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -23,7 +23,7 @@ use graphene_std::text_nodes::{StringCapitalization, TextDenomination}; use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ - ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, + ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, WeldMethod, }; use graphene_std::vector::style::{DashPattern, GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector, VectorModification}; @@ -321,7 +321,7 @@ fn node_registry() -> HashMap, distance: f64); - fn merge_by_distance_spatial(&mut self, paths: &mut List, transform: DAffine2, distance: f64); -} - -impl MergeByDistanceExt for Vector { - fn merge_by_distance_topological(&mut self, paths: &mut List, distance: f64) { - let mut working = WorkingVector::flatten(self, paths); - - // Treat the working form as an undirected graph - let indices = VectorIndex::build_from(&working); - - // TODO: We lose information on the winding order by using an undirected graph. Switch to a directed graph and fix the algorithm to handle that. - // Graph containing only short edges, referencing the data graph - let mut short_edges = UnGraphMap::new(); - - for segment_id in working.segment_ids.iter().copied() { - let length = indices.segment_chord_length(segment_id); - if length < distance { - let [start, end] = indices.segment_ends(segment_id); - let start = indices.point_graph.node_weight(start).unwrap().id; - let end = indices.point_graph.node_weight(end).unwrap().id; - - short_edges.add_node(start); - short_edges.add_node(end); - short_edges.add_edge(start, end, segment_id); - } - } - - // Group connected segments to collapse them into a single point - // TODO: there are a few possible algorithms for this - perhaps test empirically to find fastest - let collapse: Vec> = petgraph::algo::tarjan_scc(&short_edges).into_iter().map(|connected| connected.into_iter().collect()).collect(); - let average_position = collapse - .iter() - .map(|collapse_set| { - let sum: DVec2 = collapse_set.iter().map(|&id| indices.point_position(id, &working)).sum(); - sum / collapse_set.len() as f64 - }) - .collect::>(); - - // Collect points and segments to delete at the end to avoid invalidating indices - let mut points_to_delete = FxHashSet::default(); - let mut segments_to_delete = FxHashSet::default(); - for (mut collapse_set, average_pos) in collapse.into_iter().zip(average_position) { - // Remove any segments where both endpoints are in the collapse set - for segment_index in 0..working.segment_ids.len() { - let start = working.point_ids[working.segment_start[segment_index]]; - let end = working.point_ids[working.segment_end[segment_index]]; - if collapse_set.contains(&start) && collapse_set.contains(&end) { - segments_to_delete.insert(working.segment_ids[segment_index]); - } - } - - // Delete all points but the first, set its position to the average, and update segments - let first_id = collapse_set.iter().copied().next().unwrap(); - collapse_set.remove(&first_id); - let first_offset = indices.point_to_offset[&first_id]; - - // Look for segments with endpoints in `collapse_set` and replace them with the point we are collapsing to - for segment_index in 0..working.segment_ids.len() { - let start_id = working.point_ids[working.segment_start[segment_index]]; - let end_id = working.point_ids[working.segment_end[segment_index]]; - - // Update Bezier handles for moved points - if start_id == first_id { - let point_position = working.point_positions[working.segment_start[segment_index]]; - working.segment_handles[segment_index].move_start(average_pos - point_position); - } - if end_id == first_id { - let point_position = working.point_positions[working.segment_end[segment_index]]; - working.segment_handles[segment_index].move_end(average_pos - point_position); - } - - // Replace removed points with the collapsed point - if collapse_set.contains(&start_id) { - let point_position = working.point_positions[working.segment_start[segment_index]]; - working.segment_start[segment_index] = first_offset; - working.segment_handles[segment_index].move_start(average_pos - point_position); - } - if collapse_set.contains(&end_id) { - let point_position = working.point_positions[working.segment_end[segment_index]]; - working.segment_end[segment_index] = first_offset; - working.segment_handles[segment_index].move_end(average_pos - point_position); - } - } - - // Update the position of the collapsed point - working.point_positions[first_offset] = average_pos; - - points_to_delete.extend(collapse_set) - } - - // Drop the collapsed segments, then the merged-away points, remapping segment endpoints - let keep_segment: Vec = working.segment_ids.iter().map(|id| !segments_to_delete.contains(id)).collect(); - working.carry_path_beginnings(&keep_segment); - retain_by(&mut working.segment_ids, &keep_segment); - retain_by(&mut working.segment_start, &keep_segment); - retain_by(&mut working.segment_end, &keep_segment); - retain_by(&mut working.segment_handles, &keep_segment); - retain_by(&mut working.segment_begins_path, &keep_segment); - - let mut point_map = Vec::with_capacity(working.point_ids.len()); - let mut new_index = 0; - let keep_point: Vec = working - .point_ids - .iter() - .map(|id| { - let keep = !points_to_delete.contains(id); - point_map.push(if keep { new_index } else { usize::MAX }); - new_index += usize::from(keep); - keep - }) - .collect(); - retain_by(&mut working.point_ids, &keep_point); - retain_by(&mut working.point_positions, &keep_point); - for index in working.segment_start.iter_mut().chain(working.segment_end.iter_mut()) { - *index = point_map.get(*index).copied().unwrap_or(usize::MAX); - } - - working.reweave(self, paths); - } - - fn merge_by_distance_spatial(&mut self, paths: &mut List, transform: DAffine2, distance: f64) { - let mut working = WorkingVector::flatten(self, paths); - let point_count = working.point_positions.len(); - - // Find min x and y for grid cell normalization - let mut min_x = f64::MAX; - let mut min_y = f64::MAX; - - // Calculate mins without collecting all positions - for &pos in &working.point_positions { - let transformed_pos = transform.transform_point2(pos); - min_x = min_x.min(transformed_pos.x); - min_y = min_y.min(transformed_pos.y); - } - - // Create a spatial grid with cell size of 'distance' - use std::collections::HashMap; - let mut grid: HashMap<(i32, i32), Vec> = HashMap::new(); - - // Add points to grid cells without collecting all positions first - for i in 0..point_count { - let pos = transform.transform_point2(working.point_positions[i]); - let grid_x = ((pos.x - min_x) / distance).floor() as i32; - let grid_y = ((pos.y - min_y) / distance).floor() as i32; - - grid.entry((grid_x, grid_y)).or_default().push(i); - } - - // Create point index mapping for merged points - let mut point_index_map = vec![None; point_count]; - let mut merged_positions = Vec::new(); - let mut merged_indices = Vec::new(); - - // Process each point - for i in 0..point_count { - // Skip points that have already been processed - if point_index_map[i].is_some() { - continue; - } - - let pos_i = transform.transform_point2(working.point_positions[i]); - let grid_x = ((pos_i.x - min_x) / distance).floor() as i32; - let grid_y = ((pos_i.y - min_y) / distance).floor() as i32; - - let mut group = vec![i]; - - // Check only neighboring cells (3x3 grid around current cell) - for dx in -1..=1 { - for dy in -1..=1 { - let neighbor_cell = (grid_x + dx, grid_y + dy); - - if let Some(indices) = grid.get(&neighbor_cell) { - for &j in indices { - if j > i && point_index_map[j].is_none() { - let pos_j = transform.transform_point2(working.point_positions[j]); - if pos_i.distance(pos_j) <= distance { - group.push(j); - } - } - } - } - } - } - - // Create merged point - calculate positions as needed - let merged_position = group - .iter() - .map(|&idx| transform.transform_point2(working.point_positions[idx])) - .fold(DVec2::ZERO, |sum, pos| sum + pos) - / group.len() as f64; - - let merged_position = transform.inverse().transform_point2(merged_position); - let merged_index = merged_positions.len(); - - merged_positions.push(merged_position); - merged_indices.push(working.point_ids[group[0]]); - - // Update mapping for all points in the group - for &idx in &group { - point_index_map[idx] = Some(merged_index); - } - } - - // Segments with endpoints that collapsed together drop out, handing on any path they began - let keep_segment: Vec = (0..working.segment_ids.len()) - .map(|segment_index| point_index_map[working.segment_start[segment_index]] != point_index_map[working.segment_end[segment_index]]) - .collect(); - working.carry_path_beginnings(&keep_segment); - - // Build the merged working form from the kept segments - let mut merged = WorkingVector { - point_ids: merged_indices, - point_positions: merged_positions, - colinear: working.colinear, - ..Default::default() - }; - - for segment_index in (0..working.segment_ids.len()).filter(|&segment_index| keep_segment[segment_index]) { - let new_start = point_index_map[working.segment_start[segment_index]].unwrap(); - let new_end = point_index_map[working.segment_end[segment_index]].unwrap(); - - merged.segment_ids.push(working.segment_ids[segment_index]); - merged.segment_start.push(new_start); - merged.segment_end.push(new_end); - merged.segment_handles.push(working.segment_handles[segment_index]); - merged.segment_begins_path.push(working.segment_begins_path.get(segment_index).copied().unwrap_or(false)); - } - - merged.reweave(self, paths); - } -} - -/// Keeps only the elements flagged true, in order. -fn retain_by(values: &mut Vec, keep: &[bool]) { - let mut flags = keep.iter(); - values.retain(|_| *flags.next().unwrap_or(&false)); -} - -/// All the fixed fields of a point from the working form. -pub(crate) struct Point { - pub id: PointId, - pub position: DVec2, -} - -/// Useful indexes to speed up various operations on the flattened working form. -/// -/// Important: It is the user's responsibility to ensure the indexes remain valid after mutations to the data. -pub(crate) struct VectorIndex { - /// Points and segments form a graph. Store it here in a form amenable to graph algorithms. - /// - /// Currently, segment data is not stored as it is not used, but it could easily be added. - pub(crate) point_graph: UnGraph, - pub(crate) segment_to_edge: FxHashMap, - /// Get the offset from the point ID. - pub(crate) point_to_offset: FxHashMap, - // TODO: faces -} - -impl VectorIndex { - /// Construct a [`VectorIndex`] by building indexes from the given working form. Takes `O(n)` time. - fn build_from(working: &WorkingVector) -> Self { - let point_to_offset = working.point_ids.iter().copied().enumerate().map(|(a, b)| (b, a)).collect::>(); - - let mut point_to_node = FxHashMap::default(); - let mut segment_to_edge = FxHashMap::default(); - - let mut graph = UnGraph::new_undirected(); - - for (&point_id, &position) in working.point_ids.iter().zip(&working.point_positions) { - let idx = graph.add_node(Point { id: point_id, position }); - point_to_node.insert(point_id, idx); - } - - for (segment_index, &segment_id) in working.segment_ids.iter().enumerate() { - let start_id = working.point_ids[working.segment_start[segment_index]]; - let end_id = working.point_ids[working.segment_end[segment_index]]; - let edge = graph.add_edge(point_to_node[&start_id], point_to_node[&end_id], ()); - - segment_to_edge.insert(segment_id, edge); - } - - Self { - point_graph: graph, - segment_to_edge, - point_to_offset, - } - } - - /// Fetch the length of given segment's chord. Takes `O(1)` time. - /// - /// # Panics - /// - /// Will panic if no segment with the given ID is found. - fn segment_chord_length(&self, id: SegmentId) -> f64 { - let edge_idx = self.segment_to_edge[&id]; - let (start, end) = self.point_graph.edge_endpoints(edge_idx).unwrap(); - let start_position = self.point_graph.node_weight(start).unwrap().position; - let end_position = self.point_graph.node_weight(end).unwrap().position; - (start_position - end_position).length() - } - - /// Get the ends of a segment. Takes `O(1)` time. - /// - /// The IDs will be ordered [smallest, largest] so they can be used to find other segments with the same endpoints, regardless of direction. - /// - /// # Panics - /// - /// This function will panic if the ID is not present. - fn segment_ends(&self, id: SegmentId) -> [NodeIndex; 2] { - let (start, end) = self.point_graph.edge_endpoints(self.segment_to_edge[&id]).unwrap(); - if start < end { [start, end] } else { [end, start] } - } - - /// Get the physical location of a point. Takes `O(1)` time. - /// - /// # Panics - /// - /// Will panic if `id` isn't in the data. - fn point_position(&self, id: PointId, working: &WorkingVector) -> DVec2 { - let offset = self.point_to_offset[&id]; - working.point_positions[offset] - } -} diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/mod.rs b/node-graph/libraries/vector-types/src/vector/algorithms/mod.rs index 3d45506dc9d..a0228a67910 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/mod.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/mod.rs @@ -1,9 +1,9 @@ pub mod bezpath_algorithms; pub(crate) mod consts; pub mod intersection; -pub mod merge_by_distance; pub mod offset_bezpath; mod poisson_disk; pub mod shapes; pub mod spline; pub mod util; +pub mod weld; diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs b/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs new file mode 100644 index 00000000000..46e3a5f308d --- /dev/null +++ b/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs @@ -0,0 +1,696 @@ +use crate::vector::flatten::WorkingVector; +use crate::vector::path::{CurveType, Path, continues_previous_path, curve_types_of}; +use crate::vector::{PointId, SegmentId, Vector}; +use core_types::list::List; +use glam::{DAffine2, DVec2}; +use petgraph::graph::{EdgeIndex, NodeIndex, UnGraph}; +use petgraph::prelude::UnGraphMap; +use rustc_hash::FxHashMap; +use rustc_hash::FxHashSet; +use std::ops::Range; + +pub trait WeldExt { + /// Collapses every segment shorter than the distance, joining its two ends. + fn weld_connected(&mut self, paths: &mut List, distance: f64); + /// Joins any points lying within the distance of each other, measured after the transform. + fn weld_all(&mut self, paths: &mut List, transform: DAffine2, distance: f64); + /// Joins the ends of open path flows lying within the distance of each other, measured after the transform, and leaves every other anchor where it is. + fn weld_flow_ends(&mut self, paths: &mut List, transform: DAffine2, distance: f64); +} + +impl WeldExt for Vector { + fn weld_connected(&mut self, paths: &mut List, distance: f64) { + let mut working = WorkingVector::flatten(self, paths); + + // Treat the working form as an undirected graph + let indices = VectorIndex::build_from(&working); + + // TODO: We lose information on the winding order by using an undirected graph. Switch to a directed graph and fix the algorithm to handle that. + // Graph containing only short edges, referencing the data graph + let mut short_edges = UnGraphMap::new(); + + for segment_id in working.segment_ids.iter().copied() { + let length = indices.segment_chord_length(segment_id); + if length < distance { + let [start, end] = indices.segment_ends(segment_id); + let start = indices.point_graph.node_weight(start).unwrap().id; + let end = indices.point_graph.node_weight(end).unwrap().id; + + short_edges.add_node(start); + short_edges.add_node(end); + short_edges.add_edge(start, end, segment_id); + } + } + + // Group connected segments to collapse them into a single point + // TODO: there are a few possible algorithms for this - perhaps test empirically to find fastest + let collapse: Vec> = petgraph::algo::tarjan_scc(&short_edges).into_iter().map(|connected| connected.into_iter().collect()).collect(); + let average_position = collapse + .iter() + .map(|collapse_set| { + let sum: DVec2 = collapse_set.iter().map(|&id| indices.point_position(id, &working)).sum(); + sum / collapse_set.len() as f64 + }) + .collect::>(); + + // Collect points and segments to delete at the end to avoid invalidating indices + let mut points_to_delete = FxHashSet::default(); + let mut segments_to_delete = FxHashSet::default(); + for (mut collapse_set, average_pos) in collapse.into_iter().zip(average_position) { + // Remove any segments where both endpoints are in the collapse set + for segment_index in 0..working.segment_ids.len() { + let start = working.point_ids[working.segment_start[segment_index]]; + let end = working.point_ids[working.segment_end[segment_index]]; + if collapse_set.contains(&start) && collapse_set.contains(&end) { + segments_to_delete.insert(working.segment_ids[segment_index]); + } + } + + // Delete all points but the first, set its position to the average, and update segments + let first_id = collapse_set.iter().copied().next().unwrap(); + collapse_set.remove(&first_id); + let first_offset = indices.point_to_offset[&first_id]; + + // Look for segments with endpoints in `collapse_set` and replace them with the point we are collapsing to + for segment_index in 0..working.segment_ids.len() { + let start_id = working.point_ids[working.segment_start[segment_index]]; + let end_id = working.point_ids[working.segment_end[segment_index]]; + + // Update Bezier handles for moved points + if start_id == first_id { + let point_position = working.point_positions[working.segment_start[segment_index]]; + working.segment_handles[segment_index].move_start(average_pos - point_position); + } + if end_id == first_id { + let point_position = working.point_positions[working.segment_end[segment_index]]; + working.segment_handles[segment_index].move_end(average_pos - point_position); + } + + // Replace removed points with the collapsed point + if collapse_set.contains(&start_id) { + let point_position = working.point_positions[working.segment_start[segment_index]]; + working.segment_start[segment_index] = first_offset; + working.segment_handles[segment_index].move_start(average_pos - point_position); + } + if collapse_set.contains(&end_id) { + let point_position = working.point_positions[working.segment_end[segment_index]]; + working.segment_end[segment_index] = first_offset; + working.segment_handles[segment_index].move_end(average_pos - point_position); + } + } + + // Update the position of the collapsed point + working.point_positions[first_offset] = average_pos; + + points_to_delete.extend(collapse_set) + } + + // Drop the collapsed segments, then the merged-away points, remapping segment endpoints + let keep_segment: Vec = working.segment_ids.iter().map(|id| !segments_to_delete.contains(id)).collect(); + working.carry_path_beginnings(&keep_segment); + retain_by(&mut working.segment_ids, &keep_segment); + retain_by(&mut working.segment_start, &keep_segment); + retain_by(&mut working.segment_end, &keep_segment); + retain_by(&mut working.segment_handles, &keep_segment); + retain_by(&mut working.segment_begins_path, &keep_segment); + + let mut point_map = Vec::with_capacity(working.point_ids.len()); + let mut new_index = 0; + let keep_point: Vec = working + .point_ids + .iter() + .map(|id| { + let keep = !points_to_delete.contains(id); + point_map.push(if keep { new_index } else { usize::MAX }); + new_index += usize::from(keep); + keep + }) + .collect(); + retain_by(&mut working.point_ids, &keep_point); + retain_by(&mut working.point_positions, &keep_point); + for index in working.segment_start.iter_mut().chain(working.segment_end.iter_mut()) { + *index = point_map.get(*index).copied().unwrap_or(usize::MAX); + } + + working.reweave(self, paths); + } + + fn weld_all(&mut self, paths: &mut List, transform: DAffine2, distance: f64) { + let mut working = WorkingVector::flatten(self, paths); + let point_count = working.point_positions.len(); + + // Find min x and y for grid cell normalization + let mut min_x = f64::MAX; + let mut min_y = f64::MAX; + + // Calculate mins without collecting all positions + for &pos in &working.point_positions { + let transformed_pos = transform.transform_point2(pos); + min_x = min_x.min(transformed_pos.x); + min_y = min_y.min(transformed_pos.y); + } + + // Create a spatial grid with cell size of 'distance' + use std::collections::HashMap; + let mut grid: HashMap<(i32, i32), Vec> = HashMap::new(); + + // Add points to grid cells without collecting all positions first + for i in 0..point_count { + let pos = transform.transform_point2(working.point_positions[i]); + let grid_x = ((pos.x - min_x) / distance).floor() as i32; + let grid_y = ((pos.y - min_y) / distance).floor() as i32; + + grid.entry((grid_x, grid_y)).or_default().push(i); + } + + // Create point index mapping for merged points + let mut point_index_map = vec![None; point_count]; + let mut merged_positions = Vec::new(); + let mut merged_indices = Vec::new(); + + // Process each point + for i in 0..point_count { + // Skip points that have already been processed + if point_index_map[i].is_some() { + continue; + } + + let pos_i = transform.transform_point2(working.point_positions[i]); + let grid_x = ((pos_i.x - min_x) / distance).floor() as i32; + let grid_y = ((pos_i.y - min_y) / distance).floor() as i32; + + let mut group = vec![i]; + + // Check only neighboring cells (3x3 grid around current cell) + for dx in -1..=1 { + for dy in -1..=1 { + let neighbor_cell = (grid_x + dx, grid_y + dy); + + if let Some(indices) = grid.get(&neighbor_cell) { + for &j in indices { + if j > i && point_index_map[j].is_none() { + let pos_j = transform.transform_point2(working.point_positions[j]); + if pos_i.distance(pos_j) <= distance { + group.push(j); + } + } + } + } + } + } + + // Create merged point - calculate positions as needed + let merged_position = group + .iter() + .map(|&idx| transform.transform_point2(working.point_positions[idx])) + .fold(DVec2::ZERO, |sum, pos| sum + pos) + / group.len() as f64; + + let merged_position = transform.inverse().transform_point2(merged_position); + let merged_index = merged_positions.len(); + + merged_positions.push(merged_position); + merged_indices.push(working.point_ids[group[0]]); + + // Update mapping for all points in the group + for &idx in &group { + point_index_map[idx] = Some(merged_index); + } + } + + // Segments with endpoints that collapsed together drop out, handing on any path they began + let keep_segment: Vec = (0..working.segment_ids.len()) + .map(|segment_index| point_index_map[working.segment_start[segment_index]] != point_index_map[working.segment_end[segment_index]]) + .collect(); + working.carry_path_beginnings(&keep_segment); + + // Build the merged working form from the kept segments + let mut merged = WorkingVector { + point_ids: merged_indices, + point_positions: merged_positions, + colinear: working.colinear, + ..Default::default() + }; + + for segment_index in (0..working.segment_ids.len()).filter(|&segment_index| keep_segment[segment_index]) { + let new_start = point_index_map[working.segment_start[segment_index]].unwrap(); + let new_end = point_index_map[working.segment_end[segment_index]].unwrap(); + + merged.segment_ids.push(working.segment_ids[segment_index]); + merged.segment_start.push(new_start); + merged.segment_end.push(new_end); + merged.segment_handles.push(working.segment_handles[segment_index]); + merged.segment_begins_path.push(working.segment_begins_path.get(segment_index).copied().unwrap_or(false)); + } + + merged.reweave(self, paths); + } + + fn weld_flow_ends(&mut self, paths: &mut List, transform: DAffine2, distance: f64) { + let point_count = self.0.len(); + if !transform.matrix2.determinant().is_normal() { + return; + } + let inverse = transform.inverse(); + + // Every pool point an open path flow begins or ends at + let mut is_end = vec![false; point_count]; + for flow in path_flows(paths) { + if let Some((start, end)) = path_flow_ends(paths, &flow) + && start != end + { + for point in [start, end] { + if let Some(is_end) = is_end.get_mut(point) { + *is_end = true; + } + } + } + } + let ends: Vec = (0..point_count).filter(|&point| is_end[point]).collect(); + + // Ends within the distance of an earlier one join it, found through a grid of cells as wide as the distance in O(ends) + let pool_positions = self.0.iter_element_values().as_slice(); + let positions: Vec = ends.iter().map(|&point| transform.transform_point2(pool_positions[point])).collect(); + let cell = |position: DVec2| ((position.x / distance).floor() as i64, (position.y / distance).floor() as i64); + let mut grid: FxHashMap<(i64, i64), Vec> = FxHashMap::default(); + for (index, &position) in positions.iter().enumerate() { + grid.entry(cell(position)).or_default().push(index); + } + + let mut joined_into = vec![None; point_count]; + let mut moved_by = vec![None; point_count]; + let mut taken = vec![false; ends.len()]; + for index in 0..ends.len() { + if taken[index] { + continue; + } + taken[index] = true; + + let (x, y) = cell(positions[index]); + let neighbors = (-1..=1).flat_map(|dx| (-1..=1).map(move |dy| (x.saturating_add(dx), y.saturating_add(dy)))); + let mut group = vec![index]; + for neighbor in neighbors { + for &other in grid.get(&neighbor).into_iter().flatten() { + if !taken[other] && positions[index].distance(positions[other]) <= distance { + taken[other] = true; + group.push(other); + } + } + } + if group.len() < 2 { + continue; + } + + // The group meets at its average position, which its earliest point keeps + let average = inverse.transform_point2(group.iter().map(|&member| positions[member]).sum::() / group.len() as f64); + let kept = ends[group[0]]; + for &member in &group { + let point = ends[member]; + moved_by[point] = Some(average - pool_positions[point]); + if point != kept { + joined_into[point] = Some(kept); + } + } + } + if joined_into.iter().all(Option::is_none) { + return; + } + + // A cubic anchor's handles move along with it, keeping the curve's shape beside the end + let mut handle_moved_by = vec![None; point_count]; + for (path, curve_type) in paths.iter_element_values().zip(curve_types_of(paths)) { + if curve_type != CurveType::Cubic { + continue; + } + let references = path.0.iter_element_values().as_slice(); + for (index, &anchor) in references.iter().enumerate().step_by(curve_type.stride()) { + let Some(offset) = usize::try_from(anchor).ok().and_then(|anchor| moved_by.get(anchor).copied().flatten()) else { + continue; + }; + + for &handle in [index.checked_sub(1), Some(index + 1)].into_iter().flatten().filter_map(|index| references.get(index)) { + if let Some(slot) = usize::try_from(handle).ok().and_then(|handle| handle_moved_by.get_mut(handle)) { + slot.get_or_insert(offset); + } + } + } + } + for ((position, moved_by), handle_moved_by) in self.0.iter_element_values_mut().zip(&moved_by).zip(&handle_moved_by) { + if let Some(offset) = moved_by.or(*handle_moved_by) { + *position += offset; + } + } + + // The pool drops every end that joined another, and references name the end it joined, renumbered past those dropped + let mut renumbered = Vec::with_capacity(point_count); + let mut kept_count = 0; + for joined in &joined_into { + renumbered.push(kept_count); + kept_count += usize::from(joined.is_none()); + } + let rename = |reference: i64| { + let point = usize::try_from(reference).ok()?; + let point = joined_into.get(point).copied().flatten().unwrap_or(point); + renumbered.get(point).map(|&point| point as i64) + }; + let kept = joined_into.iter().map(Option::is_none); + self.0 = std::mem::take(&mut self.0).into_iter().zip(kept).filter_map(|(point, kept)| kept.then_some(point)).collect(); + + // A straight span whose two ends just joined has nothing left to draw, so it drops out with the reference it departs from + let curve_types: Vec = curve_types_of(paths).collect(); + let mut kept_paths = vec![true; paths.len()]; + for ((path, curve_type), kept_path) in paths.iter_element_values_mut().zip(curve_types).zip(&mut kept_paths) { + let mut collapsed = Vec::new(); + let mut previous: Option<(i64, i64)> = None; + for (index, reference) in path.0.iter_element_values_mut().enumerate() { + let renamed = rename(*reference).unwrap_or(*reference); + if curve_type == CurveType::Linear + && let Some((previous_reference, previous_renamed)) = previous + && previous_reference != *reference + && previous_renamed == renamed + { + collapsed.push(index - 1); + } + + previous = Some((*reference, renamed)); + *reference = renamed; + } + if collapsed.is_empty() { + continue; + } + + let mut collapsed = collapsed.into_iter().peekable(); + path.0 = std::mem::take(&mut path.0) + .into_iter() + .enumerate() + .filter_map(|(index, reference)| collapsed.next_if_eq(&index).is_none().then_some(reference)) + .collect(); + *kept_path = path.0.len() >= 2; + } + if kept_paths.contains(&false) { + *paths = std::mem::take(paths).into_iter().zip(kept_paths).filter_map(|(path, kept)| kept.then_some(path)).collect(); + } + } +} + +/// The path flows, each a range of stored paths: a path, with those after it that only carry it on in another curve type. +fn path_flows(paths: &List) -> Vec> { + let mut flows: Vec> = Vec::new(); + for index in 0..paths.len() { + match flows.last_mut() { + Some(flow) if continues_previous_path(paths, index) => flow.end = index + 1, + _ => flows.push(index..index + 1), + } + } + + flows +} + +/// The pool points a path flow begins and ends at. +fn path_flow_ends(paths: &List, flow: &Range) -> Option<(usize, usize)> { + let start = *paths.element(flow.start)?.0.iter_element_values().as_slice().first()?; + let end = *paths.element(flow.end.checked_sub(1)?)?.0.iter_element_values().as_slice().last()?; + + Some((usize::try_from(start).ok()?, usize::try_from(end).ok()?)) +} + +/// Keeps only the elements flagged true, in order. +fn retain_by(values: &mut Vec, keep: &[bool]) { + let mut flags = keep.iter(); + values.retain(|_| *flags.next().unwrap_or(&false)); +} + +/// All the fixed fields of a point from the working form. +pub(crate) struct Point { + pub id: PointId, + pub position: DVec2, +} + +/// Useful indexes to speed up various operations on the flattened working form. +/// +/// Important: It is the user's responsibility to ensure the indexes remain valid after mutations to the data. +pub(crate) struct VectorIndex { + /// Points and segments form a graph. Store it here in a form amenable to graph algorithms. + /// + /// Currently, segment data is not stored as it is not used, but it could easily be added. + pub(crate) point_graph: UnGraph, + pub(crate) segment_to_edge: FxHashMap, + /// Get the offset from the point ID. + pub(crate) point_to_offset: FxHashMap, + // TODO: faces +} + +impl VectorIndex { + /// Construct a [`VectorIndex`] by building indexes from the given working form. Takes `O(n)` time. + fn build_from(working: &WorkingVector) -> Self { + let point_to_offset = working.point_ids.iter().copied().enumerate().map(|(a, b)| (b, a)).collect::>(); + + let mut point_to_node = FxHashMap::default(); + let mut segment_to_edge = FxHashMap::default(); + + let mut graph = UnGraph::new_undirected(); + + for (&point_id, &position) in working.point_ids.iter().zip(&working.point_positions) { + let idx = graph.add_node(Point { id: point_id, position }); + point_to_node.insert(point_id, idx); + } + + for (segment_index, &segment_id) in working.segment_ids.iter().enumerate() { + let start_id = working.point_ids[working.segment_start[segment_index]]; + let end_id = working.point_ids[working.segment_end[segment_index]]; + let edge = graph.add_edge(point_to_node[&start_id], point_to_node[&end_id], ()); + + segment_to_edge.insert(segment_id, edge); + } + + Self { + point_graph: graph, + segment_to_edge, + point_to_offset, + } + } + + /// Fetch the length of given segment's chord. Takes `O(1)` time. + /// + /// # Panics + /// + /// Will panic if no segment with the given ID is found. + fn segment_chord_length(&self, id: SegmentId) -> f64 { + let edge_idx = self.segment_to_edge[&id]; + let (start, end) = self.point_graph.edge_endpoints(edge_idx).unwrap(); + let start_position = self.point_graph.node_weight(start).unwrap().position; + let end_position = self.point_graph.node_weight(end).unwrap().position; + (start_position - end_position).length() + } + + /// Get the ends of a segment. Takes `O(1)` time. + /// + /// The IDs will be ordered [smallest, largest] so they can be used to find other segments with the same endpoints, regardless of direction. + /// + /// # Panics + /// + /// This function will panic if the ID is not present. + fn segment_ends(&self, id: SegmentId) -> [NodeIndex; 2] { + let (start, end) = self.point_graph.edge_endpoints(self.segment_to_edge[&id]).unwrap(); + if start < end { [start, end] } else { [end, start] } + } + + /// Get the physical location of a point. Takes `O(1)` time. + /// + /// # Panics + /// + /// Will panic if `id` isn't in the data. + fn point_position(&self, id: PointId, working: &WorkingVector) -> DVec2 { + let offset = self.point_to_offset[&id]; + working.point_positions[offset] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vector::contour::contours; + use crate::vector::path::push_path; + use core_types::list::{ATTR_ID, Item}; + + fn pool(positions: &[(f64, f64)]) -> Vector { + let mut vector = Vector::default(); + let mut point_id = PointId::ZERO; + for &(x, y) in positions { + vector.0.push(Item::new_from_element(DVec2::new(x, y)).with_attribute(ATTR_ID, point_id.next_id())); + } + vector + } + + fn near(position: Option<&DVec2>, expected: (f64, f64)) -> bool { + position.is_some_and(|position| position.distance(DVec2::new(expected.0, expected.1)) < 1e-9) + } + + fn references(paths: &List) -> Vec> { + paths.iter_element_values().map(|path| path.0.iter_element_values().copied().collect()).collect() + } + + fn point_ids(vector: &Vector) -> Vec { + vector.0.iter_attribute_values::(ATTR_ID).map(|ids| ids.map(|id| id.inner()).collect()).unwrap_or_default() + } + + // Two lines meeting across a small gap join there, while two close points of another path stay apart since only one is an end + #[test] + fn flow_ends_join_without_touching_other_points() { + let mut vector = pool(&[(0., 0.), (10., 0.), (10.05, 0.), (20., 0.), (0., 10.), (0.05, 10.), (10., 10.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1], []); + push_path(&mut paths, CurveType::Linear, [2, 3], []); + push_path(&mut paths, CurveType::Linear, [4, 5, 6], []); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + + assert_eq!(references(&paths), [vec![0, 1], vec![1, 2], vec![3, 4, 5]]); + assert!(near(vector.0.element(1), (10.025, 0.))); + assert_eq!(contours(vector.0.len(), &paths).len(), 2); + } + + // A path whose end comes back within the distance of its start closes there + #[test] + fn a_path_ending_beside_its_start_closes() { + let mut vector = pool(&[(0., 0.), (10., 0.), (10., 10.), (0.05, 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2, 3], []); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + + assert_eq!(references(&paths), [vec![0, 1, 2, 0]]); + assert_eq!(vector.0.len(), 3); + } + + // A short first segment whose two anchors both join drops out, and the next segment keeps its ID, while a line already of zero length stays + #[test] + fn a_segment_whose_anchors_join_drops_out() { + let mut vector = pool(&[(0., 0.), (0.05, 0.), (10., 0.), (0.05, 10.), (50., 50.)]); + let mut paths = List::new(); + let mut segment_id = SegmentId::ZERO; + push_path(&mut paths, CurveType::Linear, [0, 1, 2], [(); 2].map(|_| segment_id.next_id())); + push_path(&mut paths, CurveType::Linear, [3, 1], [segment_id.next_id()]); + push_path(&mut paths, CurveType::Linear, [4, 4], [segment_id.next_id()]); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + + assert_eq!(references(&paths), [vec![0, 1], vec![2, 0], vec![3, 3]]); + assert_eq!(vector.segment_iter(&paths).map(|(id, ..)| id.inner()).collect::>(), [2, 3, 4]); + assert!(near(vector.0.element(0), (0.025, 0.))); + } + + // A path whose end comes back to its own second anchor, beside its start, closes without a segment of zero length + #[test] + fn a_path_ending_on_its_own_anchor_beside_its_start_closes() { + let mut vector = pool(&[(0., 0.), (0.05, 0.), (10., 0.), (10., 10.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2, 3, 1], []); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + + assert_eq!(references(&paths), [vec![0, 1, 2, 0]]); + assert!(matches!(contours(vector.0.len(), &paths).as_slice(), [contour] if contour.closed)); + } + + // A path flow drawn as a line then a curve joins and closes only at the ends it was drawn with, not at the joint between its curve types + #[test] + fn a_path_in_several_curve_types_joins_only_at_its_ends() { + let mut vector = pool(&[(0., 0.), (10., 0.), (20., 10.), (12., 5.), (18., 8.), (10.05, 0.), (20.05, 10.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1], []); + push_path(&mut paths, CurveType::Cubic, [1, 3, 4, 2], []); + push_path(&mut paths, CurveType::Linear, [5, 6], []); + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + assert_eq!(references(&paths), [vec![0, 1], vec![1, 3, 4, 2], vec![5, 2]]); + + let mut vector = pool(&[(0., 0.), (10., 0.), (12., 5.), (5., 8.), (0.05, 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1], []); + push_path(&mut paths, CurveType::Cubic, [1, 2, 3, 4], []); + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + assert_eq!(references(&paths), [vec![0, 1], vec![1, 2, 3, 0]]); + assert!(matches!(contours(vector.0.len(), &paths).as_slice(), [contour] if contour.closed)); + } + + // A closed path has no ends, so its anchors stay put beside each other and beside another path's end + #[test] + fn a_closed_path_has_no_ends_to_join() { + let positions = [(0., 0.), (10., 0.), (10.05, 0.), (5., 10.), (0.05, 0.), (-10., 0.)]; + let mut vector = pool(&positions); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2, 3, 0], []); + push_path(&mut paths, CurveType::Linear, [5, 4], []); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + + assert_eq!(references(&paths), [vec![0, 1, 2, 3, 0], vec![5, 4]]); + assert_eq!(vector.0.len(), positions.len()); + } + + // Ends a small gap apart in the item's own space stay apart once the transform scales the gap past the distance + #[test] + fn flow_ends_join_by_their_distance_after_the_transform() { + let mut vector = pool(&[(0., 0.), (1., 0.), (1.05, 0.), (2., 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1], []); + push_path(&mut paths, CurveType::Linear, [2, 3], []); + let scale = DAffine2::from_scale(DVec2::splat(10.)); + + vector.weld_flow_ends(&mut paths, scale, 0.1); + assert_eq!(references(&paths), [vec![0, 1], vec![2, 3]]); + + vector.weld_flow_ends(&mut paths, scale, 1.); + assert_eq!(references(&paths), [vec![0, 1], vec![1, 2]]); + assert!(near(vector.0.element(1), (1.025, 0.))); + } + + // Three ends within the distance of the first meet at their average + #[test] + fn three_flow_ends_meet_at_their_average() { + let mut vector = pool(&[(0., 0.), (10., 0.), (10.06, 0.), (20., 0.), (10., 10.), (10., 0.06)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1], []); + push_path(&mut paths, CurveType::Linear, [2, 3], []); + push_path(&mut paths, CurveType::Linear, [4, 5], []); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + + assert_eq!(references(&paths), [vec![0, 1], vec![1, 2], vec![3, 1]]); + assert!(near(vector.0.element(1), (10.02, 0.02))); + assert_eq!(vector.0.len(), 4); + } + + // Dropping a joined point and a collapsed line keeps every other point's ID and every other path's curve type + #[test] + fn joining_flow_ends_keeps_point_ids_and_curve_types() { + let mut vector = pool(&[(0., 0.), (0., 5.), (10., 5.), (10., 0.), (30., 0.), (30.05, 0.), (40., 0.), (45., 5.), (50., 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Cubic, [0, 1, 2, 3], []); + push_path(&mut paths, CurveType::Linear, [4, 5], []); + push_path(&mut paths, CurveType::Quadratic, [6, 7, 8], []); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.1); + + assert_eq!(references(&paths), [vec![0, 1, 2, 3], vec![5, 6, 7]]); + assert_eq!(curve_types_of(&paths).collect::>(), [CurveType::Cubic, CurveType::Quadratic]); + assert_eq!(point_ids(&vector), [1, 2, 3, 4, 5, 7, 8, 9]); + } + + // A cubic end brings the handle beside it along to where it joins, and a straight segment whose two ends join leaves a lone point + #[test] + fn a_cubic_end_moves_with_its_handle_and_a_collapsed_line_goes() { + let mut vector = pool(&[(0., 0.), (0., 5.), (10., 5.), (10., 0.), (10.1, 0.), (20., 0.), (30., 0.), (30.05, 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Cubic, [0, 1, 2, 3], []); + push_path(&mut paths, CurveType::Linear, [4, 5], []); + push_path(&mut paths, CurveType::Linear, [6, 7], []); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.2); + + assert_eq!(references(&paths), [vec![0, 1, 2, 3], vec![3, 4]]); + assert!(near(vector.0.element(3), (10.05, 0.))); + assert!(near(vector.0.element(2), (10.05, 5.))); + assert_eq!(vector.0.len(), 6); + } +} diff --git a/node-graph/libraries/vector-types/src/vector/misc.rs b/node-graph/libraries/vector-types/src/vector/misc.rs index 43e84b7f38b..55f7a2c9c21 100644 --- a/node-graph/libraries/vector-types/src/vector/misc.rs +++ b/node-graph/libraries/vector-types/src/vector/misc.rs @@ -132,10 +132,16 @@ pub enum ArcType { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, DynAny, node_macro::ChoiceType)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[widget(Radio)] -pub enum MergeByDistanceAlgorithm { +pub enum WeldMethod { + /// Any points within the distance of each other join. #[default] - Spatial, - Topological, + #[cfg_attr(feature = "serde", serde(alias = "Spatial"))] + All, + /// Each segment shorter than the distance collapses, joining its two ends. + #[cfg_attr(feature = "serde", serde(alias = "Topological"))] + Connected, + /// Only the ends of open path flows join, leaving every other anchor where it is. + FlowEnds, } #[repr(C)] @@ -618,7 +624,7 @@ graphene_hash::impl_via_hash!( RowsOrColumns, GridType, ArcType, - MergeByDistanceAlgorithm, + WeldMethod, ExtrudeJoiningAlgorithm, PointSpacingType, SpiralType, diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 5ec2555862c..ba1470678e9 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -4,7 +4,7 @@ use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_OPACITY use glam::DAffine2; use graphic_types::Appearance; use graphic_types::graphic::bake_paint_transforms; -use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; +use graphic_types::vector_types::vector::algorithms::weld::WeldExt; use graphic_types::vector_types::vector::{Path, no_paths, paths_from_list_row_attributes}; use graphic_types::{Graphic, Vector}; use linesweeper::topology::Topology; @@ -61,7 +61,7 @@ fn boolean_operation( // Clean up the boolean operation result by merging duplicated points let merge_transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); let mut result_paths = result_vector_list.attribute::>(ATTR_PATHS, 0).cloned().unwrap_or_default(); - result_vector_list.element_mut(0).unwrap().merge_by_distance_spatial(&mut result_paths, merge_transform, 0.0001); + result_vector_list.element_mut(0).unwrap().weld_all(&mut result_paths, merge_transform, 0.0001); result_vector_list.set_attribute(ATTR_PATHS, 0, result_paths); } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 016116766ea..22777a30266 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -26,12 +26,12 @@ use vector_types::gradient::{build_transform_with_y_preservation, initial_gradie use vector_types::vector::algorithms::bezpath_algorithms::{ self, TValue, bezpath_area_centroid_and_area, bezpath_length_centroid_and_length, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath, }; -use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; use vector_types::vector::algorithms::offset_bezpath::offset_bezpath; use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open}; +use vector_types::vector::algorithms::weld::WeldExt; use vector_types::vector::misc::{ - BezierHandles, CentroidType, ExtrudeJoiningAlgorithm, InterpolationDistribution, ManipulatorGroup, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, Tangent, - bezpath_from_manipulator_groups, dvec2_to_point, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, + BezierHandles, CentroidType, ExtrudeJoiningAlgorithm, InterpolationDistribution, ManipulatorGroup, PointSpacingType, RowsOrColumns, Tangent, WeldMethod, bezpath_from_manipulator_groups, + dvec2_to_point, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, }; use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{ @@ -768,27 +768,36 @@ fn round_subpath_corners(subpath: &BezPath, radius: f64, roundness: f64, edge_le Some(rounded) } -#[node_macro::node(name("Merge by Distance"), category("Vector: Modifier"), icon("NodeMergeByDistance"), path(core_types::vector))] -fn merge_by_distance( +/// Joins points lying within a distance of each other into one, connecting the paths they belong to. +#[node_macro::node(category("Vector: Modifier"), icon("NodeWeld"), path(core_types::vector))] +fn weld( _: impl Ctx, - #[implementations(Graphic, Vector)] content: Item, + /// The vector with points to join. + #[implementations(Graphic, Vector)] + content: Item, + /// How close points must be to join. #[unit(" px")] #[default(0.1)] #[hard(0.0001..)] distance: Item, - algorithm: Item, + /// Which points may join: any within the distance, the two ends of each segment shorter than it, or only the ends of open path flows. + method: Item, ) -> Item { - let (distance, algorithm) = (*distance.element(), *algorithm.element()); + let (distance, method) = (*distance.element(), *method.element()); V::map_vector_items(content, |content| { let mut content = content; - match algorithm { - MergeByDistanceAlgorithm::Spatial => { + match method { + WeldMethod::All => { + let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); + edit_item_geometry(&mut content, |vector, paths| vector.weld_all(paths, transform, distance)); + } + WeldMethod::Connected => edit_item_geometry(&mut content, |vector, paths| vector.weld_connected(paths, distance)), + WeldMethod::FlowEnds => { let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); - edit_item_geometry(&mut content, |vector, paths| vector.merge_by_distance_spatial(paths, transform, distance)); + edit_item_geometry(&mut content, |vector, paths| vector.weld_flow_ends(paths, transform, distance)); } - MergeByDistanceAlgorithm::Topological => edit_item_geometry(&mut content, |vector, paths| vector.merge_by_distance_topological(paths, distance)), } content From 08d657f344b1eed390183fc40ad5cb5c26de23e4 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 20 Oct 2026 22:18:57 -0700 Subject: [PATCH 2/3] Add an Unweld node that splits shared points apart, separating path flows or segments --- .../data_panel/data_panel_message_handler.rs | 6 +- .../document/node_graph/node_inspector.rs | 5 +- .../messages/portfolio/document_migration.rs | 81 ++++- frontend/src/icons.ts | 4 +- node-graph/graph-craft/src/document/value.rs | 1 + .../interpreted-executor/src/node_registry.rs | 3 +- .../src/vector/algorithms/weld.rs | 308 +++++++++++++++++- .../libraries/vector-types/src/vector/misc.rs | 16 + .../vector-types/src/vector/vector_types.rs | 2 +- node-graph/nodes/vector/src/vector_nodes.rs | 178 ++++++---- 10 files changed, 525 insertions(+), 79 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 c75d634da0f..3c88e38faf9 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 @@ -24,7 +24,7 @@ use graphene_std::text_nodes::{StringCapitalization, TextDenomination}; use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ - ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, WeldMethod, + ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, UnweldMethod, WeldMethod, }; use graphene_std::vector::style::{ DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin, @@ -210,6 +210,7 @@ fn generate_layout(introspected_data: &Arc, List, List, + List, List, List, List, @@ -264,6 +265,7 @@ fn generate_layout(introspected_data: &Arc, Item, Item, + Item, Item, Item, Item, @@ -982,6 +984,7 @@ impl_table_item_layout_for_choice_enum!( StrokeAlign, StrokeCap, WeldMethod, + UnweldMethod, ExtrudeJoiningAlgorithm, PointSpacingType, StringCapitalization, @@ -1154,6 +1157,7 @@ macro_rules! known_item_types { StrokeAlign, StrokeCap, WeldMethod, + UnweldMethod, ExtrudeJoiningAlgorithm, PointSpacingType, StringCapitalization, diff --git a/editor/src/messages/portfolio/document/node_graph/node_inspector.rs b/editor/src/messages/portfolio/document/node_graph/node_inspector.rs index db5ff4e8f74..33b96bf4916 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_inspector.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_inspector.rs @@ -32,7 +32,9 @@ use graphene_std::text_nodes::{StringCapitalization, TextDenomination}; use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::misc::BooleanOperation; -use graphene_std::vector::misc::{ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, WeldMethod}; +use graphene_std::vector::misc::{ + ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, UnweldMethod, WeldMethod, +}; use graphene_std::vector::style::{ FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation, @@ -429,6 +431,7 @@ pub(crate) fn inspector_row_from_type(node_id: NodeId, index: usize, ty: &Type, Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).inspector_row(), diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 66292e518b4..60c2cae3cf2 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -801,11 +801,12 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &["graphene_core::vector::vector_nodes::SplitPathNode", "graphene_core::vector::SplitPathNode"], }, NodeReplacement { - node: graphene_std::vector::cut_segments::IDENTIFIER, + node: graphene_std::vector::unweld::IDENTIFIER, aliases: &[ "graphene_core::vector::vector_nodes::SplitSegmentsNode", "graphene_core::vector::SplitSegmentsNode", "graphene_core::vector::CutSegmentsNode", + "core_types::vector::CutSegmentsNode", ], }, NodeReplacement { @@ -2820,6 +2821,18 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], reset_default_display_name(document, node_id, network_path, "Spatial Merge by Distance"); } + // Cut Segments became Unweld, keeping its content and taking the All method that splits every anchor as it did + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::unweld::IDENTIFIER) && inputs_count == 1 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + node_template.inputs = vec![ + node.inputs[0].clone(), + NodeInput::value(TaggedValue::UnweldMethod(graphene_std::vector::misc::UnweldMethod::All), false), + ]; + document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + reset_default_display_name(document, node_id, network_path, "Cut Segments"); + reset_default_display_name(document, node_id, network_path, "Split Segments"); + } + if reference == DefinitionIdentifier::Network("Sample Points".into()) && inputs_count == 5 { let mut node_template = resolve_network_node_type("Sample Polyline")?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); @@ -4418,4 +4431,70 @@ mod tests { } } + // Cut Segments nodes open as Unweld nodes splitting every anchor, still wired to the node feeding them + #[test] + fn cut_segments_nodes_open_as_unweld_nodes() { + use crate::messages::portfolio::document_storage_io::load_legacy_document; + use crate::test_utils::capture_logged_errors; + use graph_craft::application_io::resource::HashMapResourceStorage; + use graphene_std::vector::misc::UnweldMethod; + + let unweld = graphene_std::vector::unweld::IDENTIFIER; + let definition = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(unweld.clone())).expect("the Unweld definition should exist"); + let rectangle = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER)).expect("the Rectangle definition should exist"); + + // The current Cut Segments node and its oldest form, which stored their titles and single content input + let content = definition.default_node_template().inputs[0].clone(); + let legacy_node = |identifier: &'static str, display_name: &str| NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(ProtoNodeIdentifier::new(identifier)), + inputs: vec![content.clone()], + display_name: display_name.to_string(), + input_metadata: vec![("Content", "").into()], + ..Default::default() + }; + let (upstream, cut_segments, split_segments) = (NodeId(1), NodeId(2), NodeId(3)); + let mut document = DocumentMessageHandler::default(); + document.network_interface.insert_node(upstream, rectangle.default_node_template(), &[]); + document + .network_interface + .insert_node(cut_segments, legacy_node("core_types::vector::CutSegmentsNode", "Cut Segments"), &[]); + document + .network_interface + .insert_node(split_segments, legacy_node("graphene_core::vector::vector_nodes::SplitSegmentsNode", "Split Segments"), &[]); + document.network_interface.set_input(&InputConnector::node_at_index(cut_segments, 0), NodeInput::node(upstream, 0), &[]); + + let serialized = document.serialize_document(); + assert!(!serialized.contains("UnweldNode"), "the saved document should name only the old node identifiers"); + + let (opened, errors) = capture_logged_errors(|| load_legacy_document(serialized, &HashMapResourceStorage::new(), false)); + let mut opened = opened.expect("the document should open"); + assert!(errors.is_empty(), "opening the document logged errors: {errors:?}"); + + let definition_inputs: Vec<(String, String)> = definition + .node_template + .input_metadata + .iter() + .map(|metadata| (metadata.persistent_metadata.input_name.clone(), metadata.persistent_metadata.input_description.clone())) + .collect(); + for node_id in [cut_segments, split_segments] { + let interface = &opened.network_interface; + let node = interface.document_node(&node_id, &[]).expect("the node should still exist"); + assert_eq!(node.implementation, DocumentNodeImplementation::ProtoNode(unweld.clone()), "node {node_id}"); + assert_eq!( + node.inputs.get(1).and_then(|input| input.as_value()), + Some(&TaggedValue::UnweldMethod(UnweldMethod::All)), + "node {node_id}" + ); + assert_eq!(interface.display_name(&node_id, &[]), "Unweld", "node {node_id}"); + + let inputs: Vec<(String, String)> = (0..node.inputs.len()).map(|index| interface.displayed_input_name_and_description(&node_id, index, &[])).collect(); + assert_eq!(inputs, definition_inputs, "node {node_id}"); + } + + // The wire from the upstream node survives in both directions + let cut_segments_content = opened.network_interface.document_node(&cut_segments, &[]).map(|node| node.inputs[0].clone()); + assert!(matches!(cut_segments_content, Some(NodeInput::Node { node_id, output_index: 0, .. }) if node_id == upstream)); + let outward_wires = opened.network_interface.outward_wires(&[]).and_then(|wires| wires.get(&OutputConnector::node(upstream, 0)).cloned()); + assert_eq!(outward_wires.as_deref(), Some([InputConnector::node_at_index(cut_segments, 0)].as_slice())); + } } diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 3d2b9803478..eeefc32e764 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -162,7 +162,6 @@ import NodeCrossProduct from "/../branding/assets/16x16-nodes/node-cross-product import NodeCurves from "/../branding/assets/16x16-nodes/node-curves.svg"; import NodeCustomNode from "/../branding/assets/16x16-nodes/node-custom-node.svg"; import NodeCutPath from "/../branding/assets/16x16-nodes/node-cut-path.svg"; -import NodeCutSegments from "/../branding/assets/16x16-nodes/node-cut-segments.svg"; import NodeDashPattern from "/../branding/assets/16x16-nodes/node-dash-pattern.svg"; import NodeDecimate from "/../branding/assets/16x16-nodes/node-decimate.svg"; import NodeDecodeImage from "/../branding/assets/16x16-nodes/node-decode-image.svg"; @@ -376,6 +375,7 @@ import NodeText from "/../branding/assets/16x16-nodes/node-text.svg"; import NodeThreshold from "/../branding/assets/16x16-nodes/node-threshold.svg"; import NodeTransform from "/../branding/assets/16x16-nodes/node-transform.svg"; import NodeTriangulate from "/../branding/assets/16x16-nodes/node-triangulate.svg"; +import NodeUnweld from "/../branding/assets/16x16-nodes/node-unweld.svg"; import NodeUploadTexture from "/../branding/assets/16x16-nodes/node-upload-texture.svg"; import NodeVec2ToNumbers from "/../branding/assets/16x16-nodes/node-vec2-to-numbers.svg"; import NodeVec2Value from "/../branding/assets/16x16-nodes/node-vec2-value.svg"; @@ -443,7 +443,6 @@ export const ICON_16X16_NODES = { NodeCurves: { svg: NodeCurves, size: 16 }, NodeCustomNode: { svg: NodeCustomNode, size: 16 }, NodeCutPath: { svg: NodeCutPath, size: 16 }, - NodeCutSegments: { svg: NodeCutSegments, size: 16 }, NodeDashPattern: { svg: NodeDashPattern, size: 16 }, NodeDecimate: { svg: NodeDecimate, size: 16 }, NodeDecodeImage: { svg: NodeDecodeImage, size: 16 }, @@ -657,6 +656,7 @@ export const ICON_16X16_NODES = { NodeThreshold: { svg: NodeThreshold, size: 16 }, NodeTransform: { svg: NodeTransform, size: 16 }, NodeTriangulate: { svg: NodeTriangulate, size: 16 }, + NodeUnweld: { svg: NodeUnweld, size: 16 }, NodeUploadTexture: { svg: NodeUploadTexture, size: 16 }, NodeVec2ToNumbers: { svg: NodeVec2ToNumbers, size: 16 }, NodeVec2Value: { svg: NodeVec2Value, size: 16 }, diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 04355810217..8d76fa69ad3 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -582,6 +582,7 @@ tagged_value! { RowsOrColumns(vector::misc::RowsOrColumns), #[serde(alias = "MergeByDistanceAlgorithm")] WeldMethod(vector::misc::WeldMethod), + UnweldMethod(vector::misc::UnweldMethod), ExtrudeJoiningAlgorithm(vector::misc::ExtrudeJoiningAlgorithm), PointSpacingType(vector::misc::PointSpacingType), SpiralType(vector::misc::SpiralType), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index c1f4a5abcfb..5585fafa74b 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -23,7 +23,7 @@ use graphene_std::text_nodes::{StringCapitalization, TextDenomination}; use graphene_std::transfer_curve::TransferCurve; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType}; use graphene_std::vector::misc::{ - ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, WeldMethod, + ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, PointSpacingType, RowsOrColumns, SpiralType, UnweldMethod, WeldMethod, }; use graphene_std::vector::style::{DashPattern, GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector, VectorModification}; @@ -322,6 +322,7 @@ fn node_registry() -> HashMap, transform: DAffine2, distance: f64); /// Joins the ends of open path flows lying within the distance of each other, measured after the transform, and leaves every other anchor where it is. fn weld_flow_ends(&mut self, paths: &mut List, transform: DAffine2, distance: f64); + /// Gives every segment, or every path flow, its own copies of the points it shares, by the method chosen. + fn unweld(&mut self, paths: &mut List, method: UnweldMethod); } impl WeldExt for Vector { @@ -391,6 +395,141 @@ impl WeldExt for Vector { *paths = std::mem::take(paths).into_iter().zip(kept_paths).filter_map(|(path, kept)| kept.then_some(path)).collect(); } } + + // A path flow stored in several curve types counts as one and keeps any point it meets more than once, in O(points + references) + fn unweld(&mut self, paths: &mut List, method: UnweldMethod) { + if method == UnweldMethod::All { + separate_segments(self, paths); + return; + } + let point_count = self.0.len(); + + // Each stored path's path flow, and whether that flow is open with two ends of its own + let flows = path_flows(paths); + let mut flow_of = vec![0; paths.len()]; + let mut open = vec![false; flows.len()]; + for (index, flow) in flows.iter().enumerate() { + if let Some(stored) = flow_of.get_mut(flow.clone()) { + stored.fill(index); + } + open[index] = path_flow_ends(paths, flow).is_some_and(|(start, end)| start != end); + } + let is_end = |path: usize, reference: usize, reference_count: usize| { + let index = flow_of[path]; + open[index] && ((path == flows[index].start && reference == 0) || (path + 1 == flows[index].end && reference + 1 == reference_count)) + }; + + // The first path flow to use each point, and under Flow Ends, which flows pass through it and which ends there first + let flow_ends_only = method == UnweldMethod::FlowEnds; + let mut first_through = vec![None; point_count]; + let mut first_ending = if flow_ends_only { vec![None; point_count] } else { Vec::new() }; + let mut passes_through = FxHashSet::default(); + for (path, stored) in paths.iter_element_values().enumerate() { + let references = stored.0.iter_element_values().as_slice(); + for (reference, &point) in references.iter().enumerate() { + let Some(point) = usize::try_from(point).ok().filter(|&point| point < point_count) else { + continue; + }; + let holder = flow_of[path]; + + if !flow_ends_only { + first_through[point].get_or_insert(holder); + } else if is_end(path, reference, references.len()) { + first_ending[point].get_or_insert(holder); + } else { + first_through[point].get_or_insert(holder); + passes_through.insert((point, holder)); + } + } + } + + // Each path flow other than a point's keeper takes its own copy, and a copied anchor takes the next ID. + // Under Flow Ends, a flow keeps every point it passes through, and a point only ends meet stays with the first flow to end there. + let keeps = |point: usize, holder: usize, end: bool| { + if flow_ends_only { + !end || passes_through.contains(&(point, holder)) || (first_through[point].is_none() && first_ending[point] == Some(holder)) + } else { + first_through[point] == Some(holder) + } + }; + let mut latest_id = largest_point_id(&self.0); + let mut copies: FxHashMap<(usize, usize), i64> = FxHashMap::default(); + for (path, stored) in paths.iter_element_values_mut().enumerate() { + let reference_count = stored.0.len(); + for (reference, point) in stored.0.iter_element_values_mut().enumerate() { + let Some(original) = usize::try_from(*point).ok().filter(|&point| point < point_count) else { + continue; + }; + let holder = flow_of[path]; + if keeps(original, holder, flow_ends_only && is_end(path, reference, reference_count)) { + continue; + } + + *point = *copies.entry((original, holder)).or_insert_with(|| { + let copy = self.0.len(); + let Some(item) = self.0.clone_item(original) else { return original as i64 }; + self.0.push(item); + + let named = self.0.attribute::(ATTR_ID, original).is_some_and(|id| *id != PointId::UNNAMED); + if named && let Some(latest) = &mut latest_id { + self.0.set_attribute(ATTR_ID, copy, latest.next_id()); + } + + copy as i64 + }); + } + } + } +} + +/// Rebuilds the paths as one per segment, each taking its own copy of any point an earlier segment used, in O(points + references). +/// Every anchor becomes an end, which drops what only an anchor between two segments carries, like smoothness. +fn separate_segments(vector: &mut Vector, paths: &mut List) { + let point_count = vector.0.len(); + let in_pool = |reference: i64| usize::try_from(reference).ok().filter(|&point| point < point_count); + + let mut used = vec![false; point_count]; + let mut latest_id = largest_point_id(&vector.0); + let mut separate_point = |pool: &mut List, reference: i64| { + let point = in_pool(reference)?; + if !std::mem::replace(&mut used[point], true) { + return u32::try_from(point).ok(); + } + + let copy = pool.len(); + pool.push(pool.clone_item(point)?); + if pool.attribute::(ATTR_ID, point).is_some_and(|id| *id != PointId::UNNAMED) + && let Some(latest) = &mut latest_id + { + pool.set_attribute(ATTR_ID, copy, latest.next_id()); + } + u32::try_from(copy).ok() + }; + + let mut segments = Vec::new(); + for (path, curve_type) in paths.iter_element_values().zip(curve_types_of(paths)) { + let references = path.0.iter_element_values().as_slice(); + let ids = path.0.iter_attribute_values::(ATTR_ID).map(|ids| ids.as_slice()).unwrap_or_default(); + let stride = curve_type.stride(); + + for span in 0..path.segment_count(curve_type) { + let Some(span_references) = references.get(span * stride..=span * stride + stride) else { + continue; + }; + + // A span naming a point outside the pool is left out before it copies any of its points + if !span_references.iter().all(|&reference| in_pool(reference).is_some()) { + continue; + } + let Some(separated) = span_references.iter().map(|&reference| separate_point(&mut vector.0, reference)).collect::>>() else { + continue; + }; + segments.push((curve_type, separated, ids.get(span * stride).copied().into_iter().collect())); + } + } + + *paths = List::new(); + push_paths(paths, segments); } /// The path flows, each a range of stored paths: a path, with those after it that only carry it on in another curve type. @@ -693,4 +832,169 @@ mod tests { assert!(near(vector.0.element(2), (10.05, 5.))); assert_eq!(vector.0.len(), 6); } + + fn diamond() -> (Vector, List) { + let vector = pool(&[(0., 0.), (-10., 10.), (0., 20.), (10., 10.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2], []); + push_path(&mut paths, CurveType::Linear, [0, 3, 2], []); + (vector, paths) + } + + // Two halves of a diamond welded at both ends come apart into two open paths, each copied anchor named next in sequence + #[test] + fn unwelding_shared_points_gives_each_path_its_own_points() { + let (mut vector, mut paths) = diamond(); + + vector.unweld(&mut paths, UnweldMethod::SharedPoints); + + assert_eq!(references(&paths), [vec![0, 1, 2], vec![4, 3, 5]]); + assert_eq!(point_ids(&vector), [1, 2, 3, 4, 5, 6]); + assert!(near(vector.0.element(4), (0., 0.)) && near(vector.0.element(5), (0., 20.))); + assert!(contours(vector.0.len(), &paths).iter().all(|contour| !contour.closed)); + } + + // A shape mixing lines and curves is stored as a path per curve type, which still count as one path flow + #[test] + fn unwelding_leaves_a_path_flow_in_several_curve_types_whole() { + let mut bezpath = kurbo::BezPath::new(); + bezpath.move_to((0., 0.)); + bezpath.line_to((10., 0.)); + bezpath.curve_to((15., 5.), (15., 10.), (10., 15.)); + bezpath.close_path(); + let (mut vector, mut paths) = Vector::from_bezpath(&bezpath); + assert_eq!(paths.len(), 3); + let before = references(&paths); + + vector.unweld(&mut paths, UnweldMethod::SharedPoints); + + assert_eq!(references(&paths), before); + assert_eq!(contours(vector.0.len(), &paths).len(), 1); + } + + // Splitting only flow ends leaves two flows crossing at a shared anchor joined there, and parts a third flow ending on it + #[test] + fn unwelding_flow_ends_keeps_crossings() { + let positions = [(-10., 0.), (10., 0.), (0., -10.), (0., 10.), (0., 0.), (10., 10.)]; + let crossing = || { + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 4, 1], []); + push_path(&mut paths, CurveType::Linear, [2, 4, 3], []); + push_path(&mut paths, CurveType::Linear, [5, 4], []); + (pool(&positions), paths) + }; + + let (mut vector, mut paths) = crossing(); + vector.unweld(&mut paths, UnweldMethod::FlowEnds); + assert_eq!(references(&paths), [vec![0, 4, 1], vec![2, 4, 3], vec![5, 6]]); + + let (mut vector, mut paths) = crossing(); + vector.unweld(&mut paths, UnweldMethod::SharedPoints); + assert_eq!(references(&paths), [vec![0, 4, 1], vec![2, 6, 3], vec![5, 7]]); + } + + // Splitting every anchor turns a closed triangle into three segments, each keeping the ID that named it + #[test] + fn unwelding_all_separates_every_segment() { + let mut vector = pool(&[(0., 0.), (10., 0.), (0., 10.)]); + let mut paths = List::new(); + let mut segment_id = SegmentId::ZERO; + let ids = [(); 3].map(|_| segment_id.next_id()); + push_path(&mut paths, CurveType::Linear, [0, 1, 2, 0], ids); + + vector.unweld(&mut paths, UnweldMethod::All); + + assert_eq!(references(&paths), [vec![0, 1], vec![3, 2], vec![4, 5]]); + let segment_ids: Vec = vector.segment_iter(&paths).map(|(id, ..)| id.inner()).collect(); + assert_eq!(segment_ids, [1, 2, 3]); + assert_eq!(contours(vector.0.len(), &paths).len(), 3); + } + + // Welding flow ends at the smallest distance puts back what unwelding flow ends split apart where only flow ends met + #[test] + fn welding_flow_ends_undoes_unwelding() { + let (mut vector, mut paths) = diamond(); + + vector.unweld(&mut paths, UnweldMethod::FlowEnds); + assert_eq!(references(&paths), [vec![0, 1, 2], vec![4, 3, 5]]); + assert_eq!(vector.0.len(), 6); + assert!(contours(vector.0.len(), &paths).iter().all(|contour| !contour.closed)); + + vector.weld_flow_ends(&mut paths, DAffine2::IDENTITY, 0.0001); + + let (original, original_paths) = diamond(); + assert_eq!(references(&paths), references(&original_paths)); + assert_eq!(vector.0.len(), original.0.len()); + assert!(contours(vector.0.len(), &paths).iter().all(|contour| contour.closed)); + } + + // A closed path takes one copy of the point it closes on for both of its references to it, so it stays closed + #[test] + fn unwelding_a_closed_path_keeps_it_closed() { + let mut vector = pool(&[(0., 0.), (-10., 10.), (0., 20.), (10., -10.), (20., 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2], []); + push_path(&mut paths, CurveType::Linear, [0, 3, 4, 0], []); + + vector.unweld(&mut paths, UnweldMethod::SharedPoints); + + assert_eq!(references(&paths), [vec![0, 1, 2], vec![5, 3, 4, 5]]); + let closed: Vec = contours(vector.0.len(), &paths).iter().map(|contour| contour.closed).collect(); + assert_eq!(closed, [false, true]); + } + + // A copied anchor takes the next ID while a copied handle stays unnamed, like every handle + #[test] + fn unwelding_leaves_copied_handles_unnamed() { + let mut vector = Vector::default(); + let mut point_id = PointId::ZERO; + for (x, y, is_anchor) in [(0., 0., true), (0., 10., false), (10., 10., false), (10., 0., true)] { + let id = if is_anchor { point_id.next_id() } else { PointId::UNNAMED }; + vector.0.push(Item::new_from_element(DVec2::new(x, y)).with_attribute(ATTR_ID, id)); + } + let mut paths = List::new(); + push_path(&mut paths, CurveType::Cubic, [0, 1, 2, 3], []); + push_path(&mut paths, CurveType::Cubic, [3, 2, 1, 0], []); + + vector.unweld(&mut paths, UnweldMethod::SharedPoints); + + assert_eq!(references(&paths), [vec![0, 1, 2, 3], vec![4, 5, 6, 7]]); + let unnamed = PointId::UNNAMED.inner(); + assert_eq!(point_ids(&vector), [1, unnamed, unnamed, 2, 3, unnamed, unnamed, 4]); + } + + // Splitting only flow ends keeps a point with a flow that passes through it before ending there, parting the other flow ending there + #[test] + fn unwelding_flow_ends_keeps_an_end_with_the_flow_passing_through_it() { + let positions = [(0., 0.), (10., 0.), (20., 0.), (20., 10.), (10., -10.)]; + let ending_on_itself = || { + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [4, 1], []); + push_path(&mut paths, CurveType::Linear, [0, 1, 2, 3, 1], []); + (pool(&positions), paths) + }; + + let (mut vector, mut paths) = ending_on_itself(); + vector.unweld(&mut paths, UnweldMethod::FlowEnds); + assert_eq!(references(&paths), [vec![4, 5], vec![0, 1, 2, 3, 1]]); + + // Splitting every shared point instead keeps it with the path stored first + let (mut vector, mut paths) = ending_on_itself(); + vector.unweld(&mut paths, UnweldMethod::SharedPoints); + assert_eq!(references(&paths), [vec![4, 1], vec![0, 5, 2, 3, 5]]); + } + + // A segment naming a point outside the pool is left out without copying the points it shares + #[test] + fn unwelding_all_leaves_out_a_segment_outside_the_pool() { + let mut vector = pool(&[(0., 0.), (10., 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1], []); + push_path(&mut paths, CurveType::Linear, [1, 99], []); + + vector.unweld(&mut paths, UnweldMethod::All); + + assert_eq!(references(&paths), [vec![0, 1]]); + assert_eq!(vector.0.len(), 2); + } } diff --git a/node-graph/libraries/vector-types/src/vector/misc.rs b/node-graph/libraries/vector-types/src/vector/misc.rs index 55f7a2c9c21..3138fbe2445 100644 --- a/node-graph/libraries/vector-types/src/vector/misc.rs +++ b/node-graph/libraries/vector-types/src/vector/misc.rs @@ -144,6 +144,21 @@ pub enum WeldMethod { FlowEnds, } +#[repr(C)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, DynAny, node_macro::ChoiceType)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[widget(Radio)] +pub enum UnweldMethod { + /// Every anchor is split, so each segment stands alone. + All, + /// Every point that more than one path flow uses is split, so each path flow becomes a path of its own. + #[default] + SharedPoints, + /// Only the shared points where a path flow ends are split, so path flows passing through a shared point stay joined. + FlowEnds, +} + #[repr(C)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, DynAny, node_macro::ChoiceType)] @@ -625,6 +640,7 @@ graphene_hash::impl_via_hash!( GridType, ArcType, WeldMethod, + UnweldMethod, ExtrudeJoiningAlgorithm, PointSpacingType, SpiralType, diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index 65c509f057c..b02f3c5978e 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -87,7 +87,7 @@ impl Vector { } /// The largest ID in the pool's point ID sequence, which a pool has once it holds point IDs at all. -fn largest_point_id(pool: &List) -> Option { +pub(crate) fn largest_point_id(pool: &List) -> Option { let ids = pool.iter_attribute_values::(ATTR_ID)?; // An ID attribute that names nothing yet counts as a sequence at zero, which documents saved with edits to such geometry rely on diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 22777a30266..2dc37328fce 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -30,8 +30,8 @@ use vector_types::vector::algorithms::offset_bezpath::offset_bezpath; use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open}; use vector_types::vector::algorithms::weld::WeldExt; use vector_types::vector::misc::{ - BezierHandles, CentroidType, ExtrudeJoiningAlgorithm, InterpolationDistribution, ManipulatorGroup, PointSpacingType, RowsOrColumns, Tangent, WeldMethod, bezpath_from_manipulator_groups, - dvec2_to_point, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, + BezierHandles, CentroidType, ExtrudeJoiningAlgorithm, InterpolationDistribution, ManipulatorGroup, PointSpacingType, RowsOrColumns, Tangent, UnweldMethod, WeldMethod, + bezpath_from_manipulator_groups, dvec2_to_point, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, }; use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{ @@ -804,6 +804,26 @@ fn weld( }) } +/// Separates path flows or segments where they meet, giving each its own copies of the points they share. +#[node_macro::node(category("Vector: Modifier"), icon("NodeUnweld"), path(core_types::vector))] +fn unweld( + _: impl Ctx, + /// The vector with path flows to separate. + #[implementations(Graphic, Vector)] + content: Item, + /// Which points are split: every anchor so each segment stands alone, every point path flows share, or only those where a path flow ends. + method: Item, +) -> Item { + let method = *method.element(); + + V::map_vector_items(content, |content| { + let mut content = content; + edit_item_geometry(&mut content, |vector, paths| vector.unweld(paths, method)); + + content + }) +} + pub mod extrude_algorithms { use super::{next_working_point_id, next_working_segment_id, push_working_point, push_working_segment}; use core_types::list::List; @@ -2300,74 +2320,6 @@ fn cut_path( }) } -/// Cuts path segments into separate disconnected pieces where each is a distinct subpath. -#[node_macro::node(category("Vector: Modifier"), icon("NodeCutSegments"), path(core_types::vector))] -fn cut_segments(_: impl Ctx, #[implementations(Graphic, Vector)] content: Item) -> Item { - V::map_vector_items(content, |content| { - let mut content = content; - // Iterate through every segment and make a copy of each of its endpoints, then reassign each segment's endpoints to its own unique point copy - edit_item_geometry(&mut content, |vector, paths| { - let mut working = WorkingVector::flatten(vector, paths); - - let points_count = working.point_ids.len(); - let segments_count = working.segment_ids.len(); - - let mut point_usages = vec![0_usize; points_count]; - - // Count how many times each point is used as an endpoint of the segments - let start_points = working.segment_start.clone(); - let end_points = working.segment_end.clone(); - for (&start, &end) in start_points.iter().zip(end_points.iter()) { - point_usages[start] += 1; - point_usages[end] += 1; - } - - let mut new_point_ids = Vec::new(); - let mut new_point_positions = Vec::new(); - let mut offset_sum: usize = 0; - let mut points_with_new_offsets = Vec::with_capacity(points_count); - - // Build the new anchor list with the original points, but with duplications based on their extra usages by the segments - for (index, (&point_id, &point)) in working.point_ids.iter().zip(&working.point_positions).enumerate() { - // Ensure at least one usage to preserve free-floating points not connected to any segments - let usage_count = point_usages[index].max(1); - - new_point_ids.push(point_id); - new_point_positions.push(point); - - for i in 1..usage_count { - new_point_ids.push(point_id.generate_from_hash(i as u64)); - new_point_positions.push(point); - } - - points_with_new_offsets.push(offset_sum); - offset_sum += usage_count; - } - - // Reconcile the segments with the new points - working.point_ids = new_point_ids; - working.point_positions = new_point_positions; - for original_segment_index in 0..segments_count { - let original_point_start_index = start_points[original_segment_index]; - let original_point_end_index = end_points[original_segment_index]; - - point_usages[original_point_start_index] -= 1; - point_usages[original_point_end_index] -= 1; - - let start_usage = points_with_new_offsets[original_point_start_index] + point_usages[original_point_start_index]; - let end_usage = points_with_new_offsets[original_point_end_index] + point_usages[original_point_end_index]; - - working.segment_start[original_segment_index] = start_usage; - working.segment_end[original_segment_index] = end_usage; - } - - working.reweave(vector, paths); - }); - - content - }) -} - /// The position, tangent direction, and normal direction at a point along a path, split into separate node outputs. #[derive(Debug, Clone, PartialEq, dyn_any::DynAny, node_macro::Destructure)] pub struct PathEvaluation { @@ -4704,4 +4656,90 @@ mod test { "a subpath too short to have corners should stay where it was" ); } + + /// Two halves of a diamond drawn top to bottom and welded at both ends. + fn welded_diamond() -> Item { + let mut vector = Vector::default(); + for position in [DVec2::new(0., 0.), DVec2::new(-10., 10.), DVec2::new(0., 20.), DVec2::new(10., 10.)] { + vector.0.push(Item::new_from_element(position)); + } + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2], []); + push_path(&mut paths, CurveType::Linear, [0, 3, 2], []); + + Item::new_from_element(vector).with_attribute(ATTR_PATHS, paths) + } + + // The welded diamond reads as one closed contour, and once unwelded as two open paths + #[test] + fn unwelded_paths_are_read_one_at_a_time() { + let diamond = welded_diamond(); + let unwelded = super::unweld((), diamond.clone(), item(UnweldMethod::SharedPoints)); + + assert!(*super::path_is_closed((), diamond.clone(), item(0)).element()); + assert!(!*super::path_is_closed((), unwelded.clone(), item(0)).element()); + + assert_eq!(super::separate_subpaths((), diamond.clone()).len(), 1); + assert_eq!(super::separate_subpaths((), unwelded.clone()).len(), 2); + assert_eq!(super::separate_subpaths((), super::unweld((), diamond.clone(), item(UnweldMethod::All))).len(), 4); + + // Halfway along the whole contour is the diamond's far tip, while halfway along the first path alone is its left corner + let halfway = super::evaluate_path((), diamond, item(0.5), item(false), item(false)); + assert!(halfway.position.element().distance(DVec2::new(0., 20.)) < 1e-3, "{:?}", halfway.position); + let halfway = super::evaluate_path((), unwelded, item(0.5), item(false), item(false)); + assert!(halfway.position.element().distance(DVec2::new(-10., 10.)) < 1e-3, "{:?}", halfway.position); + } + + // Sampling the welded diamond goes once around its closed contour, and once unwelded, along each open path on its own + #[test] + fn unwelded_paths_are_sampled_one_at_a_time() { + let sample = |content: Item| super::sample_polyline((), content, item(PointSpacingType::Quantity), item(100.), item(5), item(0.), item(0.), item(false)); + let polylines = |sampled: Item| sampled.element().stroke_bezpath_iter(item_paths(&sampled)).collect::>(); + + let welded = polylines(sample(welded_diamond())); + assert!(matches!(welded.as_slice(), [polyline] if polyline.elements().last() == Some(&PathEl::ClosePath))); + + // Each half runs from the top tip to the bottom tip by way of its own side corner + let unwelded = polylines(sample(super::unweld((), welded_diamond(), item(UnweldMethod::SharedPoints)))); + assert_eq!(unwelded.len(), 2); + for (polyline, side_corner) in unwelded.iter().zip([DVec2::new(-10., 10.), DVec2::new(10., 10.)]) { + let points: Vec = polyline.elements().iter().filter_map(|element| element.end_point()).map(point_to_dvec2).collect(); + assert_eq!(points.len(), 5); + assert!( + points[0].distance(DVec2::ZERO) < 1e-3 && points[2].distance(side_corner) < 1e-3 && points[4].distance(DVec2::new(0., 20.)) < 1e-3, + "{points:?}" + ); + } + } + + // Morphing reads the welded diamond as one closed contour and the unwelded one as two open paths, both as the control path and as the shapes morphed + #[test] + fn unwelded_paths_are_morphed_one_at_a_time() { + let unwelded = || super::unweld((), welded_diamond(), item(UnweldMethod::SharedPoints)); + + // Halfway along the whole contour is the diamond's far tip, while halfway along the first path alone is its left corner + let squares = || embed_vectors([(); 3].map(|_| vector_item_from_bezpath(Rect::new(0., 0., 1., 1.).to_path(DEFAULT_ACCURACY))).into_iter().collect()); + let morph_along = |path: Item| super::morph((), squares(), item(0.5), item(false), item(InterpolationDistribution::Objects), path); + let position = |morphed: Item| morphed.attribute_cloned_or_default::(ATTR_TRANSFORM).translation; + assert!(position(morph_along(welded_diamond())).distance(DVec2::new(0., 20.)) < 1e-3); + assert!(position(morph_along(unwelded())).distance(DVec2::new(-10., 10.)) < 1e-3); + + // Midway between two copies of the diamond, the welded one stays one closed contour and the unwelded one two open paths + let morph_between = |diamond: Item| { + super::morph( + (), + embed_vectors([diamond.clone(), diamond].into_iter().collect()), + item(0.5), + item(false), + item(InterpolationDistribution::Objects), + Item::default(), + ) + }; + let closed = |morphed: Item| { + let subpaths = morphed.element().stroke_bezpath_iter(item_paths(&morphed)); + subpaths.map(|subpath| subpath.elements().last() == Some(&PathEl::ClosePath)).collect::>() + }; + assert_eq!(closed(morph_between(welded_diamond())), [true]); + assert_eq!(closed(morph_between(unwelded())), [false, false]); + } } From bc7015d187eddf08e34f3cc8899bdb81ff39792b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 20 Oct 2026 22:38:23 -0700 Subject: [PATCH 3/3] Skip welding all points when the transform collapses the geometry --- .../vector-types/src/vector/algorithms/weld.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs b/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs index 5a11faf0dd0..9f57b6add38 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/weld.rs @@ -140,6 +140,10 @@ impl WeldExt for Vector { } fn weld_all(&mut self, paths: &mut List, transform: DAffine2, distance: f64) { + if !transform.matrix2.determinant().is_normal() { + return; + } + let mut working = WorkingVector::flatten(self, paths); let point_count = working.point_positions.len(); @@ -784,6 +788,20 @@ mod tests { assert!(near(vector.0.element(1), (1.025, 0.))); } + // A transform that collapses the item has no inverse to bring joined points back through, so nothing joins + #[test] + fn welding_all_under_a_collapsed_transform_changes_nothing() { + let mut vector = pool(&[(0., 0.), (10., 0.), (10.05, 0.), (20., 0.)]); + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1], []); + push_path(&mut paths, CurveType::Linear, [2, 3], []); + + vector.weld_all(&mut paths, DAffine2::from_scale(DVec2::new(1., 0.)), 0.1); + + assert_eq!(references(&paths), [vec![0, 1], vec![2, 3]]); + assert!(vector.0.iter_element_values().all(|position| position.is_finite())); + } + // Three ends within the distance of the first meet at their average #[test] fn three_flow_ends_meet_at_their_average() {