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