From 39c58338184996c5d78378305eaf51140d771c57 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 6 Nov 2026 04:10:50 +0100 Subject: [PATCH] Keep object snapping when Snap to Pixel Grid is on Fixes #2029 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/canvas/tools/drag.rs | 26 +++++++++--------- crates/engine/src/canvas/tools/draw.rs | 9 ++++--- .../engine/src/transform_behaviour_tests.rs | 27 +++++++++++++++++++ 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 194a90fe2..64d48bc86 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -218,15 +218,16 @@ impl Session { // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; let mut guides = Vec::new(); + if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { + // Keep the box's origin on whole pixels (a rotated parent frame's, inside one); + // snapping to layers comes after, so it wins on the axes it snaps. + d = targets.round_move(d); + } if snap_on { let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); d += c; guides = g2; } - if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { - // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). - d = targets.round_move(d); - } let ids = ids.clone(); let dup = *duplicate; let page = self.page()?; @@ -475,20 +476,21 @@ impl Session { let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; let mut p = ev.p(); let mut guides = Vec::new(); + let _ = start; + // In the handle box's own axes (one layer: its local space). + let (frame, w0, h0) = r.frame; + // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool + // scales by the factor as dragged). Snapping to layers comes after, so it wins on the axes + // it snaps. + if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { + p = Point::new(p.x.round(), p.y.round()); + } if let Some(t) = &targets { let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); p.x += dx; p.y += dy; guides = g; } - let _ = start; - // In the handle box's own axes (one layer: its local space). - let (frame, w0, h0) = r.frame; - // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool - // scales by the factor as dragged). - if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { - p = Point::new(p.x.round(), p.y.round()); - } let q = frame.inverse() * p; let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); if scale_tool { diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index d59a2b904..e3cbe7787 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -29,20 +29,23 @@ impl Session { let tool = self.tool.tool; let mut p = ev.p(); let mut guides = Vec::new(); + let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + // The pixel grid first: snapping to layers comes after, so it wins on the axes it snaps. + if pixel { + p = Point::new(p.x.round(), p.y.round()); + } if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); p.x += dx; p.y += dy; guides = g; } - let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); let mut created = id; let page = self.page()?; self.preview(|doc, sel| { - let (mut a, mut b) = (start, p); + let (mut a, b) = (start, p); if pixel { a = Point::new(a.x.round(), a.y.round()); - b = Point::new(b.x.round(), b.y.round()); } let pw = doc.world_transform(parent); let (kind, base) = match tool { diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 3e724244e..e22eb0418 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -625,6 +625,33 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() { assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0)); } +#[test] +fn snapping_to_layers_wins_over_the_pixel_grid() { + // Both on (the defaults): a layer's centre at x = 5.5 is still a target. + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 11.0, 11.0, None); + let b = rect(&mut s, 200.0, 300.0, 100.0, 100.0, None); + let _ = a; + ex(&mut s, "select.set", json!({"ids": [b]})); + // Moving: B's centre (grabbed) snaps to A's; the free axis stays on whole pixels. + s.pointer(&ev(PointerKind::Down, Point::new(250.0, 350.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(6.0, 352.4), NONE)).unwrap(); + assert!(s.tool.overlay.guides.iter().any(|g| near(g.x0, 5.5) && near(g.x1, 5.5)), "{:?}", s.tool.overlay.guides); + s.pointer(&ev(PointerKind::Up, Point::new(6.0, 352.4), NONE)).unwrap(); + assert_eq!(bounds(&s, b).origin(), Point::new(-44.5, 302.0), "moved onto A's centre"); + ex(&mut s, "edit.undo", json!({})); + // Out of reach of any layer, a move lands on whole pixels. + drag(&mut s, Point::new(250.0, 350.0), Point::new(260.3, 360.6), NONE); + assert_eq!(bounds(&s, b).origin(), Point::new(210.0, 311.0)); + ex(&mut s, "edit.undo", json!({})); + // Resizing: the left handle snaps to A's centre; the bottom one lands on whole pixels. + drag(&mut s, Point::new(200.0, 350.0), Point::new(6.0, 350.0), NONE); + assert!(near(bounds(&s, b).x0, 5.5), "{:?}", bounds(&s, b)); + ex(&mut s, "edit.undo", json!({})); + drag(&mut s, Point::new(250.0, 400.0), Point::new(250.0, 450.6), NONE); + assert_eq!(size(&s, b), (100.0, 151.0)); +} + #[test] fn corner_cursors_stay_diagonal_whatever_the_proportions() { let mut s = session();