diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index c823b8866f..c5b8858f98 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -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 { /// 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 { // 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)> = shown @@ -517,11 +525,25 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option)> = 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 HashSet<&str> { + let mut arts: HashMap<&str, &Arc> = 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, + placing: &HashSet<&str>, + picked: &dyn Fn(&Node) -> bool, + budget: &mut u64, + depth: usize, +) -> Option> { + // 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>> = 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>>>, ) { - if hidden && !n.visible || left_out(n) { + if hidden && (!n.visible || left_out(n)) { return; } if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index 82957468d8..8714072257 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -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 { + 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>, 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] diff --git a/docs/mcp.md b/docs/mcp.md index 23d48bf9ed..977ea56cea 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1129,7 +1129,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