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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013j16ENkbsNYyHSCDhxYkYR
This commit is contained in:
parent
65c5953516
commit
7de04544e4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Overlay>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<crate::guides::Targets>,
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
/// 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<Action> {
|
||||
self.quad = None;
|
||||
self.guides.clear();
|
||||
self.targets = None;
|
||||
if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue