Export: the HP-GL plot of the art's bounds places 9-slice and filtered symbol art before measuring

The art's bounds no longer clip art that 9-slice guides place, or refuse
art that an instance shows through a nested instance:

- An instance whose art goes through 9-slice guides, at any depth, is
  measured as its art placed through the guides, with the objects the
  plot leaves out hidden after placing. Leaving them out first changed
  the box the guides cut.
- Hidden art is not placed, so it does not use up the limit on placed
  objects that the visible instances share.
- The copy of a dynamic symbol that an instance's changes are measured
  on keeps the symbol's 9-slice guides and natural size.
- The changes of the instances in a dynamic symbol's art are collected
  from all of the art, also from objects at 0% opacity or without paint,
  which an instance of the symbol can change.
This commit is contained in:
dotCooCoo
2026-11-14 17:52:29 -08:00
parent 895e9493d2
commit 4ec16519a4
3 changed files with 308 additions and 9 deletions
+160 -8
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride};
use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SYMBOL_SIZES, SymbolOverride};
use vectorcraft_geom::Rect;
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
@@ -454,7 +454,12 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> {
/// make the same appearance changes to its art are measured on a copy of the symbol of their own,
/// in which each object is judged with those changes ([`as_drawn`]); the objects taken out of
/// a dynamic symbol's art leave empty groups in their places, by which instances find the objects
/// they change.
/// they change. Each copy keeps its symbol's 9-slice guides and natural size. An instance whose art
/// goes through 9-slice guides at any depth is measured as its art placed through them
/// ([`placed_instances`]), with the objects `left_out` picks hidden after placing: the guides cut
/// the box of the whole art. Where an instance stays one, the art of a symbol with 9-slice guides,
/// and of the symbols whose instances are in that art at any depth ([`sliced_art`]), is measured
/// whole.
fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> {
// The symbols instances show (the first of each name).
let mut names = HashSet::new();
@@ -492,12 +497,15 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
let Some(s) = d.symbols.get(*i).cloned() else { continue };
for (k, c) in sets.get(s.name.as_str()).into_iter().flatten().flatten().enumerate() {
copies.push((d.symbols.len(), c.as_slice()));
d.symbols.push(vectorcraft_doc::Symbol { name: format!("{}\u{0}{k}", s.name), ..s.clone() });
let name = format!("{}\u{0}{k}", s.name);
copy_placement(&mut d, &s.name, &name);
d.symbols.push(vectorcraft_doc::Symbol { name, ..s.clone() });
}
}
d
});
let src = remapped_doc.as_ref().unwrap_or(doc);
let whole = sliced_art(src);
// The symbols to measure with the changes their instances make (`None` for a static symbol),
// without the dynamic symbols measured whole.
let entries: Vec<(usize, Option<&[&SymbolOverride]>)> = shown
@@ -517,11 +525,25 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
}
let left_out =
&|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n));
// The instances whose art goes through 9-slice guides, at any depth, are measured as their art
// placed by those guides without the objects `left_out` picks, and without a clip group that
// holds only such objects.
let layers = hidden_nodes(&src.layers, left_out);
let placing = holding(src, &whole);
let layers = if placing.is_empty() {
layers
} else {
let picked = &|n: &Node| {
left_out(n) || matches!(n.kind, NodeKind::Group { clip: true, .. }) && all_left_out(n, left_out, &empty, None, &mut Vec::new())
};
let mut budget = 0;
let from = layers.as_deref().unwrap_or(&src.layers);
Some(from.iter().map(|l| placed_instances(src, l, &placing, picked, &mut budget, 0).unwrap_or_else(|| l.clone())).collect())
};
let symbols: Vec<(usize, Arc<Node>)> = entries
.iter()
.filter_map(|(i, c)| {
let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?;
let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && !whole.contains(s.name.as_str()))?;
without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art))
})
.collect();
@@ -540,6 +562,135 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Re
vectorcraft_render::encode::art_bounds(&d)
}
/// Give symbol `to` of `d` the 9-slice guides and the natural size that symbol `from` has.
fn copy_placement(d: &mut Document, from: &str, to: &str) {
if let Some(guides) = d.symbol_nine_slice(from) {
d.set_symbol_nine_slice(to, Some(guides));
}
if let Some(size) = d.unknown.get(SYMBOL_SIZES).and_then(|m| m.get(from)).cloned()
&& let Some(sizes) = d.unknown.get_mut(SYMBOL_SIZES).and_then(Value::as_object_mut)
{
sizes.insert(to.to_string(), size);
}
}
/// The names of the symbols of `doc` with 9-slice guides, and of the symbols whose instances are in
/// the art of those at any depth.
fn sliced_art(doc: &Document) -> HashSet<&str> {
let mut arts: HashMap<&str, &Arc<Node>> = HashMap::new();
for s in &doc.symbols {
arts.entry(s.name.as_str()).or_insert(&s.art);
}
let mut todo: Vec<&str> = arts.keys().copied().filter(|name| doc.symbol_nine_slice(name).is_some()).collect();
let mut found = HashSet::new();
while let Some(name) = todo.pop() {
if !found.insert(name) {
continue;
}
if let Some(&art) = arts.get(name) {
art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
todo.push(symbol.as_str());
}
});
}
}
found
}
/// The names of the symbols of `doc` in `sliced`, and of the symbols whose art holds an instance of
/// one of those at any depth.
fn holding<'a>(doc: &'a Document, sliced: &HashSet<&'a str>) -> HashSet<&'a str> {
let mut held_by: HashMap<&str, Vec<&str>> = HashMap::new();
let mut seen = HashSet::new();
for s in doc.symbols.iter().filter(|s| seen.insert(s.name.as_str())) {
s.art.walk(&mut |n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
held_by.entry(symbol.as_str()).or_default().push(s.name.as_str());
}
});
}
let mut found = HashSet::new();
let mut todo: Vec<&str> = sliced.iter().copied().collect();
while let Some(name) = todo.pop() {
if found.insert(name) {
todo.extend(held_by.get(name).into_iter().flatten().copied());
}
}
found
}
/// The most instances deep [`placed_instances`] places symbol art.
const MAX_PLACED_DEPTH: usize = 32;
/// `n` with each instance of the `placing` symbols in it, and in its layers and groups at any depth,
/// made a group with the instance's appearance that holds the instance's art (with its changes and
/// stain) as `doc` places it, through the symbol's 9-slice guides when it has them, without the
/// objects `picked` picks ([`without_nodes`]; hidden when it picks the whole art); `None` when `n`
/// holds no such instance, or is hidden. An instance on a perspective grid, one
/// [`MAX_PLACED_DEPTH`] instances deep, one in another kind of object (a blend, for example), and
/// one whose art would take the objects placed in all (`budget`) past
/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] stay instances.
/// Their symbols' art is measured whole, as taking objects out of it would change the box the
/// guides cut, and the page can come out larger than the plot.
fn placed_instances(
doc: &Document,
n: &Arc<Node>,
placing: &HashSet<&str>,
picked: &dyn Fn(&Node) -> bool,
budget: &mut u64,
depth: usize,
) -> Option<Arc<Node>> {
// Hidden art is not plotted, and placing it would take from the budget of the art that is.
if !n.visible {
return None;
}
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
if !placing.contains(symbol.as_str()) || depth >= MAX_PLACED_DEPTH || n.projection().is_some() {
return None;
}
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n);
let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc));
if budget.saturating_add(most) > vectorcraft_doc::MAX_SYMBOL_NODES {
return None;
}
let art = Arc::new(doc.place_symbol_art(symbol, &art, *xf)?);
*budget = budget.saturating_add(art.count_with_masks() as u64);
let art = placed_instances(doc, &art, placing, picked, budget, depth + 1).unwrap_or(art);
let art = if picked(&art) {
let mut hidden = (*art).clone();
hidden.visible = false;
Arc::new(hidden)
} else {
without_nodes(&art, picked, None, &mut Vec::new()).unwrap_or(art)
};
let mut g = (**n).clone();
g.kind = NodeKind::Group { children: vec![art], clip: false };
Some(Arc::new(g))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, c) in children.iter().enumerate() {
if let Some(new) = placed_instances(doc, c, placing, picked, budget, depth)
&& let Some(slot) = out.get_or_insert_with(|| children.clone()).get_mut(i)
{
*slot = new;
}
}
out.map(|c| {
let mut m = (**n).clone();
if let Some(children) = m.children_mut() {
*children = c;
}
Arc::new(m)
})
}
_ => None,
}
}
/// The appearance changes instance `n` makes to its symbol's art, without those that change nothing.
fn own_changes(n: &Node) -> Vec<&SymbolOverride> {
n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect()
@@ -600,9 +751,10 @@ fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str
/// Add the sets of appearance changes that the instances in `n` and its subtree make to the art of
/// the symbols `sets` names, each set once, and those of the instances in the art of the symbols the
/// instances show (`walked`, the symbols already looked in). The instances that `left_out` picks or
/// that are in an object it picks are left out, and with `hidden` (in a layer) the hidden ones and
/// those in a hidden object. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`.
/// instances show (`walked`, the symbols already looked in). With `hidden` (in a layer), the
/// instances that are hidden or that `left_out` picks, and those in such an object, are left out.
/// In symbol art every instance counts, since an instance of the symbol can change the objects
/// that hold it. A symbol with more than [`MAX_ART_COPIES`] sets gets `None`.
fn drawn_changes<'a>(
doc: &'a Document,
n: &'a Node,
@@ -611,7 +763,7 @@ fn drawn_changes<'a>(
walked: &mut HashSet<&'a str>,
sets: &mut HashMap<&'a str, Option<Vec<Vec<&'a SymbolOverride>>>>,
) {
if hidden && !n.visible || left_out(n) {
if hidden && (!n.visible || left_out(n)) {
return;
}
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
+146
View File
@@ -338,6 +338,152 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_symbol_art() {
assert!(e.contains("no visible art"), "{e}");
}
/// A rectangle filled black, or one that paints nothing.
fn plot_rect(d: &mut vectorcraft_doc::Document, r: vectorcraft_geom::Rect, painted: bool) -> std::sync::Arc<vectorcraft_doc::Node> {
use vectorcraft_color::{Color, Paint};
let fill = if painted { Paint::solid(Color::BLACK) } else { Paint::None };
let appearance = vectorcraft_doc::Appearance::basic(fill, Paint::None, 0.0);
std::sync::Arc::new(vectorcraft_doc::Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), appearance))
}
/// An instance of symbol `symbol` placed by `xf`.
fn plot_instance(d: &mut vectorcraft_doc::Document, symbol: &str, xf: vectorcraft_geom::Affine) -> vectorcraft_doc::Node {
vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
}
/// Symbol `name` holding `art` in a group.
fn plot_symbol(d: &mut vectorcraft_doc::Document, name: &str, art: Vec<std::sync::Arc<vectorcraft_doc::Node>>, dynamic: bool) {
let group = vectorcraft_doc::Node::group(d.alloc_id(), art);
d.symbols.push(vectorcraft_doc::Symbol { dynamic, name: name.into(), art: std::sync::Arc::new(group) });
}
/// Puts `n` on the first layer of `d`.
fn plot_on_layer(d: &mut vectorcraft_doc::Document, n: vectorcraft_doc::Node) {
std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().push(std::sync::Arc::new(n));
}
/// The art of a symbol with 9-slice guides, static or dynamic, is placed whole and measured
/// without what the plot leaves out: a frame that paints nothing sets where the guides place the
/// painted art inside it, and is not measured itself.
#[test]
fn a_plot_of_the_arts_bounds_places_9_slice_symbol_art_by_all_of_it() {
use vectorcraft_geom::{Affine, Rect};
// Static and dynamic, and with the instance's fill staining the art.
for (dynamic, stained) in [(false, false), (true, false), (false, true)] {
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
let square = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), true);
plot_symbol(&mut d, "Panel", vec![frame, square], dynamic);
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
if stained {
let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
wide.appearance = vectorcraft_doc::Appearance::basic(red, vectorcraft_color::Paint::None, 0.0).into();
}
plot_on_layer(&mut d, wide);
// Scaled 2 times across, the middle column maps x to 3x - 50: the square draws from x 70
// to 130.
let b = super::export_art_bounds(&d, "hpgl").unwrap();
let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
assert!(exact, "dynamic: {dynamic}, stained: {stained}, {b:?}");
}
}
/// In art placed through 9-slice guides, a group with a fill of its own keeps the path it paints,
/// which paints nothing by itself.
#[test]
fn a_plot_of_the_arts_bounds_keeps_what_a_group_paints_in_9_slice_symbol_art() {
use vectorcraft_geom::{Affine, Rect};
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
let bare = plot_rect(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), false);
let mut group = vectorcraft_doc::Node::group(d.alloc_id(), vec![bare]);
let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
group.appearance = vectorcraft_doc::Appearance::basic(black, vectorcraft_color::Paint::None, 0.0).into();
plot_symbol(&mut d, "Panel", vec![frame, std::sync::Arc::new(group)], false);
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
plot_on_layer(&mut d, wide);
let b = super::export_art_bounds(&d, "hpgl").expect("the group's path is plotted");
let exact = [(b.x0, 70.0), (b.y0, 40.0), (b.x1, 130.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
assert!(exact, "{b:?}");
}
/// A symbol without guides in the art of a symbol with 9-slice guides is measured without what the
/// plot leaves out, in the placed art and in an instance of its own.
#[test]
fn a_plot_of_the_arts_bounds_leaves_out_unplotted_art_of_symbols_in_9_slice_art() {
use vectorcraft_geom::{Affine, Rect};
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let far = plot_rect(&mut d, Rect::new(45.0, -500.0, 55.0, -480.0), false);
let dot = plot_rect(&mut d, Rect::new(45.0, 45.0, 55.0, 55.0), true);
plot_symbol(&mut d, "Dot", vec![far, dot], false);
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
let inner = std::sync::Arc::new(plot_instance(&mut d, "Dot", Affine::IDENTITY));
plot_symbol(&mut d, "Panel", vec![frame, inner], false);
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
plot_on_layer(&mut d, wide);
let near = |b: Rect, e: [f64; 4]| [b.x0, b.y0, b.x1, b.y1].iter().zip(e).all(|(a, e)| (a - e).abs() < 1e-6);
// The dot's box is in the middle column, which maps x to 3x - 50.
let b = super::export_art_bounds(&d, "hpgl").unwrap();
assert!(near(b, [85.0, 45.0, 115.0, 55.0]), "{b:?}");
let own = plot_instance(&mut d, "Dot", Affine::translate((200.0, 0.0)));
plot_on_layer(&mut d, own);
let b = super::export_art_bounds(&d, "hpgl").unwrap();
assert!(near(b, [85.0, 45.0, 255.0, 55.0]), "{b:?}");
}
/// The copy of a dynamic symbol that an instance's changes are measured on keeps the symbol's
/// 9-slice guides.
#[test]
fn a_plot_of_the_arts_bounds_measures_changed_9_slice_instances_by_their_guides() {
use vectorcraft_geom::{Affine, Rect};
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let bar = plot_rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), true);
plot_symbol(&mut d, "Bar", vec![bar], false);
let frame = plot_rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), false);
let inner = std::sync::Arc::new(plot_instance(&mut d, "Bar", Affine::IDENTITY));
plot_symbol(&mut d, "Panel", vec![frame, inner], true);
d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default()));
let mut wide = plot_instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
wide.extras_mut().symbol_overrides =
vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(vectorcraft_color::Paint::None), ..Default::default() }];
plot_on_layer(&mut d, wide);
// The bar crosses the left guide and is placed by the middle column's map, x to 3x - 50: it
// draws from x -20 to 160.
let b = super::export_art_bounds(&d, "hpgl").unwrap();
let exact = |b: Rect| [(b.x0, -20.0), (b.y0, 40.0), (b.x1, 160.0), (b.y1, 60.0)].iter().all(|(a, e)| (a - e).abs() < 1e-6);
assert!(exact(b), "{b:?}");
// The same instance in the art of a symbol without guides.
let wide = std::sync::Arc::make_mut(&mut d.layers[0]).children_mut().unwrap().pop().unwrap();
plot_symbol(&mut d, "Holder", vec![wide], false);
let holder = plot_instance(&mut d, "Holder", Affine::IDENTITY);
plot_on_layer(&mut d, holder);
let b = super::export_art_bounds(&d, "hpgl").unwrap();
assert!(exact(b), "{b:?}");
}
/// An instance in a dynamic symbol's art, in a group at 0% opacity that an instance of the symbol
/// brings back to full opacity, is measured with the paint it gives its own symbol's art.
#[test]
fn a_plot_of_the_arts_bounds_measures_instances_that_an_instance_brings_back() {
use vectorcraft_geom::{Affine, Rect};
let mut d = vectorcraft_doc::Document::new(400.0, 400.0);
let bare = plot_rect(&mut d, Rect::new(300.0, 300.0, 320.0, 320.0), false);
plot_symbol(&mut d, "Inner", vec![bare], true);
let mut inner = plot_instance(&mut d, "Inner", Affine::IDENTITY);
let black = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
inner.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(black), ..Default::default() }];
plot_symbol(&mut d, "Outer", vec![std::sync::Arc::new(inner)], true);
std::sync::Arc::make_mut(&mut d.symbols[1].art).opacity = 0.0;
let mut outer = plot_instance(&mut d, "Outer", Affine::IDENTITY);
outer.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![], opacity: Some(1.0), ..Default::default() }];
plot_on_layer(&mut d, outer);
let b = super::export_art_bounds(&d, "hpgl").expect("the painted rectangle is plotted");
assert!((b.x0 - 300.0).abs() < 1e-6 && (b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left
/// out when it would take more cells than the effect makes.
#[test]
+2 -1
View File
@@ -1112,7 +1112,8 @@ gradient meshes whose every point is at 0% opacity, and paths without effects wh
An image or a mesh with a Crop Marks effect, or with a Vector Halftone effect that makes dots, is measured.
Such objects in the art of a symbol are left out of its instances too, and an instance whose symbol holds nothing
else is left out. In the art of a dynamic symbol, an object that an instance's fill, stroke or opacity makes
the plot draw is measured.
the plot draw is measured. The art of an instance that goes through 9-slice guides is placed through them first, and
such objects are then left out of the placed art.
A document whose only visible art is left out is refused, and Export As refuses an HP-GL plot of it before the save
dialog. A PDF of that page, from either command, carries no Preserve Editing data; when Preserve Editing is on (as it
is in VectorCraft Default), the export comes back with a warning that says so. A `.ai` export with