From 568bb9315dde7d0f041ed31f14a0bd0834703e54 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:42:38 +0100 Subject: [PATCH] Line a moved selection's edges up with ruler guides Fixes #5746 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests/prefs.rs | 46 +++++++++++++++++++++++++ crates/tools/src/guides/targets.rs | 54 +++++++++++++++++++++++++++++- crates/tools/src/select.rs | 24 +++++++++---- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index ae8609cd6..50a429d9e 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -2001,6 +2001,52 @@ fn a_move_held_by_a_paths_stroke_lands_the_path_on_a_ruler_guide() { } } +/// #5746: a rectangle dragged until its left edge and its top edge are each 1 px from a ruler +/// guide lands on both guides, with "align", whichever guide was added last and wherever it was +/// grabbed; between two upright guides, its left edge lands on the one within reach; and an edge +/// Smart Guides line up with another object stays there, not on a ruler guide as near. +#[test] +fn a_moved_rectangle_lines_its_edges_up_with_two_ruler_guides() { + use vectorcraft_tools::Overlay; + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + let v = ViewInfo::default(); + // (guides as (vertical, pos), where it is grabbed, the top left it lands on) + type Case = (&'static [(bool, f64)], (f64, f64), (f64, f64)); + let cases: [Case; 3] = [ + (&[(true, 250.0), (false, 320.0)], (200.0, 100.0), (250.0, 320.0)), + (&[(false, 320.0), (true, 250.0)], (100.0, 175.0), (250.0, 320.0)), + (&[(true, 250.0), (true, 455.0)], (200.0, 175.0), (250.0, 100.0)), + ]; + for (guides, (x, y), want) in cases { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 150})).unwrap()["id"].as_u64().unwrap()); + for (vertical, pos) in guides { + s.execute("guide.add", &json!({"vertical": vertical, "pos": pos})).unwrap(); + } + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("selection", v).unwrap(); + // 1 px past the guides: the left edge at 251, the top edge at 321 (or where it was). + let (to_x, to_y) = (x + 151.0, if want.1 == 100.0 { y } else { y + 221.0 }); + gesture(&mut s, v, &[(Down, x, y), (Drag, (x + to_x) / 2.0, (y + to_y) / 2.0), (Drag, to_x, to_y)]); + let align = s.overlays(v).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")); + gesture(&mut s, v, &[(Up, to_x, to_y)]); + assert_eq!(top_left(&s, id), want, "guides {guides:?}, grabbed at ({x}, {y})"); + assert!(align, "guides {guides:?}, grabbed at ({x}, {y}): no align"); + } + // Dragged by its centre until its left edge is at 401: 1 px from another rectangle's left + // edge (400) and 1 px from a ruler guide (402). + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 150})).unwrap()["id"].as_u64().unwrap()); + s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 60, "height": 60})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 402.0})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("selection", v).unwrap(); + gesture(&mut s, v, &[(Down, 200.0, 175.0), (Drag, 350.0, 175.0), (Drag, 501.0, 175.0), (Up, 501.0, 175.0)]); + assert_eq!(top_left(&s, id), (400.0, 100.0), "a Smart Guides alignment gave way to a ruler guide"); +} + /// #3701: at 28,757%, Direct Selection pressed 1.5 px beside a rectangle's straight left side and /// dragged until the side is 1 px from a ruler guide lands both its anchors on the guide; so does /// the same press and drag on the rectangle selected whole, which moves it. diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 36947c931..6fed43337 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -964,7 +964,14 @@ impl Targets { /// ([`Self::for_move`]) is spaced evenly along an axis that lines up with nothing /// ([`Self::even_spacing`]). Returns the shift and the guides. pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec) { + let (d, ov, _) = self.snap_rects_axes(rects, tol); + (d, ov) + } + + /// [`Self::snap_rects`], also saying which axes (x, y) lined up or were spaced evenly. + pub(crate) fn snap_rects_axes(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec, [bool; 2]) { let (mut d, mut ov, aligned) = self.align_rects(rects, tol); + let mut taken = aligned; if let [r] = rects && !self.boxes.is_empty() { @@ -973,6 +980,7 @@ impl Targets { for (x, lined_up) in [true, false].into_iter().zip(aligned) { if let Some((shift, _)) = self.even_spacing(moved, x, tol).filter(|_| !lined_up) { *(if x { &mut d.x } else { &mut d.y }) += shift; + taken[usize::from(!x)] = true; spaced = true; } } @@ -985,7 +993,7 @@ impl Targets { } } } - (d, ov) + (d, ov, taken) } /// Spacing Guides along the x axis (`x`) or the y axis for the moved rectangle `r`, among the @@ -1088,6 +1096,50 @@ impl Targets { (d, ov, [best_x.is_some(), best_y.is_some()]) } + /// Snap a selection grabbed at `p` and moved as a whole, its bounds now `r`, to the ruler + /// guides: the grabbed point goes into line with a guide running within `tol` of it + /// ([`Self::snap_point`]), and on an axis where none does and that is not `taken` (x, y) + /// already, the edge or centre of `r` nearest a guide running past it within `tol` goes onto it + /// (#5746). Returns the shift, the guides (none when nothing lines up) and whether a guide held + /// the grabbed point. + pub(crate) fn snap_moved_to_rulers(&self, p: Point, r: Option, tol: f64, taken: [bool; 2]) -> (Vec2, Vec, bool) { + let near = |vertical: bool| { + let v = if vertical { p.x } else { p.y }; + self.rulers.iter().any(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && g.line_at(p, tol).is_some()) + }; + let held = [near(true), near(false)]; + let (q, mut ov) = if held.contains(&true) { self.snap_point(p, tol) } else { (p, vec![]) }; + let mut d = q - p; + let Some(r) = r.map(|r| r + d) else { return (d, ov, held.contains(&true)) }; + // The guide upright (`vertical`) or level nearest one of `vs`, running past `r` across. + let nearest = |vertical: bool, vs: [f64; 3]| { + let (c, half) = + if vertical { (Point::new(0.0, r.center().y), r.height() / 2.0) } else { (Point::new(r.center().x, 0.0), r.width() / 2.0) }; + self.rulers + .iter() + .filter(|g| g.vertical == vertical) + .filter_map(|g| g.line_at(c, tol + half)) + .flat_map(|(t, from, _)| vs.into_iter().map(move |v| (t - v, t, from))) + .filter(|(s, ..)| s.abs() <= tol) + .min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())) + }; + let hx = if held[0] || taken[0] { None } else { nearest(true, [r.x0, r.center().x, r.x1]) }; + let hy = if held[1] || taken[1] { None } else { nearest(false, [r.y0, r.center().y, r.y1]) }; + let shift = Vec2::new(hx.map_or(0.0, |h| h.0), hy.map_or(0.0, |h| h.0)); + let (r, lined) = (r + shift, hx.is_some() || hy.is_some()); + d += shift; + if let Some((_, x, from)) = hx { + ov.extend(self.line(Point::new(x, from.y.min(r.y0)), Point::new(x, from.y.max(r.y1)))); + } + if let Some((_, y, from)) = hy { + ov.extend(self.line(Point::new(from.x.min(r.x0), y), Point::new(from.x.max(r.x1), y))); + } + if lined && !held.contains(&true) { + ov.extend(self.label(p + d, "align")); + } + (d, ov, held.contains(&true)) + } + /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for /// 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 diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 572c667e4..e5474bbde 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -206,7 +206,7 @@ pub(crate) struct MoveSnap { copy_targets: Option, copy_points: Option, /// With Smart Guides on, the ruler guides View › Snap to Point still pulls the grabbed point - /// onto (#1534); `points` holds them with Smart Guides off. + /// onto (#1534), and lines the bounds up with (#5746); `points` holds them with Smart Guides off. rulers: Option, } @@ -245,6 +245,8 @@ impl MoveSnap { d = q - start; guides = ov; } + // The axes (x, y) Smart Guides already put in line with other art. + let mut taken = [false; 2]; if let (Some(t), Some(b)) = (targets, self.bounds) { // The point the selection was grabbed by, near another object's anchor or centre, // lands on it, rather than the bounds lining up a little beside it (#955). @@ -252,18 +254,26 @@ impl MoveSnap { let (q, ov) = t.snap_point(start + d, cx.snap_tol()); d = q - start; guides = ov; + taken = [true; 2]; } else { - let (adj, ov) = t.snap_rect(b + d, cx.snap_tol()); + let (adj, ov, axes) = t.snap_rects_axes(&[b + d], cx.snap_tol()); d += adj; guides = ov; + taken = axes; } } - // A ruler guide within reach of the grabbed point wins over lining the bounds up. + // A ruler guide within reach of the grabbed point wins over lining the bounds up; on an + // axis without one that Smart Guides left free, the bounds line up with the ruler guides + // (#5746), alongside the Smart Guides. if let Some(t) = &self.rulers { - let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance)); - if q != start + d { - d = q - start; - guides = ov; + let (adj, ov, held) = t.snap_moved_to_rulers(start + d, self.bounds.map(|b| b + d), cx.tol(cx.snap_tolerance), taken); + if adj != Vec2::ZERO { + d += adj; + if held { + guides = ov; + } else { + guides.extend(ov); + } } } if cx.snap_to_pixel