diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 07899196c..5ab9e2c6c 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -369,7 +369,12 @@ impl Node { let placed = xf.transform_rect_bbox(art.geometric); match s.placed_visual(symbol, *xf) { Some(v) => v.union(placed), - None => placed.inflate(art.reach, art.reach), + // Art placed through 9-slice guides can draw outside `placed`. + None => s + .document() + .symbol_slice_map_of(symbol, art.geometric, *xf) + .map_or(placed, |m| m.placement_bounds(art.geometric).union(placed)) + .inflate(art.reach, art.reach), } } None => xf.transform_rect_bbox(SYMBOL_SQUARE), diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 8d7c108ab..acc682872 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -343,6 +343,20 @@ impl NineSliceMap { 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(); @@ -704,6 +718,12 @@ impl Document { 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 diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 7e371c670..3a91f8d62 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -102,7 +102,9 @@ impl<'a> SymbolBoxes<'a> { /// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its /// 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. + /// 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`]). pub fn quick(self) -> Self { self.quick.set(true); self @@ -447,6 +449,25 @@ mod tests { assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach))); } + #[test] + fn quick_boxes_hold_art_that_9_slice_guides_move_past_the_instance() { + // A 100 × 100 frame with guides a quarter in, holding an instance of a bar from x 10 to 70. + // Scaled 2 times across, the bar crosses the left guide and is placed by the middle + // column's map, x → 3x − 50: it draws from x −20 to 160, past the frame's left edge at 0. + 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 panel = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0)); + let exact = panel.visual_bounds_with(&d.symbol_boxes()).unwrap(); + assert_eq!(exact, Rect::new(-20.0, 0.0, 200.0, 100.0)); + let quick = panel.visual_bounds_with(&d.symbol_boxes().quick()).unwrap(); + assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}"); + } + #[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 e171eecf6..f8060eea7 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -378,6 +378,27 @@ fn a_scaled_instance_is_culled_by_the_effects_in_its_art_as_placed() { assert_eq!(img.pixel(5, 50), [255, 0, 0, 255]); } +#[test] +fn an_instance_is_culled_by_the_art_its_9_slice_guides_move() { + // A 100 × 100 frame with guides a quarter in, holding an instance of a red bar from x 10 to + // 70. Scaled 2 times across, the bar crosses the left guide and is placed by the middle + // column's map, x → 3x − 50: moved 110 pt right, it 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 (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: "Panel".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