Merge pull request #1688 from dotCooCoo/feat/live-effects-follow-document-edits

Render: live objects are evaluated again when patterns, symbols, fonts or color settings change
This commit is contained in:
BFlat authored and GitHub committed 2026-10-14 05:03:40 +07:00
commit aeda9de044
9 files changed
+559 -41

No files matched your search

+66 -1
View File
@@ -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");
}
}
+31 -7
View File
@@ -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<Node>, cache: bool) -> Arc<Node> {
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<DocInputs> {
pub(crate) 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
+5 -1
View File
@@ -364,6 +364,10 @@ struct GeomEntry {
type ClipEntry = (Arc<Node>, Option<Arc<(BezPath, FillRule)>>);
/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]).
type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
/// 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<Node>, Arc<Node>, Option<live::Found>);
/// 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<usize, (Arc<Node>, Arc<Node>)>,
fx_arts: FrameCache<usize, FxArtEntry>,
/// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]).
placed_arts: FrameCache<PlacedKey, PlacedEntry>,
/// Layers open around the drawing point in the current context: a non-isolated group can copy
+194 -32
View File
@@ -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<Vec<Arc<Node>>>;
type Quads = Arc<Vec<MeshQuad>>;
/// 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<DocInputs>, Vec<Weak<ReadDocument>>);
/// A mesh node, its quads, and the [`DocInputs`] whose color settings colored them.
type MeshEntry = (Arc<Node>, Quads, Arc<DocInputs>);
/// 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<usize, (Arc<Node>, Expanded, u64)>,
meshes: FrameCache<(usize, usize), (Arc<Node>, Quads)>,
expanded: FrameCache<usize, (Arc<Node>, Expanded, Found)>,
meshes: FrameCache<(usize, usize), MeshEntry>,
}
impl Default for LiveCache {
@@ -57,27 +67,95 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
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<Weak<ReadDocument>>) {
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<usize>,
/// Whether type or a placed file was found.
text: bool,
/// Weak references to the reads of the placed files found.
reads: Vec<Weak<ReadDocument>>,
}
/// 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<usize>, 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<Node>, 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<Node>, m: &GradientMesh, level: usize, cache: bool) -> Arc<Vec<MeshQuad>> {
/// 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<Node>, m: &GradientMesh, level: usize, cache: bool) -> Arc<Vec<MeshQuad>> {
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::<MeshQuad>();
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");
}
}
+93
View File
@@ -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");
}
}
+118
View File
@@ -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<Node>, 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 }
}
@@ -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");
+3
View File
@@ -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
+2
View File
@@ -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