G1: Spacing Guides snap bounding-box resizes to equal gaps

Smart Guides spacing now applies when scaling with the Selection and Free Transform tools, not only on moves.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nrq6993-a11y
2026-10-20 01:48:35 +07:00
committed by bflatastic
co-authored by Cursor
parent 62910d0327
commit d41af02637
4 changed files with 86 additions and 5 deletions
+83 -2
View File
@@ -158,6 +158,19 @@ fn span(r: &Rect, x: bool) -> (f64, f64) {
if x { (r.x0, r.x1) } else { (r.y0, r.y1) }
}
/// Along axis `x`, does dragging `handle` move the box's minimum edge (true) or maximum (false)?
fn resize_moves_min_edge(handle: Handle, x: bool) -> Option<bool> {
let (ax, ay) = handle.axes();
if x && !ax || !x && !ay {
return None;
}
if x {
Some(matches!(handle, Handle::Left | Handle::TopLeft | Handle::BottomLeft))
} else {
Some(matches!(handle, Handle::Top | Handle::TopLeft | Handle::TopRight))
}
}
/// The objects a moved selection (`moving`) spaces itself evenly with, by their bounds: each
/// visible object on a layer as a whole (a group too), and inside a group holding some of the
/// moving objects its other objects, but a Shaper Group's original shapes. At most [`BUDGET`].
@@ -197,6 +210,12 @@ impl Targets {
t
}
/// Targets for scaling the selection: the other art, styled as `cx` asks, and with Smart Guides ›
/// Spacing Guides the neighbours whose gaps the moving edge should match ([`Self::snap_scale`]).
pub fn for_scale(cx: &ToolContext) -> Self {
Self::for_move(cx, &cx.selection.objects)
}
/// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its
/// anchors, centres, edges and path segments, its anchors in line too. The anchors and
/// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets.
@@ -986,13 +1005,13 @@ impl Targets {
(nx, ny, snap) = (c.0 + s * e.0, c.1 + s * e.1, found);
}
}
let out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2());
let mut out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2());
let (hit_x, hit_y, on) = snap;
if let Some((q, k)) = on {
return (out, self.label(q, k.label()).into_iter().collect());
}
// The lines run from the target's own point across the resized box, on the page.
let nr = (to_doc * out).transform_rect_bbox(r);
let mut nr = (to_doc * out).transform_rect_bbox(r);
let mut ov = vec![];
if let Some((x, from)) = hit_x {
ov.extend(self.line(Point::new(x, from.y.min(nr.y0)), Point::new(x, from.y.max(nr.y1))));
@@ -1003,6 +1022,38 @@ impl Targets {
if hit_x.is_some() || hit_y.is_some() {
ov.extend(self.label(to_doc * (out * handle.pos(r)), "align"));
}
if !self.boxes.is_empty() && !from_center {
let aligned = [hit_x.is_some(), hit_y.is_some()];
for (is_x, lined_up) in [(true, aligned[0]), (false, aligned[1])] {
if lined_up {
continue;
}
let Some(moves_min) = resize_moves_min_edge(handle, is_x) else { continue };
if let Some((shift, gaps)) = self.even_spacing(nr, is_x, tol) {
let (lo, hi) = span(&nr, is_x);
let len = hi - lo;
let new_len = if moves_min { hi - (lo + shift) } else { hi + shift - lo };
if len > 1e-9 && new_len > 1e-9 {
let f = new_len / len;
let (ax, ay) = handle.axes();
if is_x {
nx *= f;
if proportional && ay {
ny *= f;
}
} else {
ny *= f;
if proportional && ax {
nx *= f;
}
}
out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2());
nr = (to_doc * out).transform_rect_bbox(r);
ov.extend(gaps.into_iter().flat_map(|g| self.gap_marks(g, is_x, tol)));
}
}
}
}
(out, ov)
}
}
@@ -1494,6 +1545,36 @@ mod tests {
assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0));
}
/// Smart Guides › Spacing Guides (#394): resizing by a side handle snaps the moving edge to the
/// same gap as the neighbours in its row, with the gaps marked.
#[test]
fn spacing_guides_resize_snaps_the_moving_edge_to_equal_gaps() {
use crate::bbox::scale_for_drag;
use vectorcraft_doc::{Appearance, Node};
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
for r in [Rect::new(20.0, 300.0, 60.0, 340.0), Rect::new(80.0, 300.0, 120.0, 340.0)] {
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap();
}
let r = Rect::new(141.0, 305.0, 181.0, 345.0);
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap();
let s = Selection { objects: vec![id], ..Default::default() };
let t = Targets::for_scale(&cx(&d, &s, &paint()));
let (a, ov) = t.snap_scale(
&OrientedBox::aligned(r),
Handle::Left,
scale_for_drag(r, Handle::Left, Point::new(139.0, 325.0), false, false),
false,
false,
4.0,
);
assert_eq!(a.transform_rect_bbox(r).x0, 140.0, "20 pt from the second square");
let magenta = |o: &Overlay| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA);
assert!(ov.iter().any(magenta), "{ov:?}");
}
/// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and
/// Anchor/Path Labels off, change what shows and never where a point lands.
#[test]
+1 -1
View File
@@ -542,7 +542,7 @@ impl Tool for SelectionTool {
// 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.targets = Some(PointSnap::new(cx, || Targets::for_scale(cx)));
self.state = State::Scaling { handle, bx, areas };
return vec![Action::Begin(label.into())];
}
+1 -1
View File
@@ -235,7 +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 = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::collect(cx.doc, &cx.selection.objects, None)));
self.targets = op.handle().is_some().then(|| PointSnap::new(cx, || Targets::for_scale(cx)));
self.drag = Some(Drag { op, rect: r, start: p, began: false });
}
vec![]
+1 -1
View File
@@ -103,7 +103,7 @@ tools, never checked side by side) isn't captured row by row.
| Construction guides at preference angles while drawing | done | #506 |
| Line extension: the dragged end of a straight segment snaps to the segment's own direction at any angle, past its other end or short of it, with the guide along it | done | #2120; Direct Selection and Curvature, one anchor dragged (with several, the move has no single direction); nearer the horizontal or vertical through the other end, the point lines up with it ("align"), so a nearly level segment can still be straightened |
| Measurement labels while drawing, moving and resizing | partial | sizes and offsets; angle and distance readouts while drawing missing |
| Spacing guides (equal distances) | partial | for moves (#394); not for resizing or drawing |
| Spacing guides (equal distances) | partial | for moves and bounding-box resizes (#394); not while drawing |
| Snapping tolerance preference | done | |
| Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | #1840: with Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1816 waits for the reporter) |
| Snap to Glyph | missing | menu stub |