Snapping: a line end dragged with Direct Selection snaps square to straight paths and tangent to curves (#2530)

This commit is contained in:
Taha-Mahmoodi
2026-10-31 19:43:17 +07:00
committed by bflatastic
parent 811ba8727b
commit 63b685758f
6 changed files with 166 additions and 14 deletions
+39 -1
View File
@@ -180,6 +180,10 @@ pub struct DirectSelectionTool {
guides: Vec<Overlay>,
/// What the dragged anchors snap to, gathered when the drag begins.
anchor_snap: Option<PointSnap>,
/// With one open path's end anchor dragged along a straight segment, that segment's other end:
/// the dragged end may land square to a straight path or grazing a curve (Smart Guides ›
/// Geometric Guides). Set when the drag begins.
line_end: Option<Leave>,
/// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left
/// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original.
copy_snap: Option<PointSnap>,
@@ -227,6 +231,7 @@ impl DirectSelectionTool {
mesh: MeshEdit::default(),
guides: vec![],
anchor_snap: None,
line_end: None,
copy_snap: None,
handle_snap: HandleSnap::default(),
move_snap: None,
@@ -406,6 +411,38 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
v
}
/// Where a line's end anchor `grab`bed for a drag leaves from, when it is one: the only anchor
/// selected, the end of an open subpath whose one segment is straight, and no other art moving
/// with it. That segment's other end stays put, so the end snaps square to straight paths and
/// grazing curves; mid-line anchors and ends of curved segments do not (Illustrator limits Snap
/// to Tangent and Snap to Perpendicular to line endpoints).
fn line_end_leave(cx: &ToolContext, grab: Point) -> Option<Leave> {
let sel = cx.selection;
if !sel.segments.is_empty() || sel.anchors.len() != 1 {
return None;
}
let (&id, set) = sel.anchors.iter().next()?;
if set.len() != 1 || sel.objects.iter().any(|o| *o != id) {
return None;
}
let &(si, ai) = set.iter().next()?;
let sp = cx.doc.node(id)?.path_data()?.subpaths.get(si)?;
let n = sp.anchors.len();
if sp.closed || ai >= n || sp.anchors[ai].p.distance(grab) > 1e-6 {
return None;
}
let (other, seg) = if n < 2 {
return None;
} else if ai == 0 {
(1, 0)
} else if ai == n - 1 {
(n - 2, n - 2)
} else {
return None;
};
sp.segment_is_line(seg).then(|| Leave::contact(sp.anchors[other].p))
}
/// The anchor within `tol` of `p` nearest to it, of the topmost selected path or area type frame
/// that has one, else of the topmost visible one: (id, si, ai, where it is). Selected objects come
/// first so an object above can't take the press from the one being edited (#1508), as
@@ -1230,6 +1267,7 @@ impl DirectSelectionTool {
out.push(Action::Begin("Move".into()));
self.state = State::MoveAnchors { start, grab, began: true };
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
self.line_end = line_end_leave(cx, grab);
}
let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
let unsnapped = d;
@@ -1238,7 +1276,7 @@ impl DirectSelectionTool {
self.guides.clear();
if let Some(snap) = &self.anchor_snap {
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref().or(self.line_end.as_ref()));
(d, self.guides) = (q - grab, guides);
}
self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides);
+95
View File
@@ -1477,6 +1477,101 @@ fn dragged_end_of_a_slanted_line_stays_on_its_direction() {
assert!(matches!(guides[..], [(a, b)] if a == Point::new(300.0, 400.0) && (b - Point::new(420.0, 310.0)).hypot() < 1e-9), "{guides:?}");
}
/// Drawn lines snap square to a straight path and grazing a curve (Smart Guides › Geometric
/// Guides); so does an existing line whose end anchor is dragged with Direct Selection. Each
/// option switches its own snap off. Only a line's end snaps so: the middle anchor of a
/// polyline, an end of a curved segment, a closed path's anchor, several anchors dragged together,
/// a Shift drag and an end out of reach do not, nor does Group Selection, which moves the path whole.
#[test]
fn dragged_line_ends_snap_perpendicular_to_segments_and_tangent_to_curves() {
use vectorcraft_geom::{BezPath, PathData, SubPath, shapes};
let near = |v: &serde_json::Value, (x, y): (f64, f64), eps: f64| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < eps;
// The rect's right side (x = 200) and a circle (centre (500, 500), radius 50), with one more
// path: its id, and the document.
let with = |path: PathData| {
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let (circle, id) = (d.alloc_id(), d.alloc_id());
let ring =
vectorcraft_doc::Node::path(circle, shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), vectorcraft_doc::Appearance::default_art());
d.insert(Some(l), 1, ring).unwrap();
d.insert(Some(l), 2, vectorcraft_doc::Node::path(id, path, vectorcraft_doc::Appearance::default_art())).unwrap();
(d, id)
};
let p = paint();
let none = Mods::default();
let mut t = DirectSelectionTool::new(false);
// A line (300, 160)–(260, 170), its end dragged to 3.6 pt from the foot of the perpendicular
// from (300, 160) to the rect's right side: (200, 160).
let (d, id) = with(shapes::line(Point::new(300.0, 160.0), Point::new(260.0, 170.0)));
let end = anchors_of(id, &[(0, 1)]);
let c = cx(&d, &end, &p);
let (v, labels) = drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none);
assert!(near(&v, (-60.0, -10.0), 1e-6) && labels == ["perpendicular"], "{v} {labels:?}");
// The other end snaps the same way about this one: square to the side at (200, 170).
let start = anchors_of(id, &[(0, 0)]);
let c = cx(&d, &start, &p);
let (v, labels) = drag(&mut t, &c, (300.0, 160.0), (202.0, 172.0), none);
assert!(near(&v, (-100.0, 10.0), 1e-6) && labels == ["perpendicular"], "{v} {labels:?}");
// Perpendicular off: the end only lands on the path, where the pointer is; tangent off does
// not change that.
let c = ToolContext { snap_perpendicular: false, ..cx(&d, &end, &p) };
let (v, labels) = drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none);
assert!(near(&v, (-60.0, -7.0), 1e-6) && labels == ["path"], "{v} {labels:?}");
let c = ToolContext { snap_tangent: false, ..cx(&d, &end, &p) };
assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, ["perpendicular"]);
// With Smart Guides off, Snap to Point alone finds no contact.
let c = ToolContext { smart_guides: false, ..cx(&d, &end, &p) };
assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, Vec::<String>::new());
// Out of reach: left alone.
let c = cx(&d, &end, &p);
let (v, labels) = drag(&mut t, &c, (260.0, 170.0), (240.0, 140.0), none);
assert!(near(&v, (-20.0, -30.0), 1e-9) && labels.is_empty(), "{v} {labels:?}");
// Shift holds the move to its 45° step: no contact.
let shift = Mods { shift: true, ..none };
assert!(!drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), shift).1.contains(&"perpendicular".to_string()));
// Group Selection moves the path whole: no contact either.
let mut group = DirectSelectionTool::new(true);
assert!(!drag(&mut group, &c, (260.0, 170.0), (202.0, 163.0), none).1.contains(&"perpendicular".to_string()));
// A line (400, 500)–(420, 470) toward the circle: its end lands where the line from (400, 500)
// grazes it, at (475, 456.7).
let (d, id) = with(shapes::line(Point::new(400.0, 500.0), Point::new(420.0, 470.0)));
let end = anchors_of(id, &[(0, 1)]);
let c = cx(&d, &end, &p);
let (v, labels) = drag(&mut t, &c, (420.0, 470.0), (477.0, 459.0), none);
assert!(near(&v, (55.0, 500.0 - 1875f64.sqrt() - 470.0), 0.1) && labels == ["tangent"], "{v} {labels:?}");
let c = ToolContext { snap_tangent: false, ..cx(&d, &end, &p) };
let (v, labels) = drag(&mut t, &c, (420.0, 470.0), (477.0, 459.0), none);
assert!(near(&v, (55.53, -13.61), 0.01) && labels == ["path"], "the end only lands on the circle: {v} {labels:?}");
let c = ToolContext { snap_perpendicular: false, ..cx(&d, &end, &p) };
assert_eq!(drag(&mut t, &c, (420.0, 470.0), (477.0, 459.0), none).1, ["tangent"]);
// Not a line's end: the middle anchor of a polyline, and an anchor of a closed path.
let three = [Point::new(300.0, 160.0), Point::new(260.0, 170.0), Point::new(240.0, 230.0)];
let (d, id) = with(PathData::single(SubPath::polyline(&three, false)));
let mid = anchors_of(id, &[(0, 1)]);
let c = cx(&d, &mid, &p);
assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, ["path"]);
let (d, id) = with(PathData::single(SubPath::polyline(&three, true)));
let corner = anchors_of(id, &[(0, 1)]);
let c = cx(&d, &corner, &p);
assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, ["path"]);
// The end of a curved segment: no contact either.
let mut curve = BezPath::new();
curve.move_to(Point::new(300.0, 160.0));
curve.curve_to(Point::new(290.0, 150.0), Point::new(270.0, 160.0), Point::new(260.0, 170.0));
let (d, id) = with(PathData::from_bezpath(&curve));
let end = anchors_of(id, &[(0, 1)]);
let c = cx(&d, &end, &p);
assert_eq!(drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1, ["path"]);
// A line end dragged with another anchor selected: the pair moves rigidly, no contact.
let (d, id) = with(shapes::line(Point::new(300.0, 160.0), Point::new(260.0, 170.0)));
let both = anchors_of(id, &[(0, 0), (0, 1)]);
let c = cx(&d, &both, &p);
assert!(!drag(&mut t, &c, (260.0, 170.0), (202.0, 163.0), none).1.contains(&"perpendicular".to_string()));
}
/// Group Selection (and Direct Selection on a whole path) moves objects snapping as the
/// Selection tool does: B's left side lines up with A's right one.
#[test]
+8
View File
@@ -230,6 +230,14 @@ impl Leave {
self
}
/// The straight segment from the anchor `at` to a point being dragged (an open path's end
/// moved with Direct Selection): the point may land square to a segment or grazing a curve,
/// but is not held to Shift's angles or the construction guides, which a moved anchor
/// does not follow.
pub fn contact(at: Point) -> Self {
Self { at, shift: None, angles: &[], contact: true }
}
/// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids, and
/// Artboard, which passes no Shift): Shift keeps the dragged corner on a diagonal, the box a
/// square.