From a723f7359a39ab8a30c2ed49d22635814f3b5f25 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 17:47:34 -0800 Subject: [PATCH] Reuse text outlines across moves --- crates/render/src/lib.rs | 3 +++ crates/render/src/lru.rs | 28 +++++++++++++++++++- crates/render/src/renderer.rs | 2 ++ crates/render/src/renderer/text.rs | 38 ++++++++++++++++++++++++--- crates/render/src/tests/charstroke.rs | 22 ++++++++++++++++ docs/target-app-parity.md | 3 ++- 6 files changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 36c8c1d00..46a0ca64a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -53,6 +53,9 @@ pub use vello_cpu; pub struct Renderer { /// Glyph geometry per text object, keyed by `Arc` identity like `geom` (see [`Self::text_geom_of`]). texts: FrameCache, Arc)>, + /// The latest cached glyph geometry for each text object id. A move changes the node's Arc + /// but not its glyphs in text space, so the old geometry can still be used after comparison. + text_ids: PtrMap, /// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out /// again when fonts are added or rescanned. text_fonts: u64, diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index 42ab5799b..bb4808d0e 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -101,6 +101,33 @@ impl FrameCache { Some(&s.value) } + /// Read an entry only when it matches `accept`; rejected entries keep their previous age so + /// another document's object with the same id cannot prevent their eviction. + pub(crate) fn get_if(&mut self, k: &Q, stamp: u64, accept: impl FnOnce(&V) -> bool) -> Option<&V> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + let s = self.map.get_mut(k)?; + if !accept(&s.value) { + return None; + } + self.clock += 1; + (s.stamp, s.used) = (stamp, self.clock); + Some(&s.value) + } + + /// Drop the entry for `k`. + pub(crate) fn remove(&mut self, k: &Q) + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + if let Some(s) = self.map.remove(k) { + self.bytes -= s.bytes; + } + } + /// Keep `value` (about `bytes` bytes) as `k`, used in frame `stamp`, replacing any entry for /// `k`. Least recently used entries of earlier frames make room; returns false when they can't /// (the value isn't kept then). @@ -196,7 +223,6 @@ impl FrameCache { self.bytes } - #[cfg(test)] pub(crate) fn contains_key(&self, k: &Q) -> bool where K: Borrow, diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 2f69551c1..e22d4df5e 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -33,6 +33,7 @@ impl Renderer { pub fn new() -> Self { Self { texts: FrameCache::new(lru::TEXT_BUDGET, 1024), + text_ids: PtrMap::default(), text_fonts: 0, clips: FrameCache::new(lru::CLIP_BUDGET, 256), threads: default_threads(), @@ -208,6 +209,7 @@ impl Renderer { // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); self.texts.sweep(g); + self.text_ids.retain(|_, key| self.texts.contains_key(key)); self.char_strokes.sweep(g); self.clips.sweep(g); self.clip_paints.sweep(g); diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index e9dd7f664..15de88719 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -151,20 +151,50 @@ impl Renderer { let fonts = vectorcraft_text::FontDb::global().generation(); if fonts != self.text_fonts { self.texts.clear(); + self.text_ids.clear(); self.char_strokes.clear(); self.text_fonts = fonts; } let key = Arc::as_ptr(a) as usize; - if let Some((node, g)) = self.texts.get(&key, self.stamp) - && Arc::ptr_eq(node, a) - { + if let Some((_, g)) = self.texts.get_if(&key, self.stamp, |(node, _)| Arc::ptr_eq(node, a)) { return (g.clone(), true); } + if let Some(&previous) = self.text_ids.get(&a.id) + && let Some(g) = self + .texts + .get_if(&previous, self.stamp, |(node, _)| { + let NodeKind::Text(old) = &node.kind else { return false }; + // Layout and character stroke outlines are in text space. Only the transform + // changed when the remaining text attributes still compare equal. + let mut comparable = (**old).clone(); + comparable.xf = t.xf; + comparable == *t + }) + .map(|(_, g)| g.clone()) + { + // Kept under this node from here on: the next frames find it without comparing. + self.texts.remove(&previous); + let kept = self.keep_text(a, g.clone()); + return (g, kept); + } let g = Arc::new(text_geom(t)); - let kept = self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp); + let kept = self.keep_text(a, g.clone()); (g, kept) } + /// Keep glyph geometry `g` of text node `a`, as the latest of its id. Whether it was kept. + fn keep_text(&mut self, a: &Arc, g: Arc) -> bool { + let key = Arc::as_ptr(a) as usize; + let bytes = g.bytes(); + let kept = self.texts.insert(key, (a.clone(), g), bytes, self.stamp); + if kept { + self.text_ids.insert(a.id, key); + } else { + self.text_ids.remove(&a.id); + } + kept + } + /// Draw type `n` (`t`, laid out in `g`). The outlines of its character strokes are cached /// when `g` is kept in `texts` (`kept`). pub(super) fn draw_text_geom(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &Arc, kept: bool) { diff --git a/crates/render/src/tests/charstroke.rs b/crates/render/src/tests/charstroke.rs index 1dd1d8646..f8f50cdd9 100644 --- a/crates/render/src/tests/charstroke.rs +++ b/crates/render/src/tests/charstroke.rs @@ -164,6 +164,28 @@ fn character_stroke_outlines_are_kept_between_frames() { assert!(changed.len() == 1 && !Arc::ptr_eq(&changed[0], &first[0]), "the changed text is expanded again"); } +#[test] +fn moving_type_reuses_character_strokes_without_changing_pixels() { + let _fonts = fonts_steady(); + let (mut d, id) = stroked_text_doc("Type"); + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts); + let first = character_outlines(&r); + assert_eq!(first.len(), 1); + if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) { + t.xf = Affine::translate((10.0, 0.0)) * t.xf; + } + let moved = r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts); + let again = character_outlines(&r); + assert!(again.len() == 1 && Arc::ptr_eq(&again[0], &first[0]), "a move reuses the character stroke outline"); + assert_eq!(moved.pixels, Renderer::new().render(&d, SIZE, SIZE, Affine::IDENTITY, &opts).pixels); + // The entry moved to the node shown, so the next frames find it without comparing the text. + let node = d.node_arc(id).unwrap(); + assert!(r.texts.contains_key(&(Arc::as_ptr(node) as usize)), "kept under the moved node"); + assert_eq!(r.texts.len(), 1, "and not also under the old one"); +} + /// Frames that draw character strokes from the cache draw the same pixels, and what a renderer /// draws whose glyph cache has no room, so that it expands them in every frame: exactly at 100%, /// where both take a tolerance of 1/4 device pixel, and within antialiasing at 105% and 150%, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c2fc3e9e..c796fa262 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; moving unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Moving a type object without changing its text or formatting reuses glyph geometry and expanded character-stroke outlines between frames. The moved text appears at its new position with unchanged pixels, and edits to text or formatting still rebuild the outlines; scores and estimates unchanged. | | 2026-11-05 | minor | Canvas: an image drawn larger than 1,024 pixels at the size it shows, with its colours made again (separated into ink planes, colorized or shown through Separations Preview), has them made only for the 1,024-pixel tiles in view, each kept for later frames (#4155). Zooming in to 50%, 100% and 200% on the file of #4155 went from 8.6–9.0 s to 0.78 s, 0.05 s and 0.05 s (measured, M3 Pro, one run each), with the same pixels. Scores and estimates unchanged. | | 2026-11-05 | minor | Canvas: a document's first frame (opened, or its tab chosen) renders on the render worker instead of the UI thread; the window shows the document's paper and artboards until the art lands, and a frame ready within 16 ms shows at once. Opening the file of #4155 kept the UI thread busy for 8.7 s at a stretch, now at most 64 ms; a 50,000-path SVG 1.4 s, now 62 ms (measured, M3 Pro, one run each). Scores and estimates unchanged. | | 2026-11-05 | minor | Canvas: an image drawn reduced in a CMYK document is shrunk to the size it shows before it is separated into ink planes, not separated at full size and then shrunk (#4155). On the 141-megapixel image of #4155 the first frame went from 9.2 s to 1.6 s, and the sharp frame after a pan from 3.9 s to 8 ms (measured, M3 Pro, one run each). The same pixels show within 5 levels; fine detail such as 1-pixel lines now shows its true average. Scores and estimates unchanged. |