Render: raster effects are made again when patterns, symbols, images, settings or fonts change

The renderer kept the rasters of an object's drop shadows, outer glows
and filtered content (Gaussian Blur, Feather and the Photoshop-style
filters), and drew them again while the object, the zoom and rotation
and the ink plane stayed the same. The content drawn into a raster also
reads things outside the object: patterns, symbols, brushes, images,
document, layer and color settings, the knockout a group inherits, the
fonts, and the options the frame is drawn with. When one of them
changed, a renderer that had drawn the object went on drawing the
raster it had kept, made from the old values.

Each kept raster now records what its content was drawn from: which of
those things the object reads, found by walking the object when the
raster is made, and a hash of their values then. The renderer draws
the raster only while that hash is the same in the current frame. Once
a frame, the renderer hashes the document's patterns, symbols and
images. It hashes their art and pixels by the addresses of their
shared values, and it keeps weak references to those values while a
raster made with them is kept, so no other value can take one of those
addresses. A filtered raster also records the color channels its
filters split, and under De-Interlace the raster resolution.

A temporary release test timed the inputs of 400 kept rasters at about
10 microseconds a frame.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-13 00:34:41 +07:00
1 parent 8754646784
commit f4e72fe6a5
8 files changed
+583 -39

No files matched your search

+33 -1
View File
@@ -7,8 +7,11 @@ use std::sync::{Mutex, MutexGuard};
use serde_json::json;
use vectorcraft_color::cms;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::colormgmt::{PROFILES_KEY, doc_profiles};
use vectorcraft_testkit::fixtures::session;
use vectorcraft_geom::Affine;
use vectorcraft_render::{RenderOptions, Renderer};
use vectorcraft_testkit::fixtures::{exec, rect, session};
/// The active Color Settings: one test at a time.
fn settings() -> MutexGuard<'static, ()> {
@@ -52,3 +55,32 @@ fn legacy_profile_name_resolves_in_commands_and_files() {
assert_eq!(doc_profiles(&back), (Some(cms::WIDE_GAMUT_RGB.to_string()), None));
cms::set_active(&before).unwrap();
}
/// Raster effects follow the Color Settings: a blurred CMYK fill, and a white square's drop shadow
/// in a CMYK color, are drawn again through the new working CMYK profile, in a frame drawn while
/// interacting (as during a dialog's preview) and after it.
#[test]
fn raster_effects_draw_through_new_color_settings() {
let _g = settings();
let mut s = session();
let blurred = rect(&mut s, 20.0, 20.0, 160.0, 120.0);
exec(&mut s, "paint.setFill", json!({"ids": [blurred.0], "color": {"c": 0, "m": 60, "y": 100, "k": 0}}));
exec(&mut s, "effect.apply", json!({"ids": [blurred.0], "effect": "blur.gaussian", "params": {"radius": 2}}));
let lit = rect(&mut s, 220.0, 20.0, 120.0, 120.0);
exec(&mut s, "paint.setFill", json!({"ids": [lit.0], "color": "#ffffff"}));
let cyan = json!({"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0});
let shadow = json!({"x": 8, "y": 8, "blur": 1, "opacity": 100, "mode": "normal", "color": cyan});
exec(&mut s, "effect.apply", json!({"ids": [lit.0], "effect": "stylize.dropShadow", "params": shadow}));
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let live = RenderOptions { interacting: true, ..opts.clone() };
let frame = |r: &mut Renderer, s: &Session, o: &RenderOptions| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, o).pixels;
let mut r = Renderer::new();
let before = frame(&mut r, &s, &opts);
let old = cms::active_settings();
s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap();
let fresh = frame(&mut Renderer::new(), &s, &opts);
assert!(fresh != before, "the new profile changes the frame");
assert!(frame(&mut r, &s, &live) == fresh, "while interacting");
assert!(frame(&mut r, &s, &opts) == fresh, "after it");
cms::set_active(&old).unwrap();
}
+16 -1
View File
@@ -6,6 +6,7 @@
//! any of them regenerate the art and nothing else does.
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use vectorcraft_brush::Brush;
@@ -27,6 +28,9 @@ pub(crate) struct Entry {
pub(crate) struct BrushCache {
/// The document value the library was parsed from (`None` = defaults).
src: Option<Option<vectorcraft_brush::serde_json::Value>>,
/// A hash of that value (0 for the defaults), which rasters drawing brushed strokes record
/// ([`crate::fx::Inputs`]).
key: u64,
/// (frame stamp, document address) the library was last validated for.
checked: (u64, usize),
lib: HashMap<String, Arc<Brush>>,
@@ -35,7 +39,7 @@ pub(crate) struct BrushCache {
impl Default for BrushCache {
fn default() -> Self {
Self { src: None, checked: (0, 0), lib: HashMap::new(), entries: FrameCache::new(lru::BRUSH_BUDGET, 512) }
Self { src: None, key: 0, checked: (0, 0), lib: HashMap::new(), entries: FrameCache::new(lru::BRUSH_BUDGET, 512) }
}
}
@@ -51,8 +55,19 @@ impl BrushCache {
return;
}
self.src = Some(cur.cloned());
self.key = cur.map_or(0, |v| {
let mut h = DefaultHasher::new();
vectorcraft_brush::serde_json::to_string(v).unwrap_or_default().hash(&mut h);
h.finish()
});
self.lib = vectorcraft_brush::library(doc).into_iter().map(|b| (b.name.clone(), Arc::new(b))).collect();
}
/// [`Self::key`] for the brush library `doc` holds (checked once a frame, `stamp`).
pub(crate) fn library_key(&mut self, doc: &Document, stamp: u64) -> u64 {
self.library(doc, stamp);
self.key
}
}
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs).
+242 -30
View File
@@ -22,6 +22,11 @@
//! cached shadows, glows and filtered content at its new place instead of running the effects
//! again ([`Renderer::move_shadow`], [`Renderer::move_pixel_content`]).
//!
//! A cached raster is made again when something its content reads from outside the object
//! changes ([`Inputs`]): the pattern definitions, symbols, images and brushes it uses, the
//! document's raster resolution, color mode and substitution highlights, the knockout its object
//! inherits, the color settings and fonts, and the options and layer settings it is drawn with.
//!
//! The same applies to one fill or stroke's own raster effects (around that item alone) and to
//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is
//! drawn offscreen in the filter layers, reshaped first by their geometry effects. Groups and
@@ -29,9 +34,11 @@
//! ([`effects::evaluate_container`]: their fills and strokes painting the members, their geometry
//! effects reshaping them), so their raster effects apply to the composite (one combined shadow).
use std::sync::Arc;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{Arc, Weak};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeId, NodeKind};
use vectorcraft_color::cms::Cms;
use vectorcraft_doc::{AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_effects::pixel::{Channels, PixelFx};
use vectorcraft_effects::{self as effects, GeomContext, RasterFx};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathEl, Point, Rect, Shape, Size, Vec2};
@@ -236,6 +243,9 @@ pub(crate) struct PixelEntry {
k: f64,
image: std::sync::Arc<vello_cpu::Pixmap>,
made: Made,
/// What the content read when the raster was made, with the channels the effects ran on and,
/// under De-Interlace, the raster resolution.
inputs: Inputs,
/// Made for the object before it moved ([`Renderer::move_pixel_content`]), or made over
/// rasters made before their objects moved: only a frame rendered while interacting draws
/// it.
@@ -278,6 +288,8 @@ pub(crate) struct ShadowEntry {
rel: (f64, f64),
image: std::sync::Arc<vello_cpu::Pixmap>,
made: Made,
/// What the silhouette read when the raster was made.
inputs: Inputs,
/// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters
/// made before their objects moved: only a frame rendered while interacting draws it.
moved: bool,
@@ -390,6 +402,111 @@ impl Drawn {
}
}
/// What an object's content reads from outside its own nodes, in it or in the opacity masks of the
/// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct Reads {
/// Pattern paints: the pattern definitions.
patterns: bool,
/// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold
/// anything, so content that shows a symbol reads everything else too.
symbols: bool,
/// Images and placed documents: their pixels and files, and the options and the layer's Dim
/// Images they are drawn with.
images: bool,
/// Brushed strokes: the brush library.
brushes: bool,
/// Type: the fonts, whether overprinting shows ([`crate::proof::overprints`]) and Document
/// Setup's substitution highlights.
text: bool,
}
impl Reads {
/// What the content of `n` reads.
pub(crate) fn of(n: &Node) -> Self {
let mut reads = Self::default();
reads.add(n);
if reads.symbols {
return Self { patterns: true, symbols: true, images: true, brushes: true, text: true };
}
reads
}
fn add(&mut self, n: &Node) {
let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. });
n.walk(&mut |c| {
for item in &c.appearance.items {
self.patterns |= pattern(item.paint());
self.brushes |= matches!(item, AppearanceItem::Stroke(s) if s.brush.is_some());
}
match &c.kind {
NodeKind::SymbolInstance { .. } => self.symbols = true,
NodeKind::Image(_) | NodeKind::PlacedDocument(_) => self.images = true,
NodeKind::Text(t) => {
self.text = true;
self.symbols |= t.runs.iter().any(|r| r.inline.is_some());
self.patterns |= t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke));
}
_ => {}
}
if let Some(m) = c.extras().mask.as_deref() {
self.add(&m.art);
}
});
}
}
/// What a cached shadow, glow or filtered raster was drawn from besides its object, its effects
/// and the view's zoom and rotation ([`Renderer::raster_inputs`]). A raster is drawn from only
/// while its inputs equal the frame's.
#[derive(Clone)]
pub(crate) struct Inputs {
/// What the content reads.
reads: Reads,
/// A hash of the inputs as found when the raster was made: those every raster reads, those
/// `reads` names, and what the effects read themselves ([`Inputs::with`]).
key: u64,
/// The document's resources and the color settings `key` was found with: holding them keeps
/// the addresses they hashed for their values ([`DocInputs`]).
_doc: Arc<DocInputs>,
}
impl PartialEq for Inputs {
fn eq(&self, other: &Self) -> bool {
(self.reads, self.key) == (other.reads, other.key)
}
}
impl Inputs {
/// These inputs with `extra`, something the effects read themselves.
fn with(mut self, extra: impl Hash) -> Self {
let mut h = DefaultHasher::new();
(self.key, extra).hash(&mut h);
self.key = h.finish();
self
}
}
/// What raster contents read from the document by name or key, and the color settings, as a frame
/// finds them ([`Renderer::doc_inputs`]): hashes of the patterns' tilings
/// ([`crate::pattern::hash_tiling`]), of the symbols' names and art and of the images' keys and
/// pixels, and the address of the color settings. Art, pixels and previews are hashed by the
/// addresses of their shared values, and names, keys, tiles, tile types and overlaps are hashed by
/// value.
pub(crate) struct DocInputs {
patterns: u64,
symbols: u64,
images: u64,
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.
_held: Held,
}
/// The pattern and symbol art, the image pixels and the color settings, held weakly.
type Held = (Vec<Weak<Node>>, Vec<Weak<Vec<u8>>>, Weak<Cms>);
/// Most bytes the shadow and filtered-content rasters of one renderer hold together, with what each
/// was made from ([`Made`]): the least recently drawn go beyond it.
pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20;
@@ -411,9 +528,10 @@ pub(crate) struct FxGeom {
impl Renderer {
/// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster:
/// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused
/// while the object and the zoom/rotation are unchanged (pans only move it), and while
/// interacting after the object only moved ([`Self::move_shadow`]). Returns false when the
/// effect isn't a shadow/glow or the raster would be too large (caller falls back).
/// while the object, the zoom/rotation and what the silhouette reads ([`Inputs`]) are unchanged
/// (pans only move it), and while interacting after the object only moved
/// ([`Self::move_shadow`]). Returns false when the effect isn't a shadow/glow or the raster
/// would be too large (caller falls back).
#[allow(clippy::too_many_arguments)]
fn draw_shadow_cached(
&mut self,
@@ -435,9 +553,12 @@ impl Renderer {
let key = (n as *const Node as usize, i, f.ink);
let stamp = self.stamp;
let interacting = f.opts.interacting;
let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && (interacting || !e.moved);
// What the silhouette reads: as the raster kept for `n` found it, else found now.
let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads);
let inputs = self.raster_inputs(f, n, reads);
let exact = |e: &ShadowEntry| e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved);
if interacting && !self.shadows.get(&key).is_some_and(exact) {
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, paint);
self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint);
}
let hit = self.shadows.get_mut(&key).filter(|e| exact(e));
let (image, x, y) = match hit {
@@ -483,7 +604,8 @@ impl Renderer {
let anchor = reach.origin();
let p = f.view * anchor;
let node = Drawn::of(n, owner);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), made, moved, stamp });
let rel = (x0 - p.x, y0 - p.y);
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), made, inputs, moved, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
(image, x0, y0)
}
@@ -502,12 +624,12 @@ impl Renderer {
}
/// While interacting, give `n` (shadow-cache key `key`) the shadow or glow raster an earlier
/// frame made for its object (the same id, effect slot and ink, at this zoom and rotation) when
/// only the silhouette's place changed after that frame: made by the same effect `fx` from a
/// silhouette whose bounds have the size of `reach` (the content's bounds moved by the
/// shadow's offset), and whose alpha is the alpha of `n`'s silhouette drawn into a raster
/// placed as that one was against its anchor. The raster is kept as `n`'s, marked moved, and
/// is drawn at the nearest whole pixel.
/// frame made for its object (the same id, effect slot and ink, at this zoom and rotation, with
/// the same `inputs`) when only the silhouette's place changed after that frame: made by the
/// same effect `fx` from a silhouette whose bounds have the size of `reach` (the content's
/// bounds moved by the shadow's offset), and whose alpha is the alpha of `n`'s silhouette drawn
/// into a raster placed as that one was against its anchor. The raster is kept as `n`'s, marked
/// moved, and is drawn at the nearest whole pixel.
#[allow(clippy::too_many_arguments)]
fn move_shadow(
&mut self,
@@ -517,6 +639,7 @@ impl Renderer {
fx: &RasterFx,
reach: Rect,
owner: Option<&Arc<Node>>,
inputs: &Inputs,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
let (dx, dy) = match fx {
@@ -525,7 +648,12 @@ impl Renderer {
};
let (linear, stamp, size) = (linear_key(f.view), self.stamp, reach.size());
let fits = |k: &(usize, usize, Ink), e: &ShadowEntry| {
(k.1, k.2) == (key.1, key.2) && e.stamp < stamp && e.linear == linear && e.node.id() == n.id && e.made.fits(&[fx], size, None, None)
(k.1, k.2) == (key.1, key.2)
&& e.stamp < stamp
&& e.linear == linear
&& e.node.id() == n.id
&& e.inputs == *inputs
&& e.made.fits(&[fx], size, None, None)
};
let Some((old, rel, w, h)) = self.shadows.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height()))
else {
@@ -791,9 +919,9 @@ impl Renderer {
/// Photoshop-style filters) in stack order on its pixels: rendered offscreen over all it can
/// reach, filtered on the CPU and drawn back. The raster holds at most [`PIXEL_BUDGET`] pixels:
/// larger content is cropped to the view when every effect only looks nearby, else rendered
/// at a lower resolution. A whole raster at full resolution is cached while the object and the
/// zoom stay the same (panning only moves it), and while interacting after the content only
/// moved ([`Self::move_pixel_content`]).
/// at a lower resolution. A whole raster at full resolution is cached while the object, the
/// zoom and what the content reads ([`Inputs`]) stay the same (panning only moves it), and
/// while interacting after the content only moved ([`Self::move_pixel_content`]).
fn pixel_content(
&mut self,
ctx: &mut RenderContext,
@@ -835,13 +963,23 @@ impl Renderer {
Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk,
Ink::Display | Ink::RichBlack => effects::pixel::Channels::Rgb,
};
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
// What the content reads (as the raster kept for it found it, else found now), with the
// channels the effects run on and, under De-Interlace, the resolution.
let inputs = key.map(|key| {
let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads);
let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. })));
self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits())))
});
let interacting = f.opts.interacting;
let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && (interacting || !e.moved);
let exact = |e: &PixelEntry| e.linear == linear && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved);
if interacting
&& let Some(key) = key
&& let Some(inputs) = &inputs
&& !self.pixel_fx.get(&key).is_some_and(exact)
{
self.move_pixel_content(f, content, key, &chain, channels, paint);
self.move_pixel_content(f, content, key, &chain, channels, inputs, paint);
}
if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| exact(e)) {
e.stamp = stamp;
@@ -872,8 +1010,6 @@ impl Renderer {
channels: Some(channels),
outline: feather_outline(&chain, content, content.reach.origin()),
});
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent };
let (wz, hz) = (wu as usize, hu as usize);
@@ -891,26 +1027,27 @@ impl Renderer {
}
}
let image = std::sync::Arc::new(pm);
if let Some((key, made)) = key.zip(made) {
if let Some(((key, made), inputs)) = key.zip(made).zip(inputs) {
let anchor = content.reach.origin();
let p = f.view * anchor;
let rel = (x0 - p.x, y0 - p.y);
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
let node = Drawn::of(content.node, owner.as_ref());
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, moved, stamp });
self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), made, inputs, moved, stamp });
self.trim_fx_rasters(FX_RASTER_BUDGET);
}
draw_scaled(ctx, image, (x0, y0), k);
}
/// While interacting, give `content` (cache key `key`) the filtered raster an earlier frame
/// made at full size for its object (the same id, slot and ink, at this zoom and rotation)
/// when only the content's place changed after that frame: made by the same effects `chain`,
/// told of the same `channels`, from content whose bounds have the same size (and the same
/// Feather outline against its anchor), and whose pixels are the pixels of `content` drawn
/// into a raster placed as that one was against its anchor. The raster is kept as
/// `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under
/// made at full size for its object (the same id, slot and ink, at this zoom and rotation, with
/// the same `inputs`) when only the content's place changed after that frame: made by the same
/// effects `chain`, told of the same `channels`, from content whose bounds have the same size
/// (and the same Feather outline against its anchor), and whose pixels are the pixels of
/// `content` drawn into a raster placed as that one was against its anchor. The raster is kept
/// as `content`'s, marked moved, and is drawn at the nearest whole pixel. Content under
/// De-Interlace takes none.
#[allow(clippy::too_many_arguments)]
fn move_pixel_content(
&mut self,
f: &Frame,
@@ -918,6 +1055,7 @@ impl Renderer {
key: (usize, usize, Ink),
chain: &[&RasterFx],
channels: Channels,
inputs: &Inputs,
paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame),
) {
if chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }))) {
@@ -931,6 +1069,7 @@ impl Renderer {
&& e.linear == linear
&& e.k >= 1.0
&& e.node.id() == content.node.id
&& e.inputs == *inputs
&& e.made.fits(chain, content.reach.size(), Some(channels), outline.as_ref())
};
let Some((old, rel, w, h)) = self.pixel_fx.iter().find(|(k, e)| fits(k, e)).map(|(k, e)| (*k, e.rel, e.image.width(), e.image.height()))
@@ -957,6 +1096,79 @@ impl Renderer {
}
}
/// The inputs a raster of `n`'s content, which reads `reads`, is drawn with in frame `f`: the
/// color settings, the edge smoothing, the knockout `n` inherits and, while `n` holds the
/// isolated group, isolation mode; the pattern definitions, symbols and brush library it
/// reads; for images and placed documents their pixels and files, the layer's Dim Images and
/// the options they are drawn with; for type the fonts, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights.
pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs {
let doc = self.doc_inputs(f.doc);
let mut h = DefaultHasher::new();
(doc.cms, f.opts.anti_alias.threshold(), self.knockout).hash(&mut h);
if let Some((isolated, path)) = self.isolation_path(f)
&& n.id != isolated
&& path.contains(&n.id)
{
isolated.hash(&mut h);
}
if reads.patterns {
doc.patterns.hash(&mut h);
}
if reads.symbols {
doc.symbols.hash(&mut h);
}
if reads.images {
let o = f.opts;
(doc.images, self.dim_images.map(f32::to_bits), o.smooth_images, o.image_mips, o.trace_views, o.progressive_placed).hash(&mut h);
}
if reads.brushes {
self.brushes.library_key(f.doc, self.stamp).hash(&mut h);
}
if reads.text {
let setup = &f.doc.setup;
let highlights = f.opts.highlight_substitutions.then_some((setup.highlight_substituted_fonts, setup.highlight_substituted_glyphs));
(vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
}
Inputs { reads, key: h.finish(), _doc: doc }
}
/// [`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<DocInputs> {
let at = (self.stamp, std::ptr::from_ref(doc) as usize);
if let Some((seen, d)) = &self.doc_inputs
&& *seen == at
{
return d.clone();
}
let cms = vectorcraft_color::cms::active();
let hash = |each: &mut dyn FnMut(&mut DefaultHasher)| {
let mut h = DefaultHasher::new();
each(&mut h);
h.finish()
};
let address = |a: &Arc<Node>| Arc::as_ptr(a) as usize;
let patterns = hash(&mut |h| doc.patterns.iter().for_each(|p| crate::pattern::hash_tiling(p, h)));
let symbols = hash(&mut |h| doc.symbols.iter().for_each(|s| (&s.name, address(&s.art)).hash(h)));
let images = hash(&mut |h| {
for (key, blob) in &doc.images {
(key, Arc::as_ptr(&blob.bytes) as usize, blob.proxy.as_ref().map(|p| Arc::as_ptr(p) as usize)).hash(h);
}
});
let found = (patterns, symbols, images, Arc::as_ptr(&cms) as usize);
let d = match &self.doc_inputs {
Some((_, d)) if (d.patterns, d.symbols, d.images, d.cms) == found => d.clone(),
_ => {
let art = doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art)).map(Arc::downgrade).collect();
let pixels = doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy)).map(Arc::downgrade).collect();
Arc::new(DocInputs { patterns, symbols, images, cms: found.3, _held: (art, pixels, Arc::downgrade(&cms)) })
}
};
self.doc_inputs = Some((at, d.clone()));
d
}
/// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes
/// ([`FX_RASTER_BUDGET`]).
fn trim_fx_rasters(&mut self, budget: usize) {
+4
View File
@@ -415,6 +415,9 @@ pub struct Renderer {
/// Objects' content run through Photoshop-style effects, per object and content slot (see
/// `fx`): kept only while drawn.
pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>,
/// What the contents of those rasters read from the document, and the color settings, as last
/// found for a frame (`stamp`) and document (address) (see [`Self::doc_inputs`]).
doc_inputs: Option<((u64, usize), Arc<fx::DocInputs>)>,
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
knockout: bool,
/// Address of the knockout-group element being drawn as its knockout shape: at full object
@@ -531,6 +534,7 @@ impl Renderer {
brushes: Default::default(),
shadows: PtrMap::default(),
pixel_fx: PtrMap::default(),
doc_inputs: None,
live: live::LiveCache::default(),
knockout: false,
shape_of: 0,
+14
View File
@@ -12,6 +12,7 @@
//! dimmed copies of it at the neighbouring tile positions and the tile edge.
use std::cell::RefCell;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
@@ -57,6 +58,19 @@ fn same_art(a: &[Arc<Node>], b: &[Arc<Node>]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y))
}
/// Hash what pattern `def`'s tiling is drawn from, as the tile cache checks it: its name, the
/// addresses of its art, its tile, tile type and overlap.
pub(crate) fn hash_tiling(def: &PatternDef, h: &mut impl Hasher) {
def.name.hash(h);
def.art.iter().for_each(|a| (Arc::as_ptr(a) as usize).hash(h));
[def.tile.x0, def.tile.y0, def.tile.x1, def.tile.y1].map(f64::to_bits).hash(h);
std::mem::discriminant(&def.tile_type).hash(h);
if let TileType::BrickByRow { offset } | TileType::BrickByColumn { offset } = def.tile_type {
offset.to_bits().hash(h);
}
(def.overlap.right_in_front, def.overlap.bottom_in_front).hash(h);
}
/// Power-of-two raster scale for a pattern seen at device scale `s`, capped so the super-tile
/// stays under [`MAX_TILE_PX`].
pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
+24
View File
@@ -40,6 +40,30 @@ fn type_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 with raster effects redraws in a font that becomes available: its drop shadow and its
/// blurred content are made again.
#[test]
fn type_with_raster_effects_redraws_when_its_font_becomes_available() {
const FAMILY: &str = "Raster Serif 4";
let mut d = Document::new(300.0, 150.0);
let l = d.layers[0].id;
let effects =
[("stylize.dropShadow", serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100})), ("blur.gaussian", serde_json::json!({"radius": 1}))];
for (i, (id, params)) in effects.into_iter().enumerate() {
let style = CharStyle { size: 50.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, 60.0 + 70.0 * i as f64), "Hamburg", style))));
n.appearance.effects.push(vectorcraft_doc::Effect { id: id.into(), params, 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!(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]
+248 -7
View File
@@ -1,5 +1,6 @@
//! What renderers keep between frames: decoded images shared by all of them, image mips, symbol
//! art placed for instances and inline graphics, and caches swept by every kind of render.
//! art placed for instances and inline graphics, raster effects made again when what they read
//! changes, and caches swept by every kind of render.
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
@@ -280,17 +281,26 @@ fn editing_an_instance_or_a_symbol_places_only_its_art_again() {
/// Draws `d` (200 × 100 points, on white), applies `edit` and draws it again with the same
/// renderer: the frame changed, a new renderer draws the same, and only the document holds the
/// symbols' art.
#[track_caller]
fn edit_draws_as_new(mut d: Document, edit: impl FnOnce(&mut Document)) {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
edit(&mut d);
let after = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels;
assert!(after != before, "the edit changes the frame");
assert!(after == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels, "a new renderer draws the same");
let _warm = draws_as_new(&mut d, &opts, &opts, edit);
assert!(d.symbols.iter().all(|s| Arc::strong_count(&s.art) == 1), "only the document holds the symbols' art");
}
/// Draws `d` (200 × 100 points) with `before`, applies `edit` and draws it with `after` and the
/// same renderer, which it returns: the frame changed, and a new renderer draws the same.
#[track_caller]
fn draws_as_new(d: &mut Document, before: &RenderOptions, after: &RenderOptions, edit: impl FnOnce(&mut Document)) -> Renderer {
let mut r = Renderer::new();
let first = r.render(d, 200, 100, Affine::IDENTITY, before).pixels;
edit(d);
let second = r.render(d, 200, 100, Affine::IDENTITY, after).pixels;
assert!(second != first, "the edit changes the frame");
assert!(second == Renderer::new().render(d, 200, 100, Affine::IDENTITY, after).pixels, "a new renderer draws the same");
r
}
/// `d` with symbol `Holder` (a group of `members`) shown by one instance at (100, 50).
fn holder_doc(mut d: Document, members: Vec<Node>) -> Document {
let holder = Node::group(d.alloc_id(), members.into_iter().map(Arc::new).collect());
@@ -403,6 +413,237 @@ fn art_with_live_objects_draws_pattern_layout_edits() {
});
}
fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect { id: id.into(), params, visible: true }
}
/// A drop shadow 4 pt right and down, blurred 1 pt, at full opacity.
fn drop_shadow() -> vectorcraft_doc::Effect {
effect("stylize.dropShadow", serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}))
}
/// A path of `d` filled with `fill` (no stroke), with `effects`.
fn filled(d: &mut Document, shape: vectorcraft_geom::PathData, fill: Paint, effects: Vec<vectorcraft_doc::Effect>) -> Node {
let mut n = Node::path(d.alloc_id(), shape, vectorcraft_doc::Appearance::basic(fill, Paint::None, 0.0));
n.appearance.effects = effects;
n
}
/// `d` with `art` added to its first layer, bottom first.
fn with_art(mut d: Document, art: Vec<Node>) -> Document {
let l = d.layers[0].id;
for n in art {
d.insert(Some(l), usize::MAX, n).unwrap();
}
d
}
/// Pattern `Checks`: a 10 × 10 square of `color` in a 20 × 20 tile.
fn checks(color: Color) -> vectorcraft_doc::pattern::PatternDef {
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
let art = Node::path(NodeId(900), square, vectorcraft_doc::Appearance::basic(Paint::solid(color), Paint::None, 0.0));
let mut def = vectorcraft_doc::pattern::PatternDef::new("Checks", vec![Arc::new(art)]);
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
def
}
/// Raster effects on a document's own objects are made again when a pattern they paint with
/// changes: its art (as Done Editing Pattern writes it) under a blur, and its tile type (as
/// Pattern Options sets it) under a blur and a drop shadow.
#[test]
fn rasters_draw_pattern_edits() {
use vectorcraft_doc::pattern::{TileType, pattern_paint};
use vectorcraft_geom::shapes::rectangle;
let doc = || {
let mut d = Document::new(200.0, 100.0);
d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0)));
let blur = effect("blur.gaussian", serde_json::json!({"radius": 2.0}));
let blurred = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), pattern_paint("Checks"), vec![blur]);
let shadowed = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 180.0, 80.0)), pattern_paint("Checks"), vec![drop_shadow()]);
with_art(d, vec![blurred, shadowed])
};
edit_draws_as_new(doc(), |d| {
if let Some(def) = d.pattern_mut("Checks") {
def.art = checks(Color::rgb(0.0, 0.0, 1.0)).art;
}
});
edit_draws_as_new(doc(), |d| {
if let Some(def) = d.pattern_mut("Checks") {
def.tile_type = TileType::BrickByRow { offset: 0.5 };
}
});
}
/// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop
/// shadow around an instance of the other symbol), when a brush definition changes (a brushed
/// stroke's drop shadow), and when an image's pixels change under the same key, as when a linked
/// file is read again (a blur).
#[test]
fn rasters_draw_symbol_brush_and_image_edits() {
use vectorcraft_geom::shapes::{line, rectangle};
// A group of an instance of symbol `A`, whose art shows symbol `S`, and a green square.
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 shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: 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 instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "A".into(), xf: Affine::translate((50.0, 50.0)) });
let green = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.8, 0.0)), Paint::None, 0.0);
let square = Node::path(d.alloc_id(), rectangle(Rect::new(120.0, 30.0, 160.0, 70.0)), green);
let mut group = Node::group(d.alloc_id(), vec![Arc::new(instance), Arc::new(square)]);
group.appearance.effects.push(drop_shadow());
edit_draws_as_new(with_art(d, vec![group]), |d| {
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));
});
// A line stroked with a calligraphic brush, with a drop shadow; the brush gets wider.
let mut d = Document::new(200.0, 100.0);
let mut stroke = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let mut brushed = Node::path(d.alloc_id(), line((20.0, 50.0).into(), (180.0, 50.0).into()), stroke);
brushed.appearance.effects.push(drop_shadow());
edit_draws_as_new(with_art(d, vec![brushed]), |d| {
let mut lib = vectorcraft_brush::library(d);
if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut lib[0].kind {
c.size = 20.0;
}
vectorcraft_brush::store(d, &lib);
});
// A 16 × 16 image drawn 80 × 80, blurred.
edit_draws_as_new(blurred_image_doc("swapped", 104), |d| {
d.images.insert("swapped".into(), ImageBlob::new("image/png", png(16, 16, 105)));
});
}
/// A document showing a 16 × 16 image (`key`, pixels from `seed`) drawn 80 × 80 at (20, 10), under
/// a 1 pt Gaussian Blur.
fn blurred_image_doc(key: &str, seed: u8) -> Document {
let mut d = Document::new(200.0, 100.0);
d.images.insert(key.into(), ImageBlob::new("image/png", png(16, 16, seed)));
let xf = Affine::translate((20.0, 10.0)) * Affine::scale(5.0);
let im = ImageObject { key: key.into(), width: 16, height: 16, xf, link: None, placement: Default::default() };
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
n.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
with_art(d, vec![n])
}
/// Type in a family no font has, 30 pt at (10, 60), with effect `fx`.
fn missing_font_type(d: &mut Document, fx: vectorcraft_doc::Effect) -> Node {
use vectorcraft_doc::text::{CharStyle, TextObject};
let style = CharStyle { size: 30.0, font_family: "No Such Family".into(), ..Default::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(10.0, 60.0), "Hamburg", style))));
t.appearance.effects.push(fx);
t
}
/// Raster effects are made again when a document or layer setting they read changes: the raster
/// resolution under De-Interlace, Page Knockout Group around a blurred group of translucent
/// objects, Document Setup's highlight of substituted fonts under a blur, and the layer's Dim
/// Images under an image's blur.
#[test]
fn rasters_draw_document_and_layer_setting_edits() {
use vectorcraft_geom::shapes::rectangle;
// The path holds ten bars, 1 pt high and 2 pt apart.
let mut d = Document::new(200.0, 100.0);
d.raster_effects_ppi = 72.0;
let mut bars = rectangle(Rect::new(20.0, 20.0, 180.0, 21.0));
for k in 1..10 {
let y = 20.0 + 2.0 * k as f64;
bars.subpaths.extend(rectangle(Rect::new(20.0, y, 180.0, y + 1.0)).subpaths);
}
let striped = filled(&mut d, bars, Paint::solid(Color::BLACK), vec![effect("video.deinterlace", serde_json::json!({}))]);
edit_draws_as_new(with_art(d, vec![striped]), |d| d.raster_effects_ppi = 24.0);
// A blurred group of two overlapping squares at 50% opacity.
let mut d = Document::new(200.0, 100.0);
let mut squares = vec![];
for (x, color) in [(40.0, Color::rgb(1.0, 0.0, 0.0)), (80.0, Color::rgb(0.0, 0.0, 1.0))] {
let mut n = filled(&mut d, rectangle(Rect::new(x, 20.0, x + 60.0, 80.0)), Paint::solid(color), vec![]);
n.opacity = 0.5;
squares.push(Arc::new(n));
}
let mut group = Node::group(d.alloc_id(), squares);
group.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
edit_draws_as_new(with_art(d, vec![group]), |d| d.page_knockout = true);
// Blurred type in a missing family, with the substitution highlights on screen.
let mut d = Document::new(200.0, 100.0);
d.setup.highlight_substituted_fonts = false;
let blurred = missing_font_type(&mut d, effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
let mut d = with_art(d, vec![blurred]);
let highlights = RenderOptions { background: Some([255, 255, 255, 255]), highlight_substitutions: true, ..Default::default() };
// The first lookup of the missing family catalogs the installed fonts, which changes the font
// generation: a frame drawn first does that.
Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &highlights);
draws_as_new(&mut d, &highlights, &highlights, |d| d.setup.highlight_substituted_fonts = true);
// The layer of a blurred image dims its images to 50%.
edit_draws_as_new(blurred_image_doc("dimmed", 106), |d| {
if let NodeKind::Layer { dim_images, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind {
*dim_images = Some(50);
}
});
}
/// Raster effects are made again when a view option their content is drawn with changes: Pixel
/// Preview's hard-edged images under a blur, Anti-aliased Artwork off under a blur, Overprint
/// Preview for overprinting characters in a blurred group, and isolation mode for a group inside a
/// blurred group, entered and left.
#[test]
fn rasters_follow_the_view_options() {
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_geom::shapes::{ellipse, rectangle};
let on_white = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let unchanged = |_: &mut Document| {};
draws_as_new(&mut blurred_image_doc("pixels", 107), &on_white, &RenderOptions { smooth_images: false, ..on_white.clone() }, unchanged);
let mut d = Document::new(200.0, 100.0);
let blur = effect("blur.gaussian", serde_json::json!({"radius": 0.5}));
let oval = filled(&mut d, ellipse(Rect::new(30.0, 10.0, 170.0, 90.0)), Paint::solid(Color::BLACK), vec![blur]);
let hard = RenderOptions { anti_alias: AntiAlias::None, ..on_white.clone() };
draws_as_new(&mut with_art(d, vec![oval]), &on_white, &hard, unchanged);
// A blurred group of a red rectangle and cyan type whose characters overprint.
let mut d = Document::new(200.0, 100.0);
let red = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]);
let style = CharStyle { size: 50.0, fill: Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), overprint_fill: true, ..Default::default() };
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Hamburg", style))));
let mut group = Node::group(d.alloc_id(), vec![Arc::new(red), Arc::new(text)]);
group.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
let overprints = RenderOptions { overprint_preview: true, ..on_white.clone() };
draws_as_new(&mut with_art(d, vec![group]), &on_white, &overprints, unchanged);
// A blurred group of a red square and a group holding a blue square: isolating the inner group
// dims the red square.
let mut d = Document::new(200.0, 100.0);
let red = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), vec![]);
let blue = filled(&mut d, rectangle(Rect::new(110.0, 20.0, 170.0, 80.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), vec![]);
let inner = Node::group(d.alloc_id(), vec![Arc::new(blue)]);
let isolated = RenderOptions { isolated: Some(inner.id), ..on_white.clone() };
let mut outer = Node::group(d.alloc_id(), vec![Arc::new(red), Arc::new(inner)]);
outer.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0})));
let mut d = with_art(d, vec![outer]);
draws_as_new(&mut d, &on_white, &isolated, unchanged);
draws_as_new(&mut d, &isolated, &on_white, unchanged);
}
/// A thumbnail of an object under a filter that treats color channels apart (Color Halftone) is
/// made again when the document's color mode changes: CMYK documents screen ink channels, RGB
/// documents light ones.
#[test]
fn filtered_thumbnails_follow_the_color_mode() {
use vectorcraft_geom::shapes::rectangle;
let mut d = Document::new(200.0, 100.0);
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
let halftone = effect("pixelate.colorHalftone", serde_json::json!({"maxRadius": 4}));
let orange = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 180.0, 80.0)), Paint::solid(Color::rgb(1.0, 0.5, 0.0)), vec![halftone]);
let id = orange.id;
let mut d = with_art(d, vec![orange]);
let mut r = Renderer::new();
let before = r.render_thumbnail(&d, id, 128).unwrap().pixels;
d.color_mode = vectorcraft_doc::ColorMode::Rgb;
let after = r.render_thumbnail(&d, id, 128).unwrap().pixels;
assert!(after != before, "the color mode changes the thumbnail");
assert!(after == Renderer::new().render_thumbnail(&d, id, 128).unwrap().pixels, "a new renderer draws the same");
}
/// Symbol instances draw what groups of their art, stained and placed, draw: in every frame, when
/// two instances placed alike are stained differently, when the art is a single path, and with a
/// cache too small for all of the art. Art no longer drawn leaves the cache after a few frames.
+2
View File
@@ -515,6 +515,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **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.
- **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.
- **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