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.
This commit is contained in:
dotCooCoo
2026-11-04 21:59:07 +07:00
committed by bflatastic
parent ef5a4d967a
commit 9dc172a26d
4 changed files with 69 additions and 2 deletions
+6 -1
View File
@@ -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),
+20
View File
@@ -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> {
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
+22 -1
View File
@@ -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);
+21
View File
@@ -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