From d41af02637f91a40aa4da9b670c86ba6e6386133 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sun, 18 Oct 2026 21:45:57 +0300 Subject: [PATCH] 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 --- crates/tools/src/guides.rs | 85 +++++++++++++++++++++++++++++++++- crates/tools/src/select.rs | 2 +- crates/tools/src/xform/free.rs | 2 +- docs/ui-parity.md | 2 +- 4 files changed, 86 insertions(+), 5 deletions(-) diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 235750667..b3ad22a16 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -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 { + 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] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 5678f00c4..a4720b7f3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -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())]; } diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 1386e6243..e975be0f8 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -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![] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 52b89511b..857d91187 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |