From f2b35fc0395aa38cad42e9b5614846f428d02dd1 Mon Sep 17 00:00:00 2001 From: HT Date: Fri, 9 Oct 2026 11:50:26 +0100 Subject: [PATCH] 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 --- ROADMAP.md | 2 +- crates/doc/src/lib.rs | 9 ++- crates/engine/src/cmd/layer.rs | 59 +++++++++++--- crates/engine/src/tests_xform.rs | 100 ++++++++++++++++++++++++ crates/engine/tests/command_sweep.rs | 2 + crates/tools/src/settings.rs | 2 +- crates/tools/src/xform/artboard.rs | 96 +++++++++++++++++++++-- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/artboards.rs | 18 +++++ crates/ui-egui/src/panels/properties.rs | 50 +++++++++++- crates/ui-egui/src/panels/transform.rs | 3 +- crates/ui-egui/src/toolbar.rs | 8 +- docs/mcp.md | 16 +++- 20 files changed, 346 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 574b4e9c..e8b7f943 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e84c44bf..e19332cd 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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) { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index cb36e94a..bc49386a 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -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 { "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 { fn artboard_set(s: &mut Session, p: &Value) -> Result { 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::>() })) +} + +/// 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 { + 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 { diff --git a/crates/engine/src/tests_xform.rs b/crates/engine/src/tests_xform.rs index 524adf23..c8101640 100644 --- a/crates/engine/src/tests_xform.rs +++ b/crates/engine/src/tests_xform.rs @@ -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 { + 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(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index f1ebc26b..9cd9a0df 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -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})), diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index ef6a1ccf..53bf0817 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -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. diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index e20e19cf..f3b9c0f7 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -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, preview: Option, /// 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(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 834f0e9b..b6dce5bc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cf870a1e..b42b4571 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d386fb56..40f99606 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4fdc46ba..76ad4824 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 最近使用したカラー diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b5941b13..5f6f3cd9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index af7325bc..99833130 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Восстановить выделение diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7b0b5353..f2fc7482 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 召回选区 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 70f9b792..080bae82 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 叫出選取範圍 diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 135156a7..986e3cb8 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -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)) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6df5e4c3..bd3b49c6 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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 = 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)); + } } diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index d911724c..bed4c97e 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -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); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 848635c1..c90c83a1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -338,7 +338,8 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result