From 55615be12c45af749c077bb4b3a0e97654c05b4c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 12:51:29 -0800 Subject: [PATCH] Export: an HP-GL plot of the art's bounds leaves out images in dynamic symbols A plot of the art's bounds now leaves placed images, objects at 0% opacity and unpainted paths out of the art of a dynamic symbol, as it does for other symbols. An object that an instance's fill, stroke or opacity makes the plot draw is measured for that instance only, and an instance whose symbol holds nothing else is left out, so a document with only such instances is refused. --- crates/engine/src/cmd/fileio/encode.rs | 223 ++++++++++++++++++--- crates/engine/src/cmd/fileio/tests_hpgl.rs | 84 ++++++++ docs/mcp.md | 5 +- 3 files changed, 281 insertions(+), 31 deletions(-) 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 cf41f3aaab..1e4591b906 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1091,8 +1091,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