collective

Browse / github.com/storytold/vectorcraft / patch

Fix #4156: Join joins every selected open path at once

Join (CTRL-J) should join all selected segments instead of just two per command Current Behavior: - Selecting multiple segments that can be joined and choosing Join (CTRL-J) will only join two of those segments. - This behavior is the same regardless if the segments are selected with the marquee selector or CTRL-A or shift-clicking multiple selections. Desired Behavior: - If multiple segments are selected that have overlapping nodes, choosing Join should join them all in one invocation of the Join command. - I have attached a video that shows the current behavior in VectorCraft and a similar function in Inkscape that works in one command. (note in the video I use the ma

Status
open
Score
0.00
Adopters
0 other operators
Author
Hassan1305 (imported from pull request #4318, open)
Base
443b31b13df3a7ab611c2fe1b95e73898ed74fdd
Size
+221 −54 in 2 files
Review
pending
Shared
2026-10-28
Id
40f6a5b3a1357b9486968bbd

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/path.rs crates/engine/src/tests/draw2.rs

diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs--- a/crates/engine/src/cmd/path.rs+++ b/crates/engine/src/cmd/path.rs@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Join",             ["Object", "Path"],             Some("Cmd+J"),-            "{ids?: [path, path] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (which end of each path to join, as the Pen joins them; default: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two open paths into one (the first keeps its id, selected), or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",+            "{ids?: [path, path, …] (default: the selected paths), ends?: [\"first\"|\"last\", \"first\"|\"last\"] (two paths only: which end of each path to join, as the Pen joins them; default: the nearest pair), to?: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours)} join two open paths into one (the first keeps its id, selected); more than two: the open paths whose ends are closest join first, again until one is left (closed paths among them stay as they are); or close a single open path; without ids, direct-selected anchors lying on one another along a path merge into one anchor instead",             has_selection,             join         ),@@ -386,6 +386,9 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool, join_kind:     before - sp.anchors.len() } +/// `path.join`: without `ids`, direct-selected anchors lying on one another merge first; else one+/// path closes, two paths join ([`join_pair`]), and more than two join nearest ends first+/// ([`join_nearest`]), each in one undo step. fn join(s: &mut Session, p: &Value) -> Result<Value> {     const C: &str = "path.join";     let join_kind = match p.get("to").and_then(Value::as_str) {@@ -462,7 +465,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {                 Ok(())             })?;         }-        [a, b, ..] => {+        [a, b] => {             let (a, b) = (*a, *b);             if a == b {                 return Err(bad(C, "join two different paths"));@@ -471,67 +474,177 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {             let picked_a = st.selection.anchors.get(&a).cloned().unwrap_or_default();             let picked_b = st.selection.anchors.get(&b).cloned().unwrap_or_default();             s.edit("Join", |d, sel| {-                let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;-                let pa = path_mut(d, a)?;-                let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else {-                    return Err(EngineError::Other("join needs open paths".into()));-                };-                let mut x = pa.subpaths[sa].clone();-                let mut y = pb.subpaths[sb].clone();-                let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {-                    return Err(EngineError::Other("join needs open paths".into()));-                };-                // The ends asked for, else the closest pair.-                let cands =-                    [(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];-                // Direct-selected end points decide which ends meet, before the closest pair does.-                let end_of = |picked: &std::collections::BTreeSet<(usize, usize)>, si: usize, len: usize| {-                    let last_ix = len.checked_sub(1)?;-                    let first = picked.contains(&(si, 0));-                    let last = picked.contains(&(si, last_ix));-                    match (first, last) {-                        (true, false) => Some(true),-                        (false, true) => Some(false),-                        _ => None,-                    }-                };-                let by_selection = match (end_of(&picked_a, sa, x.anchors.len()), end_of(&picked_b, sb, y.anchors.len())) {-                    (Some(fa), Some(fb)) => Some((fa, !fb)),-                    _ => None,-                };-                let (rx, ry) = ends-                    .or(by_selection)-                    .unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));-                if rx {-                    x.reverse();-                }-                if ry {-                    y.reverse();-                }-                let junction = x.anchors.len() - 1;-                if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))-                    && xe.distance(ys) < 1e-6-                {-                    let first = y.anchors.remove(0);-                    if let Some(l) = x.anchors.last_mut() {-                        l.h_out = first.h_out;-                    }-                }-                x.anchors.extend(y.anchors);-                if let Some(smooth) = join_kind {-                    join_point(&mut x, junction, smooth);-                }-                pa.subpaths[sa] = x;-                d.remove(b)?;+                join_pair(d, a, b, ends.or(by_selection(d, a, b, &picked_a, &picked_b)), join_kind)?;                 sel.set([a]);                 Ok(())             })?;         }         [] => return Err(EngineError::Other("select paths to join".into())),+        many => {+            if ends.is_some() {+                return Err(bad(C, "ends takes two paths"));+            }+            let mut seen = std::collections::BTreeSet::new();+            if let Some(id) = many.iter().find(|id| !seen.insert(**id)) {+                return Err(bad(C, format!("{} is listed twice", id.0)));+            }+            // More than two: the open paths whose ends are closest join first, again and again+            // until one path is left (closed paths in the selection are left as they are).+            let st = s.doc()?;+            let mut open: Vec<(NodeId, (Point, Point))> = many.iter().filter_map(|id| Some((*id, open_ends(&st.doc, *id)?))).collect();+            if open.len() < 2 {+                return Err(EngineError::Other("join needs open paths".into()));+            }+            s.edit("Join", |d, sel| {+                let keep = join_nearest(d, &mut open, join_kind)?;+                sel.set([keep]);+                Ok(())+            })?;+        }     }     ok() } +/// The ends of `id`'s first open subpath.+fn open_ends(d: &vectorcraft_doc::Document, id: NodeId) -> Option<(Point, Point)> {+    d.node(id)?.path_data()?.subpaths.iter().find(|s| !s.closed).and_then(end_points)+}++/// How far apart the nearest ends of two paths are.+fn end_gap((xf, xl): (Point, Point), (yf, yl): (Point, Point)) -> f64 {+    xl.distance(yf).min(xl.distance(yl)).min(xf.distance(yf)).min(xf.distance(yl))+}++/// Join `paths` (open paths and their ends) into one: the pair whose ends are closest first,+/// repeated until one is left, as the [Join command](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/connect-and-join-paths.html)+/// does with more than two paths. Of each pair the one listed first keeps its id. Each path's+/// nearest other path is kept, and only redone for the paths a join touches. Returns the path left.+fn join_nearest(d: &mut vectorcraft_doc::Document, paths: &mut [(NodeId, (Point, Point))], join_kind: Option<bool>) -> Result<NodeId> {+    let n = paths.len();+    let mut alive = vec![true; n];+    let nearest_of = |paths: &[(NodeId, (Point, Point))], alive: &[bool], i: usize| -> (f64, usize) {+        let ends = paths.get(i).map(|p| p.1);+        let mut best = (f64::INFINITY, i);+        for (j, (_, e)) in paths.iter().enumerate() {+            if j != i+                && alive.get(j).copied().unwrap_or(false)+                && let Some(ends) = ends+            {+                let g = end_gap(ends, *e);+                if g < best.0 {+                    best = (g, j);+                }+            }+        }+        best+    };+    let mut nearest: Vec<(f64, usize)> = (0..n).map(|i| nearest_of(paths, &alive, i)).collect();+    for _ in 1..n {+        let Some((i, &(_, j))) =+            nearest.iter().enumerate().filter(|(i, _)| alive.get(*i).copied().unwrap_or(false)).min_by(|p, q| p.1.0.total_cmp(&q.1.0))+        else {+            break;+        };+        let (keep, drop) = (i.min(j), i.max(j));+        let (Some(&(a, _)), Some(&(b, _))) = (paths.get(keep), paths.get(drop)) else { break };+        join_pair(d, a, b, None, join_kind)?;+        if let Some(f) = alive.get_mut(drop) {+            *f = false;+        }+        let ends = open_ends(d, a).ok_or_else(|| EngineError::Other("join needs open paths".into()))?;+        if let Some(p) = paths.get_mut(keep) {+            p.1 = ends;+        }+        for k in 0..n {+            if k == keep || !alive.get(k).copied().unwrap_or(false) {+                continue;+            }+            let (g, to) = nearest.get(k).copied().unwrap_or((f64::INFINITY, k));+            let redo = to == keep || to == drop;+            let gap = paths.get(k).map_or(f64::INFINITY, |p| end_gap(p.1, ends));+            if let Some(slot) = nearest.get_mut(k) {+                if redo {+                    *slot = nearest_of(paths, &alive, k);+                } else if gap < g {+                    *slot = (gap, keep);+                }+            }+        }+        if let Some(slot) = nearest.get_mut(keep) {+            *slot = nearest_of(paths, &alive, keep);+        }+    }+    alive.iter().position(|a| *a).and_then(|i| paths.get(i)).map(|p| p.0).ok_or_else(|| EngineError::Other("select paths to join".into()))+}++/// Which ends of `a` and `b` meet when the direct selection picked one end point of each, as+/// (reverse `a`, reverse `b`).+fn by_selection(+    d: &vectorcraft_doc::Document,+    a: NodeId,+    b: NodeId,+    picked_a: &std::collections::BTreeSet<(usize, usize)>,+    picked_b: &std::collections::BTreeSet<(usize, usize)>,+) -> Option<(bool, bool)> {+    let open = |id: NodeId| {+        let pd = d.node(id)?.path_data()?;+        let si = pd.subpaths.iter().position(|s| !s.closed)?;+        Some((si, pd.subpaths.get(si)?.anchors.len()))+    };+    let end_of = |picked: &std::collections::BTreeSet<(usize, usize)>, (si, len): (usize, usize)| {+        let last_ix = len.checked_sub(1)?;+        match (picked.contains(&(si, 0)), picked.contains(&(si, last_ix))) {+            (true, false) => Some(true),+            (false, true) => Some(false),+            _ => None,+        }+    };+    Some((end_of(picked_a, open(a)?)?, !end_of(picked_b, open(b)?)?))+}++/// Join open path `b` onto open path `a` (their first open subpaths), which keeps its id; `b` is+/// removed. `ends`: (reverse `a`, reverse `b`), so `a`'s end comes last and `b`'s first; `None`+/// for the closest pair.+fn join_pair(d: &mut vectorcraft_doc::Document, a: NodeId, b: NodeId, ends: Option<(bool, bool)>, join_kind: Option<bool>) -> Result<()> {+    let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;+    let pa = path_mut(d, a)?;+    let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else {+        return Err(EngineError::Other("join needs open paths".into()));+    };+    let (Some(mut x), Some(mut y)) = (pa.subpaths.get(sa).cloned(), pb.subpaths.get(sb).cloned()) else {+        return Err(EngineError::Other("join needs open paths".into()));+    };+    let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {+        return Err(EngineError::Other("join needs open paths".into()));+    };+    let cands = [(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];+    let (rx, ry) = ends.unwrap_or_else(|| cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).map_or((false, false), |(_, rx, ry)| (rx, ry)));+    if rx {+        x.reverse();+    }+    if ry {+        y.reverse();+    }+    let junction = x.anchors.len().saturating_sub(1);+    if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))+        && xe.distance(ys) < 1e-6+    {+        let first = y.anchors.remove(0);+        if let Some(l) = x.anchors.last_mut() {+            l.h_out = first.h_out;+        }+    }+    x.anchors.extend(y.anchors);+    if let Some(smooth) = join_kind {+        join_point(&mut x, junction, smooth);+    }+    if let Some(slot) = pa.subpaths.get_mut(sa) {+        *slot = x;+    }+    d.remove(b)?;+    Ok(())+}+ pub(super) type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>;  pub(super) fn anchor_targets(s: &Session) -> Result<AnchorTargets> {diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs--- a/crates/engine/src/tests/draw2.rs+++ b/crates/engine/src/tests/draw2.rs@@ -2400,6 +2400,60 @@ fn join_takes_the_paths_and_the_ends_to_join() {     } } +/// #4156: with more than two paths selected, one Join joins them all, the ends closest to each+/// other first, in one undo step; a closed path among them is left as it is.+#[test]+fn join_joins_every_selected_open_path_at_once() {+    let mut s = session();+    // A triangle's three sides, each drawn on its own.+    let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);+    let b = line(&mut s, 50.0, 80.0, 100.0, 0.0);+    let c = line(&mut s, 0.0, 0.0, 50.0, 80.0);+    let r = rect(&mut s, 300.0, 300.0, 10.0, 10.0);+    s.execute("select.all", &json!({})).unwrap();+    s.execute("path.join", &json!({})).unwrap();+    let left: Vec<NodeId> = paths(&s).into_iter().map(|(id, _)| id).collect();+    assert_eq!(left.len(), 2, "one joined path and the rectangle");+    assert!(left.contains(&r) && path(&s, r).subpaths[0].closed, "the closed rectangle is untouched");+    let joined = *left.iter().find(|id| **id != r).unwrap();+    assert!([a, b, c].contains(&joined));+    let mut pts: Vec<(f64, f64)> = path(&s, joined).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect();+    assert_eq!(pts.len(), 4, "the shared corners merge: {pts:?}");+    assert_eq!(pts.first(), pts.last(), "the sides run all the way round");+    pts.pop();+    pts.sort_by(|p, q| p.partial_cmp(q).unwrap());+    assert_eq!(pts, [(0.0, 0.0), (50.0, 80.0), (100.0, 0.0)]);+    assert_eq!(s.doc().unwrap().selection.objects, [joined]);+    s.execute("edit.undo", &json!({})).unwrap();+    assert_eq!(paths(&s).len(), 4, "one undo step");++    // Many segments of one polyline, drawn out of order, join into it in one go.+    let mut s = session();+    let n = 200;+    let mut order: Vec<usize> = (0..n).collect();+    // A fixed shuffle: every 7th step through the indices (7 and 200 are coprime).+    order = (0..n).map(|k| order[(k * 7) % n]).collect();+    for i in order {+        let x = i as f64 * 10.0;+        let y = if i % 2 == 0 { 0.0 } else { 5.0 };+        let y2 = if i % 2 == 0 { 5.0 } else { 0.0 };+        line(&mut s, x, y, x + 10.0, y2);+    }+    s.execute("select.all", &json!({})).unwrap();+    s.execute("path.join", &json!({})).unwrap();+    let left: Vec<NodeId> = paths(&s).into_iter().map(|(id, _)| id).collect();+    assert_eq!(left.len(), 1);+    let xs: Vec<f64> = path(&s, left[0]).subpaths[0].anchors.iter().map(|x| x.p.x).collect();+    assert_eq!(xs.len(), n + 1);+    assert!(xs.windows(2).all(|w| w[1] > w[0]) || xs.windows(2).all(|w| w[1] < w[0]), "in order along the line");++    // `ends` names two paths only.+    let mut s = session();+    let ids: Vec<u64> = (0..3).map(|i| line(&mut s, i as f64 * 20.0, 0.0, i as f64 * 20.0 + 10.0, 0.0).0).collect();+    assert!(s.execute("path.join", &json!({"ids": ids, "ends": ["first", "last"]})).is_err());+    assert!(s.execute("path.join", &json!({"ids": [ids[0], ids[1], ids[0]]})).is_err(), "a path listed twice");+}+ /// Without `ends`, the end points picked with the direct selection are the ones joined. #[test] fn join_uses_the_selected_end_points() {