From 84c197cfbe6b842fe141bf8ad141163b190f74b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 11:22:03 -0800 Subject: [PATCH] Symbols: quick boxes hold art that nested 9-slice guides move An instance of a symbol whose art holds a 9-slice instance draws that instance through its guides with the transform of every instance above it. When the outer symbol has no guides, the quick box of an outer instance that scales or turns its art was its art box placed by its transform, and the renderer could cull art the nested guides move past that box. Symbol art boxes now record whether the art holds an instance of a symbol with 9-slice guides at any depth. For such art, quick boxes place the art by the instance's transform and measure it, once for each symbol and linear part. One set of boxes places at most MAX_SYMBOL_NODES objects this way, the art of opacity masks included. --- crates/doc/src/node/ops.rs | 7 +- crates/doc/src/symbols.rs | 153 ++++++++++++++++++++++++++++++++++++- crates/render/src/tests.rs | 22 ++++++ 3 files changed, 177 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 5ab9e2c6c..1af9fb250 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -212,7 +212,10 @@ impl Node { /// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from /// `symbols`. An instance that only moves its art is measured as the art's visual box moved, /// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`]) - /// grown by the farthest a stroke in the art can reach. + /// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice + /// guides, that box also holds the art box placed by each cell's map + /// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a + /// symbol with 9-slice guides at any depth is measured as its art placed by its transform. pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option { symbols.quickly(|| self.reach(Some(symbols))) } @@ -367,7 +370,7 @@ impl Node { Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual), Some((s, art)) => { let placed = xf.transform_rect_bbox(art.geometric); - match s.placed_visual(symbol, *xf) { + match s.placed_visual(symbol, *xf).or_else(|| s.sliced_visual(symbol, *xf)) { Some(v) => v.union(placed), // Art placed through 9-slice guides can draw outside `placed`. None => s diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 3a91f8d62..2cf260cbe 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -23,6 +23,9 @@ const MAX_DEPTH: usize = 32; /// holding instances: past it, an estimate stands in ([`SymbolBoxes::effects_reach`]). const MAX_REACHES: usize = 4096; +/// A symbol's name and the bits of the linear part of a transform that places its art. +type Placing = (String, [u64; 4]); + /// A symbol's art measured in symbol space. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct ArtBox { @@ -37,6 +40,9 @@ pub(crate) struct ArtBox { /// The widest stroke of the objects with a visible brushed stroke in the art, or in the art /// of a symbol an instance in it shows: `None` when no object there has one. pub(crate) brush: Option, + /// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art + /// holds one at any depth. + pub(crate) sliced: bool, } /// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`], @@ -73,6 +79,14 @@ pub struct SymbolBoxes<'a> { unplaced: RefCell>, /// How many times [`Self::measure_art`] has run a measure on symbol art ([`Self::art_measured`]). runs: Cell, + /// The boxes [`Self::sliced_visual`] has measured, by symbol name and the bits of the linear + /// part of the transform that placed the art. + sliced: RefCell>>, + /// How many objects of symbol art [`Self::sliced_visual`] has placed, the art of opacity masks + /// included. + sliced_placed: Cell, + /// The most objects of symbol art [`Self::sliced_visual`] places: [`MAX_SYMBOL_NODES`]. + sliced_most: u64, } impl Document { @@ -90,6 +104,9 @@ impl Document { farthest: RefCell::default(), unplaced: RefCell::default(), runs: Cell::new(0), + sliced: RefCell::default(), + sliced_placed: Cell::new(0), + sliced_most: MAX_SYMBOL_NODES, } } } @@ -104,7 +121,9 @@ impl<'a> SymbolBoxes<'a> { /// art box alone. An instance that scales or turns stroked art then measures a little larger /// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. The /// visual box of an instance placed through 9-slice guides holds the art box placed by each - /// cell's map ([`crate::NineSliceMap::placement_bounds`]). + /// cell's map ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an + /// instance of a symbol with 9-slice guides at any depth is measured as its art placed by its + /// transform ([`Self::sliced_visual`]). pub fn quick(self) -> Self { self.quick.set(true); self @@ -321,13 +340,64 @@ impl<'a> SymbolBoxes<'a> { if self.quick.get() { return None; } + self.place_and_measure(name, xf, most) + } + + /// [`Self::placed_visual`] in boxes made by [`Self::quick`] and while symbol art is measured + /// too, for a symbol whose art holds an instance of a symbol with 9-slice guides at any depth: + /// where that instance draws its art depends on the transform of each instance above it. The + /// art is placed once for each symbol and linear part of `xf`, and the box is moved by the rest + /// of `xf`. `None` for other symbols, when [`Self::placed_visual`] is `None` outside quick + /// boxes, and when placing the art would take the objects of the art these boxes have placed + /// for it past [`MAX_SYMBOL_NODES`], the art placed to measure the instances in placed art + /// included. The renderer draws no more objects of symbol art than that in one frame. + pub(crate) fn sliced_visual(&self, name: &str, xf: Affine) -> Option { + if !self.art(name)?.sliced { + return None; + } + let [a, b, c, d, e, f] = xf.as_coeffs(); + let (linear, moved) = (Affine::new([a, b, c, d, 0.0, 0.0]), Affine::translate((e, f))); + let key = (name.to_string(), [a, b, c, d].map(f64::to_bits)); + let cached = self.sliced.borrow().get(&key).copied(); + let v = match cached { + Some(v) => v, + None => { + // 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.doc.symbols.iter().find(|s| s.name == name)?.art; + let most = match self.doc.symbol_slice_map(name, linear) { + Some(m) => m.most_placed(art, self.doc), + None => art.count_with_masks() as u64, + }; + let used = self.sliced_placed.get(); + if used.saturating_add(most) > self.sliced_most { + return None; + } + self.sliced_placed.set(used + most); + let (v, placed) = self.place_and_count(name, linear, MAX_SYMBOL_NODES).unwrap_or((None, 0)); + self.sliced_placed.set(self.sliced_placed.get().saturating_sub(most.saturating_sub(placed))); + self.sliced.borrow_mut().insert(key, v); + v + } + }; + v.map(|v| moved.transform_rect_bbox(v)) + } + + /// 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) + } + + /// [`Self::place_and_measure`], with the number of objects of the placed art, the art of + /// 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)?, }; - self.in_art(|| art.visual(Some(self))) + Some((self.in_art(|| art.visual(Some(self))), art.count_with_masks() as u64)) }) .flatten() } @@ -385,6 +455,7 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { let reach = crate::live::max_outset(std::slice::from_ref(art), Some(symbols)); let mut effects = false; let mut brush: Option = None; + let mut sliced = false; art.walk(&mut |n| { let mut own = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects().iter())); effects |= own.any(|e| e.visible); @@ -397,9 +468,10 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { if let Some(w) = symbols.brush_width(symbol) { brush = Some(brush.map_or(w, |b| b.max(w))); } + sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced); } }); - Some(ArtBox { geometric, visual, reach, effects, brush }) + Some(ArtBox { geometric, visual, reach, effects, brush, sliced }) } #[cfg(test)] @@ -468,6 +540,81 @@ mod tests { assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}"); } + #[test] + fn quick_boxes_hold_art_that_nested_9_slice_guides_move_past_the_instance() { + // The panel of the test above, in a symbol without guides: once at its natural size and + // once scaled 2 times across. Where the bar draws depends on the transform the holder's + // instance adds to the panel's. + let mut d = Document::new(200.0, 200.0); + let bar = rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), 0.0); + symbol(&mut d, "Bar", vec![bar]); + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + let inner = instance(&mut d, "Bar", Affine::IDENTITY); + symbol(&mut d, "Panel", vec![frame, inner]); + d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default())); + let natural = instance(&mut d, "Panel", Affine::IDENTITY); + symbol(&mut d, "Holder", vec![natural]); + let wide = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + symbol(&mut d, "Wide holder", vec![wide]); + // The holder scaled 2 times across scales the panel 2 times across: the bar draws from x + // −20 to 160, as in the test above. + let holder = instance(&mut d, "Holder", Affine::scale_non_uniform(2.0, 1.0)); + let quick = holder.visual_bounds_with(&d.symbol_boxes().quick()).unwrap(); + let drawn = Rect::new(-20.0, 40.0, 160.0, 60.0); + assert_eq!(quick.union(drawn), quick, "{quick:?} holds {drawn:?}"); + let wide_holder = instance(&mut d, "Wide holder", Affine::scale(2.0)); + for n in [&holder, &wide_holder] { + let exact = n.visual_bounds_with(&d.symbol_boxes()).unwrap(); + assert!(exact.x0 < 0.0, "{exact:?} reaches past the frame's left edge"); + let quick = n.visual_bounds_with(&d.symbol_boxes().quick()).unwrap(); + assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}"); + let reach = n.reach_bounds_with(&d.symbol_boxes()).unwrap(); + assert_eq!(reach.union(exact), reach, "{reach:?} holds {exact:?}"); + } + } + + #[test] + fn quick_boxes_place_art_that_holds_9_slice_instances_a_bounded_number_of_times() { + // A frame with guides, and 16 levels of symbols that each hold an instance of the level + // below a little taller and one turned a little: the transforms that place the frame differ + // for each of the 2^16 ways down, so measuring every one would place art 2^17 times. + let mut d = Document::new(200.0, 200.0); + let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0); + symbol(&mut d, "Level 0", vec![frame]); + d.set_symbol_nine_slice("Level 0", Some(crate::SymbolNineSlice::default())); + let (taller, turned) = (Affine::scale_non_uniform(1.0, 1.01), Affine::rotate(0.1)); + for i in 1..=16 { + let below = format!("Level {}", i - 1); + let pair = vec![instance(&mut d, &below, taller), instance(&mut d, &below, turned)]; + symbol(&mut d, &format!("Level {i}"), pair); + } + let top = instance(&mut d, "Level 16", taller); + // With room for 5,000 objects: each placement places at least one. + let s = SymbolBoxes { sliced_most: 5000, ..d.symbol_boxes().quick() }; + assert!(top.visual_bounds_with(&s).is_some()); + assert!(s.sliced_placed.get() <= 5000, "{} objects", s.sliced_placed.get()); + assert!(s.art_measured() <= 5000 + d.symbols.len(), "{} measures", s.art_measured()); + // Instances whose transforms differ only by a move place the art once. + let level = instance(&mut d, "Level 1", taller); + let moved = instance(&mut d, "Level 1", Affine::translate((5.0, 7.0)) * taller); + let s = d.symbol_boxes().quick(); + let b = level.visual_bounds_with(&s).unwrap(); + let measured = s.art_measured(); + assert_eq!(moved.visual_bounds_with(&s), Some(Affine::translate((5.0, 7.0)).transform_rect_bbox(b))); + assert_eq!(s.art_measured(), measured); + // The art of an opacity mask counts toward the limit: a group holding the framed + // instance, masked by a group of 10 rectangles, is 13 objects. + let mut framed = instance(&mut d, "Level 0", Affine::IDENTITY); + let squares = (0..10).map(|_| Arc::new(rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0))).collect(); + let mask = Node::group(d.alloc_id(), squares); + framed.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(mask, true))); + symbol(&mut d, "Masked", vec![framed]); + let masked = instance(&mut d, "Masked", taller); + let s = d.symbol_boxes().quick(); + assert!(masked.visual_bounds_with(&s).is_some()); + assert_eq!(s.sliced_placed.get(), 13); + } + #[test] fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() { let mut d = Document::new(200.0, 200.0); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 692ee2962..c82daf32d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -399,6 +399,28 @@ fn an_instance_is_culled_by_the_art_its_9_slice_guides_move() { assert_eq!(img.pixel(95, 50), [255, 0, 0, 255]); } +#[test] +fn an_instance_is_culled_by_the_art_nested_9_slice_guides_move() { + // The panel of the test above at its natural size, in a symbol without guides. The holder's + // instance scales the panel 2 times across and moves it 110 pt right: the bar draws from x 90 + // to 270 while the frame starts at x 110, out of view. + let mut d = Document::new(100.0, 100.0); + let bar = rect_node(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(bar) }); + let frame = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::None, Paint::None, 0.0); + let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY }); + let art = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(inner)]); + 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 panel = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf: Affine::IDENTITY }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Holder".into(), art: Arc::new(panel) }); + let (id, l) = (d.alloc_id(), d.layers[0].id); + let xf = Affine::translate((110.0, 0.0)) * Affine::scale_non_uniform(2.0, 1.0); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Holder".into(), xf })).unwrap(); + let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(img.pixel(95, 50), [255, 0, 0, 255]); +} + #[test] fn an_instance_with_a_geometry_effect_is_culled_by_its_reshaped_art() { // The art, (0, 0) to (20, 20), drawn from (-100, 10) is out of view, and the Transform effect