Fix #776: the Pen continues any open path and joins two paths

As in Illustrator, the Pen shows its continue cursor over an end of any
open path, selected or not, and a click there selects the path and draws
on from that end (reversing it from its first end). While drawing, it
shows a join cursor over an end of another open path, and a click joins
the two into one path, in one undo step, which finishes the path.

path.join takes {ids: [a, b], ends: ["first"|"last", "first"|"last"]}
to join the ends asked for (the Pen's join); without them it joins the
selected paths' nearest ends as before. Joining a path with itself, or
anything that isn't a path, is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-09 23:59:50 +07:00
co-authored by Claude Opus 5.5
parent 825d4fdb8b
commit bd8fa795ed
8 changed files with 146 additions and 12 deletions
+28 -5
View File
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Join",
["Object", "Path"],
Some("Cmd+J"),
"{} join two open paths' nearest endpoints, or close a single open path",
"{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)} join two open paths into one (the first keeps its id, selected), or close a single open path",
has_selection,
join
),
@@ -283,8 +283,27 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
}
fn join(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_paths(s)?;
fn join(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.join";
let ids = match ids_param(p, "ids") {
Some(ids) => {
let st = s.doc()?;
if let Some(id) = ids.iter().find(|id| !matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Path { .. }))) {
return Err(bad(C, format!("{} isn't a path", id.0)));
}
ids
}
None => selected_paths(s)?,
};
// The ends to join, as (reverse the first path, reverse the second): the first path's end
// must come last, the second's first.
let ends = match p.get("ends") {
None => None,
Some(e) => match e.as_array().map(|a| a.iter().map(Value::as_str).collect::<Vec<_>>()).as_deref() {
Some([Some(a @ ("first" | "last")), Some(b @ ("first" | "last"))]) => Some((*a == "first", *b == "last")),
_ => return Err(bad(C, "ends must be two of \"first\" and \"last\"")),
},
};
match ids.as_slice() {
[one] => {
let id = *one;
@@ -305,6 +324,9 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
}
[a, b, ..] => {
let (a, b) = (*a, *b);
if a == b {
return Err(bad(C, "join two different paths"));
}
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)?;
@@ -316,10 +338,11 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {
return Err(EngineError::Other("join needs open paths".into()));
};
// Pick the closest pair of ends.
// 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)];
let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap_or((0.0, false, false));
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();
}
+54
View File
@@ -790,3 +790,57 @@ fn pen_with_alt_converts_the_anchors_of_the_path_being_drawn() {
gesture(&mut s, &[(500.0, 400.0)], none);
assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 5));
}
/// #776: the Pen continues any open path from the end clicked, selected or not, and while drawing a
/// click on an end of another open path joins the two into one path, in one undo step, which
/// finishes it.
#[test]
fn the_pen_continues_any_open_path_and_joins_another() {
use vectorcraft_tools::Cursor;
let (v, none) = (view(), Mods::default());
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
let b = line(&mut s, 300.0, 200.0, 400.0, 200.0);
s.execute("select.none", &json!({})).unwrap();
s.select_tool("pen", v).unwrap();
assert_eq!(s.cursor(Point::new(200.0, 100.0), none, v), Cursor::PenContinue, "an end of a path that isn't selected");
gesture(&mut s, &[(200.0, 100.0)], none);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.cursor(Point::new(300.0, 200.0), none, v), Cursor::PenJoin);
assert_eq!(s.cursor(Point::new(350.0, 200.0), none, v), Cursor::Pen, "the middle of the other path");
assert_eq!(gesture(&mut s, &[(300.0, 200.0)], none), 1);
let pts = |s: &Session, id| path(s, id).subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|x| (x.p.x, x.p.y))).collect::<Vec<_>>();
assert_eq!(pts(&s, a), [(100.0, 100.0), (200.0, 100.0), (300.0, 200.0), (400.0, 200.0)]);
assert!(s.doc().unwrap().doc.node(b).is_none() && paths(&s).len() == 1, "one path");
// The join finished it: the next click starts a new path.
gesture(&mut s, &[(500.0, 500.0)], none);
assert_eq!(paths(&s).len(), 2);
// From a path's first end, drawing goes on from there: the path is reversed.
s.execute("select.none", &json!({})).unwrap();
s.select_tool("selection", v).unwrap();
s.select_tool("pen", v).unwrap();
gesture(&mut s, &[(100.0, 100.0)], none);
gesture(&mut s, &[(50.0, 50.0)], none);
assert_eq!(pts(&s, a), [(400.0, 200.0), (300.0, 200.0), (200.0, 100.0), (100.0, 100.0), (50.0, 50.0)]);
}
/// `path.join {ids, ends}` joins the ends asked for, not the nearest pair; bad requests are errors.
#[test]
fn join_takes_the_paths_and_the_ends_to_join() {
let mut s = session();
let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);
let b = line(&mut s, 110.0, 0.0, 200.0, 0.0);
// The far ends: a's first to b's last (the nearest pair would be a's last and b's first).
s.execute("path.join", &json!({"ids": [a.0, b.0], "ends": ["first", "last"]})).unwrap();
let pts: Vec<(f64, f64)> = path(&s, a).subpaths[0].anchors.iter().map(|x| (x.p.x, x.p.y)).collect();
assert_eq!(pts, [(100.0, 0.0), (0.0, 0.0), (200.0, 0.0), (110.0, 0.0)]);
let r = rect(&mut s, 0.0, 50.0, 10.0, 10.0);
for bad in [
json!({"ids": [a.0, a.0]}),
json!({"ids": [a.0, 999]}),
json!({"ids": [a.0, r.0], "ends": ["first"]}),
json!({"ids": [a.0, b.0], "ends": ["first", "middle"]}),
] {
assert!(s.execute("path.join", &bad).is_err(), "{bad}");
}
}