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..07eda527 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests_fontchange.rs @@ -105,3 +105,96 @@ 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() { + 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| { + 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() { + let _adding = adding_fonts(); + 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..e0dc3d3f 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -610,6 +610,124 @@ 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() { + let _fonts = fonts_steady(); + 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() { + 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. + 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