mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Fix #2054: bounding-box resize handles snap to the grid, pixels, points and ruler guides
A bounding-box handle dragged with the Selection or Free Transform tool snapped only with Smart Guides on, and then only to the other objects and the artboards. View > Snap to Grid, Snap to Pixel, Snap to Point with Smart Guides off and the ruler guides did not move it, while a move snaps to all of them. The corner or side a handle moves now snaps as a dragged point does. With Snap to Grid on it lines up with the nearest gridline, and with Snap to Pixel on it lines up with whole pixels. With Smart Guides off, Snap to Point lands it on the other objects' anchors and on ruler guides within its distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as with the other objects. Shift still keeps the proportions. Without Alt the opposite side stays put, and with Alt the box still scales about its center. Docs: docs/target-app-parity.md.
This commit is contained in:
@@ -1708,6 +1708,27 @@ fn direct_selection_snaps_anchors_to_the_grid() {
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0));
|
||||
}
|
||||
|
||||
/// #2054: with Snap to Grid on, a bounding-box handle dragged with the Selection tool or the Free
|
||||
/// Transform tool lands the corner it moves on the grid, even with Snap to Pixel left on too; the
|
||||
/// opposite corner stays put.
|
||||
#[test]
|
||||
fn bounding_box_handles_snap_to_the_grid() {
|
||||
for tool in ["selection", "freeTransform"] {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
|
||||
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
|
||||
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
|
||||
let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() };
|
||||
s.select_tool(tool, v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 201.0, 201.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 230.0, 214.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 230.0, 214.0), v).unwrap();
|
||||
let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap();
|
||||
assert!(on_grid(b.x1) && on_grid(b.y1), "{tool}: {b:?}, grid step {step}");
|
||||
assert!((b.x0 - 101.0).abs() < 1e-9 && (b.y0 - 101.0).abs() < 1e-9, "{tool}: {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
|
||||
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
|
||||
#[test]
|
||||
|
||||
@@ -424,6 +424,22 @@ impl Targets {
|
||||
(cx.snap_to_point && !cx.smart_guides).then(|| Self::collect(cx.doc, exclude, None).for_snap_to_point(cx))
|
||||
}
|
||||
|
||||
/// The gridlines (one every `step` from the page's origin, as
|
||||
/// [`vectorcraft_geom::snap::snap_to_grid`] places them) at and either side of the nearest one
|
||||
/// to each of `points`, as lines to line up with.
|
||||
fn grid_lines(points: impl IntoIterator<Item = Point>, step: f64) -> Self {
|
||||
let mut t = Targets::default();
|
||||
let line = |v: f64, k: i32| vectorcraft_geom::snap::snap_to_grid(v, step, 0.0) + f64::from(k) * step;
|
||||
for p in points {
|
||||
for k in -1..=1 {
|
||||
let (x, y) = (line(p.x, k), line(p.y, k));
|
||||
t.xs.push((x, Point::new(x, p.y), Kind::Guide));
|
||||
t.ys.push((y, Point::new(p.x, y), Kind::Guide));
|
||||
}
|
||||
}
|
||||
t.index()
|
||||
}
|
||||
|
||||
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
|
||||
/// reach into `near`.
|
||||
fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
|
||||
@@ -799,8 +815,8 @@ impl Targets {
|
||||
/// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own
|
||||
/// frame, and so is the result. The handle lands on an anchor or centre within `tol` on the page
|
||||
/// (labelled as [`Self::snap_point`] does), else the corners and the side it moves line up with
|
||||
/// the nearest target ("align"), whatever the box's angle. A handle with one way to go (a side,
|
||||
/// or a proportional corner) slides along it until one of them is on a target.
|
||||
/// the nearest target or ruler guide ("align"), whatever the box's angle. A handle with one way
|
||||
/// to go (a side, or a proportional corner) slides along it until one of them is on a target.
|
||||
pub fn snap_scale(&self, bx: &OrientedBox, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec<Overlay>) {
|
||||
type Hit = Option<(f64, Point)>;
|
||||
// Where a snap lands: in line with a target's x and/or y, or on an anchor or centre.
|
||||
@@ -811,10 +827,16 @@ impl Targets {
|
||||
let (to_doc, to_local) = (bx.to_doc(), bx.to_doc().inverse());
|
||||
// A scale that collapses or flips the box is no snap.
|
||||
let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0;
|
||||
let nearest = |v: f64, ts: &[(f64, Point, Kind)]| -> Hit {
|
||||
// The target line along the x axis (`x`: a vertical line) or the y axis nearest `p`, the
|
||||
// ruler guides running past it included.
|
||||
let nearest = |p: Point, x: bool| -> Hit {
|
||||
let (v, ts) = if x { (p.x, &self.xs) } else { (p.y, &self.ys) };
|
||||
let rulers = self.rulers.iter().filter(|r| r.vertical == x).filter_map(|r| r.line_at(p, tol));
|
||||
lines_near(ts, v, tol)
|
||||
.iter()
|
||||
.map(|(t, from, _)| (*t, *from))
|
||||
.copied()
|
||||
.chain(rulers)
|
||||
.map(|(t, from, _)| (t, from))
|
||||
.filter(|(t, _)| (t - v).abs() <= tol)
|
||||
.min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
|
||||
};
|
||||
@@ -824,7 +846,7 @@ impl Targets {
|
||||
// The corner goes anywhere: onto an anchor or centre, else it takes the target's x, its
|
||||
// y, or both.
|
||||
let p = to_doc * (a * handle.pos(r));
|
||||
let (hx, hy) = (nearest(p.x, &self.xs), nearest(p.y, &self.ys));
|
||||
let (hx, hy) = (nearest(p, true), nearest(p, false));
|
||||
let on = self.nearest_point(p, tol).map(|(q, k)| (Some((q.x, q)), Some((q.y, q)), Some((q, k))));
|
||||
for (tx, ty, at) in on.into_iter().chain([(hx, hy, None), (hx, None, None), (None, hy, None)]) {
|
||||
if tx.is_none() && ty.is_none() {
|
||||
@@ -878,11 +900,11 @@ impl Targets {
|
||||
{
|
||||
consider((q - base).dot(dir) / l2, false, (None, None, Some((q, k))));
|
||||
}
|
||||
for (is_x, at, b, d, ts) in [(true, p.x, base.x, dir.x, &self.xs), (false, p.y, base.y, dir.y, &self.ys)] {
|
||||
for (is_x, b, d) in [(true, base.x, dir.x), (false, base.y, dir.y)] {
|
||||
if d.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let Some(hit) = nearest(at, ts) else { continue };
|
||||
let Some(hit) = nearest(p, is_x) else { continue };
|
||||
consider((hit.0 - b) / d, true, if is_x { (Some(hit), None, None) } else { (None, Some(hit), None) });
|
||||
}
|
||||
}
|
||||
@@ -980,6 +1002,38 @@ impl PointSnap {
|
||||
None => (keep(p), vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap a bounding-box resize ([`Targets::snap_scale`]) as [`Self::snap`] snaps a point. With
|
||||
/// Snap to Pixel on, the corner or side the handle moves lines up with whole pixels, and with
|
||||
/// Snap to Grid on it lines up with the nearest gridline, however far that is; neither shows
|
||||
/// guides. Otherwise the handle snaps to the targets [`Self::new`] gathered.
|
||||
pub fn snap_scale(
|
||||
&self,
|
||||
cx: &ToolContext,
|
||||
bx: &OrientedBox,
|
||||
handle: Handle,
|
||||
a: Affine,
|
||||
proportional: bool,
|
||||
from_center: bool,
|
||||
) -> (Affine, Vec<Overlay>) {
|
||||
let step = if cx.snap_to_pixel {
|
||||
Some(1.0)
|
||||
} else if cx.snap_to_grid {
|
||||
Some(cx.grid_step()).filter(|s| s.is_finite() && *s > 0.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(step) = step {
|
||||
let moved = Handle::ALL.map(|h| bx.to_doc() * (a * h.pos(bx.rect)));
|
||||
// Every point the handle moves is within half a step of a gridline, so a tolerance of
|
||||
// one step always finds one.
|
||||
return (Targets::grid_lines(moved, step).snap_scale(bx, handle, a, proportional, from_center, step).0, vec![]);
|
||||
}
|
||||
match &self.targets {
|
||||
Some((t, tol)) => t.snap_scale(bx, handle, a, proportional, from_center, cx.tol(*tol)),
|
||||
None => (a, vec![]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Construction Guides angle set new preferences start with.
|
||||
|
||||
+100
-6
@@ -1,7 +1,8 @@
|
||||
//! The Selection tool (V): click/shift-click, marquee (Shift-drag toggles the objects it reaches:
|
||||
//! the selected ones are deselected, the others selected), move (Alt copies, Shift constrains; Smart
|
||||
//! Guides, or with them off View › Snap to Point, snap it),
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! bounding-box scale (Shift proportional, Alt from center; Snap to Grid, Snap to Pixel, Smart
|
||||
//! Guides and Snap to Point snap what the handle moves) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! double-click opens the Corners dialog), drag a live ellipse's pie widgets to set its pie angles
|
||||
//! (double-click makes it whole again: [`crate::pie`]), double-click a group or an object to enter isolation
|
||||
@@ -28,7 +29,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::Targets;
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::pie::{self, PieDrag};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
@@ -91,7 +92,7 @@ pub struct SelectionTool {
|
||||
measure: Option<(Point, String)>,
|
||||
guides: Vec<Overlay>,
|
||||
/// What a bounding-box handle being dragged snaps to.
|
||||
targets: Option<Targets>,
|
||||
targets: Option<PointSnap>,
|
||||
moving: Option<MoveSnap>,
|
||||
/// The move snapped to a point or a guide at its last step: the arrow turns hollow.
|
||||
snapped: bool,
|
||||
@@ -436,8 +437,10 @@ impl Tool for SelectionTool {
|
||||
let areas = cx.selection.objects.iter().any(is_area);
|
||||
// Only area type: the step resizes type areas; anything else scales.
|
||||
let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" };
|
||||
// Smart Guides align what the handle moves with the other objects.
|
||||
self.targets = cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
|
||||
// What the handle moves snaps as a dragged point does: to pixels or the
|
||||
// grid, else with Smart Guides to the other objects, else with Snap to
|
||||
// Point to their anchors; the ruler guides with Snap to Point.
|
||||
self.targets = Some(PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None)));
|
||||
self.state = State::Scaling { handle, bx, areas };
|
||||
return vec![Action::Begin(label.into())];
|
||||
}
|
||||
@@ -514,7 +517,7 @@ impl Tool for SelectionTool {
|
||||
let mut a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||||
self.guides.clear();
|
||||
if let Some(t) = &self.targets {
|
||||
(a, self.guides) = t.snap_scale(&bx, handle, a, m.shift, m.alt, cx.snap_tol());
|
||||
(a, self.guides) = t.snap_scale(cx, &bx, handle, a, m.shift, m.alt);
|
||||
}
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
|
||||
@@ -1451,6 +1454,97 @@ mod tests {
|
||||
assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}");
|
||||
}
|
||||
|
||||
/// The box the 100–200 rectangle's bounds become under the scale a handle drag previews.
|
||||
fn scaled(a: &[Action]) -> Rect {
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]).transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 200.0))
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
|
||||
}
|
||||
|
||||
/// #2054: with Snap to Grid on, a handle drag lands what the handle moves on the nearest
|
||||
/// gridline (every 9 pt by default) while the opposite side stays put; Shift keeps the
|
||||
/// proportions, and Alt keeps the center.
|
||||
#[test]
|
||||
fn handle_drag_lands_what_it_moves_on_the_grid() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
||||
let drag = |from: (f64, f64), to: (f64, f64), mods: Mods| {
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)), vec![Action::Begin("Scale".into())]);
|
||||
scaled(&t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods)))
|
||||
};
|
||||
let none = Mods::default();
|
||||
// The right side, dragged to x = 213, lands on the gridline at 216.
|
||||
let r = drag((200.0, 150.0), (213.0, 150.0), none);
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 200.0)), "{r:?}");
|
||||
// The bottom-right corner, dragged to (213, 204), lands on (216, 207).
|
||||
let r = drag((200.0, 200.0), (213.0, 204.0), none);
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 207.0)), "{r:?}");
|
||||
// Shift: the corner, at (213, 213) in proportion, lands on (216, 216), still square.
|
||||
let r = drag((200.0, 200.0), (213.0, 204.0), Mods { shift: true, ..none });
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 216.0, 216.0)), "{r:?}");
|
||||
// Alt: the right side lands on 216 and the left side mirrors it about the center.
|
||||
let r = drag((200.0, 150.0), (213.0, 150.0), Mods { alt: true, ..none });
|
||||
assert!(near(r, Rect::new(84.0, 100.0, 216.0, 200.0)), "{r:?}");
|
||||
}
|
||||
|
||||
/// #2054: with Snap to Pixel on, what a handle drag moves lands on whole pixels.
|
||||
#[test]
|
||||
fn handle_drag_lands_what_it_moves_on_whole_pixels() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let c = ToolContext { snap_to_pixel: true, ..cx(&d, &s, &p) };
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, 200.0, 200.0));
|
||||
let r = scaled(&t.pointer(&c, &ev(PointerKind::Drag, 212.4, 230.7)));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 212.0, 231.0)), "{r:?}");
|
||||
}
|
||||
|
||||
/// #2054: with Smart Guides off, View › Snap to Point lands a dragged handle on another
|
||||
/// object's anchor or a ruler guide within its distance (2 px); with Smart Guides on, the
|
||||
/// ruler guides still pull it within the Snapping Tolerance (4 px), as a move does (#1534).
|
||||
#[test]
|
||||
fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
let other = d.alloc_id();
|
||||
let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0));
|
||||
d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap();
|
||||
d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)];
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let drag = |c: &ToolContext, from: (f64, f64), to: (f64, f64)| {
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(c, &ev(PointerKind::Down, from.0, from.1));
|
||||
scaled(&t.pointer(c, &ev(PointerKind::Drag, to.0, to.1)))
|
||||
};
|
||||
let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) };
|
||||
assert!(off.snap_to_point);
|
||||
// The corner, 1.4 px from the other square's corner, lands on it.
|
||||
let r = drag(&off, (200.0, 200.0), (249.0, 251.0));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 250.0, 250.0)), "{r:?}");
|
||||
// The right side, 1 px from the ruler guide, lands on it.
|
||||
let r = drag(&off, (200.0, 150.0), (231.5, 150.0));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}");
|
||||
// Snap to Point off: the side follows the pointer.
|
||||
let r = drag(&ToolContext { snap_to_point: false, ..off }, (200.0, 150.0), (231.5, 150.0));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 231.5, 200.0)), "{r:?}");
|
||||
// Smart Guides on: the ruler guide, 2.5 px away, pulls the side too.
|
||||
let r = drag(&cx(&d, &s, &p), (200.0, 150.0), (233.0, 150.0));
|
||||
assert!(near(r, Rect::new(100.0, 100.0, 230.5, 200.0)), "{r:?}");
|
||||
}
|
||||
|
||||
/// Smart Guides › Measurement Labels, Transform Tools and Snapping Tolerance (#394): the
|
||||
/// readouts while moving and while scaling each follow their option; the tolerance sets how
|
||||
/// far a dragged handle is pulled.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Shows the bounding box with handles. In the default mode handles scale (Shift proportional,
|
||||
//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle
|
||||
//! shears along that side. The Perspective Distort and Free Distort modes (tool option `mode`, or
|
||||
//! shears along that side. A scale snaps as the Selection tool's does. The Perspective Distort and Free Distort modes (tool option `mode`, or
|
||||
//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the
|
||||
//! box and preview `object.distort`.
|
||||
|
||||
@@ -11,6 +11,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::{BLUE, polygon, rect_corners};
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::guides::{PointSnap, Targets};
|
||||
use crate::select::{matrix_json, selection_bounds};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
@@ -82,8 +83,10 @@ pub struct FreeTransformTool {
|
||||
/// The current (distorted) quad while dragging corners.
|
||||
quad: Option<[Point; 4]>,
|
||||
measure: Option<(Point, String)>,
|
||||
/// Smart Guides for a scale: the other objects' edges, and the lines of the current snap.
|
||||
targets: Option<crate::guides::Targets>,
|
||||
/// What a scale snaps to ([`PointSnap::snap_scale`]): pixels, the grid, the other objects with
|
||||
/// Smart Guides, or their anchors with Snap to Point.
|
||||
targets: Option<PointSnap>,
|
||||
/// The lines of the current snap.
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
@@ -198,7 +201,7 @@ impl FreeTransformTool {
|
||||
Op::Scale(h) => {
|
||||
let mut a = scale_for_drag(d.rect, h, p, shift, m.alt);
|
||||
if let Some(t) = &self.targets {
|
||||
(a, self.guides) = t.snap_scale(&vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt, cx.snap_tol());
|
||||
(a, self.guides) = t.snap_scale(cx, &vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt);
|
||||
}
|
||||
let nr = a.transform_rect_bbox(d.rect);
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
|
||||
@@ -232,8 +235,7 @@ impl Tool for FreeTransformTool {
|
||||
self.drag = None;
|
||||
let Some(r) = selection_bounds(cx) else { return vec![] };
|
||||
if let Some(op) = self.classify(cx, r, p, ev.mods) {
|
||||
self.targets = (cx.smart_guides && op.handle().is_some())
|
||||
.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx));
|
||||
self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None)));
|
||||
self.drag = Some(Drag { op, rect: r, start: p, began: false });
|
||||
}
|
||||
vec![]
|
||||
@@ -384,6 +386,27 @@ mod tests {
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v["matrix"][4] == 10.0 && v["matrix"][5] == 20.0));
|
||||
}
|
||||
|
||||
/// #2054: with Snap to Grid on, a handle drag lands the side it moves on the nearest gridline
|
||||
/// (every 9 pt by default) while the opposite side stays put.
|
||||
#[test]
|
||||
fn handle_drag_snaps_to_the_grid() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
||||
let matrix = |a: &[Action]| {
|
||||
let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") };
|
||||
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]])
|
||||
};
|
||||
let mut t = FreeTransformTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 150.0));
|
||||
let r = matrix(&t.pointer(&cx, &ev(PointerKind::Drag, 213.0, 150.0))).transform_rect_bbox(Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
assert!((r.x1 - 216.0).abs() < 1e-9 && (r.x0 - 100.0).abs() < 1e-9, "{r:?}");
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 213.0, 150.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
/// In a view turned 90° the handles' cursors turn with the view.
|
||||
#[test]
|
||||
fn handle_cursors_turn_with_the_view() {
|
||||
|
||||
@@ -559,6 +559,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Object › Arrange › Send to Current Layer returns the error "the target layer is locked or hidden" and leaves the selected objects where they are when the current layer is hidden or locked, or is inside a hidden or locked layer.
|
||||
Selected objects are deselected, the key object included, when a row drag moves them into a hidden layer or group, when `node.move` moves them into a hidden or locked layer or group, when Merge Selected moves them into a hidden or locked layer, when Collect in New Layer moves them into a new layer inside a hidden or locked layer, when Flatten Artwork moves them into a locked layer, and when applying a data set hides them.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges or its centre is nearest a gridline on each axis, #740; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't).
|
||||
A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center.
|
||||
- **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls.
|
||||
- **Rotated views:** with the view rotated, Fit Artboard in Window, Fit All in Window and the status bar's artboard navigator fit the artboard or the art as the view turns it, and a Zoom tool marquee (Animated Zoom or GPU Performance off) zooms to the area dragged across. Ruler guides on no artboard turn with the view. The canvas draws the selection marquee where the tools select, Show Hidden Characters draws the marks of type partly inside the canvas, the Contextual Task Bar stays under the selection, and the Navigator's red box outlines what the window shows.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, `view.screenMode` from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486).
|
||||
|
||||
Reference in New Issue
Block a user