Symbols: instances of imported symbols are measured by their art

Every symbol instance was measured as its transform applied to the
20 × 20 square around the origin, which symbols made in the app keep
their art in. The SVG and DXF importers keep symbol art as drawn, so
the box of an instance of an imported symbol did not match its art.
The SVG Star instance at x 20, y 30 draws from (20, 30) to (40, 50)
and was measured from (10, 20) to (30, 40). Align moved such an
instance by the wrong amount, clicks and marquees beside its art
selected it, and the canvas, exports and prints left it out when its
art reached the view or the page and the square did not.

An instance is now measured by its symbol's art, which
Document::symbol_boxes looks up in the document each time
(Node::geometric_bounds_with, visual_bounds_with and
reach_bounds_with). With Use Preview Bounds on, the strokes in the art
count at their own weight, as the canvas draws them, and
object.setBounds counts them at that weight when it sizes an instance
or an object that holds one. When strokes do not scale and a width or
height of 0 or more is smaller than what a path's stroke adds,
object.setBounds now gives the path no width or height, as it does
for an instance whose art has strokes; before, it mirrored the path.
With Use Preview Bounds on, object.setBounds now measures the margins
that strokes add object by object, the members of groups and the
objects in symbol instances' art included, and solves for the width,
height and reference point asked for when the width and height are 0
or more. Before, it measured the margins of the whole selection and
could miss the size or the reference point asked for when strokes did
not scale and the objects' strokes added different margins, and when
strokes scaled and the width and height were out of proportion, even
for one path. A clip group, a Shaper Group, a compound path, type, and
a group or instance with effects or an opacity mask of its own are each
measured as one object, and so is an instance in perspective or one
turned or slanted relative to the bounding box, other than by quarter
turns. When the selection holds more than 4,096 objects to measure this
way, each selected object is measured as one. Instances inside symbol
art are measured by their own symbol's art. An instance is measured as
the square when its symbol is missing, when its symbol or a symbol
nested in its art holds an instance of itself, and when symbols nest 32
deep in its art.

Align and Distribute, the Transform panel, the bounding box and the
selection outline, clicks, the marquee, document.inspect and
document.node, Fit to Artwork Bounds and Fit to Selected Art, canvas
culling, thumbnails, the SVG filter region of an instance with a
raster effect, raster effect images, Export Selection, the SVG of an
artboard, printing, and the PDF, EPS, EMF/WMF and DXF writers use the
new box. The renderer also culls an instance by the effects and
brushed strokes in its art, and it culls a blend, envelope or repeat
by the brush art of what it holds and the effects in the art of the
instances in it. It culls an instance with geometry effects of its own
by the effects in its art after those geometry effects reshape the art.
It measures the effects in a symbol's art once for each symbol and
linear part of the transform that places it. For symbols whose art
holds instances, it measures at most 4,096 placements a frame, and at
most 4,096 more for each pattern tile it rasterizes and each placed
document it draws. Past that, it estimates the reach from the symbol's
reach with no transform, which it measures once. When the drawn
document's symbols change, the renderer drops the cached bounds of the
instances and of the objects that hold them.

The PDF and EPS writers, the SVG of an artboard and Skip Blank
Artboards grow the box of an instance whose art holds brushed strokes
by 16 times the widest stroke (at least 1 pt) of the objects in that
art that have a brushed stroke, plus 8 pt, as the renderer grows the
cull bounds of a brushed path. They keep an instance whose brush art
reaches the page. The SVG of an artboard also keeps an object that
holds an instance when what the canvas paints for it, the effects in
the symbol's art and the instance's own included, reaches the artboard.

