mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
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:
co-authored by
Claude Opus 5.5
parent
825d4fdb8b
commit
bd8fa795ed
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user