Artboards: Rearrange and Select All on Active Artboard measure a symbol instance by its art

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.
This commit is contained in:
dotCooCoo
2026-10-17 16:38:43 +07:00
committed by bflatastic
parent 1a9509b776
commit 67b47c7262
5 changed files with 189 additions and 20 deletions
+9 -3
View File
@@ -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<Rect> {
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
+18 -10
View File
@@ -224,8 +224,9 @@ fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
fn above(s: &mut Session, lock: bool) -> Result<Value> {
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<NodeId, Vec<(usize, Rect)>> = 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<Value> {
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<Value> {
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<Value> {
}
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<Option<Rect>> = 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<Value> {
fn convert_to_artboards(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
.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))
})
+5 -1
View File
@@ -259,9 +259,13 @@ fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = 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()
+146 -2
View File
@@ -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<Rect> {
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<NodeId> {
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);
+11 -4
View File
@@ -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,