Render: objects with plug-in effects follow the plug-ins installed

The renderer kept the shapes it drew for plug-in effects while the
object stayed the same. When a plug-in was installed for the first
time, installed again or removed, a renderer that had drawn the object
went on drawing the shapes from before the change.

When the plug-in registry's revision changes, the renderer now drops
the effect geometry and the effect art of objects with a plug-in
effect, and the cull bounds of every object that is or holds a symbol
instance or an object with a plug-in effect. Each thread then drops the
pattern tiles whose art has a plug-in effect or shows symbols or
patterns. The renderer adds the revision to the inputs of a raster
when its content has a plug-in effect, or shows symbols or patterns
while the document's symbol or pattern art has one, and to the key of
a live object's evaluation when the object, or an object it shows, has
a plug-in effect.

With other builds running, a temporary release test timed that key for
the 400 live objects in each of three documents at 11 to 609
microseconds a frame, against 9 to 433 before.

Docs: docs/target-app-parity.md, docs/gaps.md.
This commit is contained in:
dotCooCoo
2026-10-16 04:04:50 +07:00
committed by bflatastic
parent b34b9dadcc
commit c6837ca3ae
11 changed files with 363 additions and 25 deletions
Generated
+2
View File
@@ -4846,10 +4846,12 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-plugins",
"vectorcraft-testkit",
"vectorcraft-text",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
"wat",
"weezl",
]
+12
View File
@@ -749,6 +749,18 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
effects.iter().any(|e| e.visible && is_geometry(&e.id))
}
/// Does `n` have a visible plug-in effect, its own or a fill's or stroke's? Such an effect draws
/// what the plug-in installed under its id draws ([`plugin_revision`]).
pub fn has_plugin_effect(n: &Node) -> bool {
let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some();
n.appearance.effects.iter().any(plugin) || n.appearance.items.iter().any(|i| i.effects().iter().any(plugin))
}
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
pub fn plugin_revision() -> u64 {
vectorcraft_plugins::registry::revision()
}
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GeomContext<'a> {
+3
View File
@@ -31,6 +31,9 @@ vello_cpu = { workspace = true, features = ["multithreading"] }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
# Live-effect plug-ins the cache tests install, written in WebAssembly text.
vectorcraft-plugins = { workspace = true }
wat = { workspace = true }
# Decoders the export tests read their files back with.
image = { workspace = true, features = ["tga"] }
tiff = "0.11"
+34 -12
View File
@@ -25,7 +25,8 @@
//! A cached raster is made again when something its content reads from outside the object
//! changes ([`Inputs`]): the pattern definitions, symbols, images and brushes it uses, the
//! document's raster resolution, color mode and substitution highlights, the knockout its object
//! inherits, the color settings and fonts, and the options and layer settings it is drawn with.
//! inherits, the color settings and fonts, the plug-ins its plug-in effects run, and the options
//! and layer settings it is drawn with.
//!
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
@@ -567,6 +568,9 @@ pub(crate) struct Reads {
/// Type: the fonts, whether overprinting shows ([`crate::proof::overprints`]) and Document
/// Setup's substitution highlights.
text: bool,
/// Plug-in effects on its objects: the plug-ins installed. Those in the symbol and pattern art
/// it shows are found with the document's inputs ([`DocInputs`]).
plugins: bool,
}
impl Reads {
@@ -581,7 +585,7 @@ impl Reads {
let mut reads = Self::default();
nodes.for_each(|n| reads.add(n));
if reads.symbols || reads.patterns {
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true };
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true, plugins: reads.plugins };
}
reads
}
@@ -591,9 +595,16 @@ impl Reads {
self != Self::default()
}
/// Can installing or removing a plug-in change what the content draws? It can when an object in
/// it has a plug-in effect, and when it shows symbol or pattern art.
pub(crate) fn plugins_can_change(self) -> bool {
self.plugins || self.symbols || self.patterns
}
fn add(&mut self, n: &Node) {
let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. });
n.walk(&mut |c| {
self.plugins |= effects::has_plugin_effect(c);
for item in &c.appearance.items {
self.patterns |= pattern(item.paint());
self.brushes |= matches!(item, AppearanceItem::Stroke(s) if s.brush.is_some());
@@ -649,16 +660,19 @@ impl Inputs {
/// What raster contents read from the document by name or key, and the color settings, as a frame
/// finds them ([`Renderer::doc_inputs`]): hashes of the patterns' tilings
/// ([`crate::pattern::hash_tiling`]), of the symbols' names and art and of the images' keys and
/// pixels, and the address of the color settings. Art, pixels and previews are hashed by the
/// addresses of their shared values, and names, keys, tiles, tile types and overlaps are hashed by
/// value. The live cache keeps these inputs with each evaluated live object and with each mesh's
/// quads (`crate::live`), and [`Renderer::fx_art`] keeps them with art that can be made from
/// evaluated live objects.
/// pixels, the address of the color settings, and whether the patterns' and symbols' art has a
/// plug-in effect. Art, pixels and previews are hashed by the addresses of their shared values,
/// and names, keys, tiles, tile types and overlaps are hashed by value. The live cache keeps these
/// inputs with each evaluated live object and with each mesh's quads (`crate::live`), and
/// [`Renderer::fx_art`] keeps them with art that can be made from evaluated live objects.
pub(crate) struct DocInputs {
patterns: u64,
symbols: u64,
images: u64,
pub(crate) cms: usize,
/// Whether an object in a pattern's or a symbol's art, or in an opacity mask there, has a
/// visible plug-in effect.
plugins: bool,
/// Weak references to the values whose addresses the hashes hold: a weak reference keeps the
/// allocation, not the value, so no other value gets one of those addresses while these
/// inputs are kept.
@@ -680,7 +694,7 @@ fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize {
/// appearance item's own result on it (filled in as items are drawn). The node is kept alive, so
/// its address names it alone and a hit is exact.
pub(crate) struct FxGeom {
node: Arc<Node>,
pub(crate) node: Arc<Node>,
g: Arc<BezPath>,
items: Vec<Option<Arc<BezPath>>>,
pub(crate) stamp: u64,
@@ -865,7 +879,8 @@ impl Renderer {
/// `draw_shape` for nodes with live effects.
pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
// The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path,
// made from the node alone, so the effects give the same result every frame.
// made from the node alone, so the effects give the same result every frame while the
// plug-ins installed stay the same (`check_plugins`).
let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned();
let mut cached = owner.map(|o| {
let key = Arc::as_ptr(&o) as usize;
@@ -1331,7 +1346,9 @@ impl Renderer {
/// isolated group, isolation mode; the pattern definitions, symbols and brush library it
/// reads; for images and placed documents their pixels and files, the layer's Dim Images and
/// the options they are drawn with; for type the fonts, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights.
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
/// plug-ins installed, when an object in it has a plug-in effect, and when it shows symbol or
/// pattern art and an object in the document's symbol or pattern art has one.
pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs {
let doc = self.doc_inputs(f.doc);
let mut h = DefaultHasher::new();
@@ -1360,6 +1377,9 @@ impl Renderer {
let highlights = f.opts.highlight_substitutions.then_some((setup.highlight_substituted_fonts, setup.highlight_substituted_glyphs));
(vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
}
if reads.plugins || (doc.plugins && (reads.symbols || reads.patterns)) {
effects::plugin_revision().hash(&mut h);
}
Inputs { reads, key: h.finish(), _doc: doc }
}
@@ -1390,9 +1410,11 @@ impl Renderer {
let d = match &self.doc_inputs {
Some((_, d)) if (d.patterns, d.symbols, d.images, d.cms) == found => d.clone(),
_ => {
let art = doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art)).map(Arc::downgrade).collect();
let doc_art = || doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art));
let art = doc_art().map(Arc::downgrade).collect();
let pixels = doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy)).map(Arc::downgrade).collect();
Arc::new(DocInputs { patterns, symbols, images, cms: found.3, _held: (art, pixels, Arc::downgrade(&cms)) })
let plugins = doc_art().any(|a| crate::holds_plugin_effect(a));
Arc::new(DocInputs { patterns, symbols, images, cms: found.3, plugins, _held: (art, pixels, Arc::downgrade(&cms)) })
}
};
self.doc_inputs = Some((at, d.clone()));
+35 -1
View File
@@ -421,6 +421,9 @@ pub struct Renderer {
/// 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>)>,
/// The plug-in revision ([`effects::plugin_revision`]) that `geom`, `fx_geom` and `fx_arts` were
/// last checked against (see [`Self::check_plugins`]).
plugin_revision: u64,
/// 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>,
@@ -577,6 +580,7 @@ impl Renderer {
threads: default_threads(),
geom: PtrMap::default(),
symbols: vec![],
plugin_revision: 0,
strokes: PtrMap::default(),
cur: None,
made_again: false,
@@ -633,6 +637,7 @@ impl Renderer {
return Rendered { width: w, height: h, pixels };
}
}
self.check_plugins();
// 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, &mut self.proof_art);
@@ -823,6 +828,7 @@ 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_plugins();
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));
@@ -861,6 +867,7 @@ impl Renderer {
background: Option<[u8; 4]>,
opts: &RenderOptions,
) -> Option<Rendered> {
self.check_plugins();
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);
@@ -922,6 +929,24 @@ impl Renderer {
}
}
/// When a plug-in has been installed or removed since the last check
/// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect
/// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one,
/// of symbol instances and of the objects that hold them, and the art drawn for the effects of
/// objects with a plug-in effect. Rasters of raster effects and evaluations of live objects hold
/// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`,
/// `live::resources_key`).
fn check_plugins(&mut self) {
let revision = effects::plugin_revision();
if revision == self.plugin_revision {
return;
}
self.plugin_revision = revision;
self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node));
self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node));
self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node));
}
/// 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)>> {
@@ -1086,7 +1111,8 @@ impl Renderer {
if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) {
return self.draw_live(ctx, f, a);
}
// Its live-effect geometry depends on the node alone, so it can be kept across frames.
// Its live-effect geometry depends on the node alone, and for plug-in effects on the
// plug-ins installed (`check_plugins`), so it can be kept across frames.
let outer = self.fx_owner.replace(a.clone());
self.draw_node(ctx, f, a, true);
self.fx_owner = outer;
@@ -2117,6 +2143,14 @@ fn holds_instance(n: &Node) -> bool {
holds
}
/// Does `n` hold an object with a visible plug-in effect ([`effects::has_plugin_effect`]), in it or
/// in the opacity masks of the objects in it?
fn holds_plugin_effect(n: &Node) -> bool {
let mut holds = false;
n.walk(&mut |c| holds |= effects::has_plugin_effect(c) || c.extras().mask.as_ref().is_some_and(|m| holds_plugin_effect(&m.art)));
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] {
+16 -8
View File
@@ -3,7 +3,8 @@
//! Blends and envelopes are evaluated by `vectorcraft_doc::live` (through
//! `vectorcraft_effects::expand_live`, as the exporters do) and the resulting objects cached by
//! `Arc` identity of the live node and by the inputs from outside it that [`resources_key`] hashes:
//! the symbols, patterns and placed files it shows, the fonts for type, and the color settings (so the steps keep
//! the symbols, patterns and placed files it shows, the fonts for type, the plug-ins for plug-in
//! effects, and the color settings (so the steps keep
//! stable `Arc`s and hit the geometry cache frame after frame, and editing a symbol redraws an
//! envelope around an instance of it). Gradient meshes are tessellated into many small solid-colour quads (vello
//! has no mesh shading), with the density chosen from the on-screen patch size; each quad is
@@ -69,12 +70,13 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
/// What the evaluation of `n` (a live node, or an object whose effect art can be made from
/// evaluated live objects, see [`Renderer::live_found`]) reads from the document, placed files,
/// the fonts and the color settings, hashed: the art of the symbols, the tilings of the patterns
/// and the reads of the placed files it shows ([`hash_resources`]), the font generation
/// ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, and the color
/// settings (`cms`, their address). Art is hashed by the addresses of its `Arc`s, which copies of
/// the document share: a copy of the document has the same key. It also returns weak references
/// to the reads found.
/// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of
/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font
/// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the
/// plug-in revision ([`crate::effects::plugin_revision`]) when it shows an object with a plug-in
/// effect, and the color settings (`cms`, their address). Art is hashed by the addresses of its
/// `Arc`s, which copies of the document share: a copy of the document has the same key. It also
/// returns weak references to the reads found.
fn resources_key(doc: &Document, cms: usize, n: &Node) -> (u64, Vec<Weak<ReadDocument>>) {
let mut h = DefaultHasher::new();
let mut shown = Shown::default();
@@ -82,6 +84,9 @@ fn resources_key(doc: &Document, cms: usize, n: &Node) -> (u64, Vec<Weak<ReadDoc
if shown.text {
vectorcraft_text::FontDb::global().generation().hash(&mut h);
}
if shown.plugins {
crate::effects::plugin_revision().hash(&mut h);
}
cms.hash(&mut h);
(h.finish(), shown.reads)
}
@@ -93,6 +98,8 @@ struct Shown {
seen: Vec<usize>,
/// Whether type or a placed file was found.
text: bool,
/// Whether an object with a visible plug-in effect was found.
plugins: bool,
/// Weak references to the reads of the placed files found.
reads: Vec<Weak<ReadDocument>>,
}
@@ -103,12 +110,13 @@ struct Shown {
/// the read of each placed file that [`vectorcraft_doc::PlacedDocument::art`] shows (none before
/// the file is read). It walks `n` and then, one at a time from a list, the opacity-mask, pattern
/// and symbol art it finds, each pattern and each symbol's art once ([`first_seen`]). `shown`
/// records the type, placed files and reads found.
/// records the type, placed files, reads and plug-in effects found.
fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut DefaultHasher) {
let mut todo = vec![];
let mut next = Some(n);
while let Some(art) = next {
art.walk(&mut |c| {
shown.plugins |= crate::effects::has_plugin_effect(c);
let runs = match &c.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
+12
View File
@@ -141,6 +141,18 @@ impl<K: Hash + Eq + Clone, V, S: BuildHasher + Default> FrameCache<K, V, S> {
}
}
/// Drop the entries whose values `keep` returns false for.
pub(crate) fn retain(&mut self, mut keep: impl FnMut(&V) -> bool) {
let bytes = &mut self.bytes;
self.map.retain(|_, s| {
let kept = keep(&s.value);
if !kept {
*bytes -= s.bytes;
}
kept
});
}
/// A new stamp for caches without frames: each request its own, so every other entry may make
/// room for it (least recently used first).
pub(crate) fn tick(&mut self) -> u64 {
+24 -2
View File
@@ -9,7 +9,8 @@
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and,
//! when its art shows more than itself (a symbol, another pattern, an image, type, a brush), while
//! what the document holds of those is what it held then ([`Deps`]), and while the color settings
//! are the ones it was drawn through.
//! are the ones it was drawn through. A tile whose art has a plug-in effect, or shows symbols or
//! patterns, is also dropped when a plug-in is installed or removed.
//!
//! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
@@ -95,11 +96,30 @@ struct PatternCache {
tiles: [FrameCache<TileKey, TileEntry>; 4],
renderer: Option<Box<Renderer>>,
depth: u32,
/// The plug-in revision ([`crate::effects::plugin_revision`]) the tiles were last checked
/// against (see [`PatternCache::check_plugins`]).
plugin_revision: u64,
}
impl Default for PatternCache {
fn default() -> Self {
Self { tiles: std::array::from_fn(|_| FrameCache::new(lru::PATTERN_BUDGET, usize::MAX)), renderer: None, depth: 0 }
Self { tiles: std::array::from_fn(|_| FrameCache::new(lru::PATTERN_BUDGET, usize::MAX)), renderer: None, depth: 0, plugin_revision: 0 }
}
}
impl PatternCache {
/// When a plug-in has been installed or removed since the last check, drop the tiles whose art
/// can draw differently with it: art with a plug-in effect, and art that shows symbols or
/// patterns ([`crate::fx::Reads::plugins_can_change`]).
fn check_plugins(&mut self) {
let revision = crate::effects::plugin_revision();
if revision == self.plugin_revision {
return;
}
self.plugin_revision = revision;
for tiles in &mut self.tiles {
tiles.retain(|e| !e.art.iter().any(|a| crate::fx::Reads::of(a).plugins_can_change()));
}
}
}
@@ -141,6 +161,7 @@ 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_plugins();
r.check_symbols(doc);
let symbols = doc.symbol_boxes().quick();
r.stamp += 1;
@@ -212,6 +233,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
let cms = vectorcraft_color::cms::active();
let hit = CACHE.with(|c| {
let mut c = c.borrow_mut();
c.check_plugins();
let tiles = &mut c.tiles[ink as usize];
let t = tiles.tick();
tiles
+222
View File
@@ -878,6 +878,228 @@ fn live_objects_draw_symbol_and_pattern_art_edits() {
edit_draws_as_new(with_art(d, vec![envelope]), red_bar);
}
/// Installs live-effect plug-in `id`, replacing one with the same id: its effect draws `shape` in
/// place of the geometry it is given.
fn install_shape(id: &str, shape: &vectorcraft_geom::PathData) {
use vectorcraft_plugins::wat as tpl;
let output = serde_json::json!({"objects": [{"path": shape}]}).to_string();
let module = wat::parse_str(tpl::returning(&tpl::manifest(id, "effect", "{}"), &output)).unwrap();
vectorcraft_plugins::registry::install_bytes(&module).unwrap();
}
/// The triangle with corners (`x`, `y`), (`x` + `size`, `y`) and (`x` + `size` / 2, `y` + `size` / 2).
fn triangle(x: f64, y: f64, size: f64) -> vectorcraft_geom::PathData {
use vectorcraft_geom::{PathData, Point, SubPath};
let corners = [Point::new(x, y), Point::new(x + size, y), Point::new(x + size / 2.0, y + size / 2.0)];
PathData::single(SubPath::polyline(&corners, true))
}
/// Draws `d` with live-effect plug-in `id` drawing `before` (`None`: not installed), installs the
/// plug-in drawing `after` (`None`: removes it) and draws `d` again, as [`edit_draws_as_new`] does.
#[track_caller]
fn plugin_change_draws_as_new(d: Document, id: &str, before: Option<vectorcraft_geom::PathData>, after: Option<vectorcraft_geom::PathData>) {
match &before {
Some(shape) => install_shape(id, shape),
None => {
vectorcraft_plugins::registry::remove(id);
}
}
edit_draws_as_new(d, |_| match &after {
Some(shape) => install_shape(id, shape),
None => assert!(vectorcraft_plugins::registry::remove(id), "the plug-in was installed"),
});
}
/// What plug-in effects draw follows the plug-ins installed. In the next frame, a square with a
/// plug-in effect is drawn as the plug-in installed again with another shape draws it, and as it
/// is without the plug-in after the plug-in is removed; after the plug-in is installed again, so
/// are the square with a drop shadow, the square blurred, and an instance of a symbol whose art, a
/// group kept between frames, holds the square. A square left of the view, alone and in a group,
/// is drawn where a plug-in installed for the first time draws it, in the view, and so is an
/// instance left of the view whose symbol's art holds the square, when the plug-in installed again
/// draws a shape that reaches the view.
#[test]
fn objects_with_plugin_effects_follow_the_plugins_installed() {
let _fonts = fonts_steady();
use vectorcraft_geom::shapes::rectangle;
const ID: &str = "test.render.objects";
let (small, big) = (Some(triangle(30.0, 40.0, 30.0)), Some(triangle(30.0, 40.0, 120.0)));
// A black square of `d` from (`x`, `y`), 40 × 40, with the plug-in effect and `more` effects.
let square = |d: &mut Document, x: f64, y: f64, more: Vec<vectorcraft_doc::Effect>| {
let effects = std::iter::once(effect(&format!("plugin.{ID}"), serde_json::json!({}))).chain(more).collect();
filled(d, rectangle(Rect::new(x, y, x + 40.0, y + 40.0)), Paint::solid(Color::BLACK), effects)
};
let alone = |x: f64, more: Vec<vectorcraft_doc::Effect>| {
let mut d = Document::new(200.0, 100.0);
let n = square(&mut d, x, 30.0, more);
with_art(d, vec![n])
};
plugin_change_draws_as_new(alone(20.0, vec![]), ID, small.clone(), big.clone());
plugin_change_draws_as_new(alone(20.0, vec![]), ID, small.clone(), None);
plugin_change_draws_as_new(alone(20.0, vec![drop_shadow()]), ID, small.clone(), big.clone());
let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0}));
plugin_change_draws_as_new(alone(20.0, vec![blur]), ID, small.clone(), big.clone());
// An instance of symbol `Shaped`, a group of the square around the origin.
let mut d = Document::new(200.0, 100.0);
let art = square(&mut d, -20.0, -20.0, vec![]);
let art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Shaped".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: Affine::translate((100.0, 50.0)) });
plugin_change_draws_as_new(with_art(d, vec![instance]), ID, small.clone(), big);
// Left of the view, the square is culled until the plug-in draws it in the view.
plugin_change_draws_as_new(alone(-100.0, vec![]), ID, None, small.clone());
let mut d = Document::new(200.0, 100.0);
let n = square(&mut d, -100.0, 30.0, vec![]);
let group = Node::group(d.alloc_id(), vec![Arc::new(n)]);
plugin_change_draws_as_new(with_art(d, vec![group]), ID, None, small);
// The instance's box lies from (-50, 30) to (-10, 70). Its cull bounds grow by how far the
// effects in the art paint past the art, which the plug-in's first shape does not.
let mut d = Document::new(200.0, 100.0);
let art = square(&mut d, -20.0, -20.0, vec![]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Shaped".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: Affine::translate((-30.0, 50.0)) });
plugin_change_draws_as_new(with_art(d, vec![instance]), ID, Some(triangle(-15.0, -15.0, 10.0)), Some(triangle(0.0, 0.0, 150.0)));
}
/// Installing a plug-in between two frames leaves the path a renderer keeps for a plain square and
/// the art it keeps for a group with a Transform effect: the second frame draws them from what was
/// kept.
#[test]
fn installing_a_plugin_keeps_what_is_kept_for_other_objects() {
let _fonts = fonts_steady();
use vectorcraft_geom::shapes::rectangle;
let mut d = Document::new(200.0, 100.0);
let square = filled(&mut d, rectangle(Rect::new(20.0, 30.0, 60.0, 70.0)), Paint::solid(Color::BLACK), vec![]);
let inner = filled(&mut d, rectangle(Rect::new(120.0, 30.0, 160.0, 70.0)), Paint::solid(Color::BLACK), vec![]);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
group.appearance.effects.push(effect("distort.transform", serde_json::json!({"moveH": 10.0})));
let d = with_art(d, vec![square, group]);
let [square, group] = [0, 1].map(|i| d.layers[0].children().unwrap()[i].clone());
let kept = |r: &mut Renderer| {
let path = r.geom.get(&(Arc::as_ptr(&square) as usize)).and_then(|e| e.path.clone()).unwrap();
let art = r.fx_arts.get(&(Arc::as_ptr(&group) as usize), r.stamp).map(|e| e.1.clone()).unwrap();
(path, art)
};
let opts = RenderOptions::default();
let mut r = Renderer::new();
r.render(&d, 200, 100, Affine::IDENTITY, &opts);
let before = kept(&mut r);
install_shape("test.render.other", &triangle(0.0, 0.0, 10.0));
r.render(&d, 200, 100, Affine::IDENTITY, &opts);
let after = kept(&mut r);
assert!(Arc::ptr_eq(&before.0, &after.0), "the square's path is kept");
assert!(Arc::ptr_eq(&before.1, &after.1), "the group's art is kept");
}
/// The art drawn for an object's effects follows the plug-ins installed: in the next frame, a group
/// with a plug-in effect, the same group feathered (Feather clips to the art's outline), an
/// instance of symbol `S` ([`symbol_art`]) and type, each with a plug-in effect, are drawn as the
/// plug-in installed again with another shape draws them.
#[test]
fn effect_art_follows_the_plugins_installed() {
let _fonts = fonts_steady();
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_geom::shapes::rectangle;
const ID: &str = "test.render.art";
let plugin = || effect(&format!("plugin.{ID}"), serde_json::json!({}));
let (small, big) = (Some(triangle(30.0, 40.0, 30.0)), Some(triangle(30.0, 40.0, 120.0)));
// A group of a black square, with the plug-in effect and `more` effects.
let group = |more: Vec<vectorcraft_doc::Effect>| {
let mut d = Document::new(200.0, 100.0);
let square = filled(&mut d, rectangle(Rect::new(20.0, 30.0, 60.0, 70.0)), Paint::solid(Color::BLACK), vec![]);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(square)]);
group.appearance.effects = std::iter::once(plugin()).chain(more).collect();
with_art(d, vec![group])
};
plugin_change_draws_as_new(group(vec![]), ID, small.clone(), big.clone());
let feather = effect("stylize.feather", serde_json::json!({"radius": 2.0}));
plugin_change_draws_as_new(group(vec![feather]), ID, small.clone(), big.clone());
let mut d = Document::new(200.0, 100.0);
let art = symbol_art(&mut d);
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) });
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((100.0, 50.0)) });
instance.appearance.effects.push(plugin());
plugin_change_draws_as_new(with_art(d, vec![instance]), ID, small.clone(), big.clone());
let mut d = Document::new(200.0, 100.0);
let style = CharStyle { size: 30.0, ..Default::default() };
let mut text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(10.0, 60.0), "Hamburg", style))));
text.appearance.effects.push(plugin());
plugin_change_draws_as_new(with_art(d, vec![text]), ID, small, big);
}
/// Live objects follow the plug-ins installed: in the next frame, an envelope that distorts the
/// appearance of a band with a plug-in effect, the same envelope without Distort Appearance, an
/// envelope that distorts the appearance of an instance of a symbol whose art has a plug-in effect,
/// and a blend of two squares with a plug-in effect are drawn as the plug-in installed again with
/// another shape draws them.
#[test]
fn live_objects_follow_the_plugins_installed() {
let _fonts = fonts_steady();
use vectorcraft_geom::shapes::rectangle;
const ID: &str = "test.render.live";
let plugin = || effect(&format!("plugin.{ID}"), serde_json::json!({}));
let (small, big) = (Some(triangle(30.0, 40.0, 30.0)), Some(triangle(30.0, 40.0, 120.0)));
let black = |d: &mut Document, r: Rect| filled(d, rectangle(r), Paint::solid(Color::BLACK), vec![plugin()]);
let mut d = Document::new(200.0, 100.0);
let band = black(&mut d, Rect::new(20.0, 30.0, 180.0, 70.0));
let envelope = arc_envelope(&mut d, vec![band], true);
plugin_change_draws_as_new(with_art(d, vec![envelope]), ID, small.clone(), big.clone());
let mut d = Document::new(200.0, 100.0);
let band = black(&mut d, Rect::new(20.0, 30.0, 180.0, 70.0));
let envelope = arc_envelope(&mut d, vec![band], false);
plugin_change_draws_as_new(with_art(d, vec![envelope]), ID, small.clone(), big.clone());
// Symbol `Shaped` is a square with the plug-in effect.
let mut d = Document::new(200.0, 100.0);
let art = black(&mut d, Rect::new(-20.0, -20.0, 20.0, 20.0));
d.symbols.push(vectorcraft_doc::Symbol { name: "Shaped".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: Affine::translate((100.0, 50.0)) });
let envelope = arc_envelope(&mut d, vec![instance], true);
plugin_change_draws_as_new(with_art(d, vec![envelope]), ID, small.clone(), big.clone());
let mut d = Document::new(200.0, 100.0);
let keys = [Rect::new(20.0, 30.0, 40.0, 50.0), Rect::new(140.0, 30.0, 160.0, 50.0)].map(|r| Arc::new(black(&mut d, r)));
let spec = vectorcraft_doc::live::BlendSpec { spacing: vectorcraft_doc::live::BlendSpacing::Steps(3), ..Default::default() };
let blend = Node::new(d.alloc_id(), NodeKind::Blend { children: keys.to_vec(), spec: Box::new(spec) });
plugin_change_draws_as_new(with_art(d, vec![blend]), ID, small, big);
}
/// Pattern tiles and the rasters of raster effects follow the plug-ins installed when the art of a
/// pattern or of a symbol has a plug-in effect: in the next frame, a band filled with such a
/// pattern, the band with a drop shadow, and a group with a drop shadow around an instance of such
/// a symbol are drawn as the plug-in installed again with another shape draws them.
#[test]
fn pattern_and_symbol_art_with_plugin_effects_follow_the_plugins_installed() {
let _fonts = fonts_steady();
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
use vectorcraft_geom::shapes::rectangle;
const ID: &str = "test.render.tiles";
let plugin = || effect(&format!("plugin.{ID}"), serde_json::json!({}));
// Pattern `Shaped` is a 10 × 10 square with the plug-in effect in a 20 × 20 tile, inside which
// the plug-in draws; a band of `d` is filled with it.
let band = |d: &mut Document, effects: Vec<vectorcraft_doc::Effect>| {
let square = filled(d, rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::BLACK), vec![plugin()]);
let mut def = PatternDef::new("Shaped", vec![Arc::new(square)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
d.patterns.push(def);
filled(d, rectangle(Rect::new(20.0, 20.0, 180.0, 80.0)), pattern_paint("Shaped"), effects)
};
let (small, big) = (Some(triangle(0.0, 0.0, 10.0)), Some(triangle(0.0, 0.0, 20.0)));
let mut d = Document::new(200.0, 100.0);
let n = band(&mut d, vec![]);
plugin_change_draws_as_new(with_art(d, vec![n]), ID, small.clone(), big.clone());
let mut d = Document::new(200.0, 100.0);
let n = band(&mut d, vec![drop_shadow()]);
plugin_change_draws_as_new(with_art(d, vec![n]), ID, small, big);
// Symbol `Shaped` is a square around the origin with the plug-in effect, which draws inside
// the instance's box, from (80, 30) to (120, 70).
let mut d = Document::new(200.0, 100.0);
let art = filled(&mut d, rectangle(Rect::new(-20.0, -20.0, 20.0, 20.0)), Paint::solid(Color::BLACK), vec![plugin()]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Shaped".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: Affine::translate((100.0, 50.0)) });
let mut group = Node::group(d.alloc_id(), vec![Arc::new(instance)]);
group.appearance.effects.push(drop_shadow());
plugin_change_draws_as_new(with_art(d, vec![group]), ID, Some(triangle(82.0, 32.0, 10.0)), Some(triangle(82.0, 32.0, 30.0)));
}
fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect { id: id.into(), params, visible: true }
}
+1 -2
View File
@@ -258,8 +258,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
rendering, the GPU backend spike (M5). Large-selection edits, cached live effects and Pathfinder Trim, Merge,
Crop and Outline landed in #1173.
- **Not redrawn:** the canvas keeps drawing these objects as they were:
in the web build, type with a geometry effect keeps a substitute font's outlines after its own font has loaded;
an object with a plug-in effect keeps the shape it was drawn with after the plug-in is installed, installed again or removed.
in the web build, type with a geometry effect keeps a substitute font's outlines after its own font has loaded.
- **Estimate:** 15–25 h (the budget run and fixes block beta).
## G16 Localization: the three missing key languages
+2
View File
@@ -614,6 +614,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Live objects after document changes:** a blend, envelope or repeat, with or without an effect of its own, is evaluated again when one of these changes: the tiling of a pattern whose tiles an envelope bends (Distort Pattern Fills), a symbol shown inside a symbol, a pattern or an opacity mask, the fonts of its type, a placed document's file once the file is read, and the color settings. A gradient mesh, with or without an effect of its own, is colored again when the color settings change.
- **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.
- **Full font names (#1258):** OpenType name ID 4 identifies its face alongside family/legacy/PostScript names, including localized names, for loaded fonts, cataloged files and Missing Fonts searches. Synthetic name-table regressions cover full names distinct from their PostScript name, real-family precedence and variable fonts' default faces. Windows font-service activation remains unverified; scores and hours are unchanged.
### Corpus integrity evidence