From 3534dd73431b8fac4cbc1a973d5a531e17aebe98 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:35:04 -0800 Subject: [PATCH] Symbols: measuring and picking in 9-slice instances reuse one set of symbol boxes Measuring an instance of a symbol with 9-slice guides looked its map up through Document::symbol_slice_map, which scanned the symbol list and measured the art box again in new quick boxes, and NineSliceMap::place and most_placed made new quick boxes of their own. Each of those boxes built the symbol name index over the whole list on its first lookup, so every 9-slice instance measured read every symbol once or more. Picking an object inside a 9-slice instance's art with Direct Selection did the same for each object of the art that holds a symbol instance. - SymbolBoxes cuts the guides through the art box it has measured (SymbolBoxes::slice_map), and places and counts the art with itself as the boxes, in the mode quick boxes use. Art with no 9-slice map is moved by the instance's transform without looking the map up again. - Document::symbol_slice_map, place_symbol_art, place_symbol_art_unclipped and edited_nine_slice use one set of quick boxes for each call. - symbol_part_at and symbol_part_outline keep one set of quick boxes for the instance's art. The new tests count the symbols read. Measuring 200 instances of a symbol with guides and 200 of a symbol with guides holding it, among 2,002 symbols, read 1,205,204 symbols before; now each set of boxes reads the list once. Picking in a 9-slice instance whose art holds 20 instances read 84,042 symbols among 2,001; now it reads the list at most twice. --- crates/doc/src/hit.rs | 50 +++++++++++----------- crates/doc/src/hit/tests.rs | 47 +++++++++++++++++++++ crates/doc/src/symbol_nine_slice.rs | 54 ++++++++++++++++-------- crates/doc/src/symbols.rs | 64 +++++++++++++++++++++++++---- 4 files changed, 167 insertions(+), 48 deletions(-) diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index fd907a26cc..93b690ccf8 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -974,43 +974,45 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option /// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and /// how the canvas places each object of it: through the symbol's 9-slice guides when the instance /// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform. -struct InstanceArt { +struct InstanceArt<'a> { art: Node, map: Option, xf: vectorcraft_geom::Affine, + /// Quick boxes, which measure the instances in the art as it is placed through the guides. + boxes: SymbolBoxes<'a>, } -impl InstanceArt { +impl<'a> InstanceArt<'a> { /// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that /// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, /// which [`Document::place_symbol_art`] does not place and the canvas does not draw. - fn of(doc: &Document, inst: &Node) -> Option { + fn of(doc: &'a Document, inst: &Node) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; - let sym = doc.symbols.iter().find(|s| s.name == *symbol)?; - let art = crate::symbol_overrides::dynamic_art(&sym.art, inst); - let map = doc.symbol_slice_map(symbol, *xf); - if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) { + let boxes = doc.symbol_boxes().quick(); + let art = crate::symbol_overrides::dynamic_art(boxes.symbol_art(symbol)?, inst); + let map = boxes.slice_map(symbol, *xf); + if map.as_ref().is_some_and(|m| m.most_placed_with(&art, &boxes) > crate::MAX_SYMBOL_NODES) { return None; } - Some(Self { art, map, xf: *xf }) + Some(Self { art, map, xf: *xf, boxes }) } /// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the /// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell /// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides /// object by object. - fn whole(&self, n: &Node, doc: &Document, whole: Option) -> Option { - whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + fn whole(&self, n: &Node, whole: Option) -> Option { + whole.or_else(|| self.map.as_ref()?.one_cell_with(n, &self.boxes)) } /// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by /// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when /// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as /// [`Document::place_symbol_art`] refuses it. - fn place(&self, n: &Node, doc: &Document, whole: Option) -> Option { + fn place(&self, n: &Node, whole: Option) -> Option { match (&self.map, whole) { (_, Some(a)) => Some(moved(n, a)), - (Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)), + (Some(m), None) => (m.most_placed_with(n, &self.boxes) <= crate::MAX_SYMBOL_NODES).then(|| m.place_with(n, &self.boxes)), (None, None) => Some(moved(n, self.xf)), } } @@ -1018,10 +1020,13 @@ impl InstanceArt { /// Object `n` of the art placed on the canvas without clip groups /// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the /// same limit ([`Document::place_symbol_art_unclipped`]). - fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option) -> Option { + fn place_unclipped(&self, n: &Node, whole: Option) -> Option { match (&self.map, whole) { (_, Some(a)) => Some(moved(n, a)), - (Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)), + (Some(m), None) => { + let m = m.unclipped(); + (m.most_placed_with(n, &self.boxes) <= crate::MAX_SYMBOL_NODES).then(|| m.place_with(n, &self.boxes)) + } (None, None) => Some(moved(n, self.xf)), } } @@ -1046,15 +1051,14 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node { /// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`. pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option> { struct Find<'a> { - doc: &'a Document, - art: &'a InstanceArt, + art: &'a InstanceArt<'a>, p: Point, opt: HitOptions, symbols: &'a SymbolBoxes<'a>, } impl Find<'_> { fn hit(&self, n: &Node, whole: Option) -> bool { - self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) + self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some()) } fn skips_guide(&self, n: &Node) -> bool { self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. }) @@ -1067,11 +1071,11 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH { return self.hit(n, whole); } - let whole = self.art.whole(n, self.doc, whole); + let whole = self.art.whole(n, whole); if n.clips() && let Some(first) = children.first() { - let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false }; + let Some(clip) = self.art.place_unclipped(first, whole) else { return false }; if self.opt.clip_outlines && first.visible && !first.locked @@ -1103,7 +1107,7 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> let art = InstanceArt::of(doc, inst)?; let symbols = doc.symbol_boxes(); let mut path = vec![]; - Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) + Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path) } /// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]), @@ -1123,11 +1127,11 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< let mut n = &art.art; for &i in path { let children = n.children()?; - whole = art.whole(n, doc, whole); + whole = art.whole(n, whole); if i != 0 && n.clips() && let Some(first) = children.first() - && let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds() + && let Some(clip) = art.place_unclipped(first, whole)?.geometric_bounds() { match meet(Some(clip), limit) { Some(r) => limit = Some(r), @@ -1136,7 +1140,7 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option< } n = children.get(usize::try_from(i).ok()?)?; } - outline_within(&art.place(part, doc, whole)?, limit, &mut out); + outline_within(&art.place(part, whole)?, limit, &mut out); Some(out) } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 3eac4202b6..a4d1cbbf10 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -1586,6 +1586,53 @@ fn symbol_art_past_the_object_limit_picks_nothing() { assert!(symbol_part_outline(&d, n, &[0]).is_some()); } +/// Picking and outlining an object of a 9-slice instance's art, and placing that art, read the +/// symbol list once for each call, however many instances the art holds. +#[test] +fn picking_in_9_slice_art_reads_the_symbol_list_once() { + let mut d = Document::new(400.0, 400.0); + for i in 0..2_000 { + let dot = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()); + d.symbols.push(crate::Symbol { dynamic: false, name: format!("Dot {i}"), art: Arc::new(dot) }); + } + // A frame, a group of 20 squares in its top left cell, and 20 instances of the dots across + // the guides. + let square = |d: &mut Document, x: f64, y: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 4.0, y + 4.0)), Appearance::default_art())) + }; + let corner: Vec<_> = (0..20).map(|i| square(&mut d, f64::from(i % 5) * 4.0, f64::from(i / 5) * 4.0)).collect(); + let dot = |d: &mut Document, i: u32| { + let xf = vectorcraft_geom::Affine::translate((f64::from(i) * 4.5 + 5.0, 48.0)) * vectorcraft_geom::Affine::scale(4.0); + Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("Dot {i}"), xf })) + }; + let across: Vec<_> = (0..20).map(|i| dot(&mut d, i)).collect(); + let frame = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art())); + let mut kids = vec![frame, Arc::new(Node::group(d.alloc_id(), corner))]; + kids.extend(across); + let root = Node::group(d.alloc_id(), kids); + d.symbols.push(crate::Symbol { dynamic: true, name: "Panel".into(), art: Arc::new(root) }); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5); + let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf }); + let art = d.symbols[2_000].art.clone(); + let symbols = d.symbols.len(); + let read = || crate::symbols::SYMBOLS_READ.with(std::cell::Cell::get); + let reads = |f: &dyn Fn()| { + let before = read(); + f(); + read() - before + }; + // Picking also tests the placed art with boxes of its own, as a click outside symbols does. + let n = reads(&|| assert_eq!(symbol_part_at(&d, &inst, Point::new(2.0, 2.0), HitOptions::default()).map(|p| p[0]), Some(1))); + assert!(n <= 2 * symbols, "picking read {n} symbols"); + let n = reads(&|| assert!(symbol_part_outline(&d, &inst, &[2]).is_some())); + assert!(n <= symbols, "outlining read {n} symbols"); + let n = reads(&|| assert!(d.place_symbol_art("Panel", &art, xf).is_some())); + assert!(n <= symbols, "placing read {n} symbols"); + let n = reads(&|| assert!(d.place_symbol_art_unclipped("Panel", &art, xf).is_some())); + assert!(n <= symbols, "placing without clips read {n} symbols"); +} + /// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it /// included, so an object in it is picked and outlined there even when placing that object through /// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects. diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 3c8c1b324d..4877ee7b57 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -463,8 +463,13 @@ impl NineSliceMap { /// /// 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_with(art, &doc.symbol_boxes().quick()) + } + + /// [`Self::place`] with the instances in `art` measured by `boxes`, quick boxes or boxes + /// measuring symbol art. + pub(crate) fn place_with(&self, art: &Node, boxes: &SymbolBoxes) -> Node { + self.place_at(art, boxes, 0) } /// [`Self::place`] without clip groups, for writers that do not apply clipping (DXF, WMF): @@ -480,16 +485,25 @@ impl NineSliceMap { /// 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_with(art, &doc.symbol_boxes().quick()) + } + + /// [`Self::most_placed`] with the instances in `art` measured by `boxes`, quick boxes or boxes + /// measuring symbol art. + pub(crate) fn most_placed_with(&self, art: &Node, boxes: &SymbolBoxes) -> u64 { + 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 { + self.one_cell_with(n, &doc.symbol_boxes().quick()) + } + + /// [`Self::one_cell`] with the instances in `n` measured by `boxes`. + pub(crate) fn one_cell_with(&self, n: &Node, boxes: &SymbolBoxes) -> Option { + 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)); @@ -501,6 +515,11 @@ impl NineSliceMap { Self { clips: false, ..*self }.most_placed(art, doc) } + /// This map placing without clip groups, as [`Self::place_unclipped`] does. + pub(crate) fn unclipped(&self) -> Self { + Self { clips: false, ..*self } + } + /// [`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; @@ -671,7 +690,7 @@ impl NineSliceMap { } /// `n` moved by `a`, its strokes at their own weight. -fn transformed(n: &Node, a: Affine) -> Node { +pub(crate) fn transformed(n: &Node, a: Affine) -> Node { let mut out = n.clone(); out.transform(a, false); out @@ -765,10 +784,7 @@ impl Document { /// `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_boxes().quick().slice_map(name, xf) } /// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already @@ -782,9 +798,10 @@ impl Document { /// 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)), + let boxes = self.symbol_boxes().quick(); + match boxes.slice_map(name, xf) { + Some(m) if m.most_placed_with(art, &boxes) > MAX_SYMBOL_NODES => None, + Some(m) => Some(m.place_with(art, &boxes)), None => Some(transformed(art, xf)), } } @@ -795,8 +812,8 @@ impl Document { 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 boxes = self.symbol_boxes().quick(); + let art = boxes.art_bounds(&se.symbol).or_else(|| boxes.symbol_art(&se.symbol)?.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 { @@ -811,8 +828,9 @@ impl Document { /// [`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)), + let boxes = self.symbol_boxes().quick(); + match boxes.slice_map(name, xf).map(|m| m.unclipped()) { + Some(m) => (m.most_placed_with(art, &boxes) <= MAX_SYMBOL_NODES).then(|| m.place_with(art, &boxes)), None => Some(transformed(art, xf)), } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 822dce49ea..944accfe4b 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -8,7 +8,8 @@ use std::sync::Arc; use vectorcraft_geom::{Affine, Rect}; -use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF}; +use crate::symbol_nine_slice::transformed; +use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, NineSliceMap, 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 @@ -128,6 +129,8 @@ impl Document { /// Each symbol name of this document → the index of the first symbol of that name (the one a /// name finds). pub(crate) fn symbol_names(&self) -> SymbolNames { + #[cfg(test)] + SYMBOLS_READ.with(|c| c.set(c.get() + self.symbols.len())); let mut names = HashMap::with_capacity(self.symbols.len()); for (i, s) in self.symbols.iter().enumerate() { names.entry(s.name.clone()).or_insert(i); @@ -136,6 +139,12 @@ impl Document { } } +#[cfg(test)] +thread_local! { + /// How many symbols [`Document::symbol_names`] has read on this thread. + pub(crate) static SYMBOLS_READ: Cell = const { Cell::new(0) }; +} + impl<'a> SymbolBoxes<'a> { /// The document whose symbols these are. pub fn document(&self) -> &'a Document { @@ -437,8 +446,8 @@ impl<'a> SymbolBoxes<'a> { // The most objects placing makes, the art of opacity masks included, is set aside // while the art is placed and measured, and what placing did not make is given back. let art = self.symbol_art(name)?; - let most = match self.doc.symbol_slice_map(name, linear) { - Some(m) => m.most_placed(art, self.doc), + let most = match self.slice_map(name, linear) { + Some(m) => self.in_art(|| m.most_placed_with(art, self)), None => art.count_with_masks() as u64, }; let used = self.sliced_placed.get(); @@ -455,6 +464,14 @@ impl<'a> SymbolBoxes<'a> { v.map(|v| moved.transform_rect_bbox(v)) } + /// [`Document::symbol_slice_map`] with the guides cutting the art box these boxes measure for + /// the symbol (its geometric part, which does not depend on the mode of the boxes). + pub(crate) fn slice_map(&self, name: &str, xf: Affine) -> Option { + let guides = self.doc.symbol_nine_slice(name)?; + let art = self.art_bounds(name).or_else(|| self.symbol_art(name)?.geometric_bounds())?; + NineSliceMap::new(guides, art, self.doc.symbol_natural_xf(name), xf) + } + /// The box [`Self::placed_visual_within`] returns outside quick boxes. fn place_and_measure(&self, name: &str, xf: Affine, most: u64) -> Option { self.place_and_count(name, xf, most).and_then(|(v, _)| v) @@ -464,10 +481,10 @@ impl<'a> SymbolBoxes<'a> { /// opacity masks included. `None` when the art is not placed. fn place_and_count(&self, name: &str, xf: Affine, most: u64) -> Option<(Option, u64)> { self.measure_art(name, |art| { - 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)?, + let art = match self.slice_map(name, xf) { + Some(m) if self.in_art(|| m.most_placed_with(art, self)) > most => return None, + Some(m) => self.in_art(|| m.place_with(art, self)), + None => transformed(art, xf), }; Some((self.in_art(|| art.visual(Some(self))), art.count_with_masks() as u64)) }) @@ -689,6 +706,39 @@ mod tests { assert_eq!(s.sliced_placed.get(), 13); } + #[test] + fn measuring_9_slice_instances_reads_the_symbol_list_once() { + // 2,000 symbols, then a frame with guides and a symbol with guides holding the frame + // scaled 2 times across, each shown by 200 instances scaled by different amounts. + let mut d = Document::new(200.0, 200.0); + for i in 0..2000 { + let dot = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0); + symbol(&mut d, &format!("Dot {i}"), vec![dot]); + } + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0); + symbol(&mut d, "Panel", vec![frame]); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let wide = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + symbol(&mut d, "Holder", vec![wide]); + d.set_symbol_nine_slice("Holder", Some(crate::SymbolNineSlice::default())); + let scaled = |d: &mut Document, name: &str| -> Vec { + (1..=200).map(|i| instance(d, name, Affine::scale_non_uniform(1.0 + f64::from(i) / 100.0, 1.5))).collect() + }; + let (panels, holders) = (scaled(&mut d, "Panel"), scaled(&mut d, "Holder")); + // What each instance measures in boxes of its own. + let exact: Vec<_> = panels.iter().map(|n| n.visual_bounds_with(&d.symbol_boxes())).collect(); + let quick: Vec<_> = holders.iter().map(|n| n.visual_bounds_with(&d.symbol_boxes().quick())).collect(); + let read = || SYMBOLS_READ.with(Cell::get); + let before = read(); + let s = d.symbol_boxes(); + assert_eq!(panels.iter().map(|n| n.visual_bounds_with(&s)).collect::>(), exact); + let s = d.symbol_boxes().quick(); + assert_eq!(holders.iter().map(|n| n.visual_bounds_with(&s)).collect::>(), quick); + // Each set of boxes reads the list once to find symbols by name. + let symbols = d.symbols.len(); + assert!(read() - before <= 2 * symbols, "{} symbols read", read() - before); + } + #[test] fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() { let mut d = Document::new(200.0, 200.0);