Not yet: other commands that measure objects, such as Select › All on
Active Artboard, Transform Each and Convert to Artboards, still measure
an instance by the square.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-14 04:56:30 +07:00
committed by bflatastic
parent 21a386a6d4
commit d9bd9b134d
42 changed files with 2728 additions and 205 deletions
+90 -9
View File
@@ -10,7 +10,7 @@ use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use vectorcraft_brush::Brush;
use vectorcraft_doc::{AppearanceItem, Document, Node, StrokeLayer};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, StrokeLayer, SymbolBoxes};
use vectorcraft_geom::{BezPath, Rect};
use vello_cpu::RenderContext;
@@ -70,17 +70,28 @@ impl BrushCache {
}
}
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs).
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
let b = crate::fx::cull_bounds(n)?;
if !vectorcraft_brush::has_brush(n) {
return Some(b);
}
let w = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max);
let pad = 16.0 * w + 8.0;
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs), symbol
/// instances measured by their art from `symbols`, with the brushed strokes in that art.
pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
let b = crate::fx::cull_bounds(symbols, n)?;
let pad = pad(symbols, n);
Some(b.inflate(pad, pad))
}
/// How far brush art can paint past `n`'s brushed strokes, or past the brushed strokes in the art
/// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it
/// has a visible brushed stroke and of the objects in that art that have one
/// ([`SymbolBoxes::brush_width`]), and 0 when neither has one.
pub(crate) fn pad(symbols: &SymbolBoxes, n: &Node) -> f64 {
let own = vectorcraft_brush::has_brush(n)
.then(|| n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max));
let art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.brush_width(symbol).map(|w| w.max(1.0)),
_ => None,
};
own.into_iter().chain(art).reduce(f64::max).map_or(0.0, |w| 16.0 * w + 8.0)
}
pub use vectorcraft_brush::instance_art;
impl Renderer {
@@ -198,4 +209,74 @@ mod tests {
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(50, 50), [255, 0, 0, 255]);
}
#[test]
fn an_instance_is_culled_by_the_brushed_strokes_in_its_art() {
// The art: a line from (0, 40) to (0, 60) with a 1 pt stroke painted by a 20 pt round nib,
// drawn from x -6. The line lies left of the view, and the nib paints 10 pt either side of it.
let mut d = Document::new(100.0, 100.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let art = Node::path(vectorcraft_doc::NodeId(0), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), ap);
d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: std::sync::Arc::new(art) });
let id = d.alloc_id();
let inst = Node::new(id, vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-6.0, 0.0)) });
let l = d.layers[0].id;
d.insert(Some(l), 0, inst).unwrap();
let img = render(&mut Renderer::new(), &d);
assert!(dark(img.pixel(1, 50)), "{:?}", img.pixel(1, 50));
}
#[test]
fn a_repeat_is_culled_by_the_brush_art_of_what_it_holds() {
// A one-by-one grid repeat of the line of the test above drawn from x -8, then of an
// instance of a symbol of that line: the nib paints 10 pt either side of the line, into
// the view.
let line = |d: &mut Document| {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), ap)
};
for instance in [false, true] {
let mut d = Document::new(100.0, 100.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let moved = Affine::translate((-8.0, 0.0));
let source = if instance {
let art = line(&mut d);
d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: std::sync::Arc::new(art) });
Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Nib".into(), xf: moved })
} else {
let mut n = line(&mut d);
n.transform(moved, false);
n
};
let grid = vectorcraft_doc::TileType::Grid;
let kind = vectorcraft_doc::RepeatKind::Grid {
h_spacing: 0.0,
v_spacing: 0.0,
rows: 1,
cols: 1,
grid_type: grid,
flip_rows: false,
flip_cols: false,
};
let spec = vectorcraft_doc::RepeatSpec { kind, source: vec![std::sync::Arc::new(source)] };
let repeat = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::Repeat(spec));
let l = d.layers[0].id;
d.insert(Some(l), 0, repeat).unwrap();
let img = render(&mut Renderer::new(), &d);
assert!(dark(img.pixel(1, 50)), "an instance: {instance}; {:?}", img.pixel(1, 50));
}
}
}
+11 -7
View File
@@ -15,7 +15,7 @@ pub mod tga;
pub mod tiff;
pub mod web;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
use vectorcraft_geom::Rect;
use crate::{AntiAlias, RenderOptions, Rendered, Renderer, fx};
@@ -186,22 +186,26 @@ pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
}
/// Bounds of the art an export draws: visible objects off template layers (guides left out),
/// with their strokes and live effects. `None` when nothing would be drawn.
/// with their strokes and live effects, symbol instances measured by their art. `None` when
/// nothing would be drawn.
pub fn art_bounds(doc: &Document) -> Option<Rect> {
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(l)))
let symbols = doc.symbol_boxes();
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(&symbols, l)))
}
fn drawn_bounds(n: &Node) -> Option<Rect> {
fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if !n.visible {
return None;
}
match &n.kind {
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None,
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(c)))
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c)))
}
_ if fx::has_fx(n) => fx::visual_bounds(n),
_ => n.visual_bounds(),
_ if fx::has_fx(n) => fx::visual_bounds(symbols, n),
// An instance's box takes in its art, the effects in the art and its own effects.
NodeKind::SymbolInstance { .. } => fx::cull_bounds(symbols, n),
_ => n.visual_bounds_with(symbols),
}
}
+16
View File
@@ -228,6 +228,22 @@ fn art_bounds_cover_visible_art_only() {
assert_eq!(art_bounds(&empty), None);
}
#[test]
fn art_bounds_take_in_the_effects_in_a_symbols_art() {
// A symbol's art, (0, 0) to (10, 10), with a drop shadow 8 pt to the right, drawn from (20, 20).
let mut d = Document::new(100.0, 100.0);
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), black);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Box".into(), art: std::sync::Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
let xf = vectorcraft_geom::Affine::translate((20.0, 20.0));
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf })).unwrap();
let b = art_bounds(&d).unwrap();
assert!(b.x0 <= 20.0 && b.x1 >= 38.0, "the art from x 20 and its shadow to x 38: {b:?}");
}
/// A black path round `r` in the first layer of `d`, made a guide when `guide`.
fn add_rect(d: &mut Document, r: Rect, guide: bool) {
let id = d.alloc_id();
+148 -18
View File
@@ -38,7 +38,7 @@ use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{Arc, Weak};
use vectorcraft_color::cms::Cms;
use vectorcraft_doc::{AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_doc::{AppearanceItem, Document, Effect, Node, NodeId, NodeKind, SymbolBoxes};
use vectorcraft_effects::pixel::{Channels, PixelFx};
use vectorcraft_effects::{self as effects, GeomContext, RasterFx};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathEl, Point, Rect, Shape, Size, Vec2};
@@ -147,10 +147,11 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
}
}
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
/// Visual bounds including geometry effects, stroke outsets and shadows/glows (symbol instances
/// measured by their art from `symbols`).
pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) {
return cull_bounds(&art).map(|b| {
return cull_bounds(symbols, &art).map(|b| {
let o = effects::outset(&n.appearance.effects, b);
b.inflate(o, o)
});
@@ -177,35 +178,123 @@ pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
})
}
/// Bounds used for culling: like `Node::visual_bounds`, but aware of live effects anywhere in
/// the subtree (a shadow can reach the view while its object is outside it).
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
/// Bounds used for culling: like `Node::visual_bounds_with`, but aware of live effects anywhere in
/// the subtree (a shadow can reach the view while its object is outside it). Symbol instances are
/// measured by their art from `symbols`.
pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
cull_bounds_in(symbols, n, true)
}
/// [`cull_bounds`] of `n`. With `after_reshape` true, the effects in the art of a symbol instance
/// with geometry effects of its own are measured on the art as those effects reshape it
/// ([`reshaped_art_effects_reach`]). With `after_reshape` false, they are measured on the art
/// before those effects run ([`art_effects_reach`]), which `symbols` keeps for each symbol and
/// linear part.
fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Option<Rect> {
let inner = |c: &Node| cull_bounds_in(symbols, c, after_reshape);
match &n.kind {
// The object and its crop marks.
_ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| cull_bounds(&art)),
_ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)),
// The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach.
_ if effects::has_container_appearance(n) => {
let art = effects::evaluate_container(n);
let art = art.as_ref().unwrap_or(n);
let b = if art.clips() {
art.visual_bounds()
art.visual_bounds_with(symbols)
} else {
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}?;
let o = effects::outset(&n.appearance.effects, b);
Some(b.inflate(o, o))
}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c)))
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c)))
}
_ if has_fx(n) => visual_bounds(n),
// The reshaped art (a symbol's art needs the document: its instance box stands in).
_ if has_fx(n) => visual_bounds(symbols, n),
// The reshaped art (a symbol instance's reshaped art is made from its symbol's art), grown
// by how far the effects in a symbol instance's art paint past that art.
_ if has_object_fx(n) => {
let b = effects::reshape(n, None).and_then(|r| r.visual_bounds()).or_else(|| n.visual_bounds())?;
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
};
let reshaped = effects::reshape(n, symbol.as_deref());
let b = reshaped.as_ref().and_then(|r| r.visual_bounds_with(symbols)).or_else(|| n.visual_bounds_with(symbols))?;
let a = match &reshaped {
Some(r) if after_reshape => reshaped_art_effects_reach(symbols, n, r),
_ => art_effects_reach(symbols, n),
};
let a = a.max(live_content_reach(symbols, n));
let b = b.inflate(a, a);
let o = effects::outset(&n.appearance.effects, b);
Some(b.inflate(o, o))
}
_ => n.visual_bounds(),
NodeKind::SymbolInstance { .. } => {
let b = n.visual_bounds_with(symbols)?;
let a = art_effects_reach(symbols, n);
Some(b.inflate(a, a))
}
_ => {
let b = n.visual_bounds_with(symbols)?;
let a = live_content_reach(symbols, n);
Some(b.inflate(a, a))
}
}
}
/// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual
/// bounds: for each object in it, how far the effects in a symbol instance's art paint past that
/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the
/// farthest side. 0 for any other kind of object.
fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
if !vectorcraft_doc::live::is_live(n) {
return 0.0;
}
let mut reach = 0.0f64;
n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c)));
reach
}
/// How far the effects in the art of symbol instance `n` (and in the art of the symbols the
/// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the
/// art as the instance places it before its effects run: 0 when the art has no visible effects, and
/// for any other kind of object. The reach is the same wherever the art is moved to, so the art is
/// placed by the linear part of the instance's transform alone, and `symbols` keeps the reach for
/// each symbol and linear part, up to a limit ([`SymbolBoxes::effects_reach`]).
fn art_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 {
let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return 0.0 };
if !symbols.has_effects(symbol) {
return 0.0;
}
let measure = |[m0, m1, m2, m3]: [f64; 4]| {
let past = |art: &Arc<Node>| {
let mut placed = (**art).clone();
placed.transform(Affine::new([m0, m1, m2, m3, 0.0, 0.0]), false);
match (cull_bounds(symbols, &placed), placed.visual_bounds_with(symbols)) {
(Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max),
_ => 0.0,
}
};
symbols.measure_art(symbol, past).unwrap_or(0.0)
};
let [m0, m1, m2, m3, _, _] = xf.as_coeffs();
symbols.effects_reach(symbol, [m0, m1, m2, m3], &measure)
}
/// How far the effects in the art of symbol instance `n` paint past `r`, that art as `n`'s own
/// geometry effects reshape it ([`effects::reshape`]), on its farthest side: 0 when the art has no
/// visible effects, and for any other kind of object. The art in `r` is measured without the raster
/// effects of `n` that `r` carries, by [`cull_bounds_in`] with `after_reshape` false: the effects in
/// the art of an instance in it are measured before that instance's own geometry effects run.
fn reshaped_art_effects_reach(symbols: &SymbolBoxes, n: &Node, r: &Node) -> f64 {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return 0.0 };
if !symbols.has_effects(symbol) {
return 0.0;
}
let art = r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds_in(symbols, c, false)));
match (art, r.visual_bounds_with(symbols)) {
(Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max),
_ => 0.0,
}
}
@@ -754,7 +843,7 @@ impl Renderer {
let opacity = self.opacity_of(a);
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate) {
let blends = self.blends_through(a);
let bounds = if blends { self.bounds_of(a) } else { None };
let bounds = if blends { self.bounds_of(f.symbols, a) } else { None };
let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_object_fx_art(c, fr, a, cache));
} else {
@@ -772,7 +861,7 @@ impl Renderer {
self.made_again = outer || !kept;
// With Crop Marks, the object inside the art paints its own raster effects.
let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) };
match cull_bounds(&art) {
match cull_bounds(f.symbols, &art) {
Some(reach) if !f.opts.outline && !rfx.is_empty() => {
// Feather and Inner Glow clip to the art's outline.
let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. }));
@@ -1621,7 +1710,7 @@ mod tests {
fn visual_bounds_include_shadow() {
let d = doc_with(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK, vec![("stylize.dropShadow", json!({"x": 7, "y": 7, "blur": 5}))]);
let n = &d.layers[0].children().unwrap()[0];
let b = super::visual_bounds(n).unwrap();
let b = super::visual_bounds(&d.symbol_boxes(), n).unwrap();
assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9);
}
@@ -1729,6 +1818,47 @@ mod tests {
assert!(after != last, "6.3 is not on a whole pixel");
}
/// While interacting, a group holding a symbol instance whose art lies outside the 20 × 20
/// square around the origin draws its drop shadow from the raster of an earlier frame. The
/// shadow is made from the instance's art, which the renderer measures the group's content by.
#[test]
fn a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting() {
let mut base = Document::new(100.0, 100.0);
let orange = Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0);
let art = Node::path(base.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 130.0, 126.0)), orange);
base.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: std::sync::Arc::new(art) });
// The instance draws the art from (20, 20) to (50, 46), and the 20 × 20 square around the
// origin from (-90, -90) to (-70, -70).
let xf = Affine::translate((-80.0, -80.0));
let instance = Node::new(base.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Far".into(), xf });
let mut group = Node::group(base.alloc_id(), vec![std::sync::Arc::new(instance)]);
let shadow = json!({"x": 4, "y": 3, "blur": 2, "opacity": 80});
group.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true });
let (id, l) = (group.id, base.layers[0].id);
base.insert(Some(l), 0, group).unwrap();
// The group moved from where it started, as each frame of a drag moves it.
let moved = |dx: f64, dy: f64| {
let mut d = base.clone();
d.node_mut(id).unwrap().transform(Affine::translate((dx, dy)), false);
d
};
let (view, still) = (Affine::scale(2.0), RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
let live = RenderOptions { interacting: true, ..still.clone() };
let fresh = |d: &Document| Renderer::new().render(d, 200, 200, view, &still);
let mut r = Renderer::new();
let first = r.render(&base, 200, 200, view, &still);
assert!(first.pixels == fresh(&base).pixels);
// The shadow, 4 pt right of the art's right edge at x 50, at (104, 70) in pixels.
assert!(lum(first.pixel(104, 70)) < WHITE, "{:?}", first.pixel(104, 70));
// Moves on whole pixels (2 a point): the moved shadow lies where a fresh renderer draws it.
for (dx, dy) in [(3.0, 0.0), (5.5, 2.0)] {
let d = moved(dx, dy);
let frame = r.render(&d, 200, 200, view, &live).pixels;
assert_eq!(r.stats.moved, 1, "the shadow moved by ({dx}, {dy})");
assert!(frame == fresh(&d).pixels, "moved by ({dx}, {dy})");
}
}
/// While interacting, a raster moves with its object only when the content's pixels (for a
/// shadow, their alpha) and the effects stayed the same (#1257): a new fill color under
/// Craquelure, other effect settings, a pattern fill that stays on the page, a resized object,
+66 -26
View File
@@ -24,7 +24,7 @@ pub mod proof;
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject, TraceView};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, TraceView};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
@@ -43,9 +43,10 @@ pub use vectorcraft_effects as effects;
pub use vello_cpu;
/// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and
/// geometry effects, and the shadows and glows of it and its members.
pub fn painted_bounds(n: &Node) -> Option<Rect> {
brush_fx::cull_bounds(n)
/// geometry effects, and the shadows and glows of it and its members. Symbol instances are
/// measured by their art in `doc` ([`Node::visual_bounds_with`]).
pub fn painted_bounds(doc: &Document, n: &Node) -> Option<Rect> {
brush_fx::cull_bounds(&doc.symbol_boxes(), n)
}
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
@@ -399,6 +400,11 @@ pub struct Renderer {
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
pub threads: u16,
geom: PtrMap<usize, GeomEntry>,
/// The symbols (name and art) of the document last drawn, which `geom`'s bounds measured
/// symbol instances by (see [`Self::check_symbols`]). The art is held weakly: the reference
/// keeps the art's allocation, so no other art can get its address, and does not keep the
/// art's content.
symbols: Vec<(String, std::sync::Weak<Node>)>,
/// Expanded stroke outlines keyed by ([`Self::stroke_owner`], stroke layer address, width bits,
/// tolerance level): panning re-renders reuse them instead of re-expanding every stroke.
strokes: PtrMap<(usize, usize, u64, i32), StrokeEntry>,
@@ -515,6 +521,8 @@ struct Frame<'a> {
/// Drawing into a multithreaded context: vello filter layers must be rendered offscreen.
mt: bool,
doc: &'a Document,
/// `doc`'s symbol art boxes, which culling measures symbol instances by ([`SymbolBoxes::quick`]).
symbols: &'a SymbolBoxes<'a>,
view: Affine,
/// Visible region in document coordinates (for culling).
visible: Rect,
@@ -549,6 +557,7 @@ impl Renderer {
ctx_st: None,
threads: default_threads(),
geom: PtrMap::default(),
symbols: vec![],
strokes: PtrMap::default(),
cur: None,
made_again: false,
@@ -604,6 +613,8 @@ impl Renderer {
return Rendered { width: w, height: h, pixels };
}
}
// The document's own symbols: proofing rewrites the colors and blend modes of their art.
self.check_symbols(doc);
let prepared = proof::prepare(doc, opts);
let doc: &Document = &prepared;
let w = width.clamp(1, u16::MAX as u32) as u16;
@@ -616,7 +627,8 @@ impl Renderer {
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display };
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: screen };
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen };
self.begin_frame();
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
@@ -791,12 +803,14 @@ impl Renderer {
/// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`.
pub fn render_node(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<Rendered> {
self.check_symbols(doc);
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let mut ctx = single_threaded_context(w, h);
let opts = RenderOptions::default();
let frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display };
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display };
self.begin_frame();
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
self.clip_paths.clear();
@@ -807,7 +821,8 @@ impl Renderer {
}
/// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into
/// `size`×`size` pixels, optionally over a solid premultiplied background.
/// `size`×`size` pixels, optionally over a solid premultiplied background. A symbol instance
/// is fitted by its symbol's art in `doc`.
pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option<Rendered> {
self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default())
}
@@ -826,7 +841,8 @@ impl Renderer {
background: Option<[u8; 4]>,
opts: &RenderOptions,
) -> Option<Rendered> {
let b = brush_fx::cull_bounds(n)?;
self.check_symbols(doc);
let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?;
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16;
@@ -835,7 +851,8 @@ impl Renderer {
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
}
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display };
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display };
self.begin_frame();
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
self.clip_paths.clear();
@@ -845,8 +862,9 @@ impl Renderer {
Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() })
}
/// Cached cull bounds of a node (containers union their cached children).
fn bounds_of(&mut self, a: &Arc<Node>) -> Option<Rect> {
/// Cached cull bounds of a node (containers union their cached children), symbol instances
/// measured by their art box from `symbols`.
fn bounds_of(&mut self, symbols: &SymbolBoxes, a: &Arc<Node>) -> Option<Rect> {
let key = Arc::as_ptr(a) as usize;
if let Some(e) = self.geom.get_mut(&key)
&& Arc::ptr_eq(&e.node, a)
@@ -861,17 +879,29 @@ impl Renderer {
let source = a.extras().trace.is_some().then(|| children.first()).flatten();
for c in children {
if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) {
acc = vectorcraft_geom::union_opt(acc, self.bounds_of(c));
acc = vectorcraft_geom::union_opt(acc, self.bounds_of(symbols, c));
}
}
acc
}
_ => brush_fx::cull_bounds(a),
_ => brush_fx::cull_bounds(symbols, a),
};
self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp });
b
}
/// Drop the cached bounds of symbol instances and of the objects that hold them when `doc`'s
/// symbols are not those of the document last drawn (by name and art): those bounds measured
/// the instances by their symbol's art.
fn check_symbols(&mut self, doc: &Document) {
let same = self.symbols.len() == doc.symbols.len()
&& self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art)));
if !same {
self.geom.retain(|_, e| !holds_instance(&e.node));
self.symbols = doc.symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect();
}
}
/// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]):
/// outlining text and uniting shapes is too slow to repeat every frame.
fn clip_of(&mut self, a: &Arc<Node>) -> Option<Arc<(BezPath, FillRule)>> {
@@ -903,8 +933,9 @@ impl Renderer {
(fill, stroke)
}
/// Cached BezPath of a path or compound path node.
fn path_of(&mut self, a: &Arc<Node>) -> Option<Arc<BezPath>> {
/// Cached BezPath of a path or compound path node (its bounds cached with it, see
/// [`Self::bounds_of`]).
fn path_of(&mut self, symbols: &SymbolBoxes, a: &Arc<Node>) -> Option<Arc<BezPath>> {
let key = Arc::as_ptr(a) as usize;
if let Some(e) = self.geom.get(&key)
&& Arc::ptr_eq(&e.node, a)
@@ -913,7 +944,7 @@ impl Renderer {
return Some(p.clone());
}
let p = Arc::new(a.stroke_path()?);
let bounds = self.bounds_of(a);
let bounds = self.bounds_of(symbols, a);
self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp });
Some(p)
}
@@ -922,7 +953,7 @@ impl Renderer {
/// Outline view and `n` is `fx_owner`, else `None`.
fn outline_path(&mut self, f: &Frame, n: &Node) -> Option<Arc<BezPath>> {
let owner = self.fx_owner.as_ref().filter(|o| f.opts.outline && std::ptr::eq(&***o, n)).cloned()?;
self.path_of(&owner)
self.path_of(f.symbols, &owner)
}
/// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled.
@@ -931,7 +962,7 @@ impl Renderer {
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
return true;
}
match self.bounds_of(a) {
match self.bounds_of(f.symbols, a) {
Some(b) => {
let pad = f.px * 2.0;
// Level of detail: leaves smaller than a quarter pixel are invisible.
@@ -956,7 +987,7 @@ impl Renderer {
// A masked object is a non-isolated group of its own.
let mask = Some(self.opacity_mask(f, m, ctx.width(), ctx.height()));
let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a));
let bounds = if blends { self.bounds_of(a) } else { None };
let bounds = if blends { self.bounds_of(f.symbols, a) } else { None };
self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
return;
}
@@ -999,7 +1030,7 @@ impl Renderer {
&& !a.isolate
&& !fx::has_fx(a)
&& (self.opacity_of(a) >= 1.0 || painted_items(a) == 1)
&& let Some(bp) = self.path_of(a)
&& let Some(bp) = self.path_of(f.symbols, a)
{
self.alpha = self.opacity_of(a).clamp(0.0, 1.0);
self.cur = Some(a.clone());
@@ -1047,7 +1078,7 @@ impl Renderer {
}
if force {
// Bounds were already checked by draw_arc (or the caller wants it drawn regardless).
} else if let Some(b) = fx::cull_bounds(n) {
} else if let Some(b) = fx::cull_bounds(f.symbols, n) {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += n.count();
@@ -1138,7 +1169,7 @@ impl Renderer {
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate || knockout) {
let blends = self.blends_through(n);
let bounds = if blends { self.node_bounds(n) } else { None };
let bounds = if blends { self.node_bounds(f.symbols, n) } else { None };
let comp = Composite { blend: n.blend, opacity, isolated: n.isolate, blends, bounds, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_content(c, fr, n, knockout));
} else {
@@ -1266,7 +1297,7 @@ impl Renderer {
if !c.visible || f.opts.hidden.contains(&c.id) {
return;
}
match self.bounds_of(c) {
match self.bounds_of(f.symbols, c) {
Some(b) => {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
@@ -1309,12 +1340,13 @@ impl Renderer {
}
}
/// Cull bounds of `n`: cached when it is a node of the tree being drawn.
fn node_bounds(&mut self, n: &Node) -> Option<Rect> {
/// Cull bounds of `n` (symbol instances measured by their art box from `symbols`): cached when
/// it is a node of the tree being drawn.
fn node_bounds(&mut self, symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
match self.geom.get(&(n as *const Node as usize)) {
// The entry keeps its node alive, so the address is that node's.
Some(e) if std::ptr::eq(Arc::as_ptr(&e.node), n) => e.bounds,
_ => fx::cull_bounds(n),
_ => fx::cull_bounds(symbols, n),
}
}
@@ -2043,6 +2075,14 @@ fn holds_type(n: &Node) -> bool {
holds
}
/// Is `n` a symbol instance, or does it hold one? Its cull bounds measure the instance by its
/// symbol's art.
fn holds_instance(n: &Node) -> bool {
let mut holds = false;
n.walk(&mut |c| holds |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
holds
}
/// A symbol instance's stain ([`vectorcraft_brush::stain`]) as bits for a cache key: its color
/// model, the color's components and the amount (all 0 without a stain).
fn stain_bits(stain: Option<(vectorcraft_color::Color, f32)>) -> [u32; 6] {
+1 -1
View File
@@ -226,7 +226,7 @@ impl Renderer {
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate || knockout) {
let blends = self.blends_through(a);
let bounds = if blends { self.bounds_of(a) } else { None };
let bounds = if blends { self.bounds_of(f.symbols, a) } else { None };
let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_live_body(c, fr, a, true));
} else {
+3
View File
@@ -91,6 +91,8 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
* Affine::translate(-region.origin().to_vec2());
let opts = RenderOptions::default();
r.check_symbols(doc);
let symbols = doc.symbol_boxes().quick();
r.stamp += 1;
let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64);
for o in def.offsets_covering(region) {
@@ -98,6 +100,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
let frame = Frame {
mt: false,
doc,
symbols: &symbols,
view: v,
visible: v.inverse().transform_rect_bbox(px_rect),
px: 1.0 / v.determinant().abs().sqrt().max(1e-12),
+3 -1
View File
@@ -291,7 +291,9 @@ impl Renderer {
return;
}
// The art in its own coordinates, with its own resources.
let inner = Frame { doc: &e.doc, view: f.view * m, visible: m.inverse().transform_rect_bbox(f.visible), px: f.px / k, ..*f };
let symbols = e.doc.symbol_boxes().quick();
let visible = m.inverse().transform_rect_bbox(f.visible);
let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, ..*f };
let _inside = Inside::enter();
self.draw_arc(ctx, &inner, &e.art);
}
+190
View File
@@ -187,6 +187,196 @@ fn hidden_and_culled() {
assert_eq!(rr.stats.culled, 1);
}
/// A document with symbol "Far" holding a red rectangle `art`, and an instance of it moved by
/// (-50, -50): its 20 × 20 square, (-60, -60) to (-40, -40), lies outside a view of the page.
fn far_instance(art: Rect) -> Document {
let mut d = Document::new(100.0, 100.0);
let art = rect_node(&mut d, art, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })).unwrap();
d
}
#[test]
fn an_instance_is_culled_by_its_symbols_art() {
// The art drawn from (350, 350) is out of view.
let away = far_instance(Rect::new(400.0, 400.0, 440.0, 440.0));
let mut rr = Renderer::new();
assert_eq!(rr.render(&away, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(70, 70), [0, 0, 0, 0]);
assert_eq!(rr.stats.culled, 1);
// The symbol's art, from (100, 100), drawn from (50, 50): in view. The instance is the same
// node, and the renderer measures it again.
let mut near = away.clone();
let art = rect_node(&mut near, Rect::new(100.0, 100.0, 140.0, 140.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
near.symbols[0].art = Arc::new(art);
assert_eq!(rr.render(&near, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(70, 70), [255, 0, 0, 255]);
assert_eq!(rr.stats.culled, 0);
}
#[test]
fn an_instance_is_culled_by_the_effects_in_its_art() {
// The art, (-1, -1) to (1, 1), drawn from (-5, 20) lies left of the view, and its drop shadow,
// 8 pt to the right, reaches into it.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
#[test]
fn a_repeat_of_an_instance_is_culled_by_the_effects_in_its_art() {
// The instance of the test above as the one copy of a one-by-one grid repeat.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) });
let grid = vectorcraft_doc::TileType::Grid;
let kind =
vectorcraft_doc::RepeatKind::Grid { h_spacing: 0.0, v_spacing: 0.0, rows: 1, cols: 1, grid_type: grid, flip_rows: false, flip_cols: false };
let repeat = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::RepeatSpec { kind, source: vec![Arc::new(instance)] }));
let l = d.layers[0].id;
d.insert(Some(l), 0, repeat).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
#[test]
fn past_the_measured_placements_an_instance_is_culled_by_an_estimate_of_its_art_effects() {
// Symbol `Spot` is a square from (-1, -1) to (1, 1) with a drop shadow 8 pt to the right, and
// symbols `Dot` and `Late` each hold an instance of it. 4,100 instances of `Dot`, each turned by
// another angle, lie far left of the view: one frame measures the effects in the art of at most
// 4,096 placements of symbols that hold instances. An instance of `Late` at (-5, 20) comes
// after them: its square lies left of the view, and the shadow reaches into it.
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "Spot".into(), art: Arc::new(art) });
for name in ["Dot", "Late"] {
let spot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Spot".into(), xf: Affine::IDENTITY });
let group = Node::group(d.alloc_id(), vec![Arc::new(spot)]);
d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(group) });
}
let l = d.layers[0].id;
for i in 0..4100 {
let xf = Affine::translate((-1000.0, 50.0)) * Affine::rotate(f64::from(i) * 1e-4);
let id = d.alloc_id();
d.insert(Some(l), i as usize, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
}
let id = d.alloc_id();
d.insert(Some(l), 4100, Node::new(id, NodeKind::SymbolInstance { symbol: "Late".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert!(img.pixel(3, 20)[3] > 0, "{:?}", img.pixel(3, 20));
}
/// Symbols 0 to 29 each hold two instances of the next, sheared two ways, so that the 2^30
/// instances drawn from the first are all placed differently. Symbol 30 holds a square with a drop
/// shadow. An instance of symbol 0 lies far left of the view. With `effect`, every instance has
/// that effect of its own. The renderer must cull the instance of symbol 0 within 30 s.
fn nested_instances_are_culled_in_bounded_time(effect: Option<vectorcraft_doc::Effect>) {
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0);
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 2, "y": 0, "blur": 0}), visible: true };
art.appearance.effects.push(shadow);
d.symbols.push(vectorcraft_doc::Symbol { name: "S30".into(), art: Arc::new(art) });
let instance = |d: &mut Document, symbol: String, xf: Affine| {
let mut n = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol, xf });
n.appearance.effects.extend(effect.clone());
n
};
for i in (0..30).rev() {
let next = format!("S{}", i + 1);
let mut pair = vec![];
for xf in [Affine::new([1.0, 0.0, 0.1, 1.0, 0.0, 0.0]), Affine::new([1.0, 0.1, 0.0, 1.0, 2.0, 0.0])] {
pair.push(Arc::new(instance(&mut d, next.clone(), xf)));
}
let group = Node::group(d.alloc_id(), pair);
d.symbols.push(vectorcraft_doc::Symbol { name: format!("S{i}"), art: Arc::new(group) });
}
let first = instance(&mut d, "S0".into(), Affine::translate((-100_000.0, 0.0)));
let l = d.layers[0].id;
d.insert(Some(l), 0, first).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = Renderer::new();
let img = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
tx.send((img.pixel(50, 50), r.stats.culled)).ok();
});
let (pixel, culled) = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the frame within 30 s");
assert_eq!(pixel, [0, 0, 0, 0]);
assert!(culled > 0);
}
#[test]
fn nested_instances_with_effects_are_culled_in_bounded_time() {
nested_instances_are_culled_in_bounded_time(None);
}
#[test]
fn nested_instances_with_geometry_effects_of_their_own_are_culled_in_bounded_time() {
// Each instance's Transform effect reshapes the art it shows before the effects in that art run.
let moved = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 1}), visible: true };
nested_instances_are_culled_in_bounded_time(Some(moved));
}
#[test]
fn a_scaled_instance_is_culled_by_the_effects_in_its_art_as_placed() {
// The art, (-1, -1) to (1, 1), scaled 30 times and centered on (-50, 50): (-80, 20) to
// (-20, 80), out of view. Its Transform effect doubles the placed art: (-110, -10) to
// (10, 110).
let mut d = Document::new(100.0, 100.0);
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
let double = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 200, "scaleV": 200}), visible: true };
art.appearance.effects.push(double);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
let xf = Affine::translate((-50.0, 50.0)) * Affine::scale(30.0);
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf })).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(5, 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
// moves it 150 pt right, into view.
let mut d = far_instance(Rect::new(0.0, 0.0, 20.0, 20.0));
let id = d.layers[0].children().unwrap()[0].id;
let inst = d.node_mut(id).unwrap();
inst.kind = NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-100.0, 10.0)) };
let effect = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 150}), visible: true };
inst.appearance.effects.push(effect);
assert_eq!(Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixel(60, 20), [255, 0, 0, 255]);
}
#[test]
fn an_instance_with_a_geometry_effect_is_culled_by_the_effects_in_its_reshaped_art() {
// The art, (-1, -1) to (1, 1), and the instance at (-11, 20) each have a Transform effect that
// scales by 4. The instance's effect scales the placed art, (-12, 19) to (-10, 21), to
// (-15, 16) to (-7, 24), and the art's effect then scales that to (-27, 4) to (5, 36), which
// reaches into the view.
let mut d = Document::new(100.0, 100.0);
let scale = serde_json::json!({"scaleH": 400, "scaleV": 400});
let effect = || vectorcraft_doc::Effect { id: "distort.transform".into(), params: scale.clone(), visible: true };
let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
art.appearance.effects.push(effect());
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) });
let (id, l) = (d.alloc_id(), d.layers[0].id);
let mut instance = Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-11.0, 20.0)) });
instance.appearance.effects.push(effect());
d.insert(Some(l), 0, instance).unwrap();
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
assert_eq!(img.pixel(2, 20), [255, 0, 0, 255]);
}
#[test]
fn view_transform_zooms() {
let mut d = Document::new(100.0, 100.0);
+33
View File
@@ -472,6 +472,39 @@ fn editing_an_instance_or_a_symbol_places_only_its_art_again() {
assert!(moved.is_subset(&edited) && edited.len() == moved.len() + 6, "{} copies, then {}", moved.len(), edited.len());
}
/// A symbol whose art, kept between frames, lies outside the 20 × 20 square around the origin:
/// each frame culls the instance by the art the symbol holds then and draws what a new renderer
/// draws. When the symbol holds earlier art again, the renderer draws the art it placed for it
/// then.
#[test]
fn instances_of_art_outside_the_square_follow_their_symbols_art() {
/// A blue square in a group, 20 × 20 from (x, 0).
fn square_at(d: &mut Document, x: f64) -> Arc<Node> {
let blue = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), blue);
Arc::new(Node::group(d.alloc_id(), vec![Arc::new(square)]))
}
let mut d = Document::new(200.0, 100.0);
// The instance moves the art by (-160, 40): the square from x 100 lies left of the view, and
// the one from x 200 lies from (40, 40) to (60, 60), in it. The 20 × 20 square around the
// origin lies from (-170, 30) to (-150, 50), left of the view.
let (off, on) = (square_at(&mut d, 100.0), square_at(&mut d, 200.0));
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: off.clone() });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-160.0, 40.0)) })).unwrap();
let opts = RenderOptions::default();
let mut r = Renderer::new();
let mut placed = vec![];
for art in [&off, &on, &off, &on] {
d.symbols[0].art = art.clone();
let frame = r.render(&d, 200, 100, Affine::IDENTITY, &opts);
assert!(frame.pixels == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels);
assert_eq!(frame.pixel(50, 50) == [0, 0, 255, 255], Arc::ptr_eq(art, &on), "{:?}", frame.pixel(50, 50));
placed.push(placed_copies(&r, &[&d]));
}
assert!(!placed[1].is_empty() && placed[3] == placed[1], "the fourth frame draws the art placed in the second");
}
/// Draws `d` (200 × 100 points, on white), applies `edit` and draws it again with the same
/// renderer: the frame changed, a new renderer draws the same, and only the document holds the
/// symbols' art.