diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 36dd1ad3..6ae9f11a 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -869,10 +869,10 @@ impl Renderer { // A symbol's art can change while the instance doesn't: no cached shadows. let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; - self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true)); + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art)); ctx.set_transform(Affine::IDENTITY); } - _ => self.draw_node(ctx, f, &art, true), + _ => self.draw_fx_art(ctx, f, a, &art), } self.made_again = outer; } @@ -939,6 +939,19 @@ impl Renderer { art } + /// Draw `art`, the art [`Self::fx_art`] made for `a`. Type art that `fx_arts` keeps for `a` is + /// drawn as `fx_owner`, so [`Self::draw_text`] takes its glyph geometry from `texts`. + fn draw_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, art: &Arc) { + let kept = + matches!(art.kind, NodeKind::Text(_)) && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, art)); + if !kept { + return self.draw_node(ctx, f, art, true); + } + let outer = self.fx_owner.replace(art.clone()); + self.draw_node(ctx, f, art, true); + self.fx_owner = outer; + } + /// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on /// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the /// decoded images under a key naming the adjustment. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1dbf0099..d293c6f8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1602,7 +1602,14 @@ impl Renderer { fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { let t = &*f.doc.inline_resolved(t); - let g = text_geom_snapped(t, f.text_snap(t)); + // Type drawn as `fx_owner` (the node `draw_arc` is drawing, or type art that `fx_arts` + // keeps, see `draw_fx_art`) takes its glyph geometry from `texts`, as on the fast path, + // except in frames that snap type to pixels. + let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned(); + let g = match (f.text_snap(t), owner) { + (None, Some(a)) => self.text_geom_of(&a, t), + (snap, _) => Arc::new(text_geom_snapped(t, snap)), + }; self.draw_text_geom(ctx, f, n, t, &g); } @@ -1670,7 +1677,12 @@ impl Renderer { } /// Cached glyph geometry for a text node (keyed by Arc identity like paths). + /// Text `t` that is not the node's own, such as the copy whose inline graphics + /// `Document::inline_resolved` sized again, is laid out without the cache. fn text_geom_of(&mut self, a: &Arc, t: &TextObject) -> Arc { + if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) { + return Arc::new(text_geom(t)); + } let fonts = vectorcraft_text::FontDb::global().generation(); if fonts != self.text_fonts { self.texts.clear(); diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs index 07eda527..74dba9f6 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests_fontchange.rs @@ -83,6 +83,37 @@ fn type_with_raster_effects_redraws_when_its_font_becomes_available() { assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); } +/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes +/// available: the glyph geometry kept for it is laid out again. +#[test] +fn translucent_blended_and_shadowed_type_redraws_when_its_font_becomes_available() { + const FAMILY: &str = "Blended Serif4"; + let mut d = Document::new(300.0, 150.0); + let l = d.layers[0].id; + for i in 0..3 { + let style = CharStyle { size: 36.0, font_family: FAMILY.into(), ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 40.0 + 45.0 * i as f64), "Hamburg", style)))); + match i { + 0 => n.opacity = 0.8, + 1 => n.blend = vectorcraft_color::BlendMode::Multiply, + _ => n.appearance.effects.push(vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}), + visible: true, + }), + } + d.insert(Some(l), i, n).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_eq!(r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, before, "drawn again from the kept glyph geometry"); + 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 with a geometry effect in a symbol's art redraws in a font that becomes available: the art /// kept for the instance is placed again when the fonts change. #[test] diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index e092f60f..031344ff 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -182,6 +182,122 @@ fn swept_type_entries_give_back_their_bytes() { assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0)); } +/// [`text_doc`] of three type objects reading `word`: one at 80% opacity, one in Multiply and one +/// with a drop shadow. +fn faded_text_doc(word: &str) -> Document { + let mut d = text_doc(3, word); + let ids: Vec = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + d.node_mut(ids[0]).unwrap().opacity = 0.8; + d.node_mut(ids[1]).unwrap().blend = vectorcraft_color::BlendMode::Multiply; + d.node_mut(ids[2]).unwrap().appearance.effects.push(drop_shadow()); + d +} + +/// The glyph geometry `r` keeps for each type object of `d`: under its node, or under the art +/// drawn for it when it has effects. +fn kept_glyphs(r: &mut Renderer, d: &Document) -> Vec>> { + let stamp = r.stamp; + text_keys(d) + .into_iter() + .map(|k| { + let k = r.fx_arts.get(&k, stamp).map_or(k, |e| Arc::as_ptr(&e.1) as usize); + r.texts.get(&k, stamp).map(|(_, g)| g.clone()) + }) + .collect() +} + +/// `f`'s result from a run during which the fonts did not change: other tests add fonts, and the +/// renderer lays all type out again when the fonts change. +fn fonts_unchanged(mut f: impl FnMut() -> T) -> T { + loop { + let fonts = vectorcraft_text::FontDb::global().generation(); + let t = f(); + if vectorcraft_text::FontDb::global().generation() == fonts { + return t; + } + } +} + +/// Type at 80% opacity, in Multiply and with a drop shadow keeps its glyph geometry between +/// frames, as opaque type does: the second frame draws the geometry the first laid out. Frames +/// that snap type to pixels keep none, and neither does type art that the effect-art cache has no +/// room for. +#[test] +fn translucent_blended_and_shadowed_type_keeps_its_glyph_geometry_between_frames() { + let d = faded_text_doc("Kept"); + let opts = RenderOptions::default(); + let (first, kept, entries, second, again) = fonts_unchanged(|| { + let mut r = Renderer::new(); + let first = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels; + let (kept, entries) = (kept_glyphs(&mut r, &d), r.texts.len()); + let second = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels; + (first, kept, entries, second, kept_glyphs(&mut r, &d)) + }); + assert!(kept.iter().all(Option::is_some) && entries == 3, "{entries} entries"); + assert!(second == first); + let same = kept.iter().zip(&again).all(|pair| matches!(pair, (Some(a), Some(b)) if Arc::ptr_eq(a, b))); + assert!(same, "the second frame draws the geometry the first laid out"); + // Type anti-aliasing snaps the glyphs to pixels. + let mut r = Renderer::new(); + r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions { anti_alias: AntiAlias::Type, ..Default::default() }); + assert!(r.texts.is_empty()); + // With no room for effect art, the shadowed type's art is made in every frame. + let frames = fonts_unchanged(|| { + let mut r = Renderer::new(); + r.fx_arts = FrameCache::new(0, 0); + (0..2).map(|_| (r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels == first, r.texts.len())).collect::>() + }); + assert_eq!(frames, [(true, 2), (true, 2)]); +} + +/// Editing the text of type at 80% opacity, in Multiply or with a drop shadow lays it out again: +/// the next frame draws what a new renderer draws. +#[test] +fn edited_translucent_blended_and_shadowed_type_draws_as_new() { + for i in 0..3 { + let mut d = faded_text_doc("Kept"); + let id = d.layers[0].children().unwrap()[i].id; + let opts = RenderOptions::default(); + draws_as_new(&mut d, &opts, &opts, |d| { + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].text = "Edit".into(); + } + }); + } +} + +/// Type showing an inline graphic, at 80% opacity, in Multiply, with a drop shadow and opaque: +/// when the symbol's art changes size and the document's inline graphics are not resolved again, +/// the next frame lays the type out with the new size, as a new renderer does. +#[test] +fn type_with_an_inline_graphic_draws_its_symbols_new_size() { + use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; + for i in 0..4 { + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let style = CharStyle { size: 24.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone())); + t.runs.push(TextRun::new("B", style)); + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + match i { + 0 => n.opacity = 0.8, + 1 => n.blend = vectorcraft_color::BlendMode::Multiply, + 2 => n.appearance.effects.push(drop_shadow()), + _ => {} + } + let mut d = with_art(d, vec![n]); + d.resolve_inline_art(); + // `S` becomes a wide red bar: the inline graphic grows, and the type after it moves. + edit_draws_as_new(d, |d| { + let bar = vectorcraft_geom::shapes::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); + d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red)); + }); + } +} + /// A symbol's art inside the 20 × 20 square around the origin: a group of a square filled blue /// with a 2 pt black stroke and a black line. fn symbol_art(d: &mut Document) -> Node { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3c4a75a7..a33ec84f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -546,6 +546,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames. +- **Type with transparency or effects:** type at less than 100% opacity, in a blend mode other than Normal, or with raster effects such as a drop shadow keeps its glyph outlines between frames, as opaque type does, and is laid out again when it is edited or the fonts change. At 2880 × 1800 on 8 threads, a page of 1,000 type objects takes 13 ms a frame instead of 23 ms at 80% opacity, 31 ms instead of 41 ms in Multiply and 16 ms instead of 26 ms with a drop shadow, with the same pixels. + - **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified. - **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it.