Fix #602: Scale Artwork with Artboard scales the art with a resized artboard

`artboard.setProps {scaleArt: true}` maps the art fully inside a resized
artboard (as Move Artwork with Artboard gathers it) and its artboard guides
from the old rectangle onto the new one, each side by its own ratio, in one
undo step, and returns the ids scaled. Strokes, effects and corners follow
Scale Strokes & Effects and Scale Corners (`strokes`, `corners`), pattern tiles
Transform Pattern Tiles (`patterns`); locked and hidden art follows
`lockedAndHidden` (default: prefs moveLockedWithArtboard). The resolved choices
are journaled, so a replay scales the same objects the same way. A move that
keeps the size scales nothing.

The Artboard tool's `scaleArt` option (saved with `moveArt`) makes handle
drags proportional, as the public help describes, keeps them proportional at
the 1 pt minimum, and leaves the scaled art out of the Smart Guide targets.
Move and Scale Artwork with Artboard are check boxes in the Control bar and
Properties' Edit Artboards; the artboard size fields there and in the
Transform panel pass the option. Translated in the eight catalogs that have
Move Artwork with Artboard. docs/mcp.md and the ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HT 2026-10-09 11:50:26 +01:00
parent 133ad87541
commit f2b35fc039
20 changed files with 346 additions and 27 deletions

View File

@ -134,7 +134,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681), Measure, Type, Hand, Zoom, Rotate View.
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
- **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.

View File

