//! 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. 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"; /// The four 9-slice guides as fractions of the symbol's natural size. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SymbolNineSlice { /// Left guide, from the left edge. pub left: f64, /// Top guide, from the top edge. pub top: f64, /// Right guide, from the right edge. pub right: f64, /// Bottom guide, from the bottom edge. pub bottom: f64, } impl Default for SymbolNineSlice { /// Each guide sits a quarter of the symbol's width or height in from its edge. fn default() -> Self { let q = 0.25; Self { left: q, top: q, right: q, bottom: q } } } impl SymbolNineSlice { /// Guides from `[left, top, right, bottom]`; `None` unless each is finite, within 0..1, and the /// two on each axis leave a gap between them. pub fn new(left: f64, top: f64, right: f64, bottom: f64) -> Option { let ok = |v: f64| v.is_finite() && (0.0..=1.0).contains(&v); (ok(left) && ok(top) && ok(right) && ok(bottom) && left + right < 1.0 && top + bottom < 1.0).then_some(Self { left, top, right, bottom }) } pub fn to_value(self) -> Value { json!([self.left, self.top, self.right, self.bottom]) } pub fn from_value(v: &Value) -> Option { let a = v.as_array()?; let n = |i: usize| a.get(i).and_then(Value::as_f64); if a.len() != 4 { return None; } Self::new(n(0)?, n(1)?, n(2)?, n(3)?) } } /// One of the four 9-slice guides. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NineSliceGuide { Left, Top, Right, Bottom, } impl NineSliceGuide { pub const ALL: [Self; 4] = [Self::Left, Self::Top, Self::Right, Self::Bottom]; } /// The narrowest center band a guide dragged toward the opposite guide leaves, as a fraction of /// the art's width or height (VectorCraft's own rule). pub const MIN_CENTER_BAND: f64 = 0.001; /// The 9-slice guides of the symbol edited in symbol editing mode, over its art on the editing /// layer ([`Document::edited_nine_slice`]). #[derive(Clone, Debug, PartialEq)] pub struct EditedNineSlice { /// The symbol's name. pub symbol: String, pub guides: SymbolNineSlice, /// The art's geometric box at its natural size. art: Rect, /// The art at its natural size → the document. to_doc: Affine, } impl EditedNineSlice { /// Guide `g` as a line across the art's box, from one edge to the other, in the document. pub fn line(&self, g: NineSliceGuide) -> (Point, Point) { let (b, s) = (self.art, self.guides); let (a, c) = match g { NineSliceGuide::Left | NineSliceGuide::Right => { let x = if g == NineSliceGuide::Left { b.x0 + s.left * b.width() } else { b.x1 - s.right * b.width() }; (Point::new(x, b.y0), Point::new(x, b.y1)) } NineSliceGuide::Top | NineSliceGuide::Bottom => { let y = if g == NineSliceGuide::Top { b.y0 + s.top * b.height() } else { b.y1 - s.bottom * b.height() }; (Point::new(b.x0, y), Point::new(b.x1, y)) } }; (self.to_doc * a, self.to_doc * c) } /// The four guides' lines: left, top, right, bottom. pub fn lines(&self) -> [(NineSliceGuide, Point, Point); 4] { NineSliceGuide::ALL.map(|g| { let (a, b) = self.line(g); (g, a, b) }) } /// The guides with guide `g` moved across its axis to document point `p`. The guide stays /// within the art's box, and [`MIN_CENTER_BAND`] short of the opposite guide, or where it is /// when the guides are already closer than that. A point that is not finite leaves the guides /// as they are. pub fn moved(&self, g: NineSliceGuide, p: Point) -> SymbolNineSlice { let (b, mut out) = (self.art, self.guides); let q = self.to_doc.inverse() * p; let fit = |v: f64, other: f64, now: f64| if v.is_finite() { v.clamp(0.0, (1.0 - other - MIN_CENTER_BAND).max(now)) } else { now }; match g { NineSliceGuide::Left => out.left = fit((q.x - b.x0) / b.width(), out.right, out.left), NineSliceGuide::Right => out.right = fit((b.x1 - q.x) / b.width(), out.left, out.right), NineSliceGuide::Top => out.top = fit((q.y - b.y0) / b.height(), out.bottom, out.top), NineSliceGuide::Bottom => out.bottom = fit((b.y1 - q.y) / b.height(), out.top, out.bottom), } out } } /// 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)) } /// A box that holds everything [`Self::place`] draws of art whose geometric box in symbol space /// is `art`, before strokes and effects: `art` placed by each of the nine cells' maps. An object /// that crosses a guide and draws symbol art is placed by the map of the cell its center is in, /// so it can draw outside the box the corner cells place `art` in. pub fn placement_bounds(&self, art: Rect) -> Rect { let mut b = self.cell_xf(0, 0).transform_rect_bbox(art); for j in 0..3 { for i in 0..3 { b = b.union(self.cell_xf(i, j).transform_rect_bbox(art)); } } b } /// 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) } /// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one /// piece, with everything in it: the map of the cell its box lies in, or of the cell at the /// origin when it has no box. `None` when its box crosses a guide. pub fn one_cell(&self, n: &Node, doc: &Document) -> Option { let boxes = doc.symbol_boxes().quick(); let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else { return Some(self.cell_at(Point::ZERO)); }; let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1)); (cols[0] == cols[1] && rows[0] == rows[1]).then(|| self.cell_xf(cols[0], rows[0])) } /// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`. pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 { Self { clips: false, ..*self }.most_placed(art, doc) } /// [`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) } /// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already /// measured. pub(crate) fn symbol_slice_map_of(&self, name: &str, art: Rect, xf: Affine) -> Option { NineSliceMap::new(self.symbol_nine_slice(name)?, 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)), } } /// The 9-slice guides of the symbol being edited in symbol editing mode, over its art on the /// editing layer: `None` outside the mode, when the symbol has no guides, or when its art has /// no width or height. pub fn edited_nine_slice(&self) -> Option { let se = self.symbol_edit.as_ref()?; let guides = self.symbol_nine_slice(&se.symbol)?; let sym = self.symbols.iter().find(|s| s.name == se.symbol)?; let art = self.symbol_boxes().quick().art_bounds(&se.symbol).or_else(|| sym.art.geometric_bounds())?; let natural = self.symbol_natural_xf(&se.symbol); let b = natural.transform_rect_bbox(art); if !finite(b) || b.width() <= 1e-9 || b.height() <= 1e-9 || natural.determinant().abs() < 1e-18 { return None; } let to_doc = se.xf * natural.inverse(); let det = to_doc.determinant(); let usable = to_doc.as_coeffs().into_iter().all(f64::is_finite) && det.is_finite() && det.abs() > 1e-18; usable.then(|| EditedNineSlice { symbol: se.symbol.clone(), guides, art: b, to_doc }) } /// [`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) => (m.most_placed_unclipped(art, self) <= MAX_SYMBOL_NODES).then(|| m.place_unclipped(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); // Unclipped, as DXF writes it, the bar is cut at the guides and stays one path. assert_eq!(m.most_placed_unclipped(&bar, &d), 1); assert_eq!(m.place_unclipped(&bar, &d).count(), 1); // 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:?}"); } /// Symbol "S" of [`markers`] edited in symbol editing mode, its art placed by `xf`. fn editing(guides: Option, xf: Affine) -> Document { let mut d = markers(guides); let layer = d.layers[0].id; d.symbol_edit = Some(crate::SymbolEdit { symbol: "S".into(), layer, instance: None, xf, group: true, changed: false, back_isolation: None, back_layer: None, }); d } fn near_point(a: Point, b: Point) -> bool { (a - b).hypot() < 1e-9 } /// While a symbol with guides is edited, its four guides cross its art's box from edge to edge /// where the art shows on the editing layer: here at 25 % in, the art doubled and moved. #[test] fn the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it() { let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0); let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap(); assert_eq!(e.symbol, "S"); let want = [ (NineSliceGuide::Left, Point::new(60.0, 20.0), Point::new(60.0, 220.0)), (NineSliceGuide::Top, Point::new(10.0, 70.0), Point::new(210.0, 70.0)), (NineSliceGuide::Right, Point::new(160.0, 20.0), Point::new(160.0, 220.0)), (NineSliceGuide::Bottom, Point::new(10.0, 170.0), Point::new(210.0, 170.0)), ]; for ((g, a, b), (wg, wa, wb)) in e.lines().into_iter().zip(want) { assert!(g == wg && near_point(a, wa) && near_point(b, wb), "{g:?} {a:?} {b:?}"); } // Outside the mode, without guides, or for a symbol that is gone: no guides. assert!(markers(Some(SymbolNineSlice::default())).edited_nine_slice().is_none()); assert!(editing(None, xf).edited_nine_slice().is_none()); let mut d = editing(Some(SymbolNineSlice::default()), xf); d.symbols.clear(); assert!(d.edited_nine_slice().is_none()); } /// Moving a guide changes that guide alone, follows the pointer across its axis only, stays /// within the art's box and stops short of the opposite guide. #[test] fn a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide() { let xf = Affine::translate((10.0, 20.0)) * Affine::scale(2.0); let e = editing(Some(SymbolNineSlice::default()), xf).edited_nine_slice().unwrap(); let q = SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 }; assert_eq!(e.moved(NineSliceGuide::Left, Point::new(30.0, 999.0)), SymbolNineSlice { left: 0.1, ..q }); assert_eq!(e.moved(NineSliceGuide::Bottom, Point::new(-50.0, 180.0)), SymbolNineSlice { bottom: 0.2, ..q }); assert_eq!(e.moved(NineSliceGuide::Top, Point::new(0.0, -500.0)), SymbolNineSlice { top: 0.0, ..q }); assert_eq!(e.moved(NineSliceGuide::Right, Point::new(1e6, 0.0)), SymbolNineSlice { right: 0.0, ..q }); let crossed = e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)); assert!((crossed.left - (0.75 - MIN_CENTER_BAND)).abs() < 1e-12, "{crossed:?}"); assert!(SymbolNineSlice::new(crossed.left, crossed.top, crossed.right, crossed.bottom).is_some()); assert_eq!(e.moved(NineSliceGuide::Top, Point::new(f64::NAN, f64::NAN)), q); // Saved guides closer than the minimum center band keep their place when a drag does not // move them closer. let narrow = SymbolNineSlice::new(0.00005, 0.25, 0.9999, 0.25).unwrap(); let e = editing(Some(narrow), xf).edited_nine_slice().unwrap(); assert_eq!(e.moved(NineSliceGuide::Left, Point::new(200.0, 50.0)), narrow); assert_eq!(e.moved(NineSliceGuide::Left, Point::new(0.0, 50.0)), SymbolNineSlice { left: 0.0, ..narrow }); // A mirrored placement measures along the symbol's own axes. let e = editing(Some(SymbolNineSlice::default()), Affine::scale_non_uniform(-1.0, 1.0)).edited_nine_slice().unwrap(); assert!(near_point(e.line(NineSliceGuide::Left).0, Point::new(-25.0, 0.0))); assert_eq!(e.moved(NineSliceGuide::Left, Point::new(-10.0, 0.0)), SymbolNineSlice { left: 0.1, ..q }); } }