diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 88f98820f..e421c157e 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { "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) -> 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 { 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 { 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 { && 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 { 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 { } } 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]); diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 2ac9f2071..4fd0df21b 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 857d91187..a3c193da6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |