From 23a5e563c71cf2cf0d33bb92c923fd617a943964 Mon Sep 17 00:00:00 2001 From: Richard Dodd Date: Tue, 18 Feb 2025 14:11:21 +0000 Subject: [PATCH] add `merge_by_distance` node --- Cargo.lock | 24 +++- node-graph/gcore/Cargo.toml | 9 +- .../gcore/src/vector/merge_by_distance.rs | 109 ++++++++++++++++++ node-graph/gcore/src/vector/mod.rs | 2 + node-graph/gcore/src/vector/vector_data.rs | 31 +++++ .../src/vector/vector_data/attributes.rs | 43 +++++-- .../gcore/src/vector/vector_data/indexed.rs | 105 +++++++++++++++++ node-graph/gcore/src/vector/vector_nodes.rs | 27 ++++- 8 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 node-graph/gcore/src/vector/merge_by_distance.rs create mode 100644 node-graph/gcore/src/vector/vector_data/indexed.rs diff --git a/Cargo.lock b/Cargo.lock index e04ac9aeae..317b113f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1730,6 +1730,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.0.35" @@ -2459,8 +2465,10 @@ dependencies = [ "node-macro", "num-derive", "num-traits", + "petgraph 0.7.1", "rand 0.9.0", "rand_chacha 0.9.0", + "rustc-hash 2.1.0", "rustybuzz 0.20.1", "serde", "serde_json", @@ -3881,7 +3889,7 @@ dependencies = [ "hexf-parse", "indexmap 2.7.1", "log", - "petgraph", + "petgraph 0.6.5", "rustc-hash 1.1.0", "spirv", "termcolor", @@ -4623,7 +4631,17 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset", + "fixedbitset 0.4.2", + "indexmap 2.7.1", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", "indexmap 2.7.1", ] @@ -7158,7 +7176,7 @@ dependencies = [ "memchr", "nom", "once_cell", - "petgraph", + "petgraph 0.6.5", ] [[package]] diff --git a/node-graph/gcore/Cargo.toml b/node-graph/gcore/Cargo.toml index 80fc2462e0..d929a67636 100644 --- a/node-graph/gcore/Cargo.toml +++ b/node-graph/gcore/Cargo.toml @@ -28,10 +28,7 @@ std = [ "image", "reflections", ] -reflections = [ - "alloc", - "ctor", -] +reflections = ["alloc", "ctor"] serde = [ "dep:serde", "glam/serde", @@ -67,7 +64,7 @@ rand_chacha = { workspace = true, optional = true } bezier-rs = { workspace = true, optional = true } kurbo = { workspace = true, optional = true } base64 = { workspace = true, optional = true } -vello = { workspace = true, optional = true } +vello = { workspace = true, optional = true } wgpu = { workspace = true, optional = true } specta = { workspace = true, optional = true } rustybuzz = { workspace = true, optional = true } @@ -80,6 +77,8 @@ image = { workspace = true, optional = true, default-features = false, features "png", ] } math-parser = { path = "../../libraries/math-parser" } +rustc-hash = { workspace = true } +petgraph = "0.7.1" [dev-dependencies] # Workspace dependencies diff --git a/node-graph/gcore/src/vector/merge_by_distance.rs b/node-graph/gcore/src/vector/merge_by_distance.rs new file mode 100644 index 0000000000..9862def79e --- /dev/null +++ b/node-graph/gcore/src/vector/merge_by_distance.rs @@ -0,0 +1,109 @@ +//! Collapse all nodes with all edges < distance + +use glam::DVec2; +use petgraph::prelude::UnGraphMap; +use rustc_hash::FxHashSet; + +use super::PointId; +use super::VectorData; + +use super::VectorDataIndex; + +impl VectorData { + /// Collapse all nodes with all edges < distance + pub(crate) fn merge_by_distance(&mut self, distance: f64) { + // treat self as an undirected graph with point = node, and segment = edge + let indices = VectorDataIndex::build_from(self); + + // Graph that will contain only short edges. References data graph + let mut short_edges = UnGraphMap::new(); + for seg_id in self.segment_ids().iter().copied() { + let length = indices.segment_chord_length(seg_id); + if length < distance { + let [start, end] = indices.segment_ends(seg_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, seg_id); + } + } + + // Now group connected segments - all will be collapsed to a single point. + // Note: there are a few algos 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, self)).sum(); + sum / collapse_set.len() as f64 + }) + .collect::>(); + + // we collect all points up and delete them at the end, so that our indices aren't invalidated + 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.into_iter()) { + // remove any segments where both endpoints are in the collapse set + segments_to_delete.extend(self.segment_domain.iter().filter_map(|(id, start_offset, end_offset, _)| { + let start = self.point_domain.ids()[start_offset]; + let end = self.point_domain.ids()[end_offset]; + if collapse_set.contains(&start) && collapse_set.contains(&end) { + Some(id) + } else { + None + } + })); + + // Delete all points but the first (arbitrary). Set that point's position to the + // average of the points, update segments to use replace all points with collapsed + // point. + + // Unwrap: set created from connected algo will not be empty + let first_id = collapse_set.iter().copied().next().unwrap(); + // `first_id` the point we will collapse to. + collapse_set.remove(&first_id); + let first_offset = indices.point_to_offset[&first_id]; + + // look for segments with ends in collapse_set and replace them with the point we are collapsing to + for (_, start_offset, end_offset, handles) in self.segment_domain.iter_mut() { + let start_id = self.point_domain.ids()[*start_offset]; + let end_id = self.point_domain.ids()[*end_offset]; + + // moved points (only need to update Bezier handles) + if start_id == first_id { + let point_position = self.point_domain.positions[*start_offset]; + handles.move_start(average_pos - point_position); + } + if end_id == first_id { + let point_position = self.point_domain.positions[*end_offset]; + handles.move_end(average_pos - point_position); + } + + // removed points + if collapse_set.contains(&start_id) { + let point_position = self.point_domain.positions[*start_offset]; + *start_offset = first_offset; + handles.move_start(average_pos - point_position); + } + if collapse_set.contains(&end_id) { + let point_position = self.point_domain.positions[*end_offset]; + *end_offset = first_offset; + handles.move_end(average_pos - point_position); + } + } + // This must come after iterating segments, so segments involving the point at + // `first_offset` have their handles updated correctly. + self.point_domain.positions[first_offset] = average_pos; + + points_to_delete.extend(collapse_set) + } + self.segment_domain.retain(|id| !segments_to_delete.contains(id), usize::MAX); + self.point_domain.retain(&mut self.segment_domain, |id| !points_to_delete.contains(id)); + + log::debug!("{:?}", self.segment_domain.handles()); + + // TODO: don't forget about faces + } +} diff --git a/node-graph/gcore/src/vector/mod.rs b/node-graph/gcore/src/vector/mod.rs index e729eede53..46ec7c3b6d 100644 --- a/node-graph/gcore/src/vector/mod.rs +++ b/node-graph/gcore/src/vector/mod.rs @@ -12,3 +12,5 @@ mod vector_nodes; pub use vector_nodes::*; pub use bezier_rs; + +mod merge_by_distance; diff --git a/node-graph/gcore/src/vector/vector_data.rs b/node-graph/gcore/src/vector/vector_data.rs index 8502b7178b..fffa8d02b6 100644 --- a/node-graph/gcore/src/vector/vector_data.rs +++ b/node-graph/gcore/src/vector/vector_data.rs @@ -1,6 +1,9 @@ mod attributes; +mod indexed; mod modification; + pub use attributes::*; +pub use indexed::VectorDataIndex; pub use modification::*; use super::style::{PathStyle, Stroke}; @@ -12,6 +15,7 @@ use dyn_any::DynAny; use core::borrow::Borrow; use glam::{DAffine2, DVec2}; +use std::borrow::Cow; // TODO: Eventually remove this migration document upgrade code pub fn migrate_vector_data<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { @@ -35,6 +39,8 @@ pub type VectorDataTable = Instances; /// [VectorData] is passed between nodes. /// It contains a list of subpaths (that may be open or closed), a transform, and some style information. +/// +/// Segments are connected if they share end points. #[derive(Clone, Debug, PartialEq, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct VectorData { @@ -65,6 +71,24 @@ impl core::hash::Hash for VectorData { } } +impl<'a> From<&'a VectorData> for Cow<'a, VectorData> { + fn from(value: &'a VectorData) -> Self { + Self::Borrowed(value) + } +} + +impl<'a> From<&'a mut VectorData> for Cow<'a, VectorData> { + fn from(value: &'a mut VectorData) -> Self { + Self::Borrowed(value) + } +} + +impl From for Cow<'static, VectorData> { + fn from(value: VectorData) -> Self { + Self::Owned(value) + } +} + impl VectorData { /// An empty subpath with no data, an identity transform, and a black fill. pub const fn empty() -> Self { @@ -241,6 +265,13 @@ impl VectorData { index.flat_map(|index| self.segment_domain.connected_points(index).map(|index| self.point_domain.ids()[index])) } + /// A slice all segment IDs + /// + /// Convenience function + pub fn segment_ids(&self) -> &[SegmentId] { + self.segment_domain.ids() + } + /// Enumerate all segments that start at the point. pub fn start_connected(&self, point: PointId) -> impl Iterator + '_ { let index = [self.point_domain.resolve_id(point)].into_iter().flatten(); diff --git a/node-graph/gcore/src/vector/vector_data/attributes.rs b/node-graph/gcore/src/vector/vector_data/attributes.rs index cb9b7b1db2..9f9e70b22c 100644 --- a/node-graph/gcore/src/vector/vector_data/attributes.rs +++ b/node-graph/gcore/src/vector/vector_data/attributes.rs @@ -1,8 +1,10 @@ use crate::vector::vector_data::{HandleId, VectorData, VectorDataTable}; use crate::vector::ConcatElement; +use bezier_rs::BezierHandles; use dyn_any::DynAny; +use core::iter::zip; use glam::{DAffine2, DVec2}; use std::collections::HashMap; use std::hash::{Hash, Hasher}; @@ -82,7 +84,7 @@ impl core::hash::BuildHasher for NoHashBuilder { /// Stores data which is per-point. Each point is merely a position and can be used in a point cloud or to for a bézier path. In future this will be extendable at runtime with custom attributes. pub struct PointDomain { id: Vec, - positions: Vec, + pub(crate) positions: Vec, } impl core::hash::Hash for PointDomain { @@ -117,7 +119,8 @@ impl PointDomain { id_map.push(new_index); new_index += 1; } else { - id_map.push(usize::MAX); // A placeholder for invalid ids. This is checked after the segment domain is modified. + // A placeholder for invalid ids. This is checked after the segment domain is modified. + id_map.push(usize::MAX); } } @@ -184,6 +187,11 @@ impl PointDomain { *pos = transform.transform_point2(*pos); } } + + /// Iterate over point IDs and positions + pub fn iter(&self) -> impl Iterator + '_ { + self.ids().iter().copied().zip(self.positions().iter().copied()) + } } #[derive(Clone, Debug, Default, PartialEq, Hash, DynAny)] @@ -359,6 +367,7 @@ impl SegmentDomain { }) } + /// Get index from ID, `O(n)` fn id_to_index(&self, id: SegmentId) -> Option { debug_assert_eq!(self.ids.len(), self.handles.len()); debug_assert_eq!(self.ids.len(), self.start_point.len()); @@ -413,11 +422,35 @@ impl SegmentDomain { pub(crate) fn connected_count(&self, point: usize) -> usize { self.all_connected(point).count() } + + /// Iterate over segments in the domain. + /// + /// tuple is: (id, start point, end point, handles) + pub(crate) fn iter(&self) -> impl Iterator + '_ { + let ids = self.ids.iter().copied(); + let start_point = self.start_point.iter().copied(); + let end_point = self.end_point.iter().copied(); + let handles = self.handles.iter().copied(); + zip(ids, zip(start_point, zip(end_point, handles))).map(|(id, (start_point, (end_point, handles)))| (id, start_point, end_point, handles)) + } + + /// Iterate over segments in the domain. + /// + /// tuple is: (id, start point, end point, handles) + pub(crate) fn iter_mut(&mut self) -> impl Iterator + '_ { + let ids = self.ids.iter_mut(); + let start_point = self.start_point.iter_mut(); + let end_point = self.end_point.iter_mut(); + let handles = self.handles.iter_mut(); + zip(ids, zip(start_point, zip(end_point, handles))).map(|(id, (start_point, (end_point, handles)))| (id, start_point, end_point, handles)) + } } #[derive(Clone, Debug, Default, PartialEq, Hash, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -/// Stores data which is per-region. A region is an enclosed area composed of a range of segments from the [`SegmentDomain`] that can be given a fill. In future this will be extendable at runtime with custom attributes. +/// Stores data which is per-region. A region is an enclosed area composed of a range +/// of segments from the [`SegmentDomain`] that can be given a fill. In future this will +/// be extendable at runtime with custom attributes. pub struct RegionDomain { ids: Vec, segment_range: Vec>, @@ -457,10 +490,6 @@ impl RegionDomain { self.fill.push(fill); } - fn _resolve_id(&self, id: RegionId) -> Option { - self.ids.iter().position(|&check_id| check_id == id) - } - pub fn next_id(&self) -> RegionId { self.ids.iter().copied().max_by(|a, b| a.0.cmp(&b.0)).map(|mut id| id.next_id()).unwrap_or(RegionId::ZERO) } diff --git a/node-graph/gcore/src/vector/vector_data/indexed.rs b/node-graph/gcore/src/vector/vector_data/indexed.rs new file mode 100644 index 0000000000..6c6d3b9d8b --- /dev/null +++ b/node-graph/gcore/src/vector/vector_data/indexed.rs @@ -0,0 +1,105 @@ +use glam::DVec2; +use petgraph::graph::{EdgeIndex, NodeIndex, UnGraph}; +use rustc_hash::FxHashMap; + +use super::{PointId, SegmentId, VectorData}; + +/// Useful indexes to speed up various operations on `VectorData`. +/// +/// Important: It is the user's responsibility to ensure the indexes remain valid after uutations to the data. +pub struct VectorDataIndex { + /// Points and segments form a graph. Store it here in a form amenable to graph algos + /// + /// 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 offset from point id + pub(crate) point_to_offset: FxHashMap, + // TODO: faces +} + +/// All the fixed fields of a point from the point domain. +pub struct Point { + pub id: PointId, + pub position: DVec2, +} + +impl VectorDataIndex { + /// Build indexes (`O(n)` operation). + pub fn build_from(data: &VectorData) -> Self { + let point_to_offset = data.point_domain.ids().iter().copied().enumerate().map(flip).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 data.point_domain.iter() { + let idx = graph.add_node(Point { id: point_id, position }); + point_to_node.insert(point_id, idx); + } + for (segment_id, start_offset, end_offset, ..) in data.segment_domain.iter() { + let start_id = data.point_domain.ids()[start_offset]; + let end_id = data.point_domain.ids()[end_offset]; + 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. + /// + /// `O(1)` + /// + /// # Panics + /// + /// Will panic if not segment with the given ID is found. + pub 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 + /// + /// The IDs will be ordered [smallest, largest] so they can be used to find other segments with + /// the same endpoints, regardless of direction. + /// + /// O(1) + /// + /// # Panics + /// + /// This function will panic if the ID is not present. + pub 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 + /// + /// O(1) + /// + /// # Panics + /// + /// Will panic if `id` isn't in the data. + pub fn point_position(&self, id: PointId, data: &VectorData) -> DVec2 { + let offset = self.point_to_offset[&id]; + data.point_domain.positions()[offset] + } +} + +/// flip fields in 2-tuple +fn flip((t, u): (T, U)) -> (U, T) { + (u, t) +} diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs index be1a9f4a16..1589e275b4 100644 --- a/node-graph/gcore/src/vector/vector_nodes.rs +++ b/node-graph/gcore/src/vector/vector_nodes.rs @@ -1099,9 +1099,9 @@ fn bevel_algorithm(mut vector_data: VectorData, distance: f64) -> VectorData { bezier.split(bezier_rs::TValue::Parametric(parametric))[1] } - /// Produces a list that correspons with the point id. The value is how many segments are connected. + /// Produces a list that corresponds with the point id. The value is how many segments are connected. fn segments_connected_count(vector_data: &VectorData) -> Vec { - // Count the number of segments connectign to each point. + // Count the number of segments connecting to each point. let mut segments_connected_count = vec![0; vector_data.point_domain.ids().len()]; for &point_index in vector_data.segment_domain.start_point().iter().chain(vector_data.segment_domain.end_point()) { segments_connected_count[point_index] += 1; @@ -1220,6 +1220,29 @@ async fn bevel( VectorDataTable::new(result) } +#[node_macro::node(category("Vector"), path(graphene_core::vector))] +async fn merge_by_distance( + #[implementations( + (), + Footprint, + )] + footprint: F, + #[implementations( + () -> VectorDataTable, + Footprint -> VectorDataTable, + )] + source: impl Node, + #[default(10.)] distance: Length, +) -> VectorDataTable { + let source = source.eval(footprint).await; + let source = source.one_item(); + + let mut result = source.clone(); + result.merge_by_distance(distance); + + VectorDataTable::new(result) +} + #[node_macro::node(category("Vector"), path(graphene_core::vector))] async fn area(_: (), vector_data: impl Node) -> f64 { let vector_data = vector_data.eval(Footprint::default()).await;