From 7de04544e40ac597cadecf0d352ba6d5c181da54 Mon Sep 17 00:00:00 2001 From: Fabian Grajales Date: Wed, 7 Oct 2026 13:05:26 -0500 Subject: [PATCH 1/2] Smart Guides: bounding-box resizes snap to the other objects Dragging a bounding-box handle with the Selection or Free Transform tool now lands the edges it moves on the edges and centres of the other objects and artboards, with the guide line, as a move already did. A proportional scale snaps the closer axis only; a turned bounding box doesn't snap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013j16ENkbsNYyHSCDhxYkYR --- ROADMAP.md | 2 +- crates/tools/src/guides.rs | 88 +++++++++++++++++++++++++++++++++- crates/tools/src/select.rs | 43 ++++++++++++++++- crates/tools/src/xform/free.rs | 18 ++++++- 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b9517f82..6e93b452 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,7 +116,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped. - **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options. - **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting. -- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes. +- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects), guides dragged out of the rulers, and Draw Normal / Behind / Inside modes. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard. - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 716b3b22..b8b968cd 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -3,8 +3,9 @@ use vectorcraft_doc::hit::{HitOptions, hit_test}; use vectorcraft_doc::{Document, NodeId, NodeKind}; -use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +use crate::bbox::Handle; use crate::{Overlay, ToolContext}; pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc]; @@ -154,6 +155,57 @@ impl Targets { } (d, ov) } + + /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for + /// dragging `handle` of `r` (about the centre when `from_center`): the edges that handle moves + /// land on the nearest target within `tol`. A proportional scale stays proportional, so only + /// the closer of its two axes snaps. + pub fn snap_scale(&self, r: Rect, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec) { + let origin = if from_center { r.center() } else { handle.opposite().pos(r) }; + let (start, [sx, _, _, sy, _, _]) = (handle.pos(r), a.as_coeffs()); + let (ax, ay) = handle.axes(); + // The nearest target to the edge a scale `s` takes from `v0` (about `o`), as (the scale + // landing on it, distance, target, the point it comes from). A target on the fixed side + // would collapse or flip the box: it is no candidate. + let near = |on: bool, s: f64, v0: f64, o: f64, ts: &[(f64, Point, Kind)]| { + if !on || (v0 - o).abs() <= 1e-9 { + return None; + } + let v = o + (v0 - o) * s; + ts.iter() + .map(|(t, from, _)| ((t - o) / (v0 - o), (t - v).abs(), *t, *from)) + .filter(|(k, d, _, _)| *d <= tol && k.is_finite() && k * s > 0.0) + .min_by(|p, q| p.1.total_cmp(&q.1)) + }; + let mut hx = near(ax, sx, start.x, origin.x, &self.xs); + let mut hy = near(ay, sy, start.y, origin.y, &self.ys); + if proportional && let (Some(x), Some(y)) = (hx, hy) { + if x.1 <= y.1 { + hy = None; + } else { + hx = None; + } + } + let (mut nx, mut ny) = (hx.map_or(sx, |h| h.0), hy.map_or(sy, |h| h.0)); + if proportional { + // The two scales are equal but for their signs. + if hx.is_some() { + ny = nx.abs() * sy.signum(); + } else if hy.is_some() { + nx = ny.abs() * sx.signum(); + } + } + let out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2()); + let nr = out.transform_rect_bbox(r); + let mut ov = vec![]; + if let Some((_, _, x, from)) = hx { + ov.push(Overlay::Line { a: Point::new(x, from.y.min(nr.y0)), b: Point::new(x, from.y.max(nr.y1)), color: MAGENTA, dashed: false }); + } + if let Some((_, _, y, from)) = hy { + ov.push(Overlay::Line { a: Point::new(from.x.min(nr.x0), y), b: Point::new(from.x.max(nr.x1), y), color: MAGENTA, dashed: false }); + } + (out, ov) + } } /// Snap `p` for a drawing tool when smart guides (or grid snapping) are on. @@ -209,4 +261,38 @@ mod tests { assert_eq!(dv.x, -2.0); assert!(!ov.is_empty()); } + + #[test] + fn scale_snap_lands_the_moved_edges_on_targets() { + use crate::bbox::scale_for_drag; + let (d, _) = doc_with_rect(); + // The document's rect is 100..200: a box beside it is resized against it. + let t = Targets::collect(&d, &[], None); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + let snap = |h: Handle, p: Point, shift: bool, alt: bool| { + let (a, ov) = t.snap_scale(r, h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0); + (a.transform_rect_bbox(r), ov) + }; + // The bottom edge lands on the neighbour's bottom: same height. + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 197.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 200.0)); + assert_eq!(ov.len(), 1); + // A corner snaps each axis on its own: y to the neighbour, x stays free. + let (nr, ov) = snap(Handle::BottomRight, Point::new(330.0, 202.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 330.0, 200.0)); + assert_eq!(ov.len(), 1); + // Proportional: the snapped axis carries the other one. + let (nr, _) = snap(Handle::BottomRight, Point::new(340.0, 198.0), true, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 350.0, 200.0)); + // From the centre the dragged edge still lands on the target. + let (nr, _) = snap(Handle::Bottom, Point::new(275.0, 198.0), false, true); + assert_eq!(nr, Rect::new(250.0, 50.0, 300.0, 200.0)); + // Out of reach, and the fixed edge's own target: untouched. + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 180.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 180.0)); + assert!(ov.is_empty()); + let (nr, ov) = snap(Handle::Bottom, Point::new(275.0, 102.0), false, false); + assert_eq!(nr, Rect::new(250.0, 100.0, 300.0, 102.0)); + assert!(ov.is_empty()); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index cea3fef2..1a3383d7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -136,6 +136,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 the dragged edges with the other objects. They + // are page lines: a turned box's edges don't run along them. + self.targets = + (cx.smart_guides && bx.angle == 0.0).then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None)); self.state = State::Scaling { handle, bx, areas }; return vec![Action::Begin(label.into())]; } @@ -205,7 +209,11 @@ impl Tool for SelectionTool { } (PointerKind::Drag, State::Scaling { handle, bx, areas }) => { // Scale in the box's own frame: along the objects' axes when it is rotated. - let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); + 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.rect, handle, a, m.shift, m.alt, cx.tol(5.0)); + } let nr = a.transform_rect_bbox(bx.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }); @@ -246,6 +254,8 @@ impl Tool for SelectionTool { (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { self.state = State::Idle; self.measure = None; + self.guides.clear(); + self.targets = None; vec![Action::Commit] } (PointerKind::Up, State::Marquee { start, add, .. }) => { @@ -437,6 +447,37 @@ mod tests { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } + #[test] + fn handle_drag_snaps_to_the_other_objects_with_smart_guides() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut c = cx(&d, &s, &p); + let height = |a: &[Action]| { + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + v["matrix"][3].as_f64().unwrap() * 50.0 + }; + // The bottom handle, dragged near the neighbour's bottom edge (y = 200), lands on it. + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0)); + assert!((height(&a) - 100.0).abs() < 1e-9, "{a:?}"); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. }))); + t.pointer(&c, &ev(PointerKind::Up, 275.0, 197.0)); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. }))); + // Smart Guides off: the edge follows the pointer. + c.smart_guides = false; + t.pointer(&c, &ev(PointerKind::Down, 275.0, 150.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 275.0, 197.0)); + assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}"); + } + #[test] fn handle_drag_on_area_type_resizes_its_frame() { let (d, text) = doc_with_area_type(); diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 1fa70074..faaf0c2a 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -82,6 +82,9 @@ 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, + guides: Vec, } /// Shear for dragging side `h` of `r` by `d` (about the opposite side, or the centre with Alt). @@ -178,6 +181,7 @@ impl FreeTransformTool { fn preview(&mut self, cx: &ToolContext, d: Drag, p: Point, m: Mods) -> Action { let shift = m.shift || self.constrain; let delta = p - d.start; + self.guides.clear(); let xf = match d.op { Op::Corners(h, mode) => { let q = distort_quad(d.rect, h, delta, mode); @@ -192,7 +196,10 @@ impl FreeTransformTool { Affine::translate(v) } Op::Scale(h) => { - let a = scale_for_drag(d.rect, h, p, shift, m.alt); + 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(d.rect, h, a, shift, m.alt, cx.tol(5.0)); + } let nr = a.transform_rect_bbox(d.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); a @@ -225,6 +232,8 @@ 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)); self.drag = Some(Drag { op, rect: r, start: p, began: false }); } vec![] @@ -259,6 +268,8 @@ impl Tool for FreeTransformTool { let d = self.drag.take(); self.quad = None; self.measure = None; + self.guides.clear(); + self.targets = None; if d.is_some_and(|d| d.began) { vec![Action::Commit] } else { vec![] } } _ => vec![], @@ -270,6 +281,8 @@ impl Tool for FreeTransformTool { self.drag = None; self.quad = None; self.measure = None; + self.guides.clear(); + self.targets = None; return vec![Action::Cancel]; } vec![] @@ -290,6 +303,7 @@ impl Tool for FreeTransformTool { o.push(Overlay::Anchor { p: a.midpoint(b), color: BLUE, filled: false, size: 6.0 }); } } + o.extend(self.guides.iter().cloned()); if let Some((p, t)) = &self.measure { o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() }); } @@ -332,6 +346,8 @@ impl Tool for FreeTransformTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { self.quad = None; + self.guides.clear(); + self.targets = None; if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] } } } From 3bc4365ee232b0981f64b914678863a2709663c2 Mon Sep 17 00:00:00 2001 From: Fabian Grajales Date: Wed, 7 Oct 2026 14:32:11 -0500 Subject: [PATCH 2/2] Smart Guides: a turned bounding box snaps while it is resized The resize snap skipped a bounding box standing at an angle. It now works on the page whatever the angle: a corner handle takes the target's x, y or both, and a side handle (or a proportional corner) slides along its own direction until the handle or an end of its side is on a target. A target the side runs almost along is skipped, so the handle stays near the pointer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013j16ENkbsNYyHSCDhxYkYR --- crates/tools/src/guides.rs | 118 ++++++++++++++++++++++----------- crates/tools/src/select.rs | 47 +++++++++++-- crates/tools/src/xform/free.rs | 2 +- 3 files changed, 123 insertions(+), 44 deletions(-) diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index b8b968cd..f023a855 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -2,7 +2,7 @@ //! construction lines and labels Illustrator users expect. use vectorcraft_doc::hit::{HitOptions, hit_test}; -use vectorcraft_doc::{Document, NodeId, NodeKind}; +use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::Handle; @@ -157,51 +157,93 @@ impl Targets { } /// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for - /// dragging `handle` of `r` (about the centre when `from_center`): the edges that handle moves - /// land on the nearest target within `tol`. A proportional scale stays proportional, so only - /// the closer of its two axes snaps. - pub fn snap_scale(&self, r: Rect, handle: Handle, a: Affine, proportional: bool, from_center: bool, tol: f64) -> (Affine, Vec) { + /// dragging `handle` of the box `bx` (about the centre when `from_center`), in the box's own + /// frame, and so is the result. The corners and the side that handle moves land on the nearest + /// target within `tol` on the page, 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) { + type Hit = Option<(f64, Point)>; + let r = bx.rect; let origin = if from_center { r.center() } else { handle.opposite().pos(r) }; - let (start, [sx, _, _, sy, _, _]) = (handle.pos(r), a.as_coeffs()); - let (ax, ay) = handle.axes(); - // The nearest target to the edge a scale `s` takes from `v0` (about `o`), as (the scale - // landing on it, distance, target, the point it comes from). A target on the fixed side - // would collapse or flip the box: it is no candidate. - let near = |on: bool, s: f64, v0: f64, o: f64, ts: &[(f64, Point, Kind)]| { - if !on || (v0 - o).abs() <= 1e-9 { - return None; - } - let v = o + (v0 - o) * s; - ts.iter() - .map(|(t, from, _)| ((t - o) / (v0 - o), (t - v).abs(), *t, *from)) - .filter(|(k, d, _, _)| *d <= tol && k.is_finite() && k * s > 0.0) - .min_by(|p, q| p.1.total_cmp(&q.1)) + let (d0, [sx, _, _, sy, _, _]) = (handle.pos(r) - origin, a.as_coeffs()); + 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 { + ts.iter().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())) }; - let mut hx = near(ax, sx, start.x, origin.x, &self.xs); - let mut hy = near(ay, sy, start.y, origin.y, &self.ys); - if proportional && let (Some(x), Some(y)) = (hx, hy) { - if x.1 <= y.1 { - hy = None; - } else { - hx = None; + let (mut nx, mut ny) = (sx, sy); + let (mut hit_x, mut hit_y): (Hit, Hit) = (None, None); + if handle.is_corner() && !proportional { + // The corner goes anywhere: 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)); + for (tx, ty) in [(hx, hy), (hx, None), (None, hy)] { + if tx.is_none() && ty.is_none() { + continue; + } + let q = to_local * Point::new(tx.map_or(p.x, |h| h.0), ty.map_or(p.y, |h| h.0)); + let (mut cx, mut cy) = ((q.x - origin.x) / d0.x, (q.y - origin.y) / d0.y); + // Square to the page, the axis without a target stays exactly where it was. + if bx.angle == 0.0 { + (cx, cy) = (if tx.is_some() { cx } else { sx }, if ty.is_some() { cy } else { sy }); + } + if valid(cx, sx) && valid(cy, sy) { + (nx, ny, hit_x, hit_y) = (cx, cy, tx, ty); + break; + } } - } - let (mut nx, mut ny) = (hx.map_or(sx, |h| h.0), hy.map_or(sy, |h| h.0)); - if proportional { - // The two scales are equal but for their signs. - if hx.is_some() { - ny = nx.abs() * sy.signum(); - } else if hy.is_some() { - nx = ny.abs() * sx.signum(); + } else { + // One parameter `s`: the scale is `c + s·e` on each axis. + let (ax, _) = handle.axes(); + let (e, c, s0) = match (handle.is_corner(), ax, proportional) { + (true, _, _) => ((sx.signum(), sy.signum()), (0.0, 0.0), sx.abs()), + (false, true, true) => ((1.0, sx.signum()), (0.0, 0.0), sx), + (false, true, false) => ((1.0, 0.0), (0.0, 1.0), sx), + (false, false, true) => ((sy.signum(), 1.0), (0.0, 0.0), sy), + (false, false, false) => ((0.0, 1.0), (1.0, 0.0), sy), + }; + // The points that may land on a target: the handle, and the two ends of its side. + let i = handle as usize; + let ends = if handle.is_corner() { [None, None] } else { [Handle::ALL.get((i + 1) % 8), Handle::ALL.get((i + 7) % 8)] }; + // How far the handle travels per unit of `s`: a snap may not drag it far from the + // pointer, as it would for a target the side runs almost along. + let travel = (e.0 * d0.x).hypot(e.1 * d0.y); + let mut best: Option<(f64, f64, bool, (f64, Point))> = None; + for h in std::iter::once(handle).chain(ends.into_iter().flatten().copied()) { + let v = h.pos(r) - origin; + let base = to_doc * (origin + Vec2::new(c.0 * v.x, c.1 * v.y)); + let dir = (to_doc * Point::new(e.0 * v.x, e.1 * v.y)).to_vec2(); + let p = base + dir * s0; + 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)] { + if d.abs() <= 1e-9 { + continue; + } + let Some(hit) = nearest(at, ts) else { continue }; + let s = (hit.0 - b) / d; + let moved = (s - s0).abs() * travel; + if valid(s, s0) && moved <= 2.0 * tol && best.is_none_or(|k| moved < k.1) { + best = Some((s, moved, is_x, hit)); + } + } + } + if let Some((s, _, is_x, hit)) = best { + (nx, ny) = (c.0 + s * e.0, c.1 + s * e.1); + if is_x { + hit_x = Some(hit); + } else { + hit_y = Some(hit); + } } } let out = Affine::translate(origin.to_vec2()) * Affine::scale_non_uniform(nx, ny) * Affine::translate(-origin.to_vec2()); - let nr = out.transform_rect_bbox(r); + // 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 ov = vec![]; - if let Some((_, _, x, from)) = hx { + if let Some((x, from)) = hit_x { ov.push(Overlay::Line { a: Point::new(x, from.y.min(nr.y0)), b: Point::new(x, from.y.max(nr.y1)), color: MAGENTA, dashed: false }); } - if let Some((_, _, y, from)) = hy { + if let Some((y, from)) = hit_y { ov.push(Overlay::Line { a: Point::new(from.x.min(nr.x0), y), b: Point::new(from.x.max(nr.x1), y), color: MAGENTA, dashed: false }); } (out, ov) @@ -270,7 +312,7 @@ mod tests { let t = Targets::collect(&d, &[], None); let r = Rect::new(250.0, 100.0, 300.0, 150.0); let snap = |h: Handle, p: Point, shift: bool, alt: bool| { - let (a, ov) = t.snap_scale(r, h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0); + let (a, ov) = t.snap_scale(&OrientedBox::aligned(r), h, scale_for_drag(r, h, p, shift, alt), shift, alt, 4.0); (a.transform_rect_bbox(r), ov) }; // The bottom edge lands on the neighbour's bottom: same height. diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1a3383d7..c6e287f8 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -136,10 +136,8 @@ 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 the dragged edges with the other objects. They - // are page lines: a turned box's edges don't run along them. - self.targets = - (cx.smart_guides && bx.angle == 0.0).then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None)); + // Smart Guides align what the handle moves with the other objects. + self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &cx.selection.objects, None)); self.state = State::Scaling { handle, bx, areas }; return vec![Action::Begin(label.into())]; } @@ -212,7 +210,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.rect, handle, a, m.shift, m.alt, cx.tol(5.0)); + (a, self.guides) = t.snap_scale(&bx, handle, a, m.shift, m.alt, cx.tol(5.0)); } let nr = a.transform_rect_bbox(bx.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); @@ -478,6 +476,45 @@ mod tests { assert!((height(&a) - 97.0).abs() < 1e-9, "{a:?}"); } + #[test] + fn handle_drag_of_a_turned_box_snaps_on_the_page() { + use vectorcraft_doc::{Appearance, Node}; + let matrix = |a: &[Action]| { + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + let m: Vec = 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]]) + }; + // A 50 pt square beside the 100..200 rect, turned about its centre (275, 125). + for (turn, low) in [(90.0_f64, 150.0), (45.0, 125.0 + 25.0 * std::f64::consts::SQRT_2)] { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let r = Rect::new(250.0, 100.0, 300.0, 150.0); + d.insert(Some(l), 1, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art())).unwrap(); + let c = Point::new(275.0, 125.0); + d.node_mut(id) + .unwrap() + .transform(Affine::translate(c.to_vec2()) * Affine::rotate(turn.to_radians()) * Affine::translate(-c.to_vec2()), false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let bx = selection_box(&cx).unwrap(); + assert_ne!(bx.angle, 0.0); + // The handle at the lowest point of the shape: a side at 90°, a corner at 45°. + let grab = Point::new(275.0, low); + assert!(Handle::ALL.iter().any(|h| (bx.to_doc() * h.pos(bx.rect)).distance(grab) < 1e-6), "{bx:?}"); + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, grab.x, grab.y)), vec![Action::Begin("Scale".into())]); + // Dragged near the neighbour's bottom edge (y = 200), it lands on it. + let a = t.pointer(&cx, &ev(PointerKind::Drag, 275.0, 197.0)); + let landed = matrix(&a) * grab; + assert!((landed.y - 200.0).abs() < 1e-6 && (landed.x - 275.0).abs() < 1e-6, "{turn}: {landed:?}"); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { .. }))); + t.pointer(&cx, &ev(PointerKind::Up, 275.0, 197.0)); + } + } + #[test] fn handle_drag_on_area_type_resizes_its_frame() { let (d, text) = doc_with_area_type(); diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index faaf0c2a..0a01cfda 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -198,7 +198,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(d.rect, h, a, shift, m.alt, cx.tol(5.0)); + (a, self.guides) = t.snap_scale(&vectorcraft_doc::OrientedBox::aligned(d.rect), h, a, shift, m.alt, cx.tol(5.0)); } let nr = a.transform_rect_bbox(d.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height())));