mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user