diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0bb18483cb..c823b8866f 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -2,13 +2,13 @@ //! (unknown keys are ignored, so one params object can carry the options of several formats). use std::borrow::Cow; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; -use vectorcraft_doc::{Artboard, Document, Node, NodeKind}; +use vectorcraft_doc::{Artboard, Document, Node, NodeKind, SymbolOverride}; use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; @@ -450,31 +450,85 @@ pub fn export_art_bounds(doc: &Document, format: &str) -> Option { /// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden /// ([`hidden_nodes`]) and the ones in the art of the symbols that instances show taken out /// ([`without_nodes`]: symbol art is measured with its hidden objects). An instance of a symbol -/// whose art is all picked or hidden is hidden too. The art of a dynamic symbol is measured whole: -/// an instance can paint its objects, and finds them by their places in the art. +/// whose art is all picked or hidden is hidden too. The drawn instances of a dynamic symbol that +/// 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. fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option { - // The symbols instances show (the first of each name) that aren't dynamic. + // The symbols instances show (the first of each name). let mut names = HashSet::new(); - let shown: Vec<(usize, &vectorcraft_doc::Symbol)> = - doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).filter(|(_, s)| !s.dynamic).collect(); + let shown: Vec = doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).map(|(i, _)| i).collect(); + // The sets of appearance changes the drawn instances of each dynamic symbol make to its art; + // `None` for a symbol with more than `MAX_ART_COPIES`, which is measured whole. + let mut sets: HashMap<&str, Option>>> = + shown.iter().filter_map(|i| doc.symbols.get(*i)).filter(|s| s.dynamic).map(|s| (s.name.as_str(), Some(Vec::new()))).collect(); + if !sets.is_empty() { + let mut walked = HashSet::new(); + for l in &doc.layers { + drawn_changes(doc, l, left_out, true, &mut walked, &mut sets); + } + } + // Each set of changes gets a copy of its symbol, appended after the symbols, which the instances + // that make those changes show instead. + let variant = |symbol: &str, n: &Node| -> Option { + let k = sets.get(symbol)?.as_ref()?.iter().position(|c| *c == own_changes(n))?; + Some(format!("{symbol}\u{0}{k}")) + }; + let mut copies: Vec<(usize, &[&SymbolOverride])> = Vec::new(); + let remapped_doc = sets.values().flatten().any(|v| !v.is_empty()).then(|| { + let mut d = doc.clone(); + for l in &mut d.layers { + if let Some(n) = remapped(l, &variant) { + *l = n; + } + } + for s in &mut d.symbols { + if let Some(n) = remapped(&s.art, &variant) { + s.art = n; + } + } + for i in &shown { + 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() }); + } + } + d + }); + let src = remapped_doc.as_ref().unwrap_or(doc); + // 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 + .iter() + .filter_map(|i| src.symbols.get(*i).map(|s| (*i, s))) + .filter(|(_, s)| !s.dynamic || sets.get(s.name.as_str()).is_some_and(Option::is_some)) + .map(|(i, s)| (i, s.dynamic.then_some(&[][..]))) + .chain(copies.iter().map(|(i, c)| (*i, Some(*c)))) + .collect(); // The symbols whose art `left_out` leaves nothing of, those that only hold instances of such // symbols included. let mut empty: HashSet<&str> = HashSet::new(); - while let Some((_, s)) = shown.iter().find(|(_, s)| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty)) { + while let Some(s) = entries.iter().find_map(|(i, c)| { + src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty, *c, &mut Vec::new())) + }) { empty.insert(&s.name); } let left_out = &|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n)); - let layers = hidden_nodes(&doc.layers, left_out); - let symbols: Vec<(usize, Arc)> = shown + let layers = hidden_nodes(&src.layers, left_out); + let symbols: Vec<(usize, Arc)> = entries .iter() - .filter(|(_, s)| !empty.contains(s.name.as_str())) - .filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art))) + .filter_map(|(i, c)| { + let s = src.symbols.get(*i).filter(|s| !empty.contains(s.name.as_str()))?; + without_nodes(&s.art, left_out, *c, &mut Vec::new()).map(|art| (*i, art)) + }) .collect(); if layers.is_none() && symbols.is_empty() { - return vectorcraft_render::encode::art_bounds(doc); + return vectorcraft_render::encode::art_bounds(src); } - let mut d = doc.clone(); + let mut d = remapped_doc.unwrap_or_else(|| doc.clone()); if let Some(layers) = layers { d.layers = layers; } @@ -486,22 +540,126 @@ fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option Vec<&SymbolOverride> { + n.extras().symbol_overrides.iter().filter(|o| !o.is_empty()).collect() +} + +/// `n` with each instance at any depth that `variant` gives another symbol (from the instance's +/// symbol and the instance) made an instance of that symbol; `None` when none is. +fn remapped(n: &Arc, variant: &dyn Fn(&str, &Node) -> Option) -> Option> { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + let name = variant(symbol, n)?; + let mut m = (**n).clone(); + if let NodeKind::SymbolInstance { symbol, .. } = &mut m.kind { + *symbol = name; + } + return Some(Arc::new(m)); + } + let children = n.children()?; + let mut out: Option>> = None; + for (i, c) in children.iter().enumerate() { + if let Some(new) = remapped(c, variant) + && 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) + }) +} + /// Whether `n` is hidden, picked by `left_out`, an instance of one of the `empty` symbols, or a /// layer or a group holding only such objects (its clipping path among them when it clips). A /// Shaper group, or a layer, group or instance with effects, fills or strokes of its own, is none -/// of these unless it is hidden or picked. -fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>) -> bool { +/// of these unless it is hidden or picked. In the art of a dynamic symbol (`changed`, the changes +/// an instance makes to it; `n` is at place `at`), each object is judged with those changes +/// ([`as_drawn`]). +fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>, changed: Option<&[&SymbolOverride]>, at: &mut Vec) -> bool { + let m = as_drawn(n, changed, at); !n.visible - || left_out(n) - || !has_effects(n) + || left_out(&m) + || !has_effects(&m) && n.extras().shaper.is_none() && match &n.kind { - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().all(|c| all_left_out(c, left_out, empty)), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().enumerate().all(|(i, c)| { + at.push(i as u32); + let out = all_left_out(c, left_out, empty, changed, at); + at.pop(); + out + }), NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()), _ => false, } } +/// 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`. +fn drawn_changes<'a>( + doc: &'a Document, + n: &'a Node, + left_out: &dyn Fn(&Node) -> bool, + hidden: bool, + walked: &mut HashSet<&'a str>, + sets: &mut HashMap<&'a str, Option>>>, +) { + if hidden && !n.visible || left_out(n) { + return; + } + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + let changes = own_changes(n); + if let Some(entry) = sets.get_mut(symbol.as_str()) + && !changes.is_empty() + { + let full = match entry { + Some(list) if !list.contains(&changes) && list.len() < MAX_ART_COPIES => { + list.push(changes); + false + } + Some(list) => !list.contains(&changes), + None => false, + }; + if full { + *entry = None; + } + } + if walked.insert(symbol.as_str()) + && let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) + { + drawn_changes(doc, &s.art, left_out, false, walked, sets); + } + } + for c in n.children().into_iter().flatten() { + drawn_changes(doc, c, left_out, hidden, walked, sets); + } +} + +/// The most copies of one dynamic symbol's art [`bounds_without`] measures, one for each set of +/// appearance changes its instances make; a symbol whose instances make more sets is measured whole. +const MAX_ART_COPIES: usize = 64; + +/// `n`, the object at place `at` of a symbol's art, with the appearance changes `changed` that an +/// instance makes to it applied in their order; `n` itself for the art of a symbol that is not +/// dynamic (`changed` is `None`) and when no change names `at`. +fn as_drawn<'a>(n: &'a Node, changed: Option<&[&SymbolOverride]>, at: &[u32]) -> Cow<'a, Node> { + let here: Vec = + changed.into_iter().flatten().filter(|o| o.path == at).map(|o| SymbolOverride { path: Vec::new(), ..(*o).clone() }).collect(); + if here.is_empty() { + return Cow::Borrowed(n); + } + let mut m = n.clone(); + vectorcraft_doc::symbol_overrides::apply(&mut m, &here); + Cow::Owned(m) +} + /// Whether `n` has a visible effect, fill or stroke of its own, which the objects it holds can /// take part in. fn has_effects(n: &Node) -> bool { @@ -511,25 +669,32 @@ fn has_effects(n: &Node) -> bool { /// `n` without the objects that `left_out` picks in it, when it is a layer or a group, and in the /// layers and groups in it at any depth; `None` when it picks none of them. A clipping layer or /// group keeps its clipping path, its first object, and a Shaper group and a layer or group with -/// effects, fills or strokes of its own keep all they hold. -fn without_nodes(n: &Arc, left_out: &dyn Fn(&Node) -> bool) -> Option> { +/// effects, fills or strokes of its own keep all they hold. In the art of a dynamic symbol +/// (`changed`, the changes an instance makes to it, as for [`all_left_out`]; `n` is at place +/// `at`), each object is judged with those changes ([`as_drawn`]), and an object taken out leaves +/// an empty group in its place. +fn without_nodes(n: &Arc, left_out: &dyn Fn(&Node) -> bool, changed: Option<&[&SymbolOverride]>, at: &mut Vec) -> Option> { let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None }; - if has_effects(n) || n.extras().shaper.is_some() { + if has_effects(&as_drawn(n, changed, at)) || n.extras().shaper.is_some() { return None; } - let mut changed = false; + let mut taken = false; let kept: Vec> = children .iter() .enumerate() .filter_map(|(i, c)| { - if left_out(c) && !(*clip && i == 0) { - changed = true; - return None; - } - Some(without_nodes(c, left_out).inspect(|_| changed = true).unwrap_or_else(|| c.clone())) + at.push(i as u32); + let out = if left_out(&as_drawn(c, changed, at)) && !(*clip && i == 0) { + taken = true; + changed.map(|_| Arc::new(Node::group(c.id, Vec::new()))) + } else { + Some(without_nodes(c, left_out, changed, at).inspect(|_| taken = true).unwrap_or_else(|| c.clone())) + }; + at.pop(); + out }) .collect(); - changed.then(|| { + taken.then(|| { let mut new = (**n).clone(); if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind { *children = kept; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index a7fe16690e..82957468d8 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -254,6 +254,90 @@ fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() { } } +/// In the art of a dynamic symbol a placed image and a path that paints nothing are left out as +/// well, until an instance paints the path, and a document whose only other visible art is an +/// instance of a dynamic symbol of an image is refused. +#[test] +fn a_plot_of_the_arts_bounds_leaves_out_images_in_dynamic_symbol_art() { + let mut s = two_boards(); + let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v)); + let bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap(); + let same = |a: vectorcraft_geom::Rect, b: vectorcraft_geom::Rect| { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(a, b)| (a - b).abs() < 1e-6) + }; + let printing = s.doc().unwrap().doc.layers[0].id; + s.execute("layer.new", &json!({})).unwrap(); + let mut png = vec![]; + image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone(); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone(); + let bare = s.execute("shape.rectangle", &json!({"x": 500, "y": -400, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("paint.setStroke", &json!({"ids": [bare], "none": true})).unwrap(); + let before = bounds(&s); + let inst = s.execute("symbol.new", &json!({"name": "Mixed", "ids": [image, square, bare], "type": "dynamic"})).unwrap()["id"].clone(); + let after = bounds(&s); + assert!(same(before, after), "{before:?} {after:?}"); + + // A change of the path's blending mode leaves it painting nothing; once the instance paints the + // path, the path is measured. + let art = s.doc().unwrap().doc.symbols[0].art.clone(); + let x0 = |c: &std::sync::Arc| c.visual_bounds().map_or(f64::MIN, |b| b.x0); + let at = (0..art.children().unwrap().len()).max_by(|a, b| x0(&art.children().unwrap()[*a]).total_cmp(&x0(&art.children().unwrap()[*b]))).unwrap(); + s.execute("symbol.instance.setAppearance", &json!({"id": inst, "path": [at], "blend": "multiply"})).unwrap(); + let blended = bounds(&s); + assert!(same(before, blended), "{before:?} {blended:?}"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst, "path": [at], "stroke": "#000000"})).unwrap(); + let painted = bounds(&s); + assert!(painted.x1 > 500.0 && painted.y0 < -390.0, "{painted:?}"); + // A later change to the same object, as a file can hold, takes the paint off again. + { + let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap()); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let n = d.node_mut(id).unwrap(); + let mut overrides = n.extras().symbol_overrides.clone(); + overrides.push(vectorcraft_doc::SymbolOverride { path: vec![at as u32], opacity: Some(0.0), ..Default::default() }); + n.extras_mut().symbol_overrides = overrides.clone(); + let taken_off = bounds(&s); + assert!(same(before, taken_off), "{before:?} {taken_off:?}"); + overrides.pop(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.node_mut(id).unwrap().extras_mut().symbol_overrides = overrides; + } + // Another instance, far above, that doesn't paint the path is measured without it, whether or + // not the first one paints it. + let other = s.execute("symbol.place", &json!({"name": "Mixed", "x": 0, "y": -5000})).unwrap()["id"].clone(); + let with_paint = bounds(&s); + s.execute("symbol.instance.reset", &json!({"ids": [inst]})).unwrap(); + let without_paint = bounds(&s); + assert!((with_paint.y0 - without_paint.y0).abs() < 1e-6, "{with_paint:?} {without_paint:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + let undone = bounds(&s); + assert!(same(undone, with_paint), "{undone:?} {with_paint:?}"); + s.execute("select.set", &json!({"ids": [other]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + + s.execute("select.set", &json!({"ids": [inst]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap(); + let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone(); + s.execute("symbol.new", &json!({"name": "Image", "ids": [image], "type": "dynamic"})).unwrap(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); + + // An instance that brings a group at 0% opacity back to full opacity still draws nothing when + // the group holds only an image. + let image = s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap()["ids"][0].clone(); + let group = s.execute("object.group", &json!({"ids": [image]})).unwrap()["id"].clone(); + s.execute("transparency.set", &json!({"ids": [group], "opacity": 0})).unwrap(); + let inst = s.execute("symbol.new", &json!({"name": "Clear", "ids": [group], "type": "dynamic"})).unwrap()["id"].clone(); + let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap()); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.node_mut(id).unwrap().extras_mut().symbol_overrides = + [vec![], vec![0]].into_iter().map(|path| vectorcraft_doc::SymbolOverride { path, opacity: Some(1.0), ..Default::default() }).collect(); + let e = plot(&mut s).unwrap_err().to_string(); + assert!(e.contains("no visible art"), "{e}"); +} + /// 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 2a4d7b98ad..9645f12171 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1106,8 +1106,9 @@ way. `document.exportPdf {useArtboards: false}` writes that one page as well, an art's bounds is measured without what the plot leaves out: non-printing layers, objects at 0% opacity, placed images, gradient meshes whose every point is at 0% opacity, and paths without effects whose fills and strokes paint nothing. 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 that is not dynamic are left out of its instances too, and an instance whose -symbol holds nothing else is left out. +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. 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