mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
Snapping: a line end dragged with Direct Selection snaps square to straight paths and tangent to curves (#2530)
This commit is contained in:
committed by
bflatastic
parent
811ba8727b
commit
63b685758f
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user