Merge pull request #376 from Haven-King/feat/artboard-snapping

Add Snapping to the Artboard Tool

Merged with the Artboard tool's Alt-drag duplicate (#348): a copy snaps too, and to the artboard
and art it leaves behind (a move skips them).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-07 17:50:15 -04:00
commit 4f159b98fb
6 files changed
+467 -66

No files matched your search

+1 -1
View File
@@ -119,7 +119,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, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
- **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, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), 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 (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.
- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), 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. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it.
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected.
+35
View File
@@ -883,6 +883,30 @@ impl Document {
pub fn artboard_at(&self, p: Point) -> Option<usize> {
self.artboards.iter().rposition(|a| a.rect.contains(p))
}
/// The art that moves with an artboard at `rect`: unlocked top-level objects (children of
/// unlocked layers and sublayers) lying entirely inside it.
pub fn art_on_artboard(&self, rect: Rect) -> Vec<NodeId> {
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.locked {
continue;
}
if c.is_layer() {
collect(c, rect, out);
} else if let Some(b) = c.geometric_bounds()
&& rect.contains(Point::new(b.x0, b.y0))
&& rect.contains(Point::new(b.x1, b.y1))
{
out.push(c.id);
}
}
}
let mut art = vec![];
for l in self.layers.iter().filter(|l| !l.locked) {
collect(l, rect, &mut art);
}
art
}
pub fn next_artboard_id(&self) -> u32 {
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
}
@@ -1003,6 +1027,17 @@ mod tests {
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0));
}
#[test]
fn art_on_artboard_takes_unlocked_objects_wholly_inside() {
let (mut d, a, b) = doc_with_rects();
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0)), vec![a]);
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0)), vec![a], "b only half inside");
d.node_mut(a).unwrap().locked = true;
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)), vec![b]);
Arc::make_mut(&mut d.layers[0]).locked = true;
assert!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)).is_empty());
}
#[test]
fn find_and_paths() {
let (d, a, b) = doc_with_rects();
+2 -25
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Node, NodeId, NodeKind};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::edit::selected_roots;
@@ -374,30 +374,7 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let move_art = bool_or(p, "moveArt", false);
let st = s.doc()?;
let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
// Top-level objects (children of layers) lying entirely inside the artboard.
let mut art = vec![];
if move_art {
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.locked {
continue;
}
if c.is_layer() {
collect(c, rect, out);
} else if let Some(b) = c.geometric_bounds()
&& rect.contains(Point::new(b.x0, b.y0))
&& rect.contains(Point::new(b.x1, b.y1))
{
out.push(c.id);
}
}
}
for l in &st.doc.layers {
if !l.locked {
collect(l, rect, &mut art);
}
}
}
let art = if move_art { st.doc.art_on_artboard(rect) } else { vec![] };
let scale_strokes = s.prefs.scale_strokes;
let copy = bool_or(p, "copy", false);
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
+66 -19
View File
@@ -16,6 +16,7 @@ enum Kind {
Center,
Edge,
Artboard,
Bleed,
}
impl Kind {
@@ -25,6 +26,7 @@ impl Kind {
Kind::Center => "center",
Kind::Edge => "path",
Kind::Artboard => "artboard",
Kind::Bleed => "bleed",
}
}
}
@@ -39,6 +41,16 @@ pub struct Targets {
impl Targets {
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
Self::collect_inner(doc, exclude, None, visible)
}
/// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the
/// art that moves along with it).
pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self {
Self::collect_inner(doc, exclude, Some(index), None)
}
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>) -> Self {
let mut t = Targets::default();
let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e)));
let add_rect = |t: &mut Targets, r: Rect, kind: Kind| {
@@ -47,12 +59,18 @@ impl Targets {
t.xs.push((p.x, p, kind));
t.ys.push((p.y, p, kind));
}
t.points.push((c, Kind::Center));
t.xs.push((c.x, c, Kind::Center));
t.ys.push((c.y, c, Kind::Center));
// A bleed shares its artboard's centre.
if kind != Kind::Bleed {
t.points.push((c, Kind::Center));
t.xs.push((c.x, c, Kind::Center));
t.ys.push((c.y, c, Kind::Center));
}
};
for ab in &doc.artboards {
for (_, ab) in doc.artboards.iter().enumerate().filter(|(i, _)| Some(*i) != skip_artboard) {
add_rect(&mut t, ab.rect, Kind::Artboard);
if doc.setup.has_bleed() {
add_rect(&mut t, doc.setup.bleed_rect(ab.rect), Kind::Bleed);
}
}
let mut budget = 20_000usize;
doc.walk(|n| {
@@ -124,30 +142,49 @@ impl Targets {
(out, ov)
}
/// Snap a dragged point that carries others along at `offsets` (a resize handle and the
/// bleed edge beyond it): onto an anchor or centre near the point itself, otherwise into line
/// with targets, each axis on whichever of them comes nearest.
pub fn snap_point_with(&self, p: Point, offsets: &[Vec2], tol: f64) -> (Point, Vec<Overlay>) {
if self.points.iter().any(|(q, _)| q.distance(p) <= tol) {
return self.snap_point(p, tol);
}
let rects: Vec<Rect> = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect();
let (adj, mut ov) = self.snap_rects(&rects, tol);
let out = p + adj;
if !ov.is_empty() {
ov.push(Overlay::Label { p: out, text: "align".into(), color: MAGENTA });
}
(out, ov)
}
/// Snap a moving rectangle (selection bounds after a move by `d`): tries its corners/edges/centre.
pub fn snap_rect(&self, r: Rect, tol: f64) -> (Vec2, Vec<Overlay>) {
let c = r.center();
let xs = [r.x0, c.x, r.x1];
let ys = [r.y0, c.y, r.y1];
let best_x = xs
.iter()
.flat_map(|x| self.xs.iter().map(move |(t, from, _)| (t - x, *from, *x)))
.filter(|(d, _, _)| d.abs() <= tol)
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
let best_y = ys
.iter()
.flat_map(|y| self.ys.iter().map(move |(t, from, _)| (t - y, *from, *y)))
.filter(|(d, _, _)| d.abs() <= tol)
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
self.snap_rects(&[r], tol)
}
/// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any
/// of them nearest a target, per axis. Returns the shift and the guides.
pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>) {
let nearest = |targets: &[(f64, Point, Kind)], along: fn(&Rect) -> [f64; 3]| {
rects
.iter()
.flat_map(|r| along(r).into_iter().map(move |v| (v, *r)))
.flat_map(|(v, r)| targets.iter().map(move |(t, from, _)| (t - v, *from, v, r)))
.filter(|(d, ..)| d.abs() <= tol)
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()))
};
let best_x = nearest(&self.xs, |r| [r.x0, r.center().x, r.x1]);
let best_y = nearest(&self.ys, |r| [r.y0, r.center().y, r.y1]);
let mut d = Vec2::ZERO;
let mut ov = vec![];
if let Some((dx, from, x)) = best_x {
if let Some((dx, from, x, r)) = best_x {
d.x = dx;
let x = x + dx;
let (y0, y1) = (from.y.min(r.y0), from.y.max(r.y1));
ov.push(Overlay::Line { a: Point::new(x, y0), b: Point::new(x, y1), color: MAGENTA, dashed: false });
}
if let Some((dy, from, y)) = best_y {
if let Some((dy, from, y, r)) = best_y {
d.y = dy;
let y = y + dy;
let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1));
@@ -309,6 +346,16 @@ mod tests {
use super::*;
use crate::testutil::*;
#[test]
fn artboard_bleed_is_a_target() {
let (mut d, _) = doc_with_rect();
let t = Targets::collect(&d, &[], None);
assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 512.0, "no bleed, nothing near");
d.setup.bleed = [10.0; 4];
let t = Targets::collect(&d, &[], None);
assert_eq!(t.snap_point(Point::new(512.0, 300.0), 4.0).0.x, 510.0);
}
#[test]
fn snaps_to_anchor_and_alignment() {
let (d, _) = doc_with_rect();
+265 -20
View File
@@ -4,28 +4,37 @@
//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and
//! leaves the artboard where it was), drag a handle resizes it
//! (Shift proportional, Alt from centre), drag on the pasteboard draws a new artboard, Delete removes
//! the active one and Escape returns to the Selection tool.
//! the active one and Escape returns to the Selection tool. Moving and resizing snap like drawing
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
//! objects (never to the dragged artboard or the art moving with it); the artboard's own bleed edges
//! snap too.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, Vec2};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
use crate::guides::Targets;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Copy, Debug)]
enum Drag {
/// `copy`: Alt was held at the last drag event (the step under way is a duplicate).
/// `rect` is the artboard as it was when the drag started: during the drag the document
/// already shows the preview, so it can't be read back from there. `copy`: Alt was held at the
/// last drag event (the step under way is a duplicate).
Move {
index: usize,
start: Point,
rect: Rect,
began: bool,
copy: bool,
},
/// `grab` is the handle's offset from the pointer, so the handle itself follows and snaps.
Resize {
index: usize,
handle: Handle,
rect: Rect,
grab: Vec2,
began: bool,
},
Create {
@@ -41,11 +50,17 @@ pub struct ArtboardTool {
pub move_art: bool,
drag: Option<Drag>,
preview: Option<Rect>,
/// Smart Guide targets, gathered when a move or resize starts.
targets: Option<Targets>,
/// The targets for an Alt-drag copy: the original artboard and its art stay where they are.
copy_targets: Option<Targets>,
/// Smart Guides shown while dragging.
guides: Vec<Overlay>,
}
impl Default for ArtboardTool {
fn default() -> Self {
Self { active: 0, move_art: true, drag: None, preview: None }
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![] }
}
}
@@ -57,6 +72,80 @@ impl ArtboardTool {
fn active_rect(&self, cx: &ToolContext) -> Option<Rect> {
cx.doc.artboards.get(self.active).map(|a| a.rect)
}
/// Gather the Smart Guide targets for dragging artboard `index` (call before the first preview,
/// while the document still shows where everything started).
fn begin_snapping(&mut self, cx: &ToolContext, index: usize, art_moves: bool) {
self.guides.clear();
self.targets = cx.smart_guides.then(|| {
let art = match cx.doc.artboards.get(index) {
Some(a) if art_moves => cx.doc.art_on_artboard(a.rect),
_ => vec![],
};
Targets::for_artboard(cx.doc, index, &art)
});
// Nothing to skip: no artboard has this index.
self.copy_targets = cx.smart_guides.then(|| Targets::for_artboard(cx.doc, usize::MAX, &[]));
}
/// Snap a dragged handle: to whole pixels, the grid, or Smart Guides (in that order, as when
/// drawing). With Smart Guides the bleed edge `bleed` beyond the handle may snap instead.
fn snap_handle(&mut self, cx: &ToolContext, p: Point, bleed: Vec2) -> Point {
self.guides.clear();
if cx.snap_to_pixel {
return Point::new(p.x.round(), p.y.round());
}
if cx.snap_to_grid {
return vectorcraft_geom::snap::snap_point_to_grid(p, grid_step(cx));
}
let Some(t) = &self.targets else { return p };
let offsets: &[Vec2] = if bleed == Vec2::ZERO { &[] } else { &[bleed] };
let (q, ov) = t.snap_point_with(p, offsets, cx.tol(5.0));
self.guides = ov;
q
}
/// Snap a move (or an Alt-drag `copy`) of `rect` by `d`: its top-left to whole pixels or the
/// grid, or any of its edges and centre to Smart Guides. Returns the snapped delta.
fn snap_move(&mut self, cx: &ToolContext, rect: Rect, d: Vec2, copy: bool) -> Vec2 {
self.guides.clear();
let tl = Point::new(rect.x0, rect.y0);
if cx.snap_to_pixel {
return Vec2::new((tl.x + d.x).round() - tl.x, (tl.y + d.y).round() - tl.y);
}
if cx.snap_to_grid {
return vectorcraft_geom::snap::snap_point_to_grid(tl + d, grid_step(cx)) - tl;
}
let Some(t) = (if copy { &self.copy_targets } else { &self.targets }) else { return d };
let moved = rect + d;
let (adj, ov) = if cx.doc.setup.has_bleed() {
t.snap_rects(&[moved, cx.doc.setup.bleed_rect(moved)], cx.tol(5.0))
} else {
t.snap_rect(moved, cx.tol(5.0))
};
self.guides = ov;
d + adj
}
fn end_drag(&mut self) {
self.drag = None;
self.preview = None;
self.targets = None;
self.copy_targets = None;
self.guides.clear();
}
}
/// Where the bleed edge lies beyond a handle, along the axes the handle moves.
fn bleed_offset(cx: &ToolContext, rect: Rect, handle: Handle) -> Vec2 {
let d = handle.pos(cx.doc.setup.bleed_rect(rect)) - handle.pos(rect);
let (ax, ay) = handle.axes();
Vec2::new(if ax { d.x } else { 0.0 }, if ay { d.y } else { 0.0 })
}
/// The grid's snapping step (gridline spacing over subdivisions), as `guides::snap_draw` uses.
fn grid_step(cx: &ToolContext) -> f64 {
cx.doc.grid.spacing / cx.doc.grid.subdivisions.max(1) as f64
}
impl Tool for ArtboardTool {
@@ -76,19 +165,21 @@ impl Tool for ArtboardTool {
if let Some(r) = self.active_rect(cx)
&& let Some(handle) = hit_handle(r, p, cx.tol(5.0))
{
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, began: false });
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, grab: handle.pos(r) - p, began: false });
return vec![];
}
if let Some(i) = (0..cx.doc.artboards.len()).rev().find(|i| cx.doc.artboards[*i].rect.contains(p)) {
if let Some(i) = cx.doc.artboard_at(p)
&& let Some(a) = cx.doc.artboards.get(i)
{
self.active = i;
self.drag = Some(Drag::Move { index: i, start: p, began: false, copy: false });
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
} else {
let (p, _) = crate::guides::snap_draw(cx, p, &[]);
self.drag = Some(Drag::Create { start: p, cur: p });
}
vec![]
}
(PointerKind::Drag, Some(Drag::Move { index, start, began, copy })) => {
(PointerKind::Drag, Some(Drag::Move { index, start, rect, began, copy })) => {
let mut out = vec![];
let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" };
if !began {
@@ -96,27 +187,30 @@ impl Tool for ArtboardTool {
return out;
}
out.push(Action::Begin(label(m.alt).into()));
self.begin_snapping(cx, index, self.move_art);
} else if m.alt != copy {
// Alt pressed or released mid-drag: start over as a copy or a move.
out.push(Action::Cancel);
out.push(Action::Begin(label(m.alt).into()));
}
self.drag = Some(Drag::Move { index, start, began: true, copy: m.alt });
let d = move_delta(start, p, m.shift);
self.preview = cx.doc.artboards.get(index).map(|a| a.rect + d);
self.drag = Some(Drag::Move { index, start, rect, began: true, copy: m.alt });
let d = self.snap_move(cx, rect, move_delta(start, p, m.shift), m.alt);
self.preview = Some(rect + d);
out.push(Action::Preview(
"artboard.move".into(),
json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt }),
));
out
}
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, began })) => {
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, grab, began })) => {
let mut out = vec![];
if !began {
out.push(Action::Begin("Resize Artboard".into()));
self.begin_snapping(cx, index, false);
}
self.drag = Some(Drag::Resize { index, handle, rect, began: true });
let nr = scale_for_drag(rect, handle, p, m.shift, m.alt).transform_rect_bbox(rect);
self.drag = Some(Drag::Resize { index, handle, rect, grab, began: true });
let h = self.snap_handle(cx, p + grab, bleed_offset(cx, rect, handle));
let nr = scale_for_drag(rect, handle, h, m.shift, m.alt).transform_rect_bbox(rect);
self.preview = Some(nr);
out.push(Action::Preview("artboard.setProps".into(), rect_json(index, nr)));
out
@@ -132,8 +226,7 @@ impl Tool for ArtboardTool {
vec![]
}
(PointerKind::Up, Some(d)) => {
self.drag = None;
self.preview = None;
self.end_drag();
match d {
Drag::Move { began: true, copy, .. } => {
// The copy, last in the previewed document, becomes the active artboard.
@@ -157,7 +250,7 @@ impl Tool for ArtboardTool {
}
}
(PointerKind::DoubleClick, _) => {
self.drag = None;
self.end_drag();
match cx.doc.artboards.get(self.active) {
Some(a) => {
let mut v = rect_json(self.active, a.rect);
@@ -183,8 +276,7 @@ impl Tool for ArtboardTool {
}
ToolKey::Escape => {
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
self.drag = None;
self.preview = None;
self.end_drag();
if busy { vec![Action::Cancel] } else { vec![Action::SwitchTool("selection".into())] }
}
_ => vec![],
@@ -218,6 +310,9 @@ impl Tool for ArtboardTool {
text: cx.size_label(r.width(), r.height()),
});
}
if self.drag.is_some() {
o.extend(self.guides.iter().cloned());
}
o
}
@@ -249,8 +344,7 @@ impl Tool for ArtboardTool {
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
self.drag = None;
self.preview = None;
self.end_drag();
if busy { vec![Action::Cancel] } else { vec![] }
}
}
@@ -265,6 +359,140 @@ mod tests {
PointerEvent::new(kind, x, y)
}
/// [`doc_with_rect`] (artboard 1 at 0–500, a rectangle at 100–200) plus Artboard 2 at x 600–800,
/// y 0–200.
fn two_boards() -> vectorcraft_doc::Document {
let (mut d, _) = doc_with_rect();
let mut ab = d.artboards[0].clone();
ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0);
ab.name = "Artboard 2".into();
d.artboards.push(ab);
d
}
fn preview_params(a: &[Action]) -> Value {
a.iter()
.find_map(|a| match a {
Action::Preview(_, p) => Some(p.clone()),
_ => None,
})
.unwrap()
}
/// Drag artboard 2 from (700, 100) by (dx, dy) and return the preview's params.
fn drag_board2(cx: &ToolContext, t: &mut ArtboardTool, dx: f64, dy: f64) -> Value {
t.pointer(cx, &ev(PointerKind::Down, 700.0, 100.0));
preview_params(&t.pointer(cx, &ev(PointerKind::Drag, 700.0 + dx, 100.0 + dy)))
}
#[test]
fn bounds_follow_the_artboard_while_the_document_shows_the_preview() {
let mut d = two_boards();
let (s, p) = (Selection::default(), paint());
let mut t = ArtboardTool::default();
assert_eq!(drag_board2(&cx(&d, &s, &p), &mut t, 10.0, 0.0)["dx"], 10.0);
// The engine applies the preview to the document, as it does during a drag.
d.artboards[1].rect = Rect::new(610.0, 0.0, 810.0, 200.0);
let cx = cx(&d, &s, &p);
assert_eq!(preview_params(&t.pointer(&cx, &ev(PointerKind::Drag, 720.0, 100.0)))["dx"], 20.0);
let corners: Vec<Point> = t
.overlays(&cx)
.iter()
.filter_map(|o| match o {
Overlay::Anchor { p, .. } => Some(*p),
_ => None,
})
.collect();
assert_eq!(corners[0], Point::new(620.0, 0.0), "the handles sit on the moved artboard, not 10 pt past it");
}
#[test]
fn moving_snaps_to_other_artboards_with_smart_guides() {
let d = two_boards();
let (s, p) = (Selection::default(), paint());
let mut t = ArtboardTool::default();
let mut c = cx(&d, &s, &p);
// Left edge 3 pt right of artboard 1's right edge (500) → lands on it.
assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -100.0);
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { color, .. } if *color == crate::guides::MAGENTA)));
t.pointer(&c, &ev(PointerKind::Up, 603.0, 100.0));
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })), "guides go away on release");
c.smart_guides = false;
assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -97.0);
}
#[test]
fn moving_never_snaps_to_itself_or_the_art_it_carries() {
let mut d = two_boards();
let l = d.layers[0].id;
let id = d.alloc_id();
let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0));
d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = ArtboardTool::default();
// Its own old left edge (600) isn't a target.
assert_eq!(drag_board2(&c, &mut t, 4.0, 0.0)["dx"], 4.0);
t.pointer(&c, &ev(PointerKind::Up, 704.0, 100.0));
// The rectangle moves along: its left edge (650) isn't a target either…
assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 52.0);
t.pointer(&c, &ev(PointerKind::Up, 752.0, 100.0));
// …unless it stays behind.
t.move_art = false;
assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0);
}
#[test]
fn moving_snaps_to_grid_and_pixels() {
let d = two_boards();
let (s, p) = (Selection::default(), paint());
let mut t = ArtboardTool::default();
let mut c = cx(&d, &s, &p);
c.snap_to_grid = true;
// Grid step 72 / 8 = 9: x 610 → 612, y 4 → 0.
let v = drag_board2(&c, &mut t, 10.0, 4.0);
assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(12.0), Some(0.0)));
t.pointer(&c, &ev(PointerKind::Up, 710.0, 104.0));
c.snap_to_grid = false;
c.snap_to_pixel = true;
let v = drag_board2(&c, &mut t, 10.4, 3.6);
assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(10.0), Some(4.0)));
}
#[test]
fn resizing_snaps_the_dragged_handle() {
let d = two_boards();
let (s, p) = (Selection::default(), paint());
let mut t = ArtboardTool { active: 1, ..Default::default() };
let c = cx(&d, &s, &p);
// Left handle to 3 pt off artboard 1's right edge.
t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 503.0, 100.0)));
assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(500.0), Some(300.0)));
t.pointer(&c, &ev(PointerKind::Up, 503.0, 100.0));
// The handle, not the pointer, follows: grabbed 2 pt left of it, dragged 10 pt.
t.pointer(&c, &ev(PointerKind::Down, 798.0, 100.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 808.0, 100.0)));
assert_eq!(v["width"].as_f64(), Some(210.0));
}
#[test]
fn bleed_edges_snap_to_bleed_edges() {
let mut d = two_boards();
d.setup.bleed = [10.0; 4];
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = ArtboardTool::default();
// Artboard 2's bleed (left edge 590) lands on artboard 1's bleed (right edge 510): dragged
// 77 pt left it's 3 pt off, nothing else is within reach.
assert_eq!(drag_board2(&c, &mut t, -77.0, 0.0)["dx"], -80.0);
t.pointer(&c, &ev(PointerKind::Up, 623.0, 100.0));
// Resizing: the left handle's bleed edge snaps the same way.
t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 523.0, 100.0)));
assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(520.0), Some(280.0)));
}
#[test]
fn move_resize_create_delete() {
let (mut d, _) = doc_with_rect();
@@ -301,6 +529,23 @@ mod tests {
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]);
}
#[test]
fn an_alt_drag_copy_snaps_to_the_artboard_it_leaves_behind() {
let d = two_boards();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let alt = Mods { alt: true, ..Mods::default() };
let mut t = ArtboardTool::default();
// A copy of artboard 2 dragged 203 pt right: its left edge 3 pt off the original's right
// edge (800), which stays put and is a target.
t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(alt));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0).with_mods(alt)));
assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(200.0), Some(true)));
// Released Alt: a plain move, and the artboard's own old edge is no target.
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0)));
assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(203.0), Some(false)));
}
#[test]
fn alt_drag_moves_a_copy_of_the_artboard() {
let (d, _) = doc_with_rect();
+98 -1
View File
@@ -1,9 +1,11 @@
//! Transform panel: reference point, X/Y/W/H with the constrain link, rotate and shear, flips,
//! live-shape properties and the Scale Corners / Scale Strokes & Effects options.
//! live-shape properties and the Scale Corners / Scale Strokes & Effects options. While the
//! Artboard tool is in use it shows and edits the active artboard's X/Y/W/H instead.
use egui::Ui;
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_geom::Rect;
use super::{first_selected, pstate, set_pstate};
use crate::theme::Tokens;
@@ -21,12 +23,80 @@ pub fn constrained(w: f64, h: f64, new_w: Option<f64>, new_h: Option<f64>) -> (f
}
}
/// The artboard the Artboard tool has active, while that tool is in use.
fn tool_artboard(app: &VectorcraftApp) -> Option<(usize, Rect)> {
if app.session.tool_id() != "artboard" {
return None;
}
let i = usize::try_from(app.session.tool_options().get("active")?.as_u64()?).ok()?;
app.session.active()?.doc.artboards.get(i).map(|a| (i, a.rect))
}
/// Where an artboard goes when the panel sets its X, Y, W or H: the reference point `refi` moves to
/// the new X/Y or stays put while the size changes (both sides together when `link` is on).
pub fn artboard_rect(r: Rect, refi: usize, x: Option<f64>, y: Option<f64>, w: Option<f64>, h: Option<f64>, link: bool) -> Rect {
let rp = vectorcraft_geom::reference_point(r, refi);
let (w0, h0) = (r.width(), r.height());
let (nw, nh) = if link { constrained(w0, h0, w, h) } else { (w.unwrap_or(w0), h.unwrap_or(h0)) };
// Where the reference point sits across the box (0, ½ or 1 of each side).
let fx = if w0 > 1e-9 { (rp.x - r.x0) / w0 } else { 0.0 };
let fy = if h0 > 1e-9 { (rp.y - r.y0) / h0 } else { 0.0 };
let (x0, y0) = (x.unwrap_or(rp.x) - fx * nw, y.unwrap_or(rp.y) - fy * nh);
Rect::new(x0, y0, x0 + nw, y0 + nh)
}
/// The Artboard tool's view of the panel: the active artboard's position and size.
fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect) {
let units = app.session.general_unit();
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
let link = app.session.prefs.constrain_proportions;
let rp = vectorcraft_geom::reference_point(r, refi);
let set = |app: &mut VectorcraftApp, x, y, w, h| {
let n = artboard_rect(r, refi, x, y, w, h, link);
let p = json!({"index": index, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height()});
if let Err(e) = app.run("artboard.setProps", p) {
app.status(e);
}
};
ui.horizontal(|ui| {
if let Some(i) = widgets::reference_point(ui, refi) {
ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i));
}
ui.add_space(4.0);
egui::Grid::new("xfp-ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
widgets::dim_label(ui, "X:");
if let Some(v) = widgets::num_field(ui, "xfp-ab-x", Some(rp.x), units, 80.0) {
set(app, Some(v), None, None, None);
}
widgets::dim_label(ui, "W:");
if let Some(v) = widgets::num_field(ui, "xfp-ab-w", Some(r.width()), units, 80.0) {
set(app, None, None, Some(v), None);
}
ui.end_row();
widgets::dim_label(ui, "Y:");
if let Some(v) = widgets::num_field(ui, "xfp-ab-y", Some(rp.y), units, 80.0) {
set(app, None, Some(v), None, None);
}
widgets::dim_label(ui, "H:");
if let Some(v) = widgets::num_field(ui, "xfp-ab-h", Some(r.height()), units, 80.0) {
set(app, None, None, None, Some(v));
}
ui.end_row();
});
constrain_link(app, ui);
});
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
if app.session.active().is_none() {
widgets::dim_label(ui, tl!("No document"));
return;
}
if let Some((i, r)) = tool_artboard(app) {
artboard_fields(app, ui, i, r);
return;
}
let units = app.session.general_unit();
// The bounding box, rotated with rotated objects: W/H are its own sides, X/Y its reference
// point on the page.
@@ -230,6 +300,33 @@ mod tests {
assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}");
}
#[test]
fn the_artboard_tool_shows_the_active_artboard() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
assert!(texts(&mut app, show).iter().any(|t| t == "40 pt"), "the selection first");
app.run("tool.select", json!({"tool": "artboard"})).unwrap();
let shown = texts(&mut app, show);
// Centre reference point: X 150, Y 100, W 300, H 200; no rotate/shear for artboards.
for v in ["150 pt", "100 pt", "300 pt", "200 pt"] {
assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}");
}
assert!(!shown.iter().any(|t| t == "40 pt" || t == "0°"), "{shown:?}");
}
#[test]
fn artboard_fields_keep_the_reference_point() {
let r = Rect::new(0.0, 0.0, 300.0, 200.0);
// Centre: a new X centres it there, a new W grows it both ways.
assert_eq!(artboard_rect(r, 4, Some(200.0), None, None, None, false), Rect::new(50.0, 0.0, 350.0, 200.0));
assert_eq!(artboard_rect(r, 4, None, None, Some(400.0), None, false), Rect::new(-50.0, 0.0, 350.0, 200.0));
// Top-left: a new W keeps the left edge; linked, H follows.
assert_eq!(artboard_rect(r, 0, None, None, Some(600.0), None, true), Rect::new(0.0, 0.0, 600.0, 400.0));
// Bottom-right: a new Y puts the bottom edge there.
assert_eq!(artboard_rect(r, 8, None, Some(500.0), None, None, false), Rect::new(0.0, 300.0, 300.0, 500.0));
}
#[test]
fn constrain_proportions() {
assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0));