From 6504dc7da2d23fb085bfd258d5525192350bba65 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 12 Oct 2026 14:15:54 -0700 Subject: [PATCH 1/2] Render: live objects are evaluated again when patterns, symbols, fonts or color settings change The renderer kept the evaluation of each blend, envelope and repeat while the object and a hash stayed the same: the addresses of the art of the symbols the object showed and of the patterns it painted with, and the patterns' tile bounds. When anything else the evaluation reads changed, such as a pattern's tile type, symbols in symbol art, the fonts or the color settings, a renderer that had drawn the object went on drawing the old evaluation. The quads of a gradient mesh were kept the same way after a change to the color settings, and the art drawn for a live object with an effect of its own was kept by the object's address alone. resources_key now also walks the opacity-mask, symbol and pattern art it finds. It hashes what it finds, the font generation when it finds type or a placed document, and the color settings. Mesh quads are made again when the color settings change. The art drawn for an object's effects is made again when the hash changes, if it was made from evaluated live objects. With other builds running, a temporary release test timed the hash for 400 live objects at 17 to 506 microseconds a frame, against 10 to 16 before. Docs: docs/target-app-parity.md, docs/gaps.md. --- crates/engine/tests/color_settings.rs | 67 +++++- crates/render/src/fx.rs | 38 +++- crates/render/src/lib.rs | 6 +- crates/render/src/live.rs | 226 ++++++++++++++++++--- crates/render/src/tests_fontchange.rs | 91 +++++++++ crates/render/src/tests_memory.rs | 116 +++++++++++ crates/render/src/tests_placed_document.rs | 47 +++++ docs/gaps.md | 3 + docs/target-app-parity.md | 2 + 9 files changed, 555 insertions(+), 41 deletions(-) diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index ca8bed74..efc4bd5b 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -11,7 +11,7 @@ use vectorcraft_engine::Session; use vectorcraft_engine::cmd::colormgmt::{PROFILES_KEY, doc_profiles}; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; -use vectorcraft_testkit::fixtures::{exec, rect, session}; +use vectorcraft_testkit::fixtures::{exec, id_of, rect, select, session}; /// The active Color Settings: one test at a time. fn settings() -> MutexGuard<'static, ()> { @@ -84,3 +84,68 @@ fn raster_effects_draw_through_new_color_settings() { assert!(frame(&mut r, &s, &opts) == fresh, "after it"); cms::set_active(&old).unwrap(); } + +/// Live objects follow the Color Settings: in the next frame, a blend of an RGB red and a CMYK cyan, +/// the same blend in an envelope, an envelope that distorts the appearance of a CMYK cyan rectangle +/// with Revolve, and a gradient mesh in CMYK colors draw through the new working CMYK profile, and +/// so do the blend and the mesh with a drop shadow. +#[test] +fn live_objects_draw_through_new_color_settings() { + let _g = settings(); + let old = cms::active_settings(); + let cyan = json!({"c": 100, "m": 0, "y": 0, "k": 0}); + let blend = |s: &mut Session| { + let a = rect(s, 20.0, 40.0, 60.0, 120.0); + exec(s, "paint.setFill", json!({"ids": [a.0], "color": "#ff0000"})); + let b = rect(s, 300.0, 40.0, 60.0, 120.0); + exec(s, "paint.setFill", json!({"ids": [b.0], "color": cyan})); + id_of(&exec(s, "object.blend.make", json!({"ids": [a.0, b.0], "steps": 5}))) + }; + let mut blended = session(); + blend(&mut blended); + let mut enveloped = session(); + let b = blend(&mut enveloped); + exec(&mut enveloped, "object.envelope.makeWithWarp", json!({"ids": [b.0], "style": "arc", "bend": 30})); + let mut revolved = session(); + let profile = rect(&mut revolved, 150.0, 60.0, 60.0, 120.0); + exec(&mut revolved, "paint.setFill", json!({"ids": [profile.0], "color": cyan})); + exec(&mut revolved, "effect.apply", json!({"ids": [profile.0], "effect": "threeD.revolve"})); + let e = id_of(&exec(&mut revolved, "object.envelope.makeWithWarp", json!({"ids": [profile.0], "style": "arc", "bend": 30}))); + select(&mut revolved, &[e]); + exec(&mut revolved, "object.envelope.options", json!({"distortAppearance": true})); + let mesh = |s: &mut Session| { + let m = rect(s, 20.0, 20.0, 360.0, 260.0); + exec(s, "paint.setFill", json!({"ids": [m.0], "color": cyan})); + exec(s, "object.mesh.create", json!({"ids": [m.0], "rows": 2, "cols": 2})); + exec(s, "object.mesh.setPointColor", json!({"id": m.0, "index": 4, "color": {"c": 0, "m": 100, "y": 0, "k": 0}})); + m + }; + let mut meshed = session(); + mesh(&mut meshed); + let shadow = json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}); + let mut shadowed_blend = session(); + let b = blend(&mut shadowed_blend); + exec(&mut shadowed_blend, "effect.apply", json!({"ids": [b.0], "effect": "stylize.dropShadow", "params": shadow})); + let mut shadowed_mesh = session(); + let m = mesh(&mut shadowed_mesh); + exec(&mut shadowed_mesh, "effect.apply", json!({"ids": [m.0], "effect": "stylize.dropShadow", "params": shadow})); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &opts).pixels; + for (what, mut s) in [ + ("the blend", blended), + ("the envelope", enveloped), + ("the revolved rectangle", revolved), + ("the mesh", meshed), + ("the blend with a drop shadow", shadowed_blend), + ("the mesh with a drop shadow", shadowed_mesh), + ] { + let mut r = Renderer::new(); + let before = frame(&mut r, &s); + s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap(); + let fresh = frame(&mut Renderer::new(), &s); + let warm = frame(&mut r, &s); + cms::set_active(&old).unwrap(); + assert!(fresh != before, "{what}: the new profile changes the frame"); + assert!(warm == fresh, "{what}: drawn as a new renderer draws it"); + } +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 2b7111b4..37470321 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -82,6 +82,16 @@ fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) } +/// Can the art [`Renderer::fx_art`] makes for `n` be made from live objects it evaluates? It can +/// when `n` is a live object, and when `n` is a group or layer without a Pathfinder effect that +/// holds live objects and whose geometry effects reshape them or whose own fills and strokes paint +/// their evaluated outlines ([`effects::evaluate_container`]). +fn evaluates_live(n: &Node) -> bool { + use vectorcraft_doc::live::{is_live, subtree_has_live}; + let to_members = effects::has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(effects::paints); + is_live(n) || (is_container(n) && to_members && !effects::has_pathfinder(n) && subtree_has_live(n)) +} + /// What [`Renderer::raster_fx`] paints effects around. struct Content<'a> { /// The object the effects belong to (cached shadows are checked against it). @@ -496,12 +506,14 @@ impl Inputs { /// ([`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. +/// 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, - cms: usize, + pub(crate) cms: usize, /// 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. @@ -780,14 +792,26 @@ impl Renderer { /// reshaped by its geometry effects, else type, images and paths as themselves and the others /// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their /// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]); - /// without the object's transparency and effects (a path keeps its geometry effects). + /// without the object's transparency and effects (a path keeps its geometry effects). Art that + /// is made from live objects it evaluated ([`evaluates_live`]) is kept only while the inputs of + /// their evaluation stay the same ([`Self::live_found`]). fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { let key = Arc::as_ptr(a) as usize; + let mut kept = None; if cache - && let Some((node, art)) = self.fx_arts.get(&key, self.stamp) + && let Some((node, art, found)) = self.fx_arts.get(&key, self.stamp) && Arc::ptr_eq(node, a) { - return art.clone(); + match found { + None => return art.clone(), + Some(was) => kept = Some((art.clone(), was.0)), + } + } + let found = (kept.is_some() || (cache && evaluates_live(a))).then(|| self.live_found(doc, a)); + if let Some((art, was)) = kept + && found.as_ref().is_some_and(|f| f.0 == was) + { + return art; } let symbol = match &a.kind { NodeKind::SymbolInstance { symbol, .. } => { @@ -821,7 +845,7 @@ impl Renderer { let art = Arc::new(art); if cache { let bytes = crate::lru::node_bytes(&art); - self.fx_arts.insert(key, (a.clone(), art.clone()), bytes, self.stamp); + self.fx_arts.insert(key, (a.clone(), art.clone(), found), bytes, self.stamp); } art } @@ -1149,7 +1173,7 @@ impl Renderer { /// [`DocInputs`] for document `doc`: found once a frame (and again for another document drawn /// in it, such as a placed document's own), and kept while they stay the same. - fn doc_inputs(&mut self, doc: &Document) -> Arc { + pub(crate) fn doc_inputs(&mut self, doc: &Document) -> Arc { let at = (self.stamp, std::ptr::from_ref(doc) as usize); if let Some((seen, d)) = &self.doc_inputs && *seen == at diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e63abafb..b898329f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -364,6 +364,10 @@ struct GeomEntry { type ClipEntry = (Arc, Option>); /// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). type ClipPaintEntry = (Arc, Option>, Option>); +/// An object with effects (kept alive), the art drawn for it ([`Renderer::fx_art`]), and, when that +/// art can be made from evaluated live objects (`fx::evaluates_live`), the inputs of their +/// evaluation ([`live::Found`]). +type FxArtEntry = (Arc, Arc, Option); /// What symbol art placed by [`Renderer::placed_art`] is kept under: the art's address, the bits /// of the transform's coefficients, whether strokes scale with it, and the stain ([`stain_bits`]). type PlacedKey = (usize, [u64; 6], bool, [u32; 6]); @@ -433,7 +437,7 @@ pub struct Renderer { /// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none. shape_of: usize, /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). - fx_arts: FrameCache, Arc)>, + fx_arts: FrameCache, /// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]). placed_arts: FrameCache, /// Layers open around the drawing point in the current context: a non-isolated group can copy diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index d317394e..2f551351 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -2,32 +2,42 @@ //! //! 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 of the symbols and patterns it draws (so the steps keep +//! `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 //! 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 //! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams -//! show. +//! show. The quads are made again when the color settings change. -use std::sync::Arc; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::{Arc, Weak}; use vectorcraft_doc::live::{GradientMesh, MeshQuad}; +use vectorcraft_doc::placed_document::ReadDocument; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::{BezPath, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko; +use crate::fx::DocInputs; use crate::lru::{self, FrameCache}; use crate::{Frame, Renderer}; type Expanded = Arc>>; type Quads = Arc>; +/// An evaluation's [`resources_key`], with the frame's [`DocInputs`] and weak references to the +/// reads of the placed files shown. While they are kept, no other value takes an address the key +/// hashed: the [`DocInputs`] hold the symbol and pattern art and the color settings weakly. +pub(crate) type Found = (u64, Arc, Vec>); +/// A mesh node, its quads, and the [`DocInputs`] whose color settings colored them. +type MeshEntry = (Arc, Quads, Arc); -/// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`resources_key`]), -/// and (mesh node, its quads) per node and subdivision level. +/// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`Found`]), and a +/// [`MeshEntry`] per mesh node and subdivision level. pub(crate) struct LiveCache { - expanded: FrameCache, Expanded, u64)>, - meshes: FrameCache<(usize, usize), (Arc, Quads)>, + expanded: FrameCache, Expanded, Found)>, + meshes: FrameCache<(usize, usize), MeshEntry>, } impl Default for LiveCache { @@ -57,27 +67,95 @@ pub fn expand_live(n: &Node) -> Vec { crate::effects::expand_live(None, n) } -/// What an evaluated live node depends on besides itself: the art of the symbols and the patterns -/// it draws (their `Arc`s outlive document copies, so this changes only when one is edited). -fn resources_key(doc: &Document, n: &Node) -> u64 { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - n.walk(&mut |c| { - if let NodeKind::SymbolInstance { symbol, .. } = &c.kind - && let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) - { - (Arc::as_ptr(&s.art) as usize).hash(&mut h); - } - for i in &c.appearance.items { - if let vectorcraft_color::Paint::Pattern { pattern, .. } = i.paint() - && let Some(def) = doc.pattern(pattern) - { - def.art.iter().for_each(|a| (Arc::as_ptr(a) as usize).hash(&mut h)); - [def.tile.x0, def.tile.y0, def.tile.x1, def.tile.y1].iter().for_each(|v| v.to_bits().hash(&mut h)); +/// 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. +fn resources_key(doc: &Document, cms: usize, n: &Node) -> (u64, Vec>) { + let mut h = DefaultHasher::new(); + let mut shown = Shown::default(); + hash_resources(doc, n, &mut shown, &mut h); + if shown.text { + vectorcraft_text::FontDb::global().generation().hash(&mut h); + } + cms.hash(&mut h); + (h.finish(), shown.reads) +} + +/// What [`hash_resources`] has found so far. +#[derive(Default)] +struct Shown { + /// The addresses of the pattern definitions and the symbol art walked. + seen: Vec, + /// Whether type or a placed file was found. + text: bool, + /// Weak references to the reads of the placed files found. + reads: Vec>, +} + +/// Hash into `h` what the evaluation of `n` reads from the document and from placed files: the +/// tilings ([`crate::pattern::hash_tiling`]) of the patterns in the fills and strokes of its +/// objects and of type's characters, the address of each symbol instance's art, and the address of +/// 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. +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| { + let runs = match &c.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + let paints = c.appearance.items.iter().map(|i| i.paint()).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])); + for paint in paints { + if let vectorcraft_color::Paint::Pattern { pattern, .. } = paint + && let Some(def) = doc.pattern(pattern) + && first_seen(&mut shown.seen, std::ptr::from_ref(def) as usize) + { + crate::pattern::hash_tiling(def, h); + todo.extend(def.art.iter().map(|a| &**a)); + } } - } - }); - h.finish() + match &c.kind { + NodeKind::SymbolInstance { symbol, .. } => { + if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { + (Arc::as_ptr(&s.art) as usize).hash(h); + if first_seen(&mut shown.seen, Arc::as_ptr(&s.art) as usize) { + todo.push(&s.art); + } + } + } + NodeKind::PlacedDocument(p) => { + let read = vectorcraft_doc::placed_document::read_cached(p); + read.as_ref().map(|r| Arc::as_ptr(r) as usize).hash(h); + shown.reads.extend(read.as_ref().map(Arc::downgrade)); + shown.text = true; + } + NodeKind::Text(_) => shown.text = true, + _ => {} + } + if let Some(m) = c.extras().mask.as_deref() { + todo.push(&m.art); + } + }); + next = todo.pop(); + } +} + +/// Is `address` not in `seen` yet? It is added when it isn't. +fn first_seen(seen: &mut Vec, address: usize) -> bool { + let first = !seen.contains(&address); + if first { + seen.push(address); + } + first } /// Subdivisions per patch for a patch about `size_px` device pixels across. @@ -87,13 +165,22 @@ fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize { } impl Renderer { + /// The [`resources_key`] of `n` in `doc`, with the frame's [`DocInputs`] ([`Found`]): `n` is a + /// live node, or an object with effects whose art ([`Renderer::fx_art`]) can be made from + /// evaluated live objects. + pub(crate) fn live_found(&mut self, doc: &Document, n: &Node) -> Found { + let inputs = self.doc_inputs(doc); + let (key, reads) = resources_key(doc, inputs.cms, n); + (key, inputs, reads) + } + fn live_expanded(&mut self, doc: &Document, a: &Arc, cache: bool) -> Expanded { let key = Arc::as_ptr(a) as usize; - let resources = resources_key(doc, a); + let resources = self.live_found(doc, a); if cache && let Some(e) = self.live.expanded.get(&key, self.stamp) && Arc::ptr_eq(&e.0, a) - && e.2 == resources + && e.2.0 == resources.0 { return e.1.clone(); } @@ -108,18 +195,22 @@ impl Renderer { v } - fn mesh_quads(&mut self, a: &Arc, m: &GradientMesh, level: usize, cache: bool) -> Arc> { + /// The quads of mesh `a` (`m`) at subdivision `level`, kept while the node and the color + /// settings that colored them ([`GradientMesh::color_at`]) stay the same. + fn mesh_quads(&mut self, doc: &Document, a: &Arc, m: &GradientMesh, level: usize, cache: bool) -> Arc> { let key = (Arc::as_ptr(a) as usize, level); + let inputs = self.doc_inputs(doc); if cache && let Some(e) = self.live.meshes.get(&key, self.stamp) && Arc::ptr_eq(&e.0, a) + && e.2.cms == inputs.cms { return e.1.clone(); } let q = Arc::new(m.quads(level)); if cache { let bytes = q.len() * size_of::(); - self.live.meshes.insert(key, (a.clone(), q.clone()), bytes, self.stamp); + self.live.meshes.insert(key, (a.clone(), q.clone(), inputs), bytes, self.stamp); } q } @@ -181,7 +272,7 @@ impl Renderer { } let Some(b) = m.bounds() else { return }; let level = mesh_level(m, b.width().max(b.height()) / f.px.max(1e-12)); - let quads = self.mesh_quads(a, m, level, cache); + let quads = self.mesh_quads(f.doc, a, m, level, cache); ctx.set_transform(f.view); ctx.set_fill_rule(peniko::Fill::NonZero); let grow = f.px * 0.5; @@ -218,3 +309,74 @@ impl Renderer { self.draw_live_body(ctx, f, &a, false); } } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::{Appearance, EnvelopeKind, Symbol}; + use vectorcraft_geom::{Affine, Rect, shapes}; + + /// The key of an envelope around the first of 5,000 symbols, each showing the next, is found on + /// a 256 KiB stack, and it changes when the last symbol's art changes. + #[test] + fn a_long_chain_of_symbols_is_walked_on_a_small_stack() { + const N: usize = 5_000; + let mut d = Document::new(100.0, 100.0); + let square = |d: &mut Document| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + for k in 0..N { + let art = match k + 1 < N { + true => Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("S{}", k + 1), xf: Affine::IDENTITY }), + false => square(&mut d), + }; + d.symbols.push(Symbol { name: format!("S{k}"), art: Arc::new(art) }); + } + let first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY }); + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let envelope = NodeKind::Envelope { + content: vec![Arc::new(first)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }; + let envelope = Node::new(d.alloc_id(), envelope); + let key = |d: &Document| { + std::thread::scope(|s| { + std::thread::Builder::new().stack_size(256 << 10).spawn_scoped(s, || resources_key(d, 0, &envelope).0).unwrap().join().unwrap() + }) + }; + let before = key(&d); + let last = square(&mut d); + d.symbols[N - 1].art = Arc::new(last); + assert_ne!(key(&d), before, "the last symbol's art is hashed"); + } + + /// The key of an envelope around instances of symbols `A`, `B` and `C`, where `A` and `C` show + /// the same art, changes when `C` is given the art of `B`. + #[test] + fn every_symbol_instance_hashes_its_art() { + let mut d = Document::new(100.0, 100.0); + let square = |d: &mut Document, x: f64| { + Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default())) + }; + let (x, y) = (square(&mut d, 0.0), square(&mut d, 20.0)); + for (name, art) in [("A", &x), ("B", &y), ("C", &x)] { + d.symbols.push(Symbol { name: name.into(), art: art.clone() }); + } + let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY }))); + let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let envelope = NodeKind::Envelope { + content: content.to_vec(), + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }; + let envelope = Node::new(d.alloc_id(), envelope); + let before = resources_key(&d, 0, &envelope).0; + d.symbols[2].art = y.clone(); + assert_ne!(resources_key(&d, 0, &envelope).0, before, "the art of every instance is hashed"); + } +} diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs index f824619d..1f77234e 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests_fontchange.rs @@ -105,3 +105,94 @@ fn symbol_type_redraws_when_its_font_becomes_available() { assert_ne!(before, after, "drawn in the font that arrived"); assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); } + +/// Type that an envelope bends redraws in a font that becomes available, in the envelope and in +/// the art of a symbol whose instance an envelope bends: the envelopes are evaluated again when the +/// fonts change. +#[test] +fn enveloped_type_redraws_when_its_font_becomes_available() { + const FAMILY: &str = "Warped Serif 4"; + let mut d = Document::new(300.0, 150.0); + let word = |d: &mut Document, at: Point| { + let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() }; + Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(at, "Hamburg", style)))) + }; + let typed = word(&mut d, Point::new(10.0, 60.0)); + let art = word(&mut d, Point::new(-140.0, 20.0)); + let art = Node::group(d.alloc_id(), vec![Arc::new(art)]); + d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) }); + let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) }); + let l = d.layers[0].id; + for (i, content) in [typed, shown].into_iter().enumerate() { + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let envelope = NodeKind::Envelope { + content: vec![Arc::new(content)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }; + let id = d.alloc_id(); + d.insert(Some(l), i, Node::new(id, envelope)).unwrap(); + } + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0); + let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert_ne!(before, after, "drawn in the font that arrived"); + assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); +} + +/// Type that an envelope bends redraws in a font that becomes available when the art drawn for +/// effects is made from the envelope's evaluation: the envelope with a drop shadow, and the +/// envelope in a group with a geometry effect or with a fill of its own. +#[test] +fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() { + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{AppearanceItem, Effect, FillLayer}; + let cases = [ + ("Shadow Serif 4", "the envelope with a drop shadow"), + ("Effect Serif 4", "the group with a geometry effect"), + ("Filled Serif 4", "the group with a fill"), + ]; + for (i, (family, what)) in cases.into_iter().enumerate() { + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 50.0, font_family: family.into(), ..Default::default() }; + let typed = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 80.0), "Hamburg", style)))); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let envelope = NodeKind::Envelope { + content: vec![Arc::new(typed)], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }; + let mut object = Node::new(d.alloc_id(), envelope); + match i { + 0 => { + let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}); + object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true }); + } + 1 => { + object = Node::group(d.alloc_id(), vec![Arc::new(object)]); + object.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true }); + } + _ => { + object = Node::group(d.alloc_id(), vec![Arc::new(object)]); + object.appearance.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))))); + } + } + let l = d.layers[0].id; + d.insert(Some(l), 0, object).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert!(vectorcraft_text::FontDb::global().add_font(serif_as(family)) > 0); + let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert_ne!(before, after, "{what}: drawn in the font that arrived"); + assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it"); + } +} diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index becbff92..a7e92c4e 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -610,6 +610,122 @@ fn art_with_live_objects_draws_pattern_layout_edits() { }); } +/// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort +/// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it. +fn arc_envelope(d: &mut Document, content: Vec, patterns: bool) -> Node { + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let options = vectorcraft_doc::live::EnvelopeOptions { distort_appearance: patterns, distort_pattern_fills: patterns, ..Default::default() }; + let content = content.into_iter().map(Arc::new).collect(); + Node::new( + d.alloc_id(), + NodeKind::Envelope { content, kind: Box::new(kind), fidelity: 50.0, editing: false, options, frame: Box::new(Affine::IDENTITY) }, + ) +} + +/// An envelope that bends the tiles of its pattern fills is evaluated again when a pattern it tiles +/// changes its tile type or its overlap, as Pattern Options sets them: a band filled with the +/// pattern, and type whose characters are filled with it. +#[test] +fn live_objects_draw_pattern_layout_edits() { + use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType, pattern_paint}; + use vectorcraft_doc::text::{CharStyle, TextObject}; + use vectorcraft_geom::shapes::rectangle; + let bricks = |d: &mut Document| { + if let Some(def) = d.pattern_mut("Checks") { + def.tile_type = TileType::BrickByRow { offset: 0.5 }; + } + }; + let mut d = Document::new(200.0, 100.0); + d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0))); + let band = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 180.0, 80.0)), pattern_paint("Checks"), vec![]); + let envelope = arc_envelope(&mut d, vec![band], true); + edit_draws_as_new(with_art(d, vec![envelope]), bricks); + // Pattern `Halves` is a 30 × 10 bar, red on the left and blue on the right, in a 20 × 20 tile: + // each tile's bar reaches into the next tile, and the bar drawn in front depends on the overlap. + let mut d = Document::new(200.0, 100.0); + let half = |x: f64, c: Color| { + let paint = vectorcraft_doc::Appearance::basic(Paint::solid(c), Paint::None, 0.0); + Arc::new(Node::path(NodeId(900), rectangle(Rect::new(x, 0.0, x + 15.0, 10.0)), paint)) + }; + let mut halves = PatternDef::new("Halves", vec![half(0.0, Color::rgb(1.0, 0.0, 0.0)), half(15.0, Color::rgb(0.0, 0.0, 1.0))]); + halves.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(halves); + let band = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 180.0, 80.0)), pattern_paint("Halves"), vec![]); + let envelope = arc_envelope(&mut d, vec![band], true); + edit_draws_as_new(with_art(d, vec![envelope]), |d| { + if let Some(def) = d.pattern_mut("Halves") { + def.overlap = Overlap { right_in_front: true, bottom_in_front: true }; + } + }); + // Type whose characters are filled with `Checks`. + let mut d = Document::new(200.0, 100.0); + d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0))); + let style = CharStyle { size: 90.0, fill: pattern_paint("Checks"), ..Default::default() }; + let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(10.0, 80.0), "HHH", style)))); + let envelope = arc_envelope(&mut d, vec![text], true); + edit_draws_as_new(with_art(d, vec![envelope]), bricks); +} + +/// An envelope is evaluated again when a symbol or a pattern changes that it shows through other +/// art: a symbol shown in the art of the symbol it bends, a symbol that is the opacity mask of the +/// object it bends, and, with the tiles of its pattern fills bending with it, a pattern filling +/// the art of the symbol it bends and a symbol in the art of a pattern it tiles. +#[test] +fn live_objects_draw_symbol_and_pattern_art_edits() { + use vectorcraft_doc::pattern::{PatternDef, TileType, pattern_paint}; + use vectorcraft_geom::shapes::rectangle; + // Symbol `S` ([`symbol_art`]) becomes a wide red bar. + let with_s = || { + 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) }); + d + }; + let red_bar = |d: &mut Document| { + let bar = rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0)); + let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let s = d.symbols.iter().position(|s| s.name == "S").unwrap(); + d.symbols[s].art = Arc::new(Node::path(d.alloc_id(), bar, red)); + }; + let instance = |d: &mut Document, symbol: &str, xf: Affine| Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }); + // An instance of symbol `A`, whose art shows `S`. + let mut d = with_s(); + let shown = instance(&mut d, "S", Affine::IDENTITY); + let a = Node::group(d.alloc_id(), vec![Arc::new(shown)]); + d.symbols.push(vectorcraft_doc::Symbol { name: "A".into(), art: Arc::new(a) }); + let a = instance(&mut d, "A", Affine::translate((100.0, 50.0)) * Affine::scale(2.0)); + let envelope = arc_envelope(&mut d, vec![a], false); + edit_draws_as_new(with_art(d, vec![envelope]), red_bar); + // A black rectangle whose opacity mask is an instance of `S`. + let mut d = with_s(); + let mask = instance(&mut d, "S", Affine::translate((100.0, 50.0)) * Affine::scale(3.0)); + let mut masked = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 180.0, 80.0)), Paint::solid(Color::BLACK), vec![]); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask, true))); + let envelope = arc_envelope(&mut d, vec![masked], false); + edit_draws_as_new(with_art(d, vec![envelope]), red_bar); + // An instance of symbol `Banded`, a band filled with `Checks`, whose tile type turns to bricks. + let mut d = Document::new(200.0, 100.0); + d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0))); + let band = filled(&mut d, rectangle(Rect::new(-80.0, -30.0, 80.0, 30.0)), pattern_paint("Checks"), vec![]); + d.symbols.push(vectorcraft_doc::Symbol { name: "Banded".into(), art: Arc::new(band) }); + let banded = instance(&mut d, "Banded", Affine::translate((100.0, 50.0))); + let envelope = arc_envelope(&mut d, vec![banded], true); + edit_draws_as_new(with_art(d, vec![envelope]), |d| { + if let Some(def) = d.pattern_mut("Checks") { + def.tile_type = TileType::BrickByRow { offset: 0.5 }; + } + }); + // A band filled with pattern `Dots`, whose art is an instance of `S` at half size. + let mut d = with_s(); + let dot = instance(&mut d, "S", Affine::translate((10.0, 10.0)) * Affine::scale(0.5)); + let mut dots = PatternDef::new("Dots", vec![Arc::new(dot)]); + dots.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(dots); + let band = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 180.0, 80.0)), pattern_paint("Dots"), vec![]); + let envelope = arc_envelope(&mut d, vec![band], true); + edit_draws_as_new(with_art(d, vec![envelope]), red_bar); +} + fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect { vectorcraft_doc::Effect { id: id.into(), params, visible: true } } diff --git a/crates/render/src/tests_placed_document.rs b/crates/render/src/tests_placed_document.rs index c04af829..cedfbcdc 100644 --- a/crates/render/src/tests_placed_document.rs +++ b/crates/render/src/tests_placed_document.rs @@ -67,6 +67,53 @@ fn a_placed_document_draws_exactly_with_its_own_art() { assert_eq!(&r.pixel(95, 95)[..3], &[255, 255, 255], "nothing outside"); } +#[test] +fn an_enveloped_placed_document_shows_its_file_once_read() { + let mut d = doc("enveloped-1"); + let placed = d.remove(NodeId(50)).unwrap(); + let NodeKind::PlacedDocument(p) = &placed.kind else { panic!("a placed document") }; + let p = (**p).clone(); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let envelope = NodeKind::Envelope { + content: vec![placed], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }; + let l = d.layers[0].id; + d.insert(Some(l), 0, Node::new(NodeId(60), envelope)).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let before = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels; + // Exporting the document as PDF, SVG or EPS reads the file. + d.with_placed_art(); + assert!(vectorcraft_doc::placed_document::read_cached(&p).is_some(), "the file is read"); + let after = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels; + assert!(after != before, "the file's art shows"); + assert!(after == Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); +} + +#[test] +fn a_placed_document_with_a_drop_shadow_shows_its_file_once_read() { + let mut d = doc("shadowed-1"); + let n = d.node_mut(NodeId(50)).unwrap(); + let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}); + n.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params, visible: true }); + let NodeKind::PlacedDocument(p) = &n.kind else { panic!("a placed document") }; + let p = (**p).clone(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let before = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels; + // Exporting the document as PDF, SVG or EPS reads the file. + d.with_placed_art(); + assert!(vectorcraft_doc::placed_document::read_cached(&p).is_some(), "the file is read"); + let after = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels; + assert!(after != before, "the file's art shows"); + assert!(after == Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); +} + #[test] fn on_screen_a_placed_document_shows_a_placeholder_then_its_bitmap() { let d = doc("progressive-1"); diff --git a/docs/gaps.md b/docs/gaps.md index bf027b02..a7ca999f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -202,6 +202,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall budget met, 400% zoom 12.3 ms against 16 ms, measured), a spatial index for zoomed-in frames, dirty-region 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. - **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 96b29cc0..0f916e95 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -530,6 +530,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, Document Setup's substitution highlights, a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). +- **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. + - **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. ## Revision history From caa80fb213889f3175ff6558733ca6ea6ec137b4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 04:41:03 +0700 Subject: [PATCH 2/2] Render tests: the live object tests take the font locks (#1688) --- crates/render/src/tests_fontchange.rs | 2 ++ crates/render/src/tests_memory.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs index 1f77234e..07eda527 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests_fontchange.rs @@ -111,6 +111,7 @@ fn symbol_type_redraws_when_its_font_becomes_available() { /// fonts change. #[test] fn enveloped_type_redraws_when_its_font_becomes_available() { + let _adding = adding_fonts(); const FAMILY: &str = "Warped Serif 4"; let mut d = Document::new(300.0, 150.0); let word = |d: &mut Document, at: Point| { @@ -150,6 +151,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() { /// envelope in a group with a geometry effect or with a fill of its own. #[test] fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() { + let _adding = adding_fonts(); use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{AppearanceItem, Effect, FillLayer}; let cases = [ diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index a7e92c4e..e0dc3d3f 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -627,6 +627,7 @@ fn arc_envelope(d: &mut Document, content: Vec, patterns: bool) -> Node { /// pattern, and type whose characters are filled with it. #[test] fn live_objects_draw_pattern_layout_edits() { + let _fonts = fonts_steady(); use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType, pattern_paint}; use vectorcraft_doc::text::{CharStyle, TextObject}; use vectorcraft_geom::shapes::rectangle; @@ -672,6 +673,7 @@ fn live_objects_draw_pattern_layout_edits() { /// the art of the symbol it bends and a symbol in the art of a pattern it tiles. #[test] fn live_objects_draw_symbol_and_pattern_art_edits() { + let _fonts = fonts_steady(); use vectorcraft_doc::pattern::{PatternDef, TileType, pattern_paint}; use vectorcraft_geom::shapes::rectangle; // Symbol `S` ([`symbol_art`]) becomes a wide red bar.