mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:12:11 +00:00
The enabled check of artboard.deleteEmpty built the full list of empty artboards, measuring the visual bounds of the art against every artboard, each time the Artboards panel menu was drawn. It now returns false at once in a document with one artboard (that artboard is never deleted). With two or more, it asks the new Document::any_artboard_without_art whether any unlocked artboard has no art, which is the same answer as a non-empty list. Document::any_artboard_without_art builds the symbol boxes once for all the artboards and first tests each path and compound path by a quick box: its control points' box grown by the farthest its strokes reach (Appearance::outset), with a margin for rounding. An artboard that no quick box meets is reported empty without measuring along any path. Only when every artboard may hold art is each one measured. Document::artboard_has_art also skips measuring an object whose quick box misses the artboard; its answers are unchanged. The check of gradient.presets.save found the proxy paints through Node::proxy_paint, which measures the object's geometric bounds for a gradient box the check does not use. Node::proxy_paint_only picks the same paint without measuring, and the engine's proxy_paint uses it, so the Fill and Stroke proxies and the mixed-paint test no longer measure each object either. With an open stroked path of 200,000 anchors selected, the two checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build, and Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms with a second, empty artboard. Tests compare each check with what its command does (one artboard, all empty, one empty, locked empty, nested art; with and without a gradient), compare any_empty_artboards with the previous check and with measuring every object on 120 seeded documents, compare the quick boxes with the visual bounds on seeded strokes, and compare proxy_paint_only with proxy_paint on seeded objects. Docs: docs/target-app-parity.md, one line under Delete Empty Artboards.
366 lines
18 KiB
Rust
366 lines
18 KiB
Rust
use super::document_ext::free_id;
|
||
use super::guides::Guide;
|
||
use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds};
|
||
|
||
impl Document {
|
||
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
|
||
/// instances measured by their art ([`Node::geometric_bounds_with`]).
|
||
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
|
||
self.bounds_of_with(ids, visual, &self.symbol_boxes())
|
||
}
|
||
|
||
/// [`bounds_of`](Document::bounds_of) measuring symbol instances from `symbols`, a cache shared
|
||
/// across calls: measuring many instances one by one otherwise measures their art again for
|
||
/// each.
|
||
pub fn bounds_of_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option<Rect> {
|
||
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
|
||
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) })
|
||
})
|
||
}
|
||
|
||
/// Visual bounds of all art, with symbol instances measured by their art.
|
||
pub fn art_bounds(&self) -> Option<Rect> {
|
||
let symbols = self.symbol_boxes();
|
||
self.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, l.visual_bounds_with(&symbols)))
|
||
}
|
||
|
||
/// Artboard index containing point `p` (topmost = last), its edges included
|
||
/// ([`Document::artboard_holding`]).
|
||
pub fn artboard_at(&self, p: Point) -> Option<usize> {
|
||
self.artboard_holding(p, true)
|
||
}
|
||
|
||
/// Index of the artboard that holds point `p`: the first (the last when `topmost`) that holds it
|
||
/// inside or on its left or top edge, else the first (the last) that holds it by
|
||
/// [`artboard_holds`]: on its right or bottom edge, or within a millionth of a point outside an
|
||
/// edge. On an edge two artboards share, the artboard to the right of it or below it holds the
|
||
/// point, except at the end of the edge that lies on that artboard's bottom or right edge, such
|
||
/// as the point where both artboards' bottom edges meet; there the first (the last) holds it.
|
||
pub fn artboard_holding(&self, p: Point, topmost: bool) -> Option<usize> {
|
||
let pick = |on: &dyn Fn(Rect) -> bool| {
|
||
let on = |i: &usize| self.artboards.get(*i).is_some_and(|a| on(a.rect));
|
||
let mut all = 0..self.artboards.len();
|
||
if topmost { all.rfind(on) } else { all.find(on) }
|
||
};
|
||
pick(&|r| r.contains(p)).or_else(|| pick(&|r| artboard_holds(r, p)))
|
||
}
|
||
|
||
/// The art that moves with an artboard at `rect`: top-level objects (children of layers and
|
||
/// sublayers) lying entirely inside it, with symbol instances measured by their art. Locked and
|
||
/// hidden objects and layers stay put unless `locked_and_hidden` (Selection & Anchor Display ›
|
||
/// Move Locked and Hidden Artwork with Artboard).
|
||
pub fn art_on_artboard(&self, rect: Rect, locked_and_hidden: bool) -> Vec<NodeId> {
|
||
// Art drawn flush with the artboard can end up a rounding error outside it (a background
|
||
// 3e-14 pt past the top edge, #1295): a millionth of a point is far below anything visible.
|
||
let rect = rect.inflate(1e-6, 1e-6);
|
||
fn collect(n: &Node, s: &SymbolBoxes, rect: Rect, all: bool, out: &mut Vec<NodeId>) {
|
||
for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) {
|
||
if c.is_layer() {
|
||
collect(c, s, rect, all, out);
|
||
} else if let Some(b) = c.geometric_bounds_with(s)
|
||
// Inside it, edges included: art flush with the right or bottom edge is on it too
|
||
// (`Rect::contains` leaves those out, #890).
|
||
&& b.x0 >= rect.x0
|
||
&& b.y0 >= rect.y0
|
||
&& b.x1 <= rect.x1
|
||
&& b.y1 <= rect.y1
|
||
{
|
||
out.push(c.id);
|
||
}
|
||
}
|
||
}
|
||
let symbols = self.symbol_boxes();
|
||
let mut art = vec![];
|
||
for l in self.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) {
|
||
collect(l, &symbols, rect, locked_and_hidden, &mut art);
|
||
}
|
||
art
|
||
}
|
||
|
||
/// Does any artwork lie on `rect` (an artboard)? Artwork is any object under the layers,
|
||
/// hidden and locked ones and those on template layers included, whose visual bounds (symbol
|
||
/// instances measured by their art) meet `rect`; touching its edge counts. A group (not a clip
|
||
/// group) counts by its members, so one whose members sit on two other artboards leaves the one
|
||
/// between them empty. A clip group or clipping layer counts by its members inside its clip (and
|
||
/// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork
|
||
/// (Delete Empty Artboards).
|
||
pub fn artboard_has_art(&self, rect: Rect) -> bool {
|
||
self.art_meets(&self.symbol_boxes(), rect, true)
|
||
}
|
||
|
||
/// Is there a rect in `rects` (artboards) with no artwork on it, by [`Document::artboard_has_art`]?
|
||
/// The symbols are measured once for all of them. A first pass rules art out by a quick box
|
||
/// (paths and compound paths: their control points grown by the farthest their strokes reach),
|
||
/// so a rect away from every path is found without measuring along any; only when every rect
|
||
/// may hold art is each one measured.
|
||
pub fn any_artboard_without_art(&self, rects: impl IntoIterator<Item = Rect>) -> bool {
|
||
let rects: Vec<Rect> = rects.into_iter().collect();
|
||
let symbols = self.symbol_boxes();
|
||
rects.iter().any(|r| !self.art_meets(&symbols, *r, false)) || rects.iter().any(|r| !self.art_meets(&symbols, *r, true))
|
||
}
|
||
|
||
/// [`Document::artboard_has_art`] of `rect` when `exact`. Otherwise whether art may lie on it:
|
||
/// paths and compound paths count when their quick box meets it, so a false answer is exact and
|
||
/// a true one may not be.
|
||
fn art_meets(&self, symbols: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
|
||
// As in `art_on_artboard`: a millionth of a point covers rounding at the edges.
|
||
let rect = rect.inflate(1e-6, 1e-6);
|
||
fn meets(b: Option<Rect>, r: Rect) -> bool {
|
||
b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)
|
||
}
|
||
/// A box that holds the visual bounds of a path, or of a compound path of paths, found
|
||
/// without measuring along its shape: its control points' box grown by the farthest its
|
||
/// strokes reach ([`crate::Appearance::outset`], as in [`Node::reach_bounds`]), with a
|
||
/// margin for rounding. None for other objects, and when the reach is not finite.
|
||
fn quick_box(c: &Node) -> Option<Rect> {
|
||
let b = match &c.kind {
|
||
NodeKind::Path { path, .. } => path.control_bounds()?,
|
||
NodeKind::Compound { children, .. } => children.iter().try_fold(None, |acc, m| {
|
||
let NodeKind::Path { path, .. } = &m.kind else { return None };
|
||
Some(vectorcraft_geom::union_opt(acc, path.control_bounds()))
|
||
})??,
|
||
_ => return None,
|
||
};
|
||
let o = c.appearance.outset();
|
||
let o = o * (1.0 + 1e-9) + 1e-6;
|
||
o.is_finite().then(|| b.inflate(o, o))
|
||
}
|
||
/// Does `c`'s visual box meet `rect`? Without `exact`, a quick box that meets it answers yes.
|
||
fn art_box_meets(c: &Node, s: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
|
||
match quick_box(c).map(|b| meets(Some(b), rect)) {
|
||
Some(false) => false,
|
||
Some(true) if !exact => true,
|
||
_ => meets(c.visual_bounds_with(s), rect),
|
||
}
|
||
}
|
||
fn has(c: &Node, s: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
|
||
match &c.kind {
|
||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||
let Some((clip, rest)) = children.split_first() else { return false };
|
||
let paint = clip.clip_paint();
|
||
if (paint.fill.is_some() || paint.stroke.is_some()) && art_box_meets(clip, s, rect, exact) {
|
||
return true;
|
||
}
|
||
if !exact {
|
||
// The part of `rect` inside the clip's box lies inside `rect`.
|
||
return rest.iter().any(|m| has(m, s, rect, false));
|
||
}
|
||
// The members show only inside the clip's box.
|
||
let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false };
|
||
let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1));
|
||
rest.iter().any(|m| has(m, s, inside, true))
|
||
}
|
||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)),
|
||
NodeKind::Path { guide: true, .. } => false,
|
||
_ => art_box_meets(c, s, rect, exact),
|
||
}
|
||
}
|
||
self.layers.iter().any(|l| has(l, symbols, rect, exact))
|
||
}
|
||
|
||
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
|
||
/// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as
|
||
/// locked art does.
|
||
pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec<NodeId> {
|
||
let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] };
|
||
let mut art = self.art_on_artboard(rect, locked_and_hidden);
|
||
let locks = self.locked_artboards_except(index);
|
||
if !locked_and_hidden && !locks.is_empty() {
|
||
art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n)));
|
||
}
|
||
art
|
||
}
|
||
|
||
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
|
||
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
|
||
/// artboard is gone.
|
||
pub fn guide_span(&self, g: &Guide) -> Option<(f64, f64)> {
|
||
let r = self.artboards.iter().find(|a| Some(a.id) == g.artboard)?.rect;
|
||
Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })
|
||
}
|
||
|
||
/// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has.
|
||
fn guide_layer(&self, g: &Guide) -> Option<NodeId> {
|
||
g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer))
|
||
}
|
||
|
||
/// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is
|
||
/// visible? A guide whose layer is gone shows as one on no layer.
|
||
pub fn guide_shown(&self, g: &Guide) -> bool {
|
||
self.guide_layer(g).is_none_or(|l| self.is_visible(l))
|
||
}
|
||
|
||
/// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked?
|
||
pub fn guide_editable(&self, g: &Guide) -> bool {
|
||
self.guide_layer(g).is_none_or(|l| self.is_editable(l))
|
||
}
|
||
|
||
/// Put the ruler guides on no layer this document has (from files saved before guides had
|
||
/// layers, or of layers merged into `layer`) on layer `layer`. Returns how many.
|
||
pub fn rehome_guides(&mut self, layer: NodeId) -> usize {
|
||
let lost: Vec<bool> = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect();
|
||
let mut n = 0;
|
||
for (g, lost) in self.guides.iter_mut().zip(lost) {
|
||
if lost {
|
||
g.layer = Some(layer);
|
||
n += 1;
|
||
}
|
||
}
|
||
n
|
||
}
|
||
|
||
/// Put the ruler guides on no layer (from files saved before guides had layers), or on one that
|
||
/// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many.
|
||
pub fn adopt_guides(&mut self) -> usize {
|
||
self.default_layer().map_or(0, |home| self.rehome_guides(home))
|
||
}
|
||
|
||
/// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in
|
||
/// step. Guides on no layer stay. Returns how many went.
|
||
pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize {
|
||
let gone: Vec<bool> = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect();
|
||
self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false))
|
||
}
|
||
|
||
/// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made
|
||
/// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies
|
||
/// the objects on it. Returns how many.
|
||
pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize {
|
||
let layers = |n: &Node| {
|
||
let mut out = vec![];
|
||
n.walk(&mut |c| {
|
||
if c.is_layer() {
|
||
out.push(c.id);
|
||
}
|
||
});
|
||
out
|
||
};
|
||
let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect();
|
||
let copies: Vec<Guide> = self
|
||
.guides
|
||
.iter()
|
||
.filter_map(|g| {
|
||
let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1;
|
||
Some(Guide { layer: Some(to), ..g.clone() })
|
||
})
|
||
.collect();
|
||
let n = copies.len();
|
||
self.guides.extend(copies);
|
||
n
|
||
}
|
||
|
||
/// The ruler guides on layer `layer` (not its sublayers'), by index.
|
||
pub fn guides_on(&self, layer: NodeId) -> impl Iterator<Item = (usize, &Guide)> + '_ {
|
||
self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer))
|
||
}
|
||
|
||
/// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)?
|
||
pub fn guide_passes(&self, g: &Guide, p: Point, tol: f64) -> bool {
|
||
let along = if g.vertical { p.y } else { p.x };
|
||
self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol)
|
||
}
|
||
|
||
/// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their
|
||
/// place among the rest (the selected art stays selected). Returns how many went.
|
||
pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize {
|
||
let kept: Vec<bool> = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect();
|
||
// Where each kept guide ends up.
|
||
let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0);
|
||
for k in &kept {
|
||
to.push(k.then_some(n));
|
||
n += usize::from(*k);
|
||
}
|
||
sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect();
|
||
sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten());
|
||
let before = self.guides.len();
|
||
let mut flags = kept.into_iter();
|
||
self.guides.retain(|_| flags.next().unwrap_or(true));
|
||
before - self.guides.len()
|
||
}
|
||
|
||
/// Move the guides of artboard `id` by `d` (along with their artboard).
|
||
pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) {
|
||
for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) {
|
||
*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) {
|
||
let copies: Vec<Guide> =
|
||
self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect();
|
||
self.guides.extend(copies);
|
||
}
|
||
|
||
/// An id for a new artboard: one no artboard has, above the ids given out before and the ids of
|
||
/// the artboards deleted from the document ([`Document::retired_artboard_id`]). The id counts
|
||
/// as given out, so an artboard made after an undo does not take the id of one the undo took
|
||
/// away.
|
||
pub fn next_artboard_id(&self) -> u32 {
|
||
let id = free_id(self.artboards.iter().map(|a| a.id).chain([self.retired_artboard_id.get()]));
|
||
self.retired_artboard_id.raise(id);
|
||
id
|
||
}
|
||
|
||
/// Record that the artboard with `id` was deleted: no later artboard takes its id.
|
||
pub fn retire_artboard_id(&self, id: u32) {
|
||
self.retired_artboard_id.raise(id);
|
||
}
|
||
|
||
pub fn pattern(&self, name: &str) -> Option<&PatternDef> {
|
||
self.patterns.iter().find(|p| p.name == name)
|
||
}
|
||
|
||
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
|
||
self.patterns.iter_mut().find(|p| p.name == name)
|
||
}
|
||
|
||
/// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken
|
||
/// in ([`Self::adopt_pixels`]).
|
||
pub fn reid(&mut self, node: &Node) -> Node {
|
||
let mut n = node.clone();
|
||
n.id = self.alloc_id();
|
||
if let NodeKind::Image(im) = &mut n.kind {
|
||
self.adopt_image_pixels(im);
|
||
}
|
||
// A copied annotation must never bind its source ids to unrelated art in a different
|
||
// document. Keep the measured geometry; new measurements can be linked after pasting.
|
||
if let Some(e) = n.extras_if_any()
|
||
&& let Some(dimension) = &mut e.dimension
|
||
{
|
||
dimension.source = None;
|
||
}
|
||
// A copy of a threaded frame is in no thread, so its first paragraph starts in it and its
|
||
// layout caches are dropped. A caller that keeps the copy as an object fills them again.
|
||
if let NodeKind::Text(t) = &mut n.kind
|
||
&& t.first_para_continues
|
||
{
|
||
t.first_para_continues = false;
|
||
t.cached_bounds = None;
|
||
t.cached_baselines.clear();
|
||
}
|
||
let mut renamed = std::collections::HashMap::new();
|
||
if let Some(ch) = n.children_mut() {
|
||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||
}
|
||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||
if let Some(e) = n.extras_if_any()
|
||
&& let Some(on_path) = &mut e.objects_on_path
|
||
{
|
||
on_path.rename(&renamed);
|
||
}
|
||
n
|
||
}
|
||
}
|