From 83f2bfe9d6ee47469659e534460c53b4a28773d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 15:22:07 +0700 Subject: [PATCH] Symbols: a scaled instance of a symbol with 9-slice guides keeps its corners and edges unstretched Squashed from #5079 onto main. --- crates/cad/src/scene.rs | 3 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/symbol_nine_slice.rs | 1245 +++++++++++++++++- crates/doc/src/symbols.rs | 25 +- crates/effects/src/live.rs | 11 +- crates/effects/src/reshape.rs | 18 +- crates/effects/src/tests.rs | 9 + crates/engine/src/cmd/brushsym.rs | 4 +- crates/engine/src/cmd/brushsym/symbols.rs | 35 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/symbol_nine_slice.rs | 211 +++ crates/eps/src/legacy_ai/writer.rs | 3 +- crates/eps/src/scene.rs | 3 +- crates/metafile/src/scene.rs | 5 +- crates/pdf/src/export/nodes.rs | 38 +- crates/pdf/src/tests.rs | 54 + crates/render/src/frame.rs | 11 +- crates/render/src/fx/tests.rs | 26 + crates/render/src/renderer/nodes.rs | 27 +- crates/render/src/renderer/text.rs | 61 +- crates/render/src/tests/memory.rs | 64 + crates/svg/src/export.rs | 8 + crates/svg/src/export/nodes.rs | 76 +- crates/svg/src/export/text.rs | 2 + crates/svg/tests/it/symbols_layers.rs | 142 ++ crates/tools/src/xform/eyedropper.rs | 69 +- crates/ui-egui/src/dialogs/symbol_options.rs | 9 +- docs/gaps.md | 2 +- docs/mcp.md | 7 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 31 files changed, 2090 insertions(+), 84 deletions(-) create mode 100644 crates/engine/src/tests/symbol_nine_slice.rs diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index ea3b8558d..b7aeb5c6c 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -544,9 +544,8 @@ impl<'a> Scene<'a> { NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST + && let Some(art) = self.doc.place_symbol_art_unclipped(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf) { - let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); - art.transform(*xf, false); self.nest += 1; self.node(&art, true); self.nest -= 1; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ed03b6c75..c836390e1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -29,7 +29,7 @@ mod symbol_registration; pub use inline::{SYMBOL_HALF, SYMBOL_SIZES}; pub use shared::Shared; pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType}; -pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice}; +pub use symbol_nine_slice::{MAX_SLICE_GROWTH, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, charge_symbol_art}; pub use symbol_overrides::SymbolOverride; pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration}; pub mod links; diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 7de70be38..a37b92eef 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -1,14 +1,29 @@ -//! Symbol Options › Enable Guides for 9-Slice Scaling: four guides that cut a symbol into a 3 × 3 -//! grid so that, in Animate / Flash handoff, the corners keep their size while the edges and the -//! centre stretch -//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). +//! Symbol Options › Enable Guides for 9-Slice Scaling: two horizontal and two vertical guides cut +//! a symbol into nine sections, and when an instance is scaled the parts of the symbol outside the +//! guides are not scaled +//! ([helpx: maintain proportions while scaling symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)). +//! New guides sit a quarter of the symbol's width and height in from each edge. +//! +//! VectorCraft's own rules: the corners keep their size, the top and bottom edges stretch only in +//! width, the left and right edges only in height, and the center in both. When the instance is +//! smaller than its two outer cells on an axis, both shrink in proportion and the center is empty. +//! The cells are sized along the symbol's own axes, and the instance's rotation, shear, reflection +//! and position then apply to them. //! //! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left -//! and top edges and from the right and bottom edges, so they survive resizing the art. Like -//! [`crate::SymbolExportType`] this is handoff metadata: instances place the same on the canvas. -//! Stored under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. +//! and top edges and from the right and bottom edges, so they survive resizing the art. Stored +//! under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry. +//! +//! [`NineSliceMap`] places such an instance's art for the canvas, bounds, Break Link, Expand and +//! the exporters ([`Document::place_symbol_art`]). + +use std::sync::Arc; use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_geom::{Affine, Anchor, CubicBez, FillRule, ParamCurve, PathData, Point, Rect, SubPath, shapes}; + +use crate::{Appearance, Document, MAX_SYMBOL_NODES, Node, NodeId, NodeKind, SymbolBoxes}; /// Document unknown key: `{ symbolName: [left, top, right, bottom] }`. pub const SYMBOL_NINE_SLICE: &str = "symbolNineSlice"; @@ -27,10 +42,10 @@ pub struct SymbolNineSlice { } impl Default for SymbolNineSlice { - /// The centre third of each axis is the stretchy cell. + /// Each guide sits a quarter of the symbol's width or height in from its edge. fn default() -> Self { - let t = 1.0 / 3.0; - Self { left: t, top: t, right: t, bottom: t } + let q = 0.25; + Self { left: q, top: q, right: q, bottom: q } } } @@ -55,3 +70,1213 @@ impl SymbolNineSlice { Self::new(n(0)?, n(1)?, n(2)?, n(3)?) } } + +/// The most objects [`NineSliceMap::place`] makes for each object of the art it places: an +/// object drawn in nine cells becomes a group of nine clip groups, each a clip path and a copy. +pub const MAX_SLICE_GROWTH: u64 = 28; + +/// Places a symbol instance's art within [`MAX_SYMBOL_NODES`] objects, of which `used` are already +/// spent. `place` runs only when the budget has room for `most`, the most objects placing can make +/// (the art's object count, or [`NineSliceMap::most_placed`] when it is placed through 9-slice +/// guides), and returns the placed art and its object count, which `used` then grows by. `None`, +/// with `used` as it was, when the instance does not fit. +pub fn charge_symbol_art(used: &mut u64, most: u64, place: impl FnOnce() -> (T, u64)) -> Option { + if used.saturating_add(most) > MAX_SYMBOL_NODES { + return None; + } + let (art, placed) = place(); + let total = used.saturating_add(placed); + if total > MAX_SYMBOL_NODES { + return None; + } + *used = total; + Some(art) +} + +/// The effects that paint past the edges of what an object draws without reshaping it: drop +/// shadow, outer glow and Gaussian blur. [`NineSliceMap::place`] applies them once to all the +/// cells of art that crosses the guides. +pub const PAINTED_EFFECTS: [&str; 3] = ["stylize.dropShadow", "stylize.outerGlow", "blur.gaussian"]; + +/// How far an instance's scale on an axis may be from 1 and still leave its art unchanged. +const SCALE_EPS: f64 = 1e-9; + +/// How deep [`NineSliceMap::place`] follows groups and opacity masks. Art deeper than this is +/// placed as one piece ([`NineSliceMap::place`] says how). +const MAX_SLICE_DEPTH: u32 = 64; + +/// One axis of a 9-slice map: the three cells between the cuts and where each one goes. +#[derive(Clone, Copy, Debug, PartialEq)] +struct AxisMap { + /// The cuts in the art at its natural size: its start, the two guides, its end. + src: [f64; 4], + /// Where the cuts go. The outer cells keep their length unless the scaled length is shorter + /// than both together; then both shrink in proportion and the middle cell is empty. + dst: [f64; 4], +} + +impl AxisMap { + /// The axis from `lo` to `hi` with guides `near` and `far` (fractions of its length in from + /// each end), scaled by `k` (above 0). + fn new(lo: f64, hi: f64, near: f64, far: f64, k: f64) -> Self { + let len = hi - lo; + let (a, b) = (near * len, far * len); + let src = [lo, lo + a, hi - b, hi]; + let (t0, t3) = (k * lo, k * hi); + let room = t3 - t0; + let (a, b) = if a + b <= room { + (a, b) + } else { + let f = (room / (a + b)).max(0.0); + (a * f, b * f) + }; + let t1 = t0 + a; + let t2 = (t3 - b).max(t1); + Self { src, dst: [t0, t1, t2, t3] } + } + + /// The cell `v` lies in; values outside the art belong to the outer cells. + fn cell(&self, v: f64) -> usize { + if v < self.src[1] { + 0 + } else if v < self.src[2] { + 1 + } else { + 2 + } + } + + /// The first and last cells that the span `lo..hi` reaches. A span that ends on a guide does + /// not reach the cell past it. + fn cells(&self, lo: f64, hi: f64) -> [usize; 2] { + let eps = 1e-9 * (self.src[3] - self.src[0]).abs().max(1.0); + let first = if lo >= self.src[2] - eps { + 2 + } else if lo >= self.src[1] - eps { + 1 + } else { + 0 + }; + let last = if hi <= self.src[1] + eps { + 0 + } else if hi <= self.src[2] + eps { + 1 + } else { + 2 + }; + [first, last.max(first)] + } + + /// The part of `lo..hi` in cell `i`, the outer cells reaching past the art; `None` when it is + /// empty. + fn part(&self, i: usize, lo: f64, hi: f64) -> Option<(f64, f64)> { + let start = if i == 0 { lo } else { lo.max(self.src[i]) }; + let end = if i == 2 { hi } else { hi.min(self.src[i + 1]) }; + (start < end).then_some((start, end)) + } + + /// Cell `i`'s map, as (scale, offset). + fn linear(&self, i: usize) -> (f64, f64) { + let (s0, s1, d0, d1) = (self.src[i], self.src[i + 1], self.dst[i], self.dst[i + 1]); + let k = if (s1 - s0).abs() > 1e-12 { (d1 - d0) / (s1 - s0) } else { 1.0 }; + (k, d0 - s0 * k) + } + + fn map(&self, v: f64) -> f64 { + let (k, o) = self.linear(self.cell(v)); + v * k + o + } +} + +/// How an instance of a symbol with 9-slice guides places its art: a map from symbol space to +/// document space that is affine within each of the nine cells and continuous across the guides. +/// The guides cut the art along the symbol's own axes; the instance's rotation, shear, reflection +/// and position apply after the cells are sized (VectorCraft's own rule). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NineSliceMap { + /// Symbol space → the art at its natural size (an axis-aligned scale). + natural: Affine, + x: AxisMap, + y: AxisMap, + /// The instance's transform without its scale along the symbol's axes. + outer: Affine, + /// Whether [`Self::place`] draws art that crosses a guide in clip groups, one per cell. + clips: bool, +} + +impl NineSliceMap { + /// The map for an instance placed by `xf` that shows art with natural-size transform + /// `natural` and geometric bounds `art` (in symbol space). `None` when the art or the + /// transform is degenerate, or when the instance is at its natural size on both axes (then + /// `xf` places the art as it is). + pub fn new(guides: SymbolNineSlice, art: Rect, natural: Affine, xf: Affine) -> Option { + let b = natural.transform_rect_bbox(art); + if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= 1e-9 || b.height() <= 1e-9 { + return None; + } + if natural.determinant().abs() < 1e-18 { + return None; + } + let m = (xf * natural.inverse()).as_coeffs(); + if !m.into_iter().all(f64::is_finite) { + return None; + } + // The linear part as (rotation or reflection) · (shear along the x axis) · (scale along the + // symbol's axes): x scales by the length of the first column, y by the extent of the second + // column across it, so a shear along x alone scales neither axis. A shear along y alone + // keeps the second column's length and the area: it is the natural size too. + let kx = m[0].hypot(m[1]); + if kx <= 1e-12 { + return None; + } + let (u, det) = ((m[0] / kx, m[1] / kx), m[0] * m[3] - m[1] * m[2]); + if (m[2].hypot(m[3]) - 1.0).abs() < SCALE_EPS && (det.abs() - 1.0).abs() < SCALE_EPS { + return None; + } + let v = if det < 0.0 { (u.1, -u.0) } else { (-u.1, u.0) }; + let ky = m[2] * v.0 + m[3] * v.1; + if ky <= 1e-12 || !ky.is_finite() || ((kx - 1.0).abs() < SCALE_EPS && (ky - 1.0).abs() < SCALE_EPS) { + return None; + } + let h = (m[2] * u.0 + m[3] * u.1) / ky; + let outer = Affine::new([u.0, u.1, v.0 + h * u.0, v.1 + h * u.1, m[4], m[5]]); + let x = AxisMap::new(b.x0, b.x1, guides.left, guides.right, kx); + let y = AxisMap::new(b.y0, b.y1, guides.top, guides.bottom, ky); + Some(Self { natural, x, y, outer, clips: true }) + } + + /// Where a point of the symbol's art draws. + pub fn map_point(&self, p: Point) -> Point { + let q = self.natural * p; + self.outer * Point::new(self.x.map(q.x), self.y.map(q.y)) + } + + /// The affine map of the cell in column `i` and row `j`, from the art at its natural size to + /// document space. + fn cell_local(&self, i: usize, j: usize) -> Affine { + let (kx, ox) = self.x.linear(i); + let (ky, oy) = self.y.linear(j); + self.outer * Affine::new([kx, 0.0, 0.0, ky, ox, oy]) + } + + /// The affine map of the cell in column `i` and row `j`: symbol space → document space. + fn cell_xf(&self, i: usize, j: usize) -> Affine { + self.cell_local(i, j) * self.natural + } + + /// The affine map of the cell that symbol-space point `p` lies in. + pub fn cell_at(&self, p: Point) -> Affine { + let q = self.natural * p; + self.cell_xf(self.x.cell(q.x), self.y.cell(q.y)) + } + + /// The guides in symbol space: the two x values and the two y values. + fn cuts(&self) -> ([f64; 2], [f64; 2]) { + let inv = self.natural.inverse(); + let a = inv * Point::new(self.x.src[1], self.y.src[1]); + let b = inv * Point::new(self.x.src[2], self.y.src[2]); + ([a.x, b.x], [a.y, b.y]) + } + + /// `path` (in symbol space) in document space. Each segment is cut where it crosses a guide + /// (at most 12 cuts a segment) and each piece is placed by its cell's map, so the path's + /// anchors and curves follow the map exactly. The result is still one path: a stroke along it + /// is drawn once, without joins or caps at the guides. + pub fn map_path(&self, path: &PathData) -> PathData { + let (xs, ys) = self.cuts(); + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let n = sp.anchors.len(); + let Some(first) = sp.anchors.first() else { continue }; + let a0 = self.cell_at(first.p); + let mut res = vec![Anchor { p: a0 * first.p, h_in: a0 * first.h_in, h_out: a0 * first.h_out, kind: first.kind }]; + for i in 0..sp.segment_count() { + let line = sp.segment_is_line(i); + let pieces = split(sp.segment(i), &xs, &ys); + let count = pieces.len(); + let mut end_in = None; + for (j, piece) in pieces.into_iter().enumerate() { + let a = self.cell_at(piece.eval(0.5)); + let (p1, p2, p3) = (a * piece.p1, a * piece.p2, a * piece.p3); + if let Some(prev) = res.last_mut() { + prev.h_out = if line { prev.p } else { p1 }; + } + let h_in = if line { p3 } else { p2 }; + if j + 1 < count { + res.push(Anchor { p: p3, h_in, h_out: p3, kind: Default::default() }); + } else { + end_in = Some(h_in); + } + } + let end = (i + 1) % n; + let b = &sp.anchors[end]; + let ab = self.cell_at(b.p); + let h_in = end_in.unwrap_or(ab * b.p); + if end == 0 { + res[0].h_in = h_in; + } else { + res.push(Anchor { p: ab * b.p, h_in, h_out: ab * b.h_out, kind: b.kind }); + } + } + subs.push(SubPath::new(res, sp.closed)); + } + PathData::new(subs) + } + + /// Symbol art `art` (in symbol space) placed in document space, its instances measured with + /// the symbols of `doc`. VectorCraft's own rules: + /// + /// - An object whose geometric box lies in one cell is placed by that cell's map, so an + /// object in a corner keeps its size and stays as it is (live shapes, type and images + /// included). A stroke that reaches past a guide is drawn with its path, at its weight. + /// - A path with solid or no paint that crosses a guide is cut at the guides + /// ([`Self::map_path`]); a live shape among them becomes a plain path. + /// - Any other object that crosses a guide (type, images, live objects, paths painted with a + /// gradient or pattern) is drawn once in each cell it reaches, placed by that cell's map and + /// clipped to the cell, so its paint lines up across the guides. + /// - Such an object that draws symbol art ([`draws_symbol_art`]: a symbol instance, type with + /// an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws any + /// of these, in it or in its opacity masks) is placed by the map of the cell its center is + /// in, and is not copied into each cell. So is art nested more than 64 levels deep that + /// draws symbol art; other such art is clipped to the cells as above. A path with solid or + /// no paint is cut at the guides whatever its strokes' brushes, and stays one path. + /// + /// Strokes keep their weight, as they do for any instance. + pub fn place(&self, art: &Node, doc: &Document) -> Node { + let boxes = doc.symbol_boxes().quick(); + self.place_at(art, &boxes, 0) + } + + /// [`Self::place`] without clip groups, for writers that do not apply clipping (DXF, WMF): + /// every path that crosses a guide is cut at the guides whatever its paint, and any other + /// object that crosses one is placed by the map of the cell its center is in. + pub fn place_unclipped(&self, art: &Node, doc: &Document) -> Node { + Self { clips: false, ..*self }.place(art, doc) + } + + /// The most objects [`Self::place`] makes of `art`, as [`Node::count_with_masks`] counts them + /// (the art of opacity masks included), found without placing it: an object whose box lies in + /// one cell counts its own objects, a path cut at the guides counts 1 and its mask, and an + /// object drawn in clip groups counts one group plus a clip path and a copy for each cell its + /// box reaches. That is never more than [`MAX_SLICE_GROWTH`] for each object of `art`. + pub fn most_placed(&self, art: &Node, doc: &Document) -> u64 { + let boxes = doc.symbol_boxes().quick(); + self.most_at(art, &boxes, 0) + } + + /// [`Self::most_placed`] for `n` at nesting depth `depth`, following [`Self::place_at`]. + fn most_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { + let whole = n.count_with_masks() as u64; + let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + return whole; + }; + let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); + if cols[0] == cols[1] && rows[0] == rows[1] { + return whole; + } + if depth >= MAX_SLICE_DEPTH { + return self.most_clipped(n, boxes, b); + } + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => self.most_container(n, boxes, depth), + NodeKind::Compound { .. } if !self.clips || plain_paint(n) => self.most_container(n, boxes, depth), + NodeKind::Path { .. } if !self.clips || plain_paint(n) => self.most_mask(n, boxes, depth).saturating_add(1), + _ => self.most_clipped(n, boxes, b), + } + } + + /// [`Self::most_placed`] for a container placed by [`Self::container`]. + fn most_container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { + let own = self.most_mask(n, boxes, depth).saturating_add(1); + n.children().into_iter().flatten().fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) + } + + /// [`Self::most_placed`] for the opacity mask of `n` as [`Self::place_mask`] places it: a + /// linked one through the map, an unlinked one as it is. + fn most_mask(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { + match n.extras().mask.as_ref() { + Some(m) if m.linked => self.most_at(&m.art, boxes, depth + 1), + Some(m) => m.art.count_with_masks() as u64, + None => 0, + } + } + + /// [`Self::most_placed`] for an object placed by [`Self::clipped`], whose box at the natural + /// size is `b`. + fn most_clipped(&self, n: &Node, boxes: &SymbolBoxes, b: Rect) -> u64 { + let whole = n.count_with_masks() as u64; + if !self.clips || draws_symbol_art(n, boxes.document()) { + return whole; + } + let (cols, rows) = self.clip_cells(n, boxes, b).0; + let cells = ((cols[1] - cols[0] + 1) * (rows[1] - rows[0] + 1)) as u64; + cells.saturating_mul(whole.saturating_add(2)).saturating_add(1) + } + + /// The columns and rows that [`Self::clipped`] draws `n` in, and the box it clips: `n`'s visual + /// box at the natural size joined with its geometric box `b`. + fn clip_cells(&self, n: &Node, boxes: &SymbolBoxes, b: Rect) -> (([usize; 2], [usize; 2]), Rect) { + let v = n.visual(Some(boxes)).map(|v| self.natural.transform_rect_bbox(v)).filter(|v| finite(*v)).map_or(b, |v| v.union(b)); + ((self.x.cells(v.x0, v.x1), self.y.cells(v.y0, v.y1)), v) + } + + fn place_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> Node { + let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { + return transformed(n, self.cell_at(Point::ZERO)); + }; + let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); + if cols[0] == cols[1] && rows[0] == rows[1] { + return transformed(n, self.cell_xf(cols[0], rows[0])); + } + let center = self.cell_xf(self.x.cell(b.center().x), self.y.cell(b.center().y)); + if depth >= MAX_SLICE_DEPTH { + return self.clipped(n, boxes, b, center); + } + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => self.container(n, boxes, depth, center), + NodeKind::Compound { .. } if !self.clips || plain_paint(n) => self.container(n, boxes, depth, center), + NodeKind::Path { path: src, .. } if !self.clips || plain_paint(n) => { + let mut out = n.clone(); + out.transform(center, false); + if let NodeKind::Path { path, live, .. } = &mut out.kind { + *path = self.map_path(src); + *live = None; + } + self.place_mask(n, &mut out, boxes, depth); + out + } + _ => self.clipped(n, boxes, b, center), + } + } + + /// Group, layer or compound path `n`: its members placed one by one, the container itself + /// (its gradients and frames) by `center`. + fn container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32, center: Affine) -> Node { + let mut out = n.clone(); + let kids = out.children_mut().map(std::mem::take).unwrap_or_default(); + out.transform(center, false); + if let Some(children) = out.children_mut() { + *children = kids.iter().map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); + } + self.place_mask(n, &mut out, boxes, depth); + out + } + + /// The linked opacity mask of `src` placed through the map into `out`; an unlinked one stays + /// where it is, as [`Node::transform`] leaves it. + fn place_mask(&self, src: &Node, out: &mut Node, boxes: &SymbolBoxes, depth: u32) { + if let Some(m) = src.extras().mask.as_ref().filter(|m| m.linked) + && let Some(dst) = out.extras_if_any().and_then(|e| e.mask.as_mut()) + { + dst.art = Arc::new(self.place_at(&m.art, boxes, depth + 1)); + } + } + + /// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of + /// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed + /// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`]. + /// + /// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own + /// effects, after its last other visible effect, go on that group with its opacity, blending + /// mode and isolation, and the group applies them to the copies together: they reach past the + /// outer cells instead of being clipped to them. Its other effects stay on each copy, in their + /// order before the moved ones, so effects that reshape it do not reshape the cells' clip + /// paths, and effects that follow its outline follow the copy's. + fn clipped(&self, n: &Node, boxes: &SymbolBoxes, b: Rect, center: Affine) -> Node { + if !self.clips || draws_symbol_art(n, boxes.document()) { + return transformed(n, center); + } + let mut piece = n.clone(); + let own = &mut piece.appearance.effects; + let keep = own.iter().rposition(|e| e.visible && !PAINTED_EFFECTS.contains(&e.id.as_str())).map_or(0, |i| i + 1); + let effects = own.split_off(keep); + let (opacity, blend, isolate) = (piece.opacity, piece.blend, piece.isolate); + if !effects.is_empty() { + (piece.opacity, piece.blend, piece.isolate) = (1.0, vectorcraft_color::BlendMode::Normal, false); + } + let n = &piece; + let ((cols, rows), v) = self.clip_cells(n, boxes, b); + let mut cells = Vec::with_capacity(9); + for j in rows[0]..=rows[1] { + for i in cols[0]..=cols[1] { + let (Some((x0, x1)), Some((y0, y1))) = (self.x.part(i, v.x0, v.x1), self.y.part(j, v.y0, v.y1)) else { continue }; + let local = self.cell_local(i, j); + if local.determinant().abs() < 1e-18 { + continue; + } + let mut area = shapes::rectangle(Rect::new(x0, y0, x1, y1)); + area.transform(local); + let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false }); + clip.appearance = Appearance::default().into(); + let copy = transformed(n, local * self.natural); + cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true }))); + } + } + let mut out = Node::group(n.id, cells); + out.visible = n.visible; + out.locked = n.locked; + out.name = n.name.clone(); + out.knockout_shape = n.knockout_shape; + if !effects.is_empty() { + out.appearance.effects = effects; + (out.opacity, out.blend, out.isolate) = (opacity, blend, isolate); + } + out + } +} + +/// `n` moved by `a`, its strokes at their own weight. +fn transformed(n: &Node, a: Affine) -> Node { + let mut out = n.clone(); + out.transform(a, false); + out +} + +fn finite(b: Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) +} + +/// Whether `n`'s own fills and strokes are solid or empty (no gradient or pattern). +fn plain_paint(n: &Node) -> bool { + n.appearance.items.iter().all(|i| matches!(i.paint(), Paint::None | Paint::Solid { .. })) +} + +/// Whether drawing `n` draws the art of a symbol: `n`, a member of it (the members and sources of +/// live objects included) or the art of an opacity mask in it is a symbol instance, type with an +/// inline graphic, a placed document (whose art may hold instances), an object with a visible +/// brushed stroke (whose brush art may hold instances), or is painted with a pattern of `doc` +/// whose tile draws symbol art (by a visible fill or stroke, or by its characters). Each +/// pattern's tile is looked at once, without recursion. +fn draws_symbol_art(n: &Node, doc: &Document) -> bool { + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![n]; + while let Some(n) = stack.pop() { + let draws = match &n.kind { + NodeKind::SymbolInstance { .. } | NodeKind::PlacedDocument(_) => true, + NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()), + _ => false, + }; + let brushed = n.appearance.items.iter().any(|i| matches!(i, crate::AppearanceItem::Stroke(s) if s.visible && s.brush.is_some())); + if draws || brushed { + return true; + } + let runs = match &n.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + let items = n.appearance.items.iter().filter(|i| i.visible()).map(|i| i.paint()); + for p in items.chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) { + if let Paint::Pattern { pattern, .. } = p + && seen.insert(pattern.as_str()) + && let Some(def) = doc.pattern(pattern) + { + stack.extend(def.art.iter().map(|a| &**a)); + } + } + stack.extend(n.children().into_iter().flatten().map(|c| &**c)); + stack.extend(n.extras().mask.as_ref().map(|m| &*m.art)); + } + false +} + +/// The parameters in (0, 1) where `c` crosses one of the lines x = `xs` or y = `ys`, in order. +fn crossings(c: CubicBez, xs: &[f64; 2], ys: &[f64; 2]) -> Vec { + // Power-basis coefficients of one coordinate of the curve. + let coeffs = |p0: f64, p1: f64, p2: f64, p3: f64| [p0, 3.0 * (p1 - p0), 3.0 * (p2 - 2.0 * p1 + p0), p3 - 3.0 * p2 + 3.0 * p1 - p0]; + let cx = coeffs(c.p0.x, c.p1.x, c.p2.x, c.p3.x); + let cy = coeffs(c.p0.y, c.p1.y, c.p2.y, c.p3.y); + let mut ts = vec![]; + for (k, cuts) in [(cx, xs), (cy, ys)] { + for v in cuts { + for t in vectorcraft_geom::kurbo::common::solve_cubic(k[0] - v, k[1], k[2], k[3]) { + if t.is_finite() && t > 1e-9 && t < 1.0 - 1e-9 { + ts.push(t); + } + } + } + } + ts.sort_by(f64::total_cmp); + ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + ts +} + +/// `c` cut at every crossing of a guide. +fn split(c: CubicBez, xs: &[f64; 2], ys: &[f64; 2]) -> Vec { + let ts = crossings(c, xs, ys); + if ts.is_empty() { + return vec![c]; + } + let mut out = Vec::with_capacity(ts.len() + 1); + let mut t0 = 0.0; + for t in ts.into_iter().chain([1.0]) { + out.push(c.subsegment(t0..t)); + t0 = t; + } + out +} + +impl Document { + /// The 9-slice map for an instance of symbol `name` placed by `xf` (see [`NineSliceMap::new`]): + /// `None` when the symbol has no guides or the instance is at its natural size. The guides cut + /// the art's box with the instances in it measured by their own art. + pub fn symbol_slice_map(&self, name: &str, xf: Affine) -> Option { + let guides = self.symbol_nine_slice(name)?; + let sym = self.symbols.iter().find(|s| s.name == name)?; + let art = self.symbol_boxes().quick().art_bounds(name).or_else(|| sym.art.geometric_bounds())?; + NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf) + } + + /// Art `art` of symbol `name` (stained as the caller draws it) placed by an instance's + /// transform `xf`, with strokes at their own weight: through the symbol's 9-slice guides when + /// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through + /// the guides could make more than [`MAX_SYMBOL_NODES`] objects ([`NineSliceMap::most_placed`]). + pub fn place_symbol_art(&self, name: &str, art: &Node, xf: Affine) -> Option { + match self.symbol_slice_map(name, xf) { + Some(m) if m.most_placed(art, self) > MAX_SYMBOL_NODES => None, + Some(m) => Some(m.place(art, self)), + None => Some(transformed(art, xf)), + } + } + + /// [`Self::place_symbol_art`] for writers that do not apply clipping + /// ([`NineSliceMap::place_unclipped`]). + pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option { + match self.symbol_slice_map(name, xf) { + Some(m) => { + let m = NineSliceMap { clips: false, ..m }; + (m.most_placed(art, self) <= MAX_SYMBOL_NODES).then(|| m.place(art, self)) + } + None => Some(transformed(art, xf)), + } + } +} + +#[cfg(test)] +mod tests { + use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; + use vectorcraft_geom::{Vec2, shapes}; + + use super::*; + use crate::{Appearance, ImageObject, OpacityMask, PatternDef, PlacedDocument, SYMBOL_SIZES, Symbol}; + + fn square(d: &mut Document, r: Rect, stroke: f64) -> Node { + let s = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None }; + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), s, stroke)) + } + + /// A 100 × 100 symbol "S": a 10 × 10 square in each corner, one in the center and one at the + /// middle of the top edge, inside a frame with no paint that gives the art its bounds. + fn markers(guides: Option) -> Document { + let mut d = Document::new(400.0, 400.0); + let rects = [ + Rect::new(0.0, 0.0, 100.0, 100.0), + Rect::new(0.0, 0.0, 10.0, 10.0), + Rect::new(90.0, 0.0, 100.0, 10.0), + Rect::new(0.0, 90.0, 10.0, 100.0), + Rect::new(90.0, 90.0, 100.0, 100.0), + Rect::new(45.0, 45.0, 55.0, 55.0), + Rect::new(45.0, 0.0, 55.0, 10.0), + ]; + let art: Vec> = rects.iter().map(|r| Arc::new(square(&mut d, *r, 0.0))).collect(); + let group = Node::group(d.alloc_id(), art); + d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(group) }); + d.set_symbol_nine_slice("S", guides); + d + } + + fn placed(d: &Document, xf: Affine) -> Node { + d.place_symbol_art("S", &d.symbols[0].art, xf).unwrap() + } + + /// The geometric boxes of the placed art's members. + fn boxes(d: &Document, xf: Affine) -> Vec { + placed(d, xf).children().map(|c| c.iter().filter_map(|c| c.geometric_bounds()).collect()).unwrap_or_default() + } + + fn near(a: Rect, b: Rect) -> bool { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 1e-9) + } + + fn assert_boxes(got: &[Rect], want: &[Rect]) { + assert_eq!(got.len(), want.len(), "{got:?}"); + for (g, w) in got.iter().zip(want) { + assert!(near(*g, *w), "{g:?} vs {w:?} in {got:?}"); + } + } + + /// The guides sit a quarter in by default. At 200 × 150 the corners stay 10 × 10, the center + /// marker becomes 30 × 20 and the top marker stretches in width only. + #[test] + fn a_scaled_instance_keeps_its_corners_and_stretches_between_the_guides() { + assert_eq!(SymbolNineSlice::default(), SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 }); + let d = markers(Some(SymbolNineSlice::default())); + let got = boxes(&d, Affine::translate((20.0, 30.0)) * Affine::scale_non_uniform(2.0, 1.5)); + let want = [ + Rect::new(0.0, 0.0, 200.0, 150.0), + Rect::new(0.0, 0.0, 10.0, 10.0), + Rect::new(190.0, 0.0, 200.0, 10.0), + Rect::new(0.0, 140.0, 10.0, 150.0), + Rect::new(190.0, 140.0, 200.0, 150.0), + Rect::new(85.0, 65.0, 115.0, 85.0), + Rect::new(85.0, 0.0, 115.0, 10.0), + ] + .map(|r| r + Vec2::new(20.0, 30.0)); + assert_boxes(&got, &want); + // Asymmetric guides: 10 % from the left, 40 % from the right. + let d = markers(SymbolNineSlice::new(0.1, 0.25, 0.4, 0.25)); + let got = boxes(&d, Affine::scale_non_uniform(2.0, 1.0)); + // The middle column runs from 10 to 60 and is stretched to 10..160 (3 times). + assert!(near(got[5], Rect::new(10.0 + 35.0 * 3.0, 45.0, 10.0 + 45.0 * 3.0, 55.0)), "{:?}", got[5]); + assert!(near(got[2], Rect::new(190.0, 0.0, 200.0, 10.0)), "{:?}", got[2]); + } + + /// Without guides, or at the natural size, the transform places the art as it is. + #[test] + fn without_guides_or_scale_the_art_is_placed_by_the_transform() { + let xf = Affine::scale_non_uniform(2.0, 1.5); + let d = markers(None); + assert!(d.symbol_slice_map("S", xf).is_none()); + assert!(near(boxes(&d, xf)[1], Rect::new(0.0, 0.0, 20.0, 15.0))); + let d = markers(Some(SymbolNineSlice::default())); + let moved = Affine::translate((5.0, 6.0)) * Affine::rotate(0.5); + assert!(d.symbol_slice_map("S", moved).is_none()); + assert!(d.symbol_slice_map("S", Affine::scale(0.0)).is_none()); + assert!(d.symbol_slice_map("S", Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])).is_none()); + assert!(d.symbol_slice_map("S", Affine::new([f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0])).is_none()); + assert!(d.symbol_slice_map("Missing", xf).is_none()); + } + + /// At 30 × 30 the 25 + 25 of the outer cells do not fit: both shrink in proportion (to 15) and + /// the center is empty, never turned inside out. + #[test] + fn a_target_smaller_than_the_outer_cells_shrinks_them() { + let d = markers(Some(SymbolNineSlice::default())); + let got = boxes(&d, Affine::scale(0.3)); + assert!(near(got[1], Rect::new(0.0, 0.0, 6.0, 6.0)), "{:?}", got[1]); + assert!(near(got[4], Rect::new(24.0, 24.0, 30.0, 30.0)), "{:?}", got[4]); + let c = got[5]; + assert!(c.width().abs() < 1e-9 && c.height().abs() < 1e-9 && near(c, Rect::new(15.0, 15.0, 15.0, 15.0)), "{c:?}"); + for b in boxes(&d, Affine::scale(1e-6)) { + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() >= 0.0 && b.height() >= 0.0, "{b:?}"); + } + // Zero-width guides on one side leave that side's corners out of the outer cells. + let d = markers(SymbolNineSlice::new(0.0, 0.25, 0.25, 0.0)); + let got = boxes(&d, Affine::scale(2.0)); + assert!(near(got[0], Rect::new(0.0, 0.0, 200.0, 200.0)), "{:?}", got[0]); + assert!(got.iter().all(|b| finite(*b)), "{got:?}"); + } + + /// A negative scale reflects the sized cells; a rotation turns them, the corners keeping + /// their size along the symbol's own axes. + #[test] + fn reflection_and_rotation_apply_after_the_cells_are_sized() { + let d = markers(Some(SymbolNineSlice::default())); + let got = boxes(&d, Affine::scale_non_uniform(-2.0, 1.0)); + // The top-left corner square is now at the right, still 10 wide; the center is 30 wide. + assert!(near(got[1], Rect::new(-10.0, 0.0, 0.0, 10.0)), "{:?}", got[1]); + assert!(near(got[5], Rect::new(-115.0, 45.0, -85.0, 55.0)), "{:?}", got[5]); + // Reflected top to bottom (a negative determinant): the corner square stays 10 × 10. + let got = boxes(&d, Affine::scale_non_uniform(2.0, -1.5)); + assert!(near(got[1], Rect::new(0.0, -10.0, 10.0, 0.0)), "{:?}", got[1]); + assert!(near(got[5], Rect::new(85.0, -85.0, 115.0, -65.0)), "{:?}", got[5]); + let turn = Affine::translate((50.0, 50.0)) * Affine::rotate(std::f64::consts::FRAC_PI_6); + let m = d.symbol_slice_map("S", turn * Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let o = m.map_point(Point::ORIGIN); + assert!((o - Point::new(50.0, 50.0)).hypot() < 1e-9); + assert!(((m.map_point(Point::new(10.0, 0.0)) - o).hypot() - 10.0).abs() < 1e-9); + assert!(((m.map_point(Point::new(0.0, 10.0)) - o).hypot() - 10.0).abs() < 1e-9); + assert!(((m.map_point(Point::new(100.0, 0.0)) - o).hypot() - 200.0).abs() < 1e-9); + assert!(((m.map_point(Point::new(0.0, 100.0)) - o).hypot() - 150.0).abs() < 1e-9); + } + + /// A sheared instance sizes the cells along the symbol's axes before the shear: the corner is + /// the unscaled corner sheared, the art's outer corners land where the instance's transform + /// puts them, and the map is continuous at the guides. A shear alone scales nothing. + #[test] + fn a_sheared_instance_keeps_its_corners_along_the_symbols_axes() { + let d = markers(Some(SymbolNineSlice::default())); + let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 10.0, 20.0]); + assert!(d.symbol_slice_map("S", shear).is_none()); + assert!(d.symbol_slice_map("S", Affine::new([1.0, 1.0, 0.0, 1.0, 0.0, 0.0])).is_none(), "a shear along y"); + // A turn by -60° of the shear along y by √3: (1, 0) goes to (2, 0), (0, 1) to (√0.75, 0.5). + let turned = Affine::rotate(-std::f64::consts::FRAC_PI_3) * Affine::new([1.0, 3f64.sqrt(), 0.0, 1.0, 0.0, 0.0]); + assert!((turned * Point::new(1.0, 0.0) - Point::new(2.0, 0.0)).hypot() < 1e-9, "{turned:?}"); + assert!(d.symbol_slice_map("S", turned).is_none()); + let xf = shear * Affine::scale_non_uniform(2.0, 1.5); + let m = d.symbol_slice_map("S", xf).unwrap(); + for p in [Point::new(10.0, 0.0), Point::new(0.0, 10.0), Point::new(10.0, 10.0)] { + assert!((m.map_point(p) - shear * p).hypot() < 1e-9, "{p:?}"); + } + for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0)] { + assert!((m.map_point(p) - xf * p).hypot() < 1e-9, "{p:?}"); + } + for g in [25.0, 75.0] { + let before = m.map_point(Point::new(g - 1e-7, 40.0)); + let after = m.map_point(Point::new(g + 1e-7, 40.0)); + assert!((before - after).hypot() < 1e-5, "{g}"); + } + } + + /// A path that crosses the guides is cut where it crosses them, each piece placed by its cell; + /// its stroke keeps its weight and its live shape becomes a plain path. + #[test] + fn paths_across_the_guides_are_cut_at_them() { + let mut d = markers(Some(SymbolNineSlice::default())); + let diagonal = Node::path( + d.alloc_id(), + shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 100.0)), + Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0), + ); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let placed = m.place(&diagonal, &d); + let pts: Vec = placed.path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect(); + let want = [Point::new(0.0, 0.0), Point::new(25.0, 25.0), Point::new(175.0, 125.0), Point::new(200.0, 150.0)]; + assert_eq!(pts.len(), want.len(), "{pts:?}"); + for (p, w) in pts.iter().zip(want) { + assert!((*p - w).hypot() < 1e-9, "{pts:?}"); + } + assert_eq!(placed.appearance.stroke().map(|s| s.width), Some(4.0)); + // A closed curve crossing a guide keeps its shape on each side of it. + let circle = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(10.0, 10.0, 50.0, 40.0)), Appearance::default()); + let placed = m.place(&circle, &d); + let b = placed.geometric_bounds().unwrap(); + // The middle column is stretched 3 times and the middle row 2 times past the guides at 25. + assert!(near(b, Rect::new(10.0, 10.0, 25.0 + 25.0 * 3.0, 25.0 + 15.0 * 2.0)), "{b:?}"); + // The two segments that cross the guide at x = 25 between their anchors are cut there. + assert_eq!(placed.path_data().unwrap().anchor_count(), 6, "{placed:?}"); + for (_, _, a) in placed.path_data().unwrap().anchors() { + assert!([a.p.x, a.p.y, a.h_in.x, a.h_out.y].iter().all(|v| v.is_finite())); + } + assert!(placed.path_data().unwrap().is_closed()); + // A path with points that are not finite gives a path, not a crash. + let bad = Node::path(d.alloc_id(), shapes::line(Point::new(f64::NAN, 0.0), Point::new(100.0, f64::INFINITY)), Appearance::default()); + let _ = m.place(&bad, &d); + } + + /// A linked opacity mask is placed through the guides with its object; an unlinked one stays + /// where it is, as any transform leaves it. + #[test] + fn linked_masks_follow_the_guides_and_unlinked_ones_stay() { + let mut d = markers(Some(SymbolNineSlice::default())); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let mask_art = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + for linked in [true, false] { + let mut mask = crate::OpacityMask::new(mask_art.clone(), true); + mask.linked = linked; + let bar = square(&mut d, Rect::new(0.0, 40.0, 100.0, 60.0), 0.0).with_mask(Some(Box::new(mask))); + let p = m.place(&bar, &d); + let got = p.extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap(); + let want = if linked { Rect::new(0.0, 0.0, 200.0, 150.0) } else { Rect::new(0.0, 0.0, 100.0, 100.0) }; + assert!(near(got, want), "linked {linked}: {got:?}"); + } + } + + /// An object that lies in one corner keeps its size and kind: a live rectangle stays live and + /// an image keeps its pixel scale. + #[test] + fn objects_in_one_cell_are_placed_by_that_cell() { + let mut d = markers(Some(SymbolNineSlice::default())); + let shape = crate::LiveShape::Rectangle { + w: 6.0, + h: 6.0, + radii: [1.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((92.0, 92.0)), + }; + let mut live = Node::path(d.alloc_id(), shape.to_path(), Appearance::default()); + if let NodeKind::Path { live: l, .. } = &mut live.kind { + *l = Some(Box::new(shape)); + } + let image = ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: Affine::translate((2.0, 2.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&live, &d); + assert!(matches!(p.kind, NodeKind::Path { live: Some(_), .. }), "{p:?}"); + assert!(near(p.geometric_bounds().unwrap(), Rect::new(192.0, 142.0, 198.0, 148.0))); + let p = m.place(&image, &d); + let NodeKind::Image(im) = &p.kind else { panic!("{p:?}") }; + assert_eq!(im.xf, Affine::translate((2.0, 2.0))); + } + + /// A gradient-filled path and an image that cross the guides are drawn in each cell they + /// reach, clipped to it: the pieces' clip areas tile the stretched box with no gap. + #[test] + fn other_art_across_the_guides_is_clipped_to_each_cell() { + let mut d = markers(Some(SymbolNineSlice::default())); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&bar, &d); + assert_eq!(p.id, bar.id); + let cells = p.children().unwrap(); + // Three columns by one row: the bar lies between the horizontal guides. + assert_eq!(cells.len(), 3, "{p:?}"); + let mut width = 0.0; + for c in cells { + assert!(matches!(c.kind, NodeKind::Group { clip: true, .. })); + let clip = c.children().unwrap()[0].geometric_bounds().unwrap(); + width += clip.width(); + let copy = &c.children().unwrap()[1]; + assert!(matches!(copy.appearance.fill().map(|f| &f.paint), Some(Paint::Gradient(_)))); + } + // The clip areas cover the 200-point stretched bar and no more. + assert!((width - 200.0).abs() < 1e-9, "{width}"); + assert!(near(p.geometric_bounds().unwrap(), Rect::new(0.0, 55.0, 200.0, 95.0)), "{:?}", p.geometric_bounds()); + // An image across the vertical guides: three pieces, each its own image. + let image = ImageObject { + key: "k".into(), + width: 100, + height: 10, + xf: Affine::translate((0.0, 45.0)), + link: None, + placement: Default::default(), + pixels: None, + }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); + let p = m.place(&image, &d); + assert!(p.count() as u64 <= MAX_SLICE_GROWTH); + let kids = p.children().unwrap(); + assert_eq!(kids.len(), 3); + assert!(kids.iter().all(|c| matches!(c.children().unwrap()[1].kind, NodeKind::Image(_)))); + // For writers without clipping, the bar is one path cut at the guides and the image one + // image placed by the center cell. + let p = m.place_unclipped(&bar, &d); + assert!(matches!(p.kind, NodeKind::Path { .. }), "{p:?}"); + assert!(near(p.geometric_bounds().unwrap(), Rect::new(0.0, 55.0, 200.0, 95.0)), "{:?}", p.geometric_bounds()); + assert!(matches!(m.place_unclipped(&image, &d).kind, NodeKind::Image(_))); + } + + /// A shadowed, rounded, feathered, half-opaque, multiplied and isolated gradient bar across + /// the guides: its pieces keep the round corners and the feathering and are drawn without the + /// shadow at default transparency, and the group around them carries the shadow, the opacity, + /// the blending mode and the isolation. + #[test] + fn effects_on_art_across_the_guides_apply_to_all_its_cells() { + let mut d = markers(Some(SymbolNineSlice::default())); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let shadow = crate::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 7, "y": 7, "blur": 5}), visible: true }; + let round = crate::Effect { id: "stylize.roundCorners".into(), params: json!({"radius": 4}), visible: true }; + let feather = crate::Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true }; + bar.appearance.effects = vec![round.clone(), feather.clone(), shadow.clone()]; + (bar.opacity, bar.blend, bar.isolate) = (0.5, vectorcraft_color::BlendMode::Multiply, true); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&bar, &d); + assert_eq!(p.appearance.effects, vec![shadow.clone()]); + assert_eq!((p.opacity, p.blend, p.isolate), (0.5, vectorcraft_color::BlendMode::Multiply, true)); + let cells = p.children().unwrap(); + assert_eq!(cells.len(), 3, "{p:?}"); + // The copies keep the effects that reshape them or follow their outline. + for c in cells { + let [clip, copy] = c.children().unwrap().as_slice() else { panic!("{c:?}") }; + assert!(clip.appearance.effects.is_empty(), "{clip:?}"); + assert_eq!(copy.appearance.effects, vec![round.clone(), feather.clone()]); + assert!(copy.has_default_transparency(), "{copy:?}"); + } + // A shadow followed by an effect that stays on the copies stays with it, in its order. + bar.appearance.effects = vec![shadow.clone(), round.clone()]; + let p = m.place(&bar, &d); + assert!(p.appearance.effects.is_empty() && p.opacity == 1.0, "{p:?}"); + assert!(p.children().unwrap().iter().all(|c| c.children().unwrap()[1].appearance.effects == vec![shadow.clone(), round.clone()])); + // A hidden effect after it does not keep it on the copies. + let hidden = crate::Effect { visible: false, ..round.clone() }; + bar.appearance.effects = vec![shadow.clone(), hidden.clone()]; + assert_eq!(m.place(&bar, &d).appearance.effects, vec![shadow.clone(), hidden]); + // Without effects the pieces keep the opacity. + bar.appearance.effects.clear(); + let p = m.place(&bar, &d); + assert_eq!(p.opacity, 1.0); + assert!(p.children().unwrap().iter().all(|c| c.children().unwrap()[1].opacity == 0.5)); + } + + /// A gradient bar across the guides with Knockout Shape set keeps it on the group that holds + /// its cells, the object a knockout group reads it from. + #[test] + fn knockout_shape_on_art_across_the_guides_stays_on_its_group() { + let mut d = markers(Some(SymbolNineSlice::default())); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + bar.knockout_shape = true; + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&bar, &d); + assert_eq!(p.children().map(|c| c.len()), Some(3), "{p:?}"); + assert!(p.knockout_shape, "{p:?}"); + } + + /// A locked gradient bar across the guides stays locked: the group that holds its cells is + /// locked too, so Select All does not list it. + #[test] + fn locked_art_across_the_guides_stays_locked() { + let mut d = markers(Some(SymbolNineSlice::default())); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + bar.locked = true; + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let p = m.place(&bar, &d); + assert_eq!(p.children().map(|c| c.len()), Some(3), "{p:?}"); + assert!(p.locked, "{p:?}"); + } + + /// An instance that does not fit leaves the budget as it was: it needs room for the most + /// objects placing can make, and a plain one after it still fits. + #[test] + fn a_skipped_instance_leaves_room_for_the_next() { + let mut used = MAX_SYMBOL_NODES - 100; + let mut placed = false; + assert_eq!( + charge_symbol_art(&mut used, 280, || { + placed = true; + ((), 10) + }), + None + ); + assert!(!placed && used == MAX_SYMBOL_NODES - 100); + // Placed, but it made more objects than are left. + assert_eq!(charge_symbol_art(&mut used, 84, || ((), 101)), None); + assert_eq!(used, MAX_SYMBOL_NODES - 100); + assert_eq!(charge_symbol_art(&mut used, 100, || ("plain", 100)), Some("plain")); + assert_eq!(used, MAX_SYMBOL_NODES); + assert_eq!(charge_symbol_art(&mut used, 1, || ((), 1)), None); + // An instance is charged the objects placing made. + let mut used = 0; + assert_eq!(charge_symbol_art(&mut used, 280, || ((), 37)), Some(())); + assert_eq!(used, 37); + } + + /// The bound on what placing makes counts each object in one cell once and each object across + /// the guides by the cells it reaches, and is never below what placing makes. + #[test] + fn the_bound_on_placed_objects_follows_the_cells_they_reach() { + let mut d = markers(Some(SymbolNineSlice::default())); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + // Six squares in one cell each and a plain frame cut at the guides: the group and its seven. + let art = d.symbols[0].art.clone(); + assert_eq!(m.most_placed(&art, &d), 8); + assert_eq!(m.place(&art, &d).count(), 8); + // Many objects in one corner count one each. + let many: Vec> = (0..1_000).map(|_| Arc::new(square(&mut d, Rect::new(1.0, 1.0, 9.0, 9.0), 0.0))).collect(); + let corner = Node::group(d.alloc_id(), many); + assert_eq!(m.most_placed(&corner, &d), 1_001); + assert_eq!(m.place(&corner, &d).count(), 1_001); + // A gradient bar across three columns: a group, then a clip path and a copy in each. + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + assert_eq!(m.most_placed(&bar, &d), 10); + assert_eq!(m.place(&bar, &d).count(), 10); + // Art nested past the depth limit, clipped in all nine cells. + let mut deep = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + for _ in 0..=MAX_SLICE_DEPTH { + deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]); + } + let most = m.most_placed(&deep, &d); + assert!(most >= m.place(&deep, &d).count() as u64 && most <= deep.count() as u64 * MAX_SLICE_GROWTH, "{most}"); + } + + /// The bound counts the art of opacity masks, which placing copies into each cell (unlinked) + /// or places through the guides (linked). + #[test] + fn the_bound_on_placed_objects_counts_opacity_masks() { + let mut d = markers(Some(SymbolNineSlice::default())); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let many: Vec> = (0..1_000).map(|_| Arc::new(square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0))).collect(); + let mask = Node::group(d.alloc_id(), many); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let mut frame = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + for linked in [false, true] { + let mut om = OpacityMask::new(mask.clone(), true); + om.linked = linked; + bar.extras_mut().mask = Some(Box::new(om.clone())); + frame.extras_mut().mask = Some(Box::new(om)); + let group = Node::group(d.alloc_id(), vec![Arc::new(bar.clone()), Arc::new(frame.clone())]); + for art in [&bar, &frame, &group] { + let most = m.most_placed(art, &d); + let placed = m.place(art, &d).count_with_masks() as u64; + assert!(most >= placed && placed > 1_000, "linked {linked}: {most} < {placed}"); + } + } + } + + /// Art that placing through the guides could make more than [`MAX_SYMBOL_NODES`] objects of + /// is not placed. Without clips the same path is cut at the guides once, with one copy of its + /// mask, and is placed; at the natural size it is moved as it is. + #[test] + fn symbol_art_past_the_object_limit_is_not_placed() { + let mut d = markers(Some(SymbolNineSlice::default())); + let leaf = Arc::new(square(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0)); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_500])); + let mut mask = OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_500]), true); + mask.linked = false; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + art.extras_mut().mask = Some(Box::new(mask)); + let xf = Affine::scale_non_uniform(2.0, 1.5); + assert!(d.place_symbol_art("S", &art, xf).is_none()); + assert!(d.place_symbol_art_unclipped("S", &art, xf).is_some()); + assert!(d.place_symbol_art("S", &art, Affine::translate((5.0, 5.0))).is_some()); + } + + /// A symbol instance that crosses the guides is placed as one piece, by the cell its center is + /// in, so copies do not multiply with nesting; one in a corner is placed by the corner. + #[test] + fn nested_instances_are_placed_as_one_piece() { + let mut d = markers(Some(SymbolNineSlice::default())); + let wide = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::new([0.8, 0.0, 0.0, 0.1, 10.0, 45.0]) }); + let corner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::new([0.05, 0.0, 0.0, 0.05, 92.0, 92.0]) }); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let NodeKind::SymbolInstance { xf, .. } = m.place(&wide, &d).kind else { panic!() }; + // Its center is in the center cell, which stretches 3 times across and 2 times down. + assert!((xf.as_coeffs()[0] - 2.4).abs() < 1e-9 && (xf.as_coeffs()[3] - 0.2).abs() < 1e-9, "{xf:?}"); + let NodeKind::SymbolInstance { xf, .. } = m.place(&corner, &d).kind else { panic!() }; + assert_eq!(xf, Affine::new([0.05, 0.0, 0.0, 0.05, 192.0, 142.0])); + } + + /// Type with an inline graphic, a placed document, a brushed stroke and a pattern whose tile + /// holds an instance draw symbol art: across the guides each is placed by its center's cell as + /// one piece, and counted as one, while the same art without them is drawn in each cell. + #[test] + fn art_that_draws_symbol_art_across_the_guides_is_placed_as_one_piece() { + let mut d = markers(Some(SymbolNineSlice::default())); + let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap(); + let across = Rect::new(0.0, 40.0, 100.0, 60.0); + let one_piece = |d: &Document, n: &Node| { + let p = m.place(n, d); + assert_eq!(std::mem::discriminant(&p.kind), std::mem::discriminant(&n.kind), "{p:?}"); + assert_eq!(m.most_placed(n, d), n.count_with_masks() as u64); + }; + let in_cells = |d: &Document, n: &Node| { + let p = m.place(n, d); + assert_eq!(p.children().map(Vec::len), Some(3), "{p:?}"); + }; + // Type. + let mut text = crate::TextObject::point(Point::ZERO, "Label", crate::CharStyle::default()); + text.kind = crate::TextKind::Area { frame: shapes::rectangle(across) }; + text.xf = Affine::IDENTITY; + let plain = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text.clone()))); + text.runs[0].inline = Some(crate::InlineArt::new("S")); + let inline = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text))); + in_cells(&d, &plain); + one_piece(&d, &inline); + // A placed document. + let doc = PlacedDocument { + link: crate::LinkInfo::new("/art/logo.vectorcraft"), + key: "k".into(), + bounding: false, + page_box: crate::PageBox::Crop, + width: 100.0, + height: 20.0, + xf: Affine::translate((0.0, 40.0)), + placement: Default::default(), + }; + let doc = Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(doc))); + one_piece(&d, &doc); + // A brushed stroke on a gradient bar, in a group so the group's member is the one placed. + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let bar = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(gradient, Paint::solid(Color::BLACK), 1.0)); + in_cells(&d, &bar); + let mut brushed = bar.clone(); + brushed.appearance.stroke_mut().unwrap().brush = Some("Brush".into()); + one_piece(&d, &brushed); + let group = Node::group(d.alloc_id(), vec![Arc::new(brushed)]); + assert!(matches!(m.place(&group, &d).children().unwrap()[0].kind, NodeKind::Path { .. })); + // Patterns: one whose tile holds an instance (through a second pattern), one whose does not. + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::scale(0.1) }); + let mut tile = square(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0); + d.patterns.push(PatternDef::new("Plain", vec![Arc::new(tile.clone())])); + d.patterns.push(PatternDef::new("Inner", vec![Arc::new(instance)])); + tile.appearance = Appearance::basic(Paint::Pattern { pattern: "Inner".into(), xf: Default::default() }, Paint::None, 0.0).into(); + d.patterns.push(PatternDef::new("Outer", vec![Arc::new(tile)])); + for (name, holds) in [("Plain", false), ("Outer", true)] { + let fill = Paint::Pattern { pattern: name.into(), xf: Default::default() }; + let bar = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(fill, Paint::None, 0.0)); + if holds { one_piece(&d, &bar) } else { in_cells(&d, &bar) } + } + // A hidden stroke painted with that pattern draws nothing: the gradient bar is drawn in + // each cell. + let outer = Paint::Pattern { pattern: "Outer".into(), xf: Default::default() }; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(gradient, outer, 1.0)); + hidden.appearance.stroke_mut().unwrap().visible = false; + in_cells(&d, &hidden); + // A brushed path with solid paint is cut at the guides as one path, drawn once. + let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0); + let mut solid = Node::path(d.alloc_id(), shapes::rectangle(across), black); + solid.appearance.stroke_mut().unwrap().brush = Some("Brush".into()); + assert!(matches!(m.place(&solid, &d).kind, NodeKind::Path { .. })); + } + + /// Art nested deeper than [`MAX_SLICE_DEPTH`] is clipped to the cells it reaches, so it still + /// draws with its corners unstretched; paths are not cut at the guides there. + #[test] + fn art_nested_past_the_limit_is_clipped_to_the_cells() { + let mut d = Document::new(400.0, 400.0); + let mut deep = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + for _ in 0..=MAX_SLICE_DEPTH { + deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]); + } + d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(deep) }); + d.set_symbol_nine_slice("S", Some(SymbolNineSlice::default())); + let mut n = placed(&d, Affine::scale_non_uniform(2.0, 1.5)); + assert!(n.count() as u64 <= d.symbols[0].art.count() as u64 * MAX_SLICE_GROWTH); + for _ in 0..MAX_SLICE_DEPTH { + n = (*n.children().unwrap()[0]).clone(); + } + let cells = n.children().unwrap(); + assert_eq!(cells.len(), 9, "{n:?}"); + assert!(cells.iter().all(|c| matches!(c.kind, NodeKind::Group { clip: true, .. }))); + // The top-left cell's copy is the path moved, at its natural size. + let copy = &cells[0].children().unwrap()[1]; + assert!(near(copy.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)), "{copy:?}"); + } + + /// Symbols made in the app keep their art in a 20 × 20 square and their size apart: the + /// guides cut the art at its natural size. + #[test] + fn the_guides_cut_the_art_at_its_natural_size() { + let mut d = Document::new(400.0, 400.0); + let corner = square(&mut d, Rect::new(-10.0, -10.0, -8.0, -8.0), 0.0); + let frame = square(&mut d, Rect::new(-10.0, -10.0, 10.0, 10.0), 0.0); + let group = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(corner)]); + d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(group) }); + d.unknown.insert(SYMBOL_SIZES.into(), json!({ "S": [100.0, 100.0] })); + d.set_symbol_nine_slice("S", Some(SymbolNineSlice::default())); + // 200 × 150 is 10 × 7.5 times the 20 × 20 art. + let got = boxes(&d, Affine::scale_non_uniform(10.0, 7.5)); + assert!(near(got[0], Rect::new(-100.0, -75.0, 100.0, 75.0)), "{:?}", got[0]); + assert!(near(got[1], Rect::new(-100.0, -75.0, -90.0, -65.0)), "{:?}", got[1]); + // At its natural size the guides change nothing. + assert!(d.symbol_slice_map("S", Affine::scale(5.0)).is_none()); + } + + /// A scaled instance with strokes in its art is measured by placing the art through the + /// guides only while placing it stays within the limit; past it, the art is not placed and + /// the instance is measured by its art box and the strokes' reach. + #[test] + fn a_stroked_instance_past_the_limit_is_measured_without_placing_its_art() { + let mut d = markers(Some(SymbolNineSlice::default())); + let ring = square(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), 4.0); + let mut art = (*d.symbols[0].art).clone(); + art.children_mut().unwrap().push(Arc::new(ring)); + d.symbols[0].art = Arc::new(art); + let xf = Affine::scale_non_uniform(2.0, 1.5); + let most = d.symbol_slice_map("S", xf).unwrap().most_placed(&d.symbols[0].art, &d); + let whole = Rect::new(0.0, 0.0, 200.0, 150.0); + let boxes = d.symbol_boxes(); + let placed = boxes.placed_visual_within("S", xf, most); + assert!(placed.is_some_and(|b| near(b, whole)), "{placed:?}"); + assert_eq!(boxes.placed_visual_within("S", xf, most - 1), None); + // Within the real limit the instance's visual box is the placed art's. + let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + let v = inst.visual_bounds_with(&d.symbol_boxes()); + assert!(v.is_some_and(|b| near(b, whole)), "{v:?}"); + } +} diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index bb3cbf11f..7e371c670 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use vectorcraft_geom::{Affine, Rect}; -use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF}; +use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF}; /// The box a symbol instance is measured by when its symbol's art has no box /// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin @@ -303,17 +303,28 @@ impl<'a> SymbolBoxes<'a> { } } - /// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it, - /// its strokes at their own weight, the instances in it measured by their art boxes. `None` - /// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a - /// click's box is measured. + /// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it + /// (through its 9-slice guides when it has them, [`Document::place_symbol_art`]), its strokes + /// at their own weight, the instances in it measured by their art boxes. `None` + /// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], while symbol art or a + /// click's box is measured, and when placing the art through its guides could make more than + /// [`MAX_SYMBOL_NODES`] objects ([`crate::NineSliceMap::most_placed`]). pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option { + self.placed_visual_within(name, xf, MAX_SYMBOL_NODES) + } + + /// [`Self::placed_visual`], `None` when placing the art through its guides could make more + /// than `most` objects. + pub(crate) fn placed_visual_within(&self, name: &str, xf: Affine, most: u64) -> Option { if self.quick.get() { return None; } self.measure_art(name, |art| { - let mut art = (**art).clone(); - art.transform(xf, false); + let art = match self.doc.symbol_slice_map(name, xf) { + Some(m) if m.most_placed(art, self.doc) > most => return None, + Some(m) => m.place(art, self.doc), + None => self.doc.place_symbol_art(name, art, xf)?, + }; self.in_art(|| art.visual(Some(self))) }) .flatten() diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index 769eae6b5..8767d5741 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -19,13 +19,16 @@ pub fn text_outliner() -> Outliner<'static> { Some(&outline_text) } -/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance. +/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance, +/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None` +/// past its object limit). fn outline(doc: Option<&Document>, n: &Node) -> Option { match &n.kind { NodeKind::SymbolInstance { symbol, xf } => { - let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?; - let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); - art.transform(*xf, false); + let doc = doc?; + let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + let art = doc.place_symbol_art(symbol, &art, *xf)?; Some(Node::group(n.id, vec![Arc::new(art)])) } _ => outline_text(n), diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 1ebfe8fc2..69de857cb 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -244,14 +244,22 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform. `None` for other kinds (images, paths, containers). A Repeat counts -/// the Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. +/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them). +/// `None` for other kinds (images, paths, containers), and for an instance whose art placed +/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of +/// `doc`'s symbols that its instances show toward its instance budget. pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { let mut art = match &n.kind { NodeKind::Text(_) => outline_text(n)?, - NodeKind::SymbolInstance { xf, .. } => { - let mut a = symbol_art?.clone(); - a.transform(*xf, false); + NodeKind::SymbolInstance { symbol, xf } => { + let a = match doc { + Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?, + None => { + let mut a = symbol_art?.clone(); + a.transform(*xf, false); + a + } + }; Node::group(n.id, vec![Arc::new(a)]) } // The object as the one piece the copies of its Transform effect are made from. diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index cff94b0f4..bc309ce07 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -133,6 +133,15 @@ fn geometry_effects_are_deterministic() { } } +/// The effects that symbol art across 9-slice guides draws once around all its cells are raster +/// effects that do not reshape it. +#[test] +fn nine_slice_painted_effects_are_raster_effects() { + for id in vectorcraft_doc::PAINTED_EFFECTS { + assert!(is_raster(id) && !is_geometry(id), "{id}"); + } +} + #[test] fn open_paths_stay_open() { let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(50.0, 20.0), Point::new(100.0, 0.0)], false)); diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 85b209d56..adfb221ef 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -174,7 +174,7 @@ pub fn specs() -> Vec { "New Symbol…", [], None, - "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice, type}", + "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}", has_selection, symbol_new ), @@ -201,7 +201,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol) → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them → {ids}", has_selection, symbol_break_link ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 8eb21dd64..9a3606311 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -47,7 +47,8 @@ fn export_type_param(p: &Value, cmd: &str) -> Result> { /// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural /// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it -/// on with guides `g` (the symbol's current ones, else the centre third, when none are given). +/// on with guides `g` (the symbol's current ones, else a quarter in from each edge, when none are +/// given). fn nine_slice_param(p: &Value, cmd: &str, current: Option) -> Result>> { let on = match p.get("nineSlice") { None | Some(Value::Null) => None, @@ -339,7 +340,8 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { } /// The plain art of instance `inst` in document space (fresh ids), with the instance's own -/// appearance changes to a dynamic symbol's art when `own`. +/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice +/// guides when it has them. fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; @@ -349,7 +351,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { { brush::tint_node(&mut art, &c, f.opacity); } - art.transform(*xf, false); + let art = d.place_symbol_art(symbol, &art, *xf)?; let mut art = d.reid(&art); if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal { if !matches!(art.kind, NodeKind::Group { .. }) { @@ -362,9 +364,31 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { Some(art) } +/// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its +/// symbol's object count with the art of its opacity masks, or +/// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus +/// the group that opacity or a blending mode adds. 0 for any other object. +fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { + let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 }; + let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 }; + let dynamic = own && !inst.extras().symbol_overrides.is_empty(); + let most = match d.symbol_slice_map(symbol, *xf) { + Some(m) if dynamic => m.most_placed(&vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst), d), + Some(m) => m.most_placed(&sym.art, d), + None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64, + None => sym.art.count_with_masks() as u64, + }; + most.saturating_add(1) +} + /// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the -/// art's ids. +/// art's ids. Fails before any edit when the instances together would make more than +/// [`MAX_EXPAND_NODES`] objects. fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { + let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own))); + if total > MAX_EXPAND_NODES as u64 { + return Err(EngineError::Other("Too many pieces to make at once".into())); + } let mut out = vec![]; for id in ids { let Some(inst) = d.node(*id).cloned() else { continue }; @@ -378,6 +402,9 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result Result> { let d = &s.doc()?.doc; let mut out = vec![]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4345f640c..b1f1c2599 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -158,6 +158,7 @@ mod styles; mod swatchcmds; mod swatches; mod swatchlib; +mod symbol_nine_slice; mod symbolbounds; mod symboledit; mod symbollib; diff --git a/crates/engine/src/tests/symbol_nine_slice.rs b/crates/engine/src/tests/symbol_nine_slice.rs new file mode 100644 index 000000000..127af65c7 --- /dev/null +++ b/crates/engine/src/tests/symbol_nine_slice.rs @@ -0,0 +1,211 @@ +//! Symbol Options › 9-slice guides: a scaled instance stays an instance and draws its corners at +//! their size; Break Link to Symbol gives the art as it draws, in one step. + +use serde_json::json; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::Rect; + +use crate::*; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn close(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +/// A 100 × 100 frame with a 10 × 10 square in its top-left corner and one in its center, made +/// into symbol "Panel" with 9-slice guides on, its instance selected at its natural size. +fn panel_symbol() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ids = [rect(&mut s, 50.0, 50.0, 100.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0, 10.0)]; + s.select(|_, sel| sel.set(ids)).unwrap(); + let r = s.execute("symbol.new", &json!({"name": "Panel", "nineSlice": true})).unwrap(); + assert_eq!(r["nineSlice"], json!([0.25, 0.25, 0.25, 0.25])); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +/// [`panel_symbol`] with its instance resized to 200 × 150 at (50, 50). +fn panel() -> (Session, NodeId) { + let (mut s, id) = panel_symbol(); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap(); + (s, id) +} + +/// The stroke weights of the paths under `n`, in paint order. +fn stroke_widths(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) { + out.push(c.appearance.stroke_width()); + } + }); + out +} + +/// The geometric boxes of the paths under `n`, in paint order. +fn path_boxes(n: &Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |c| { + if let NodeKind::Path { path, .. } = &c.kind { + out.extend(path.bounds()); + } + }); + out +} + +/// The art as a 200 × 150 instance draws it: the frame, the corner square at 10 × 10 and the +/// center square at 30 × 20. +fn assert_sliced(boxes: &[Rect]) { + let want = [Rect::new(50.0, 50.0, 250.0, 200.0), Rect::new(50.0, 50.0, 60.0, 60.0), Rect::new(135.0, 115.0, 165.0, 135.0)]; + assert_eq!(boxes.len(), want.len(), "{boxes:?}"); + for (b, w) in boxes.iter().zip(want) { + assert!(close(*b, w), "{b:?} vs {w:?} in {boxes:?}"); + } +} + +#[test] +fn a_resized_instance_stays_linked_and_break_link_gives_the_sliced_art() { + let (mut s, id) = panel(); + let d = &s.doc().unwrap().doc; + let inst = d.node(id).unwrap().clone(); + assert!(matches!(inst.kind, NodeKind::SymbolInstance { .. }), "still an instance"); + assert!(close(inst.geometric_bounds_with(&d.symbol_boxes()).unwrap(), Rect::new(50.0, 50.0, 250.0, 200.0))); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + let art = NodeId(r["ids"][0].as_u64().unwrap()); + let d = &s.doc().unwrap().doc; + assert_sliced(&path_boxes(d.node(art).unwrap())); + // The pieces are editable paths: the frame, cut at the guides, is a plain path, and the + // squares, each inside one cell, stay live rectangles. + let mut live = vec![]; + d.node(art).unwrap().walk(&mut |c| match &c.kind { + NodeKind::SymbolInstance { .. } => panic!("no instance is left"), + NodeKind::Path { live: l, .. } => live.push(l.is_some()), + _ => {} + }); + assert_eq!(live, [false, true, true]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::SymbolInstance { .. }), "one undo step"); +} + +/// Scaling the instance with Scale Strokes & Effects on or off leaves the art's strokes at their +/// weight in the symbol: the corners and the cut paths draw them unchanged. +#[test] +fn the_art_keeps_its_stroke_weights_with_scale_strokes_on_or_off() { + for strokes in [true, false] { + let (mut s, id) = panel_symbol(); + let d = &s.doc().unwrap().doc; + let want = stroke_widths(&d.symbols.iter().find(|s| s.name == "Panel").unwrap().art); + assert_eq!(want.len(), 3); + s.execute("prefs.set", &json!({"key": "scaleStrokes", "value": strokes})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 150})).unwrap(); + let d = &s.doc().unwrap().doc; + let NodeKind::SymbolInstance { symbol, xf } = &d.node(id).unwrap().kind else { panic!("still an instance") }; + assert!(d.symbol_slice_map(symbol, *xf).is_some(), "scaled through its guides"); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + let art = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + assert_eq!(stroke_widths(art), want, "scaleStrokes {strokes}"); + } +} + +/// Break Link to Symbol on an instance whose art, cut at the guides, would make more than a +/// million objects reports an error without an edit. +#[test] +fn an_instance_that_slices_into_too_many_objects_stays_an_instance() { + let (mut s, id) = panel(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let art = doc.symbols.iter().find(|s| s.name == "Panel").unwrap().art.clone(); + let frame = art.geometric_bounds().unwrap(); + let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let mut kids = art.children().unwrap().to_vec(); + for _ in 0..36_000 { + let look = vectorcraft_doc::Appearance::basic(gradient.clone(), vectorcraft_color::Paint::None, 0.0); + kids.push(std::sync::Arc::new(Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(frame), look))); + } + let big = Node::group(doc.alloc_id(), kids); + doc.symbols.iter_mut().find(|s| s.name == "Panel").unwrap().art = std::sync::Arc::new(big); + let is_instance = |s: &Session| matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::SymbolInstance { .. }); + assert!(s.execute("symbol.breakLink", &json!({})).is_err()); + assert!(is_instance(&s), "Break Link leaves it an instance"); +} + +/// Break Link to Symbol on two instances, each of which would make fewer than a million objects +/// but which together would make more, reports an error without an edit. +#[test] +fn break_link_counts_the_pieces_of_every_selected_instance_together() { + let (mut s, id) = panel(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let art = doc.symbols.iter().find(|s| s.name == "Panel").unwrap().art.clone(); + let frame = art.geometric_bounds().unwrap(); + let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let mut kids = art.children().unwrap().to_vec(); + for _ in 0..20_000 { + let look = vectorcraft_doc::Appearance::basic(gradient.clone(), vectorcraft_color::Paint::None, 0.0); + kids.push(std::sync::Arc::new(Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(frame), look))); + } + let big = std::sync::Arc::new(Node::group(doc.alloc_id(), kids)); + doc.symbols.iter_mut().find(|s| s.name == "Panel").unwrap().art = big.clone(); + let mut second = doc.node(id).unwrap().clone(); + second.id = doc.alloc_id(); + let id2 = second.id; + let (par, idx, _) = doc.position(id).unwrap(); + doc.insert(par, idx + 1, second).unwrap(); + let NodeKind::SymbolInstance { symbol, xf } = &doc.node(id).unwrap().kind else { panic!("an instance") }; + let each = doc.symbol_slice_map(symbol, *xf).unwrap().most_placed(&big, doc); + assert!(each < 1_000_000 && 2 * each > 1_000_000, "{each}"); + s.select(|_, sel| sel.set([id, id2])).unwrap(); + assert!(s.execute("symbol.breakLink", &json!({})).is_err()); + for i in [id, id2] { + assert!(matches!(s.doc().unwrap().doc.node(i).unwrap().kind, NodeKind::SymbolInstance { .. }), "no edit"); + } +} + +/// Break Link to Symbol on an instance of a symbol without guides whose opacity masks hold more +/// than a million objects reports an error without an edit, though the art outside the masks +/// holds about a thousand. +#[test] +fn an_instance_whose_masks_hold_too_many_objects_stays_an_instance() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let r = rect(&mut s, 50.0, 50.0, 20.0, 20.0); + s.select(|_, sel| sel.set([r])).unwrap(); + let id = NodeId(s.execute("symbol.new", &json!({"name": "Masked"})).unwrap()["id"].as_u64().unwrap()); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + assert!(doc.symbol_nine_slice("Masked").is_none(), "no guides"); + let square = doc.symbols.iter().find(|s| s.name == "Masked").unwrap().art.clone(); + let mask_art = Node::group(doc.alloc_id(), vec![square.clone(); 1_000]); + let mut masked = (*square).clone(); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask_art, true))); + let masked = std::sync::Arc::new(masked); + let big = Node::group(doc.alloc_id(), vec![masked; 1_000]); + assert!(big.count() < 1_000_000 && big.count_with_masks() > 1_000_000); + doc.symbols.iter_mut().find(|s| s.name == "Masked").unwrap().art = std::sync::Arc::new(big); + let is_instance = |s: &Session| matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::SymbolInstance { .. }); + assert!(s.execute("symbol.breakLink", &json!({})).is_err()); + assert!(is_instance(&s), "Break Link leaves it an instance"); +} + +/// A scaled instance of a dynamic symbol keeps its own appearance changes when Break Link to +/// Symbol places its art through the guides. +#[test] +fn a_dynamic_instance_keeps_its_changes_through_the_guides() { + let (mut s, id) = panel_symbol(); + s.execute("symbol.options", &json!({"name": "Panel", "type": "dynamic"})).unwrap(); + s.execute("symbol.instance.setAppearance", &json!({"id": id.0, "path": [2], "fill": "#ff0000"})).unwrap(); + s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap(); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let art = d.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + assert_sliced(&path_boxes(art)); + let mut fills = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) { + fills.push(c.appearance.fill_paint().color().map(|c| c.to_hex())); + } + }); + assert_eq!(fills.last().cloned().flatten().as_deref(), Some("#ff0000"), "{fills:?}"); + assert_ne!(fills[0].as_deref(), Some("#ff0000"), "only the changed object"); +} diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 981ab610a..73a6e5842 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -278,9 +278,8 @@ impl<'a> Writer<'a> { self.warn(LIVE); if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST + && let Some(mut art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf) { - let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); - art.transform(*xf, false); art.visible = true; self.nest += 1; self.node(&art, locked); diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index e07e318b1..64c5da6d4 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -370,9 +370,8 @@ impl<'a> Scene<'a> { NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST + && let Some(art) = self.doc.place_symbol_art(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf) { - let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); - art.transform(*xf, false); self.nest += 1; self.node(&art, true); self.nest -= 1; diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index fff90f697..7dea2afba 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -216,9 +216,10 @@ impl<'a> Scene<'a> { NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST + && let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n) + && let Some(art) = + if self.clips { self.doc.place_symbol_art(symbol, &art, *xf) } else { self.doc.place_symbol_art_unclipped(symbol, &art, *xf) } { - let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); - art.transform(*xf, false); self.nest += 1; self.node(&art, true); self.nest -= 1; diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 7d063046c..80a95ad16 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -214,13 +214,37 @@ impl Exporter<'_> { NodeKind::SymbolInstance { symbol, xf } => { // Within the objects a frame may draw of symbol instances, as on screen: nested // instances multiply. - let sym = self.doc.symbols.iter().find(|x| &x.name == symbol); - let total = sym.map_or(0, |sym| self.symbol_nodes.saturating_add(sym.art.count() as u64)); - if let Some(sym) = sym.filter(|_| total <= vectorcraft_doc::MAX_SYMBOL_NODES) { - self.symbol_nodes = total; - let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); - art.transform(*xf, false); - self.node(s, &art, page, true); + // Placing through 9-slice guides needs room for the most objects it can make + // (`NineSliceMap::most_placed`), and the objects it makes count. + // A dynamic symbol's instance shows its own appearance changes. + let doc = self.doc; + if let Some(sym) = doc.symbols.iter().find(|x| &x.name == symbol) { + let changed; + let art: &Node = if n.extras().symbol_overrides.is_empty() { + &sym.art + } else { + changed = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); + &changed + }; + let count = art.count() as u64; + let map = doc.symbol_slice_map(symbol, *xf); + let most = map.map_or(count, |m| m.most_placed(art, doc)); + let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let art = match map { + Some(m) => m.place(art, doc), + None => { + let mut a = art.clone(); + a.transform(*xf, false); + a + } + }; + // As `most_placed`, placed art counts its opacity-mask art. + let count = if map.is_some() { art.count_with_masks() } else { art.count() } as u64; + (art, count) + }); + if let Some(art) = placed { + self.node(s, &art, page, true); + } } } // A gradient mesh is a smooth patch shading where it can be written as one. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8ac576a23..132b338e7 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -236,6 +236,60 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() { assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(-15.0, 30.0, 5.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds()); } +/// An instance of a symbol with 9-slice guides, scaled to 200 × 150, is written as the canvas +/// draws it: its 10 × 10 corner square stays 10 × 10 and its 10 × 10 center square becomes 30 × 20. +#[test] +fn a_scaled_nine_slice_instance_keeps_its_corners() { + let mut d = doc(300.0, 200.0); + let art = Node::group( + NodeId(0), + vec![ + Arc::new(rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb8(200, 200, 200))), + Arc::new(rect_node(Rect::new(0.0, 0.0, 10.0, 10.0), Color::rgb(1.0, 0.0, 0.0))), + Arc::new(rect_node(Rect::new(45.0, 45.0, 55.0, 55.0), Color::rgb(0.0, 0.0, 1.0))), + ], + ); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) }); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let xf = Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5); + add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Panel".into(), xf })); + let out = roundtrip(&d); + let boxes: Vec = leaves(&out).iter().filter_map(|n| n.geometric_bounds()).collect(); + assert_eq!(boxes.len(), 3, "{boxes:?}"); + assert!(close(boxes[0], Rect::new(20.0, 20.0, 220.0, 170.0), 0.01), "{boxes:?}"); + assert!(close(boxes[1], Rect::new(20.0, 20.0, 30.0, 30.0), 0.01), "{boxes:?}"); + assert!(close(boxes[2], Rect::new(105.0, 85.0, 135.0, 105.0), 0.01), "{boxes:?}"); +} + +/// The placed art of all the 9-slice instances in one export holds at most `MAX_SYMBOL_NODES` +/// objects, opacity-mask art included, as on the canvas: of two instances whose art placed through +/// the guides holds about 9,009,000 objects each (a gradient square in all nine cells, each copy +/// with a disabled opacity mask of 1,001,001 objects), the first is written in its nine cells and +/// the second is left out. With a small mask both are written. +#[test] +fn nine_slice_instances_share_one_object_limit_in_an_export() { + let doc = |mask_objects: usize| { + let mut d = doc(300.0, 200.0); + let leaf = Arc::new(rect_node(Rect::new(0.0, 0.0, 1.0, 1.0), Color::BLACK)); + let inner = Arc::new(Node::group(NodeId(0), vec![leaf; mask_objects])); + let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(0), vec![inner; mask_objects]), true); + (mask.linked, mask.disabled) = (false, true); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut square = Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + square.extras_mut().mask = Some(Box::new(mask)); + let art = Node::group(NodeId(0), vec![Arc::new(square)]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) }); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + for x in [20.0, 40.0] { + let xf = Affine::translate((x, 20.0)) * Affine::scale_non_uniform(2.0, 1.5); + add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Panel".into(), xf })); + } + d + }; + assert_eq!(leaves(&roundtrip(&doc(2))).len(), 18); + assert_eq!(leaves(&roundtrip(&doc(1_000))).len(), 9); +} + #[test] fn roundtrip_colours_preserved() { let mut d = doc(300.0, 100.0); diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index 51592da7e..c01f27a3f 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -69,11 +69,12 @@ pub(crate) type ClipPaintEntry = (Arc, Option>, Option /// ([`live::Found`]). pub(crate) type FxArtEntry = (Arc, Arc, Option); -/// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits -/// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]). +/// What symbol art placed by [`Renderer::placed_art`] or [`Renderer::sliced_art`] is kept under: +/// the art's address, the bits of the transform's coefficients, whether strokes scale with it, and +/// the stain ([`stain_bits`]). pub(crate) type PlacedKey = (usize, [u64; 6], bool, [u32; 6]); -/// Symbol art placed by [`Renderer::placed_art`]. +/// Symbol art placed by [`Renderer::placed_art`] or [`Renderer::sliced_art`]. pub(crate) struct PlacedEntry { /// The symbol's art, held weakly: the reference keeps the art's allocation, so no other art can /// get its address while the entry exists, and does not keep the art's content. @@ -83,6 +84,10 @@ pub(crate) struct PlacedEntry { /// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when /// it holds type: the art is placed again when the fonts change. pub(crate) fonts: Option, + /// The 9-slice map the art was placed through ([`Renderer::sliced_art`]), or `None` for art + /// placed by the transform alone ([`Renderer::placed_art`]). An entry is used only by the + /// function that made it, with the same map. + pub(crate) slice: Option, } /// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing. diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 274c763b0..031c5afa9 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -796,6 +796,32 @@ fn a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting() } } +/// A gradient bar with a drop shadow in a symbol with 9-slice guides, across the guides of an +/// instance scaled twice across: the bar is drawn cell by cell, and its shadow still shows past +/// the bar's right end, where the right cell ends. +#[test] +fn a_shadow_on_art_across_nine_slice_guides_reaches_past_the_outer_cells() { + let _fonts = fonts_steady(); + let mut d = Document::new(300.0, 200.0); + let gradient = Paint::Gradient(Box::new(vectorcraft_doc::color::GradientPaint::new(Default::default()))); + let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()); + let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0)); + let shadow = json!({"x": 8, "y": 0, "blur": 0, "opacity": 100}); + bar.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }); + let art = Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(bar)]); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: std::sync::Arc::new(art) }); + d.set_symbol_nine_slice("Bar", Some(vectorcraft_doc::SymbolNineSlice::default())); + let xf = Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.0); + let instance = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Bar".into(), xf }); + let l = d.layers[0].id; + d.insert(Some(l), 0, instance).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let out = Renderer::new().render(&d, 300, 200, Affine::IDENTITY, &opts); + // The bar runs from x 20 to 220 and y 60 to 80; its shadow, 8 pt to the right, to x 228. + assert!(lum(out.pixel(224, 70)) < WHITE, "{:?}", out.pixel(224, 70)); + assert_eq!(lum(out.pixel(232, 70)), WHITE, "{:?}", out.pixel(232, 70)); +} + /// While interacting, a raster moves with its object only when the content's pixels (for a /// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under /// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object, diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index c604e290b..25f626091 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -485,11 +485,21 @@ impl Renderer { NodeKind::Text(t) => self.draw_text(ctx, f, n, t), NodeKind::Image(im) => self.draw_image_in(ctx, f, im, None, images::image_tint(n, f.ink), n.extras().pixelated), NodeKind::SymbolInstance { symbol, xf } => { - if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) - && self.charge_symbol(&sym.art) - { - let (art, kept) = self.placed_art(&sym.art, *xf, false, Some(n)); - self.draw_placed_art(ctx, f, &art, kept); + if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { + // The instance draws when the frame has room for its art's objects; a symbol + // with 9-slice guides is placed cell by cell (`Self::sliced_art`). + let mut used = self.symbol_nodes; + let placed = match f.doc.symbol_slice_map(symbol, *xf) { + Some(m) => self.sliced_art(f.doc, &sym.art, &m, *xf, n, &mut used), + None => { + let count = sym.art.count() as u64; + vectorcraft_doc::charge_symbol_art(&mut used, count, || (self.placed_art(&sym.art, *xf, false, Some(n)), count)) + } + }; + self.symbol_nodes = used; + if let Some((art, kept)) = placed { + self.draw_placed_art(ctx, f, &art, kept); + } } } NodeKind::Blend { .. } @@ -504,13 +514,6 @@ impl Renderer { } } - /// Count symbol art `art`'s objects against the frame's [`vectorcraft_doc::MAX_SYMBOL_NODES`]: `false` (the - /// instance isn't drawn) once they would pass it. - fn charge_symbol(&mut self, art: &Node) -> bool { - let total = self.symbol_nodes.saturating_add(art.count() as u64); - (total <= vectorcraft_doc::MAX_SYMBOL_NODES).then(|| self.symbol_nodes = total).is_some() - } - /// Draw symbol art `art` as [`Self::placed_art`] placed it for an instance or an inline graphic /// (`kept`: kept between frames, see [`Self::draw_kept_node`]). pub(super) fn draw_placed_art(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc, kept: bool) { diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index c53cbb714..a2488556a 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -66,6 +66,7 @@ impl Renderer { if let Some(e) = self.placed_arts.get(&key, self.stamp) && std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art)) && e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation()) + && e.slice.is_none() { return match &e.placed { Some(placed) => (placed.clone(), true), @@ -73,14 +74,70 @@ impl Renderer { }; } let placed = place(art, xf, scale_strokes, inst); + let inserted = self.keep_placed(key, art, &placed, None); + (placed, inserted) + } + + /// Symbol art `art` placed for instance `inst` (placed by `xf`) through 9-slice map `map` + /// ([`vectorcraft_doc::NineSliceMap::place`], stained by the instance), and whether it is kept, + /// when the frame's symbol budget (`used`, see [`vectorcraft_doc::charge_symbol_art`]) has room + /// for it. The art is kept between frames as [`Self::placed_art`] keeps it, under the same key + /// and only with the same map. Kept art is charged by its own object count, the art of its + /// opacity masks included ([`Node::count_with_masks`]), and is not placed + /// again; other art is charged by [`vectorcraft_doc::NineSliceMap::most_placed`] before it is + /// placed. + pub(super) fn sliced_art( + &mut self, + doc: &vectorcraft_doc::Document, + art: &Arc, + map: &vectorcraft_doc::NineSliceMap, + xf: Affine, + inst: &Node, + used: &mut u64, + ) -> Option<(Arc, bool)> { + let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art); + let stain = stain_bits(vectorcraft_brush::stain(inst)); + let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), false, stain); + // As in `placed_art`, an instance with appearance changes of its own isn't kept. + let overridden = !inst.extras().symbol_overrides.is_empty(); + let mut keeps = kept_kind && !overridden && !fx::has_object_fx(art); + if keeps + && let Some(e) = self.placed_arts.get(&key, self.stamp) + && std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art)) + && e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation()) + && e.slice.as_ref() == Some(map) + { + match e.placed.clone() { + Some(placed) => { + let count = placed.count_with_masks() as u64; + return vectorcraft_doc::charge_symbol_art(used, count, || ((placed, true), count)); + } + None => keeps = false, + } + } + let stained = crate::brush_fx::instance_art(art, inst); + let most = map.most_placed(&stained, doc); + let placed = vectorcraft_doc::charge_symbol_art(used, most, || { + let placed = Arc::new(map.place(&stained, doc)); + let count = placed.count_with_masks() as u64; + (placed, count) + })?; + let kept = keeps && self.keep_placed(key, art, &placed, Some(*map)); + Some((placed, kept)) + } + + /// Keep `placed`, symbol art `art` as placed under `key` (through 9-slice map `slice`, when it + /// has one), unless `art` depends on the document ([`depends_on_document`]): then the entry + /// records that it is placed again in every frame. Whether `placed` is kept. + fn keep_placed(&mut self, key: PlacedKey, art: &Arc, placed: &Arc, slice: Option) -> bool { let kept = (!depends_on_document(art)).then(|| placed.clone()); let keeps = kept.is_some(); let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation()); // The bytes count the key and the entry, the art's allocation that the weak reference // keeps, and the kept art. let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes); - let inserted = self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts }, bytes, self.stamp); - (placed, keeps && inserted) + let inserted = self.placed_arts.insert(key, PlacedEntry { art: Arc::downgrade(art), placed: kept, fonts, slice }, bytes, self.stamp); + keeps && inserted } /// Cached glyph geometry for a text node (keyed by Arc identity like paths). diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index f95c61799..8b3648604 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -474,6 +474,70 @@ fn symbol_instances_keep_their_placed_art_between_frames() { assert_eq!(Arc::strong_count(&d.symbols[0].art), 1, "only the document holds the symbol's art"); } +/// The art placed through 9-slice guides that `r` keeps and that its last frame used. +fn sliced_copies(r: &Renderer) -> Vec> { + r.placed_arts.used_in(r.stamp).filter(|e| e.slice.is_some()).filter_map(|e| e.placed.clone()).collect() +} + +/// A second frame of an unchanged document draws the art placed through a symbol's 9-slice guides +/// for its scaled instance in the first, and the geometry kept for that art's paths is used again. +/// Moving a guide places the art again. +#[test] +fn nine_slice_instances_keep_their_placed_art_between_frames() { + let _fonts = fonts_steady(); + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + d.set_symbol_nine_slice("S", Some(vectorcraft_doc::SymbolNineSlice::default())); + let l = d.layers[0].id; + let xf = Affine::translate((100.0, 50.0)) * Affine::scale_non_uniform(4.0, 2.0); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "S".into(), xf })).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + let kept = sliced_copies(&r); + assert_eq!(kept.len(), 1, "the instance's sliced art is kept"); + let geometry = placed_copies(&r, &[&d]); + assert!(r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels == first); + let again = sliced_copies(&r); + assert!(again.len() == 1 && Arc::ptr_eq(&again[0], &kept[0]), "the second frame draws the art the first placed"); + assert_eq!(placed_copies(&r, &[&d]), geometry, "and uses the geometry kept for it"); + assert_eq!(Arc::strong_count(&d.symbols[0].art), 1, "only the document holds the symbol's art"); + let mut moved = d.clone(); + moved.set_symbol_nine_slice("S", Some(vectorcraft_doc::SymbolNineSlice { left: 0.1, top: 0.1, right: 0.1, bottom: 0.1 })); + let want = Renderer::new().render(&moved, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(want != first, "the moved guides draw the art differently"); + assert!(r.render(&moved, 200, 100, Affine::IDENTITY, &opts).pixels == want, "the moved guides place the art again"); + let placed = sliced_copies(&r); + assert!(placed.len() == 1 && !Arc::ptr_eq(&placed[0], &kept[0]), "under the moved guides"); +} + +/// A scaled 9-slice instance of a dynamic symbol with appearance changes of its own draws them +/// after a frame without them: its sliced art is not taken from the art kept for the same symbol +/// and transform. +#[test] +fn nine_slice_instances_with_their_own_changes_are_placed_again() { + let _fonts = fonts_steady(); + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: true, name: "S".into(), art: Arc::new(art) }); + d.set_symbol_nine_slice("S", Some(vectorcraft_doc::SymbolNineSlice::default())); + let l = d.layers[0].id; + let xf = Affine::translate((100.0, 50.0)) * Affine::scale_non_uniform(4.0, 2.0); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "S".into(), xf })).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + let mut changed = d.clone(); + let red = vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(Paint::solid(Color::rgb(1.0, 0.0, 0.0))), ..Default::default() }; + changed.node_mut(id).unwrap().extras_mut().symbol_overrides = vec![red]; + let want = Renderer::new().render(&changed, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(want != first, "the change draws the art differently"); + assert!(r.render(&changed, 200, 100, Affine::IDENTITY, &opts).pixels == want, "the change is drawn"); +} + /// The stroke outlines and the gradient slices of strokes that `r` keeps and that its last frame made /// or used. fn stroke_outlines(r: &Renderer) -> (Vec>, Vec) { diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index b9cd372ea..13c426847 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -105,6 +105,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: instance_xf: Affine::IDENTITY, symbols: HashMap::new(), symbol_nest: 0, + symbol_nodes: 0, + inline_graphic: false, fonts: Vec::new(), shared_images: HashMap::new(), colorized_counts: HashMap::new(), @@ -271,6 +273,12 @@ struct Writer<'a> { /// Symbols being written inside one another (instances nested deeper are left out: a symbol /// can't contain itself). symbol_nest: u32, + /// The objects in the placed art of the 9-slice instances written so far (see + /// [`vectorcraft_doc::charge_symbol_art`]). + symbol_nodes: u64, + /// An inline graphic in type is being written: its symbol's 9-slice guides do not apply, as + /// on the canvas. + inline_graphic: bool, /// The characters type uses from each face ([`ExportOptions::embed_fonts`]). fonts: Vec, /// Images written once in the defs and drawn with ``, by key and size: the pieces an diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 358cbd38b..d2b18c39b 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -11,6 +11,9 @@ pub(super) struct SymbolDef { /// The def's id, once written. id: Option, reuse: Reuse, + /// The objects in the placed art of the 9-slice instances in the def (see + /// [`Writer::symbol_nodes`]), charged again for each `` of it. + nodes: u64, } /// Which instance transforms paint a symbol's art exactly as the def under that transform (the @@ -71,6 +74,11 @@ impl Reuse { | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => (self.turns, self.scales) = (false, false), NodeKind::SymbolInstance { symbol, .. } => { + // A scale reaching an instance of a symbol with 9-slice guides changes how its + // cells are sized. + if doc.symbol_nine_slice(symbol).is_some() { + self.scales = false; + } if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { self.scan(doc, &s.art, depth + 1); } @@ -415,16 +423,29 @@ impl Writer<'_> { return; } let Some((first, rest)) = elements.split_first() else { return }; - for c in rest.iter().rev() { + // The elements are written first, in paint order, so that each mask copies an element + // with the symbol art count the element itself was written with + // (`Self::symbol_nodes`): an instance left out of an element is left out of its mask. + let (depth, nest) = (self.depth, rest.len()); + let first = self.detached(depth + nest, |w| w.node(first)); + let mut written = Vec::with_capacity(nest); + for (i, c) in rest.iter().enumerate() { + let before = self.symbol_nodes; + written.push((before, self.detached(depth + nest - 1 - i, |w| w.node(c)))); + } + let after = self.symbol_nodes; + for (c, (before, _)) in rest.iter().zip(&written).rev() { + self.symbol_nodes = *before; let mid = self.knockout_mask(c); self.line(&format!("")); self.depth += 1; } - self.node(first); - for c in rest { + self.symbol_nodes = after; + self.body.push_str(&first); + for (_, body) in written { self.depth -= 1; self.line(""); - self.node(c); + self.body.push_str(&body); } } @@ -600,12 +621,43 @@ impl Writer<'_> { } NodeKind::SymbolInstance { symbol, xf } => { let doc = self.doc; + // The instances nested in an inline graphic's art are ordinary instances again. + let inline = std::mem::take(&mut self.inline_graphic); let Some(sym) = doc.symbols.iter().find(|s| s.name == *symbol) else { return }; if self.symbol_nest > MAX_SYMBOL_NEST { return; } let id = self.id_attr(n); let a = self.attrs(&css::transparency(n)); + // A scaled instance of a symbol with 9-slice guides is written as its placed art, + // as the canvas draws it, instead of one `` transform. The scale counts the + // transforms of the instances it sits in. The placed art of all such instances in + // the export holds at most `MAX_SYMBOL_NODES` objects: an instance that would take + // it past that is left out, as the canvas leaves it out. + if let Some(map) = doc.symbol_slice_map(symbol, self.xf * *xf).filter(|_| !inline) { + let art = vectorcraft_brush::instance_art(&sym.art, n); + let most = map.most_placed(&art, doc); + let Some(art) = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || { + let a = map.place(&art, doc); + let c = a.count_with_masks() as u64; + (a, c) + }) else { + return; + }; + let saved = (self.xf, self.instance_xf); + (self.xf, self.instance_xf) = (Affine::IDENTITY, Affine::IDENTITY); + self.line(&format!("")); + self.depth += 1; + let knockout = std::mem::take(&mut self.knockout); + self.symbol_nest += 1; + self.node(&art); + self.symbol_nest -= 1; + self.knockout = knockout; + self.depth -= 1; + self.line(""); + (self.xf, self.instance_xf) = saved; + return; + } if let Some(def) = self.symbol_use(sym, n, *xf) { let m = self.xf * *xf; let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; @@ -658,14 +710,20 @@ impl Writer<'_> { } if !self.symbols.contains_key(&sym.name) { let reuse = Reuse::of(self.doc, &sym.art); - self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse }); + self.symbols.insert(sym.name.clone(), SymbolDef { id: None, reuse, nodes: 0 }); } let def = self.symbols.get(&sym.name)?; if !def.reuse.allows(xf) { return None; } - if let Some(id) = &def.id { - return Some(id.clone()); + // A `` that would take the placed 9-slice art past `MAX_SYMBOL_NODES` gives way to + // the instance's own art, which leaves out the instances that don't fit. + if let Some(id) = def.id.clone() { + let nodes = def.nodes; + if nodes > 0 && vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, nodes, || ((), nodes)).is_none() { + return None; + } + return Some(id); } // The art in symbol space, as its own picture: no knockout around it, and no object ids // (every instance shows it). @@ -673,7 +731,9 @@ impl Writer<'_> { let saved = (self.xf, self.instance_xf, std::mem::take(&mut self.knockout), std::mem::take(&mut self.names), self.anonymous); (self.xf, self.instance_xf, self.anonymous) = (Affine::IDENTITY, Affine::IDENTITY, false); self.symbol_nest += 1; + let before = self.symbol_nodes; let art = self.detached(3, |w| w.node(&sym.art)); + let nodes = self.symbol_nodes.saturating_sub(before); self.symbol_nest -= 1; (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; // The symbol's name, when the id had to differ from it, comes back on import. @@ -689,7 +749,7 @@ impl Writer<'_> { self.defs.push_str(&art); self.def(1, ""); if let Some(def) = self.symbols.get_mut(&sym.name) { - def.id = Some(id.clone()); + (def.id, def.nodes) = (Some(id.clone()), nodes); } Some(id) } diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index c8b6ced72..bab0cf327 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -128,7 +128,9 @@ impl Writer<'_> { NodeId(u64::MAX), NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf: t.xf * ig.xf * doc.symbol_natural_xf(&art.symbol) }, ); + let inline = std::mem::replace(&mut self.inline_graphic, true); self.node(&inst); + self.inline_graphic = inline; } glyphs(self, above); self.anonymous = anonymous; diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs index 1e581ed37..0fad6ff09 100644 --- a/crates/svg/tests/it/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -68,6 +68,148 @@ fn three_instances_give_one_symbol_and_three_uses() { assert!(boxes.iter().any(|b| (b.x0 - 90.0).abs() < 1e-3 && (b.y0 - 30.0).abs() < 1e-3), "{boxes:?}"); } +/// A scaled instance of a symbol with 9-slice guides is written as the canvas draws it, with no +/// ``: at 200 × 150 its 10 × 10 corner square stays 10 × 10 and its center square becomes +/// 30 × 20. An instance at the natural size still uses the def. +#[test] +fn a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it() { + let mut d = Document::new(300.0, 200.0); + let fill = |c: Color| Appearance::basic(Paint::solid(c), Paint::None, 0.0); + let rects = [ + (Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb8(220, 220, 220)), + (Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK), + (Rect::new(45.0, 45.0, 55.0, 55.0), Color::BLACK), + ]; + let art: Vec> = rects.iter().map(|(r, c)| Arc::new(Node::path(d.alloc_id(), shapes::rectangle(*r), fill(*c)))).collect(); + let group = Node::group(d.alloc_id(), art); + d.symbols.push(Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(group) }); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + instance(&mut d, "Panel", Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5)); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(svg.matches("` of the symbol. + let mut d = d.clone(); + let l = d.layers[0].id; + d.remove(d.children(Some(l)).unwrap()[0].id).unwrap(); + instance(&mut d, "Panel", Affine::translate((20.0, 20.0))); + assert_eq!(export(&d, &ExportOptions::default()).matches("` and the corners stay as the canvas + // draws them. + d.remove(d.children(Some(l)).unwrap()[0].id).unwrap(); + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf: Affine::IDENTITY }); + let outer = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + d.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + instance(&mut d, "Outer", Affine::translate((20.0, 20.0)) * Affine::scale_non_uniform(2.0, 1.5)); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(svg.matches("` of +/// a def holding the instance's nine clip groups, and the second, which the def's art would take +/// past the limit, is written as its own art without the instance. +#[test] +fn nine_slice_instances_share_one_object_limit_in_an_export() { + let doc = |mask_objects: usize, plain: &[f64], layout: &str| { + let mut d = Document::new(300.0, 200.0); + let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default())); + let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; mask_objects])); + let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; mask_objects]), true); + // A linked mask moves with its art, which lets "Outer" share a def. + (mask.linked, mask.disabled) = (layout == "outer", true); + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()))); + let mut square = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0)); + square.extras_mut().mask = Some(Box::new(mask)); + let art = Node::group(d.alloc_id(), vec![Arc::new(square)]); + d.symbols.push(Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(art) }); + d.set_symbol_nine_slice("Panel", Some(vectorcraft_doc::SymbolNineSlice::default())); + let xf = |x: f64| Affine::translate((x, 20.0)) * Affine::scale_non_uniform(2.0, 1.5); + for x in plain { + instance(&mut d, "Panel", xf(*x)); + } + let panel = |d: &mut Document, x: f64| Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf: xf(x) }); + let l = d.layers[0].id; + if layout == "knockout" { + let below = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 200.0)), Appearance::default()); + let top = panel(&mut d, 60.0); + let mut group = Node::group(d.alloc_id(), vec![below.into(), top.into()]); + group.knockout = vectorcraft_doc::Knockout::On; + d.insert(Some(l), usize::MAX, group).unwrap(); + } else if layout == "outer" { + let art = Node::group(d.alloc_id(), vec![panel(&mut d, 20.0).into()]); + d.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(art) }); + for y in [0.0, 10.0] { + instance(&mut d, "Outer", Affine::translate((0.0, y))); + } + } + d + }; + let svg = export(&doc(2, &[20.0, 40.0], ""), &ExportOptions::default()); + assert_eq!(svg.matches("], n: &Node, on_ } /// The color the art of symbol `name` shows at `p` as instance `inst` draws it: stained by the -/// instance's fill and placed in the document by `xf`, its strokes and patterns unscaled. +/// instance's fill and placed in the document by `xf` (through the symbol's 9-slice guides when it +/// has them), its strokes and patterns unscaled. The objects the guides add count as work too. fn symbol_color_at(s: &mut Sampler, levels: &mut [Option], inst: &Node, name: &str, xf: Affine, p: Point) -> Option { if xf.determinant().abs() < 1e-12 { return None; @@ -233,8 +234,24 @@ fn symbol_color_at(s: &mut Sampler, levels: &mut [Option], inst: &Node let doc = s.doc; let sym = doc.symbols.iter().find(|s| s.name == name)?; s.spend(sym.art.count())?; - let mut art = vectorcraft_brush::instance_art(&sym.art, inst); - art.transform(xf, false); + let art = vectorcraft_brush::instance_art(&sym.art, inst); + let art = match doc.symbol_slice_map(name, xf) { + Some(m) => { + let most = usize::try_from(m.most_placed(&art, doc)).unwrap_or(usize::MAX).saturating_sub(sym.art.count()); + if s.work < most { + // Too little work is left for the most objects the guides can make. + s.spend(most)?; + } + let placed = m.place(&art, doc); + s.spend(placed.count().saturating_sub(sym.art.count()))?; + placed + } + None => { + let mut art = art; + art.transform(xf, false); + art + } + }; art_color_at(s, levels, [art], p) } @@ -869,6 +886,52 @@ mod tests { assert_eq!(at(&d, 150.0, 155.0), "#00ff00"); } + /// An instance of a symbol with 9-slice guides is sampled as it is drawn: scaled twice, its + /// 10 pt red corner square stays 10 pt, so 15 pt in from the corner shows the green art behind it. + #[test] + fn a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn() { + use std::sync::Arc; + use vectorcraft_doc::{NodeId, SymbolNineSlice}; + use vectorcraft_geom::{Affine, Rect}; + let (mut d, _) = doc_with_rect(); + let green = filled_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + let red = filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(Node::group(NodeId(0), vec![green, red])) }); + let (l, inst) = (d.layers[0].id, d.alloc_id()); + let xf = Affine::translate((250.0, 250.0)) * Affine::scale(2.0); + d.insert(Some(l), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap(); + let (s, p) = (Selection::default(), paint()); + let shift = Mods { shift: true, ..Default::default() }; + let at = |d: &vectorcraft_doc::Document, x: f64, y: f64| { + sampled_hex(&EyedropperTool::default().pointer(&cx(d, &s, &p), &PointerEvent::new(PointerKind::Down, x, y).with_mods(shift))) + }; + assert_eq!(at(&d, 265.0, 265.0), "#ff0000", "without guides the corner scales"); + d.set_symbol_nine_slice("Panel", Some(SymbolNineSlice::default())); + assert_eq!((at(&d, 255.0, 255.0), at(&d, 265.0, 265.0)), ("#ff0000".to_string(), "#00ff00".to_string())); + } + + /// A scaled 9-slice instance of a symbol with 40,000 objects in its corner is sampled within + /// the work limit, as it is without guides: objects that stay in one cell are not reserved for + /// as if each were drawn in nine. + #[test] + fn a_large_nine_slice_instance_with_its_art_in_one_cell_is_sampled() { + use std::sync::Arc; + use vectorcraft_doc::{NodeId, SymbolNineSlice}; + use vectorcraft_geom::{Affine, Rect}; + let (mut d, _) = doc_with_rect(); + let mut art = vec![filled_rect(Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 0.0)))]; + art.extend((0..40_000).map(|_| filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0))))); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Panel".into(), art: Arc::new(Node::group(NodeId(0), art)) }); + d.set_symbol_nine_slice("Panel", Some(SymbolNineSlice::default())); + let (l, inst) = (d.layers[0].id, d.alloc_id()); + let xf = Affine::translate((250.0, 250.0)) * Affine::scale(2.0); + d.insert(Some(l), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Panel".into(), xf })).unwrap(); + let (s, p) = (Selection::default(), paint()); + let shift = Mods { shift: true, ..Default::default() }; + let ev = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 255.0, 255.0).with_mods(shift)); + assert_eq!(sampled_hex(&ev), "#ff0000"); + } + /// A pattern nested more than three patterns deep, and a pattern the document lacks, sample as /// the gray the canvas draws them with. #[test] diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index a8814a9e0..f388cb4ec 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -23,7 +23,10 @@ pub const KIND: &str = "symbolOptions"; /// The 9-slice guide fields, in `[left, top, right, bottom]` order. const SLICE_KEYS: [&str; 4] = ["sliceLeft", "sliceTop", "sliceRight", "sliceBottom"]; -/// The 9-slice dialog fields for `guides` (percent of the symbol's size; a third each when off). +/// A guide field's value when the dialog has none: a quarter of the symbol's size, in percent. +const DEFAULT_SLICE_PERCENT: f64 = 25.0; + +/// The 9-slice dialog fields for `guides` (percent of the symbol's size; a quarter each when off). fn slice_fields(guides: Option) -> Map { let g = guides.unwrap_or_default(); let mut m = Map::new(); @@ -36,7 +39,7 @@ fn slice_fields(guides: Option) -> Map Value { - let [l, t, r, b] = SLICE_KEYS.map(|key| d.f64(key, 100.0 / 3.0) / 100.0); + let [l, t, r, b] = SLICE_KEYS.map(|key| d.f64(key, DEFAULT_SLICE_PERCENT) / 100.0); if d.bool("nineSlice") { json!({ "nineSlice": true, "nineSliceGuides": [l, t, r, b] }) } else { json!({ "nineSlice": false }) } } @@ -130,7 +133,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if d.bool("nineSlice") { for (key, text) in SLICE_KEYS.into_iter().zip([tl!("Left:"), tl!("Top:"), tl!("Right:"), tl!("Bottom:")]) { label(ui, text); - let v = d.f64(key, 100.0 / 3.0); + let v = d.f64(key, DEFAULT_SLICE_PERCENT); if let Some(n) = widgets::range_field(ui, ("dlg-field", key), v, 0.0..=100.0, "%", 0, 64.0) { d.fields.insert(key.into(), json!(n)); } diff --git a/docs/gaps.md b/docs/gaps.md index 680c0984e..8a305fb7e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -565,7 +565,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall dynamic instance, with the Fill/Stroke controls and Appearance panel then setting its change) and their `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start; - drawing the 9-slice guides on the canvas in symbol edit mode and scaling by them. + drawing the 9-slice guides on the canvas in symbol edit mode. - **Estimate:** 14–24 h. ## G3 3D and Materials diff --git a/docs/mcp.md b/docs/mcp.md index 296e74e91..d4c7e45d6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1568,9 +1568,10 @@ already walks Image Trace. Short agent entry points: `static`|`dynamic`), `symbol.new {name?, registration?, exportType?, nineSlice?, nineSliceGuides?, type?, ids?}`, `symbol.options {name?, newName?, registration?, exportType?, nineSlice?, nineSliceGuides?, type?}` (`nineSlice` bool turns - 9-slice guides on or off; `nineSliceGuides` is `[left, top, right, bottom]`, fractions 0..1 of the symbol size; rows - of `symbol.list` carry `nineSlice`: the guides or `null`; `type` is Symbol Type, `static` by default for the command - and `dynamic` by default in the Symbol Options dialog), + 9-slice guides on or off; `nineSliceGuides` is `[left, top, right, bottom]`, fractions 0..1 of the symbol size, a + quarter each when none are given; rows of `symbol.list` carry `nineSlice`: the guides or `null`; a scaled instance of + a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; `type` is + Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog), `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}`, `symbol.replace`, `symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 64a7f2767..f553cf549 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -618,6 +618,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493). - Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them. - **Symbol libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own, as swatch and graphic style libraries do. The built-in library, Basic Shapes, is original art generated in code (`crates/engine/src/cmd/symbollib.rs`). A click on a symbol adds it to the document's Symbols as one undo step, with its Symbol Options (size, registration, export type, 9-slice guides) and every resource it uses (the symbols it nests, patterns, swatches, brushes, graphic styles, images), and makes it the current symbol; a symbol the document already has is not added again and another symbol of the same name keeps its name, the library's comes in numbered. Thumbnails, in the library panels and the Symbols panel, draw a symbol with the symbols it nests and the patterns and images it uses. Dragging a library symbol onto the canvas also places an instance there, in the same step ([placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)). Save Symbols (the library menu) and Save Symbol Library… (the Symbols panel menu) write the document's symbols as a `.vectorcraft` document holding only them, into the user library folder (listed under User Defined) or a file; Other Library… reads the symbols of any document VectorCraft opens; a file it can't read is an error and opens no panel. Tests: `crates/engine/src/tests/symbollib.rs`, `crates/ui-egui/src/panels/tests_symbol_libraries.rs`, `panels::symbols::tests::thumbnails_show_the_symbols_a_symbol_nests`, `canvas::tests::a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step`. + - **9-slice scaling** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html): "When you scale the symbol, the parts of the symbol that don't lie within the guides are not scaled."): a scaled instance of a symbol with Enable Guides for 9-Slice Scaling on stays an instance and keeps the parts of its art outside the guides at their size. New guides sit a quarter of the symbol's width and height in from each edge ([reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf), "Use 9-slice scaling": "guides are placed at 25% (or 1/4)"); saved guides keep their values. The rest is VectorCraft's own rule: the corners keep both sizes, the top and bottom edges stretch in width, the left and right edges in height and the center in both; an instance smaller than its two outer cells on an axis shrinks them in proportion and leaves the center empty; the cells are sized along the symbol's axes before the instance's rotation, shear or reflection. A path with solid paint that crosses a guide is cut there and stays one path, its stroke at its weight with Scale Strokes & Effects on or off; type, images, live objects and gradient or pattern paint that cross a guide are drawn in each cell they reach, clipped to it, except those that draw symbol art (a nested instance, type with an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws one of these), which are placed by the cell their center is in; an object in one cell is placed by that cell and stays as it is. The canvas, bounds, the Eyedropper, Break Link to Symbol and the PDF, EPS, DXF, EMF/WMF and SVG exports place the art the same way; SVG writes such an instance as its placed art instead of a ``, and DXF and WMF, which do not clip, cut every path that crosses a guide and place other such objects by the cell their center is in. Guides are not drawn in symbol edit mode yet. Tests: `symbol_nine_slice::tests` in `crates/doc`, `crates/engine/src/tests/symbol_nine_slice.rs`, `a_scaled_nine_slice_instance_keeps_its_corners` (PDF), `a_scaled_nine_slice_instance_is_written_as_the_canvas_draws_it` (SVG), `a_scaled_nine_slice_instance_is_sampled_as_it_is_drawn` (Eyedropper). - **Symbol editing mode** ([Edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): a double-click on a symbol instance, the Control bar's Edit Symbol button, a double-click on a symbol in the Symbols panel and the panel menu's Edit Symbol (`symbol.edit`) open the symbol's art on a temporary isolated layer, where the instance shows it (from the panel: centered on the active artboard, or in the view in a document without artboards), with the rest of the document dimmed. Each edit there changes every instance at once; the instances keep their own transforms and stay linked (Break Link to Symbol still expands one), and a dynamic symbol's instances keep their appearance changes to the objects the art still has. Escape, the isolation bar's back button, the Control bar's Exit Symbol Editing Mode and a double-click outside the art leave the mode (`symbol.exitEditing`); leaving without an edit adds no undo step. Saving leaves the editing layer out. Tests: `crates/engine/src/tests/symboledit.rs`, `select::tests::a_double_click_on_a_symbol_instance_edits_its_symbol`, `panels::symbols::tests::edit_symbol_from_the_panel_centers_the_art_on_the_active_artboard_or_the_view`. - Symbol editing mode keeps every instance in step: instances of a dynamic symbol inside other symbols' art, pattern tiles, graph designs, stored brushes and opacity masks keep their appearance changes on the objects an edit reorders and drop the changes to objects it deletes. The view does not draw the instance the edit was entered from. Area type in the symbol's art takes the text wrap that moving a wrap object gives it. Editing another symbol at a place that fails keeps the current edit open, and only the editing layer or art on it can be isolated. Tests: `crates/engine/src/tests/symboledit.rs`, `isolation::symbol_editing_does_not_draw_the_instance_it_was_entered_from`, `panels::symbols::tests::double_clicking_a_symbol_in_the_panel_edits_it_in_either_view`, `the_panel_menus_edit_symbol_edits_the_instances_symbol_or_the_panels`, `the_control_bars_edit_symbol_edits_the_selected_instances_symbol`, `chrome::tests::the_control_bar_exits_symbol_editing_mode`. - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e915b8a65..8d0ecea6b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -313,7 +313,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 | | Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | -| Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (guides stored, not drawn on the canvas); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | +| Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; guides not drawn in symbol edit mode); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | | Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; on-canvas Direct Selection of a part inside an instance (with the Fill/Stroke controls then setting its change) remains | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) |