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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j16ENkbsNYyHSCDhxYkYR
This commit is contained in:
Fabian Grajales
2026-10-07 14:32:11 -05:00
co-authored by Claude Opus 5.5
parent 7de04544e4
commit 3bc4365ee2
3 changed files with 123 additions and 44 deletions
+80 -38
View File
@@ -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<Overlay>) {
/// 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<Overlay>) {
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.
+42 -5
View File
@@ -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<f64> = 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();
+1 -1
View File
@@ -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())));