Menus: Delete Empty Artboards and Save Gradient Preset decide their enabled state with less measuring

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.
This commit is contained in:
dotCooCoo
2026-11-05 06:55:59 +07:00
committed by bflatastic
parent 8f8f71f803
commit ea75150131
7 changed files with 623 additions and 12 deletions
+53 -7
View File
@@ -85,31 +85,77 @@ impl Document {
/// 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)
}
fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool {
/// 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()) && meets(clip.visual_bounds_with(s), rect) {
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))
rest.iter().any(|m| has(m, s, inside, true))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)),
NodeKind::Path { guide: true, .. } => false,
_ => meets(c.visual_bounds_with(s), rect),
_ => art_box_meets(c, s, rect, exact),
}
}
let symbols = self.symbol_boxes();
self.layers.iter().any(|l| has(l, &symbols, rect))
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`]
+19
View File
@@ -606,6 +606,25 @@ impl Node {
}
}
/// The paint behind the Fill (or Stroke) proxy, the one [`Node::proxy_paint`] gives, without its
/// map and box: the object is not measured, so a long path costs no more than a short one.
pub fn proxy_paint_only(&self, stroke: bool, item: Option<usize>) -> Option<&vectorcraft_color::Paint> {
let item = self.appearance.item_of_kind(item, !stroke);
if item.is_none()
&& !stroke
&& let Some(c) = self.extras().colorize.as_deref()
{
return Some(c);
}
if item.is_none()
&& let NodeKind::Text(t) = &self.kind
{
let st = &t.runs.first()?.style;
return Some(if stroke { &st.stroke } else { &st.fill });
}
if stroke { self.appearance.stroke_at(item).map(|s| &s.paint) } else { self.appearance.fill_at(item).map(|f| &f.paint) }
}
/// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates
/// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits.
pub fn proxy_gradient(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> {
+157 -1
View File
@@ -945,9 +945,17 @@ pub(crate) fn empty_artboards(d: &Document) -> Vec<usize> {
empty
}
/// Is [`empty_artboards`] of `d` not empty? A document with one artboard keeps it, so there is
/// nothing to delete; with two or more, any unlocked artboard without art settles it (when every
/// artboard is empty, all but artboard 1 go). [`Document::any_artboard_without_art`] finds an
/// artboard away from every path without measuring along the paths.
fn any_empty_artboards(d: &Document) -> bool {
d.artboards.len() > 1 && d.any_artboard_without_art(d.artboards.iter().filter(|a| !a.locked).map(|a| a.rect))
}
fn has_empty_artboards(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if empty_artboards(&st.doc).is_empty() { Err("no empty artboards to delete".into()) } else { Ok(()) }
if any_empty_artboards(&st.doc) { Ok(()) } else { Err("no empty artboards to delete".into()) }
}
fn artboard_delete_empty(s: &mut Session, _: &Value) -> Result<Value> {
@@ -1164,3 +1172,151 @@ fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, LineCap, LineJoin, StrokeAlign, StrokeLayer, SymbolBoxes};
use super::*;
/// Does art lie on `rect`, measuring every object's visual bounds: the rule of
/// [`Document::artboard_has_art`] with no quick box in front of it.
fn measured_has_art(d: &Document, rect: Rect) -> bool {
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)
}
fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> 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()) && meets(clip.visual_bounds_with(s), rect) {
return true;
}
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))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)),
NodeKind::Path { guide: true, .. } => false,
_ => meets(c.visual_bounds_with(s), rect),
}
}
let symbols = d.symbol_boxes();
d.layers.iter().any(|l| has(l, &symbols, rect))
}
/// Would Delete Empty Artboards delete something, by [`measured_has_art`] of each artboard:
/// the unlocked artboards without art, less artboard 1 when that is every artboard.
fn measured_any_empty(d: &Document) -> bool {
let mut empty: Vec<usize> =
d.artboards.iter().enumerate().filter(|(_, a)| !a.locked && !measured_has_art(d, a.rect)).map(|(i, _)| i).collect();
if empty.len() == d.artboards.len() {
empty.retain(|&i| i != 0);
}
!empty.is_empty()
}
/// On documents made from a fixed seed (one to five artboards placed anywhere, overlapping
/// some of the time, some locked; rectangles, long curved paths and symbol instances, some
/// with wide strokes reaching past their shape, some grouped or clipped), the menu's check
/// [`any_empty_artboards`] gives the same answer as `!empty_artboards(d).is_empty()` and as
/// measuring every object against every artboard, and both answers come up.
#[test]
fn the_empty_artboard_check_matches_measuring_every_artboard() {
let mut seed: u64 = 0x0a2d_5eed_e4b7;
let mut next = |n: u64| {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 33) % n
};
let (mut enabled, mut disabled_with_art) = (0, 0);
for _ in 0..120 {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 150})).unwrap();
for _ in 0..next(5) {
let (x, y) = (next(1400) as f64 - 100.0, next(800) as f64 - 100.0);
s.execute("artboard.new", &json!({"x": x, "y": y, "width": 20 + next(300), "height": 20 + next(200)})).unwrap();
}
let artboards = s.doc().unwrap().doc.artboards.len();
for i in 0..artboards {
if next(5) == 0 {
s.execute("artboard.setProps", &json!({"index": i, "locked": true})).unwrap();
}
}
let mut symbol = false;
let mut ids = vec![];
for _ in 0..next(6) {
let id = match next(4) {
0 => {
let (segments, span) = (20 + next(80), 10 + next(700));
let (x, y) = (next(1400) as i64 - 100, next(800) as i64 - 100);
let mut pt = || format!("{} {}", x + next(span) as i64, y + next(span) as i64);
let mut d = format!("M {}", pt());
for _ in 0..segments {
d += &format!(" C {} {} {}", pt(), pt(), pt());
}
s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap()
}
1 => {
let (x, y) = (next(1400) as f64 - 100.0, next(800) as f64 - 100.0);
if !symbol {
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5 + next(60), "height": 5 + next(60)})).unwrap();
s.execute("symbol.new", &json!({"name": "Mark", "ids": [r["id"]]})).unwrap();
s.execute("object.delete", &json!({"ids": [r["id"]]})).ok();
symbol = true;
}
s.execute("symbol.place", &json!({"name": "Mark", "x": x, "y": y})).unwrap()["id"].as_u64().unwrap()
}
_ => {
let (x, y) = (next(1400) as f64 - 100.0, next(800) as f64 - 100.0);
let r = json!({"x": x, "y": y, "width": 1 + next(150), "height": 1 + next(150)});
s.execute("shape.rectangle", &r).unwrap()["id"].as_u64().unwrap()
}
};
if next(2) == 0 {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 1.0 + next(120) as f64);
st.join = [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel][next(3) as usize];
st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize];
st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize];
st.miter_limit = 1.0 + next(20) as f64;
let look = Appearance { items: vec![AppearanceItem::Stroke(st)], ..Appearance::default() };
s.edit("Strokes", |d, _| {
d.node_mut(NodeId(id)).unwrap().appearance = look.into();
Ok(())
})
.unwrap();
}
ids.push(id);
}
if ids.len() > 1 {
match next(3) {
0 => {
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("object.group", &json!({})).unwrap();
}
1 => {
let (x, y) = (next(1400) as f64 - 100.0, next(800) as f64 - 100.0);
let clip = json!({"x": x, "y": y, "width": 1 + next(400), "height": 1 + next(300)});
let clip = s.execute("shape.rectangle", &clip).unwrap()["id"].as_u64().unwrap();
let members: Vec<u64> = ids.iter().copied().chain([clip]).collect();
s.execute("select.set", &json!({"ids": members})).unwrap();
s.execute("object.clippingMask.make", &json!({})).unwrap();
}
_ => {}
}
}
let d = &s.doc().unwrap().doc;
let measured = measured_any_empty(d);
let at = || d.artboards.iter().map(|a| (a.rect, a.locked)).collect::<Vec<_>>();
assert_eq!(any_empty_artboards(d), measured, "{:?}", at());
assert_eq!(!empty_artboards(d).is_empty(), measured, "{:?}", at());
enabled += usize::from(measured);
let several = d.artboards.len() > 1 && !ids.is_empty();
disabled_with_art += usize::from(several && !measured);
}
assert!(enabled > 15 && enabled < 105, "both answers come up: {enabled} of 120 enabled");
assert!(disabled_with_art > 3, "documents with several artboards and art come up disabled: {disabled_with_art}");
}
}
+4 -4
View File
@@ -206,11 +206,11 @@ impl Session {
}
}
/// The fill or stroke a proxy shows for `n` ([`Node::proxy_paint`]: fill or stroke `item` when it
/// is of that kind, type's first run, else the topmost one).
/// The fill or stroke a proxy shows for `n` ([`Node::proxy_paint_only`]: fill or stroke `item` when
/// it is of that kind, type's first run, else the topmost one).
pub(crate) fn proxy_paint(n: &Node, stroke: bool, item: Option<usize>) -> Paint {
match n.proxy_paint(stroke, item) {
Some((p, ..)) => p.clone(),
match n.proxy_paint_only(stroke, item) {
Some(p) => p.clone(),
// Type without runs shows the default character style.
None if matches!(n.kind, NodeKind::Text(_)) => {
let st = CharStyle::default();
+177
View File
@@ -137,6 +137,183 @@ fn saving_needs_a_gradient_behind_the_proxy() {
assert!(s.execute("gradient.presets.save", &json!({"name": 7})).is_err(), "a name is text");
}
/// Save Gradient Preset is enabled exactly when saving works, with and without a gradient behind
/// the active proxy: the defaults with nothing selected, a fill or a stroke, a group's first
/// painted member, type and an Appearance panel row.
#[test]
fn the_save_check_matches_saving_with_and_without_a_gradient() {
/// What the case is, how its session is made, and whether a gradient is behind the proxy.
type Case = (&'static str, fn() -> Session, bool);
let cases: [Case; 9] = [
(
"default solid paint",
|| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
s
},
false,
),
(
"default gradient paint",
|| {
let (mut s, _) = session_with(json!({"gradient": {}}));
s.execute("select.none", &json!({})).unwrap();
s
},
true,
),
("solid fill", || session_with(json!({"color": "#336699"})).0, false),
("gradient fill", || session_with(json!({"gradient": {"kind": "radial"}})).0, true),
(
"gradient fill, Stroke proxy in front",
|| {
let (mut s, _) = session_with(json!({"gradient": {}}));
s.execute("paint.toggleActive", &json!({"fill": false})).unwrap();
s
},
false,
),
(
"gradient stroke, Stroke proxy in front",
|| {
let (mut s, _) = session_with(json!({"color": "#336699"}));
s.execute("paint.setStroke", &json!({"gradient": {}, "focus": false})).unwrap();
s.execute("paint.toggleActive", &json!({"fill": false})).unwrap();
s
},
true,
),
(
"group of a gradient and a solid",
|| {
let (mut s, a) = session_with(json!({"gradient": {}}));
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s
},
true,
),
(
"type with a gradient fill",
|| {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
s.execute("text.create", &json!({"x": 10, "y": 100, "text": "type"})).unwrap();
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
s
},
true,
),
(
"a solid fill row active under a gradient fill",
|| {
let (mut s, _) = session_with(json!({"color": "#336699"}));
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("paint.setFill", &json!({"gradient": {}, "item": null})).unwrap();
let items = &s.doc().unwrap().doc.node(s.doc().unwrap().selection.objects[0]).unwrap().appearance.items;
let solid = items
.iter()
.position(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(f) if matches!(f.paint, Paint::Solid { .. })))
.unwrap();
s.execute("appearance.setActiveItem", &json!({"index": solid})).unwrap();
s
},
false,
),
];
for (why, make, gradient) in cases {
let mut s = make();
let enabled = (crate::find_command("gradient.presets.save").unwrap().enabled)(&s).is_ok();
assert_eq!((enabled, s.execute("gradient.presets.save", &json!({})).is_ok()), (gradient, gradient), "{why}");
}
}
/// [`Node::proxy_paint_only`] picks the paint [`Node::proxy_paint`] does, for fills and strokes,
/// with and without an Appearance panel row, on paths with several fills, type, an empty path
/// and a colorized object.
#[test]
fn the_unmeasured_proxy_paint_is_the_measured_one() {
let (mut s, id) = session_with(json!({"gradient": {}}));
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("appearance.addStroke", &json!({})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let plain = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
let colorized = NodeId(s.execute("shape.rectangle", &json!({"x": 40, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
s.edit("Colorize", |d, _| {
d.node_mut(colorized).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0))));
Ok(())
})
.unwrap();
let text = NodeId(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "type"})).unwrap()["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
let mut nodes: Vec<vectorcraft_doc::Node> = [id, plain, colorized, text].iter().map(|i| d.node(*i).unwrap().clone()).collect();
nodes.push(vectorcraft_doc::Node::path(NodeId(9999), Default::default(), (*d.node(plain).unwrap().appearance).clone()));
let mut runless = d.node(text).unwrap().clone();
if let vectorcraft_doc::NodeKind::Text(t) = &mut runless.kind {
t.runs.clear();
}
nodes.push(runless);
for n in &nodes {
for stroke in [false, true] {
for item in [None, Some(0), Some(1), Some(2), Some(3), Some(9)] {
assert_eq!(n.proxy_paint_only(stroke, item), n.proxy_paint(stroke, item).map(|p| p.0), "{:?} stroke {stroke} item {item:?}", n.id);
}
}
}
}
/// On objects made from a fixed seed (paths, an empty path, type with and without runs, each
/// with zero to four fills and strokes of none, a solid color or a gradient, some colorized),
/// [`Node::proxy_paint_only`] picks the paint [`Node::proxy_paint`] does for every row.
#[test]
fn the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects() {
let (mut s, rect) = session_with(json!({"gradient": {}}));
let gradient = s.doc().unwrap().doc.node(rect).unwrap().appearance.paint_at(None, true).unwrap().clone();
let text = NodeId(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "type"})).unwrap()["id"].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
let mut runless = d.node(text).unwrap().clone();
if let vectorcraft_doc::NodeKind::Text(t) = &mut runless.kind {
t.runs.clear();
}
let empty = vectorcraft_doc::Node::path(NodeId(9999), Default::default(), Default::default());
let bases = [d.node(rect).unwrap().clone(), empty, d.node(text).unwrap().clone(), runless];
let mut seed: u64 = 0x9a1d_5eed_c0de;
let mut next = |n: u64| {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 33) % n
};
let (mut checked, mut some) = (0, 0);
for _ in 0..200 {
let mut n = bases[next(4) as usize].clone();
let paints = [Paint::None, Paint::solid(Color::rgb8(next(256) as u8, 0, 0)), gradient.clone()];
let mut look = vectorcraft_doc::Appearance::default();
for _ in 0..next(5) {
let p = paints[next(3) as usize].clone();
look.items.push(if next(2) == 0 {
vectorcraft_doc::AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(p))
} else {
vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(p, 1.0 + next(5) as f64))
});
}
n.appearance = look.into();
if next(4) == 0 {
n.extras_mut().colorize = Some(Box::new(paints[next(3) as usize].clone()));
}
for stroke in [false, true] {
for item in [None, Some(0), Some(1), Some(2), Some(3), Some(4)] {
let measured = n.proxy_paint(stroke, item).map(|p| p.0);
assert_eq!(n.proxy_paint_only(stroke, item), measured, "{n:?} stroke {stroke} item {item:?}");
checked += 1;
some += usize::from(measured.is_some());
}
}
}
assert!(some > checked / 4 && some < checked, "both answers come up: {some} of {checked} found a paint");
}
#[test]
fn saved_presets_round_trip_with_the_preferences() {
let (mut s, _) = session_with(json!({"gradient": {}}));
+212
View File
@@ -1355,3 +1355,215 @@ fn delete_empty_artboards_measures_clipped_art_through_the_clip() {
assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [1, 3]}));
assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 3"]);
}
/// Is Delete Empty Artboards enabled, and does running it then delete something? The two answers
/// must agree.
fn delete_empty_enabled_and_outcome(s: &mut Session) -> (bool, bool) {
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "artboard.deleteEmpty").unwrap();
let enabled = (spec.enabled)(s).is_ok();
(enabled, s.execute("artboard.deleteEmpty", &json!({})).is_ok())
}
/// Delete Empty Artboards is enabled exactly when it deletes something: never with one artboard,
/// with every artboard empty (all but artboard 1 go), with one empty, not when the only empty one
/// is locked, and with art nested in groups and sublayers counted.
#[test]
fn delete_empty_artboards_is_enabled_exactly_when_it_deletes_something() {
/// What the case is, how its session is made, and whether the command deletes something.
type Case = (&'static str, fn() -> Session, bool);
let cases: [Case; 8] = [
("one empty artboard", || row_of_artboards(1), false),
(
"one artboard with art",
|| {
let mut s = row_of_artboards(1);
box_at(&mut s, 10.0, 20.0);
s
},
false,
),
("every artboard empty", || row_of_artboards(3), true),
(
"one empty",
|| {
let mut s = row_of_artboards(2);
box_at(&mut s, 10.0, 20.0);
s
},
true,
),
(
"the empty one locked",
|| {
let mut s = row_of_artboards(2);
box_at(&mut s, 10.0, 20.0);
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
s
},
false,
),
(
"every artboard empty and locked",
|| {
let mut s = row_of_artboards(2);
s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
s
},
false,
),
(
"nested art on each",
|| {
let mut s = row_of_artboards(2);
let a = box_at(&mut s, 10.0, 20.0);
let b = box_at(&mut s, 30.0, 20.0);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s.execute("layer.newSublayer", &json!({})).unwrap();
box_at(&mut s, 410.0, 20.0);
s
},
false,
),
(
"nested art on one",
|| {
let mut s = row_of_artboards(3);
s.execute("layer.newSublayer", &json!({})).unwrap();
let a = box_at(&mut s, 410.0, 20.0);
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("object.group", &json!({})).unwrap();
s
},
true,
),
];
for (why, make, deletes) in cases {
assert_eq!(delete_empty_enabled_and_outcome(&mut make()), (deletes, deletes), "{why}");
}
}
/// On documents made from a fixed seed (artboards in a row, some locked, boxes on, between and
/// across them, some grouped), Delete Empty Artboards' enabled state matches what it does.
#[test]
fn delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents() {
let mut seed: u64 = 0x5eed_a17b_0a2d;
let mut next = |n: u64| {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 33) % n
};
let mut enabled_count = 0;
for _ in 0..60 {
let mut s = row_of_artboards(1 + next(4) as usize);
let artboards = s.doc().unwrap().doc.artboards.len();
for i in 0..artboards {
if next(4) == 0 {
s.execute("artboard.setProps", &json!({"index": i, "locked": true})).unwrap();
}
}
let mut ids = vec![];
for _ in 0..next(4) {
ids.push(box_at(&mut s, next(1700) as f64 - 50.0, 5.0 + next(300) as f64).0);
}
if ids.len() > 1 && next(2) == 0 {
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("object.group", &json!({})).unwrap();
}
let (enabled, deleted) = delete_empty_enabled_and_outcome(&mut s);
assert_eq!(enabled, deleted, "{:?}", s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect, a.locked)).collect::<Vec<_>>());
enabled_count += usize::from(enabled);
}
assert!(enabled_count > 5 && enabled_count < 55, "both answers come up: {enabled_count} of 60 enabled");
}
/// On paths and compound paths made from a fixed seed (curves, open and closed, with strokes of
/// every join, cap and alignment, miter limits, arrowheads, width profiles and dashes), an
/// artboard has art exactly when an object's visual bounds meet it, for artboards just inside,
/// on and just outside each object's visual box and far from it; and
/// [`Document::any_artboard_without_art`] answers as asking [`Document::artboard_has_art`] of each.
#[test]
fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() {
use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_geom::Rect;
let mut seed: u64 = 0xa27b_0a2d_e6b7;
let mut next = |n: u64| {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 33) % n
};
let (mut rects_checked, mut with_art) = (0, 0);
for _ in 0..40 {
let mut s = session();
let path = |s: &mut Session, next: &mut dyn FnMut(u64) -> u64| {
let mut d = format!("M {} {}", 100 + next(400), 100 + next(300));
for _ in 0..1 + next(4) {
let mut pt = || format!("{} {}", 100 + next(400), 100 + next(300));
d += &format!(" C {} {} {}", pt(), pt(), pt());
}
if next(2) == 0 {
d += " Z";
}
s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap()
};
let mut objects = vec![];
for _ in 0..1 + next(3) {
let id = if next(3) == 0 {
let ids = [path(&mut s, &mut next), path(&mut s, &mut next)];
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()
} else {
path(&mut s, &mut next)
};
let mut look = Appearance::default();
for _ in 0..1 + next(2) {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 0.5 + next(30) as f64);
st.join = [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel][next(3) as usize];
st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize];
st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize];
st.miter_limit = 1.0 + next(20) as f64;
let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]);
(st.start_arrow, st.end_arrow) = (head(), head());
st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64);
if next(3) == 0 {
st.profile = Some(WidthProfile { points: vec![(0.0, next(3) as f64, 1.0), (0.5, 0.5, next(4) as f64), (1.0, 1.0, 0.25)] });
}
if next(3) == 0 {
st.dash = Some(Dash { pattern: vec![1.0 + next(10) as f64, 1.0 + next(10) as f64], offset: 0.0, align_corners: false });
}
look.items.push(AppearanceItem::Stroke(st));
}
s.edit("Strokes", |d, _| {
d.node_mut(NodeId(id)).unwrap().appearance = look.into();
Ok(())
})
.unwrap();
objects.push(NodeId(id));
}
let d = &s.doc().unwrap().doc;
let symbols = d.symbol_boxes();
let boxes: Vec<Rect> = objects.iter().filter_map(|id| d.node(*id).unwrap().visual_bounds_with(&symbols)).collect();
let has_art = |r: Rect| {
let r = r.inflate(1e-6, 1e-6);
boxes.iter().any(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)
};
let mut rects = vec![Rect::new(5000.0, 5000.0, 5200.0, 5150.0)];
for b in &boxes {
for gap in [-0.01, 0.0, 5e-7, 2e-6, 0.01, 1.0] {
rects.push(Rect::new(b.x1 + gap, b.y0, b.x1 + gap + 10.0, b.y1));
rects.push(Rect::new(b.x0 - gap - 10.0, b.y0, b.x0 - gap, b.y1));
rects.push(Rect::new(b.x0, b.y1 + gap, b.x1, b.y1 + gap + 10.0));
rects.push(Rect::new(b.x0, b.y0 - gap - 10.0, b.x1, b.y0 - gap));
}
}
for r in &rects {
assert_eq!(d.artboard_has_art(*r), has_art(*r), "{r:?} against {boxes:?}");
rects_checked += 1;
with_art += usize::from(has_art(*r));
}
for _ in 0..20 {
let pick: Vec<Rect> = (0..1 + next(3)).map(|_| rects[next(rects.len() as u64) as usize]).collect();
assert_eq!(d.any_artboard_without_art(pick.iter().copied()), pick.iter().any(|r| !has_art(*r)), "{pick:?} against {boxes:?}");
}
}
assert!(with_art > rects_checked / 4 && with_art < rects_checked * 3 / 4, "both answers come up: {with_art} of {rects_checked}");
}
+1
View File
@@ -519,6 +519,7 @@ since 2026-10-01.
- **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html).
- **Delete Empty Artboards:** the Artboards panel menu item ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html)) runs `artboard.deleteEmpty`: every unlocked artboard with no artwork goes with its guides in one undo step, artboard 1 stays when every artboard is empty and none is locked, and the panel makes an artboard that is left active; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). Engine tests in `tests/panelcmds.rs` (`delete_empty_artboards_*`) and the panel test `delete_empty_artboards_deletes_the_empty_ones_and_selects_one_left`.
The menu item's enabled check measures nothing in a document with one artboard, and Save Gradient Preset's check reads the proxy's paint without measuring the object; both give the same answer as before. An artboard that no path's control points, grown by the farthest its strokes reach, can meet is found empty without measuring the path. With an open path of 200,000 anchors selected, the checks take under 0.01 ms instead of 21.9 ms and 17.2 ms in a release build; with a second, empty artboard, Delete Empty Artboards' check takes 0.85 ms instead of 42.1 ms. Tests `delete_empty_artboards_is_enabled_exactly_when_it_deletes_something`, `delete_empty_artboards_enabled_state_matches_the_command_on_varied_documents`, `empty_artboard_checks_match_the_visual_bounds_on_varied_strokes`, `the_empty_artboard_check_matches_measuring_every_artboard`, `the_save_check_matches_saving_with_and_without_a_gradient`, `the_unmeasured_proxy_paint_is_the_measured_one` and `the_unmeasured_proxy_paint_is_the_measured_one_on_varied_objects`.
- **Artboard history selection:** headless pointer/key tests in `panels/properties.rs` pass for panel New → Undo → Delete/Backspace/Properties, Duplicate → Undo → Delete, pruning removed indices and preserving valid multiselection. They assert board survivors, artwork and object selection, Move/Scale Artwork options, saved state and Undo/Redo. Full workspace CI passes.
- **Artboard tab-switch follow-up:** changing the active document through its tab aligns the Artboard tool with the displayed board before Properties Delete; clicking the already-active tab preserves multiselection and Move/Scale Artwork options. Three headless `chrome::tests::artboard_document_tab_*` regressions cover actual tab A → B → A clicks followed by Properties Delete, surviving boards and guides, preserved artwork/symbols and Undo/Redo; controls cover same-tab multiselection, Move/Scale Artwork options and other tools. Historical focused validation on `dbd2497b` recorded one expected RED failure with two passing controls, then all three GREEN tests passed. All three also passed in historical full CI on `a0671777`. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure.
- **Artboard clean active-tab close (2026-10-15):** with the Artboard tool active, closing a clean active tab restores the surviving document's displayed board before Properties Delete. Nine targeted checks passed: three existing tab regressions, one clean close/Delete regression and five existing unsaved-document controls. The close regression checks the correct surviving board, preserved artwork/symbols and object selection, affected guides, one Delete edit, saved state and Undo/Redo. Three reviewed original full-app headless captures show the active tab before closing, the surviving document's shown board after closing, and the correct survivor after Properties Delete, using the actual tab × and Delete controls.