Join: merge a run of coincident anchors in one pass over the path

Join removed each merged anchor from the path's anchor list as it went,
which moved every anchor after it, so a long selected run in front of
many other anchors took time quadratic in the path's length. It also
looked up each anchor's selection in the selected-anchor set.

Join now marks the merged anchors and removes them in one pass at the
end, reading the selection from one flag per anchor. Each corner or
smooth conversion sees the same neighbors as before, so the resulting
anchors are unchanged. Anchor::smooth_between holds the smoothing that
SubPath::smooth_anchor applies.

Tests compare the new merge bit for bit with the old one on 30,000
generated paths, and count the anchors moved when 300,000 selected
anchors in front of 300,000 others merge: each of the others moves
once.
This commit is contained in:
dotCooCoo
2026-11-13 07:25:49 -08:00
parent e8c7421a3d
commit 8b990de004
3 changed files with 229 additions and 24 deletions
+207 -8
View File
@@ -481,6 +481,12 @@ fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) {
}
}
#[cfg(test)]
thread_local! {
/// Anchors that [`merge_coincident`] moved to a lower index of their vector on this thread.
static ANCHOR_MOVES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Merge each run of `picked` neighbours of `sp` that lie on one another into one anchor, which
/// keeps the first one's incoming handle and the last one's outgoing handle. Returns how many
/// anchors went.
@@ -501,23 +507,68 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind:
join_point(sp, 0, smooth);
}
}
// Back to front, so the indices `picked` knows stay true for the anchors still to visit.
for i in (1..sp.anchors.len()).rev() {
// Back to front, so the indices `picked` knows stay true for the anchors still to visit. A
// merged anchor is only marked here; the marked anchors are removed in one pass at the end.
let closed = sp.closed;
let anchors = &mut sp.anchors;
let len = anchors.len();
let mut gone = vec![false; len];
// The anchor after `i` that is still on the path, and the last one still on it.
let mut next = None;
let mut last = len.saturating_sub(1);
for i in (1..len).rev() {
if picked(i - 1)
&& picked(i)
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
&& let Some([a, b]) = anchors.get_mut(i - 1..=i)
&& same(a, b)
{
a.h_out = b.h_out;
sp.anchors.remove(i);
if let Some(smooth) = join_kind {
join_point(sp, i - 1, smooth);
if let Some(slot) = gone.get_mut(i) {
*slot = true;
}
if next.is_none() {
last = i - 1;
}
if let Some(smooth) = join_kind {
// The neighbors the anchor has on the path as it now stands, as `join_point` sees them.
let prev = if i > 1 { Some(i - 2) } else { closed.then_some(last) };
let after = next.or(closed.then_some(0));
join_point_between(anchors, i - 1, prev, after, smooth);
}
} else {
next = Some(i);
}
}
#[cfg(test)]
if let Some(first) = gone.iter().position(|&g| g) {
// `retain` moves each anchor it keeps after the first one it drops.
let dropped = gone.iter().filter(|&&g| g).count();
ANCHOR_MOVES.set(ANCHOR_MOVES.get() + (len - first - dropped));
}
if gone.contains(&true) {
let mut k = 0;
anchors.retain(|_| {
let keep = !gone.get(k).copied().unwrap_or(false);
k += 1;
keep
});
}
before - sp.anchors.len()
}
/// [`join_point`] on anchor `ai` of `anchors` with its neighbors given by index.
fn join_point_between(anchors: &mut [Anchor], ai: usize, prev: Option<usize>, next: Option<usize>, smooth: bool) {
let at = |j: Option<usize>| j.and_then(|j| anchors.get(j)).map(|a| a.p);
let (prev, next) = (at(prev), at(next));
if let Some(a) = anchors.get_mut(ai) {
if smooth {
a.smooth_between(prev, next);
} else {
a.retract();
}
}
}
/// Close subpath `si` of path `id`, merging coincident endpoints if present.
fn close_subpath_in_node(d: &mut Document, id: NodeId, si: usize, join_kind: Option<bool>) -> Result<()> {
let path = path_mut(d, id)?;
@@ -751,8 +802,19 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result<Value> {
let mut merged = vec![];
for (id, picked) in &st.selection.anchors {
let Some(mut path) = st.doc.node(*id).and_then(|n| n.path_data()).cloned() else { continue };
let n: usize =
path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)), join_kind)).sum();
// One flag per anchor; references past the end of a subpath or to a missing one pick nothing.
let mut masks: Vec<Vec<bool>> = path.subpaths.iter().map(|sp| vec![false; sp.anchors.len()]).collect();
for &(si, ai) in picked {
if let Some(flag) = masks.get_mut(si).and_then(|m| m.get_mut(ai)) {
*flag = true;
}
}
let n: usize = path
.subpaths
.iter_mut()
.zip(&masks)
.map(|(sp, mask)| merge_coincident(sp, |ai| mask.get(ai).copied().unwrap_or(false), join_kind))
.sum();
if n > 0 {
merged.push((*id, path));
}
@@ -1763,3 +1825,140 @@ fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
})?;
Ok(super::draw2::ids_json(&ids))
}
#[cfg(test)]
mod tests {
use super::*;
/// `merge_coincident` removing each merged anchor from the vector straight away, kept as the
/// reference the one-pass version must match.
fn merge_coincident_by_removal(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind: Option<bool>) -> usize {
let same = |a: &Anchor, b: &Anchor| a.p.distance(b.p) < 1e-6;
let before = sp.anchors.len();
if sp.closed
&& before > 2
&& picked(0)
&& picked(before - 1)
&& let [first, .., last] = sp.anchors.as_mut_slice()
&& same(first, last)
{
first.h_in = last.h_in;
sp.anchors.pop();
if let Some(smooth) = join_kind {
join_point(sp, 0, smooth);
}
}
for i in (1..sp.anchors.len()).rev() {
if picked(i - 1)
&& picked(i)
&& let Some([a, b]) = sp.anchors.get_mut(i - 1..=i)
&& same(a, b)
{
a.h_out = b.h_out;
sp.anchors.remove(i);
if let Some(smooth) = join_kind {
join_point(sp, i - 1, smooth);
}
}
}
before - sp.anchors.len()
}
/// Every field of every anchor, bit for bit.
fn bits(sp: &SubPath) -> (Vec<[u64; 6]>, Vec<AnchorKind>, bool) {
let b = |p: Point| [p.x.to_bits(), p.y.to_bits()];
let fields = sp.anchors.iter().map(|a| [b(a.p), b(a.h_in), b(a.h_out)].concat().try_into().unwrap()).collect();
(fields, sp.anchors.iter().map(|a| a.kind).collect(), sp.closed)
}
/// A small linear congruential generator, so the cases are the same on every run.
struct Lcg(u64);
impl Lcg {
fn below(&mut self, n: u64) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 33) % n
}
}
fn same_as_removal(sp: &SubPath, picked: &dyn Fn(usize) -> bool, join_kind: Option<bool>) {
let (mut ours, mut theirs) = (sp.clone(), sp.clone());
let n = merge_coincident(&mut ours, picked, join_kind);
let m = merge_coincident_by_removal(&mut theirs, picked, join_kind);
assert_eq!(n, m, "{sp:?} {join_kind:?}");
assert_eq!(bits(&ours), bits(&theirs), "{sp:?} {join_kind:?}");
}
/// Short paths with points on, near and just past the merge distance, selections with stale
/// indices, every anchor kind and every join kind give the same anchors, bit for bit, as
/// removing each merged anchor straight away.
#[test]
fn merging_coincident_anchors_matches_removing_them_one_by_one() {
let spots = [(0.0, 0.0), (0.0, 0.0), (0.0, 0.0), (5e-7, 0.0), (0.0, 1.5e-6), (10.0, 0.0), (5.0, 5.0), (10.0, 0.0)];
let mut rng = Lcg(7);
for _ in 0..30_000 {
let n = rng.below(9) as usize;
let anchors = (0..n)
.map(|_| {
let (x, y) = spots[rng.below(spots.len() as u64) as usize];
let p = Point::new(x, y);
let handle = |rng: &mut Lcg| match rng.below(3) {
0 => p,
_ => p + Vec2::new(rng.below(7) as f64 - 3.0, rng.below(7) as f64 - 3.0),
};
let (h_in, h_out) = (handle(&mut rng), handle(&mut rng));
let kind = if rng.below(2) == 0 { AnchorKind::Corner } else { AnchorKind::Smooth };
Anchor { p, h_in, h_out, kind }
})
.collect();
let sp = SubPath::new(anchors, rng.below(2) == 0);
let picks: Vec<bool> = (0..n + 2).map(|_| rng.below(4) != 0).collect();
let join_kind = [None, Some(false), Some(true)][rng.below(3) as usize];
same_as_removal(&sp, &|i| picks.get(i).copied().unwrap_or(false), join_kind);
}
}
/// A smooth join of three anchors on one another takes the outgoing handle the middle anchor
/// was given when it joined the right one: an already smooth left anchor keeps that handle.
#[test]
fn a_smooth_join_of_three_anchors_keeps_the_handle_the_middle_one_got() {
let o = Point::ZERO;
let left = Anchor { p: o, h_in: Point::new(-2.0, 0.0), h_out: o, kind: AnchorKind::Smooth };
let right = Anchor { p: o, h_in: o, h_out: Point::new(0.0, 5.0), kind: AnchorKind::Corner };
let mut sp = SubPath::new(vec![left, Anchor::corner(o), right, Anchor::corner(Point::new(10.0, 0.0))], false);
assert_eq!(merge_coincident(&mut sp, |i| i < 3, Some(true)), 2);
let a = sp.anchors[0];
assert_eq!((a.h_in, a.h_out, a.kind), (Point::new(-2.0, 0.0), Point::new(10.0 / 3.0, 0.0), AnchorKind::Smooth));
assert_eq!(sp.anchors.len(), 2);
}
/// One long run of selected anchors on one another, open or closed, becomes one anchor.
#[test]
fn a_long_run_of_anchors_on_one_another_becomes_one() {
for closed in [false, true] {
for join_kind in [None, Some(false), Some(true)] {
let anchors = (0..50_000).map(|i| Anchor::smooth(Point::ZERO, Point::new(1.0, i as f64))).collect();
let sp = SubPath::new(anchors, closed);
same_as_removal(&sp, &|_| true, join_kind);
let mut ours = sp.clone();
assert_eq!(merge_coincident(&mut ours, |_| true, join_kind), 49_999);
assert_eq!((ours.anchors.len(), ours.closed), (1, closed));
}
}
}
/// A long selected run at the start of a path followed by as many anchors that stay: each
/// anchor after the run moves once, not once per merged anchor.
#[test]
fn a_long_run_before_many_other_anchors_merges_in_one_pass() {
let run = 300_000;
let anchors = (0..run).map(|_| Anchor::corner(Point::ZERO)).chain((1..=run).map(|i| Anchor::corner(Point::new(i as f64, 1.0)))).collect();
let mut sp = SubPath::new(anchors, false);
ANCHOR_MOVES.set(0);
assert_eq!(merge_coincident(&mut sp, |i| i < run, Some(false)), run - 1);
assert_eq!(ANCHOR_MOVES.get(), run);
assert_eq!(sp.anchors.len(), run + 1);
assert_eq!(sp.anchors[0].p, Point::ZERO);
assert_eq!(sp.anchors[1].p, Point::new(1.0, 1.0));
assert_eq!(sp.anchors[run].p, Point::new(run as f64, 1.0));
}
}
+21 -15
View File
@@ -165,6 +165,26 @@ impl Anchor {
pub fn reversed(&self) -> Self {
Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind }
}
/// Make the anchor smooth between the points `prev` and `next` (its own point for a missing
/// one): handles in line with them, each a third of the way to that side's point. A smooth
/// anchor with both handles keeps them. False, with no change, when the two points give no
/// direction. [`SubPath::smooth_anchor`] calls it with the anchor's neighbors.
pub fn smooth_between(&mut self, prev: Option<Point>, next: Option<Point>) -> bool {
if self.kind == AnchorKind::Smooth && self.has_in() && self.has_out() {
return true;
}
let (pp, nn) = (prev.unwrap_or(self.p), next.unwrap_or(self.p));
let dir = nn - pp;
let l = dir.hypot();
if l <= 1e-9 {
return false;
}
let u = dir / l;
self.h_in = self.p - u * (self.p.distance(pp) / 3.0);
self.h_out = self.p + u * (self.p.distance(nn) / 3.0);
self.kind = AnchorKind::Smooth;
true
}
/// Remove both handles (convert to a sharp corner).
pub fn retract(&mut self) {
self.h_in = self.p;
@@ -398,21 +418,7 @@ impl SubPath {
};
let at = |j: Option<usize>| j.and_then(|j| self.anchors.get(j)).map(|a| a.p);
let (prev, next) = (at(prev), at(next));
let Some(a) = self.anchors.get_mut(i) else { return false };
if a.kind == AnchorKind::Smooth && a.has_in() && a.has_out() {
return true;
}
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l <= 1e-9 {
return false;
}
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
true
self.anchors.get_mut(i).is_some_and(|a| a.smooth_between(prev, next))
}
/// Cut at the anchors `at`: each becomes the end of one open piece and the start of the next,
/// its handle off each piece retracted (the shape doesn't change). A closed subpath opens at
+1 -1
View File
@@ -1056,7 +1056,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them.
- **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html).
- **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step.
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
- **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.
Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out.