G1: path.join accepts corner or smooth on the merged anchor

Join can set the surviving junction point to a corner or smooth anchor via `to`, matching Illustrator's join choice without a combined Average+Join command.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-20 01:48:35 +07:00
committed by bflatastic
co-authored by Cursor
parent bb32072be9
commit 4b8e42e517
3 changed files with 58 additions and 4 deletions
+32 -3
View File
@@ -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)} 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\"] (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",
has_selection,
join
),
@@ -336,10 +336,19 @@ fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
}
/// Corner or smooth on an anchor a join leaves behind.
fn join_point(sp: &mut SubPath, ai: usize, smooth: bool) {
if smooth {
sp.smooth_anchor(ai);
} else if let Some(a) = sp.anchors.get_mut(ai) {
a.retract();
}
}
/// 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.
fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
fn merge_coincident(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();
// The closing pair of a closed path first: taking the last anchor moves no other index.
@@ -352,6 +361,9 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
{
first.h_in = last.h_in;
sp.anchors.pop();
if let Some(smooth) = 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() {
@@ -362,6 +374,9 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
{
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()
@@ -369,6 +384,12 @@ fn merge_coincident(sp: &mut SubPath, picked: impl Fn(usize) -> bool) -> usize {
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) {
None => None,
Some("smooth") => Some(true),
Some("corner") => Some(false),
Some(_) => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")),
};
let ids = match ids_param(p, "ids") {
Some(ids) => {
let st = s.doc()?;
@@ -386,7 +407,8 @@ 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)))).sum();
let n: usize =
path.subpaths.iter_mut().enumerate().map(|(si, sp)| merge_coincident(sp, |ai| picked.contains(&(si, ai)), join_kind)).sum();
if n > 0 {
merged.push((*id, path));
}
@@ -426,6 +448,9 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
&& let Some(first) = sp.anchors.first_mut()
{
first.h_in = last.h_in;
if let Some(smooth) = join_kind {
join_point(sp, 0, smooth);
}
}
sp.closed = true;
Ok(())
@@ -458,6 +483,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
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
{
@@ -467,6 +493,9 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
}
}
x.anchors.extend(y.anchors);
if let Some(smooth) = join_kind {
join_point(&mut x, junction, smooth);
}
pa.subpaths[sa] = x;
d.remove(b)?;
sel.set([a]);
+25
View File
@@ -1846,11 +1846,36 @@ fn join_takes_the_paths_and_the_ends_to_join() {
json!({"ids": [a.0, 999]}),
json!({"ids": [a.0, r.0], "ends": ["first"]}),
json!({"ids": [a.0, b.0], "ends": ["first", "middle"]}),
json!({"to": "round"}),
] {
assert!(s.execute("path.join", &bad).is_err(), "{bad}");
}
}
/// `path.join {to}` makes the joined anchor a corner or smooth point.
#[test]
fn join_to_corner_or_smooth_sets_the_merged_anchor() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 100.0, 80.0);
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap();
s.execute("path.average", &json!({"axis": "both"})).unwrap();
s.execute("path.join", &json!({"to": "corner"})).unwrap();
let a = &path(&s, r).subpaths[0].anchors[0];
assert_eq!(a.kind, vectorcraft_geom::AnchorKind::Corner);
assert_eq!((a.h_in, a.h_out), (a.p, a.p));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("path.average", &json!({"axis": "both"})).unwrap();
s.execute("path.join", &json!({"to": "smooth"})).unwrap();
let a = &path(&s, r).subpaths[0].anchors[0];
assert_eq!(a.kind, vectorcraft_geom::AnchorKind::Smooth);
let a = line(&mut s, 0.0, 0.0, 100.0, 0.0);
let b = line(&mut s, 100.0, 0.0, 200.0, 0.0);
s.execute("path.join", &json!({"ids": [a.0, b.0], "to": "smooth"})).unwrap();
let j = &path(&s, a).subpaths[0].anchors[1];
assert_eq!(j.kind, vectorcraft_geom::AnchorKind::Smooth);
}
#[test]
fn pen_with_alt_converts_the_anchors_of_a_path_in_a_selected_group() {
// #1273: a selected group (or compound path) shows its paths' anchors, so Alt with the Pen
+1 -1
View File
@@ -63,7 +63,7 @@ tools, never checked side by side) isn't captured row by row.
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Alt key toggles to Smooth Tool (#2001); Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679) |
| Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | partial | Alt to Smooth is missing; Edit Selected Paths continues a selected path from an end and, from a stroke that starts and ends on a selected path, redraws the section between (the shorter way round a closed path, #2850); with Keep selected (Pencil and Paintbrush, on by default) off, the path a stroke draws or continues is not selected afterwards; a filled Pencil path keeps a stroke of None (#1804, part of #1679) |
| Join tool scrubbing, Average, Join | done | Join merges the direct-selected anchors lying on one another along a path, as after Average, into one (#1533) |
| Average and Join in one step, with a corner or smooth choice for the joined anchor | missing | `path.join` and `path.average` are separate commands, and Join has no corner/smooth choice; [gap 1](gaps.md) |
| Average and Join in one step, with a corner or smooth choice for the joined anchor | partial | `path.join` accepts `to: corner|smooth` on the merged anchor; Average and Join are still separate commands; [gap 1](gaps.md) |
| Smooth tool smooths only the stretch of path under the drag | done | #1512 |
| Simplify (slider, auto, corner angle threshold) | partial | Tolerance, default-on Preview and original/new point counts (#1272); Corner Point Angle Threshold, Convert to Straight Lines and Show Original Path; slider, auto-simplify and Retain my latest settings remain missing |