@ -96,7 +96,7 @@ pub use vectorcraft_geom as geom;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{Swatch, SwatchGroup};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DocError {
@ -1006,6 +1006,13 @@ impl Document {
*g = g.moved(d);
}
}
/// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork
/// with Artboard).
pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) {
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y };
}
}
/// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their
/// artboard).
pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) {

View File

@ -10,8 +10,8 @@ use std::collections::HashSet;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Rect};
use super::*;
use crate::EngineError;
@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Artboard Options…",
["Window", "Artboards"],
None,
"{index, name?, x?, y?, width?, height?}",
"{index, name?, x?, y?, width?, height?, scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale)} → null, or with scaleArt {scaled: the art scaled}",
has_doc,
artboard_set
),
@ -752,21 +752,62 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let was = s.doc()?.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let now = rect_from(p, was);
let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6;
// Scale Artwork with Artboard: resized, it takes the art fully inside it (as Move Artwork with
// Artboard gathers it) and its guides from the old rectangle onto the new one.
let scale_art = bool_or(p, "scaleArt", false);
let fit = if scale_art && resized { rect_map(was, now) } else { None };
let art = if fit.is_some() {
// Locked and hidden art too? Noted in the journal, so a replay scales the same objects
// whatever the preference is then.
let all = bool_or(p, "lockedAndHidden", s.prefs.move_locked_with_artboard);
s.note_journal("lockedAndHidden", json!(all));
s.doc()?.doc.art_on_artboard(was, all)
} else {
vec![]
};
let mut sc = match fit {
Some(xf) if Scaling::factor(xf).is_some() => super::object::scaling(s, p),
_ => Scaling::default(),
};
sc.patterns = super::object::transform_patterns(s, p, &art)?;
s.edit("Artboard Options", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
let was = a.rect;
a.rect = rect_from(p, a.rect);
a.rect = now;
if let Some(n) = str_param(p, "name") {
a.name = n.to_string();
}
// Moved (not resized), it takes its guides along.
let (id, now) = (a.id, a.rect);
if (now.width() - was.width()).abs() <= 1e-6 && (now.height() - was.height()).abs() <= 1e-6 {
let id = a.id;
if let Some(xf) = fit {
d.map_artboard_guides(id, xf);
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(xf, sc);
}
}
} else if !resized {
// Moved (not resized), it takes its guides along.
d.move_artboard_guides(id, now.origin() - was.origin());
}
Ok(())
})?;
ok()
if !scale_art {
return ok();
}
Ok(json!({ "scaled": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// The map taking artboard rectangle `from` onto `to`, each side scaled by its own ratio. None
/// when `from` has no area or the map isn't finite (a size out of range).
fn rect_map(from: Rect, to: Rect) -> Option<Affine> {
if from.width() <= 1e-9 || from.height() <= 1e-9 {
return None;
}
let (sx, sy) = (to.width() / from.width(), to.height() / from.height());
let xf = Affine::translate(to.origin().to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-from.origin().to_vec2());
xf.is_finite().then_some(xf)
}
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {

View File

@ -316,6 +316,106 @@ fn artboard_move_copy_reports_the_copies_and_follows_move_art() {
assert_eq!(ids.len(), 3, "every artboard has an id of its own");
}
fn stroke_width(s: &Session, id: NodeId) -> f64 {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width
}
fn guide_positions(s: &Session) -> Vec<f64> {
s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect()
}
/// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the
/// artboard and its guides from the old rectangle onto the new one, each side by its own ratio, in
/// one undo step; art partly outside and canvas guides stay.
#[test]
fn artboard_set_props_scales_the_art_and_guides_with_the_artboard() {
let mut s = session();
let inside = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
let partly = rect(&mut s, 700.0, 500.0, 200.0, 200.0);
for (vertical, pos, artboard) in [(true, 400.0, json!(0)), (false, 300.0, json!(0)), (true, 400.0, json!(null))] {
s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "artboard": artboard})).unwrap();
}
let before = s.doc().unwrap().doc.clone();
// Half the size: half the art, half the stroke (Scale Strokes & Effects).
let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true, "strokes": true})).unwrap();
assert_eq!(out["scaled"], json!([inside.0]));
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 400.0, 300.0));
assert_eq!(bounds(&s, inside), Rect::new(50.0, 50.0, 150.0, 100.0));
assert_eq!(stroke_width(&s, inside), 2.0);
assert_eq!(bounds(&s, partly), Rect::new(700.0, 500.0, 900.0, 700.0), "art partly outside stays");
assert_eq!(guide_positions(&s), vec![200.0, 150.0, 400.0], "the artboard's guides scale, the canvas guide stays");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before, "one undo step");
// Moved and stretched (left edge to -100, twice as wide), strokes kept: the art follows.
s.execute("artboard.setProps", &json!({"index": 0, "x": -100, "width": 1600, "scaleArt": true, "strokes": false})).unwrap();
assert_eq!(bounds(&s, inside), Rect::new(100.0, 100.0, 500.0, 200.0));
assert_eq!(stroke_width(&s, inside), 4.0);
assert_eq!(guide_positions(&s), vec![700.0, 300.0, 400.0]);
}
/// Without `scaleArt` a resize leaves the art and guides alone (and returns nothing, as before);
/// with it, a pure move scales nothing (guides move along, as they always have).
#[test]
fn artboard_set_props_scales_art_only_when_asked_and_resized() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 400, "artboard": 0})).unwrap();
assert_eq!(s.execute("artboard.setProps", &json!({"index": 0, "width": 400})).unwrap(), Value::Null);
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
assert_eq!(guide_positions(&s), vec![400.0]);
let out = s.execute("artboard.setProps", &json!({"index": 0, "x": 50, "scaleArt": true})).unwrap();
assert_eq!(out["scaled"], json!([]));
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
assert_eq!(guide_positions(&s), vec![450.0]);
assert!(s.execute("artboard.setProps", &json!({"index": 9, "width": 10, "scaleArt": true})).is_err());
}
/// Locked and hidden art scales only with Move Locked and Hidden Artwork with Artboard, as it
/// moves only with it.
#[test]
fn scale_artwork_with_artboard_leaves_locked_art_unless_the_preference_says() {
let mut s = session();
let locked = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("object.lock", &json!({})).unwrap();
let scaled = |s: &mut Session| {
let out = s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
out["scaled"].clone()
};
assert_eq!(scaled(&mut s), json!([]));
s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": true})).unwrap();
assert_eq!(scaled(&mut s), json!([locked.0]));
// The choice is journaled: replayed with the preference off, the same art scales.
s.execute("artboard.setProps", &json!({"index": 0, "width": 400, "height": 300, "scaleArt": true})).unwrap();
assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], true);
let (id, p) = s.journal.last().unwrap().clone();
s.execute("edit.undo", &json!({})).unwrap();
s.execute("prefs.set", &json!({"key": "moveLockedWithArtboard", "value": false})).unwrap();
assert_eq!(s.execute(&id, &p).unwrap()["scaled"], json!([locked.0]));
}
/// The Artboard tool with its `scaleArt` option on resizes proportionally and the art scales with
/// the artboard, in one undo step.
#[test]
fn artboard_tool_scales_art_with_the_artboard() {
let mut s = session();
let r = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
let before = s.doc().unwrap().doc.clone();
s.set_tool_options(Some("artboard"), json!({"scaleArt": true}).as_object().unwrap());
// The right handle 100 pt out: 9/8 as wide, and as tall about the middle of the left side.
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, -37.5, 900.0, 637.5));
assert_eq!(bounds(&s, r), Rect::new(112.5, 75.0, 337.5, 187.5));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(*s.doc().unwrap().doc, *before, "one undo step");
// Off again (the option is remembered like Move Artwork with Artboard): the art stays.
s.set_tool_options(Some("artboard"), json!({"scaleArt": false}).as_object().unwrap());
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0));
assert_eq!(bounds(&s, r), Rect::new(100.0, 100.0, 300.0, 200.0));
}
#[test]
fn magic_wand_selects_same_fill() {
let mut s = session();

View File

@ -272,6 +272,8 @@ fn structured_junk() {
("object.setBounds", json!({"width": -10, "height": 1e308, "reference": 99})),
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": -1e308})),
("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})),
("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})),
("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})),
("artboard.new", json!({"width": 0, "height": -1})),
("artboard.delete", json!({"index": 0})),
("artboard.move", json!({"index": 0, "dx": 1e308, "dy": 0, "moveArt": true})),

View File

@ -34,7 +34,7 @@ const ROWS: &[Row] = &[
(&["polygon"], None, &["sides"]),
(&["star"], None, &["points"]),
(&["freeTransform"], None, &["constrain"]),
(&["artboard"], None, &["moveArt"]),
(&["artboard"], None, &["moveArt", "scaleArt"]),
];
/// The stores `tool`'s persistent options live in, each with the option keys it holds.

View File

@ -3,15 +3,16 @@
//! 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; 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
//! (Shift proportional, Alt from centre; with the `scaleArt` option on, Scale Artwork with Artboard,
//! always proportional, and the art fully inside scales with it), drag on the pasteboard draws a new artboard, Delete removes
//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`) 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
//! objects (never to the dragged artboard or the art moving or scaling with it); the artboard's own bleed edges
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]).
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, Vec2};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
@ -49,6 +50,9 @@ pub struct ArtboardTool {
pub active: usize,
/// Move artwork with the artboard.
pub move_art: bool,
/// Scale artwork with the artboard: a resize takes the art fully inside it along, and keeps the
/// artboard's proportions.
pub scale_art: bool,
drag: Option<Drag>,
preview: Option<Rect>,
/// Smart Guide targets, gathered when a move or resize starts.
@ -63,7 +67,17 @@ pub struct ArtboardTool {
impl Default for ArtboardTool {
fn default() -> Self {
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![], draw: DrawSnap::default() }
Self {
active: 0,
move_art: true,
scale_art: false,
drag: None,
preview: None,
targets: None,
copy_targets: None,
guides: vec![],
draw: DrawSnap::default(),
}
}
}
@ -140,6 +154,20 @@ impl ArtboardTool {
}
}
/// An artboard's shortest side (as `artboard.setProps` keeps it).
const MIN_SIDE: f64 = 1.0;
/// `r` grown about `anchor` until both sides are at least [`MIN_SIDE`], by one factor, so a
/// proportional resize keeps its proportions where the command would lengthen only the short side.
fn keep_min_side(r: Rect, anchor: Point) -> Rect {
let short = r.width().min(r.height());
if short >= MIN_SIDE || short <= 0.0 || !short.is_finite() {
return r;
}
let k = MIN_SIDE / short;
(Affine::translate(anchor.to_vec2()) * Affine::scale(k) * Affine::translate(-anchor.to_vec2())).transform_rect_bbox(r)
}
/// 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);
@ -206,13 +234,21 @@ impl Tool for ArtboardTool {
let mut out = vec![];
if !began {
out.push(Action::Begin("Resize Artboard".into()));
self.begin_snapping(cx, index, false);
self.begin_snapping(cx, index, self.scale_art);
}
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);
let proportional = m.shift || self.scale_art;
let mut nr = scale_for_drag(rect, handle, h, proportional, m.alt).transform_rect_bbox(rect);
if proportional {
nr = keep_min_side(nr, if m.alt { rect.center() } else { handle.opposite().pos(rect) });
}
self.preview = Some(nr);
out.push(Action::Preview("artboard.setProps".into(), rect_json(index, nr)));
let mut v = rect_json(index, nr);
if self.scale_art {
v["scaleArt"] = json!(true);
}
out.push(Action::Preview("artboard.setProps".into(), v));
out
}
(PointerKind::Drag, Some(Drag::Create { start, .. })) => {
@ -338,13 +374,14 @@ impl Tool for ArtboardTool {
}
fn options(&self) -> Value {
json!({ "active": self.active, "moveArt": self.move_art })
json!({ "active": self.active, "moveArt": self.move_art, "scaleArt": self.scale_art })
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"active" => self.active = value.as_u64().unwrap_or(0) as usize,
"moveArt" => self.move_art = value.as_bool().unwrap_or(true),
"scaleArt" => self.scale_art = value.as_bool().unwrap_or(false),
_ => {}
}
}
@ -483,6 +520,49 @@ mod tests {
assert_eq!(v["width"].as_f64(), Some(210.0));
}
/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's
/// proportions, and never snaps to the art scaling with it.
#[test]
fn scale_art_resizes_proportionally_without_snapping_to_its_art() {
let mut d = two_boards();
let l = d.layers[0].id;
let id = d.alloc_id();
let art = vectorcraft_geom::shapes::rectangle(Rect::new(700.0, 50.0, 710.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);
// Off: the right handle 3 pt past the art's right edge (710) lands on it; the height stays.
let mut t = ArtboardTool { active: 1, ..Default::default() };
t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0)));
assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v.get("scaleArt")), (Some(110.0), Some(200.0), None));
t.pointer(&c, &ev(PointerKind::Up, 713.0, 100.0));
// On: the art scales along, so it's no target, and the height follows the width.
t.set_option("scaleArt", &json!(true));
assert_eq!(t.options()["scaleArt"], true);
t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0)));
assert_eq!((v["width"].as_f64(), v["scaleArt"].as_bool()), (Some(113.0), Some(true)));
assert!((v["height"].as_f64().unwrap() - 113.0).abs() < 1e-9);
}
/// A proportional resize never asks for a side under 1 pt, which the command would lengthen
/// alone (scaling the art unevenly): both sides grow by one factor instead.
#[test]
fn a_proportional_resize_keeps_its_proportions_at_the_smallest_size() {
let mut d = two_boards();
d.artboards[1].rect = Rect::new(600.0, 0.0, 800.0, 20.0);
let (s, p) = (Selection::default(), paint());
let mut c = cx(&d, &s, &p);
c.smart_guides = false;
let mut t = ArtboardTool { active: 1, scale_art: true, ..Default::default() };
// The right handle to 2.5 % of the width: 5 × 0.5, grown to 10 × 1 about the left middle.
t.pointer(&c, &ev(PointerKind::Down, 800.0, 10.0));
let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 605.0, 10.0)));
let got = ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap());
assert!(got.iter().zip([600.0, 9.5, 10.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}");
}
#[test]
fn bleed_edges_snap_to_bleed_edges() {
let mut d = two_boards();

View File

@ -1991,6 +1991,7 @@
Arrange by Row Organizar en una fila
Arrange by Column Organizar en una columna
Move Artwork with Artboard Mover la ilustración con la mesa de trabajo
Scale Artwork with Artboard Escalar la ilustración con la mesa de trabajo
Artboards: {n} Mesas de trabajo: {n}
Rearrange All Artboards… Reorganizar todas las mesas de trabajo…
Recall Selection Recuperar selección

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1988,6 +1988,7 @@
Arrange by Row Disposer en ligne
Arrange by Column Disposer en colonne
Move Artwork with Artboard Déplacer l’illustration avec le plan de travail
Scale Artwork with Artboard Mettre à l’échelle l’illustration avec le plan de travail
Artboards: {n} Plans de travail : {n}
Rearrange All Artboards… Réorganiser tous les plans de travail…
Recall Selection Rappeler la sélection

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1991,6 +1991,7 @@
Arrange by Row Disponi in una riga
Arrange by Column Disponi in una colonna
Move Artwork with Artboard Sposta la grafica con la tavola da disegno
Scale Artwork with Artboard Scala la grafica con la tavola da disegno
Artboards: {n} Tavole da disegno: {n}
Rearrange All Artboards… Ridisponi tutte le tavole da disegno…
Recall Selection Richiama selezione

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2006,6 +2006,7 @@
Arrange by Row 1 行に配置
Arrange by Column 1 列に配置
Move Artwork with Artboard アートボードと一緒にアートワークを移動
Scale Artwork with Artboard アートボードと一緒にアートワークを拡大・縮小
Artboards: {n} アートボード: {n}
Recall Selection 選択範囲を呼び出し
Recent Colors 最近使用したカラー

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1760,6 +1760,7 @@
Arrange by Row Organizar em Linha
Arrange by Column Organizar em Coluna
Move Artwork with Artboard Mover Arte com a Mesa de Trabalho
Scale Artwork with Artboard Dimensionar Arte com a Mesa de Trabalho
Artboards: {n} Mesas de Trabalho: {n}
Rearrange All Artboards… Reorganizar Todas as Mesas de Trabalho…
Recall Selection Relembrar Seleção

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1987,6 +1987,7 @@
Arrange by Row Расположить в строку
Arrange by Column Расположить в столбец
Move Artwork with Artboard Перемещать иллюстрацию вместе с монтажной областью
Scale Artwork with Artboard Масштабировать иллюстрацию вместе с монтажной областью
Artboards: {n} Монтажные области: {n}
Rearrange All Artboards… Переставить все монтажные области…
Recall Selection Восстановить выделение

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1945,6 +1945,7 @@
Arrange by Row 排成一行
Arrange by Column 排成一列
Move Artwork with Artboard 随画板移动图稿
Scale Artwork with Artboard 随画板缩放图稿
Artboards: {n} 画板:{n}
Rearrange All Artboards… 重新排列所有画板…
Recall Selection 召回选区

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1994,6 +1994,7 @@
Arrange by Row 排成一列
Arrange by Column 排成一欄
Move Artwork with Artboard 隨工作區域移動圖稿
Scale Artwork with Artboard 隨工作區域縮放圖稿
Artboards: {n} 工作區域:{n}
Rearrange All Artboards… 重新排列所有工作區域…
Recall Selection 叫出選取範圍

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -12,6 +12,24 @@ use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
/// The Artboard tool's Move Artwork with Artboard and Scale Artwork with Artboard options (#602) as
/// check boxes: the Control bar and Properties show them while the tool is in use.
pub(crate) fn art_options(app: &mut VectorcraftApp, ui: &mut Ui) {
let opts = app.session.tool_options();
for (key, label, default) in [("moveArt", tl!("Move Artwork with Artboard"), true), ("scaleArt", tl!("Scale Artwork with Artboard"), false)] {
let on = opts[key].as_bool().unwrap_or(default);
if widgets::check(ui, label, on, true) {
app.run("tool.setOption", json!({ "key": key, "value": !on })).ok();
}
}
}
/// Whether artboards resized while the Artboard tool is in use take their art along (its Scale
/// Artwork with Artboard option): the panels' sizes pass it to `artboard.setProps` as `scaleArt`.
pub(crate) fn scale_art(app: &VectorcraftApp) -> bool {
app.session.tool_id() == "artboard" && app.session.tool_options()["scaleArt"].as_bool().unwrap_or(false)
}
/// The active artboard (of `n`).
pub(crate) fn selected(app: &VectorcraftApp, n: usize) -> usize {
app.view().map_or(0, |v| v.artboard).min(n.saturating_sub(1))

View File

@ -198,12 +198,14 @@ fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
}
/// Edit Artboards (the Artboard tool): the active artboard's name, position and size, its preset and
/// orientation (#671), New Artboard and Delete Artboard, and Quick Actions: Rearrange All (#681) and
/// orientation (#671) (with Scale Artwork with Artboard on, a new size scales its art, #602), Move
/// and Scale Artwork with Artboard, New Artboard and Delete Artboard, and Quick Actions: Rearrange All (#681) and
/// Exit back to the Selection tool (#530).
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = app.session.general_unit();
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
let i = super::artboards::selected(app, n);
let scale_art = super::artboards::scale_art(app);
if let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() {
// Its name as it is (names are never translated).
ui.label(egui::RichText::new(&ab.name).size(13.0).color(Tokens::get(ui.ctx()).text));
@ -216,13 +218,15 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
for (label, key, v) in [(l1, k1, v1), (l2, k2, v2)] {
dim_label(ui, label);
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v), units, fw) {
app.run("artboard.setProps", json!({"index": i, key: v})).ok();
app.run("artboard.setProps", json!({"index": i, key: v, "scaleArt": scale_art})).ok();
}
}
ui.end_row();
}
});
artboard_size_row(app, ui, i, ab.rect.width(), ab.rect.height());
ui.add_space(2.0);
super::artboards::art_options(app, ui);
}
ui.add_space(4.0);
let w = (ui.available_width() - 6.0) / 2.0;
@ -296,7 +300,8 @@ fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize, w: f64, h:
}
});
if let Some((width, height)) = size {
app.run("artboard.setProps", json!({"index": i, "width": width, "height": height})).ok();
let scale_art = super::artboards::scale_art(app);
app.run("artboard.setProps", json!({"index": i, "width": width, "height": height, "scaleArt": scale_art})).ok();
}
}
@ -675,4 +680,43 @@ mod tests {
app.run("artboard.setProps", json!({"index": 0, "width": 333})).unwrap();
assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Custom"));
}
/// #602: Edit Artboards shows Move and Scale Artwork with Artboard; with Scale on, a new size
/// (here the other orientation) takes the art on the artboard along.
#[test]
fn edit_artboards_scales_the_art_with_scale_artwork_with_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let r = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.select_tool("artboard");
let ctx = egui::Context::default();
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Move Artwork with Artboard"), "{texts:?}");
assert_eq!(app.session.tool_options()["scaleArt"], false);
click(&mut app, &ctx, &texts, "Scale Artwork with Artboard");
assert_eq!(app.session.tool_options()["scaleArt"], true);
// Portrait: 100 × 200, the art scaled by ½ across and 2 down.
let texts = frame(&mut app, &ctx, vec![]);
let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap();
let portrait = ctx.viewport(|vp| {
let mut r: Vec<Rect> = vp
.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.collect();
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r[r.len() - 2].center()
});
let press = |pressed| Event::PointerButton { pos: portrait, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(portrait), press(true)]);
frame(&mut app, &ctx, vec![press(false)]);
let d = &app.session.active().unwrap().doc;
let ab = d.artboards[0].rect;
assert_eq!((ab.width(), ab.height()), (100.0, 200.0));
let b = d.node(vectorcraft_doc::NodeId(r)).unwrap().geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 40.0, 20.0, 80.0));
}
}

