From 9dc172a26dab5be5e9ca081826feb295a917af29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 06:46:58 -0800 Subject: [PATCH] Symbols: the quick box of a 9-slice instance holds art its guides move An object in 9-slice art 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 instance's transformed art box. Bounds measured without placing the art (the renderer's culling, and placements past the object limit) used that box alone, so the displaced art could be culled and not drawn. The fallback now takes in the art box placed by each of the nine cells' maps (NineSliceMap::placement_bounds), grown by the art's reach as before. --- crates/doc/src/node/ops.rs | 7 ++++++- crates/doc/src/symbol_nine_slice.rs | 20 ++++++++++++++++++++ crates/doc/src/symbols.rs | 23 ++++++++++++++++++++++- crates/render/src/tests.rs | 21 +++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) 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