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:35 +01:00
co-authored by Claude Opus 5.5
parent 133ad87541
commit f2b35fc039
20 changed files with 346 additions and 27 deletions
+50 -9
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> {