Merge pull request #7730 from dotCooCoo/fix/hpgl-9slice-art-bounds

Export: the HP-GL plot of the art's bounds places 9-slice and filtered symbol art before measuring
This commit is contained in:
BFlat
2026-11-15 11:37:09 +07:00
committed by GitHub
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]