From f6ff968af484aa590f6d43f89ee789a066fb07e5 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 20 Aug 2026 23:57:53 -0700 Subject: [PATCH 1/8] Add the Path and CurveType primitives for the pool-based Vector format --- node-graph/libraries/core-types/src/list.rs | 13 +++++ .../libraries/vector-types/src/vector/mod.rs | 2 + .../libraries/vector-types/src/vector/path.rs | 54 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 node-graph/libraries/vector-types/src/vector/path.rs diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 534a98079e2..f2b381a466e 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -105,6 +105,19 @@ pub const ATTR_DIAMETER: &str = "diameter"; pub const ATTR_HARDNESS: &str = "hardness"; /// Brush stroke item's per-pass paint coverage from `0.` to `1.` (`f64`). pub const ATTR_FLOW: &str = "flow"; +/// `List` of connected strokes woven through the point pool, on the item holding a `Vector`. +/// Absent means the pool is a point cloud. +pub const ATTR_PATHS: &str = "paths"; +/// Path's `CurveType` naming the stride its point references weave in, on the `List` item +/// holding the path (implicit default `Linear`). +pub const ATTR_CURVE_TYPE: &str = "curve_type"; +/// Point reference's `bool` tool state marking the tangent pair at this anchor occurrence as smooth, +/// on the `List` inside a `Path` (implicit default `false`). Editing tools keep a smooth pair +/// colinear. Rendering derives continuity from actual tangent geometry and does not read this. +pub const ATTR_SMOOTH: &str = "smooth"; +/// Point's stable `PointId` for external addressing (modifications, tools, animation), on a +/// `Vector`'s point pool. Indices are the internal addressing, so IDs are opt-in. +pub const ATTR_ID: &str = "id"; /// Text item's font size in document-space units (`f64`, implicit default `24.`). pub const ATTR_FONT_SIZE: &str = "font_size"; /// Text item's font, as a `Resource` of the loaded font file. diff --git a/node-graph/libraries/vector-types/src/vector/mod.rs b/node-graph/libraries/vector-types/src/vector/mod.rs index edc01337cd7..71652af3cc3 100644 --- a/node-graph/libraries/vector-types/src/vector/mod.rs +++ b/node-graph/libraries/vector-types/src/vector/mod.rs @@ -1,12 +1,14 @@ pub mod algorithms; pub mod click_target; pub mod misc; +mod path; pub mod reference_point; pub mod style; mod vector_attributes; mod vector_modification; mod vector_types; +pub use path::*; pub use reference_point::*; pub use vector_attributes::*; pub use vector_modification::*; diff --git a/node-graph/libraries/vector-types/src/vector/path.rs b/node-graph/libraries/vector-types/src/vector/path.rs new file mode 100644 index 00000000000..924b6068dbf --- /dev/null +++ b/node-graph/libraries/vector-types/src/vector/path.rs @@ -0,0 +1,54 @@ +use core_types::list::List; +use graphene_hash::CacheHash; + +/// The curve family a path's point references weave in, fixing the stride of its reference pattern. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, CacheHash)] +pub enum CurveType { + /// Straight segments between consecutive anchors, with every reference an anchor. + #[default] + Linear, + /// Quadratic Bézier segments, with one handle reference between each pair of anchors. + Quadratic, + /// Cubic Bézier segments, with two handle references between each pair of anchors. + Cubic, +} + +impl CurveType { + /// Returns the number of references stepping from one anchor to the next: the segment's handle count plus one. + pub fn stride(&self) -> usize { + match self { + Self::Linear => 1, + Self::Quadratic => 2, + Self::Cubic => 3, + } + } +} + +impl std::fmt::Display for CurveType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Linear => write!(f, "Linear"), + Self::Quadratic => write!(f, "Quadratic"), + Self::Cubic => write!(f, "Cubic"), + } + } +} + +/// One connected stroke of geometry: an ordered list of references (indices into the owning `Vector`'s point pool) +/// woven in its curve type's stride order. Welding is two paths, or two ends of one path, referencing the same anchor. +/// Per-reference data such as `ATTR_SMOOTH` rides this list's attributes. +#[derive(Clone, Debug, Default, PartialEq, CacheHash)] +pub struct Path(pub List); + +impl Path { + /// Whether this path is closed, indicated by its first and last references naming the same pool point. + pub fn is_closed(&self) -> bool { + let len = self.0.len(); + len > 1 && self.0.element(0) == self.0.element(len - 1) + } + + /// Whether the reference at the given index is an anchor (rather than a handle) under the given curve type. + pub fn is_anchor_reference(&self, index: usize, curve_type: CurveType) -> bool { + index.is_multiple_of(curve_type.stride()) + } +} From 2885fd84e52c53d5022b74b0d7c8c4202669cca3 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 21 Aug 2026 00:14:07 -0700 Subject: [PATCH 2/8] Resolve path segments and emit contours against the Vector point pool --- .../libraries/vector-types/src/vector/path.rs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/node-graph/libraries/vector-types/src/vector/path.rs b/node-graph/libraries/vector-types/src/vector/path.rs index 924b6068dbf..a44427ff331 100644 --- a/node-graph/libraries/vector-types/src/vector/path.rs +++ b/node-graph/libraries/vector-types/src/vector/path.rs @@ -1,5 +1,8 @@ +use super::misc::dvec2_to_point; use core_types::list::List; +use glam::DVec2; use graphene_hash::CacheHash; +use kurbo::{CubicBez, Line, PathSeg, QuadBez}; /// The curve family a path's point references weave in, fixing the stride of its reference pattern. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, CacheHash)] @@ -51,4 +54,89 @@ impl Path { pub fn is_anchor_reference(&self, index: usize, curve_type: CurveType) -> bool { index.is_multiple_of(curve_type.stride()) } + + /// The number of segments this path draws, one fewer than its anchor count when open and equal to it when closed. + pub fn segment_count(&self, curve_type: CurveType) -> usize { + self.0.len().saturating_sub(1) / curve_type.stride() + } + + /// The pool position named by the reference at `index`, or `None` if either lookup falls out of bounds. + pub fn position(&self, pool: &List, index: usize) -> Option { + pool.element(*self.0.element(index)? as usize).copied() + } + + /// The segment at `index`, resolved against the point pool. + pub fn segment(&self, pool: &List, curve_type: CurveType, index: usize) -> Option { + let start = index * curve_type.stride(); + let point = |offset: usize| self.position(pool, start + offset).map(dvec2_to_point); + + Some(match curve_type { + CurveType::Linear => PathSeg::Line(Line::new(point(0)?, point(1)?)), + CurveType::Quadratic => PathSeg::Quad(QuadBez::new(point(0)?, point(1)?, point(2)?)), + CurveType::Cubic => PathSeg::Cubic(CubicBez::new(point(0)?, point(1)?, point(2)?, point(3)?)), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core_types::list::Item; + + fn pool(positions: &[(f64, f64)]) -> List { + positions.iter().map(|&(x, y)| Item::new_from_element(DVec2::new(x, y))).collect() + } + + fn path(references: &[u32]) -> Path { + Path(references.iter().map(|&reference| Item::new_from_element(reference)).collect()) + } + + #[test] + fn open_linear_path() { + let pool = pool(&[(0., 0.), (10., 0.), (10., 10.)]); + let path = path(&[0, 1, 2]); + + assert!(!path.is_closed()); + assert_eq!(path.segment_count(CurveType::Linear), 2); + assert_eq!(path.segment(&pool, CurveType::Linear, 1), Some(PathSeg::Line(Line::new((10., 0.), (10., 10.))))); + assert_eq!(path.segment(&pool, CurveType::Linear, 2), None); + } + + // A closed path repeats its first reference last, so anchors and segments count alike + #[test] + fn closed_cubic_path() { + let pool = pool(&[(0., 0.), (5., 0.), (10., 5.), (10., 10.), (10., 15.), (5., 20.)]); + let path = path(&[0, 1, 2, 3, 4, 5, 0]); + + assert!(path.is_closed()); + assert_eq!(path.segment_count(CurveType::Cubic), 2); + assert_eq!( + path.segment(&pool, CurveType::Cubic, 1), + Some(PathSeg::Cubic(CubicBez::new((10., 10.), (10., 15.), (5., 20.), (0., 0.)))) + ); + } + + // Anchor occurrences fall on stride multiples, and the references between them are handles + #[test] + fn anchor_references_by_stride() { + let path = path(&[0, 1, 2, 3]); + + assert!(path.is_anchor_reference(0, CurveType::Cubic)); + assert!(!path.is_anchor_reference(1, CurveType::Cubic)); + assert!(!path.is_anchor_reference(2, CurveType::Cubic)); + assert!(path.is_anchor_reference(3, CurveType::Cubic)); + assert!(path.is_anchor_reference(2, CurveType::Quadratic)); + assert!(path.is_anchor_reference(2, CurveType::Linear)); + } + + // A reference naming a point the pool does not have drops its segment instead of panicking + #[test] + fn dangling_reference_skips_its_segment() { + let pool = pool(&[(0., 0.), (10., 0.)]); + let path = path(&[0, 1, 99]); + + assert_eq!(path.segment_count(CurveType::Linear), 2); + assert!(path.segment(&pool, CurveType::Linear, 0).is_some()); + assert_eq!(path.segment(&pool, CurveType::Linear, 1), None); + } } From 361f5c51b9113f03475d8d667485603150ec7487 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 21 Aug 2026 21:38:00 -0700 Subject: [PATCH 3/8] Read a vector's paths and their curve types off its holder item --- .../libraries/vector-types/src/vector/path.rs | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/node-graph/libraries/vector-types/src/vector/path.rs b/node-graph/libraries/vector-types/src/vector/path.rs index a44427ff331..d086b8c146f 100644 --- a/node-graph/libraries/vector-types/src/vector/path.rs +++ b/node-graph/libraries/vector-types/src/vector/path.rs @@ -1,5 +1,5 @@ use super::misc::dvec2_to_point; -use core_types::list::List; +use core_types::list::{ATTR_CURVE_TYPE, ATTR_PATHS, Item, List}; use glam::DVec2; use graphene_hash::CacheHash; use kurbo::{CubicBez, Line, PathSeg, QuadBez}; @@ -78,10 +78,24 @@ impl Path { } } +/// The paths an item carries beside a vector element, absent when the pool is a bare point cloud. +pub fn paths_from_item_attributes(item: &Item) -> Option<&List> { + item.attribute(ATTR_PATHS) +} + +/// The paths a list carries at `index` beside a vector element, absent when the pool is a bare point cloud. +pub fn paths_from_list_row_attributes(list: &List, index: usize) -> Option<&List> { + list.attribute(ATTR_PATHS, index) +} + +/// The curve type the path at `index` weaves in. +pub fn curve_type_at(paths: &List, index: usize) -> CurveType { + paths.attribute_cloned_or_default(ATTR_CURVE_TYPE, index) +} + #[cfg(test)] mod tests { use super::*; - use core_types::list::Item; fn pool(positions: &[(f64, f64)]) -> List { positions.iter().map(|&(x, y)| Item::new_from_element(DVec2::new(x, y))).collect() @@ -139,4 +153,51 @@ mod tests { assert!(path.segment(&pool, CurveType::Linear, 0).is_some()); assert_eq!(path.segment(&pool, CurveType::Linear, 1), None); } + + // Four corners draw four sides from five references, the last repeating the first to close the shape + #[test] + fn closed_rectangle() { + let pool = pool(&[(-36.25, -17.5), (36.25, -17.5), (36.25, 17.5), (-36.25, 17.5)]); + let mut paths = List::new(); + paths.push(Item::new_from_element(path(&[0, 1, 2, 3, 0]))); + paths.set_attribute(ATTR_CURVE_TYPE, 0, CurveType::Linear); + + let rectangle = paths.element(0).unwrap(); + assert!(rectangle.is_closed()); + assert_eq!(rectangle.segment_count(CurveType::Linear), 4); + + let closing_side = rectangle.segment(&pool, curve_type_at(&paths, 0), 3); + assert_eq!(closing_side, Some(PathSeg::Line(Line::new((-36.25, 17.5), (-36.25, -17.5))))); + } + + // Connectivity rides the holder, so a pool with a holder that declares no paths is a point cloud + #[test] + fn paths_ride_the_holder() { + let mut item = Item::new_from_element(pool(&[(0., 0.), (10., 0.)])); + assert!(paths_from_item_attributes(&item).is_none()); + + let mut paths = List::new(); + paths.push(Item::new_from_element(path(&[0, 1]))); + item.set_attribute(ATTR_PATHS, paths); + + assert_eq!(paths_from_item_attributes(&item).map(List::len), Some(1)); + assert_eq!(curve_type_at(paths_from_item_attributes(&item).unwrap(), 0), CurveType::Linear); + } + + // Two merged rectangles keep their own paths on their own items + #[test] + fn each_list_item_carries_its_own_paths() { + let mut vectors = List::new(); + for _ in 0..2 { + let mut paths = List::new(); + paths.push(Item::new_from_element(path(&[0, 1, 2, 3, 0]))); + vectors.push(Item::new_from_element(pool(&[(0., 0.), (1., 0.), (1., 1.), (0., 1.)])).with_attribute(ATTR_PATHS, paths)); + } + + assert_eq!(vectors.len(), 2); + for index in 0..vectors.len() { + let paths = paths_from_list_row_attributes(&vectors, index).unwrap(); + assert_eq!(paths.element(0).unwrap().segment_count(CurveType::Linear), 4); + } + } } From e172a36be3a92f29f786a818d41ce016d83fc9e1 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 21 Aug 2026 01:40:42 -0700 Subject: [PATCH 4/8] Weave bezpath contours into pool points and stride-uniform paths --- .../src/vector/algorithms/consts.rs | 4 + .../libraries/vector-types/src/vector/path.rs | 242 +++++++++++++++++- .../src/vector/vector_modification.rs | 5 +- 3 files changed, 245 insertions(+), 6 deletions(-) diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/consts.rs b/node-graph/libraries/vector-types/src/vector/algorithms/consts.rs index 46eec7326bc..15b93a5e839 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/consts.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/consts.rs @@ -6,3 +6,7 @@ pub(crate) const MAX_ABSOLUTE_DIFFERENCE: f64 = 1e-3; /// Maximum distance at which two points are treated as one and the same point. pub(crate) const MAX_COINCIDENT_POINT_DISTANCE: f64 = 1e-7; + +/// Distance below which a path's closing point is treated as coincident with its start point. +/// Matches Kurbo's default path accuracy, so points within an offset operation's own precision are not split into separate anchors. +pub(crate) const CLOSE_POINT_TOLERANCE: f64 = 1e-6; diff --git a/node-graph/libraries/vector-types/src/vector/path.rs b/node-graph/libraries/vector-types/src/vector/path.rs index d086b8c146f..e8458020af2 100644 --- a/node-graph/libraries/vector-types/src/vector/path.rs +++ b/node-graph/libraries/vector-types/src/vector/path.rs @@ -1,8 +1,9 @@ -use super::misc::dvec2_to_point; +use super::algorithms::consts::CLOSE_POINT_TOLERANCE; +use super::misc::{dvec2_to_point, point_to_dvec2}; use core_types::list::{ATTR_CURVE_TYPE, ATTR_PATHS, Item, List}; use glam::DVec2; use graphene_hash::CacheHash; -use kurbo::{CubicBez, Line, PathSeg, QuadBez}; +use kurbo::{BezPath, CubicBez, Line, PathEl, PathSeg, Point, QuadBez}; /// The curve family a path's point references weave in, fixing the stride of its reference pattern. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, CacheHash)] @@ -93,9 +94,161 @@ pub fn curve_type_at(paths: &List, index: usize) -> CurveType { paths.attribute_cloned_or_default(ATTR_CURVE_TYPE, index) } +/// Weaves bezpath contours into pool points and paths, starting a new path at each verb change so every +/// path keeps one stride. The runs of a split subpath share the anchor they meet at, welding them. +struct PathWeaver { + positions: Vec, + offset: u32, + woven: Vec<(CurveType, Vec)>, + references: Vec, + curve_type: Option, + subpath_start: Option, +} + +impl PathWeaver { + fn new(offset: u32) -> Self { + Self { + positions: Vec::new(), + offset, + woven: Vec::new(), + references: Vec::new(), + curve_type: None, + subpath_start: None, + } + } + + /// Adds a point to the pool being built and returns the reference naming it. + fn push_point(&mut self, point: Point) -> u32 { + self.positions.push(point_to_dvec2(point)); + self.offset + self.positions.len() as u32 - 1 + } + + fn position(&self, reference: u32) -> Option { + self.positions.get(reference.checked_sub(self.offset)? as usize).copied() + } + + /// Banks the references woven so far as a finished path, dropping a lone anchor that drew nothing. + fn flush(&mut self) { + if self.references.len() >= 2 { + let curve_type = self.curve_type.unwrap_or_default(); + self.woven.push((curve_type, std::mem::take(&mut self.references))); + } + + self.references.clear(); + self.curve_type = None; + } + + /// Prepares to weave a segment of the given curve type, splitting at the shared anchor when the verb changes. + /// Returns whether an anchor exists to draw from. + fn begin_segment(&mut self, verb: CurveType) -> bool { + // After a close the pen rests on the subpath's first point, so a segment drawn with no new MoveTo departs from there + let weld = match self.references.last() { + Some(&weld) => weld, + None => { + let Some(start) = self.subpath_start else { return false }; + self.references.push(start); + start + } + }; + + if let Some(current) = self.curve_type + && current != verb + { + self.flush(); + self.references.push(weld); + } + self.curve_type = Some(verb); + + true + } + + fn push_element(&mut self, element: PathEl) { + match element { + PathEl::MoveTo(point) => { + self.flush(); + let reference = self.push_point(point); + self.subpath_start = Some(reference); + self.references.push(reference); + } + PathEl::LineTo(anchor) => self.push_segment(CurveType::Linear, &[anchor]), + PathEl::QuadTo(handle, anchor) => self.push_segment(CurveType::Quadratic, &[handle, anchor]), + PathEl::CurveTo(start_handle, end_handle, anchor) => self.push_segment(CurveType::Cubic, &[start_handle, end_handle, anchor]), + PathEl::ClosePath => self.close(), + } + } + + fn push_segment(&mut self, verb: CurveType, points: &[Point]) { + if !self.begin_segment(verb) { + return; + } + + for &point in points { + let reference = self.push_point(point); + self.references.push(reference); + } + } + + /// Closes the subpath by naming its first point again, drawing a straight line home when the pen sits elsewhere. + fn close(&mut self) { + let (Some(start), Some(&last)) = (self.subpath_start, self.references.last()) else { return }; + + if last == start { + self.flush(); + return; + } + + // A final anchor resting on the subpath's first point is that same point, so name it instead of duplicating it + let coincident = matches!((self.position(last), self.position(start)), (Some(last), Some(start)) if last.distance_squared(start) <= CLOSE_POINT_TOLERANCE * CLOSE_POINT_TOLERANCE); + if coincident { + self.references.pop(); + self.references.push(start); + if last as usize + 1 == self.offset as usize + self.positions.len() { + self.positions.pop(); + } + } else if self.begin_segment(CurveType::Linear) { + self.references.push(start); + } + + self.flush(); + } + + fn finish(mut self, pool: &mut List, paths: &mut List) { + self.flush(); + + for position in self.positions { + pool.push(Item::new_from_element(position)); + } + + for (curve_type, references) in self.woven { + let path = Path(references.into_iter().map(Item::new_from_element).collect()); + paths.push(Item::new_from_element(path).with_attribute(ATTR_CURVE_TYPE, curve_type)); + } + } +} + +/// Appends a bezpath's contours to a point pool and its paths. +pub fn append_bezpath(pool: &mut List, paths: &mut List, bezpath: &BezPath) { + let mut weaver = PathWeaver::new(pool.len() as u32); + + for &element in bezpath.elements() { + weaver.push_element(element); + } + + weaver.finish(pool, paths); +} + +/// Builds a fresh point pool and its paths from a bezpath. +pub fn pool_and_paths_from_bezpath(bezpath: &BezPath) -> (List, List) { + let (mut pool, mut paths) = (List::new(), List::new()); + append_bezpath(&mut pool, &mut paths, bezpath); + + (pool, paths) +} + #[cfg(test)] mod tests { use super::*; + use kurbo::{Rect, Shape}; fn pool(positions: &[(f64, f64)]) -> List { positions.iter().map(|&(x, y)| Item::new_from_element(DVec2::new(x, y))).collect() @@ -200,4 +353,89 @@ mod tests { assert_eq!(paths.element(0).unwrap().segment_count(CurveType::Linear), 4); } } + + // A rectangle weaves to four pool points and five references, the fifth naming the first again + #[test] + fn weave_rectangle() { + let (pool, paths) = pool_and_paths_from_bezpath(&Rect::new(-36.25, -17.5, 36.25, 17.5).to_path(0.)); + + assert_eq!(pool.len(), 4); + assert_eq!(paths.len(), 1); + assert_eq!(curve_type_at(&paths, 0), CurveType::Linear); + + let rectangle = paths.element(0).unwrap(); + assert_eq!(rectangle.0.iter_element_values().copied().collect::>(), vec![0, 1, 2, 3, 0]); + assert!(rectangle.is_closed()); + assert_eq!(rectangle.segment_count(CurveType::Linear), 4); + } + + // A closed contour landing back on its first point names that point rather than duplicating it + #[test] + fn weave_closed_cubic_contour() { + let mut bezpath = BezPath::new(); + bezpath.move_to((0., 0.)); + bezpath.curve_to((5., 0.), (10., 5.), (10., 10.)); + bezpath.curve_to((5., 15.), (0., 5.), (0., 0.)); + bezpath.close_path(); + + let (pool, paths) = pool_and_paths_from_bezpath(&bezpath); + + assert_eq!(pool.len(), 6); + let contour = paths.element(0).unwrap(); + assert_eq!(contour.0.iter_element_values().copied().collect::>(), vec![0, 1, 2, 3, 4, 5, 0]); + assert!(contour.is_closed()); + assert_eq!(contour.segment_count(CurveType::Cubic), 2); + } + + // Mixing verbs splits the subpath into one path per stride, welded where they meet + #[test] + fn weave_splits_at_verb_changes() { + let mut bezpath = 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 (_, paths) = pool_and_paths_from_bezpath(&bezpath); + + assert_eq!(paths.len(), 3); + assert_eq!(curve_type_at(&paths, 0), CurveType::Linear); + assert_eq!(curve_type_at(&paths, 1), CurveType::Cubic); + assert_eq!(curve_type_at(&paths, 2), CurveType::Linear); + + let references = |index: usize| paths.element(index).unwrap().0.iter_element_values().copied().collect::>(); + assert_eq!(references(0), vec![0, 1]); + assert_eq!(references(1), vec![1, 2, 3, 4]); + assert_eq!(references(2), vec![4, 0]); + } + + // A lone move leaves a free point in the pool and draws no path + #[test] + fn weave_lone_move_is_a_free_point() { + let mut bezpath = BezPath::new(); + bezpath.move_to((5., 5.)); + + let (pool, paths) = pool_and_paths_from_bezpath(&bezpath); + + assert_eq!(pool.len(), 1); + assert!(paths.is_empty()); + } + + // A segment drawn straight after a close departs from the closed subpath's first point + #[test] + fn weave_continues_from_subpath_start_after_close() { + let mut bezpath = BezPath::new(); + bezpath.move_to((0., 0.)); + bezpath.line_to((10., 0.)); + bezpath.close_path(); + bezpath.line_to((5., 5.)); + + let (pool, paths) = pool_and_paths_from_bezpath(&bezpath); + + assert_eq!(pool.len(), 3); + assert_eq!(paths.len(), 2); + let references = |index: usize| paths.element(index).unwrap().0.iter_element_values().copied().collect::>(); + assert_eq!(references(0), vec![0, 1, 0]); + assert_eq!(references(1), vec![0, 2]); + } } diff --git a/node-graph/libraries/vector-types/src/vector/vector_modification.rs b/node-graph/libraries/vector-types/src/vector/vector_modification.rs index 70c55b75505..1fedf925cec 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_modification.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_modification.rs @@ -1,4 +1,5 @@ use super::*; +use crate::vector::algorithms::consts::CLOSE_POINT_TOLERANCE; use crate::vector::misc::BezierHandles; use crate::vector::misc::{HandleId, HandleType, point_to_dvec2, segment_to_handles}; use core_types::uuid::generate_uuid; @@ -587,10 +588,6 @@ where deserializer.deserialize_seq(visitor) } -/// Distance below which a path's closing point is treated as coincident with its start point. -/// Matches Kurbo's default path accuracy, so points within an offset operation's own precision are not split into separate anchors. -const CLOSE_POINT_TOLERANCE: f64 = 1e-6; - pub(crate) struct AppendBezpath<'a> { first_point: Option, last_point: Option, From 9cc2114810b90b261f8e9eb19054e34bf5f20568 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 21 Aug 2026 01:45:44 -0700 Subject: [PATCH 5/8] Index which paths meet at each pool point for topology queries --- .../vector-types/src/vector/connectivity.rs | 155 ++++++++++++++++++ .../libraries/vector-types/src/vector/mod.rs | 2 + 2 files changed, 157 insertions(+) create mode 100644 node-graph/libraries/vector-types/src/vector/connectivity.rs diff --git a/node-graph/libraries/vector-types/src/vector/connectivity.rs b/node-graph/libraries/vector-types/src/vector/connectivity.rs new file mode 100644 index 00000000000..bcd42d3c90c --- /dev/null +++ b/node-graph/libraries/vector-types/src/vector/connectivity.rs @@ -0,0 +1,155 @@ +use super::path::{Path, curve_type_at}; +use core_types::list::List; + +/// One path's reference to a pool point, naming where in the weave that point is used. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Reference { + pub path: u32, + pub index: u32, +} + +/// Reverse index from each pool point to the references naming it. +/// +/// Paths store their own traversal order, so connectivity never has to be searched for. This index +/// answers the remaining question of which paths meet at a point, built in one pass over every +/// reference and read afterwards as a slice per point. +#[derive(Clone, Debug, Default)] +pub struct Connectivity { + starts: Vec, + references: Vec, +} + +impl Connectivity { + /// Indexes every reference by the pool point it names, in O(points + references). + pub fn build(point_count: usize, paths: &List) -> Self { + let mut starts = vec![0_u32; point_count + 1]; + for path in paths.iter_element_values() { + for &point in path.0.iter_element_values() { + if let Some(count) = (point as usize).checked_add(1).and_then(|slot| starts.get_mut(slot)) { + *count += 1; + } + } + } + + // Summing the per-point counts in place turns them into the offset where each point's run begins + for index in 1..starts.len() { + starts[index] += starts[index - 1]; + } + + let mut cursors = starts[..point_count].to_vec(); + let mut references = vec![Reference::default(); starts[point_count] as usize]; + for (path_index, path) in paths.iter_element_values().enumerate() { + for (reference_index, &point) in path.0.iter_element_values().enumerate() { + let Some(cursor) = cursors.get_mut(point as usize) else { continue }; + references[*cursor as usize] = Reference { + path: path_index as u32, + index: reference_index as u32, + }; + *cursor += 1; + } + } + + Self { starts, references } + } + + /// The references naming the given pool point, ordered by path. + pub fn references(&self, point: u32) -> &[Reference] { + let (Some(&start), Some(&end)) = (self.starts.get(point as usize), (point as usize).checked_add(1).and_then(|next| self.starts.get(next))) else { + return &[]; + }; + + &self.references[start as usize..end as usize] + } + + /// Whether any path names this point, false for a free point carried by the pool alone. + pub fn is_referenced(&self, point: u32) -> bool { + !self.references(point).is_empty() + } + + /// The segment ends meeting at this point: 1 at a free end, 2 along a chain, 3 or more at a junction. + /// Handle references contribute none, since only anchors have segments radiating from them. + pub fn spokes(&self, point: u32, paths: &List) -> usize { + self.references(point) + .iter() + .filter_map(|reference| { + let path = paths.element(reference.path as usize)?; + if !path.is_anchor_reference(reference.index as usize, curve_type_at(paths, reference.path as usize)) { + return None; + } + + let last = path.0.len().saturating_sub(1) as u32; + Some(usize::from(reference.index > 0) + usize::from(reference.index < last)) + }) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::super::path::CurveType; + use super::*; + use core_types::list::{ATTR_CURVE_TYPE, Item}; + + fn paths(specs: &[(CurveType, &[u32])]) -> List { + let mut paths = List::new(); + + for &(curve_type, references) in specs { + let path = Path(references.iter().map(|&reference| Item::new_from_element(reference)).collect()); + paths.push(Item::new_from_element(path).with_attribute(ATTR_CURVE_TYPE, curve_type)); + } + + paths + } + + // A closed circle crossed by a chord welded at two of its anchors, plus two points no path names + #[test] + fn spokes_at_welds_and_free_points() { + let paths = paths(&[(CurveType::Cubic, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0]), (CurveType::Linear, &[0, 12, 6])]); + let connectivity = Connectivity::build(15, &paths); + + // The seam contributes two spokes and the chord a third + assert_eq!(connectivity.spokes(0, &paths), 3); + assert_eq!(connectivity.spokes(6, &paths), 3); + + assert_eq!(connectivity.spokes(3, &paths), 2); + assert_eq!(connectivity.spokes(12, &paths), 2); + + // Handles carry no segments, and unreferenced points are free + assert_eq!(connectivity.spokes(1, &paths), 0); + assert!(!connectivity.is_referenced(13)); + assert!(connectivity.is_referenced(12)); + } + + #[test] + fn references_name_every_use() { + let paths = paths(&[(CurveType::Linear, &[0, 1, 2]), (CurveType::Linear, &[2, 3])]); + let connectivity = Connectivity::build(4, &paths); + + assert_eq!(connectivity.references(0), [Reference { path: 0, index: 0 }]); + assert_eq!(connectivity.references(2), [Reference { path: 0, index: 2 }, Reference { path: 1, index: 0 }]); + assert_eq!(connectivity.spokes(2, &paths), 2); + } + + // References falling anywhere outside the pool are ignored, including one exactly at its end + #[test] + fn dangling_references_are_ignored() { + let paths = paths(&[(CurveType::Linear, &[0, 3, 1, 4])]); + let connectivity = Connectivity::build(3, &paths); + + assert_eq!(connectivity.references(0), [Reference { path: 0, index: 0 }]); + assert_eq!(connectivity.references(1), [Reference { path: 0, index: 2 }]); + assert_eq!(connectivity.references(3), []); + assert_eq!(connectivity.references(4), []); + } + + // An open end has one spoke, which is what makes it a cap site + #[test] + fn free_ends_have_one_spoke() { + let paths = paths(&[(CurveType::Linear, &[0, 1, 2])]); + let connectivity = Connectivity::build(3, &paths); + + assert_eq!(connectivity.spokes(0, &paths), 1); + assert_eq!(connectivity.spokes(2, &paths), 1); + assert_eq!(connectivity.spokes(1, &paths), 2); + } +} diff --git a/node-graph/libraries/vector-types/src/vector/mod.rs b/node-graph/libraries/vector-types/src/vector/mod.rs index 71652af3cc3..5c159586829 100644 --- a/node-graph/libraries/vector-types/src/vector/mod.rs +++ b/node-graph/libraries/vector-types/src/vector/mod.rs @@ -1,5 +1,6 @@ pub mod algorithms; pub mod click_target; +mod connectivity; pub mod misc; mod path; pub mod reference_point; @@ -8,6 +9,7 @@ mod vector_attributes; mod vector_modification; mod vector_types; +pub use connectivity::*; pub use path::*; pub use reference_point::*; pub use vector_attributes::*; From b0f9790753811f5f549e04c7328fc9208143cdb1 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 21 Aug 2026 01:48:51 -0700 Subject: [PATCH 6/8] Assemble render contours by following welds across split paths --- .../vector-types/src/vector/contour.rs | 352 ++++++++++++++++++ .../libraries/vector-types/src/vector/mod.rs | 2 + .../libraries/vector-types/src/vector/path.rs | 9 + 3 files changed, 363 insertions(+) create mode 100644 node-graph/libraries/vector-types/src/vector/contour.rs diff --git a/node-graph/libraries/vector-types/src/vector/contour.rs b/node-graph/libraries/vector-types/src/vector/contour.rs new file mode 100644 index 00000000000..cbcf5648f34 --- /dev/null +++ b/node-graph/libraries/vector-types/src/vector/contour.rs @@ -0,0 +1,352 @@ +use super::misc::dvec2_to_point; +use super::path::{Path, append_segment, curve_type_at}; +use core_types::list::List; +use glam::DVec2; +use kurbo::BezPath; + +/// One span of a path as a subpath walks it, running against its stored direction when reversed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Step { + pub path: u32, + pub span: u32, + pub reversed: bool, +} + +/// Spans walked end to end across the anchors they share, from one pool point to another. +/// A closed subpath arrives back at the point it left from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Subpath { + pub steps: Vec, + pub closed: bool, + pub entry: u32, + pub exit: u32, +} + +/// A span flattened out of its path, naming the anchors at its two ends. +struct Edge { + path: u32, + span: u32, + start: u32, + end: u32, +} + +/// Each anchor's span ends in the order the spans were woven, consumed as the walk uses them up. +/// An end is named by twice its edge's index, plus one for the end the span arrives at. +struct SpanEnds { + offsets: Vec, + ends: Vec, + cursors: Vec, + used: Vec, + remaining: Vec, +} + +impl SpanEnds { + fn new(point_count: usize, edges: &[Edge]) -> Self { + let mut offsets = vec![0; point_count + 1]; + for edge in edges { + offsets[edge.start as usize + 1] += 1; + offsets[edge.end as usize + 1] += 1; + } + for point in 0..point_count { + offsets[point + 1] += offsets[point]; + } + + let mut ends = vec![0; edges.len() * 2]; + let mut fill = offsets.clone(); + for (index, edge) in edges.iter().enumerate() { + for (point, end) in [(edge.start as usize, index * 2), (edge.end as usize, index * 2 + 1)] { + ends[fill[point]] = end; + fill[point] += 1; + } + } + + let remaining = (0..point_count).map(|point| offsets[point + 1] - offsets[point]).collect(); + + Self { + cursors: offsets[..point_count].to_vec(), + offsets, + ends, + used: vec![false; edges.len() * 2], + remaining, + } + } + + /// Uses up the earliest woven span end still waiting at the point. + fn take_earliest(&mut self, point: usize) -> Option { + while self.cursors[point] < self.offsets[point + 1] { + let end = self.ends[self.cursors[point]]; + self.cursors[point] += 1; + + if !self.used[end] { + self.used[end] = true; + self.remaining[point] -= 1; + return Some(end); + } + } + + None + } + + /// Uses up one particular span end, which is the far side of a span the walk just crossed. + fn take(&mut self, point: usize, end: usize) { + if !self.used[end] { + self.used[end] = true; + self.remaining[point] -= 1; + } + } +} + +/// Walks every span into subpaths in O(points + spans): open ones first, each from a point one span end meets, +/// then the rest from the lowest point with spans left. A walk follows the earliest woven span waiting at each +/// point, backwards when that span ends there, and closes on arriving back where it began. +pub fn subpaths(point_count: usize, paths: &List) -> Vec { + let mut edges = Vec::new(); + for (path_index, path) in paths.iter_element_values().enumerate() { + let curve_type = curve_type_at(paths, path_index); + let stride = curve_type.stride(); + + for span in 0..path.segment_count(curve_type) { + let references = (span * stride..=(span + 1) * stride).map(|index| path.0.element(index).copied()); + let Some(references) = references.collect::>>() else { continue }; + + // A span naming a point outside the pool cannot be drawn, so it takes no part in the walk + if references.iter().any(|&reference| reference as usize >= point_count) { + continue; + } + let (Some(&start), Some(&end)) = (references.first(), references.last()) else { continue }; + + edges.push(Edge { + path: path_index as u32, + span: span as u32, + start, + end, + }); + } + } + + let mut span_ends = SpanEnds::new(point_count, &edges); + let mut found = Vec::new(); + let (mut skip, mut open_ends_done) = (0, false); + + loop { + let mut entry = None; + if !open_ends_done { + entry = (skip..point_count).find(|&point| span_ends.remaining[point] == 1); + open_ends_done = entry.is_none(); + skip = entry.map_or(0, |point| point + 1); + } + if entry.is_none() { + entry = (skip..point_count).find(|&point| span_ends.remaining[point] > 0); + skip = entry.unwrap_or(point_count); + } + let Some(entry) = entry else { break }; + + let (mut point, mut steps, mut closed) = (entry, Vec::new(), false); + while let Some(end) = span_ends.take_earliest(point) { + let edge = &edges[end / 2]; + let reversed = end % 2 == 1; + let next = if reversed { edge.start } else { edge.end } as usize; + + steps.push(Step { + path: edge.path, + span: edge.span, + reversed, + }); + span_ends.take(next, end ^ 1); + point = next; + + if next == entry { + closed = true; + break; + } + } + + found.push(Subpath { + steps, + closed, + entry: entry as u32, + exit: point as u32, + }); + } + + found +} + +/// Emits one subpath as a bezpath, or `None` when it has no drawable geometry. +pub fn subpath_to_bezpath(pool: &List, paths: &List, subpath: &Subpath) -> Option { + let mut bezpath = BezPath::new(); + bezpath.move_to(dvec2_to_point(*pool.element(subpath.entry as usize)?)); + + for step in &subpath.steps { + let path = paths.element(step.path as usize)?; + let segment = path.segment(pool, curve_type_at(paths, step.path as usize), step.span as usize)?; + + append_segment(&mut bezpath, if step.reversed { segment.reverse() } else { segment }); + } + + if subpath.closed { + bezpath.close_path(); + } + + Some(bezpath) +} + +/// Assembles the paths into contours for rendering, one bezpath per subpath in the order they are walked. +pub fn contours(pool: &List, paths: &List) -> Vec { + subpaths(pool.len(), paths).iter().filter_map(|subpath| subpath_to_bezpath(pool, paths, subpath)).collect() +} + +#[cfg(test)] +mod tests { + use super::super::path::pool_and_paths_from_bezpath; + use super::*; + use core_types::list::Item; + use kurbo::PathEl; + + fn bezpath_of(build: impl FnOnce(&mut BezPath)) -> BezPath { + let mut bezpath = BezPath::new(); + build(&mut bezpath); + + bezpath + } + + /// Linear paths over a pool of the given size, one path per list of references. + fn linear(point_count: usize, paths: &[&[u32]]) -> (List, List) { + let pool = (0..point_count).map(|index| Item::new_from_element(DVec2::new(index as f64, (index * index) as f64))).collect(); + let paths = paths + .iter() + .map(|references| Item::new_from_element(Path(references.iter().copied().map(Item::new_from_element).collect()))) + .collect(); + + (pool, paths) + } + + /// The anchors each subpath visits, with whether it closed. + fn walked(point_count: usize, paths: &List) -> Vec<(Vec, bool)> { + subpaths(point_count, paths) + .into_iter() + .map(|subpath| { + let mut anchors = vec![subpath.entry]; + for step in &subpath.steps { + let path = paths.element(step.path as usize).expect("the step names a path"); + let (start, end) = (path.0.element(step.span as usize), path.0.element(step.span as usize + 1)); + anchors.push(*if step.reversed { start } else { end }.expect("the span has both ends")); + } + if subpath.closed { + anchors.pop(); + } + + (anchors, subpath.closed) + }) + .collect() + } + + // The three paths a mixed-verb subpath weaves into rejoin as the one contour the artist drew + #[test] + fn split_subpath_rejoins_as_one_contour() { + let source = bezpath_of(|bezpath| { + bezpath.move_to((0., 0.)); + bezpath.line_to((10., 0.)); + bezpath.curve_to((15., 5.), (15., 10.), (10., 15.)); + bezpath.close_path(); + }); + let (pool, paths) = pool_and_paths_from_bezpath(&source); + assert_eq!(paths.len(), 3); + + let assembled = contours(&pool, &paths); + assert_eq!(assembled.len(), 1); + assert!(matches!( + assembled[0].elements(), + [PathEl::MoveTo(_), PathEl::LineTo(_), PathEl::CurveTo(..), PathEl::LineTo(_), PathEl::ClosePath] + )); + } + + // Separate shapes stay separate contours + #[test] + fn disjoint_shapes_emit_separately() { + let source = bezpath_of(|bezpath| { + bezpath.move_to((0., 0.)); + bezpath.line_to((10., 0.)); + bezpath.line_to((10., 10.)); + bezpath.close_path(); + bezpath.move_to((20., 20.)); + bezpath.line_to((30., 20.)); + bezpath.close_path(); + }); + let (pool, paths) = pool_and_paths_from_bezpath(&source); + + assert_eq!(contours(&pool, &paths).len(), 2); + } + + // An open subpath emits one contour that stays open + #[test] + fn open_subpath_joins_without_closing() { + let source = bezpath_of(|bezpath| { + bezpath.move_to((0., 0.)); + bezpath.line_to((10., 0.)); + bezpath.curve_to((15., 5.), (15., 10.), (10., 15.)); + }); + let (pool, paths) = pool_and_paths_from_bezpath(&source); + assert_eq!(paths.len(), 2); + + let assembled = contours(&pool, &paths); + assert_eq!(assembled.len(), 1); + assert!(matches!(assembled[0].elements(), [PathEl::MoveTo(_), PathEl::LineTo(_), PathEl::CurveTo(..)])); + } + + // A loop leaves from its lowest point along the earliest span there, which it crosses backwards when that span ends at the point + #[test] + fn loop_walks_backwards_when_its_earliest_span_arrives() { + let (_, paths) = linear(4, &[&[1, 2, 3, 0, 1]]); + + assert_eq!(walked(4, &paths), [(vec![0, 3, 2, 1], true)]); + } + + // An open subpath leaves from its lower end even when that is where it was woven to + #[test] + fn open_subpath_leaves_from_its_lower_end() { + let (_, paths) = linear(4, &[&[0, 1, 2], &[3, 0]]); + + assert_eq!(walked(4, &paths), [(vec![2, 1, 0, 3], false)]); + } + + // Open subpaths lead, however high their points, and loops follow in the order of their lowest points + #[test] + fn open_subpaths_lead_the_loops() { + let (_, paths) = linear(8, &[&[5, 6, 7, 5], &[0, 1, 2, 0], &[3, 4]]); + + assert_eq!(walked(8, &paths), [(vec![3, 4], false), (vec![0, 1, 2], true), (vec![5, 6, 7], true)]); + } + + // Loops that touch an outline are walked apart from it, since the outline's own spans were woven first + #[test] + fn touching_loops_walk_apart_from_the_outline() { + let (_, paths) = linear(6, &[&[0, 1, 2, 3, 0], &[1, 4, 1], &[3, 5, 3]]); + + assert_eq!(walked(6, &paths), [(vec![0, 1, 2, 3], true), (vec![1, 4], true), (vec![3, 5], true)]); + } + + // A walk carries on through a junction along its earliest span and ends where none are left + #[test] + fn junction_is_walked_through_along_its_earliest_span() { + let (_, paths) = linear(4, &[&[0, 3], &[1, 3], &[2, 3]]); + + assert_eq!(walked(4, &paths), [(vec![0, 3, 1], false), (vec![2, 3], false)]); + } + + // A walk that arrives back at a point other than the one it left from stays open + #[test] + fn loop_on_a_tail_stays_open() { + let (_, paths) = linear(4, &[&[0, 1, 2, 3, 1]]); + + assert_eq!(walked(4, &paths), [(vec![0, 1, 2, 3, 1], false)]); + } + + // A span naming a point outside the pool is left out of the walk + #[test] + fn dangling_span_is_left_out() { + let (_, paths) = linear(3, &[&[0, 1, 9], &[1, 2]]); + + assert_eq!(walked(3, &paths), [(vec![0, 1, 2], false)]); + } +} diff --git a/node-graph/libraries/vector-types/src/vector/mod.rs b/node-graph/libraries/vector-types/src/vector/mod.rs index 5c159586829..59380f952bc 100644 --- a/node-graph/libraries/vector-types/src/vector/mod.rs +++ b/node-graph/libraries/vector-types/src/vector/mod.rs @@ -1,6 +1,7 @@ pub mod algorithms; pub mod click_target; mod connectivity; +mod contour; pub mod misc; mod path; pub mod reference_point; @@ -10,6 +11,7 @@ mod vector_modification; mod vector_types; pub use connectivity::*; +pub use contour::*; pub use path::*; pub use reference_point::*; pub use vector_attributes::*; diff --git a/node-graph/libraries/vector-types/src/vector/path.rs b/node-graph/libraries/vector-types/src/vector/path.rs index e8458020af2..4227f610ae6 100644 --- a/node-graph/libraries/vector-types/src/vector/path.rs +++ b/node-graph/libraries/vector-types/src/vector/path.rs @@ -79,6 +79,15 @@ impl Path { } } +/// Appends a segment to a bezpath already positioned at that segment's start point. +pub fn append_segment(bezpath: &mut BezPath, segment: PathSeg) { + match segment { + PathSeg::Line(line) => bezpath.line_to(line.p1), + PathSeg::Quad(quad) => bezpath.quad_to(quad.p1, quad.p2), + PathSeg::Cubic(cubic) => bezpath.curve_to(cubic.p1, cubic.p2, cubic.p3), + } +} + /// The paths an item carries beside a vector element, absent when the pool is a bare point cloud. pub fn paths_from_item_attributes(item: &Item) -> Option<&List> { item.attribute(ATTR_PATHS) From c0fcd0a36ab4db90f9de2ed2f25218c7e2eb5442 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 21 Aug 2026 02:00:50 -0700 Subject: [PATCH 7/8] Address segments as path spans while keeping stable point and segment IDs --- node-graph/libraries/core-types/src/list.rs | 5 +- .../vector-types/src/vector/addressing.rs | 229 ++++++++++++++++++ .../libraries/vector-types/src/vector/mod.rs | 2 + .../src/vector/vector_attributes.rs | 4 +- 4 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 node-graph/libraries/vector-types/src/vector/addressing.rs diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index f2b381a466e..d9f75d319aa 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -115,8 +115,9 @@ pub const ATTR_CURVE_TYPE: &str = "curve_type"; /// on the `List` inside a `Path` (implicit default `false`). Editing tools keep a smooth pair /// colinear. Rendering derives continuity from actual tangent geometry and does not read this. pub const ATTR_SMOOTH: &str = "smooth"; -/// Point's stable `PointId` for external addressing (modifications, tools, animation), on a -/// `Vector`'s point pool. Indices are the internal addressing, so IDs are opt-in. +/// Item's stable ID for external addressing (modifications, tools, animation), opt-in because indices are +/// the internal addressing: a `PointId` on a `Vector`'s point pool, or a `SegmentId` on the `List` +/// inside a `Path` at the anchor reference the segment departs from. pub const ATTR_ID: &str = "id"; /// Text item's font size in document-space units (`f64`, implicit default `24.`). pub const ATTR_FONT_SIZE: &str = "font_size"; diff --git a/node-graph/libraries/vector-types/src/vector/addressing.rs b/node-graph/libraries/vector-types/src/vector/addressing.rs new file mode 100644 index 00000000000..a78719c3688 --- /dev/null +++ b/node-graph/libraries/vector-types/src/vector/addressing.rs @@ -0,0 +1,229 @@ +use super::connectivity::Connectivity; +use super::misc::HandleId; +use super::path::{CurveType, Path, curve_type_at}; +use super::vector_attributes::{PointId, SegmentId}; +use core_types::list::{ATTR_ID, List}; +use glam::DVec2; + +/// A segment's place in the weave: the span of one path between two consecutive anchors. +/// Segments are never stored, so this is how one is named. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Span { + pub path: u32, + pub index: u32, +} + +impl Span { + /// The reference index where this span's starting anchor sits. + pub fn start_reference(&self, curve_type: CurveType) -> usize { + self.index as usize * curve_type.stride() + } + + /// The reference index where this span's ending anchor sits. + pub fn end_reference(&self, curve_type: CurveType) -> usize { + self.start_reference(curve_type) + curve_type.stride() + } +} + +/// Every span of every path, in path order. +pub fn spans(paths: &List) -> impl Iterator + '_ { + paths.iter_element_values().enumerate().flat_map(|(path_index, path)| { + let curve_type = curve_type_at(paths, path_index); + (0..path.segment_count(curve_type) as u32).map(move |index| Span { path: path_index as u32, index }) + }) +} + +/// The stable ID naming a span, stored on the anchor reference the span departs from. A reference cell left unnamed +/// holds `SegmentId::ZERO`, which sequential IDs never take. +pub fn segment_id_of(paths: &List, span: Span) -> Option { + let path = paths.element(span.path as usize)?; + let reference = span.start_reference(curve_type_at(paths, span.path as usize)); + + path.0.attribute::(ATTR_ID, reference).copied() +} + +/// The span a segment ID names, found by scanning every span in O(references). +pub fn span_of_segment_id(paths: &List, segment: SegmentId) -> Option { + spans(paths).find(|&span| segment_id_of(paths, span) == Some(segment)) +} + +/// The pool points a span runs between. +pub fn span_endpoints(paths: &List, span: Span) -> Option<(u32, u32)> { + let path = paths.element(span.path as usize)?; + let curve_type = curve_type_at(paths, span.path as usize); + let start = path.0.element(span.start_reference(curve_type))?; + let end = path.0.element(span.end_reference(curve_type))?; + + Some((*start, *end)) +} + +/// The pool point a handle of the given span occupies, absent on a linear span that has no handles. +pub fn handle_point(paths: &List, span: Span, primary: bool) -> Option { + let path = paths.element(span.path as usize)?; + let curve_type = curve_type_at(paths, span.path as usize); + if curve_type == CurveType::Linear { + return None; + } + + // A quadratic span's single handle answers to either side + let offset = if primary || curve_type == CurveType::Quadratic { 1 } else { 2 }; + + path.0.element(span.start_reference(curve_type) + offset).copied() +} + +/// The stable ID of a pool point. +pub fn point_id_at(pool: &List, index: u32) -> Option { + pool.attribute::(ATTR_ID, index as usize).copied() +} + +/// The pool index a point ID names. +pub fn index_of_point_id(pool: &List, point: PointId) -> Option { + pool.iter_attribute_values::(ATTR_ID)?.position(|&id| id == point).map(|index| index as u32) +} + +/// The spans meeting at a pool point, each paired with whether the point is where that span ends. +pub fn spans_at(pool_index: u32, paths: &List, connectivity: &Connectivity) -> Vec<(Span, bool)> { + let mut found = Vec::new(); + + for reference in connectivity.references(pool_index) { + let path_index = reference.path as usize; + let Some(path) = paths.element(path_index) else { continue }; + + let curve_type = curve_type_at(paths, path_index); + if !path.is_anchor_reference(reference.index as usize, curve_type) { + continue; + } + + // An anchor departs the span starting at it and arrives on the one before it + let anchor = reference.index as usize / curve_type.stride(); + if anchor < path.segment_count(curve_type) { + found.push(( + Span { + path: reference.path, + index: anchor as u32, + }, + false, + )); + } + if let Some(previous) = anchor.checked_sub(1) { + found.push(( + Span { + path: reference.path, + index: previous as u32, + }, + true, + )); + } + } + + found +} + +/// The segments departing the given pool point. +pub fn start_connected(pool_index: u32, paths: &List, connectivity: &Connectivity) -> Vec { + spans_at(pool_index, paths, connectivity) + .into_iter() + .filter(|&(_, arriving)| !arriving) + .filter_map(|(span, _)| segment_id_of(paths, span)) + .collect() +} + +/// The segments arriving at the given pool point. +pub fn end_connected(pool_index: u32, paths: &List, connectivity: &Connectivity) -> Vec { + spans_at(pool_index, paths, connectivity) + .into_iter() + .filter(|&(_, arriving)| arriving) + .filter_map(|(span, _)| segment_id_of(paths, span)) + .collect() +} + +/// The handles of every segment meeting the given pool point, naming the side that faces it. +pub fn all_connected(pool_index: u32, paths: &List, connectivity: &Connectivity) -> Vec { + spans_at(pool_index, paths, connectivity) + .into_iter() + .filter_map(|(span, arriving)| { + let segment = segment_id_of(paths, span)?; + Some(if arriving { HandleId::end(segment) } else { HandleId::primary(segment) }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use core_types::list::{ATTR_CURVE_TYPE, Item}; + + fn pool(count: u32) -> List { + (0..count) + .map(|index| Item::new_from_element(DVec2::new(index as f64, 0.)).with_attribute(ATTR_ID, PointId::generate())) + .collect() + } + + fn paths_of(specs: &[(CurveType, &[u32])]) -> List { + let mut paths = List::new(); + + for &(curve_type, references) in specs { + let mut path = Path(references.iter().map(|&reference| Item::new_from_element(reference)).collect()); + + // Name each span on the anchor it departs from + for span in 0..path.segment_count(curve_type) { + path.0.set_attribute(ATTR_ID, span * curve_type.stride(), SegmentId::generate()); + } + + paths.push(Item::new_from_element(path).with_attribute(ATTR_CURVE_TYPE, curve_type)); + } + + paths + } + + #[test] + fn spans_name_every_segment() { + let paths = paths_of(&[(CurveType::Linear, &[0, 1, 2]), (CurveType::Cubic, &[2, 3, 4, 5])]); + + let all: Vec<_> = spans(&paths).collect(); + assert_eq!(all.len(), 3); + assert_eq!(all[2], Span { path: 1, index: 0 }); + + for span in all { + let id = segment_id_of(&paths, span).expect("every span was named"); + assert_eq!(span_of_segment_id(&paths, id), Some(span)); + } + } + + #[test] + fn spans_resolve_their_endpoints_and_handles() { + let paths = paths_of(&[(CurveType::Cubic, &[0, 1, 2, 3])]); + let span = Span { path: 0, index: 0 }; + + assert_eq!(span_endpoints(&paths, span), Some((0, 3))); + assert_eq!(handle_point(&paths, span, true), Some(1)); + assert_eq!(handle_point(&paths, span, false), Some(2)); + + let linear = paths_of(&[(CurveType::Linear, &[0, 1])]); + assert_eq!(handle_point(&linear, Span { path: 0, index: 0 }, true), None); + } + + // A welded anchor reports the segments on both sides, each from the path that owns it + #[test] + fn connectivity_by_segment_id() { + let paths = paths_of(&[(CurveType::Linear, &[0, 1]), (CurveType::Linear, &[1, 2])]); + let connectivity = Connectivity::build(3, &paths); + + assert_eq!(start_connected(0, &paths, &connectivity).len(), 1); + assert!(end_connected(0, &paths, &connectivity).is_empty()); + + assert_eq!(start_connected(1, &paths, &connectivity).len(), 1); + assert_eq!(end_connected(1, &paths, &connectivity).len(), 1); + assert_eq!(all_connected(1, &paths, &connectivity).len(), 2); + } + + #[test] + fn point_ids_resolve_both_ways() { + let pool = pool(4); + + for index in 0..4 { + let id = point_id_at(&pool, index).expect("every point was named"); + assert_eq!(index_of_point_id(&pool, id), Some(index)); + } + } +} diff --git a/node-graph/libraries/vector-types/src/vector/mod.rs b/node-graph/libraries/vector-types/src/vector/mod.rs index 59380f952bc..e290c269b06 100644 --- a/node-graph/libraries/vector-types/src/vector/mod.rs +++ b/node-graph/libraries/vector-types/src/vector/mod.rs @@ -1,3 +1,4 @@ +mod addressing; pub mod algorithms; pub mod click_target; mod connectivity; @@ -10,6 +11,7 @@ mod vector_attributes; mod vector_modification; mod vector_types; +pub use addressing::*; pub use connectivity::*; pub use contour::*; pub use path::*; diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 023b086a4a2..16fb9a092cd 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -12,9 +12,9 @@ use std::iter::zip; macro_rules! create_ids { ($($id:ident),*) => { $( - #[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Ord, Eq, Hash, graphene_hash::CacheHash, DynAny)] + #[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd, Ord, Eq, Hash, graphene_hash::CacheHash, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] - /// A strongly typed ID + /// A strongly typed ID, defaulting to [`Self::ZERO`]. pub struct $id(u64); impl $id { From 19c2e4b8e5bdd66822e6d7b9137c80812eec7dfc Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 15 Oct 2026 23:35:12 -0700 Subject: [PATCH 8/8] Give the 'flatten_add' test its own node ID counter so it can't flake --- node-graph/graph-craft/src/document.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/node-graph/graph-craft/src/document.rs b/node-graph/graph-craft/src/document.rs index fc9a1d8fed4..6297ceab3e4 100644 --- a/node-graph/graph-craft/src/document.rs +++ b/node-graph/graph-craft/src/document.rs @@ -1339,6 +1339,7 @@ mod test { use crate::proto::{ConstructionArgs, ProtoNetwork, ProtoNode}; use std::sync::atomic::AtomicU64; + /// Generates IDs from a counter all tests share, so the IDs a test gets depend on what else runs in parallel. fn gen_node_id() -> NodeId { static NODE_ID: AtomicU64 = AtomicU64::new(4); NodeId(NODE_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst)) @@ -1450,7 +1451,12 @@ mod test { ..Default::default() }; network.populate_dependants(); - network.flatten_with_fns(NodeId(1), |self_id, inner_id| NodeId(self_id.0 * 10 + inner_id.0), gen_node_id); + + // A counter of its own, since the expected network names the nodes by the IDs it generates + let next_id = AtomicU64::new(4); + let gen_id = || NodeId(next_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst)); + network.flatten_with_fns(NodeId(1), |self_id, inner_id| NodeId(self_id.0 * 10 + inner_id.0), gen_id); + let expected = flatten_add_expected(); println!("{expected:#?}"); println!("{network:#?}");