Merge pull request #2614 from dotCooCoo/fix/artboard-art-symbol-bounds

Artboards: Rearrange and Select All on Active Artboard measure a symbol instance by its art
This commit is contained in:
BFlat
2026-10-17 17:12:16 +07:00
committed by GitHub
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,