From 3bc4365ee232b0981f64b914678863a2709663c2 Mon Sep 17 00:00:00 2001 From: Fabian Grajales Date: Wed, 7 Oct 2026 14:32:11 -0500 Subject: [PATCH] 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 b8b968cd7..f023a8558 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 1a3383d73..c6e287f88 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 faaf0c2ac..0a01cfda4 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())));