View File

@ -55,7 +55,8 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect)
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()});
let scale_art = crate::panels::artboards::scale_art(app);
let p = json!({"index": index, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height(), "scaleArt": scale_art});
if let Err(e) = app.run("artboard.setProps", p) {
app.status(e);
}

View File

@ -338,7 +338,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
}
/// The active tool's options in the Control bar: Mirror & Cut's axis and the side it keeps, Puppet
/// Warp's mesh and pins (set through `tool.setOption`).
/// Warp's mesh and pins, the Artboard tool's Move and Scale Artwork with Artboard (set through
/// `tool.setOption`).
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
/// (value, label) of each choice.
type Choices = &'static [(&'static str, &'static str)];
@ -349,6 +350,11 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if app.session.tool_id() == "puppetWarp" {
return puppet_warp_options(app, ui);
}
if app.session.tool_id() == "artboard" {
crate::panels::artboards::art_options(app, ui);
ui.separator();
return;
}
if app.session.tool_id() != "mirrorCut" {
return;
}

View File

@ -629,7 +629,7 @@ repeated names, and ids the document doesn't have are dropped.
Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a
vertical guide, the y of a horizontal one, in points) → `{index}`. With `artboard` (an artboard's index) it is an
artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in
`artboard.setProps`, `artboard.rearrange`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is
`artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is
deleted with it (`artboard.delete`); without, a canvas guide runs across the whole canvas. `guide.list` →
`[{index, vertical, pos, selected, artboard?}…]` (`artboard` only for artboard guides).
`guide.select {indexes: [index…], toggle?}` selects guides on their own (the art is deselected; `toggle` adds or
@ -1268,7 +1268,8 @@ The Selection & Anchor Display and General preferences apply to `pointer_gesture
point a selection is dragged by, a drawn point and a transform tool's reference point land on an anchor or a ruler
guide that near.
- `moveLockedWithArtboard`: `artboard.move {moveArt: true}`, the Artboard tool and `artboard.rearrange` move locked and
hidden art with the artboard too; off (the default) it stays where it is.
hidden art with the artboard too, and `artboard.setProps {scaleArt: true}` scales it too; off (the default) it stays
where it is.
- `penRubberBand`, `curvatureRubberBand` (on by default): off, the Pen and Curvature tools draw no preview segment to
the pointer.
- `showHandlesMultipleAnchors` (on by default): off, Direct Selection (and the Anchor Point tool, and the Pen with Alt)
@ -1551,6 +1552,17 @@ pastes only the art), in this document or another; the result's `artboard` is th
does the same in one step → `{index, ids}`, and `artboard.move {copy: true, moveArt: true}` is the Artboard tool's
Alt-drag.
Scale Artwork with Artboard (#602): `artboard.setProps {index, x?, y?, width?, height?, scaleArt: true}` resizes an
artboard and scales the art fully inside it, and its artboard guides, from the old rectangle onto the new one, each side
by its own ratio (keep the proportions for an even scale), in one undo step → `{scaled: [id…]}`. Locked and hidden art
scales only with `lockedAndHidden: true` (default: prefs `moveLockedWithArtboard`); strokes, effects and corners scale
as with `object.scale` (`strokes?`, `corners?`, default: Scale Strokes & Effects and Scale Corners), pattern tiles with
`patterns?` (default: Transform Pattern Tiles). A move that keeps the size scales nothing (art moves with
`artboard.move {moveArt}`). The Artboard tool's `scaleArt` option (`tool.setOption {tool: "artboard", key: "scaleArt",
value: true}`; the Control bar's and Properties' Scale Artwork with Artboard check box, beside Move Artwork with
Artboard) makes its handle drags proportional and passes `scaleArt`, as do Properties' and the Transform panel's
artboard size fields while the tool is in use.
```json
{"name":"run_command","arguments":{"command":"artboard.copy","params":{"index":0}}}
{"name":"run_command","arguments":{"command":"edit.paste","params":{}}}