From b976de560d5c33cff0c46bdf91a20b903346dcfb Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:21:55 -0500 Subject: [PATCH] Join tool: scrubbing the ends of two crossing paths trims them and joins the paths at the crossing (#5844) The Join tool drew a segment between the open ends of two crossing paths instead of trimming what overhangs the crossing. path.joinScrub now looks for two crossing open subpaths whose overhanging ends the scrub runs over, cuts both at the crossing and joins the kept parts there with a corner, in one undo step. Without a crossing the endpoint join is unchanged. --- crates/engine/src/cmd/draw2/commands.rs | 2 +- crates/engine/src/cmd/draw2/smooth.rs | 116 +++++++++++++++++++++++- crates/engine/src/tests/draw2.rs | 48 ++++++++++ docs/ui-parity.md | 5 +- 4 files changed, 166 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index df1d47600..0358acddf 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -119,7 +119,7 @@ pub fn specs() -> Vec { "Join", [], None, - "{points: [[x,y]…], tolerance?: pt (12)} join the open-path endpoints scrubbed over → {id}", + "{points: [[x,y]…], tolerance?: pt (12)} join the open-path endpoints scrubbed over, or trim two crossing open paths whose overhanging ends are scrubbed and join them at the crossing → {id}", has_doc, join_scrub ), diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index a4aa1f79a..d63bdaea7 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -1,4 +1,6 @@ -use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, target_paths}; +use vectorcraft_pathops::addons::pathtools as pt; + +use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, subpath_len, target_paths}; use super::{ Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json, number_or, po, @@ -179,11 +181,121 @@ pub(super) fn smooth_region(s: &mut Session, p: &Value) -> Result { // ---------- Join tool ---------- +/// Most open subpaths the Join tool compares pairwise for crossings before it only joins ends. +const MAX_TRIM_SUBPATHS: usize = 64; + +/// One end of an open subpath that runs on past where it crosses another: the part kept, which +/// ends at the crossing, and the part past it. +struct Overhang { + kept: SubPath, + cut: SubPath, +} + +impl Overhang { + fn at(&self) -> Option { + self.kept.anchors.last().map(|a| a.p) + } +} + +/// The overhangs of `sp` at its first and last crossing (`places`: subpath, segment, t, in order). +/// A crossing on an end anchor leaves no overhang there. +fn overhangs(sp: &SubPath, places: &[(usize, usize, f64)]) -> Vec { + let mut out = vec![]; + for (place, at_start) in [(places.first(), true), (places.last(), false)] { + let Some(&(_, seg, t)) = place else { continue }; + let (split, marks) = pt::insert_at(sp, &[(seg, t)]); + let Ok([first, second]) = <[SubPath; 2]>::try_from(split.cut_at(&marks)) else { continue }; + let (mut kept, cut) = if at_start { (second, first) } else { (first, second) }; + if at_start { + kept.reverse(); + } + out.push(Overhang { kept, cut }); + } + out +} + +/// Does the scrub `pts` (`scrub` as a path) cross `cut` or pass within `tol` of it? +fn scrubbed(cut: &SubPath, pts: &[Point], scrub: &PathData, tol: f64) -> bool { + let pd = PathData::single(cut.clone()); + pt::crossings(&[&pd, scrub], false).is_some_and(|c| c.first().is_some_and(|v| !v.is_empty())) + || cut.anchors.iter().any(|a| dist_to_polyline(pts, a.p) <= tol) + || pts.iter().any(|q| pd.nearest(*q).is_some_and(|n| n.4 <= tol)) +} + +/// Two crossing open subpaths, each `(path, subpath)`, trimmed and joined at the crossing. +struct TrimJoin { + a: (NodeId, usize), + b: (NodeId, usize), + joined: SubPath, +} + +/// #5844: two open subpaths among `ids` that cross, where the scrub runs over the part of each +/// that overhangs the crossing, trimmed at the crossing and joined there with a corner. When +/// several pairs or ends qualify, the one trimming the least wins. +fn trim_join(d: &vectorcraft_doc::Document, ids: &[NodeId], pts: &[Point], tol: f64) -> Result> { + let mut open = vec![]; + for &id in ids { + let (pd, _) = path_of(d, id)?; + open.extend(pd.subpaths.into_iter().enumerate().filter(|(_, sp)| !sp.closed && sp.anchors.len() >= 2).map(|(si, sp)| (id, si, sp))); + } + if open.len() > MAX_TRIM_SUBPATHS { + return Ok(None); + } + let scrub = PathData::single(SubPath::polyline(pts, false)); + let mut best: Option<(f64, TrimJoin)> = None; + for (i, (ida, sa, a)) in open.iter().enumerate() { + for (idb, sb, b) in open.iter().skip(i + 1) { + let (pa, pb) = (PathData::single(a.clone()), PathData::single(b.clone())); + let Some(places) = pt::crossings(&[&pa, &pb], false) else { continue }; + let (Some(on_a), Some(on_b)) = (places.first(), places.get(1)) else { continue }; + let (oa, ob) = (overhangs(a, on_a), overhangs(b, on_b)); + for x in oa.iter().filter(|o| scrubbed(&o.cut, pts, &scrub, tol)) { + for y in ob.iter().filter(|o| scrubbed(&o.cut, pts, &scrub, tol)) { + let (Some(xa), Some(ya)) = (x.at(), y.at()) else { continue }; + let trimmed = subpath_len(&x.cut) + subpath_len(&y.cut); + if xa.distance(ya) > 1e-2 || best.as_ref().is_some_and(|b| b.0 <= trimmed) { + continue; + } + // `x` ends at the crossing; `y`, reversed, starts there: one corner anchor. + let mut joined = x.kept.clone(); + let mut rest = y.kept.clone(); + rest.reverse(); + let (Some(last), Some(first)) = (joined.anchors.last_mut(), rest.anchors.first()) else { continue }; + *last = + Anchor { p: last.p, h_in: last.h_in, h_out: first.h_out + (last.p - first.p), kind: vectorcraft_geom::AnchorKind::Corner }; + joined.anchors.extend(rest.anchors.into_iter().skip(1)); + best = Some((trimmed, TrimJoin { a: (*ida, *sa), b: (*idb, *sb), joined })); + } + } + } + } + Ok(best.map(|(_, t)| t)) +} + pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result { const C: &str = "path.joinScrub"; let pts = points_param(p, "points", C)?; let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0); let st = s.doc()?; + let targets = target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)); + // Paths that cross with their ends scrubbed are trimmed there and joined (#5844). + if let Some(TrimJoin { a: (ida, sa), b: (idb, sb), joined }) = trim_join(&st.doc, &targets, &pts, tol)? { + s.edit("Join", |d, sel| { + let missing = || crate::EngineError::Other("Join: a subpath went missing".into()); + *path_mut(d, ida)?.subpaths.get_mut(sa).ok_or_else(missing)? = joined; + let pbm = path_mut(d, idb)?; + if sb >= pbm.subpaths.len() { + return Err(missing()); + } + pbm.subpaths.remove(sb); + if ida != idb && pbm.is_empty() { + d.remove(idb)?; + } + sel.set([ida]); + Ok(()) + })?; + return Ok(json!({ "id": ida.0 })); + } // Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start). let poly = { let mut bp = BezPath::new(); @@ -202,7 +314,7 @@ pub(super) fn join_scrub(s: &mut Session, p: &Value) -> Result { }; let (first_pt, last_pt) = (pts.first().copied(), pts.last().copied()); let mut ends: Vec<(f64, NodeId, usize, bool, Point)> = vec![]; - for id in target_paths(&st.doc, &st.selection, poly_bounds(&pts, tol)) { + for id in targets { let (pd, _) = path_of(&st.doc, id)?; for (si, sp) in pd.subpaths.iter().enumerate() { if sp.closed || sp.anchors.len() < 2 { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 479b815ff..bb8c7329d 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -1343,6 +1343,54 @@ fn join_tool_joins_endpoints_near_the_scrub_ends() { assert_eq!(p.subpaths[0].anchors[1].p, Point::new(202.5, 100.0)); } +/// #5844: scrubbing over the ends of two crossing open paths that run past the crossing trims +/// those ends and joins the paths there with a corner, rather than drawing a segment between the +/// ends. +#[test] +fn join_tool_trims_crossing_paths_and_joins_them_at_the_crossing() { + let mut s = session(); + let a = line(&mut s, 100.0, 300.0, 400.0, 300.0); + let b = line(&mut s, 300.0, 100.0, 300.0, 380.0); + s.execute("select.all", &json!({})).unwrap(); + drag(&mut s, "join", &[(380.0, 285.0), (285.0, 365.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 1, "one path is left: {ps:?}"); + let (id, p) = &ps[0]; + assert!(*id == a || *id == b); + assert_eq!(p.subpaths.len(), 1); + let mut got: Vec = p.subpaths[0].anchors.iter().map(|x| x.p).collect(); + if got.first() != Some(&Point::new(100.0, 300.0)) { + got.reverse(); + } + let near = |q: Point, r: Point| q.distance(r) < 1e-6; + assert_eq!(got.len(), 3, "{got:?}"); + assert!(near(got[0], Point::new(100.0, 300.0)) && near(got[1], Point::new(300.0, 300.0)) && near(got[2], Point::new(300.0, 100.0)), "{got:?}"); + assert!(!p.subpaths[0].closed); + assert_eq!(s.doc().unwrap().selection.objects.len(), 1); +} + +/// #5844: a curve with a hook past a straight line (the report's case) is trimmed at the crossing +/// and joined to the line's kept part; nothing of either overhang is left. +#[test] +fn join_tool_trims_a_curve_hooking_past_a_line() { + let mut s = session(); + line(&mut s, 0.0, 300.0, 480.0, 300.0); + s.execute("path.create", &json!({"anchors": [{"x": 260, "y": 0, "out": [290, 150]}, {"x": 378, "y": 368, "in": [310, 380]}]})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + drag(&mut s, "join", &[(470.0, 280.0), (420.0, 330.0), (370.0, 380.0)], Mods::default()); + let ps = paths(&s); + assert_eq!(ps.len(), 1, "{ps:?}"); + let sp = &ps[0].1.subpaths[0]; + assert!(!sp.closed); + // Every anchor is on the kept sides: the line left of the crossing, the curve above it. + let joint = sp.anchors.iter().find(|x| (x.p.y - 300.0).abs() < 1e-6 && x.p.x > 100.0).expect("an anchor at the crossing"); + for x in &sp.anchors { + assert!(x.p.y <= 300.0 + 1e-6 && x.p.x <= joint.p.x + 1e-6, "{x:?} is past the crossing at {joint:?}"); + } + let bounds = ps[0].1.bounds().unwrap(); + assert!(bounds.y1 <= 300.0 + 1e-6 && bounds.x1 <= joint.p.x + 1e-6, "{bounds:?}"); +} + #[test] fn line_family_tools_draw_and_click_asks_dialog() { let mut s = session(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7bbc9e58c..570bd4817 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Join tool: scrubbing the overhanging ends of two crossing paths trims them and joins the paths at the crossing, #5844; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) ## Feature submission evidence @@ -38,6 +38,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Join tool: scrubbing over the ends of two crossing open paths that run past the crossing trims those ends and joins the paths there with a corner, instead of drawing a segment between the ends (#5844); scores and estimates unchanged | | 2026-11-05 | minor | Effect dialogs: a swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged | | 2026-11-05 | minor | Shape tools: a shape just drawn keeps its bounding box and Live Shape widgets on show with the Rectangle, Rounded Rectangle, Ellipse, Polygon or Star tool, and its corner and pie widgets drag as with the Selection tool (#5477); scores and estimates unchanged | | 2026-11-05 | minor | Character panel › Show Snap to Glyph Options scrolls the panel down to the Snap to Glyph section, which comes after every character attribute and could sit out of sight, and the section starts with a Snap to Glyph checkbox (View › Snap to Glyph) so its dimmed options can be turned on there (#5273); scores and estimates unchanged | @@ -137,7 +138,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 | Fidelity goes down to 0.01 pt in the dialog, the tool and `path.freehand` (the Smooth tool keeps 0.05); its slider runs across the Tool Options row on a logarithmic rail from 0.01 to 20 pt, the field shows hundredths, and the slider's and the field's arrow keys step hundredths up to 0.1 pt, tenths up to 1 pt and whole points above (Shift ×10) (#3328); Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor while Shift or Alt is held before the press, and a path-continuation cursor over an end of a selected open path, where the line continues the path; 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) on, a stroke selects the path it draws or continues and replaces the earlier selection, and off a new stroke leaves nothing selected, the earlier selection included, while a continued or redrawn path is deselected and the rest of the selection stays; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path | | Pencil: Fidelity, Keep selected, Alt to Smooth, edit selected paths, close by proximity | done | Alt key toggles to Smooth Tool (#2001); Shift held at the press draws a straight line at 45° steps from the Constrain Angle, and Alt held at the press (with Alt key toggles to Smooth Tool off) a straight line at any angle (#3799), with a straight-line cursor while Shift or Alt is held before the press, and a path-continuation cursor over an end of a selected open path, where the line continues the path; 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) on, a stroke selects the path it draws or continues and replaces the earlier selection, and off a new stroke leaves nothing selected, the earlier selection included, while a continued or redrawn path is deselected and the rest of the selection stays; a filled Pencil path keeps a stroke of None (#1804, part of #1679); while a freehand stroke is drawn back to within Close Paths When Ends Are Within of its start, the Pencil and the Paintbrush show the closing cursor, and a release there closes the path; a Pencil stroke from an end of one selected open path to an end of another connects the two into one path | | Effect dialogs' colour fields: a swatch beside the hex follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills it and Cancel comes back unchanged (#5365) | done | dialogs::effect test outer_glow_colour_swatch_opens_the_color_picker; the picker previews nothing itself, the effect dialog previews the returned colour | -| 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) | +| 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); the Join tool trims two crossing open paths whose overhanging ends it scrubs over and joins them at the crossing, instead of drawing a segment between their ends (#5844) | | Average and Join in one step, with a corner or smooth choice for the joined anchor | done | Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, "Create corner or smooth join" in [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): two open path ends meet at their midpoint and join, a corner or smooth point as the dialog's Points choice asks (Corner first, then the last choice), in one undo step | | Smooth tool smooths only the stretch of path under the drag | done | #1512 | | Drawing modes apply to pastes: Draw Inside takes Paste, Paste in Place and Paste on All Artboards inside the object; Draw Behind puts Paste in Place and Paste on All Artboards behind the selection; Shift+D cycles past Draw Behind with any selection ([drawing modes](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/learn-drawing-basics/drawing-modes-overview.html)) | done | #3705; `pasting_in_draw_inside_puts_the_copies_inside_the_object`, `paste_in_place_in_draw_behind_goes_behind_the_selection`, `shift_d_goes_past_draw_behind_whatever_is_selected` |