M11: artboards snap by their bleed; transform panel edits an active artboard

This commit is contained in:
haven
2026-10-07 11:42:00 -07:00
parent 9cd91deafa
commit 364c579c04
4 changed files with 192 additions and 27 deletions
+1 -1
View File
@@ -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 (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.
- **Drawing aids:** Smart Guides and snapping (the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving 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.
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
+55 -18
View File
@@ -15,6 +15,7 @@ enum Kind {
Center,
Edge,
Artboard,
Bleed,
}
impl Kind {
@@ -24,6 +25,7 @@ impl Kind {
Kind::Center => "center",
Kind::Edge => "path",
Kind::Artboard => "artboard",
Kind::Bleed => "bleed",
}
}
}
@@ -56,12 +58,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.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| {
@@ -133,30 +141,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));
@@ -199,6 +226,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();
+38 -7
View File
@@ -4,8 +4,9 @@
//! 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. 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).
//! 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};
@@ -81,8 +82,8 @@ impl ArtboardTool {
}
/// 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 {
/// 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());
@@ -91,7 +92,8 @@ impl ArtboardTool {
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));
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
}
@@ -108,7 +110,12 @@ impl ArtboardTool {
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));
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
}
@@ -121,6 +128,13 @@ impl ArtboardTool {
}
}
/// 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
@@ -179,7 +193,7 @@ impl Tool for ArtboardTool {
self.begin_snapping(cx, index, false);
}
self.drag = Some(Drag::Resize { index, handle, rect, grab, began: true });
let h = self.snap_point(cx, p + grab);
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)));
@@ -439,6 +453,23 @@ mod tests {
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();
+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));