From 9cd91deafac034d26cac70f1d0c3a49bf975e2ab Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:10:39 -0700 Subject: [PATCH] M11: Artboard tool snaps artboards while moving and resizing --- ROADMAP.md | 2 +- crates/doc/src/lib.rs | 35 ++++ crates/engine/src/cmd/xform.rs | 27 +--- crates/tools/src/guides.rs | 12 +- crates/tools/src/xform/artboard.rs | 248 ++++++++++++++++++++++++++--- 5 files changed, 275 insertions(+), 49 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b9517f82..43d00ab2 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 (the Artboard tool snaps artboards to other artboards and art, the grid or whole pixels while moving and resizing them), 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/doc/src/lib.rs b/crates/doc/src/lib.rs index fada7e98..ebbafde4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -869,6 +869,30 @@ impl Document { pub fn artboard_at(&self, p: Point) -> Option { 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 { + fn collect(n: &Node, rect: Rect, out: &mut Vec) { + 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 } @@ -989,6 +1013,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(); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 5b2a4412..0767fcf6 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -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 { 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) { - 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; s.edit("Move Artboard", |d, _| { let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?; diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 716b3b22..4b5847ab 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -38,6 +38,16 @@ pub struct Targets { impl Targets { pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option) -> 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, visible: Option) -> 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| { @@ -50,7 +60,7 @@ impl Targets { 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); } let mut budget = 20_000usize; diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 19b351d3..f4352ab8 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -3,20 +3,40 @@ //! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves //! it (with its artwork when the `moveArt` option is on, Shift constrains), 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 and objects (never to +//! the dragged artboard or the art moving with it). 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 { - Move { index: usize, start: Point, began: bool }, - Resize { index: usize, handle: Handle, rect: Rect, began: bool }, - Create { start: Point, cur: Point }, + /// `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. + Move { + index: usize, + start: Point, + rect: Rect, + began: 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 { + start: Point, + cur: Point, + }, } pub struct ArtboardTool { @@ -26,11 +46,15 @@ pub struct ArtboardTool { pub move_art: bool, drag: Option, preview: Option, + /// Smart Guide targets, gathered when a move or resize starts. + targets: Option, + /// Smart Guides shown while dragging. + guides: Vec, } 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, guides: vec![] } } } @@ -42,6 +66,64 @@ impl ArtboardTool { fn active_rect(&self, cx: &ToolContext) -> Option { 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) + }); + } + + /// Snap a dragged handle: to whole pixels, the grid, or Smart Guides (in that order, as when + /// drawing). + fn snap_point(&mut self, cx: &ToolContext, p: Point) -> 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 (q, ov) = t.snap_point(p, cx.tol(5.0)); + self.guides = ov; + q + } + + /// Snap a move 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) -> 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) = &self.targets else { return d }; + let (adj, ov) = t.snap_rect(rect + d, cx.tol(5.0)); + self.guides = ov; + d + adj + } + + fn end_drag(&mut self) { + self.drag = None; + self.preview = None; + self.targets = None; + self.guides.clear(); + } +} + +/// 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 { @@ -61,39 +143,44 @@ 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 }); + self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: 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 })) => { + (PointerKind::Drag, Some(Drag::Move { index, start, rect, began })) => { let mut out = vec![]; if !began { if p.distance(start) < cx.tol(3.0) { return out; } out.push(Action::Begin("Move Artboard".into())); + self.begin_snapping(cx, index, self.move_art); } - self.drag = Some(Drag::Move { index, start, began: true }); - 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 }); + let d = self.snap_move(cx, rect, move_delta(start, p, m.shift)); + 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 }))); 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_point(cx, p + grab); + 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 @@ -109,8 +196,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, .. } | Drag::Resize { began: true, .. } => vec![Action::Commit], Drag::Create { start, cur } => { @@ -127,7 +213,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); @@ -153,8 +239,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![], @@ -188,6 +273,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 } @@ -219,8 +307,7 @@ impl Tool for ArtboardTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { 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![] } } } @@ -235,6 +322,123 @@ 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 = 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 move_resize_create_delete() { let (mut d, _) = doc_with_rect();