From c6837ca3ae4bafcd6baf1bc0fa6d2acc3271d5b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 18:32:48 -0700 Subject: [PATCH] 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. --- Cargo.lock | 2 + crates/effects/src/lib.rs | 12 ++ crates/render/Cargo.toml | 3 + crates/render/src/fx.rs | 46 +++++-- crates/render/src/lib.rs | 36 ++++- crates/render/src/live.rs | 24 ++-- crates/render/src/lru.rs | 12 ++ crates/render/src/pattern.rs | 26 +++- crates/render/src/tests_memory.rs | 222 ++++++++++++++++++++++++++++++ docs/gaps.md | 3 +- docs/target-app-parity.md | 2 + 11 files changed, 363 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31a1eb2a4..f85d88263 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 45b8df31a..03b377144 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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> { diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 44c14f571..5039995f1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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" diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index cf5df89e8..c0e80b04e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -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, + pub(crate) node: Arc, g: Arc, items: Vec>>, 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())); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a1c4eed74..4c3141569 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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)>, + /// 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, w: u16, h: u16, view: Affine) -> Option { + 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 { + 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) -> Option> { @@ -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] { diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index cf05cf515..c9cad92b3 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -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 { /// 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>) { 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, /// 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>, } @@ -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(), _ => &[], diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index 2a2e62923..d9fec8752 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -141,6 +141,18 @@ impl FrameCache { } } + /// 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 { diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index c21103be0..7008e1ecb 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -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; 4], renderer: Option>, 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 diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index 075443579..e730a137a 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -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, after: Option) { + 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| { + 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| { + 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| { + 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| { + 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 } } diff --git a/docs/gaps.md b/docs/gaps.md index 58eff56c7..2555e427e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index de97a6210..feedb7ec7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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