diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/merge_by_distance.rs b/node-graph/libraries/vector-types/src/vector/algorithms/merge_by_distance.rs index 3bed3916cf1..03e0c9d0004 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/merge_by_distance.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/merge_by_distance.rs @@ -103,10 +103,12 @@ impl MergeByDistanceExt for Vector { // 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; @@ -130,7 +132,7 @@ impl MergeByDistanceExt for Vector { } fn merge_by_distance_spatial(&mut self, paths: &mut List, transform: DAffine2, distance: f64) { - let working = WorkingVector::flatten(self, paths); + let mut working = WorkingVector::flatten(self, paths); let point_count = working.point_positions.len(); // Find min x and y for grid cell normalization @@ -212,7 +214,13 @@ impl MergeByDistanceExt for Vector { } } - // Build the merged working form, skipping segments with endpoints that collapsed together + // 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, @@ -220,16 +228,15 @@ impl MergeByDistanceExt for Vector { ..Default::default() }; - for segment_index in 0..working.segment_ids.len() { + 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(); - if new_start != new_end { - 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_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); diff --git a/node-graph/libraries/vector-types/src/vector/contour.rs b/node-graph/libraries/vector-types/src/vector/contour.rs index 10c72301000..b0cf78faf52 100644 --- a/node-graph/libraries/vector-types/src/vector/contour.rs +++ b/node-graph/libraries/vector-types/src/vector/contour.rs @@ -4,212 +4,228 @@ 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. +/// One path's part in a contour, crossed against its stored direction when reversed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Step { +pub struct ContourPart { 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. +/// Paths stitched end to end into one outline, as [`contours`] describes. +/// A closed contour arrives back at the point it left from. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct Subpath { - pub steps: Vec, +pub struct Contour { + pub parts: 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, +/// The path ends meeting at each pool point, kept up to the two a weld can stitch, beside every spoke there. +struct Welds { + ends: Vec<[(u32, bool); 2]>, + end_count: Vec, + spokes: Vec, + /// Which paths can be drawn whole: at least one segment, no partial one, and every reference inside the pool. + drawable: Vec, } -/// 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 Welds { + fn new(point_count: usize, paths: &List, curve_types: &[CurveType]) -> Self { + let mut welds = Self { + ends: vec![[(0, false); 2]; point_count], + end_count: vec![0; point_count], + spokes: vec![0; point_count], + drawable: vec![false; paths.len()], + }; + + for (path_index, path) in paths.iter_element_values().enumerate() { + let references = path.0.iter_element_values().as_slice(); + let stride = curve_types.get(path_index).copied().unwrap_or_default().stride(); + let last = references.len().saturating_sub(1); + if last == 0 || !last.is_multiple_of(stride) || references.iter().any(|&point| point as usize >= point_count) { + continue; + } + welds.drawable[path_index] = true; -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]; - } + for (index, &point) in references.iter().enumerate().step_by(stride) { + let Some(spokes) = welds.spokes.get_mut(point as usize) else { continue }; + *spokes = spokes.saturating_add(u8::from(index > 0) + u8::from(index < last)); + } - 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; + // A closed path's two ends are one anchor, which welds with nothing + let (start, end) = (references[0], references[last]); + if start != end { + welds.add_end(start as u32, path_index as u32, false); + welds.add_end(end as u32, path_index as u32, true); } } - 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, - } + welds } - /// 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); - } + fn add_end(&mut self, point: u32, path: u32, arriving: bool) { + let Some(count) = self.end_count.get_mut(point as usize) else { return }; + if let Some(slot) = self.ends[point as usize].get_mut(*count as usize) { + *slot = (path, arriving); } - - None + *count = count.saturating_add(1); } - /// 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; + /// The one other path end at the point, when exactly two path ends meet there and nothing passes through. + fn other_end(&self, point: u32, this: (u32, bool)) -> Option<(u32, bool)> { + let point = point as usize; + if *self.end_count.get(point)? != 2 || *self.spokes.get(point)? != 2 { + return None; } + + self.ends[point].iter().copied().find(|&end| end != this) } } -/// 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 { - subpaths_with(point_count, paths, &curve_types(paths)) +/// Stitches the paths into contours in O(points + references), each seeded by the earliest stored path not yet taken +/// and running in that path's direction until it arrives back where it set out. A path runs on into the next stored +/// path when that one sets out from where it ends, and otherwise into the one path welded to its end where exactly two +/// path ends meet and nothing passes through. Every other meeting is a junction, where paths end. A closed path is +/// always a contour of its own, and a path that cannot be drawn whole takes no part. +pub fn contours(point_count: usize, paths: &List) -> Vec { + contours_with(point_count, paths, &curve_types(paths)) } -/// The curve type of every path, read once so the walkers below never look one up per span. +/// The curve type of every path, read once so the stitching below never looks one up per path. fn curve_types(paths: &List) -> Vec { (0..paths.len()).map(|index| curve_type_at(paths, index)).collect() } -fn subpaths_with(point_count: usize, paths: &List, curve_types: &[CurveType]) -> Vec { - let mut edges = Vec::new(); - for (path_index, path) in paths.iter_element_values().enumerate() { - let curve_type = curve_types.get(path_index).copied().unwrap_or_default(); - let stride = curve_type.stride(); - let references = path.0.iter_element_values().as_slice(); +fn contours_with(point_count: usize, paths: &List, curve_types: &[CurveType]) -> Vec { + let welds = Welds::new(point_count, paths, curve_types); + let ends_of = |path: u32| match paths.element(path as usize)?.0.iter_element_values().as_slice() { + [start, .., end] if welds.drawable[path as usize] => Some((*start as u32, *end as u32)), + _ => None, + }; - for span in 0..path.segment_count(curve_type) { - let Some(references) = references.get(span * stride..=(span + 1) * stride) else { continue }; + // The points a part is crossed from and to, in the direction the contour runs along it + let crossing_ends = |part: ContourPart| ends_of(part.path).map(|(start, end)| if part.reversed { (end, start) } else { (start, end) }); - // A span naming a point outside the pool cannot be drawn, so it takes no part in the walk - if references.iter().any(|&reference| !(0..point_count as i64).contains(&reference)) { - continue; - } - let (Some(&start), Some(&end)) = (references.first(), references.last()) else { continue }; + // The stored neighbor that carries on from this part at the point: onward, the one setting out from it, and backward, the one arriving at it + let consecutive = |part: ContourPart, point: u32, onward: bool| { + let step = if onward != part.reversed { 1 } else { -1 }; + let candidate = ContourPart { + path: u32::try_from(part.path as i64 + step).ok()?, + reversed: part.reversed, + }; + let (from, to) = crossing_ends(candidate)?; - edges.push(Edge { - path: path_index as u32, - span: span as u32, - start: start as u32, - end: end as u32, - }); - } - } + (from != to && if onward { from } else { to } == point).then_some(candidate) + }; - let mut span_ends = SpanEnds::new(point_count, &edges); + // The one path welded to this part's end at the point, crossed away from it + let welded = |part: ContourPart, point: u32, onward: bool| { + let (path, arriving) = welds.other_end(point, (part.path, onward != part.reversed))?; + + Some(ContourPart { path, reversed: arriving == onward }) + }; + + let mut consumed = vec![false; paths.len()]; 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); + + for seed in 0..paths.len() as u32 { + if consumed[seed as usize] { + continue; } - 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, + let Some((start, end)) = ends_of(seed) else { continue }; + consumed[seed as usize] = true; + + let mut parts = vec![ContourPart { path: seed, reversed: false }]; + if start == end { + found.push(Contour { + parts, + closed: true, + entry: start, + exit: start, }); - span_ends.take(next, end ^ 1); - point = next; + continue; + } - if next == entry { + // Onward from the seed's end until the contour arrives back where it set out or nothing carries on + let (mut exit, mut last) = (end, parts[0]); + let mut closed = false; + loop { + if exit == start { closed = true; break; } + let unconsumed = |part: &ContourPart| !consumed[part.path as usize]; + let Some(next) = consecutive(last, exit, true).filter(unconsumed).or_else(|| welded(last, exit, true).filter(unconsumed)) else { + break; + }; + let Some((_, to)) = crossing_ends(next) else { break }; + + consumed[next.path as usize] = true; + parts.push(next); + (exit, last) = (to, next); + } + + // Backward from the seed's start, so the contour begins where the stitched paths run out or meet its end + let (mut entry, mut first) = (start, parts[0]); + let mut before = Vec::new(); + while !closed { + let unconsumed = |part: &ContourPart| !consumed[part.path as usize]; + let Some(previous) = consecutive(first, entry, false).filter(unconsumed).or_else(|| welded(first, entry, false).filter(unconsumed)) else { + break; + }; + let Some((from, _)) = crossing_ends(previous) else { break }; + + consumed[previous.path as usize] = true; + before.push(previous); + (entry, first) = (from, previous); + closed = entry == exit; } + before.reverse(); + before.append(&mut parts); - found.push(Subpath { - steps, + found.push(Contour { + parts: before, closed, - entry: entry as u32, - exit: point as u32, + entry, + exit: if closed { entry } else { exit }, }); } 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 { - subpath_to_bezpath_with(pool, paths, &curve_types(paths), subpath) -} - -fn subpath_to_bezpath_with(pool: &List, paths: &List, curve_types: &[CurveType], subpath: &Subpath) -> Option { +/// Emits one contour as a bezpath, or `None` when its entry point is not in the pool. +fn contour_to_bezpath(pool: &List, paths: &List, curve_types: &[CurveType], contour: &Contour) -> Option { let mut bezpath = BezPath::new(); - bezpath.move_to(dvec2_to_point(*pool.element(subpath.entry as usize)?)); + bezpath.move_to(dvec2_to_point(*pool.element(contour.entry as usize)?)); - for step in &subpath.steps { - let path = paths.element(step.path as usize)?; - let segment = path.segment(pool, *curve_types.get(step.path as usize)?, step.span as usize)?; + for part in &contour.parts { + let path = paths.element(part.path as usize)?; + let curve_type = *curve_types.get(part.path as usize)?; + let count = path.segment_count(curve_type); - append_segment(&mut bezpath, if step.reversed { segment.reverse() } else { segment }); + for step in 0..count { + let span = if part.reversed { count - 1 - step } else { step }; + let Some(segment) = path.segment(pool, curve_type, span) else { continue }; + + append_segment(&mut bezpath, if part.reversed { segment.reverse() } else { segment }); + } } - if subpath.closed { + if contour.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 { +/// Every contour as a bezpath, in the order the paths are stored. +pub fn contour_bezpaths(pool: &List, paths: &List) -> Vec { let curve_types = curve_types(paths); - subpaths_with(pool.len(), paths, &curve_types) + contours_with(pool.len(), paths, &curve_types) .iter() - .filter_map(|subpath| subpath_to_bezpath_with(pool, paths, &curve_types, subpath)) + .filter_map(|contour| contour_to_bezpath(pool, paths, &curve_types, contour)) .collect() } @@ -235,22 +251,25 @@ mod tests { (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) + /// The anchors each contour visits in order, with whether it closed. + fn stitched(point_count: usize, paths: &List) -> Vec<(Vec, bool)> { + contours(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.reference(step.span as usize), path.reference(step.span as usize + 1)); - anchors.push(if step.reversed { start } else { end }.expect("the span has both ends")); + .map(|contour| { + let mut anchors = vec![contour.entry]; + for part in &contour.parts { + let path = paths.element(part.path as usize).expect("the part names a path"); + let mut references: Vec = path.references().collect(); + if part.reversed { + references.reverse(); + } + anchors.extend(references.into_iter().skip(1)); } - if subpath.closed { + if contour.closed { anchors.pop(); } - (anchors, subpath.closed) + (anchors, contour.closed) }) .collect() } @@ -267,7 +286,7 @@ mod tests { let (pool, paths) = pool_and_paths_from_bezpath(&source); assert_eq!(paths.len(), 3); - let assembled = contours(&pool, &paths); + let assembled = contour_bezpaths(&pool, &paths); assert_eq!(assembled.len(), 1); assert!(matches!( assembled[0].elements(), @@ -289,7 +308,7 @@ mod tests { }); let (pool, paths) = pool_and_paths_from_bezpath(&source); - assert_eq!(contours(&pool, &paths).len(), 2); + assert_eq!(contour_bezpaths(&pool, &paths).len(), 2); } // An open subpath emits one contour that stays open @@ -303,64 +322,106 @@ mod tests { let (pool, paths) = pool_and_paths_from_bezpath(&source); assert_eq!(paths.len(), 2); - let assembled = contours(&pool, &paths); + let assembled = contour_bezpaths(&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 + // A loop is read from the anchor it is stored from, in the direction it is stored in #[test] - fn loop_walks_backwards_when_its_earliest_span_arrives() { + fn a_loop_keeps_its_stored_start_and_direction() { let (_, paths) = linear(4, &[&[1, 2, 3, 0, 1]]); - assert_eq!(walked(4, &paths), [(vec![0, 3, 2, 1], true)]); + assert_eq!(stitched(4, &paths), [(vec![1, 2, 3, 0], true)]); } - // An open subpath leaves from its lower end even when that is where it was woven to + // A path welded ahead of the first one is stitched on in front, running its own stored direction #[test] - fn open_subpath_leaves_from_its_lower_end() { + fn a_welded_path_is_stitched_on_in_front() { let (_, paths) = linear(4, &[&[0, 1, 2], &[3, 0]]); - assert_eq!(walked(4, &paths), [(vec![2, 1, 0, 3], false)]); + assert_eq!(stitched(4, &paths), [(vec![3, 0, 1, 2], false)]); } - // Open subpaths lead, however high their points, and loops follow in the order of their lowest points + // Contours come out in the order their paths are stored, open or closed #[test] - fn open_subpaths_lead_the_loops() { + fn contours_follow_path_order() { 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)]); + assert_eq!(stitched(8, &paths), [(vec![5, 6, 7], true), (vec![0, 1, 2], true), (vec![3, 4], false)]); } - // Loops that touch an outline are walked apart from it, since the outline's own spans were woven first + // Loops that touch an outline are each their own contour, since a closed path welds with nothing #[test] - fn touching_loops_walk_apart_from_the_outline() { + fn touching_loops_stay_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)]); + assert_eq!(stitched(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 + // Three paths meeting at one anchor make a junction, where every one of them ends #[test] - fn junction_is_walked_through_along_its_earliest_span() { + fn every_path_ends_at_a_junction() { let (_, paths) = linear(4, &[&[0, 3], &[1, 3], &[2, 3]]); - assert_eq!(walked(4, &paths), [(vec![0, 3, 1], false), (vec![2, 3], false)]); + assert_eq!(stitched(4, &paths), [(vec![0, 3], false), (vec![1, 3], false), (vec![2, 3], false)]); + } + + // A path passing through a junction stays whole, and a path ending there is not stitched onto it + #[test] + fn a_path_through_a_junction_stays_whole() { + let (_, paths) = linear(5, &[&[0, 1, 2, 3, 1], &[4, 1]]); + + assert_eq!(stitched(5, &paths), [(vec![0, 1, 2, 3, 1], false), (vec![4, 1], false)]); + } + + // Consecutive paths meeting head to tail are one stroke even through a junction, while the path passing through it stays whole + #[test] + fn consecutive_paths_carry_on_through_a_junction() { + let (_, paths) = linear(5, &[&[0, 1], &[1, 2, 0], &[3, 1, 4]]); + + assert_eq!(stitched(5, &paths), [(vec![0, 1, 2], true), (vec![3, 1, 4], false)]); + } + + // Crossing a stored path backward carries on backward into the path stored before it, here through the junction at point 1 + #[test] + fn a_backward_crossing_carries_on_into_the_path_before() { + let (_, paths) = linear(8, &[&[5, 2], &[0, 1], &[1, 2], &[7, 1]]); + + assert_eq!(stitched(8, &paths), [(vec![5, 2, 1, 0], false), (vec![7, 1], false)]); + } + + // Two halves both running top to bottom, welded at both ends, render as one outline with the second half crossed backwards + #[test] + fn heart_halves_stitch_into_one_outline() { + let (_, paths) = linear(4, &[&[0, 1, 2], &[0, 3, 2]]); + + assert_eq!(stitched(4, &paths), [(vec![0, 1, 2, 3], true)]); + assert!(contours(4, &paths)[0].parts[1].reversed); + } + + // A walk that stops at a junction still closes when stitching backward from its start arrives at that junction + #[test] + fn a_contour_closes_when_the_walk_back_meets_its_end() { + let (_, paths) = linear(5, &[&[0, 1], &[2, 0], &[1, 2], &[3, 2, 4]]); + + assert_eq!(stitched(5, &paths), [(vec![2, 0, 1], true), (vec![3, 2, 4], false)]); } - // A walk that arrives back at a point other than the one it left from stays open + // A closed path is never stitched onto the path stored before it, even when it sets out from that path's end #[test] - fn loop_on_a_tail_stays_open() { - let (_, paths) = linear(4, &[&[0, 1, 2, 3, 1]]); + fn a_closed_path_is_never_stitched_on() { + let (_, paths) = linear(4, &[&[3, 0], &[0, 1, 2, 0]]); - assert_eq!(walked(4, &paths), [(vec![0, 1, 2, 3, 1], false)]); + assert_eq!(stitched(4, &paths), [(vec![3, 0], false), (vec![0, 1, 2], true)]); } - // A span naming a point outside the pool is left out of the walk + // A path naming a point outside the pool, or ending partway through a segment, is left out whole #[test] - fn dangling_span_is_left_out() { - let (_, paths) = linear(3, &[&[0, 1, 9], &[1, 2]]); + fn a_path_that_cannot_be_drawn_whole_is_left_out() { + let (pool, paths) = linear(3, &[&[0, 1, 9], &[1, 2]]); - assert_eq!(walked(3, &paths), [(vec![0, 1, 2], false)]); + assert_eq!(stitched(3, &paths), [(vec![1, 2], false)]); + assert_eq!(contour_bezpaths(&pool, &paths).len(), 1); } } diff --git a/node-graph/libraries/vector-types/src/vector/flatten.rs b/node-graph/libraries/vector-types/src/vector/flatten.rs index f8734ffda2d..3296a5807fa 100644 --- a/node-graph/libraries/vector-types/src/vector/flatten.rs +++ b/node-graph/libraries/vector-types/src/vector/flatten.rs @@ -20,6 +20,8 @@ pub struct WorkingVector { pub segment_start: Vec, pub segment_end: Vec, pub segment_handles: Vec, + /// Which segments open a path of their own, kept as path boundaries when rewoven. Missing entries count as false. + pub segment_begins_path: Vec, pub colinear: Vec<[HandleId; 2]>, } @@ -46,6 +48,7 @@ impl WorkingVector { let stride = curve_type.stride(); let span_ids = path.0.iter_attribute_values::(ATTR_ID).map(|ids| ids.as_slice()).unwrap_or_default(); + let mut begins_path = true; for span in 0..path.segment_count(curve_type) { let Some(segment) = path.segment(&vector.0, curve_type, span) else { continue }; let (Some(start), Some(end)) = (path.reference(span * stride), path.reference(span * stride + stride)) else { @@ -57,6 +60,8 @@ impl WorkingVector { working.segment_handles.push(segment_to_handles(&segment)); working.segment_start.push(start); working.segment_end.push(end); + working.segment_begins_path.push(begins_path); + begins_path = false; } } @@ -65,7 +70,21 @@ impl WorkingVector { working } - /// Weaves the working form back into a pool and paths, gathering consecutive segments that meet head to tail into one path. + /// Hands each removed segment's mark as the beginning of a path to the next kept segment, which begins what is left of that path. + pub(crate) fn carry_path_beginnings(&mut self, keep: &[bool]) { + let mut carried = false; + for (begins, &kept) in self.segment_begins_path.iter_mut().zip(keep) { + if !kept { + carried |= *begins; + } else if carried { + *begins = true; + carried = false; + } + } + } + + /// Weaves the working form back into a pool and paths, gathering consecutive segments that meet head to tail into one path + /// until it closes or a segment begins a path of its own. pub fn reweave(self, vector: &mut Vector, paths: &mut List) { *paths = List::new(); @@ -83,7 +102,7 @@ impl WorkingVector { let mut segment_anchors = FxHashMap::default(); let ends = self.segment_start.iter().zip(&self.segment_end); - for ((&id, (&start, &end)), handles) in self.segment_ids.iter().zip(ends).zip(&self.segment_handles) { + for (index, ((&id, (&start, &end)), handles)) in self.segment_ids.iter().zip(ends).zip(&self.segment_handles).enumerate() { if start >= anchor_count || end >= anchor_count { continue; } @@ -94,11 +113,13 @@ impl WorkingVector { BezierHandles::Cubic { handle_start, handle_end } => (CurveType::Cubic, vec![handle_start, handle_end]), }; - // Runs stay in segment order, since the order spans are woven in decides how subpaths are walked. - // A run carries on through a junction and gives way only once it has closed. - let continues_run = runs - .last() - .is_some_and(|(run_curve_type, references, _)| *run_curve_type == curve_type && references.last() == Some(&(start as u32)) && references.first() != references.last()); + // Runs stay in segment order, which is the order the paths come out in. A run carries on through a + // junction and gives way once it has closed, or when the next segment opens a path of its own. + let begins_path = self.segment_begins_path.get(index).copied().unwrap_or(false); + let continues_run = !begins_path + && runs + .last() + .is_some_and(|(run_curve_type, references, _)| *run_curve_type == curve_type && references.last() == Some(&(start as u32)) && references.first() != references.last()); if !continues_run { runs.push((curve_type, vec![start as u32], Vec::new())); } @@ -140,11 +161,163 @@ impl WorkingVector { vector.set_smooth_at_indices(paths, smooth_anchors, true); } + + /// Reorders and reorients the segments so each continues the one it meets head to tail, taking the straightest + /// route through every junction, and marks where each chain begins. This bakes path structure into a segment soup once. + pub fn chain_by_straightest_route(&mut self) { + let segment_count = [self.segment_ids.len(), self.segment_start.len(), self.segment_end.len(), self.segment_handles.len()] + .into_iter() + .min() + .unwrap_or(0); + let point_count = self.point_positions.len(); + + // Every segment end at each point, named by twice the segment's index plus one for its arriving end + let mut ends_at: Vec> = vec![Vec::new(); point_count]; + for index in 0..segment_count { + let (start, end) = (self.segment_start[index], self.segment_end[index]); + if start < point_count && end < point_count { + ends_at[start].push(index * 2); + ends_at[end].push(index * 2 + 1); + } + } + + // The direction a segment leaves its anchor in from the given end, toward the nearest control point not sitting on the anchor + let direction = |end: usize| { + let (index, arriving) = (end / 2, end % 2 == 1); + let (start, finish) = (self.point_positions[self.segment_start[index]], self.point_positions[self.segment_end[index]]); + let handles = self.segment_handles[index]; + let (anchor, toward) = if arriving { + (finish, [handles.end(), handles.start(), Some(start)]) + } else { + (start, [handles.start(), handles.end(), Some(finish)]) + }; + + let along = toward.into_iter().flatten().map(|point| point - anchor).find(|along| along.length_squared() > 1e-12); + along.unwrap_or_default().normalize_or_zero() + }; + + // Pair off the ends meeting at each point, the straightest pair through a junction first, in O(k² log k) for k ends there + let mut partner = vec![usize::MAX; segment_count * 2]; + for ends in ends_at.iter().filter(|ends| ends.len() >= 2) { + let directions: Vec = ends.iter().map(|&end| direction(end)).collect(); + + // Straightness is rounded so pairs differing only by float error are equally straight, leaving the earlier stored ends first + let mut pairs: Vec<(i64, usize, usize)> = (0..ends.len()) + .flat_map(|i| (i + 1..ends.len()).map(move |j| (i, j))) + .map(|(i, j)| ((-directions[i].dot(directions[j]) * 1e9).round() as i64, i, j)) + .collect(); + pairs.sort_by(|a, b| b.0.cmp(&a.0).then((a.1, a.2).cmp(&(b.1, b.2)))); + + let mut paired = vec![false; ends.len()]; + for (_, i, j) in pairs { + if paired[i] || paired[j] { + continue; + } + paired[i] = true; + paired[j] = true; + partner[ends[i]] = ends[j]; + partner[ends[j]] = ends[i]; + } + } + + // Follow the pairings into chains, each seeded by the earliest unvisited segment and keeping its direction + let mut visited = vec![false; segment_count]; + let mut order: Vec<(usize, bool)> = Vec::with_capacity(segment_count); + let mut begins_path = Vec::with_capacity(segment_count); + for seed in 0..segment_count { + if std::mem::replace(&mut visited[seed], true) { + continue; + } + + let mut chain = vec![(seed, false)]; + let mut closed = false; + let mut end = seed * 2 + 1; + loop { + let next = partner[end]; + if next == usize::MAX { + break; + } + let segment = next / 2; + if segment == seed { + closed = true; + break; + } + if std::mem::replace(&mut visited[segment], true) { + break; + } + + // Entering at the segment's arriving end crosses it backwards + let reversed = next % 2 == 1; + chain.push((segment, reversed)); + end = if reversed { segment * 2 } else { segment * 2 + 1 }; + } + + // An open chain also grows backwards from the seed's start, which is then where it begins + let mut before = Vec::new(); + let mut end = seed * 2; + if !closed { + loop { + let next = partner[end]; + if next == usize::MAX { + break; + } + let segment = next / 2; + if std::mem::replace(&mut visited[segment], true) { + break; + } + + // Joining at the segment's arriving end means it runs forward into the chain + let reversed = next % 2 == 0; + before.push((segment, reversed)); + end = if reversed { segment * 2 + 1 } else { segment * 2 }; + } + } + before.reverse(); + + begins_path.extend((0..before.len() + chain.len()).map(|position| position == 0)); + order.append(&mut before); + order.append(&mut chain); + } + + // Rebuild the segment arrays in chain order, turning around the segments that run against their chain + let mut reversed_ids = FxHashSet::default(); + let (mut ids, mut starts, mut ends, mut handles) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for &(segment, reversed) in &order { + ids.push(self.segment_ids[segment]); + let (start, end) = (self.segment_start[segment], self.segment_end[segment]); + if reversed { + reversed_ids.insert(self.segment_ids[segment]); + starts.push(end); + ends.push(start); + handles.push(self.segment_handles[segment].reversed()); + } else { + starts.push(start); + ends.push(end); + handles.push(self.segment_handles[segment]); + } + } + self.segment_ids = ids; + self.segment_start = starts; + self.segment_end = ends; + self.segment_handles = handles; + self.segment_begins_path = begins_path; + + // A turned segment's handles swap sides, so the G1 pairs naming them follow + for handle in self.colinear.iter_mut().flatten() { + if reversed_ids.contains(&handle.segment) { + handle.ty = match handle.ty { + HandleType::Primary => HandleType::End, + HandleType::End => HandleType::Primary, + }; + } + } + } } // TODO: Eventually remove this document upgrade code -/// Builds storage from the legacy flat arrays: anchors with IDs and positions, segments with IDs, -/// endpoint indices, and absolute handles, plus G1 pairs. Used by document migration. +/// Builds storage from the legacy flat arrays: anchors with IDs and positions, segments with IDs, endpoint indices, +/// and absolute handles, plus G1 pairs. Legacy segments carry no path structure, so they are chained by the straightest +/// route through each junction before weaving. Used by document migration. pub fn vector_from_legacy_parts( point_ids: Vec, point_positions: Vec, @@ -154,15 +327,17 @@ pub fn vector_from_legacy_parts( segment_handles: Vec, colinear: Vec<[HandleId; 2]>, ) -> (Vector, List) { - let working = WorkingVector { + let mut working = WorkingVector { point_ids, point_positions, segment_ids, segment_start, segment_end, segment_handles, + segment_begins_path: Vec::new(), colinear, }; + working.chain_by_straightest_route(); let (mut vector, mut paths) = (Vector::default(), List::new()); working.reweave(&mut vector, &mut paths); @@ -173,7 +348,7 @@ pub fn vector_from_legacy_parts( #[cfg(test)] mod tests { use super::*; - use core_types::list::{ATTR_CURVE_TYPE, ATTR_ID, Item}; + use core_types::list::{ATTR_CURVE_TYPE, ATTR_ID, ATTR_SMOOTH, Item}; fn working(points: usize, segments: &[(usize, usize, BezierHandles)]) -> WorkingVector { WorkingVector { @@ -183,10 +358,18 @@ mod tests { segment_start: segments.iter().map(|&(start, ..)| start).collect(), segment_end: segments.iter().map(|&(_, end, _)| end).collect(), segment_handles: segments.iter().map(|(.., handles)| *handles).collect(), + segment_begins_path: Vec::new(), colinear: Vec::new(), } } + fn working_at(positions: &[DVec2], segments: &[(usize, usize, BezierHandles)]) -> WorkingVector { + let mut working = working(positions.len(), segments); + working.point_positions = positions.to_vec(); + + working + } + fn woven(working: WorkingVector) -> (Vector, List) { let (mut vector, mut paths) = (Vector::default(), List::new()); working.reweave(&mut vector, &mut paths); @@ -341,6 +524,118 @@ mod tests { assert_eq!(references(&paths, 0), [0, 1, 2]); } + // Flattened paths welded head to tail stay apart when rewoven, since a path boundary is data of its own + #[test] + fn reweave_keeps_flattened_paths_apart() { + let mut vector = Vector::default(); + for index in 0..4 { + vector.0.push(Item::new_from_element(DVec2::new(index as f64, 0.)).with_attribute(ATTR_ID, PointId::generate())); + } + let mut paths = List::new(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2], [SegmentId::generate(), SegmentId::generate()]); + push_path(&mut paths, CurveType::Linear, [2, 3], [SegmentId::generate()]); + + let working = WorkingVector::flatten(&vector, &paths); + assert_eq!(working.segment_begins_path, vec![true, false, true]); + + let (mut rewoven, mut rewoven_paths) = (Vector::default(), List::new()); + working.reweave(&mut rewoven, &mut rewoven_paths); + assert_eq!(rewoven_paths.len(), 2); + assert_eq!(references(&rewoven_paths, 0), [0, 1, 2]); + assert_eq!(references(&rewoven_paths, 1), [2, 3]); + } + + // The bar of a T keeps going through the junction, being the straightest route, and the stem starts its own path + #[test] + fn straightest_route_continues_through_a_junction() { + let positions = [DVec2::new(0., 0.), DVec2::new(1., 0.), DVec2::new(2., 0.), DVec2::new(1., 1.)]; + let soup = [(1, 3, BezierHandles::Linear), (0, 1, BezierHandles::Linear), (1, 2, BezierHandles::Linear)]; + let mut working = working_at(&positions, &soup); + + working.chain_by_straightest_route(); + assert_eq!(working.segment_begins_path, vec![true, true, false]); + + let (_, paths) = woven(working); + assert_eq!(paths.len(), 2); + assert_eq!(references(&paths, 0), [1, 3]); + assert_eq!(references(&paths, 1), [0, 1, 2]); + } + + // A curve whose end handle is retracted onto the junction arrives along its other handle, so it carries on downward rather than along its chord + #[test] + fn straightest_route_reads_a_retracted_handle_from_the_other_one() { + let positions = [DVec2::new(-10., 0.), DVec2::ZERO, DVec2::new(0., -10.), DVec2::new(10., 0.)]; + let curve = BezierHandles::Cubic { + handle_start: DVec2::new(0., 10.), + handle_end: DVec2::ZERO, + }; + let soup = [(0, 1, curve), (1, 3, BezierHandles::Linear), (1, 2, BezierHandles::Linear)]; + let mut working = working_at(&positions, &soup); + let ids = working.segment_ids.clone(); + + working.chain_by_straightest_route(); + assert_eq!(working.segment_ids, vec![ids[0], ids[2], ids[1]]); + assert_eq!(working.segment_begins_path, vec![true, false, true]); + } + + // Four segments all drawn into one crossing pair off into two straight lines, turning around the ones drawn the other way + #[test] + fn crossing_lines_each_stay_whole() { + let positions = [DVec2::new(-1., 0.), DVec2::new(1., 0.), DVec2::new(0., -1.), DVec2::new(0., 1.), DVec2::ZERO]; + let soup = [ + (0, 4, BezierHandles::Linear), + (2, 4, BezierHandles::Linear), + (1, 4, BezierHandles::Linear), + (3, 4, BezierHandles::Linear), + ]; + let mut working = working_at(&positions, &soup); + let ids = working.segment_ids.clone(); + + working.chain_by_straightest_route(); + assert_eq!(working.segment_ids, vec![ids[0], ids[2], ids[1], ids[3]]); + assert_eq!(working.segment_start, vec![0, 4, 2, 4]); + assert_eq!(working.segment_end, vec![4, 1, 4, 3]); + + let (_, paths) = woven(working); + assert_eq!(paths.len(), 2); + assert_eq!(references(&paths, 0), [0, 4, 1]); + assert_eq!(references(&paths, 1), [2, 4, 3]); + } + + // A G1 pair follows its segment when that segment is turned around, so the shared anchor still comes out smooth + #[test] + fn a_turned_segment_keeps_its_smooth_pair() { + let cubic = |from: DVec2, to: DVec2| BezierHandles::Cubic { + handle_start: from + DVec2::new(1., 0.), + handle_end: to - DVec2::new(1., 0.), + }; + let positions = [DVec2::new(0., 0.), DVec2::new(3., 0.), DVec2::new(6., 0.)]; + let ids = [SegmentId::generate(), SegmentId::generate()]; + + // The second segment runs back toward the shared anchor, so both handles facing it, one on each side, are stored as end handles + let (_, paths) = vector_from_legacy_parts( + (0..3).map(|_| PointId::generate()).collect(), + positions.to_vec(), + ids.to_vec(), + vec![0, 2], + vec![1, 1], + vec![ + cubic(positions[0], positions[1]), + BezierHandles::Cubic { + handle_start: positions[2] - DVec2::X, + handle_end: positions[1] + DVec2::X, + }, + ], + vec![[HandleId::end(ids[0]), HandleId::end(ids[1])]], + ); + + assert_eq!(paths.len(), 1); + assert_eq!(curve_type_at(&paths, 0), CurveType::Cubic); + assert_eq!(references(&paths, 0), [0, 3, 4, 1, 5, 6, 2]); + let path = paths.element(0).expect("the chain became one path"); + assert_eq!(path.0.attribute::(ATTR_SMOOTH, 3), Some(&true)); + } + // Gathering runs stays invisible to the bridge: the same segments come back in the same order #[test] fn reweave_round_trips_through_flatten() { diff --git a/node-graph/libraries/vector-types/src/vector/path.rs b/node-graph/libraries/vector-types/src/vector/path.rs index 3e55529aec9..328d3c950a7 100644 --- a/node-graph/libraries/vector-types/src/vector/path.rs +++ b/node-graph/libraries/vector-types/src/vector/path.rs @@ -108,24 +108,30 @@ impl Path { piece } - /// Reverses the path in place, so the weave walks its references in the opposite direction. + /// Reverses the given spans where they sit, so contours cross them in the opposite direction. /// Per-reference attributes travel with their references, and each span's ID moves to the anchor it now departs from. - pub fn reverse(&mut self, curve_type: CurveType) { + pub fn reverse_spans(&mut self, curve_type: CurveType, spans: std::ops::Range) { let stride = curve_type.stride(); - let count = self.segment_count(curve_type); - let ids: Vec> = (0..count).map(|span| self.0.attribute::(ATTR_ID, span * stride).copied()).collect(); + let spans = spans.start..spans.end.min(self.segment_count(curve_type)); + if spans.is_empty() { + return; + } + let (first, last) = (spans.start * stride, spans.end * stride); - self.0 = (0..self.0.len()).rev().filter_map(|index| self.0.clone_item(index)).collect(); + // The cell on the last anchor belongs to whichever span follows these, so it stays where it is + let id_at = |path: &Self, index: usize| path.0.attribute::(ATTR_ID, index).copied(); + let ids: Vec> = spans.clone().map(|span| id_at(self, span * stride)).collect(); + let following_id = id_at(self, last); - // Span k of the reversed path walks old span (count - 1 - k) backwards, so it takes over that span's ID - for span in 0..count { - if let Some(id) = ids[count - 1 - span] { - self.0.set_attribute(ATTR_ID, span * stride, id); + let mirrored = |index: usize| if (first..=last).contains(&index) { first + last - index } else { index }; + self.0 = (0..self.0.len()).filter_map(|index| self.0.clone_item(mirrored(index))).collect(); + + // Each reversed span walks the old span mirrored across the range backwards, so it takes over that span's ID + for (place, id) in ids.into_iter().rev().chain([following_id]).enumerate() { + if let Some(id) = id { + self.0.set_attribute(ATTR_ID, first + place * stride, id); } } - if count > 0 && self.0.attribute::(ATTR_ID, count * stride).is_some() { - self.0.set_attribute(ATTR_ID, count * stride, SegmentId::default()); - } } } @@ -376,6 +382,33 @@ mod tests { ); } + // Turning around the middle of a path keeps every span's ID on the anchor it sets out from + #[test] + fn reversing_spans_where_they_sit() { + let mut lasso = path(&[0, 1, 2, 3, 4, 1, 5]); + let mut segment_id = SegmentId::ZERO; + let ids: Vec = (0..6).map(|_| segment_id.next_id()).collect(); + for (span, &id) in ids.iter().enumerate() { + lasso.0.set_attribute(ATTR_ID, span, id); + } + + lasso.reverse_spans(CurveType::Linear, 1..5); + + assert_eq!(lasso.0.iter_element_values().copied().collect::>(), vec![0, 1, 4, 3, 2, 1, 5]); + let span_ids: Vec = (0..6).map(|span| lasso.0.attribute_cloned_or_default(ATTR_ID, span)).collect(); + assert_eq!(span_ids, [ids[0], ids[4], ids[3], ids[2], ids[1], ids[5]]); + } + + // A partial segment trailing a path is left where it is, so anchors and handles keep their roles + #[test] + fn reversing_leaves_a_partial_tail_in_place() { + let mut ragged = path(&[4, 10, 11, 0, 12, 13, 2, 14]); + + ragged.reverse_spans(CurveType::Cubic, 0..2); + + assert_eq!(ragged.0.iter_element_values().copied().collect::>(), vec![2, 13, 12, 0, 11, 10, 4, 14]); + } + // A reference naming a point the pool does not have drops its segment instead of panicking #[test] fn dangling_reference_skips_its_segment() { 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 1450a6ece55..b4b8adf545d 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -1,5 +1,5 @@ use crate::vector::addressing::{self, Span}; -use crate::vector::contour::subpaths; +use crate::vector::contour::contours; use crate::vector::misc::{ManipulatorGroup, Tangent, point_to_dvec2, segment_to_handles}; use crate::vector::path::{CurveType, Path, append_segment, curve_type_at}; use crate::vector::vector_types::Vector; @@ -746,7 +746,7 @@ impl Vector { /// Construct and return an iterator of `(Vec, bool)` for each stroke. /// The boolean in the tuple indicates if the path is closed. - /// The groups pair index-for-index with the contours emission, both walking the same subpaths. + /// The groups pair index-for-index with the contours emission, both stitching the same contours. pub fn stroke_manipulator_groups(&self, paths: &List) -> impl Iterator, bool)> { let mut groups = Vec::new(); @@ -754,50 +754,53 @@ impl Vector { let ids = self.0.iter_attribute_values::(ATTR_ID).map(|ids| ids.as_slice()).unwrap_or_default(); let id_at = |point: u32| ids.get(point as usize).copied().unwrap_or_default(); - for subpath in subpaths(self.point_count(), paths) { + for contour in contours(self.point_count(), paths) { let mut manipulators_list: Vec = Vec::new(); let mut in_handle: Option = None; - for step in &subpath.steps { - let Some(path) = paths.element(step.path as usize) else { continue }; - let Some(&curve_type) = curve_types.get(step.path as usize) else { continue }; - let Some(segment) = path.segment(&self.0, curve_type, step.span as usize) else { continue }; - - let first = step.span as usize * curve_type.stride(); - let (Some(start), Some(end)) = (path.reference(first), path.reference(first + curve_type.stride())) else { - continue; - }; - - // A quadratic's one handle leaves its anchor whichever way the span is crossed - let handles = segment_to_handles(&segment); - let handles = if step.reversed { handles.reversed() } else { handles }; - let anchor = if step.reversed { end } else { start }; - - manipulators_list.push(ManipulatorGroup { - anchor: point_to_dvec2(if step.reversed { segment.end() } else { segment.start() }), - in_handle, - out_handle: handles.start(), - id: id_at(anchor), - }); - - in_handle = handles.end(); + for part in &contour.parts { + let Some(path) = paths.element(part.path as usize) else { continue }; + let Some(&curve_type) = curve_types.get(part.path as usize) else { continue }; + let (stride, count) = (curve_type.stride(), path.segment_count(curve_type)); + + for step in 0..count { + let span = if part.reversed { count - 1 - step } else { step }; + let Some(segment) = path.segment(&self.0, curve_type, span) else { continue }; + let (Some(start), Some(end)) = (path.reference(span * stride), path.reference(span * stride + stride)) else { + continue; + }; + + // A quadratic's one handle leaves its anchor whichever way the span is crossed + let handles = segment_to_handles(&segment); + let handles = if part.reversed { handles.reversed() } else { handles }; + let anchor = if part.reversed { end } else { start }; + + manipulators_list.push(ManipulatorGroup { + anchor: point_to_dvec2(if part.reversed { segment.end() } else { segment.start() }), + in_handle, + out_handle: handles.start(), + id: id_at(anchor), + }); + + in_handle = handles.end(); + } } - if subpath.closed { + if contour.closed { if let Some(first) = manipulators_list.first_mut() { first.in_handle = in_handle; } - } else if let Some(position) = self.position(subpath.exit as usize) { + } else if let Some(position) = self.position(contour.exit as usize) { manipulators_list.push(ManipulatorGroup { anchor: position, in_handle, out_handle: None, - id: id_at(subpath.exit), + id: id_at(contour.exit), }); } if !manipulators_list.is_empty() { - groups.push((manipulators_list, subpath.closed)); + groups.push((manipulators_list, contour.closed)); } } @@ -916,41 +919,69 @@ impl Vector { } } -/// Reweaves the paths with the given `(path, span)` pairs reversed. Each reversed span keeps its place in span -/// order, which decides how subpaths are walked, so it becomes a path of its own among the pieces left around it. +/// Reweaves the paths with the given `(path, span)` pairs reversed. Reversed spans that run on from one into the next, within +/// a path or across consecutive paths, reverse together as one stretch, so a flipped loop with its pieces stored together is +/// still read from the same anchor. fn reverse_spans_in_place(paths: &List, reversed: &HashSet<(u32, u32)>) -> List { let mut rewoven = List::new(); + // Reversed pieces of the stretch being gathered, output in the opposite order once it ends + let mut stretch: Vec> = Vec::new(); + let mut stretch_end = None; + for path_index in 0..paths.len() { - let Some(template) = paths.clone_item(path_index) else { continue }; + let Some(mut template) = paths.clone_item(path_index) else { continue }; let curve_type = curve_type_at(paths, path_index); + let stride = curve_type.stride(); let span_count = template.element().segment_count(curve_type); - let is_reversed = |span: usize| reversed.contains(&(path_index as u32, span as u32)); + let mut flips: Vec = (0..span_count).map(|span| reversed.contains(&(path_index as u32, span as u32))).collect(); + let run_end = |flips: &[bool], start: usize| (start + 1..span_count).find(|&span| flips[span] != flips[start]).unwrap_or(span_count); + + // A reversed run arriving back at the anchor it leaves from turns around where it sits, which keeps the path whole + let mut start = 0; + while start < span_count { + let end = run_end(&flips, start); + let references = &template.element().0; + if flips[start] && references.element(start * stride) == references.element(end * stride) { + template.element_mut().reverse_spans(curve_type, start..end); + flips[start..end].fill(false); + } + start = end; + } - if !(0..span_count).any(is_reversed) { + if !flips.contains(&true) { + rewoven.extend(stretch.drain(..).rev().collect()); rewoven.push(template); continue; } + // Cut the path into pieces wherever it changes between kept and reversed spans let path = template.element(); - let mut push_piece = |piece: Path| rewoven.push(Item::from_parts(piece, template.attributes().clone())); + let mut start = 0; + while start < span_count { + let end = run_end(&flips, start); + + let mut piece = Item::from_parts(path.clone_span_range(curve_type, start, end), template.attributes().clone()); + let references = &piece.element().0; + let (first, last) = (references.element(0).copied(), references.element(references.len().saturating_sub(1)).copied()); - let mut kept_from = 0; - for span in (0..span_count).filter(|&span| is_reversed(span)) { - if kept_from < span { - push_piece(path.clone_span_range(curve_type, kept_from, span)); + // A reversed piece joins the stretch only by setting out from where the stretch has reached + if !flips[start] || stretch_end != first { + rewoven.extend(stretch.drain(..).rev().collect()); } - let mut flipped = path.clone_span_range(curve_type, span, span + 1); - flipped.reverse(curve_type); - push_piece(flipped); + if flips[start] { + piece.element_mut().reverse_spans(curve_type, 0..end - start); + stretch.push(piece); + stretch_end = last; + } else { + rewoven.push(piece); + } - kept_from = span + 1; - } - if kept_from < span_count { - push_piece(path.clone_span_range(curve_type, kept_from, span_count)); + start = end; } } + rewoven.extend(stretch.drain(..).rev().collect()); rewoven } @@ -959,7 +990,7 @@ fn reverse_spans_in_place(paths: &List, reversed: &HashSet<(u32, u32)>) -> mod tests { use super::*; use crate::vector::path::CurveType; - use core_types::list::{ATTR_CURVE_TYPE, ATTR_ID, Item}; + use core_types::list::ATTR_CURVE_TYPE; use kurbo::Shape; fn build_vector(points: &[DVec2], segments: &[(usize, usize)]) -> (Vector, List) { @@ -981,12 +1012,11 @@ mod tests { (vector, paths) } - // A morph pairs manipulators by position, so a loop's walk is fixed: from its lowest anchor, along the earliest span there + // A morph pairs manipulators by position, so a loop is read from the anchor it is stored from, in its stored direction #[test] - fn a_closed_loop_walks_from_its_lowest_anchor_along_its_earliest_span() { + fn a_closed_loop_keeps_its_stored_start_and_direction() { let (vector, mut paths) = build_vector(&[DVec2::new(0., 10.), DVec2::new(0., 0.), DVec2::new(10., 0.)], &[]); - // The loop weaves 1 to 2 to 0 and back, so the earliest span at anchor 0 arrives there and is crossed backwards let mut path = Path::from_references([1, 2, 0, 1]); let mut segment_id = SegmentId::ZERO; for span in 0..3 { @@ -994,20 +1024,20 @@ mod tests { } paths.push(Item::new_from_element(path).with_attribute(ATTR_CURVE_TYPE, CurveType::Linear)); - let (manipulators, closed) = vector.stroke_manipulator_groups(&paths).next().expect("the loop is one subpath"); + let (manipulators, closed) = vector.stroke_manipulator_groups(&paths).next().expect("the loop is one contour"); assert!(closed); let anchors: Vec = manipulators.iter().map(|manipulator| manipulator.anchor).collect(); - assert_eq!(anchors, vec![DVec2::new(0., 10.), DVec2::new(10., 0.), DVec2::new(0., 0.)]); + assert_eq!(anchors, vec![DVec2::new(0., 0.), DVec2::new(10., 0.), DVec2::new(0., 10.)]); } - /// Manipulator groups and contours are two views of the same walk, so they must agree on order and + /// Manipulator groups and contours are two views of the same stitching, so they must agree on order and /// geometry. Click targets, the snapper's point recovery, and outline stitching all rely on it. #[test] fn manipulator_groups_and_contours_agree() { use crate::vector::misc::bezpath_from_manipulator_groups; use crate::vector::path::pool_and_paths_from_bezpath; - // A closed contour woven before an open one, so path order alone would emit them the other way around + // A closed contour woven before an open one, which is the order both views keep let mut source = kurbo::BezPath::new(); source.move_to((0., 0.)); source.line_to((10., 0.)); @@ -1025,19 +1055,19 @@ mod tests { assert_eq!(groups.len(), 2); assert_eq!(contours.len(), 2); - // Open runs lead, and each group re-emits its own contour - assert!(!groups[0].1, "the open run should be emitted first"); - assert!(groups[1].1, "the closed loop should follow"); + // Each group re-emits its own contour, in stored order + assert!(groups[0].1, "the closed loop should be emitted first"); + assert!(!groups[1].1, "the open run should follow"); for (index, (manipulators, closed)) in groups.iter().enumerate() { let rebuilt = bezpath_from_manipulator_groups(manipulators, *closed); assert_eq!(rebuilt.elements(), contours[index].elements(), "group {index} should describe contour {index}"); } } - /// Two paths welded head to head, so the subpath must walk the second one backward and still + /// Two paths welded head to head, so the contour must cross the second one backward and still /// emit anchors and geometry in travel order. #[test] - fn subpath_walks_a_welded_path_backward() { + fn contour_crosses_a_welded_path_backward() { use crate::vector::misc::bezpath_from_manipulator_groups; let points = [DVec2::new(0., 0.), DVec2::new(10., 0.), DVec2::new(20., 0.)]; @@ -1275,8 +1305,7 @@ mod tests { assert!(faces.is_empty(), "a reverse-wound loop with an interior spur should produce no filled faces"); } - /// Shoelace sum over the drawn direction of a range of line segments, so tests can read the - /// stored winding directly rather than through a path walker that may pick its own direction. + /// Shoelace sum over the stored direction of a range of line segments, so tests read each segment's winding as stored. fn drawn_signed_area(vector: &Vector, paths: &List, segment_range: std::ops::Range) -> f64 { let graph = SegmentGraph::new(vector, paths); segment_range @@ -1299,6 +1328,54 @@ mod tests { assert!(drawn_signed_area(&vector, &paths, 0..4) > 0., "a lone loop should wind positively after normalization"); } + fn references(paths: &List) -> Vec> { + paths.iter_element_values().map(|path| path.references().collect()).collect() + } + + fn span_ids(paths: &List, path_index: usize, span_count: usize) -> Vec { + let path = paths.element(path_index).unwrap(); + (0..span_count).map(|span| path.0.attribute_cloned_or_default(ATTR_ID, span)).collect() + } + + // A morph or a dash pattern reads a loop from where it is stored, so flipping it must not move that anchor + #[test] + fn normalization_flips_a_closed_path_from_the_same_start() { + let points = [DVec2::new(0., 0.), DVec2::new(0., 10.), DVec2::new(10., 10.), DVec2::new(10., 0.)]; + let (mut vector, mut paths) = build_vector(&points, &[]); + + let mut path = Path::from_references([0, 1, 2, 3, 0]); + let mut segment_id = SegmentId::ZERO; + let ids: Vec = (0..4).map(|_| segment_id.next_id()).collect(); + for (span, &id) in ids.iter().enumerate() { + path.0.set_attribute(ATTR_ID, span, id); + } + paths.push(Item::new_from_element(path).with_attribute(ATTR_CURVE_TYPE, CurveType::Linear)); + + vector.normalize_winding_directions(&mut paths); + + assert_eq!(references(&paths), vec![vec![0, 3, 2, 1, 0]]); + assert_eq!(span_ids(&paths, 0, 4), ids.iter().rev().copied().collect::>(), "each span keeps its ID"); + } + + #[test] + fn normalization_flips_a_loop_of_several_paths_from_the_same_start() { + let points = [DVec2::new(0., 0.), DVec2::new(0., 10.), DVec2::new(10., 10.), DVec2::new(10., 0.)]; + let (mut vector, mut paths) = build_vector(&points, &[]); + + for references in [[0, 1, 2], [2, 3, 0]] { + let path = Path::from_references(references); + paths.push(Item::new_from_element(path).with_attribute(ATTR_CURVE_TYPE, CurveType::Linear)); + } + + vector.normalize_winding_directions(&mut paths); + + assert_eq!(references(&paths), vec![vec![0, 3, 2], vec![2, 1, 0]]); + let contours = contours(vector.0.len(), &paths); + assert_eq!(contours.len(), 1); + assert!(contours[0].closed); + assert_eq!(contours[0].entry, 0); + } + #[test] fn normalization_gives_nested_loops_alternating_winding() { let points = [ @@ -1351,9 +1428,9 @@ mod tests { assert_eq!((graph.start_point[spur], graph.end_point[spur]), (0, 4), "the spur should keep its drawn direction"); } - /// A loop covering only the middle spans of one longer path splits that path so just the loop reverses. + /// A loop covering only the middle spans of one longer path reverses where it sits, leaving the path whole. #[test] - fn normalization_splits_a_path_to_flip_its_inner_loop() { + fn normalization_flips_a_loop_inside_a_longer_path() { let points = [ DVec2::new(-5., 0.), DVec2::new(0., 0.), @@ -1391,5 +1468,6 @@ mod tests { .sum::() / 2.; assert!(loop_area > 0., "the inner loop should wind positively after normalization"); + assert_eq!(references(&paths), vec![vec![0, 1, 4, 3, 2, 1, 5]], "the path stays whole around its flipped loop"); } } 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 c9c8dc80cb6..fe8ce4f27e2 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_modification.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_modification.rs @@ -209,6 +209,7 @@ impl SegmentModification { let keep: Vec = (0..working.segment_ids.len()) .map(|index| !self.remove.contains(&working.segment_ids[index]) && working.segment_start[index] < point_count && working.segment_end[index] < point_count) .collect(); + working.carry_path_beginnings(&keep); let mut keep_iter = keep.iter(); working.segment_start.retain(|_| *keep_iter.next().unwrap_or(&false)); let mut keep_iter = keep.iter(); @@ -217,6 +218,8 @@ impl SegmentModification { working.segment_handles.retain(|_| *keep_iter.next().unwrap_or(&false)); let mut keep_iter = keep.iter(); working.segment_ids.retain(|_| *keep_iter.next().unwrap_or(&false)); + let mut keep_iter = keep.iter(); + working.segment_begins_path.retain(|_| *keep_iter.next().unwrap_or(&false)); // The points no longer change here, so one index serves every endpoint lookup below. // Nothing looks an endpoint up in the common pure-handle case, so it skips the build. @@ -926,7 +929,7 @@ mod tests { vector.position(vector.index_of_point_id(id).unwrap() as usize).unwrap() } - /// What the tests compare of a modification's result: anchors by ID, segments by ID, the stroke walk, and G1 pairs. + /// What the tests compare of a modification's result: anchors by ID, segments by ID, the stroke contours, and G1 pairs. fn describe(vector: &Vector, paths: &List) -> String { let mut anchors: Vec = vector .anchor_points(paths) @@ -1169,6 +1172,28 @@ mod tests { assert_eq!(in_place_paths, paths, "moving points leaves the weave untouched"); } + // Removing the segment a path begins with leaves the rest of that path beginning one of its own, rather than joining the path it touches + #[test] + fn a_path_keeps_its_start_when_its_first_segment_is_removed() { + use crate::vector::path::push_path; + use core_types::list::Item; + + let mut vector = Vector::default(); + for position in [(0., 0.), (10., 0.), (20., 0.), (20., 10.), (30., 0.)] { + vector.0.push(Item::new_from_element(DVec2::from(position)).with_attribute(ATTR_ID, PointId::generate())); + } + let mut paths = List::new(); + let removed = SegmentId::generate(); + push_path(&mut paths, CurveType::Linear, [0, 1, 2], [SegmentId::generate(), SegmentId::generate()]); + push_path(&mut paths, CurveType::Linear, [3, 2, 4], [removed, SegmentId::generate()]); + + let mut modification = VectorModification::default(); + modification.modify(&VectorModificationType::RemoveSegment { id: removed }); + modification.apply(&mut vector, &mut paths); + + assert_eq!(paths.len(), 2, "the remainder of the second path stays a path of its own"); + } + // Re-adding a segment or point under an ID the geometry already holds adds no second copy under that ID #[test] fn duplicate_adds_are_skipped() { 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 05508d8056a..64dacb7f5b1 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -1,6 +1,6 @@ use super::addressing; use super::connectivity::Connectivity; -use super::contour::{contours, subpaths}; +use super::contour::{contour_bezpaths, contours}; use super::misc::{HandleId, ManipulatorPointId, dvec2_to_point}; use super::path::{CurveType, Path, curve_type_at, no_paths, paths_from_item_attributes, paths_from_list_row_attributes, push_path, weave_bezpath}; use super::style::{Stroke, StrokeAlign, StrokeCap, StrokeJoin}; @@ -225,9 +225,9 @@ impl Vector { } } - /// Emits the assembled contours, one bezpath per subpath walked. + /// Emits the stitched contours, one bezpath each, in the order the paths are stored. pub fn stroke_bezpath_iter(&self, paths: &List) -> impl Iterator { - contours(&self.0, paths).into_iter() + contour_bezpaths(&self.0, paths).into_iter() } } @@ -677,20 +677,55 @@ impl Vector { push_path(paths, curve_type, references, [id]); } - /// Welds every open subpath shut with a linear segment from its first point to its last. + /// Welds every open contour shut with a linear segment from its last point back to its first, stored where the contour runs on into it. pub fn close_subpaths(&mut self, paths: &mut List) { - let closures: Vec<_> = subpaths(self.point_count(), paths) + // A closing segment sits after the contour's last stored path, or before it when the contour crosses that path + // backward, since that is the neighbor the contour reads on into. Either way it is read from the contour's exit. + let closings: Vec<(usize, bool, [u32; 2])> = contours(self.point_count(), paths) .into_iter() - .filter(|subpath| !subpath.closed) - .map(|subpath| (subpath.entry, subpath.exit)) + .filter(|contour| !contour.closed) + .filter_map(|contour| { + let last = contour.parts.last()?; + Some(if last.reversed { + (last.path as usize, true, [contour.entry, contour.exit]) + } else { + (last.path as usize + 1, false, [contour.exit, contour.entry]) + }) + }) .collect(); + if closings.is_empty() { + return; + } - for (entry, exit) in closures { - let mut base = next_segment_id(paths); + // Each closing segment's ID skips one after the previous, as the IDs of a newly appended bezpath do + let mut base = next_segment_id(paths); + let mut closing_paths = Vec::with_capacity(closings.len()); + for (before, backward, references) in closings { let id = base.next_id(); + base.next_id(); - push_path(paths, CurveType::Linear, [entry, exit], [id]); + let mut single = List::new(); + push_path(&mut single, CurveType::Linear, references, [id]); + closing_paths.extend(single.into_iter().map(|path| (before, backward, path))); } + + // Two closing segments can share a place between two stored paths, where the one read on from the path before goes first + closing_paths.sort_by_key(|&(before, backward, _)| (before, backward)); + + // Rebuild the paths with each closing segment in its place, keeping every other path in its stored order + let mut closing_paths = closing_paths.into_iter().peekable(); + let mut rebuilt = List::new(); + for (index, path) in std::mem::take(paths).into_iter().enumerate() { + while let Some((_, _, closing)) = closing_paths.next_if(|&(before, ..)| before == index) { + rebuilt.push(closing); + } + rebuilt.push(path); + } + for (_, _, closing) in closing_paths { + rebuilt.push(closing); + } + + *paths = rebuilt; } /// Appends another vector's points and paths, remapping IDs that collide with this vector's. @@ -986,4 +1021,71 @@ mod tests { vector.close_subpaths(&mut paths); assert_eq!(paths.len(), 2); } + + // A crossbar between two junctions closes in one pass like the uprights it joins, and closing again adds nothing + #[test] + fn close_subpaths_closes_a_path_between_two_junctions() { + let mut vector = Vector::default(); + for position in [(0., 0.), (0., 5.), (0., 10.), (10., 0.), (10., 5.), (10., 10.)] { + vector.0.push(Item::new_from_element(DVec2::from(position))); + } + + // Two uprights joined by a crossbar at their midpoints, like an H + let mut paths = List::new(); + for references in [vec![0, 1, 2], vec![3, 4, 5], vec![1, 4]] { + push_path(&mut paths, CurveType::Linear, references, []); + } + + vector.close_subpaths(&mut paths); + let contours: Vec<_> = vector.stroke_bezpath_iter(&paths).collect(); + assert_eq!(contours.len(), 3); + assert!(contours.iter().all(|contour| matches!(contour.elements().last(), Some(PathEl::ClosePath)))); + + vector.close_subpaths(&mut paths); + assert_eq!(paths.len(), 6); + } + + // Two contours whose closing segments belong in the same place between two stored paths both close in one pass + #[test] + fn close_subpaths_closes_contours_meeting_between_the_same_paths() { + let mut vector = Vector::default(); + for position in [(0., 0.), (10., 0.), (10., 10.), (20., 0.), (30., 0.), (30., 10.)] { + vector.0.push(Item::new_from_element(DVec2::from(position))); + } + + // The first contour runs along path 0, then backward along path 3, so its closing segment goes before path 3. + // The second runs along paths 1 and 2, so its closing segment goes after path 2, the same place. + let mut paths = List::new(); + for references in [vec![0, 1], vec![3, 4], vec![4, 5], vec![2, 1]] { + push_path(&mut paths, CurveType::Linear, references, []); + } + + vector.close_subpaths(&mut paths); + let contours: Vec<_> = vector.stroke_bezpath_iter(&paths).collect(); + assert_eq!(contours.len(), 2); + assert!(contours.iter().all(|contour| matches!(contour.elements().last(), Some(PathEl::ClosePath)))); + + vector.close_subpaths(&mut paths); + assert_eq!(paths.len(), 6); + } + + // Paths ending at a junction close too, and closing them again adds nothing + #[test] + fn close_subpaths_closes_paths_ending_at_a_junction() { + let mut vector = Vector::default(); + for index in 0..5 { + vector.0.push(Item::new_from_element(DVec2::new(index as f64, (index * index) as f64))); + } + let mut paths = List::new(); + for references in [vec![0, 1, 2], vec![3, 2], vec![4, 2]] { + push_path(&mut paths, CurveType::Linear, references, []); + } + + vector.close_subpaths(&mut paths); + assert_eq!(paths.len(), 6); + assert!(vector.stroke_bezpath_iter(&paths).all(|contour| matches!(contour.elements().last(), Some(PathEl::ClosePath)))); + + vector.close_subpaths(&mut paths); + assert_eq!(paths.len(), 6); + } } diff --git a/node-graph/nodes/vector/src/vector_modification_nodes.rs b/node-graph/nodes/vector/src/vector_modification_nodes.rs index 8c201ca64c4..f902f9fec8a 100644 --- a/node-graph/nodes/vector/src/vector_modification_nodes.rs +++ b/node-graph/nodes/vector/src/vector_modification_nodes.rs @@ -15,9 +15,11 @@ async fn path_modify(_ctx: impl Ctx, vector: Item, modification: Item(_ctx: impl Ctx, content } + +#[cfg(test)] +mod tests { + use super::*; + use vector_types::vector::paths_from_item_attributes; + + // A lone loop fills by its contour alone, so it keeps the direction it was drawn in even when that winds backwards + #[tokio::test] + async fn a_drawn_loop_keeps_its_direction() { + let mut bezpath = BezPath::new(); + bezpath.move_to((0., 0.)); + bezpath.line_to((0., 10.)); + bezpath.line_to((10., 10.)); + bezpath.line_to((10., 0.)); + bezpath.close_path(); + let drawn = Vector::item_from_bezpath(&bezpath); + + let modified = path_modify((), drawn.clone(), Item::default(), Item::default()).await; + + assert_eq!(paths_from_item_attributes(&modified), paths_from_item_attributes(&drawn)); + } +} diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 6a26f286ed2..033a63a51db 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -829,6 +829,8 @@ pub mod extrude_algorithms { let mut next_point = next_working_point_id(working); let mut next_segment = next_working_segment_id(working); + // Each segment's pieces take its place in order, so the path it belongs to stays whole and still begins where it did + let mut pieces = WorkingVector::default(); for segment_index in 0..segment_count { let start = working.point_positions[working.segment_start[segment_index]]; let end = working.point_positions[working.segment_end[segment_index]]; @@ -836,6 +838,8 @@ pub mod extrude_algorithms { let mut start_index = working.segment_start[segment_index]; let pathseg = segment.to_cubic(); let mut start_t = 0.; + let mut remaining_handles = working.segment_handles[segment_index]; + let mut begins_path = working.segment_begins_path.get(segment_index).copied().unwrap_or(false); for split_t in find_splits(pathseg, direction) { let [first, second] = [pathseg.subsegment(start_t..split_t), pathseg.subsegment(split_t..1.)]; @@ -844,14 +848,24 @@ pub mod extrude_algorithms { let start_segment = next_segment.next_id(); let middle_point_index = push_working_point(working, middle_point, DVec2::new(first.end().x, first.end().y)); - push_working_segment(working, start_segment, start_index, middle_point_index, first_handles); - working.segment_start[segment_index] = middle_point_index; - working.segment_handles[segment_index] = second_handles; + push_working_segment(&mut pieces, start_segment, start_index, middle_point_index, first_handles); + pieces.segment_begins_path.push(std::mem::take(&mut begins_path)); start_t = split_t; start_index = middle_point_index; + remaining_handles = second_handles; } + + // The last piece keeps the segment's own ID + push_working_segment(&mut pieces, working.segment_ids[segment_index], start_index, working.segment_end[segment_index], remaining_handles); + pieces.segment_begins_path.push(begins_path); } + + working.segment_ids = pieces.segment_ids; + working.segment_start = pieces.segment_start; + working.segment_end = pieces.segment_end; + working.segment_handles = pieces.segment_handles; + working.segment_begins_path = pieces.segment_begins_path; } /// Copy all segments with the offset of `direction`. @@ -871,6 +885,10 @@ pub mod extrude_algorithms { push_working_segment(working, next_segment.next_id(), start, end, handles); } + + // The copies begin paths where the originals do + working.segment_begins_path.resize(segment_count, false); + working.segment_begins_path.extend_from_within(..segment_count); } /// Join points from the original to the copied that are on opposite sides of the direction. @@ -979,6 +997,24 @@ pub mod extrude_algorithms { assert_eq!(working.segment_ids.len(), 3); assert_eq!(working.point_ids.len(), 4); } + + // A split curve's pieces stay in its place, so the path holding it stays one path + #[test] + fn split_keeps_the_path_whole() { + let mut bezpath = kurbo::BezPath::new(); + bezpath.move_to((0., 0.)); + bezpath.curve_to((100., -10.), (100., 110.), (0., 100.)); + bezpath.curve_to((0., 130.), (0., 170.), (0., 200.)); + let (mut vector, mut paths) = graphic_types::Vector::from_bezpath(&bezpath); + assert_eq!(paths.len(), 1); + + let mut working = super::WorkingVector::flatten(&vector, &paths); + super::split(&mut working, DVec2::X); + assert_eq!(working.segment_ids.len(), 4); + + working.reweave(&mut vector, &mut paths); + assert_eq!(paths.len(), 1); + } } }