From 67b47c7262861c6a6631dfcadabcbb74774786c8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 19:44:45 -0700 Subject: [PATCH] Artboards: Rearrange and Select All on Active Artboard measure a symbol instance by its art MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rearrange and Select › All on Active Artboard measured a symbol instance as its transform applied to the 20 × 20 square around the symbol's origin, not by the symbol's art. An instance whose art lies away from that square moved with, or was selected on, the artboard the square was on. Convert to Artboards, Transform Each, Lock and Hide › All Artwork Above, Paste and Place from Library at a point, and the clipboard imports measured the instance the same way, as did Copy and Cut when they recorded the source artboard and library.add when it sized a graphic. These commands now measure an instance by its art through Document::symbol_boxes, as Document::art_on_artboard, Fit to Artwork Bounds and the Align commands do. Clipboard::bounds measures with the symbols the clipboard holds. clipboard.exportPng, clipboard.exportPdf and the PNG and PDF that Copy offers other apps are now cropped to the instance's art. Docs: docs/mcp.md. --- crates/engine/src/cmd/clipboard.rs | 12 +- crates/engine/src/cmd/menucmds.rs | 28 +++-- crates/engine/src/cmd/select.rs | 6 +- crates/engine/src/tests_symbolbounds.rs | 148 +++++++++++++++++++++++- docs/mcp.md | 15 ++- 5 files changed, 189 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 3cad57ac5..5dae8205a 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -109,7 +109,9 @@ impl Clipboard { // The nearest enclosing layer (or sublayer). source_layers.push(anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone())); } - let bounds = nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b)); + // Symbol instances are measured by their art. + let symbols = d.symbol_boxes(); + let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b)); // Art on no artboard has no source artboard: in-place pastes put it back where it was (#1924). let board = bounds.and_then(|b| d.artboard_at(b.center())); Self { @@ -127,9 +129,13 @@ impl Clipboard { Self::gather(src, nodes) } - /// Union of the objects' visual bounds. + /// Union of the objects' visual bounds, with symbol instances measured by the art of the + /// symbols the clipboard holds. pub fn bounds(&self) -> Option { - self.nodes.iter().filter_map(Node::visual_bounds).reduce(|a, b| a.union(b)) + let mut d = Document::new(100.0, 100.0); + d.symbols = self.symbols.clone(); + let symbols = d.symbol_boxes(); + self.nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b)) } /// The swatch name conflicts pasting into `st` raises, as (pasted, document) swatches: none diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index e0019a9c7..4d4193326 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -224,8 +224,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result { fn above(s: &mut Session, lock: bool) -> Result { let roots = selected_roots(s)?; let d = &s.doc()?.doc; + let symbols = d.symbol_boxes(); // `selected` holds each selected object's visual bounds, by layer, with the index there of its - // ancestor directly inside the layer. + // ancestor directly inside the layer. Symbol instances are measured by their art. let mut selected: BTreeMap> = BTreeMap::new(); for id in &roots { let Some(layer) = d.layer_of(*id) else { continue }; @@ -233,7 +234,7 @@ fn above(s: &mut Session, lock: bool) -> Result { let anc = d.ancestry(*id).unwrap_or_default(); let Some(pos) = anc.iter().position(|a| *a == layer) else { continue }; let top = anc.get(pos + 1).copied().unwrap_or(*id); - let Some(b) = d.node(*id).and_then(|n| n.visual_bounds()) else { continue }; + let Some(b) = d.node(*id).and_then(|n| n.visual_bounds_with(&symbols)) else { continue }; let Some((_, idx, _)) = d.position(top) else { continue }; selected.entry(layer).or_default().push((idx, b)); } @@ -253,7 +254,7 @@ fn above(s: &mut Session, lock: bool) -> Result { if under.is_empty() || roots.contains(&n.id) { continue; } - if n.visual_bounds().is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) { + if n.visual_bounds_with(&symbols).is_some_and(|nb| under.iter().any(|b| nb.intersect(*b).area() > 0.0 || nb.contains(b.center()))) { hits.insert(n.id); } } @@ -366,8 +367,11 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { } let ids = s.edit(label, |d, sel| { let targets = if copy { duplicate_in(d, sel, &roots, Affine::IDENTITY)? } else { roots.clone() }; - for id in &targets { - let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue }; + // Measure each object's geometric bounds first, with symbol instances measured by their art. + let symbols = d.symbol_boxes(); + let boxes: Vec> = targets.iter().map(|id| d.node(*id).and_then(|n| n.geometric_bounds_with(&symbols))).collect(); + for (id, b) in targets.iter().zip(boxes) { + let Some(b) = b else { continue }; let (mut k_sh, mut k_sv, mut k_mh, mut k_mv, mut k_rot) = (sh, sv, mh, mv, rot); if random { k_sh = 100.0 + (sh - 100.0) * rng.next(); @@ -898,9 +902,11 @@ fn convert_to_shape(s: &mut Session, _: &Value) -> Result { fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let n = s.edit("Convert to Artboards", |d, sel| { + // Measure each object's geometric bounds first, with symbol instances measured by their art. + let symbols = d.symbol_boxes(); + let boxes: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, d.node(*id)?.geometric_bounds_with(&symbols)?))).collect(); let mut converted = vec![]; - for id in &roots { - let Some(b) = d.node(*id).and_then(|n| n.geometric_bounds()) else { continue }; + for (id, b) in boxes { if b.width() <= 0.0 || b.height() <= 0.0 { continue; } @@ -912,7 +918,7 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { show_center_mark: false, show_cross_hairs: false, }); - converted.push(*id); + converted.push(id); } if converted.is_empty() { return Err(EngineError::Other("Convert to Artboards: selection has no area".into())); @@ -964,8 +970,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) { return Ok(json!({ "artboards": count, "rows": rows, "columns": cols })); } - // With moveArtwork, each top-level object moves with the artboard its center is on; locked and + // With moveArtwork, each top-level object moves with the artboard its center is on, with symbol + // instances measured by their art as `Document::art_on_artboard` measures them; locked and // hidden art moves only when prefs moveLockedWithArtboard is on. + let symbols = d.symbol_boxes(); let art: Vec<(NodeId, Vec2)> = if move_art { d.layers .iter() @@ -973,7 +981,7 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { .flat_map(|l| l.children().into_iter().flatten()) .filter(|n| n.rides_with_artboard(locked_and_hidden)) .filter_map(|n| { - let c = n.geometric_bounds()?.center(); + let c = n.geometric_bounds_with(&symbols)?.center(); let dl = *rects.iter().position(|r| r.contains(c)).and_then(|i| deltas.get(i))?; (dl != Vec2::ZERO).then_some((n.id, dl)) }) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index c456e9a59..7735b7522 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -259,9 +259,13 @@ fn all_on_artboard(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?; + // Symbol instances are measured by their art. + let symbols = st.doc.symbol_boxes(); let ids: Vec = selectable(&st.doc, st.isolation) .into_iter() - .filter(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds()).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center()))) + .filter(|id| { + st.doc.node(*id).and_then(|n| n.geometric_bounds_with(&symbols)).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center())) + }) .collect(); s.select(|_, sel| sel.set(ids.iter().copied()))?; ok() diff --git a/crates/engine/src/tests_symbolbounds.rs b/crates/engine/src/tests_symbolbounds.rs index b1a06f260..12dfb03a3 100644 --- a/crates/engine/src/tests_symbolbounds.rs +++ b/crates/engine/src/tests_symbolbounds.rs @@ -1,6 +1,8 @@ //! Symbol instances measured by their symbol's art: Align, the Transform panel and the bounding -//! box, Use Preview Bounds, Fit to Artwork Bounds, EPS export of the art, an artboard's SVG, Export -//! Selection and the raster effect images of exports. +//! box, Use Preview Bounds, Fit to Artwork Bounds, Rearrange, Convert to Artboards, All on Active +//! Artboard, Transform Each, Lock and Hide › All Artwork Above, EPS export of the art, an +//! artboard's SVG, Export Selection, copy and paste, library graphics and the raster effect images +//! of exports. use std::sync::Arc; @@ -316,6 +318,90 @@ fn fit_to_artwork_bounds_fits_the_instances_art() { assert!(close(Some(board), Rect::new(80.0, 0.0, 90.0, 10.0)), "{board:?}"); } +/// Returns a session whose document has artboard 1 from (0, 0) to (800, 600), artboard 2 from +/// (1000, 0) to (1400, 300), and an instance of a symbol whose art, a black rectangle from +/// (1100, 50) to (1140, 70), lies far from the symbol's origin, and returns the instance's id. With +/// the instance moved by (100, 100), the art, (1200, 150) to (1240, 170), is on artboard 2, and the +/// 20 × 20 square around the origin, (90, 90) to (110, 110), is on artboard 1. +fn far_on_artboard_2() -> (Session, NodeId) { + let mut d = Document::new(800.0, 600.0); + let rect = shapes::rectangle(Rect::new(1100.0, 50.0, 1140.0, 70.0)); + let art = Node::path(d.alloc_id(), rect, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + d.symbols.push(Symbol { name: "Far".into(), art: Arc::new(art) }); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap(); + let mut s = Session::new(); + s.add_document(d, None); + run(&mut s, "artboard.new", json!({"x": 1000, "y": 0, "width": 400, "height": 300})); + (s, id) +} + +/// Draws a 10 × 10 square at (`x`, `y`), on top, and returns its id. +fn square(s: &mut Session, x: f64, y: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": y, "width": 10, "height": 10}))["id"].as_u64().expect("the square")) +} + +fn bounds(s: &Session, ids: &[NodeId]) -> Option { + s.doc().unwrap().doc.bounds_of(ids, false) +} + +#[test] +fn rearrange_moves_an_instance_with_the_artboard_its_art_is_on() { + let (mut s, far) = far_on_artboard_2(); + run(&mut s, "artboard.rearrange", json!({"columns": 1, "spacing": 50})); + assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(0.0, 650.0, 400.0, 950.0)); + assert!(close(bounds(&s, &[far]), Rect::new(200.0, 800.0, 240.0, 820.0)), "{:?}", bounds(&s, &[far])); +} + +#[test] +fn all_on_active_artboard_selects_an_instance_on_the_artboard_its_art_is_on() { + let (mut s, far) = far_on_artboard_2(); + run(&mut s, "select.allOnArtboard", json!({"artboard": 1})); + assert_eq!(s.doc().unwrap().selection.objects, [far]); + run(&mut s, "select.allOnArtboard", json!({"artboard": 0})); + assert!(s.doc().unwrap().selection.objects.is_empty()); +} + +#[test] +fn convert_to_artboards_makes_an_artboard_of_the_instances_art() { + let (mut s, far) = far_on_artboard_2(); + run(&mut s, "select.set", json!({"ids": [far.0]})); + run(&mut s, "artboard.convertToArtboards", json!({})); + let board = s.doc().unwrap().doc.artboards[2].rect; + assert!(close(Some(board), Rect::new(1200.0, 150.0, 1240.0, 170.0)), "{board:?}"); +} + +#[test] +fn transform_each_scales_an_instance_about_the_center_of_its_art() { + let (mut s, far) = far_on_artboard_2(); + run(&mut s, "select.set", json!({"ids": [far.0]})); + run(&mut s, "object.transformEach", json!({"scaleH": 200, "scaleV": 200})); + assert!(close(bounds(&s, &[far]), Rect::new(1180.0, 140.0, 1260.0, 180.0)), "{:?}", bounds(&s, &[far])); +} + +#[test] +fn lock_and_hide_all_artwork_above_measure_the_instances_art() { + // Above the selected instance, the square on its art is locked and the square on the 20 × 20 + // square is not. + let (mut s, far) = far_on_artboard_2(); + let (on_art, on_square) = (square(&mut s, 1205.0, 155.0), square(&mut s, 95.0, 95.0)); + run(&mut s, "select.set", json!({"ids": [far.0]})); + run(&mut s, "object.lock.above", json!({})); + let node = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(node(&s, on_art).locked && !node(&s, on_square).locked); + // Brought in front of both squares, the instance is hidden above the square on its art only. + let (mut s, far) = far_on_artboard_2(); + let (on_art, on_square) = (square(&mut s, 1205.0, 155.0), square(&mut s, 95.0, 95.0)); + run(&mut s, "select.set", json!({"ids": [far.0]})); + run(&mut s, "object.arrange.bringToFront", json!({})); + run(&mut s, "select.set", json!({"ids": [on_square.0]})); + run(&mut s, "object.hide.above", json!({})); + assert!(node(&s, far).visible, "the instance's art does not overlap the selected square"); + run(&mut s, "select.set", json!({"ids": [on_art.0]})); + run(&mut s, "object.hide.above", json!({})); + assert!(!node(&s, far).visible); +} + #[test] fn eps_of_the_art_bounds_and_writes_an_instances_art() { let (mut s, _) = open(FAR); @@ -397,6 +483,64 @@ fn export_selection_crops_to_the_instances_art() { assert_eq!(bounds, [100.0, 60.0, 40.0, 20.0], "{v}"); } +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().filter_map(Value::as_u64).map(NodeId).collect() +} + +#[test] +fn a_copied_instance_comes_from_the_artboard_its_art_is_on() { + let (mut s, far) = far_on_artboard_2(); + run(&mut s, "select.set", json!({"ids": [far.0]})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard.source_artboard, Some(Rect::new(1000.0, 0.0, 1400.0, 300.0))); + // Pasted in place onto artboard 2, it lands where it was. + let pasted = ids(&run(&mut s, "edit.pasteInPlace", json!({"artboard": 1}))); + assert!(close(bounds(&s, &pasted), Rect::new(1200.0, 150.0, 1240.0, 170.0)), "{:?}", bounds(&s, &pasted)); +} + +#[test] +fn a_copied_instances_png_is_cropped_to_its_art() { + let (mut s, far) = far_on_artboard_2(); + run(&mut s, "select.set", json!({"ids": [far.0]})); + run(&mut s, "edit.copy", json!({})); + let r = run(&mut s, "clipboard.exportPng", json!({})); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(40), Some(20))); + let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.get_pixel(20, 10).0, [0, 0, 0, 255], "the art fills the picture"); +} + +#[test] +fn an_instance_pasted_at_a_point_has_its_art_centered_there() { + let (mut s, far) = far_on_artboard_2(); + run(&mut s, "select.set", json!({"ids": [far.0]})); + run(&mut s, "edit.copy", json!({})); + let pasted = ids(&run(&mut s, "edit.paste", json!({"center": [300, 200]}))); + assert!(close(bounds(&s, &pasted), Rect::new(280.0, 190.0, 320.0, 210.0)), "{:?}", bounds(&s, &pasted)); +} + +#[test] +fn svg_markup_on_the_clipboard_is_centered_by_the_instances_art() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 300})); + run(&mut s, "clipboard.importSvg", json!({"dataBase64": vectorcraft_format::base64_encode(FAR.as_bytes()), "center": [200, 150]})); + let pasted = ids(&run(&mut s, "edit.pasteInPlace", json!({}))); + assert!(close(bounds(&s, &pasted), Rect::new(180.0, 140.0, 220.0, 160.0)), "{:?}", bounds(&s, &pasted)); +} + +#[test] +fn a_library_graphic_of_an_instance_is_measured_by_its_art() { + let (mut s, far) = far_on_artboard_2(); + run(&mut s, "select.set", json!({"ids": [far.0]})); + let added = run(&mut s, "library.add", json!({"kind": "graphic"})); + let (lib, item) = (added["library"].clone(), added["id"].clone()); + let g = &run(&mut s, "library.get", json!({"library": lib}))["graphics"][0]; + assert_eq!((g["width"].as_f64(), g["height"].as_f64()), (Some(40.0), Some(20.0)), "{g}"); + run(&mut s, "file.new", json!({"width": 300, "height": 300})); + let placed = ids(&run(&mut s, "library.use", json!({"library": lib, "kind": "graphic", "item": item, "center": [150, 100]}))); + assert!(close(bounds(&s, &placed), Rect::new(130.0, 90.0, 170.0, 110.0)), "{:?}", bounds(&s, &placed)); +} + #[test] fn a_groups_shadow_image_covers_the_art_of_the_instance_in_it() { let (mut s, far) = open(FAR); diff --git a/docs/mcp.md b/docs/mcp.md index eef40077d..fe4fdd0f5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1704,10 +1704,17 @@ limit. A rectangle's right-angle miters stay within half the weight of its edges A symbol instance is measured by its symbol's art, placed by the instance's transform. The bounds in `document.inspect` and `document.node`, the Align commands, `object.setBounds`, the bounding box, clicks, marquees, -Fit to Artwork Bounds and exports of the art use that box. Visual bounds (Use Preview Bounds, Fit to Selected Art) -take in the strokes in the art at their own weight, as the canvas draws them when the instance scales the art. An -instance is measured as the 20 × 20 square around the origin, which symbols made in the app keep their art in, when -its symbol is missing, when its symbol or a symbol nested in its art holds an instance of itself, and when symbols +Fit to Artwork Bounds and exports of the art use that box. So do `artboard.rearrange` and `artboard.move` (which +artboard the art moves with), `artboard.convertToArtboards`, `select.allOnArtboard`, `object.transformEach`, +`object.lock.above` and `object.hide.above`. `edit.copy` and `edit.cut` record the artboard under the center of that +box as the one the copy comes from, and `edit.pasteInPlace`, `edit.pasteInFront`, `edit.pasteInBack` and +`edit.pasteOnAllArtboards` keep the copy's offset to it. `edit.paste {center}`, `library.use {center}` and the +`clipboard.import*` commands center that box on the point, and `library.add` gives a graphic the size of that box. +`clipboard.exportPng`, `clipboard.exportPdf` and the PNG and PDF that Copy offers other apps are cropped to that box. +Visual bounds (Use Preview Bounds, Fit to Selected Art) take in the strokes in the art at their own weight, as the +canvas draws them when the instance scales the art. An instance is measured as the 20 × 20 +square around the origin, which symbols made in the app keep their art in, when its symbol is missing, +when its symbol or a symbol nested in its art holds an instance of itself, and when symbols nest 32 deep in its art. PDF and EPS export and the SVG of an artboard leave out art outside the page or artboard, and Skip Blank Artboards skips an artboard with no art on it. For both, the box of an instance whose art holds brushed strokes grows by 16 times the widest stroke (at least 1 pt) of the objects in its art that have a brushed stroke,