From b9dbd867f6267852b95246d1b858a1c04ffdde48 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Sun, 8 Nov 2026 20:12:58 -0700 Subject: [PATCH 01/14] Canvas: a 3D effect changed on type or a group redraws where its solid was MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an edit changes an object, the canvas draws again only the area the object painted before and after it, measured by its cull bounds. Type and groups with Extrude & Bevel, Inflate or Rotate were measured by their letters or members, not by the solid the effect draws, which reaches well past them. Making the solid shallower or turning it left pieces of the old solid on the canvas outside that area until the view was drawn again. An object with a 3D effect is now measured by the effect's art, as the renderer draws it: with its geometry effects, each object in it with the raster effects of its own fills and strokes and the art its brushed strokes draw, grown by the reach of the object's own raster effects. One function measures that art for every bounds the renderer and exports take, where three copies of it disagreed. - The renderer measures the art it draws. What it makes to measure an object it holds until the frame ends, with what the art read from outside the object (fonts, symbols, color settings), and keeps it with the art it draws if it draws the object while that is unchanged. Art measured but not drawn (culled, off the view) isn't kept. It holds at most as much as it keeps for the art it draws (64 MB); past that, an object is made again to draw it. An object whose 3D effect builds nothing (Revolve on a group, a solid too complex) is measured as it is drawn otherwise, as without a renderer. FrameStats::three_d_built counts the 3D art the renderer makes. - The canvas measures what an edit changed through the renderer that drew the frame shown (Renderer::changed). When it draws the part on its own thread, it draws it with the art it made to measure it; when the render worker draws the frame, or nothing in view changed, that art is let go. Under Overprint Preview or Separations Preview, which draw copies of the objects, the edit is measured afresh. An object the frame doesn't draw (hidden, in a hidden group or layer, or hidden by the view) is measured as nothing, before and after, so an edit to it builds no solid: changed() without a renderer counts it as nothing too, where it measured it. The canvas measures the edit before it knows where the frame is drawn: when the worker draws it, the solids it built to measure it are built again there. - Fit to Artwork Bounds, the Artboards panel's check for it and per-artboard SVG export measure every artboard through one renderer (Renderer::art_bounds_on, Renderer::effect_bounds), which keeps each object's bounds for the pass, a group holding 3D art or a hidden member included, so each solid is built once: 30 extruded type objects over 30 artboards take about 2 ms, where measuring their letters took under 1 ms and building each solid for each artboard would take 70 ms. Objects with brushed strokes, which culling leaves room for and exports don't, are measured on each artboard. The Artboard tool's canvas menu keeps whether Fit to Artwork Bounds has art to fit for the document's revision, where it measured it on every frame it was open. - Other exports, Object › Rasterize and changed() without a renderer build the art on each call, as 3D paths and groups already did. Exports and Rasterize now take the solid of 3D type, where they cropped it to the letters, and the art its brushed strokes draw, not the room culling leaves for it. Rasterize on drawn 3D art measures it once while the art is kept. - A shadow after a 3D effect, on the object or on a fill after the fill's own 3D effect, reaches past the solid once; it was counted twice. - With Crop Marks or Vector Halftone, which draw other art, the object is measured by that art, as before. - Culling leaves room for a 3D object's brushed strokes as for any object's, and the renderer measures a 3D object again when the brush library changes: a brush widened past that room would leave the object culled. A document's first frame builds the solid of every 3D object, those off the view too, to know whether it reaches into the view: 200 extruded words with 150 off the view take about 12 ms, where measuring their letters took 6 ms. Type whose object effects leave it as it is (Revolve builds no art from type) is measured by its own box grown to hold its glyphs, as plain type is; it was measured by its box alone, which needn't hold them. --- crates/engine/src/cmd/fileio/export.rs | 22 +- crates/engine/src/cmd/fileio/svg.rs | 4 +- crates/engine/src/cmd/layer.rs | 4 +- crates/render/src/bounds.rs | 33 +- crates/render/src/brush_fx.rs | 7 + crates/render/src/changed.rs | 96 ++++- crates/render/src/encode.rs | 47 ++- crates/render/src/frame.rs | 6 + crates/render/src/fx.rs | 2 +- crates/render/src/fx/bounds.rs | 93 +++-- crates/render/src/fx/renderer.rs | 116 +++++- crates/render/src/fx/tests.rs | 520 +++++++++++++++++++++++++ crates/render/src/lib.rs | 30 ++ crates/render/src/lru.rs | 4 + crates/render/src/renderer.rs | 9 + crates/render/src/renderer/nodes.rs | 38 +- crates/render/src/tests/changed.rs | 128 +++++- crates/render/src/tests/fontchange.rs | 31 ++ crates/ui-egui/src/caches.rs | 2 +- crates/ui-egui/src/canvas.rs | 7 + crates/ui-egui/src/canvas/raster.rs | 6 +- crates/ui-egui/src/canvas/tests.rs | 50 +++ crates/ui-egui/src/panels/artboards.rs | 19 +- docs/target-app-parity.md | 4 +- 24 files changed, 1200 insertions(+), 78 deletions(-) diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 0869ad850a..a004fad895 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -126,13 +126,20 @@ fn selected_part(n: &Node, keep: &HashSet) -> Option { /// holds a symbol instance also stays when what the canvas paints for it reaches `rect` /// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A /// clipping layer keeps every object, its clipping path among them. An artboard's file then holds -/// its own art, not a whole document of artboards. -pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document { - fn kept(n: &Arc, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option> { +/// its own art, not a whole document of artboards. `renderer` measures the 3D art, and keeps it for +/// the next artboard of the same document. +pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect, renderer: &mut vectorcraft_render::Renderer) -> Document { + fn kept( + n: &Arc, + doc: &Document, + symbols: &SymbolBoxes, + rect: vectorcraft_geom::Rect, + renderer: &mut vectorcraft_render::Renderer, + ) -> Option> { if matches!(n.kind, NodeKind::Layer { clip: false, .. }) { let mut layer = Node::clone(n); let children = layer.children_mut()?; - *children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect(); + *children = children.iter().filter_map(|c| kept(c, doc, symbols, rect, renderer)).collect(); return Some(Arc::new(layer)); } // A group that doesn't clip keeps only its members over the artboard, so art gathered in one @@ -141,19 +148,18 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document if matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.appearance.effects.is_empty() { let mut group = Node::clone(n); let children = group.children_mut()?; - *children = children.iter().filter_map(|c| kept(c, doc, symbols, rect)).collect(); + *children = children.iter().filter_map(|c| kept(c, doc, symbols, rect, renderer)).collect(); return (!children.is_empty()).then(|| Arc::new(group)); } let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1; let mut holds_instance = false; n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. })); - let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over); let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over); - (n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone()) + (n.visual_bounds_with(symbols).is_none_or(over) || renderer.effect_bounds(symbols, n).is_some_and(over) || painted()).then(|| n.clone()) } let symbols = doc.symbol_boxes().with_brush_art(); let mut d = doc.clone(); - d.layers = doc.layers.iter().filter_map(|l| kept(l, doc, &symbols, rect)).collect(); + d.layers = doc.layers.iter().filter_map(|l| kept(l, doc, &symbols, rect, renderer)).collect(); d } diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 56bfd0f7b6..3123fdcfb1 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -180,9 +180,11 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result Result { super::panelcmds::refuse_locked(d, &indices)?; } let mut fits = vec![]; + // One renderer measures every artboard: each 3D solid is built once. + let mut renderer = vectorcraft_render::Renderer::new(); for i in unlocked { let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; - if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) { + if let Some(b) = renderer.art_bounds_on(d, was) { // At least 1 pt a side, as `rect_from` makes Artboard Options' sizes. fits.push((i, was, Rect::new(b.x0, b.y0, b.x0 + b.width().max(1.0), b.y0 + b.height().max(1.0)))); } diff --git a/crates/render/src/bounds.rs b/crates/render/src/bounds.rs index 5cb622e5dc..c3aaf98322 100644 --- a/crates/render/src/bounds.rs +++ b/crates/render/src/bounds.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use super::{BezPath, Document, FillRule, Node, NodeKind, Rect, SymbolBoxes, brush_fx, fx}; /// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and @@ -13,12 +15,39 @@ pub fn painted_bounds(doc: &Document, n: &Node) -> Option { /// out the transparent source art of a Shaper group as [`Node::visual_bounds_with`] does. Symbol /// instances are measured by their art in `symbols`. pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + effect_bounds_with(symbols, n, None, None) +} + +impl crate::Renderer { + /// [`effect_bounds`] of `n`, its 3D art measured as this renderer draws and keeps it + /// (`Renderer::bounds_of`): a pass that measures the same objects again, over each + /// artboard of an export, builds each solid once. What it keeps is measured with the symbol + /// boxes it was first measured with, and found by the objects' addresses: give each document, + /// and each kind of `symbols`, its own renderer. + pub fn effect_bounds(&mut self, symbols: &SymbolBoxes, n: &Arc) -> Option { + effect_bounds_with(symbols, n, Some(n), Some(self)) + } +} + +/// [`effect_bounds`] of `n` (`held`: `n` as held in its document, which `renderer` keeps what it +/// measures by). Through `renderer`, an object is measured once for every artboard it measures: +/// those it measures as [`effect_bounds`] does (`Renderer::bounds_of`), those whose +/// brushed strokes it leaves room for afresh. +pub(crate) fn effect_bounds_with( + symbols: &SymbolBoxes, + n: &Node, + held: Option<&Arc>, + mut renderer: Option<&mut crate::Renderer>, +) -> Option { match &n.kind { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children .iter() .skip(usize::from(n.extras().shaper.is_some())) - .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, effect_bounds(symbols, c))), - _ => fx::cull_bounds(symbols, n), + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, effect_bounds_with(symbols, c, Some(c), renderer.as_deref_mut()))), + _ => match (renderer, held) { + (Some(r), Some(a)) if brush_fx::pad(symbols, a) == 0.0 => r.bounds_of(symbols, a), + _ => fx::cull_bounds(symbols, n), + }, } } diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index f412d7249c..2b724f2805 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -115,6 +115,13 @@ impl BrushCache { self.library(doc, stamp); self.key } + + /// [`Self::library_key`] checked now, between frames: `doc` may be the document of the last + /// frame, edited in place. + pub(crate) fn library_key_now(&mut self, doc: &Document, stamp: u64) -> u64 { + self.checked = (u64::MAX, 0); + self.library_key(doc, stamp) + } } /// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs), symbol diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs index 01fd6da015..9d57b7431a 100644 --- a/crates/render/src/changed.rs +++ b/crates/render/src/changed.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use std::sync::Arc; -use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, SymbolBoxes}; use vectorcraft_geom::{Affine, Rect}; /// What may draw differently in a document than in its version before an edit ([`changed`]). @@ -82,10 +82,43 @@ fn add_placed(symbols: &SymbolBoxes, n: &Node, files: &HashSet, area: &m /// art also draws in the copies round it). Objects that an edit doesn't touch /// keep their identity, so this costs what changed, not the size of the document. pub fn changed(old: &Document, new: &Document) -> Changed { + changed_with(old, new, None, &[]) +} + +impl crate::Renderer { + /// [`changed`], measured through this renderer (`Renderer::bounds_of`): what it drew + /// of `old` it measured to draw it, and what it measures of `new`, a 3D effect's art included, + /// it holds to draw that with `opts`; the objects `opts` draws hidden count as hidden objects + /// do, drawing nothing. The view that drew `old` with this renderer and `opts` asks this. With + /// Overprint Preview or Separations Preview the renderer draws copies of the objects + /// (`proof::prepare`), so they are measured afresh and nothing is held. + pub fn changed(&mut self, old: &Document, new: &Document, opts: &crate::RenderOptions) -> Changed { + let renderer = (!crate::proof::overprints(opts)).then_some(self); + changed_with(old, new, renderer, &opts.hidden) + } + + /// Let go of the art made to measure objects ([`crate::Renderer::changed`]) and held for this + /// renderer to draw them: another one draws the frame. + pub fn let_go_of_measured(&mut self) { + self.measured.clear(); + self.measured_bytes = 0; + self.pass_bounds.clear(); + self.three_d_bounds.retain(|_, e| e.0.strong_count() > 0); + self.three_d_kept = self.three_d_bounds.len(); + } + + /// The bytes of the art made to measure objects that this renderer holds to draw them + /// ([`crate::Renderer::changed`]). + pub fn measured_bytes(&self) -> usize { + self.measured_bytes + } +} + +fn changed_with(old: &Document, new: &Document, renderer: Option<&mut crate::Renderer>, hidden: &[NodeId]) -> Changed { if new.pattern_edit.is_some() || !old.same_outside_layers(new) { return Changed::Everything; } - let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None }; + let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None, renderer, hidden }; diff.children(&old.layers, &new.layers); diff.area.map_or(Changed::Nothing, Changed::Area) } @@ -102,14 +135,17 @@ pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> (x1 > x0 && y1 > y0).then_some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) } -/// [`changed`] in progress: the symbols' boxes of both versions, and the area so far. -struct Diff<'a> { +/// [`changed`] in progress: the symbols' boxes of both versions, the area so far, the renderer +/// that measures, if any (else each object is measured afresh), and the objects it draws hidden. +struct Diff<'a, 'r> { old: SymbolBoxes<'a>, new: SymbolBoxes<'a>, area: Option, + renderer: Option<&'r mut crate::Renderer>, + hidden: &'r [NodeId], } -impl Diff<'_> { +impl Diff<'_, '_> { /// Members in the same order compare one by one. Members added, removed or restacked count /// before and after, except those that kept their place unchanged. fn children(&mut self, old: &[Arc], new: &[Arc]) { @@ -123,8 +159,8 @@ impl Diff<'_> { match (old.get(i), new.get(i)) { (Some(a), Some(b)) if Arc::ptr_eq(a, b) => {} (a, b) => { - self.add_old(a.map(|a| &**a)); - self.add_new(b.map(|b| &**b)); + self.add_old(a); + self.add_new(b); } } } @@ -135,11 +171,13 @@ impl Diff<'_> { /// only had anchors moved changed around them ([`moved_anchors`]). Any other object that /// changed counts whole, before and after. fn node(&mut self, old: &Arc, new: &Arc) { - if Arc::ptr_eq(old, new) { + if Arc::ptr_eq(old, new) || self.unseen(old) && self.unseen(new) { return; } + // An Image Trace object draws its hidden source image in some views: it changed whole. if let (Some(a), Some(b)) = (members(old), members(new)) && same_but_members(old, new) + && old.extras().trace.is_none() { self.children(a, b); } else if let Some((area, moved, anchors)) = moved_anchors(old, new) { @@ -148,7 +186,7 @@ impl Diff<'_> { } else { // The object's box before and after holds every pixel that changed too, and so // does where the two overlap. - let whole = vectorcraft_geom::union_opt(crate::brush_fx::cull_bounds(&self.old, old), crate::brush_fx::cull_bounds(&self.new, new)); + let whole = vectorcraft_geom::union_opt(self.bounds(Side::Old, old), self.bounds(Side::New, new)); self.add(area.zip(whole).map(|(a, w)| a.intersect(w))); } } else { @@ -157,12 +195,37 @@ impl Diff<'_> { } } - fn add_old(&mut self, n: Option<&Node>) { - self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.old, n))); + /// Does `n` draw nothing: hidden, or drawn hidden? The members of a hidden group or layer are + /// never compared ([`Self::node`]). + fn unseen(&self, n: &Node) -> bool { + !n.visible || self.hidden.contains(&n.id) } - fn add_new(&mut self, n: Option<&Node>) { - self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.new, n))); + /// The cull bounds of `n` of the `side` version, through the renderer when there is one; + /// `None` when it draws nothing ([`Self::unseen`]), as nothing is measured to draw it. + fn bounds(&mut self, side: Side, n: &Arc) -> Option { + if self.unseen(n) { + return None; + } + let Diff { old, new, renderer, .. } = self; + let symbols = match side { + Side::Old => old, + Side::New => new, + }; + match renderer { + Some(r) => r.bounds_of(symbols, n), + None => crate::brush_fx::cull_bounds(symbols, n), + } + } + + fn add_old(&mut self, n: Option<&Arc>) { + let b = n.and_then(|n| self.bounds(Side::Old, n)); + self.add(b); + } + + fn add_new(&mut self, n: Option<&Arc>) { + let b = n.and_then(|n| self.bounds(Side::New, n)); + self.add(b); } fn add(&mut self, r: Option) { @@ -170,6 +233,13 @@ impl Diff<'_> { } } +/// Which version of the document [`Diff`] measures an object of. +#[derive(Clone, Copy)] +enum Side { + Old, + New, +} + /// The members of a group or layer whose members draw each on their own: no clipping mask, and no /// effects of its own. fn members(n: &Node) -> Option<&[Arc]> { diff --git a/crates/render/src/encode.rs b/crates/render/src/encode.rs index db5e3b74ff..201931a8ac 100644 --- a/crates/render/src/encode.rs +++ b/crates/render/src/encode.rs @@ -205,18 +205,32 @@ pub fn art_bounds(doc: &Document) -> Option { /// object of a layer or sublayer whose drawn bounds overlap `rect`, with all of its bounds; a group /// or a clipping layer counts as one object. `None` when no object overlaps `rect`. pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option { + art_bounds_on_with(doc, rect, None) +} + +impl crate::Renderer { + /// [`art_bounds_on`], its 3D art measured as this renderer draws and keeps it + /// (`Renderer::bounds_of`): a pass over each artboard builds each solid once. What it + /// keeps is measured with the document's symbol boxes as it first measured them, and found by + /// the objects' addresses: give each document its own renderer. + pub fn art_bounds_on(&mut self, doc: &Document, rect: Rect) -> Option { + art_bounds_on_with(doc, rect, Some(self)) + } +} + +fn art_bounds_on_with(doc: &Document, rect: Rect, mut renderer: Option<&mut crate::Renderer>) -> Option { // Art flush with an edge from outside can overlap it by a rounding error (#1295). let rect = rect.inflate(-1e-6, -1e-6); - fn on(symbols: &SymbolBoxes, n: &Node, rect: Rect) -> Option { + fn on(symbols: &SymbolBoxes, n: &Arc, rect: Rect, mut renderer: Option<&mut crate::Renderer>) -> Option { match &n.kind { NodeKind::Layer { template: false, clip: false, children, .. } if n.visible => { - children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, on(symbols, c, rect))) + children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, on(symbols, c, rect, renderer.as_deref_mut()))) } - _ => drawn_bounds(symbols, n).filter(|b| b.x0 < rect.x1 && b.x1 > rect.x0 && b.y0 < rect.y1 && b.y1 > rect.y0), + _ => drawn_bounds_with(symbols, n, Some(n), renderer).filter(|b| b.x0 < rect.x1 && b.x1 > rect.x0 && b.y0 < rect.y1 && b.y1 > rect.y0), } } let symbols = doc.symbol_boxes(); - doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect))) + doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, on(&symbols, l, rect, renderer.as_deref_mut()))) } /// Bounds of what an export draws of object `n` ([`art_bounds`]): `None` when `n` is hidden, a @@ -224,6 +238,12 @@ pub fn art_bounds_on(doc: &Document, rect: Rect) -> Option { /// except in a group or layer with effects of its own that keep all its members (`without_undrawn` /// lists them). Symbol instances are measured by their art in `symbols`. pub fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + drawn_bounds_with(symbols, n, None, None) +} + +/// [`drawn_bounds`] of `n` (`held`: `n` as held in its document, which `renderer` keeps what it +/// measures by). +fn drawn_bounds_with(symbols: &SymbolBoxes, n: &Node, held: Option<&Arc>, mut renderer: Option<&mut crate::Renderer>) -> Option { if !n.visible { return None; } @@ -232,8 +252,23 @@ pub fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children .iter() .skip(usize::from(n.extras().shaper.is_some())) - .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c))), - _ => crate::effect_bounds(symbols, without_undrawn(n).as_ref().unwrap_or(n)), + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds_with(symbols, c, Some(c), renderer.as_deref_mut()))), + _ => match (without_undrawn(n), held, renderer) { + // A new object, measured once a pass by the one it was made from. + (Some(kept), Some(a), Some(r)) => { + let key = Arc::as_ptr(a) as usize; + if let Some((node, b)) = r.pass_bounds.get(&key) + && Arc::ptr_eq(node, a) + { + return *b; + } + let b = crate::bounds::effect_bounds_with(symbols, &kept, None, Some(r)); + r.pass_bounds.insert(key, (a.clone(), b)); + b + } + (Some(kept), _, renderer) => crate::bounds::effect_bounds_with(symbols, &kept, None, renderer), + (None, held, renderer) => crate::bounds::effect_bounds_with(symbols, n, held, renderer), + }, } } diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index 326a8c18e9..e16f134aca 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -174,6 +174,12 @@ pub struct FrameStats { /// ([`Renderer::draw_type_edges`]). Type drawn from the pixels an earlier frame kept is not /// counted. pub type_edges: usize, + /// The art of 3D effects (Revolve, Extrude & Bevel, Inflate, Rotate) this renderer made for the + /// objects it draws or measures itself (`Renderer::bounds_of`) since its last frame + /// began: none it found kept, and none made by measures without a renderer (the free bounds + /// functions), which this renderer doesn't see. A view that measured what changed through the + /// renderer ([`crate::Renderer::changed`]) made the art then, and draws it with none made. + pub three_d_built: usize, pub micros: u64, } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index bb944559e5..a337616329 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -70,7 +70,7 @@ mod renderer; #[cfg(test)] pub(crate) use bounds::visual_bounds; -pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds}; +pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds, three_d_art_bounds, with_own_raster_reach}; #[cfg(test)] pub(crate) use entries::Drawn; pub(crate) use entries::{DocInputs, FxGeom, Inputs, Reads, ShadowEntry}; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 8ad62cf4ce..9bc9ba7565 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -57,12 +57,6 @@ pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] { /// Visual bounds including geometry effects, stroke outsets and shadows/glows (symbol instances /// measured by their art from `symbols`). pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { - if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { - return cull_bounds(symbols, &art).map(|b| { - let o = outset(symbols, &n.appearance.effects, b); - b.inflate(o, o) - }); - } if let Some(art) = painted_art(symbols, n) { return art.visual_bounds_with(symbols).map(|r| with_raster_reach(symbols, n, r)); } @@ -109,6 +103,10 @@ fn paints(item: &AppearanceItem) -> bool { /// [`Node::visual_bounds_with`] measures them, without the reach of `n`'s own raster effects /// (symbol instances measured by their art from `symbols`): what Rasterize makes its image over. pub(crate) fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + // A 3D effect's art holds its fills' and strokes' raster effects, measured with it. + if let Some(b) = three_d_bounds(symbols, n) { + return b; + } let b = art_bounds(symbols, n)?; let items = n.appearance.items.iter().filter(|i| paints(i) && !effects::raster_effects(item_effects(i)).is_empty()); Some(items.map(|i| spread(symbols, item_effects(i), item_bounds(symbols.document(), n, i).unwrap_or(b))).fold(b, |acc, r| acc.union(r))) @@ -168,13 +166,6 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) }; match &n.kind { - // A group's or layer's 3D effect: the art of its members' solids. - NodeKind::Layer { .. } | NodeKind::Group { clip: false, .. } - if effects::has_solid(n) - && let Some(art) = effects::solid_art(n) => - { - drawn_bounds(symbols, &art) - } _ if effects::has_container_appearance(n) => { let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); @@ -182,10 +173,6 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { } NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. } => union(n), _ if has_fx(n) => { - // A 3D effect's art. - if let Some(art) = effects::revolve_art(n).or_else(|| effects::solid_art(n)) { - return drawn_bounds(symbols, &art); - } if let Some(art) = painted_art(symbols, n) { return art.visual_bounds_with(symbols); } @@ -221,7 +208,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { Some(r) => r.visual_bounds_with(symbols), None => None, } - .or_else(|| n.visual_bounds_with(symbols))?; + .or_else(|| own_box(symbols, n))?; let a = match &reshaped { Some(r) => reshaped_art_effects_reach(symbols, n, r), None => art_effects_reach(symbols, n), @@ -385,6 +372,13 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio let reach = effects::addons::halftone::reach(n); inner(&effects::addons::halftone::split(n).0).map(|b| b.inflate(reach, reach)) } + // A 3D effect's art, as the renderer draws it, and the raster effects' reach: the solid of + // type or of a group's members reaches past the letters or members the arms below measure. + _ if (effects::has_revolve(n) || effects::has_solid(n)) + && let Some(b) = three_d_bounds(symbols, n) => + { + b.map(|b| with_own_raster_reach(symbols, n, b)) + } // The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach. _ if effects::has_container_appearance(n) => { let art = effects::evaluate_container(Some(symbols.document()), n); @@ -394,8 +388,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio } else { art.children()?.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) }?; - let o = outset(symbols, &n.appearance.effects, b); - Some(b.inflate(o, o)) + Some(with_own_raster_reach(symbols, n, b)) } NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) @@ -418,15 +411,14 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio Some(r) => r.visual_bounds_with(symbols), None => None, } - .or_else(|| n.visual_bounds_with(symbols))?; + .or_else(|| own_box(symbols, n))?; let a = match &reshaped { Some(r) if after_reshape => reshaped_art_effects_reach(symbols, n, r), _ => art_effects_reach(symbols, n), }; let a = a.max(live_content_reach(symbols, n)); let b = b.inflate(a, a); - let o = outset(symbols, &n.appearance.effects, b); - Some(b.inflate(o, o)) + Some(with_own_raster_reach(symbols, n, b)) } NodeKind::SymbolInstance { .. } => { let b = n.visual_bounds_with(symbols)?; @@ -445,6 +437,61 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio } } +/// The bounds of the art a 3D effect of `n` draws (Revolve, Extrude & Bevel, Inflate, Rotate), +/// built now, without `n`'s own raster effects ([`three_d_art_bounds`]): `None` when it draws no +/// such art, `Some(None)` when that art paints nothing. The renderer measures the art it keeps +/// instead ([`crate::Renderer::bounds_of`]). +fn three_d_bounds(symbols: &SymbolBoxes, n: &Node) -> Option> { + if !effects::has_revolve(n) && !effects::has_solid(n) { + return None; + } + let art = effects::revolve_art(n).or_else(|| effects::solid_art(n))?; + Some(three_d_art_bounds(symbols, &art)) +} + +/// The bounds of `art`, the art a 3D effect draws for an object, as the renderer draws it: with +/// its geometry effects, without the raster effects the object paints over it (which +/// [`with_own_raster_reach`] adds), and each object in it with the raster effects of its own fills +/// and strokes and the art of its brushed strokes (a stroke without a 3D effect of its own keeps +/// its brush): the art itself, which Rasterize and exports take, not the room culling leaves. +pub(crate) fn three_d_art_bounds(symbols: &SymbolBoxes, art: &Node) -> Option { + fn pieces(symbols: &SymbolBoxes, n: &Node) -> Option { + match &n.kind { + NodeKind::Group { children, clip: false } if !has_object_fx(n) => { + children.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, pieces(symbols, c))) + } + // The art its brushed strokes draw, not the room culling leaves for it. + _ => { + let brushed = vectorcraft_brush::has_brush(n) + .then(|| vectorcraft_brush::expand(symbols.document(), n)) + .flatten() + .and_then(|art| art.visual_bounds_with(symbols)); + vectorcraft_geom::union_opt(cull_bounds(symbols, n), brushed) + } + } + } + let mut art = art.clone(); + art.appearance.effects.retain(|e| effects::is_geometry(&e.id)); + let art = effects::evaluate_container(Some(symbols.document()), &art).unwrap_or(art); + pieces(symbols, &art) +} + +/// `b`, the bounds of what `n` draws, grown by how far `n`'s own raster effects paint past it. +pub(crate) fn with_own_raster_reach(symbols: &SymbolBoxes, n: &Node, b: Rect) -> Rect { + let o = outset(symbols, &n.appearance.effects, b); + b.inflate(o, o) +} + +/// `n`'s own visual box, type's grown to hold its glyphs ([`glyphs_box`]): what it draws when its +/// object effects leave it as it is (Revolve builds no art from type). +fn own_box(symbols: &SymbolBoxes, n: &Node) -> Option { + let b = n.visual_bounds_with(symbols)?; + Some(match &n.kind { + NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), + _ => b, + }) +} + /// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its /// characters' strokes, miter spikes included, and by `outset`: on a path they stand above it and /// run past its ends, in an area they can hang past its frame, where its own box diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index ee09114643..8477662b72 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -6,8 +6,8 @@ use super::raster::{ shade_filter, shadow_filter, silhouette, }; use super::{ - Affine, Arc, BezPath, FillRule, Frame, GeomContext, Ink, Node, NodeKind, RasterFx, Rect, RenderContext, Renderer, Shape, Vec2, blend_mode, - effects, fill_rule, peniko, + Affine, Arc, BezPath, FillRule, Frame, GeomContext, Ink, Node, NodeKind, RasterFx, Rect, RenderContext, Renderer, Shape, SymbolBoxes, Vec2, + blend_mode, effects, fill_rule, peniko, }; #[cfg(test)] @@ -437,7 +437,13 @@ impl Renderer { let region = if clipped { self.clip_of(&art) } else { None }; // 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 bounds = rasterize_bounds(f.symbols, a, &rfx); + // A 3D effect's art is in hand: Rasterize takes its bounds, measured once while it is + // kept, without building it again. + let rasterizes = rfx.iter().any(|x| matches!(x, RasterFx::Rasterize(_))); + let bounds = match rasterizes.then(|| self.three_d_art_bounds(f.symbols, &art)).flatten() { + Some(b) => b, + None => rasterize_bounds(f.symbols, a, &rfx), + }; let reach = bounds.map_or(reach, |b| reach.union(b)); let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)), bounds, kept: None }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_fx_art(c, fr, a, &art)); @@ -487,6 +493,91 @@ impl Renderer { { return art; } + // Made to measure it ([`Self::measured_fx_art`]): taken out, and kept in `fx_arts` from now + // on if made from what it reads now, else made again. + let measured = self.measured.remove(&key).filter(|m| Arc::ptr_eq(&m.0, a)).map(|m| { + self.measured_bytes = self.measured_bytes.saturating_sub(crate::lru::node_bytes(&m.1)); + (m.1, m.2) + }); + let art = match measured { + Some((art, was)) => { + let now = match &found { + Some(f) => Some(f.0), + None => self.outside_key(doc, a), + }; + if was == now { art } else { self.make_fx_art(doc, a) } + } + None => self.make_fx_art(doc, a), + }; + if cache { + let bytes = crate::lru::node_bytes(&art); + self.fx_arts.insert(key, (a.clone(), art.clone(), found), bytes, self.stamp); + } + art + } + + /// The art [`Self::fx_art`] draws for `a`, to measure it: the art kept for it, else made now and + /// held until the frame ends ([`Renderer::measured`]), for [`Self::fx_art`] to keep if `a` is + /// drawn. What is measured but never drawn (culled, off the view) is let go, and isn't kept in + /// place of what is drawn. + pub(crate) fn measured_fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc) -> Arc { + let key = Arc::as_ptr(a) as usize; + if let Some((node, art, found)) = self.fx_arts.get(&key, self.stamp) + && Arc::ptr_eq(node, a) + { + let (art, was) = (art.clone(), found.as_ref().map(|f| f.0)); + if was.is_none_or(|was| self.live_found(doc, a).0 == was) { + return art; + } + } + let now = self.outside_key(doc, a); + if let Some((node, art, was)) = self.measured.get(&key) + && Arc::ptr_eq(node, a) + && *was == now + { + return art.clone(); + } + let art = self.make_fx_art(doc, a); + // Art held for an object before (made from what it read then) gives way. + if let Some(old) = self.measured.remove(&key) { + self.measured_bytes = self.measured_bytes.saturating_sub(crate::lru::node_bytes(&old.1)); + } + let bytes = crate::lru::node_bytes(&art); + // Within a budget: past it, what is measured is made again to draw it. + if self.measured_bytes + bytes <= self.measured_budget { + self.measured_bytes += bytes; + self.measured.insert(key, (a.clone(), art.clone(), now)); + } + art + } + + /// The bounds of `art`, art [`Self::fx_art`] made for an object, when it is a 3D effect's art + /// ([`super::three_d_art_bounds`]), measured once while it is kept; `None` for any other art. + pub(crate) fn three_d_art_bounds(&mut self, symbols: &SymbolBoxes, art: &Arc) -> Option> { + let key = Arc::as_ptr(art) as usize; + // Raster effects in the art spread by the resolution; brushed strokes in it draw from the + // brushes. + let at = (super::raster::doc_raster_ppi(symbols.document()).to_bits(), self.brushes.library_key(symbols.document(), self.stamp)); + let (kept, measured) = + self.three_d_bounds.get(&key).filter(|e| std::ptr::eq(e.0.as_ptr(), Arc::as_ptr(art))).map(|e| (true, e.1.filter(|m| m.0 == at)))?; + if let (true, Some((_, b))) = (kept, measured) { + return Some(b); + } + let b = super::three_d_art_bounds(symbols, art); + self.three_d_bounds.insert(key, (Arc::downgrade(art), Some((at, b)))); + Some(b) + } + + /// The key of what `a`'s art reads from outside it ([`Self::live_found`]), `None` when it reads + /// nothing. + fn outside_key(&mut self, doc: &vectorcraft_doc::Document, a: &Arc) -> Option { + (evaluates_live(a) || reads_outside(a)).then(|| self.live_found(doc, a).0) + } + + /// The art [`Self::fx_art`] draws for `a`, made now. + fn make_fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc) -> Arc { + // Whether the art is a 3D effect's ([`Self::three_d_art_bounds`]). + let mut three_d = false; let symbol = match &a.kind { NodeKind::SymbolInstance { symbol, .. } => { doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a)) @@ -496,8 +587,12 @@ impl Renderer { let container = is_container(a); let art = match effects::crop_marks_art(a) .or_else(|| crate::addons::halftone::art(doc, a)) - .or_else(|| effects::revolve_art(a)) - .or_else(|| effects::solid_art(a)) + .or_else(|| { + let art = effects::revolve_art(a).or_else(|| effects::solid_art(a)); + self.stats.three_d_built += usize::from(art.is_some()); + three_d |= art.is_some(); + art + }) .or_else(|| effects::reshape(Some(doc), a, symbol.as_ref())) { Some(r) => r, @@ -520,9 +615,14 @@ impl Renderer { // knockout setting says). art.knockout = if container { a.knockout } else { vectorcraft_doc::Knockout::Off }; let art = Arc::new(art); - if cache { - let bytes = crate::lru::node_bytes(&art); - self.fx_arts.insert(key, (a.clone(), art.clone(), found), bytes, self.stamp); + if three_d { + // A renderer that only measures (an export, Fit to Artwork Bounds) ends no frame: what + // it no longer holds is let go here too. + if self.three_d_bounds.len() >= (2 * self.three_d_kept).max(1024) { + self.three_d_bounds.retain(|_, e| e.0.strong_count() > 0); + self.three_d_kept = self.three_d_bounds.len(); + } + self.three_d_bounds.insert(Arc::as_ptr(&art) as usize, (Arc::downgrade(&art), None)); } art } diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index a2b8473ac1..7e4802f3a7 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use serde_json::json; use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Effect, Node}; @@ -541,6 +543,524 @@ fn visual_bounds_include_shadow() { assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9); } +/// A shadow after a 3D effect reaches as far past the solid as it says, once: the solid's art keeps +/// the raster effects that follow the 3D effect, and the object paints them over that art. +#[test] +fn a_shadow_after_a_3d_effect_reaches_past_the_solid_once() { + let r = Rect::new(40.0, 40.0, 60.0, 60.0); + let extrude = ("threeD.extrude", json!({"depth": 20.0, "position": "isometricTop"})); + let shadow = ("stylize.dropShadow", json!({"x": 20, "y": 0, "blur": 0})); + let measure = |d: &Document| super::cull_bounds(&d.symbol_boxes(), &d.layers[0].children().unwrap()[0]).unwrap(); + let solid = measure(&doc_with(r, Color::BLACK, vec![extrude.clone()])); + let shadowed = measure(&doc_with(r, Color::BLACK, vec![extrude, shadow])); + assert_eq!(shadowed.union(solid), shadowed); + let grown = [solid.x0 - shadowed.x0, solid.y0 - shadowed.y0, shadowed.x1 - solid.x1, shadowed.y1 - solid.y1]; + assert!(grown.iter().all(|g| *g <= 20.0 + 1e-6) && grown.iter().any(|g| *g >= 20.0 - 1e-6), "{solid:?} {shadowed:?}"); +} + +/// Revolve builds no art from type: the type draws its glyphs, and its bounds hold them. +#[test] +fn revolved_type_is_measured_by_the_glyphs_it_draws() { + use vectorcraft_doc::NodeKind; + use vectorcraft_doc::text::{CharStyle, TextObject}; + let _fonts = fonts_steady(); + let mut d = Document::new(100.0, 100.0); + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), "KW", style)))); + n.appearance.effects = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }]; + let l = d.layers[0].id; + let id = d.insert(Some(l), 0, n).unwrap(); + let (img, b) = with_fonts_unchanged(|| (render(&d), super::cull_bounds(&d.symbol_boxes(), d.node(id).unwrap()).unwrap())); + let inked: Vec<(u32, u32)> = (0..100).flat_map(|y| (0..100).map(move |x| (x, y))).filter(|&(x, y)| img.pixel(x, y)[0] < 250).collect(); + assert!(!inked.is_empty()); + assert!(inked.iter().all(|&(x, y)| b.inflate(1.0, 1.0).contains(vectorcraft_geom::Point::new(f64::from(x), f64::from(y)))), "{b:?}"); +} + +/// An export crops to the solid a 3D effect draws on type, not to the letters, and so does +/// Object › Rasterize. +#[test] +fn an_export_and_rasterize_hold_the_solid_of_3d_type() { + use vectorcraft_doc::NodeKind; + use vectorcraft_doc::text::{CharStyle, TextObject}; + let _fonts = fonts_steady(); + let mut d = Document::new(100.0, 100.0); + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), "K", style)))); + let letters = with_fonts_unchanged(|| crate::encode::art_bounds(&doc_of(&mut d.clone(), n.clone())).unwrap()); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 60.0, "position": "isometricTop"}), visible: true }]; + let solid_doc = doc_of(&mut d, n.clone()); + let (solid, rasterized) = with_fonts_unchanged(|| { + let n = &solid_doc.layers[0].children().unwrap()[0]; + (crate::encode::art_bounds(&solid_doc).unwrap(), crate::drawn_bounds(&solid_doc, n).unwrap()) + }); + assert!(solid.width() > letters.width() + 15.0, "{letters:?} {solid:?}"); + assert!(rasterized.width() > letters.width() + 15.0, "{letters:?} {rasterized:?}"); +} + +fn doc_of(d: &mut Document, n: Node) -> Document { + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d.clone() +} + +/// A shadow on a fill after its own 3D effect is drawn on the fill's solid and reaches past it +/// once: Object › Rasterize takes the solid and the shadow, no more. +#[test] +fn a_fills_shadow_after_its_3d_effect_reaches_past_its_solid_once() { + let mut d = doc_with(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK, vec![]); + let id = d.layers[0].children().unwrap()[0].id; + let fill = |shadow: bool| { + let mut fx = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0, "position": "isometricTop"}), visible: true }]; + if shadow { + fx.push(Effect { id: "stylize.dropShadow".into(), params: json!({"x": 20, "y": 0, "blur": 0}), visible: true }); + } + fx + }; + d.node_mut(id).unwrap().appearance.fill_mut().unwrap().effects = fill(false); + let solid = crate::drawn_bounds(&d, d.node(id).unwrap()).unwrap(); + d.node_mut(id).unwrap().appearance.fill_mut().unwrap().effects = fill(true); + let shadowed = crate::drawn_bounds(&d, d.node(id).unwrap()).unwrap(); + let grown = [solid.x0 - shadowed.x0, solid.y0 - shadowed.y0, shadowed.x1 - solid.x1, shadowed.y1 - solid.y1]; + assert!(grown.iter().all(|g| *g <= 20.0 + 1e-6) && grown.iter().any(|g| *g >= 20.0 - 1e-6), "{solid:?} {shadowed:?}"); +} + +/// A fill's own 3D effect leaves the brushed stroke drawn with its brush: Object › Rasterize, exports +/// and culling take the brush's art in, and Rasterize and exports no more than the solid and that +/// art. +#[test] +fn a_brushed_stroke_beside_a_fills_3d_effect_is_measured_with_its_brush() { + let mut d = doc_with(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK, vec![]); + let id = d.layers[0].children().unwrap()[0].id; + { + let n = d.node_mut(id).unwrap(); + n.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 1.0))); + if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = n.appearance.items.last_mut() { + s.brush = Some("10 pt. Oval".into()); + } + n.appearance.fill_mut().unwrap().effects = + vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0, "position": "isometricTop"}), visible: true }]; + } + let n = d.node(id).unwrap(); + let brush = vectorcraft_brush::expand(&d, n).and_then(|art| art.visual_bounds()).unwrap(); + let symbols = d.symbol_boxes(); + let rasterized = crate::drawn_bounds(&d, n).unwrap(); + let exported = crate::effect_bounds(&symbols, n).unwrap(); + for (what, b) in [("Rasterize", rasterized), ("exports", exported), ("culling", super::cull_bounds(&symbols, n).unwrap())] { + assert_eq!(b.union(brush), b, "{what}: {b:?} holds the brush's art {brush:?}"); + } + // The solid alone, without the stroke: what else may be measured. + let mut plain = d.clone(); + plain.node_mut(id).unwrap().appearance.items.retain(|i| !matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))); + let solid = crate::drawn_bounds(&plain, plain.node(id).unwrap()).unwrap(); + let most = solid.union(brush).inflate(1.0, 1.0); + for (what, b) in [("Rasterize", rasterized), ("exports", exported)] { + assert_eq!(most.union(b), most, "{what}: {b:?} no more than the solid and the brush's art {most:?}"); + } +} + +/// The renderer measures a 3D object with Crop Marks as the free measure does: by the marks and +/// the object, which paints its own shadow inside them, counted once. +#[test] +fn the_renderer_measures_3d_art_with_crop_marks_as_drawn() { + let fx = vec![ + ("threeD.extrude", json!({"depth": 30.0, "position": "isometricTop"})), + ("stylize.dropShadow", json!({"x": 20, "y": 0, "blur": 0})), + (crate::effects::CROP_MARKS, json!({})), + ]; + let d = doc_with(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK, fx); + let n = d.layers[0].children().unwrap()[0].clone(); + let symbols = d.symbol_boxes(); + let free = super::cull_bounds(&symbols, &n).unwrap(); + assert_eq!(Renderer::new().bounds_of(&symbols, &n), Some(free)); +} + +/// A renderer measures the art over each artboard as [`crate::encode::art_bounds_on`] does, and +/// builds each 3D solid once for every artboard it measures. +#[test] +fn a_renderer_measures_the_art_on_each_artboard_building_each_solid_once() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let boards = 30; + for i in 0..boards { + let x = f64::from(i) * 100.0; + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(x + 30.0, 30.0, x + 60.0, 60.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.appearance.effects = + vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 40.0, "position": "isometricTop"}), visible: true }]; + d.insert(Some(l), 0, n).unwrap(); + } + let mut r = Renderer::new(); + for i in 0..boards { + let rect = Rect::new(f64::from(i) * 100.0, 0.0, f64::from(i) * 100.0 + 100.0, 100.0); + assert_eq!(r.art_bounds_on(&d, rect), crate::encode::art_bounds_on(&d, rect), "artboard {i}"); + } + assert_eq!(r.stats.three_d_built, boards as usize, "each solid built once"); +} + +/// A 3D object measured but not drawn (off the view) doesn't keep its art past the frame: what is +/// kept is what is drawn. +#[test] +fn only_drawn_3d_art_is_kept() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let mut ids = vec![]; + for x in [30.0, 530.0] { + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(x, 30.0, x + 30.0, 60.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + ids.push(d.insert(Some(l), 0, n).unwrap()); + } + let mut r = Renderer::new(); + r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + let kept = |r: &Renderer, id| { + let n = d.layers[0].children().unwrap().iter().find(|c| c.id == id).unwrap(); + r.fx_arts.contains_key(&(Arc::as_ptr(n) as usize)) + }; + assert!(kept(&r, ids[0]), "drawn, kept"); + assert!(!kept(&r, ids[1]), "off the view, let go"); + assert!(r.measured.is_empty()); +} + +/// Art made to measure objects is held within a budget: past it, objects are measured and their +/// art is made again if they are drawn. +#[test] +fn measured_art_is_held_within_its_budget() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + for i in 0..20 { + let x = 500.0 + f64::from(i) * 40.0; + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(x, 30.0, x + 30.0, 60.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + d.insert(Some(l), 0, n).unwrap(); + } + let symbols = d.symbol_boxes(); + let mut r = Renderer::new(); + let one = { + let n = &d.layers[0].children().unwrap()[0]; + r.bounds_of(&symbols, n); + r.measured_bytes + }; + r.let_go_of_measured(); + r.measured_budget = one * 3; + for n in d.layers[0].children().unwrap() { + r.bounds_of(&symbols, n); + } + assert!(r.measured_bytes <= one * 3 && r.measured.len() <= 3, "{} bytes in {} entries", r.measured_bytes, r.measured.len()); +} + +/// An edit to 3D objects the frame doesn't draw (hidden, in a hidden group, drawn hidden) builds +/// nothing to measure them, and changes nothing. +#[test] +fn an_edit_out_of_sight_builds_nothing() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let extruded = |d: &mut Document, x: f64| { + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(x, 30.0, x + 20.0, 50.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + n + }; + let mut hidden = extruded(&mut d, 10.0); + hidden.visible = false; + let member = extruded(&mut d, 40.0); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(member)]); + group.visible = false; + let drawn_hidden = extruded(&mut d, 70.0); + let ids = [d.insert(Some(l), 0, hidden).unwrap(), group.children().unwrap()[0].id, d.insert(Some(l), 0, drawn_hidden).unwrap()]; + d.insert(Some(l), 0, group).unwrap(); + let opts = RenderOptions { hidden: vec![ids[2]], ..Default::default() }; + let mut r = Renderer::new(); + r.render(&d, 100, 100, Affine::IDENTITY, &opts); + let mut e = d.clone(); + for id in ids { + e.node_mut(id).unwrap().appearance.effects[0].params = json!({"depth": 5.0}); + } + let built = r.stats.three_d_built; + assert_eq!(r.changed(&d, &e, &opts), crate::Changed::Nothing); + assert_eq!((r.stats.three_d_built - built, r.measured.len()), (0, 0)); +} + +/// The bounds of 3D art kept for a pass that only measures stay as many as the art still held. +#[test] +fn a_measuring_pass_keeps_the_bounds_of_held_art_only() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + for i in 0..3000 { + let x = f64::from(i) * 30.0; + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(x, 30.0, x + 20.0, 50.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 2.0}), visible: true }]; + d.insert(Some(l), 0, n).unwrap(); + } + let symbols = d.symbol_boxes(); + let mut r = Renderer::new(); + // Nothing measured is held: the art is let go once measured. + r.measured_budget = 0; + for n in d.layers[0].children().unwrap() { + r.bounds_of(&symbols, n); + } + assert_eq!(r.stats.three_d_built, 3000); + assert!(r.three_d_bounds.len() <= 1024, "{} kept", r.three_d_bounds.len()); +} + +/// An object whose 3D effect builds nothing (Revolve on a group) is measured by the renderer as the +/// free measure does it: as the renderer draws it, by its members. +#[test] +fn a_3d_effect_that_builds_nothing_is_measured_alike() { + let mut d = Document::new(100.0, 100.0); + let mut hidden = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(60.0, 10.0, 90.0, 20.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + hidden.visible = false; + let shown = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(hidden), Arc::new(shown)]); + g.appearance.effects = vec![Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let n = d.layers[0].children().unwrap()[0].clone(); + let symbols = d.symbol_boxes(); + assert_eq!(Renderer::new().effect_bounds(&symbols, &n), crate::effect_bounds(&symbols, &n)); + assert_eq!(Renderer::new().bounds_of(&symbols, &n), crate::brush_fx::cull_bounds(&symbols, &n)); +} + +/// A group with effects of its own that holds 3D type is measured once for every artboard of a +/// pass: its bounds are kept after the first. +#[test] +fn a_group_holding_3d_art_is_measured_once_a_pass() { + let mut d = Document::new(100.0, 100.0); + let mut inner = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + inner.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + let mut g = Node::group(d.alloc_id(), vec![Arc::new(inner)]); + g.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 4, "blur": 2}), visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let g = d.layers[0].children().unwrap()[0].clone(); + let mut r = Renderer::new(); + for i in 0..3 { + let rect = Rect::new(f64::from(i) * 100.0, 0.0, f64::from(i) * 100.0 + 100.0, 100.0); + assert_eq!(r.art_bounds_on(&d, rect), crate::encode::art_bounds_on(&d, rect)); + } + assert!(r.geom.contains_key(&(Arc::as_ptr(&g) as usize)), "kept for the next artboard"); +} + +/// A pass that only measures (Fit to Artwork Bounds, an export) holds no 3D art past its budget: +/// what it measured and didn't keep is let go. +#[test] +fn a_measuring_pass_holds_no_art_past_its_budget() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + for i in 0..20 { + let x = f64::from(i) * 40.0; + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(x, 30.0, x + 30.0, 60.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + d.insert(Some(l), 0, n).unwrap(); + } + let mut r = Renderer::new(); + r.measured_budget = 0; + r.art_bounds_on(&d, Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(r.measured.is_empty()); + assert!(r.three_d_bounds.values().all(|e| e.0.strong_count() == 0), "no art held"); +} + +/// Drawing an object takes the art made to measure it out of the budget: a frame draws each 3D +/// object with that art, making none again, while what it measured fits the budget. +#[test] +fn drawing_takes_measured_art_out_of_the_budget() { + let mut d = Document::new(800.0, 100.0); + let l = d.layers[0].id; + for i in 0..20 { + let x = f64::from(i) * 40.0; + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(x, 30.0, x + 30.0, 60.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + d.insert(Some(l), 0, n).unwrap(); + } + let n = d.layers[0].children().unwrap()[0].clone(); + let mut r = Renderer::new(); + r.bounds_of(&d.symbol_boxes(), &n); + assert!(r.measured_bytes > 0); + r.fx_art(&d, &n, true); + assert_eq!((r.measured_bytes, r.measured.len()), (0, 0), "taken out when drawn"); + let mut r = Renderer::new(); + r.render(&d, 800, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(r.stats.three_d_built, 20, "each made once"); +} + +/// An object with a brushed stroke whose 3D effect builds nothing is measured by a pass as without +/// it: not with the room culling leaves for its brush. +#[test] +fn a_pass_measures_brushed_art_as_exports_do() { + let mut d = Document::new(100.0, 100.0); + let mut art = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + art.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 1.0))); + if let Some(vectorcraft_doc::AppearanceItem::Stroke(s)) = art.appearance.items.last_mut() { + s.brush = Some("10 pt. Oval".into()); + } + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Brushed".into(), art: Arc::new(art) }); + let mut n = Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Brushed".into(), xf: Affine::translate((50.0, 50.0)) }); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let n = d.layers[0].children().unwrap()[0].clone(); + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + assert_eq!(Renderer::new().art_bounds_on(&d, rect), crate::encode::art_bounds_on(&d, rect)); + let symbols = d.symbol_boxes().with_brush_art(); + assert_eq!(Renderer::new().effect_bounds(&symbols, &n), crate::effect_bounds(&symbols, &n)); +} + +/// A 3D object whose brushed stroke's brush gets wider is drawn where the brush now reaches, by a +/// renderer that measured it before: culling leaves room for brush art. +#[test] +fn a_3d_objects_wider_brush_is_drawn_where_it_reaches() { + let mut d = Document::new(100.0, 100.0); + let mut ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(104.0, 40.0, 124.0, 60.0)), ap); + n.appearance.fill_mut().unwrap().effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 2.0}), visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + r.render(&d, 100, 100, Affine::IDENTITY, &opts); + let mut lib = vectorcraft_brush::library(&d); + let round = lib.iter_mut().find(|b| b.name == "3 pt. Round").unwrap(); + if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut round.kind { + c.size = 30.0; + } + vectorcraft_brush::store(&mut d, &lib); + let drawn = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels; + let fresh = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels; + assert!(fresh.iter().any(|c| *c < 128), "the wider brush reaches into the view"); + assert!(drawn == fresh, "as a new renderer draws it"); +} + +/// A 20 pt square at `x` with a fill-level 3D effect, a brushed stroke and Rasterize, in a 100 pt +/// view: drawn by a renderer that drew it before its brush got wider (`size` pt), and by a new +/// renderer. +fn drawn_after_a_brush_widens(x: f64, size: f64) -> (Vec, Vec) { + let brush = "3 pt. Round"; + let mut d = Document::new(100.0, 100.0); + let mut ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0); + ap.stroke_mut().unwrap().brush = Some(brush.into()); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 40.0, x + 20.0, 60.0)), ap); + n.appearance.fill_mut().unwrap().effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 2.0}), visible: true }]; + n.appearance.effects = vec![Effect { id: "rasterize".into(), params: json!({"useDocumentResolution": false, "resolution": 72}), visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + r.render(&d, 100, 100, Affine::IDENTITY, &opts); + let mut lib = vectorcraft_brush::library(&d); + let b = lib.iter_mut().find(|b| b.name == brush).unwrap(); + if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut b.kind { + c.size = size; + } + vectorcraft_brush::store(&mut d, &lib); + let drawn = r.render(&d, 100, 100, Affine::IDENTITY, &opts).pixels; + (drawn, Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts).pixels) +} + +/// Rasterize on a brushed 3D object outside the view draws its art with the brush as it is now, by +/// a renderer that drew it with the brush before: the object is measured again when the brush +/// library changes, with the brush's art reaching past the room culling leaves for it, and +/// rasterized again. +#[test] +fn a_rasterized_3d_objects_brush_widened_is_drawn() { + let (drawn, fresh) = drawn_after_a_brush_widens(140.0, 120.0); + assert!(fresh.iter().any(|c| *c < 128), "the wider brush reaches into the view"); + assert!(drawn == fresh, "as a new renderer draws it"); +} + +/// A rasterized brushed 3D object in the view is rasterized again when its brush changes. +#[test] +fn a_rasterized_objects_brush_widened_in_view_is_drawn_again() { + let (drawn, fresh) = drawn_after_a_brush_widens(40.0, 12.0); + assert!(drawn == fresh, "as a new renderer draws it"); +} + +/// A group with effects of its own that holds a hidden member is measured without it once a pass, +/// as exports measure it. +#[test] +fn a_group_with_a_hidden_member_is_measured_once_a_pass() { + let mut d = Document::new(100.0, 100.0); + let mut solid = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + solid.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + let mut hidden = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(80.0, 80.0, 95.0, 95.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + hidden.visible = false; + let mut g = Node::group(d.alloc_id(), vec![Arc::new(solid), Arc::new(hidden)]); + g.appearance.effects = vec![Effect { id: "stylize.dropShadow".into(), params: json!({"x": 4, "y": 4, "blur": 2}), visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + let g = d.layers[0].children().unwrap()[0].clone(); + let mut r = Renderer::new(); + for i in 0..3 { + let rect = Rect::new(f64::from(i) * 100.0, 0.0, f64::from(i) * 100.0 + 100.0, 100.0); + assert_eq!(r.art_bounds_on(&d, rect), crate::encode::art_bounds_on(&d, rect)); + } + assert!(r.pass_bounds.contains_key(&(Arc::as_ptr(&g) as usize)), "kept for the next artboard"); +} + +/// Art made again for an object, after what it read from outside it changed, takes the place of +/// the art held for it, and of its bytes. +#[test] +fn art_measured_again_takes_the_place_of_the_held() { + let d = doc_with(Rect::new(30.0, 30.0, 60.0, 60.0), Color::BLACK, vec![("threeD.extrude", json!({"depth": 20.0}))]); + let n = d.layers[0].children().unwrap()[0].clone(); + let key = Arc::as_ptr(&n) as usize; + let mut r = Renderer::new(); + let art = r.measured_fx_art(&d, &n); + let bytes = r.measured_bytes; + // As if it had read something else then. + r.measured.insert(key, (n.clone(), art, Some(7))); + r.measured_fx_art(&d, &n); + assert_eq!((r.measured_bytes, r.measured.len()), (bytes, 1)); +} + /// Multithreaded rendering filters each effect offscreen; it must match the single-threaded /// reference (blend modes against the real backdrop, objects partly off-screen, all effects). #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 31591e4b71..6c660710a3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -49,6 +49,14 @@ pub use pattern::render_pattern_swatch; pub use vectorcraft_effects as effects; pub use vello_cpu; +/// Art made to measure an object ([`Renderer::measured`]): the object, its art, and the key of +/// what the art read from outside the object (`None`: nothing). +type MeasuredArt = (Arc, Arc, Option); + +/// A 3D effect's art ([`Renderer::three_d_bounds`]), held weakly, and its bounds once measured at a +/// raster effects resolution (its bits) with a brush library (its key). +type ThreeDArt = (std::sync::Weak, Option<((u64, u64), Option)>); + /// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded /// images are shared by all renderers, see [`set_image_cache_budget`]). pub struct Renderer { @@ -76,6 +84,9 @@ pub struct Renderer { /// The plug-in revision ([`effects::plugin_revision`]) that `geom`, `fx_geom` and `fx_arts` were /// last checked against (see [`Self::check_plugins`]). plugin_revision: u64, + /// The key of the brush library ([`brush_fx::BrushCache::library_key`]) that `geom`'s bounds + /// of 3D art measured brushed strokes by (see [`Self::check_symbols`]). + brush_library: u64, /// Is a live-effect plug-in that paints its pieces installed (found when `plugin_revision` /// changes)? painting_plugins: bool, @@ -148,6 +159,25 @@ pub struct Renderer { shape_of: usize, /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). fx_arts: FrameCache, + /// The art of objects with object effects made to measure them ([`Renderer::bounds_of`]) and + /// not drawn yet, by the object's address, with the object and the key of what it read from + /// outside the object when it was made (`None`: nothing): drawing one keeps it in `fx_arts` + /// while that is unchanged; the frame's end lets go of the rest. + measured: PtrMap, + /// The bytes of the art in `measured` ([`lru::node_bytes`]), within `measured_budget` + /// ([`lru::MEASURED_BUDGET`]). + measured_bytes: usize, + measured_budget: usize, + /// The export bounds of the objects measured without their hidden members + /// (`encode::drawn_bounds`), by the object they were made from: kept for a pass over artboards, + /// let go when a frame ends. + pub(crate) pass_bounds: PtrMap, Option)>, + /// The 3D effects' art among the art `fx_art` made, by its address, with the art (weakly: what + /// keeps the art is `fx_arts` and `measured`) and its bounds once measured. + three_d_bounds: PtrMap, + /// How many entries `three_d_bounds` kept when it last let go of the art no longer held: it + /// does so again when it holds twice as many. + three_d_kept: usize, /// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]). placed_arts: FrameCache, /// The bitmap clock of [`placed_document`] when this frame first drew a placed document from diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index 5f5976a097..54ab4e51ed 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -33,6 +33,10 @@ pub(crate) const CHAR_STROKE_BUDGET: usize = 128 << 20; pub(crate) const CLIP_BUDGET: usize = 32 << 20; /// The art drawn for objects with effects that apply through it (`Renderer::fx_art`). pub(crate) const FX_ART_BUDGET: usize = 64 << 20; +/// Art made to measure objects and held until the frame ends, for the objects drawn to keep +/// (`Renderer::measured`): as much as the art kept for the objects drawn (`FX_ART_BUDGET`), which +/// a frame measures before it draws them. Past it, an object is made again to draw it. +pub(crate) const MEASURED_BUDGET: usize = FX_ART_BUDGET; /// Symbol art placed for instances and inline graphics (`Renderer::placed_art`). pub(crate) const PLACED_ART_BUDGET: usize = 64 << 20; /// Brush art per brushed stroke (`Renderer::draw_brush`). diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index e8df562155..bfb49add4f 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -45,6 +45,7 @@ impl Renderer { kept: PtrMap::default(), symbols: vec![], plugin_revision: 0, + brush_library: 0, painting_plugins: false, paint_names: 0, strokes: PtrMap::default(), @@ -70,6 +71,12 @@ impl Renderer { knockout: false, shape_of: 0, fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256), + measured: PtrMap::default(), + measured_bytes: 0, + measured_budget: lru::MEASURED_BUDGET, + pass_bounds: PtrMap::default(), + three_d_bounds: PtrMap::default(), + three_d_kept: 0, placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256), placed_frame: None, nested: 0, @@ -227,6 +234,8 @@ impl Renderer { self.clips.sweep(g); self.clip_paints.sweep(g); self.fx_arts.sweep(g); + // Only the 3D art still kept (in `fx_arts`) keeps its bounds. + self.let_go_of_measured(); self.placed_arts.sweep(g); self.brushes.entries.sweep(g); self.live.sweep(g); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index f5c9cfdb0a..12f7675e9b 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -6,6 +6,20 @@ use super::{ }; use crate::kept_paths::{self, KeptPath}; +/// Does [`Renderer::bounds_of`] measure `n` by the 3D art this renderer draws and keeps for it: a +/// Revolve, Extrude & Bevel, Inflate or Rotate without Crop Marks or Vector Halftone, which draw +/// other art? +fn measures_three_d(n: &Node) -> bool { + (effects::has_revolve(n) || effects::has_solid(n)) && !effects::has_crop_marks(n) && !effects::addons::halftone::has_halftone(n) +} + +/// Does `n` hold an object that [`Renderer::bounds_of`] measures by its 3D effect's art? +fn holds_measured_three_d(n: &Node) -> bool { + let mut holds = false; + n.walk(&mut |c| holds |= measures_three_d(c)); + holds +} + impl Renderer { /// Cached cull bounds of a node (containers union their cached children), symbol instances /// measured by their art box from `symbols`. @@ -29,6 +43,21 @@ impl Renderer { } acc } + // A 3D effect's art, measured as this renderer draws and keeps it ([`Self::fx_art`]): one + // build for the measure and the draw. Crop Marks and Vector Halftone draw other art, and + // an object whose 3D effect builds nothing (Revolve on a group, a solid too complex) is + // drawn otherwise, measured as below. + _ if measures_three_d(a) + && let Some(b) = { + let art = self.measured_fx_art(symbols.document(), a); + self.three_d_art_bounds(symbols, &art) + } => + { + // Culling leaves room for brush art as for any object ([`brush_fx::cull_bounds`]); + // the measure is made again when the brush library changes ([`Self::check_symbols`]). + let pad = brush_fx::pad(symbols, a); + b.map(|b| fx::with_own_raster_reach(symbols, a, b).inflate(pad, pad)) + } _ => self.kept_bounds(symbols, a).unwrap_or_else(|| brush_fx::cull_bounds(symbols, a)), }; self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp }); @@ -81,7 +110,8 @@ impl Renderer { /// Drop the cached bounds of symbol instances and of the objects that hold them when `doc`'s /// symbols are not those of the document last drawn (by name and art): those bounds measured - /// the instances by their symbol's art. + /// the instances by their symbol's art. Likewise drop those of objects measured by a 3D + /// effect's art, and of the objects that hold them, when the brush library has changed. pub(crate) fn check_symbols(&mut self, doc: &Document) { let same = self.symbols.len() == doc.symbols.len() && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && std::ptr::eq(art.as_ptr(), Arc::as_ptr(&s.art))); @@ -89,6 +119,12 @@ impl Renderer { self.geom.retain(|_, e| !holds_instance(&e.node)); self.symbols = doc.symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(); } + // That art is measured with the art its brushed strokes draw ([`Self::bounds_of`]). + let brushes = self.brushes.library_key_now(doc, self.stamp); + if brushes != self.brush_library { + self.brush_library = brushes; + self.geom.retain(|_, e| !holds_measured_three_d(&e.node)); + } } /// When a plug-in has been installed or removed since the last check diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index 4593971492..a65205bb0d 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -55,13 +55,25 @@ fn document() -> (Document, [NodeId; 5]) { } /// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that -/// this is `new` drawn whole; returns what changed. +/// this is `new` drawn whole; returns what changed. Measured afresh ([`changed`]) and by the +/// renderer that drew `old`, which then draws the part ([`Renderer::changed`]): both must hold. fn redraw(old: &Document, new: &Document) -> Changed { - let mut r = Renderer::new(); let opts = RenderOptions::default(); - let before = r.render(old, W, H, view(), &opts); - let after = r.render(new, W, H, view(), &opts); + let after = Renderer::new().render(new, W, H, view(), &opts); let change = changed(old, new); + let mut fresh = Renderer::new(); + let before = fresh.render(old, W, H, view(), &opts); + patched(&mut fresh, &before, new, change, &after); + let mut r = Renderer::new(); + let before = r.render(old, W, H, view(), &opts); + let measured = r.changed(old, new, &opts); + patched(&mut r, &before, new, measured, &after); + change +} + +/// `before` with `new` drawn again by `r` only where `change` says, checked against `after`. +fn patched(r: &mut Renderer, before: &Rendered, new: &Document, change: Changed, after: &Rendered) { + let opts = RenderOptions::default(); let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() }; match change { Changed::Everything => patched.pixels = after.pixels.clone(), @@ -78,7 +90,6 @@ fn redraw(old: &Document, new: &Document) -> Changed { } } assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all"); - change } fn area(c: Changed) -> Rect { @@ -421,3 +432,110 @@ fn a_path_painted_by_its_whole_shape_changes_whole_when_an_anchor_moves() { let a = area(redraw(&plain, &added)); assert!(a.width() > 80.0 && a.height() > 60.0, "{a:?}"); } + +/// Changing a 3D effect's parameters on `make`'s object, from `before` to `after`. +fn three_d_change(make: impl Fn(&mut Document) -> Node, id: &str, before: serde_json::Value, after: serde_json::Value) -> Changed { + let effect = |params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true }; + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let mut n = make(&mut old); + n.appearance.effects.push(effect(before)); + let n = old.insert(Some(l), 0, n).unwrap(); + let mut new = old.clone(); + new.node_mut(n).unwrap().appearance.effects[0] = effect(after); + redraw(&old, &new) +} + +/// Type and a group draw a 3D effect as solids that reach past their letters and members: a +/// shallower or turned solid changes where the deeper one drew too. +#[test] +fn a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was() { + use vectorcraft_doc::{CharStyle, TextObject}; + let blue = Color::rgb(0.1, 0.2, 0.6); + let type_k = move |d: &mut Document| { + let style = CharStyle { size: 30.0, fill: Paint::solid(blue), ..Default::default() }; + Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 50.0), "K", style)))) + }; + let group = move |d: &mut Document| { + let (a, b) = (rect(d, Rect::new(40.0, 30.0, 50.0, 50.0), blue), rect(d, Rect::new(52.0, 30.0, 60.0, 50.0), blue)); + Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]) + }; + let at = |depth: f64| serde_json::json!({"depth": depth, "position": "isometricTop"}); + for make in [&type_k as &dyn Fn(&mut Document) -> Node, &group] { + for (id, before, after) in [ + ("threeD.extrude", at(60.0), at(5.0)), + ("threeD.inflate", at(40.0), at(2.0)), + ("threeD.rotate", serde_json::json!({"rotationX": 0.0}), serde_json::json!({"rotationX": 80.0})), + ] { + // `redraw` holds the pixels drawn again only where it changed to drawing it all, and + // only an area of the document is drawn again. + area(three_d_change(make, id, before, after)); + } + } + // Type on a path, a group with a hidden member, and a group in a group. + let on_path = move |d: &mut Document| { + let style = CharStyle { size: 14.0, fill: Paint::solid(blue), ..Default::default() }; + let mut t = TextObject::point(Point::ZERO, "Along", style); + let line = kurbo::Line::new((30.0, 55.0), (80.0, 35.0)).to_path(0.1); + t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))) + }; + let hiding = move |d: &mut Document| { + let mut hidden = rect(d, Rect::new(10.0, 10.0, 20.0, 20.0), blue); + hidden.visible = false; + let shown = rect(d, Rect::new(40.0, 30.0, 60.0, 50.0), blue); + Node::group(d.alloc_id(), vec![Arc::new(hidden), Arc::new(shown)]) + }; + let nested = move |d: &mut Document| { + let inner = group(d); + Node::group(d.alloc_id(), vec![Arc::new(inner)]) + }; + for make in [&on_path as &dyn Fn(&mut Document) -> Node, &hiding, &nested] { + area(three_d_change(make, "threeD.extrude", at(60.0), at(5.0))); + } + // A revolved path, and revolved type, which Revolve leaves as it is. + let path = move |d: &mut Document| rect(d, Rect::new(40.0, 30.0, 60.0, 50.0), blue); + for make in [&path as &dyn Fn(&mut Document) -> Node, &type_k] { + area(three_d_change(make, "threeD.revolve", serde_json::json!({"offset": 30.0}), serde_json::json!({"offset": 0.0}))); + } +} + +/// A renderer that drew a document measures what an edit changed through what it keeps +/// ([`Renderer::changed`]): the same area as [`changed`], and the 3D art it made to measure the +/// edit it draws the edit with, making none again. [`changed`] makes its own, and the renderer +/// makes the art again to draw it. +#[test] +fn a_renderer_measures_an_edit_with_the_3d_art_it_draws() { + use vectorcraft_doc::{CharStyle, TextObject}; + let mut old = Document::new(110.0, 80.0); + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::rgb(0.1, 0.2, 0.6)), ..Default::default() }; + let mut n = Node::new(old.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 50.0), "K", style)))); + let extrude = |depth: f64| vectorcraft_doc::Effect { + id: "threeD.extrude".into(), + params: serde_json::json!({"depth": depth, "position": "isometricTop"}), + visible: true, + }; + n.appearance.effects.push(extrude(60.0)); + let l = old.layers[0].id; + let id = old.insert(Some(l), 0, n).unwrap(); + let mut new = old.clone(); + new.node_mut(id).unwrap().appearance.effects[0] = extrude(5.0); + let opts = RenderOptions::default(); + let _fonts = crate::tests_fontchange::fonts_steady(); + let (measured, built, apart) = crate::tests_fontchange::with_fonts_unchanged(|| { + let mut r = Renderer::new(); + r.render(&old, W, H, view(), &opts); + let measured = area(r.changed(&old, &new, &opts)); + r.render(&new, W, H, view(), &opts); + let mut fresh = Renderer::new(); + fresh.render(&old, W, H, view(), &opts); + let _ = changed(&old, &new); + fresh.render(&new, W, H, view(), &opts); + (measured, r.stats.three_d_built, fresh.stats.three_d_built) + }); + assert_eq!(measured, area(changed(&old, &new)), "as changed measures it"); + assert_eq!(built, 0, "drawn with the art made to measure it"); + assert_eq!(apart, 1, "changed keeps nothing for the renderer"); +} diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index 5b0f60091a..82c5c77ff7 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -371,3 +371,34 @@ fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() { assert!(off(before, after) > 0.5, "the type draws in the font that arrived: {before:?} vs {after:?}"); assert!(off(src(), after) < 1e-6, "{:?} vs {after:?}", src()); } + +/// 3D type measured for an edit ([`Renderer::changed`]) and drawn after its font became available +/// is drawn in that font: the art made to measure it, in the font it had, isn't taken. +#[test] +fn three_d_type_measured_before_its_font_arrives_draws_in_it() { + let _adding = adding_fonts(); + const FAMILY: &str = "Solid Serif 44"; + let mut old = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() }; + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style)))); + n.id = old.alloc_id(); + let extrude = |depth: f64| vectorcraft_doc::Effect { + id: "threeD.extrude".into(), + params: serde_json::json!({"depth": depth, "position": "isometricTop"}), + visible: true, + }; + n.appearance.effects.push(extrude(40.0)); + let l = old.layers[0].id; + let id = old.insert(Some(l), 0, n).unwrap(); + let mut new = old.clone(); + new.node_mut(id).unwrap().appearance.effects[0] = extrude(10.0); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + r.render(&old, 300, 150, Affine::IDENTITY, &opts); + let _ = r.changed(&old, &new, &opts); + assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0); + let drawn = r.render(&new, 300, 150, Affine::IDENTITY, &opts).pixels; + let fresh = Renderer::new().render(&new, 300, 150, Affine::IDENTITY, &opts).pixels; + let differ = drawn.iter().zip(&fresh).filter(|(a, b)| a != b).count(); + assert_eq!(differ, 0, "as a new renderer draws it"); +} diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index a9211eccff..5577277da8 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -7,7 +7,7 @@ pub struct CanvasCache { pub key: Option, /// The document the frame shown was rendered from, and the options it was rendered with, with /// [`Self::key`]: when only the document changes, the canvas renders again only where it - /// changed ([`vectorcraft_render::changed`]). + /// changed, measured by [`Self::renderer`], which drew it ([`vectorcraft_render::Renderer::changed`]). pub shown: Option<(std::sync::Arc, vectorcraft_render::RenderOptions)>, pub last_ms: f64, /// The frame shown drew raster effects from rasters made before their objects moved, or at a diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 19244247dc..b99702f045 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -734,6 +734,10 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { // rendered again, here, into the texture. `last_ms` stays the whole frame's. (_, _, Some(region)) => { let t0 = now_ms(); + if region.is_none() { + // Nothing in view to draw again: what was made to measure the edit isn't drawn. + app.canvas.renderer.let_go_of_measured(); + } if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); let color = color_image(&part); @@ -746,6 +750,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) { app.perf.render_ms = now_ms() - t0; } (Some(worker), true, None) => { + // The worker draws the frame: what this thread's renderer made to measure the edit + // isn't drawn here. + app.canvas.renderer.let_go_of_measured(); let queued = worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index 8684cea057..64fc3599f5 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -35,8 +35,10 @@ pub(crate) fn color_image_owned(img: vectorcraft_render::Rendered) -> egui::Colo /// objects showing those bitmaps' files cover (`Some(None)`: none), grown to whole eighths of the /// frame, as long as they are at most half of it. None: render the whole frame. Frames the worker /// renders stay whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. +/// What changed is measured by this thread's renderer, which drew the frame shown: it keeps what +/// it measures (a 3D effect's art) to draw the part again. pub(super) fn changed_pixels( - app: &VectorcraftApp, + app: &mut VectorcraftApp, key: &CacheKey, doc: &vectorcraft_doc::Document, opts: &vectorcraft_render::RenderOptions, @@ -60,7 +62,7 @@ pub(super) fn changed_pixels( } else { vectorcraft_render::placed_area(doc, &vectorcraft_render::placed_document::made_between(shown_key.placed, key.placed)?) }; - match vectorcraft_render::changed(shown, doc).and(placed) { + match app.canvas.renderer.changed(shown, doc, opts).and(placed) { vectorcraft_render::Changed::Nothing => Some(None), vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { None => Some(None), diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 5a94afb3f4..058bce8ce3 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10656,3 +10656,53 @@ fn direct_selection_shows_the_bounding_box_while_cmd_is_held() { assert!(!handle_drawn(&mut app, egui::Modifiers::NONE), "gone once Cmd is released"); assert!(app.ui.view.bounding_box, "View › Show Bounding Box is unchanged"); } + +/// An edit to a 3D effect renders the part it changed on this thread, measured by the renderer +/// that drew the frame shown ([`vectorcraft_render::Renderer::changed`]): it draws the part with +/// the 3D art it made to measure it. +#[test] +fn an_edit_to_a_3d_effect_redraws_with_the_art_measured() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let id = app.run("shape.rectangle", json!({"x": 380, "y": 280, "width": 40, "height": 40})).unwrap()["id"].clone(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "ids": [id], "params": {"depth": 60.0}})).unwrap(); + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); + assert_eq!(app.canvas.renderer.stats.three_d_built, 1, "the whole frame made the art"); + app.run("effect.setParams", json!({"ids": [id], "index": 0, "item": null, "params": {"depth": 5.0}})).unwrap(); + // Drawing only a part keeps the whole frame's time; drawing it whole measures it again. + app.canvas.last_ms = 7.25; + shapes(&mut app, &ctx); + assert!(!app.canvas.from_worker); + assert_eq!(app.canvas.last_ms, 7.25, "only the part that changed was drawn"); + assert_eq!(app.canvas.renderer.stats.three_d_built, 0, "the part was drawn with the art made to measure the edit"); +} + +/// An edit to a 3D effect off the view, and one the render worker draws, leave none of the art made +/// to measure them held by this thread's renderer. +#[test] +fn art_measured_for_an_edit_not_drawn_here_is_let_go() { + for worker in [false, true] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + app.canvas.worker_started = true; // render on this thread + // Off the view, or filling it, for the worker to draw. + let (x, size) = if worker { (0, 800) } else { (5000, 40) }; + let id = app.run("shape.rectangle", json!({"x": x, "y": 0, "width": size, "height": size})).unwrap()["id"].clone(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "ids": [id], "params": {"depth": 60.0}})).unwrap(); + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); + if worker { + app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + app.canvas.last_ms = 100.0; + } + app.run("effect.setParams", json!({"ids": [id], "index": 0, "item": null, "params": {"depth": 5.0}})).unwrap(); + shapes(&mut app, &ctx); + if worker { + assert!(app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()), "the worker draws it"); + } + assert_eq!(app.canvas.renderer.measured_bytes(), 0, "worker {worker}"); + app.canvas.worker = None; + } +} diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 9790270a44..269395b5da 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -312,13 +312,22 @@ pub struct MenuFacts { pub(crate) any_art: bool, } -/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. +/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. The +/// answer is kept for the document's revision: the canvas menu asks on every frame it is open +/// without its [`MenuFacts`], and measuring builds the solids of 3D effects. pub(crate) fn fit_has_art(app: &VectorcraftApp, indices: &[usize]) -> bool { + /// The document's uid and revision, the artboards asked about, and the answer. + type Kept = Option<(u64, u64, Vec, bool)>; + thread_local!(static KEPT: std::cell::RefCell = const { std::cell::RefCell::new(None) }); app.session.active().is_some_and(|st| { - indices - .iter() - .filter_map(|i| st.doc.artboards.get(*i)) - .any(|a| !a.locked && vectorcraft_render::encode::art_bounds_on(&st.doc, a.rect).is_some()) + if let Some(any) = KEPT.with_borrow(|k| k.as_ref().filter(|k| k.0 == st.uid && k.1 == st.revision && k.2 == indices).map(|k| k.3)) { + return any; + } + // One renderer measures every artboard: each object once. + let mut r = vectorcraft_render::Renderer::new(); + let any = indices.iter().filter_map(|i| st.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&st.doc, a.rect).is_some()); + KEPT.set(Some((st.uid, st.revision, indices.to_vec(), any))); + any }) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0093bc2099..5f9397b0b0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously: `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -37,6 +37,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`. Scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-07 | minor | Pen and Curvature tool with Cmd/Ctrl held: Edit › Copy and Cut take the anchors and segments the lent Direct Selection picked, not the whole path; with the Pen's own last anchor selected Copy still takes the whole path (#6123); scores and estimates unchanged | | 2026-11-07 | minor | Live Corners: with Direct Selection, while every corner of a path is selected (the path selected whole), a Shift-click on a corner widget picks that corner alone, as the first click of "click one widget, then Shift-click the others" does, instead of deselecting it and hiding its widget (#5188). The other corners' widgets then hide: a Shift-click on another corner's anchor adds that corner and shows its widget, and a Shift-click on a showing widget deselects its corner. Adding a corner by Shift-clicking its widget while it is hidden stays open (#1232). Test: `corners::tests::a_shift_click_on_a_widget_of_a_whole_path_picks_its_corner`. Scores and estimates unchanged. | @@ -1465,6 +1466,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). + When an edit changes a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve), the canvas draws again where its art was and is, measured by that art: type and groups were measured by their letters or members, so a shallower or turned solid left pieces of the old one on the canvas. When the canvas draws the part on its own thread, it measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type, where they cropped it to the letters, and Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once unless the object has brushed strokes: 30 extruded type objects measured over 30 artboards take about 2 ms in a release build, where measuring their letters took under 1 ms. A document's first frame builds every 3D object's solid, off the view too, to know whether it reaches into the view. When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times). Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million. Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. From 3617c678b74bc0800e18a599d4bf527cf5fa93ef Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 00:29:06 -0700 Subject: [PATCH 02/14] Canvas redraw: what the renderer leaves out measured as nothing, alike Review of #6290. - An edit is measured as the renderer draws: an object it leaves out (hidden, a template layer or guide the view hides, drawn hidden, the instance a symbol is edited from) is measured as nothing, by one predicate the renderer and the measure share (node_checks::left_out, before culling). - An Image Trace object's members are compared one by one again, its hidden source image, which some views draw, counted as shown: moving the source redraws where it was and is, and recoloring a traced shape redraws only the shape. - The renderer checks the brush library between frames only while it keeps bounds measured with brushed strokes in 3D art. - Tests: the sweep of 3D art's bounds counted while the art is held, and Fit to Artwork Bounds' menu answer kept for a revision. --- crates/render/src/changed.rs | 41 ++++++++----- crates/render/src/fx/renderer.rs | 4 ++ crates/render/src/fx/tests.rs | 37 +++++++++++ crates/render/src/lib.rs | 8 ++- crates/render/src/node_checks.rs | 26 ++++++++ crates/render/src/renderer.rs | 4 +- crates/render/src/renderer/nodes.rs | 34 ++++------- crates/render/src/tests/changed.rs | 64 ++++++++++++++++++++ crates/ui-egui/src/panels/artboards/tests.rs | 23 +++++++ 9 files changed, 200 insertions(+), 41 deletions(-) diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs index 9d57b7431a..5db3e3ab00 100644 --- a/crates/render/src/changed.rs +++ b/crates/render/src/changed.rs @@ -82,19 +82,19 @@ fn add_placed(symbols: &SymbolBoxes, n: &Node, files: &HashSet, area: &m /// art also draws in the copies round it). Objects that an edit doesn't touch /// keep their identity, so this costs what changed, not the size of the document. pub fn changed(old: &Document, new: &Document) -> Changed { - changed_with(old, new, None, &[]) + changed_with(old, new, None, &crate::RenderOptions::default()) } impl crate::Renderer { /// [`changed`], measured through this renderer (`Renderer::bounds_of`): what it drew /// of `old` it measured to draw it, and what it measures of `new`, a 3D effect's art included, - /// it holds to draw that with `opts`; the objects `opts` draws hidden count as hidden objects - /// do, drawing nothing. The view that drew `old` with this renderer and `opts` asks this. With + /// it holds to draw that with `opts`; the objects `opts` leaves out (hidden, templates and + /// guides the view hides, the instance a symbol is edited from) draw nothing. The view that drew `old` with this renderer and `opts` asks this. With /// Overprint Preview or Separations Preview the renderer draws copies of the objects /// (`proof::prepare`), so they are measured afresh and nothing is held. pub fn changed(&mut self, old: &Document, new: &Document, opts: &crate::RenderOptions) -> Changed { let renderer = (!crate::proof::overprints(opts)).then_some(self); - changed_with(old, new, renderer, &opts.hidden) + changed_with(old, new, renderer, opts) } /// Let go of the art made to measure objects ([`crate::Renderer::changed`]) and held for this @@ -114,11 +114,11 @@ impl crate::Renderer { } } -fn changed_with(old: &Document, new: &Document, renderer: Option<&mut crate::Renderer>, hidden: &[NodeId]) -> Changed { +fn changed_with(old: &Document, new: &Document, renderer: Option<&mut crate::Renderer>, opts: &crate::RenderOptions) -> Changed { if new.pattern_edit.is_some() || !old.same_outside_layers(new) { return Changed::Everything; } - let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None, renderer, hidden }; + let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None, renderer, opts, source: None }; diff.children(&old.layers, &new.layers); diff.area.map_or(Changed::Nothing, Changed::Area) } @@ -136,13 +136,15 @@ pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> } /// [`changed`] in progress: the symbols' boxes of both versions, the area so far, the renderer -/// that measures, if any (else each object is measured afresh), and the objects it draws hidden. +/// that measures, if any (else each object is measured afresh), the options of the frames it +/// measures for, and the hidden source image of the Image Trace object whose members it compares. struct Diff<'a, 'r> { old: SymbolBoxes<'a>, new: SymbolBoxes<'a>, area: Option, renderer: Option<&'r mut crate::Renderer>, - hidden: &'r [NodeId], + opts: &'r crate::RenderOptions, + source: Option, } impl Diff<'_, '_> { @@ -171,15 +173,17 @@ impl Diff<'_, '_> { /// only had anchors moved changed around them ([`moved_anchors`]). Any other object that /// changed counts whole, before and after. fn node(&mut self, old: &Arc, new: &Arc) { - if Arc::ptr_eq(old, new) || self.unseen(old) && self.unseen(new) { + if Arc::ptr_eq(old, new) || self.unseen(Side::Old, old) && self.unseen(Side::New, new) { return; } - // An Image Trace object draws its hidden source image in some views: it changed whole. if let (Some(a), Some(b)) = (members(old), members(new)) && same_but_members(old, new) - && old.extras().trace.is_none() { + // An Image Trace object draws its hidden source image, its first member, in some views. + let source = old.extras().trace.is_some().then(|| a.first().map(|s| s.id)).flatten(); + let outer = std::mem::replace(&mut self.source, source); self.children(a, b); + self.source = outer; } else if let Some((area, moved, anchors)) = moved_anchors(old, new) { if moved <= FEW_MOVED && anchors >= MANY_ANCHORS { self.add(area); @@ -195,16 +199,21 @@ impl Diff<'_, '_> { } } - /// Does `n` draw nothing: hidden, or drawn hidden? The members of a hidden group or layer are - /// never compared ([`Self::node`]). - fn unseen(&self, n: &Node) -> bool { - !n.visible || self.hidden.contains(&n.id) + /// Does `n` of the `side` version draw nothing, as the renderer leaves it out + /// (`node_checks::left_out`)? The source image of an Image Trace object draws in some views. + /// The members of a group or layer left out are never compared ([`Self::node`]). + fn unseen(&self, side: Side, n: &Node) -> bool { + let doc = match side { + Side::Old => self.old.document(), + Side::New => self.new.document(), + }; + self.source != Some(n.id) && crate::node_checks::left_out(self.opts, doc, n) } /// The cull bounds of `n` of the `side` version, through the renderer when there is one; /// `None` when it draws nothing ([`Self::unseen`]), as nothing is measured to draw it. fn bounds(&mut self, side: Side, n: &Arc) -> Option { - if self.unseen(n) { + if self.unseen(side, n) { return None; } let Diff { old, new, renderer, .. } = self; diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 8477662b72..99ea4e97f2 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -621,6 +621,10 @@ impl Renderer { if self.three_d_bounds.len() >= (2 * self.three_d_kept).max(1024) { self.three_d_bounds.retain(|_, e| e.0.strong_count() > 0); self.three_d_kept = self.three_d_bounds.len(); + #[cfg(test)] + { + self.three_d_sweeps += 1; + } } self.three_d_bounds.insert(Arc::as_ptr(&art) as usize, (Arc::downgrade(&art), None)); } diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 7e4802f3a7..b184628204 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -819,6 +819,43 @@ fn a_measuring_pass_keeps_the_bounds_of_held_art_only() { assert!(r.three_d_bounds.len() <= 1024, "{} kept", r.three_d_bounds.len()); } +/// While its 3D art is held, a pass that only measures sweeps the bounds kept for 3D art each time +/// they double, not on every new art past a size. +#[test] +fn a_measuring_pass_sweeps_the_bounds_of_held_art_as_they_double() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + for i in 0..3000 { + let x = f64::from(i) * 30.0; + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(x, 30.0, x + 20.0, 50.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + n.appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 2.0}), visible: true }]; + d.insert(Some(l), 0, n).unwrap(); + } + let mut r = Renderer::new(); + // Held here, as the art kept to draw is. + let held: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| r.measured_fx_art(&d, n)).collect(); + assert_eq!((held.len(), r.three_d_bounds.len()), (3000, 3000)); + assert!(r.three_d_sweeps <= 2, "{} sweeps", r.three_d_sweeps); +} + +/// A renderer checks the brush library between frames only while it keeps bounds measured with +/// brushed strokes in 3D art. +#[test] +fn the_brush_library_is_checked_only_with_3d_bounds_kept() { + let mut d = doc_with(Rect::new(30.0, 30.0, 60.0, 60.0), Color::BLACK, vec![]); + let mut r = Renderer::new(); + r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert_eq!(r.brush_library, None); + let id = d.layers[0].children().unwrap()[0].id; + d.node_mut(id).unwrap().appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; + r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); + assert!(r.brush_library.is_some()); +} + /// An object whose 3D effect builds nothing (Revolve on a group) is measured by the renderer as the /// free measure does it: as the renderer draws it, by its members. #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6c660710a3..89a673855e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -85,8 +85,9 @@ pub struct Renderer { /// last checked against (see [`Self::check_plugins`]). plugin_revision: u64, /// The key of the brush library ([`brush_fx::BrushCache::library_key`]) that `geom`'s bounds - /// of 3D art measured brushed strokes by (see [`Self::check_symbols`]). - brush_library: u64, + /// of 3D art measured brushed strokes by (see [`Self::check_symbols`]), while `geom` may hold + /// such bounds (`Some`). + brush_library: Option, /// Is a live-effect plug-in that paints its pieces installed (found when `plugin_revision` /// changes)? painting_plugins: bool, @@ -178,6 +179,9 @@ pub struct Renderer { /// How many entries `three_d_bounds` kept when it last let go of the art no longer held: it /// does so again when it holds twice as many. three_d_kept: usize, + /// How many times `three_d_bounds` was swept for art no longer held between frames. + #[cfg(test)] + three_d_sweeps: usize, /// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]). placed_arts: FrameCache, /// The bitmap clock of [`placed_document`] when this frame first drew a placed document from diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs index 07a1b9dc28..8a150fd3c9 100644 --- a/crates/render/src/node_checks.rs +++ b/crates/render/src/node_checks.rs @@ -107,6 +107,32 @@ pub(crate) fn holds_instance(n: &Node) -> bool { holds } +/// Is `a` left out of a frame `doc` draws with `opts`, before any culling: hidden, a template or +/// guide the view hides, drawn hidden, or the instance its symbol is being edited from? +pub(crate) fn left_out(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, a: &Node) -> bool { + let view_item = (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) + || (opts.skip_templates || opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); + !a.visible || view_item || opts.hidden.contains(&a.id) || edited_instance(opts, doc, a.id) +} + +/// Is `id` the symbol instance that `doc`'s symbol is edited from in isolation (drawn without it)? +pub(crate) fn edited_instance(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId) -> bool { + opts.isolated.is_some() && doc.symbol_edit.as_ref().is_some_and(|e| e.instance == Some(id)) +} + +/// Does [`crate::Renderer::bounds_of`] measure `n` by the 3D art the renderer draws and keeps for +/// it: a Revolve, Extrude & Bevel, Inflate or Rotate without Crop Marks or Vector Halftone, which +/// draw other art? +pub(crate) fn measures_three_d(n: &Node) -> bool { + (effects::has_revolve(n) || effects::has_solid(n)) && !effects::has_crop_marks(n) && !effects::addons::halftone::has_halftone(n) +} + +/// Does `n` hold an object that [`crate::Renderer::bounds_of`] measures by its 3D effect's art +/// ([`measures_three_d`])? +pub(crate) fn holds_measured_three_d(n: &Node) -> bool { + measures_three_d(n) || n.children().is_some_and(|c| c.iter().any(|c| holds_measured_three_d(c))) +} + /// Does `n` hold an object with a visible plug-in effect ([`effects::has_plugin_effect`]), in it or /// in the opacity masks of the objects in it? pub(crate) fn holds_plugin_effect(n: &Node) -> bool { diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index bfb49add4f..2a188fc0a9 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -45,7 +45,7 @@ impl Renderer { kept: PtrMap::default(), symbols: vec![], plugin_revision: 0, - brush_library: 0, + brush_library: None, painting_plugins: false, paint_names: 0, strokes: PtrMap::default(), @@ -77,6 +77,8 @@ impl Renderer { pass_bounds: PtrMap::default(), three_d_bounds: PtrMap::default(), three_d_kept: 0, + #[cfg(test)] + three_d_sweeps: 0, placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256), placed_frame: None, nested: 0, diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 12f7675e9b..4f67b16ec3 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -5,20 +5,7 @@ use super::{ shape_masked_image, single_plain_fill, text_geom_snapped, white_shape_mask, }; use crate::kept_paths::{self, KeptPath}; - -/// Does [`Renderer::bounds_of`] measure `n` by the 3D art this renderer draws and keeps for it: a -/// Revolve, Extrude & Bevel, Inflate or Rotate without Crop Marks or Vector Halftone, which draw -/// other art? -fn measures_three_d(n: &Node) -> bool { - (effects::has_revolve(n) || effects::has_solid(n)) && !effects::has_crop_marks(n) && !effects::addons::halftone::has_halftone(n) -} - -/// Does `n` hold an object that [`Renderer::bounds_of`] measures by its 3D effect's art? -fn holds_measured_three_d(n: &Node) -> bool { - let mut holds = false; - n.walk(&mut |c| holds |= measures_three_d(c)); - holds -} +use crate::node_checks::{holds_measured_three_d, measures_three_d}; impl Renderer { /// Cached cull bounds of a node (containers union their cached children), symbol instances @@ -55,6 +42,9 @@ impl Renderer { { // Culling leaves room for brush art as for any object ([`brush_fx::cull_bounds`]); // the measure is made again when the brush library changes ([`Self::check_symbols`]). + if self.brush_library.is_none() { + self.brush_library = Some(self.brushes.library_key_now(symbols.document(), self.stamp)); + } let pad = brush_fx::pad(symbols, a); b.map(|b| fx::with_own_raster_reach(symbols, a, b).inflate(pad, pad)) } @@ -119,11 +109,13 @@ impl Renderer { self.geom.retain(|_, e| !holds_instance(&e.node)); self.symbols = doc.symbols.iter().map(|s| (s.name.clone(), Arc::downgrade(&s.art))).collect(); } - // That art is measured with the art its brushed strokes draw ([`Self::bounds_of`]). - let brushes = self.brushes.library_key_now(doc, self.stamp); - if brushes != self.brush_library { - self.brush_library = brushes; + // That art is measured with the art its brushed strokes draw ([`Self::bounds_of`]). Without + // such bounds, nothing is checked. + if let Some(was) = self.brush_library + && self.brushes.library_key_now(doc, self.stamp) != was + { self.geom.retain(|_, e| !holds_measured_three_d(&e.node)); + self.brush_library = None; } } @@ -228,15 +220,13 @@ impl Renderer { /// drawn (with its masks and effects), since the editing layer holds a copy of its art in its /// place. fn edited_instance(f: &Frame, id: NodeId) -> bool { - f.opts.isolated.is_some() && f.doc.symbol_edit.as_ref().is_some_and(|e| e.instance == Some(id)) + crate::node_checks::edited_instance(f.opts, f.doc, id) } /// Whether `a` is left out of this frame: hidden, a skipped template or guide, the instance a /// symbol is edited from, or culled. fn skipped(&mut self, f: &Frame, a: &Arc) -> bool { - let skipped_view_item = (f.opts.skip_templates || f.opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) - || (f.opts.skip_templates || f.opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); - if !a.visible || skipped_view_item || f.opts.hidden.contains(&a.id) || Self::edited_instance(f, a.id) { + if crate::node_checks::left_out(f.opts, f.doc, a) { return true; } match self.bounds_of(f.symbols, a) { diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index a65205bb0d..cb32de8706 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -539,3 +539,67 @@ fn a_renderer_measures_an_edit_with_the_3d_art_it_draws() { assert_eq!(built, 0, "drawn with the art made to measure it"); assert_eq!(apart, 1, "changed keeps nothing for the renderer"); } + +/// An Image Trace object shown with its source image (`view`): its hidden source, at the left, and +/// two traced shapes, at the right. The ids of the source and of the second shape. +fn traced(view: &str) -> (Document, NodeId, NodeId) { + let mut d = Document::new(W as f64, H as f64); + let mut source = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), Color::rgb(0.5, 0.5, 0.5)); + source.visible = false; + let a = rect(&mut d, Rect::new(60.0, 10.0, 70.0, 20.0), Color::rgb(0.0, 0.5, 0.0)); + let b = rect(&mut d, Rect::new(80.0, 10.0, 90.0, 20.0), Color::rgb(1.0, 0.5, 0.0)); + let ids = (source.id, b.id); + let mut g = Node::group(d.alloc_id(), vec![Arc::new(source), Arc::new(a), Arc::new(b)]); + g.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": view }))); + let l = d.layers[0].id; + d.insert(Some(l), 0, g).unwrap(); + (d, ids.0, ids.1) +} + +#[test] +fn moving_a_trace_objects_source_changes_where_it_shows() { + for view in ["sourceImage", "outlinesWithSourceImage"] { + let (old, source, _) = traced(view); + let mut new = old.clone(); + new.node_mut(source).unwrap().transform(Affine::translate((5.0, 30.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.contains(Point::new(12.0, 12.0)) && a.contains(Point::new(44.0, 69.0)), "{view}: before and after: {a:?}"); + } +} + +#[test] +fn recolouring_a_traced_shape_changes_only_the_shape() { + let (old, _, shape) = traced("outlinesWithSourceImage"); + let mut new = old.clone(); + new.node_mut(shape).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + let a = area(redraw(&old, &new)); + assert!(a.x0 > 72.0, "not the source or the other shape: {a:?}"); +} + +/// Templates and guides that the view hides draw nothing: an edit to them changes nothing. +#[test] +fn an_edit_to_what_the_view_hides_changes_nothing() { + let mut old = Document::new(W as f64, H as f64); + let template = Node::layer(old.alloc_id(), "Template", vectorcraft_doc::LayerColor::Preset(1)); + let template = old.insert(None, usize::MAX, template).unwrap(); + if let Some(NodeKind::Layer { template: t, .. }) = old.node_mut(template).map(|n| &mut n.kind) { + *t = true; + } + let traced = rect(&mut old, Rect::new(10.0, 10.0, 40.0, 40.0), Color::rgb(0.5, 0.5, 0.5)); + let traced = old.insert(Some(template), 0, traced).unwrap(); + let mut guide = rect(&mut old, Rect::new(60.0, 10.0, 90.0, 40.0), Color::BLACK); + if let NodeKind::Path { guide: g, .. } = &mut guide.kind { + *g = true; + } + let l = old.layers[0].id; + let guide = old.insert(Some(l), 0, guide).unwrap(); + let mut new = old.clone(); + for id in [traced, guide] { + new.node_mut(id).unwrap().transform(Affine::translate((5.0, 5.0)), false); + } + let opts = RenderOptions { hide_templates: true, hide_guides: true, ..Default::default() }; + let mut r = Renderer::new(); + r.render(&old, W, H, view(), &opts); + assert_eq!(r.changed(&old, &new, &opts), Changed::Nothing); + assert!(matches!(changed(&old, &new), Changed::Area(_)), "shown, they change"); +} diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index 256e7ceaff..e10fd8ab33 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -1656,6 +1656,29 @@ fn the_open_menu_reads_what_the_right_click_measured() { assert!(labels.contains(&"Delete Empty Artboards") && !labels.contains(&"Fit to Artwork Bounds"), "{labels:?}"); } +/// Whether Fit to Artwork Bounds has art to fit is measured once for a revision of the document: +/// the canvas menu asks on every frame it is open. +#[test] +fn fit_to_art_is_measured_once_a_revision() { + let mut app = menu_app(2); + assert!(!super::fit_has_art(&app, &[0, 1])); + // Art on the first artboard, the revision left as it was: the answer kept stands. + let st = app.session.active_mut().unwrap(); + let mut doc = (*st.doc).clone(); + let l = doc.layers[0].id; + let id = doc.alloc_id(); + let art = vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 10.0, 20.0, 20.0)), + vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + doc.insert(Some(l), 0, art).unwrap(); + st.doc = std::sync::Arc::new(doc); + assert!(!super::fit_has_art(&app, &[0, 1]), "kept for the revision"); + app.session.active_mut().unwrap().revision += 1; + assert!(super::fit_has_art(&app, &[0, 1]), "measured again"); +} + /// `artboard.selectAll` chooses the Artboard tool first when another tool is in use, and fails /// without a document. #[test] From d5d04f86843fe450643a491494a1c5f77d028773 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 00:44:21 -0700 Subject: [PATCH 03/14] Canvas: a 3D effect on a fill that builds nothing draws without it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An object with an effect of its own (Drop Shadow, Roughen) and a 3D effect on its fill or stroke that builds nothing (Revolve after Roughen, a solid too complex) overflowed the stack when drawn, on the canvas and in Object › Rasterize: the art made for the object kept the item's 3D effect, so drawing it drew it as an object with effects again, and that art again, without end. That art now leaves out the 3D effects on its fills and strokes when none built anything, as a group's or layer's own are left to the art of each fill or stroke. The fill draws as without the effect; with a raster effect the object draws from that art, so its edges differ a little from the same object without the 3D effect, drawn as a path. --- crates/render/src/fx/renderer.rs | 11 ++++++++- crates/render/src/fx/tests.rs | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 99ea4e97f2..75143f9a06 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -600,7 +600,16 @@ impl Renderer { None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), None => effects::outline_art(Some(doc), a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; - let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art }; + let has_three_d = effects::has_revolve(a) || effects::has_solid(a); + let mut art = if has_three_d { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art }; + // A 3D effect on a fill or stroke that built nothing (Revolve after Roughen, a solid too + // complex) draws the item as without it: left in the art, it would draw the art as an + // object with effects again, and that art again, without end. + if has_three_d && !three_d && !container { + for item in &mut art.appearance.items { + item.effects_mut().retain(|e| !effects::is_three_d(&e.id)); + } + } // Vector Halftone samples the object with its colour adjustments: its dots keep their inks. let outer = if effects::addons::halftone::has_halftone(a) { None } else { effects::color_map(&a.appearance.effects) }; let mut art = self.adjusted_art(doc, art, outer.as_ref()); diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index b184628204..71c0ad412d 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -1700,3 +1700,42 @@ fn an_image_inner_glow_follows_its_visible_pixels() { assert!(lum(img.pixel(60, 50)) < lum(img.pixel(51, 50)), "threads {threads}: fading inward"); } } + +/// A 3D effect on a path's fill that builds nothing (Revolve after Roughen) draws the fill as +/// without it, under the object's own effects too: the art made for the object is drawn without +/// it, not as an object with effects again, and that art again, without end. With a raster effect +/// the object draws from that art, where without the 3D effect it draws as a path: their edges +/// differ a little. An Extrude may build. +#[test] +fn a_fills_3d_effect_that_builds_nothing_draws_the_fill_as_without_it() { + let rough = |detail: i64| Effect { + id: "distort.roughen".into(), + params: json!({"size": 10, "relative": false, "detail": detail, "points": "smooth"}), + visible: true, + }; + let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true }; + let revolve = Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }; + let extrude = Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }; + for (object, fill, three) in [ + (vec![shadow.clone()], rough(10), revolve.clone()), + (vec![], rough(10), revolve), + (vec![shadow], rough(60), extrude.clone()), + (vec![rough(60)], rough(60), extrude), + ] { + let draw = |three: Option<&Effect>| { + let mut d = doc_with(Rect::new(30.0, 30.0, 60.0, 60.0), Color::BLACK, vec![]); + let id = d.layers[0].children().unwrap()[0].id; + let n = d.node_mut(id).unwrap(); + n.appearance.effects = object.clone(); + n.appearance.fill_mut().unwrap().effects = [Some(&fill), three].into_iter().flatten().cloned().collect(); + Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixels + }; + let with = draw(Some(&three)); + if three.id == "threeD.revolve" { + let without = draw(None); + let differ = with.iter().zip(&without).filter(|(a, b)| a != b).count(); + let most = with.iter().zip(&without).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(differ * 100 < with.len() && most <= 32, "{object:?}: {differ} channels differ, by up to {most}"); + } + } +} From d8472b2acc5d07cf3c08fe9a2fd21553f6d50f06 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 02:18:53 -0700 Subject: [PATCH 04/14] Canvas redraw: a trace object's source in each version; review follow-ups Review of #6290. - An Image Trace object's hidden source image counts as shown in each version by that version's own first member: an edit that puts it first again (undoing Send to Back on a traced shape), or a new source image in its place, redraws where it shows. Tested with an image source on screen with the objects' views. - A shape of an Image Trace object in Outline view is left out by the rule every object is (node_checks::left_out), so the drawing and the measure of an edit can't disagree. - The renderer keeps the brush library's key from its first measure of 3D art until the library changes; the test says so, and that a change lets it go. - The test of a 3D effect that builds nothing covers it on a fill and on a stroke, without effects of the object's own (exactly as without it) and under a raster effect. On the commit before, "Canvas: a 3D effect on a fill that builds nothing draws without it": it needs no effect of the object's own. A 3D effect on a fill or stroke that builds nothing overflowed the stack on its own too. With a raster effect, the object now draws from its art, where without the 3D effect it draws as a path, whose raster effects are clipped to the path's own geometry, cutting off the item's Roughen spikes: the new drawing is the one that keeps them. --- crates/render/src/changed.rs | 35 +++++++------ crates/render/src/fx/tests.rs | 75 +++++++++++++++------------ crates/render/src/lib.rs | 7 +-- crates/render/src/node_checks.rs | 5 +- crates/render/src/renderer/nodes.rs | 14 +---- crates/render/src/tests/changed.rs | 80 +++++++++++++++++++++++------ 6 files changed, 135 insertions(+), 81 deletions(-) diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs index 5db3e3ab00..c1612f0c96 100644 --- a/crates/render/src/changed.rs +++ b/crates/render/src/changed.rs @@ -86,12 +86,13 @@ pub fn changed(old: &Document, new: &Document) -> Changed { } impl crate::Renderer { - /// [`changed`], measured through this renderer (`Renderer::bounds_of`): what it drew - /// of `old` it measured to draw it, and what it measures of `new`, a 3D effect's art included, - /// it holds to draw that with `opts`; the objects `opts` leaves out (hidden, templates and - /// guides the view hides, the instance a symbol is edited from) draw nothing. The view that drew `old` with this renderer and `opts` asks this. With - /// Overprint Preview or Separations Preview the renderer draws copies of the objects - /// (`proof::prepare`), so they are measured afresh and nothing is held. + /// [`changed`], measured through this renderer (`Renderer::bounds_of`): what it drew of `old` + /// it measured to draw it, and what it measures of `new`, a 3D effect's art included, it holds + /// to draw that with `opts`; the objects `opts` leaves out (hidden, templates and guides the + /// view hides, the instance a symbol is edited from) draw nothing. The view that drew `old` + /// with this renderer and `opts` asks this. With Overprint Preview or Separations Preview the + /// renderer draws copies of the objects (`proof::prepare`), so they are measured afresh and + /// nothing is held. pub fn changed(&mut self, old: &Document, new: &Document, opts: &crate::RenderOptions) -> Changed { let renderer = (!crate::proof::overprints(opts)).then_some(self); changed_with(old, new, renderer, opts) @@ -118,7 +119,7 @@ fn changed_with(old: &Document, new: &Document, renderer: Option<&mut crate::Ren if new.pattern_edit.is_some() || !old.same_outside_layers(new) { return Changed::Everything; } - let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None, renderer, opts, source: None }; + let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None, renderer, opts, sources: (None, None) }; diff.children(&old.layers, &new.layers); diff.area.map_or(Changed::Nothing, Changed::Area) } @@ -137,14 +138,15 @@ pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> /// [`changed`] in progress: the symbols' boxes of both versions, the area so far, the renderer /// that measures, if any (else each object is measured afresh), the options of the frames it -/// measures for, and the hidden source image of the Image Trace object whose members it compares. +/// measures for, and the hidden source image, in each version, of the Image Trace object whose +/// members it compares. struct Diff<'a, 'r> { old: SymbolBoxes<'a>, new: SymbolBoxes<'a>, area: Option, renderer: Option<&'r mut crate::Renderer>, opts: &'r crate::RenderOptions, - source: Option, + sources: (Option, Option), } impl Diff<'_, '_> { @@ -180,10 +182,11 @@ impl Diff<'_, '_> { && same_but_members(old, new) { // An Image Trace object draws its hidden source image, its first member, in some views. - let source = old.extras().trace.is_some().then(|| a.first().map(|s| s.id)).flatten(); - let outer = std::mem::replace(&mut self.source, source); + // Each version's own: an edit can put another member first (undoing Send to Back). + let first = |m: &[Arc]| old.extras().trace.is_some().then(|| m.first().map(|s| s.id)).flatten(); + let outer = std::mem::replace(&mut self.sources, (first(a), first(b))); self.children(a, b); - self.source = outer; + self.sources = outer; } else if let Some((area, moved, anchors)) = moved_anchors(old, new) { if moved <= FEW_MOVED && anchors >= MANY_ANCHORS { self.add(area); @@ -203,11 +206,11 @@ impl Diff<'_, '_> { /// (`node_checks::left_out`)? The source image of an Image Trace object draws in some views. /// The members of a group or layer left out are never compared ([`Self::node`]). fn unseen(&self, side: Side, n: &Node) -> bool { - let doc = match side { - Side::Old => self.old.document(), - Side::New => self.new.document(), + let (doc, source) = match side { + Side::Old => (self.old.document(), self.sources.0), + Side::New => (self.new.document(), self.sources.1), }; - self.source != Some(n.id) && crate::node_checks::left_out(self.opts, doc, n) + source != Some(n.id) && crate::node_checks::left_out(self.opts, doc, n) } /// The cull bounds of `n` of the `side` version, through the renderer when there is one; diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 71c0ad412d..211d62066f 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -842,10 +842,10 @@ fn a_measuring_pass_sweeps_the_bounds_of_held_art_as_they_double() { assert!(r.three_d_sweeps <= 2, "{} sweeps", r.three_d_sweeps); } -/// A renderer checks the brush library between frames only while it keeps bounds measured with -/// brushed strokes in 3D art. +/// A renderer checks the brush library between frames only once it has measured 3D art, and again +/// only after measuring 3D art since the library last changed. #[test] -fn the_brush_library_is_checked_only_with_3d_bounds_kept() { +fn the_brush_library_is_checked_only_after_3d_art_is_measured() { let mut d = doc_with(Rect::new(30.0, 30.0, 60.0, 60.0), Color::BLACK, vec![]); let mut r = Renderer::new(); r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); @@ -854,6 +854,12 @@ fn the_brush_library_is_checked_only_with_3d_bounds_kept() { d.node_mut(id).unwrap().appearance.effects = vec![Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }]; r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); assert!(r.brush_library.is_some()); + // A library changed: the bounds measured with the old one go, and so does its key. + let mut lib = vectorcraft_brush::library(&d); + lib.retain(|b| b.name != "3 pt. Round"); + vectorcraft_brush::store(&mut d, &lib); + r.check_symbols(&d); + assert_eq!(r.brush_library, None); } /// An object whose 3D effect builds nothing (Revolve on a group) is measured by the renderer as the @@ -1701,41 +1707,46 @@ fn an_image_inner_glow_follows_its_visible_pixels() { } } -/// A 3D effect on a path's fill that builds nothing (Revolve after Roughen) draws the fill as -/// without it, under the object's own effects too: the art made for the object is drawn without -/// it, not as an object with effects again, and that art again, without end. With a raster effect -/// the object draws from that art, where without the 3D effect it draws as a path: their edges -/// differ a little. An Extrude may build. +/// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen) draws the +/// item as without it: the art made for the object is drawn without it, not as an object with +/// effects again, and that art again, without end. Without effects of its own the object draws +/// exactly as without the 3D effect. With a raster effect (Drop Shadow, Gaussian Blur) it draws from +/// that art, where without the 3D effect it draws as a path, whose raster effects are clipped to +/// the path's own geometry: there the item's Roughen spikes are cut off, so the two differ past +/// the path, and match inside it. #[test] -fn a_fills_3d_effect_that_builds_nothing_draws_the_fill_as_without_it() { - let rough = |detail: i64| Effect { - id: "distort.roughen".into(), - params: json!({"size": 10, "relative": false, "detail": detail, "points": "smooth"}), - visible: true, - }; - let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true }; +fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { + let rough = + Effect { id: "distort.roughen".into(), params: json!({"size": 10, "relative": false, "detail": 10, "points": "smooth"}), visible: true }; let revolve = Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }; - let extrude = Effect { id: "threeD.extrude".into(), params: json!({"depth": 20.0}), visible: true }; - for (object, fill, three) in [ - (vec![shadow.clone()], rough(10), revolve.clone()), - (vec![], rough(10), revolve), - (vec![shadow], rough(60), extrude.clone()), - (vec![rough(60)], rough(60), extrude), - ] { - let draw = |three: Option<&Effect>| { - let mut d = doc_with(Rect::new(30.0, 30.0, 60.0, 60.0), Color::BLACK, vec![]); - let id = d.layers[0].children().unwrap()[0].id; + let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true }; + let blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 2}), visible: true }; + for (object, on_fill) in [(vec![], true), (vec![], false), (vec![shadow], true), (vec![blur], false)] { + let draw = |three: bool| { + let mut d = Document::new(100.0, 100.0); + let ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0); + let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)), ap); + let l = d.layers[0].id; + let id = d.insert(Some(l), 0, n).unwrap(); let n = d.node_mut(id).unwrap(); n.appearance.effects = object.clone(); - n.appearance.fill_mut().unwrap().effects = [Some(&fill), three].into_iter().flatten().cloned().collect(); + let item = if on_fill { + n.appearance.fill_mut().unwrap().effects = vec![]; + &mut n.appearance.items[0] + } else { + &mut n.appearance.items[1] + }; + *item.effects_mut() = [Some(rough.clone()), three.then(|| revolve.clone())].into_iter().flatten().collect(); + assert!(crate::effects::revolve_art(d.node(id).unwrap()).is_none(), "builds nothing"); Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixels }; - let with = draw(Some(&three)); - if three.id == "threeD.revolve" { - let without = draw(None); - let differ = with.iter().zip(&without).filter(|(a, b)| a != b).count(); - let most = with.iter().zip(&without).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); - assert!(differ * 100 < with.len() && most <= 32, "{object:?}: {differ} channels differ, by up to {most}"); + let (with, without) = (draw(true), draw(false)); + if object.is_empty() { + assert!(with == without, "on the fill {on_fill}: as without it"); + } else { + // Inside the path, well within its Roughen. + let at = |p: &[u8], x: usize, y: usize| p[(y * 100 + x) * 4..][..4].to_vec(); + assert_eq!(at(&with, 45, 45), at(&without, 45, 45), "{object:?}"); } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 89a673855e..32fb8fd029 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -84,9 +84,10 @@ pub struct Renderer { /// The plug-in revision ([`effects::plugin_revision`]) that `geom`, `fx_geom` and `fx_arts` were /// last checked against (see [`Self::check_plugins`]). plugin_revision: u64, - /// The key of the brush library ([`brush_fx::BrushCache::library_key`]) that `geom`'s bounds - /// of 3D art measured brushed strokes by (see [`Self::check_symbols`]), while `geom` may hold - /// such bounds (`Some`). + /// The key of the brush library ([`brush_fx::BrushCache::library_key`]) when `geom`'s bounds of + /// 3D art were measured with it (see [`Self::check_symbols`]): set by the first such measure + /// and kept, whether or not those bounds still are, until the library changes. `None` until + /// then, and nothing is checked. brush_library: Option, /// Is a live-effect plug-in that paints its pieces installed (found when `plugin_revision` /// changes)? diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs index 8a150fd3c9..c4c81b3373 100644 --- a/crates/render/src/node_checks.rs +++ b/crates/render/src/node_checks.rs @@ -115,8 +115,9 @@ pub(crate) fn left_out(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Docum !a.visible || view_item || opts.hidden.contains(&a.id) || edited_instance(opts, doc, a.id) } -/// Is `id` the symbol instance that `doc`'s symbol is edited from in isolation (drawn without it)? -pub(crate) fn edited_instance(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId) -> bool { +/// Symbol editing mode on screen: is `id` the instance editing was entered from, which is not drawn +/// (with its masks and effects), since the editing layer holds a copy of its art in its place? +fn edited_instance(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, id: vectorcraft_doc::NodeId) -> bool { opts.isolated.is_some() && doc.symbol_edit.as_ref().is_some_and(|e| e.instance == Some(id)) } diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 4f67b16ec3..9b59367deb 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -216,13 +216,6 @@ impl Renderer { self.path_of(f.symbols, &owner) } - /// Symbol editing mode on screen: `id` is the instance editing was entered from, which is not - /// drawn (with its masks and effects), since the editing layer holds a copy of its art in its - /// place. - fn edited_instance(f: &Frame, id: NodeId) -> bool { - crate::node_checks::edited_instance(f.opts, f.doc, id) - } - /// Whether `a` is left out of this frame: hidden, a skipped template or guide, the instance a /// symbol is edited from, or culled. fn skipped(&mut self, f: &Frame, a: &Arc) -> bool { @@ -661,11 +654,8 @@ impl Renderer { /// `fx_owner`, so its path, its live-effect geometry and, through [`Self::draw_kept_node`], a /// live object's evaluation come from the caches. fn draw_outline_member(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc) { - if !c.visible - || f.opts.hidden.contains(&c.id) - || f.opts.hide_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) - || Self::edited_instance(f, c.id) - { + // Left out as any object is ([`Self::skipped`]). + if crate::node_checks::left_out(f.opts, f.doc, c) { return; } match self.bounds_of(f.symbols, c) { diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index cb32de8706..8d2a58bc71 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -63,24 +63,24 @@ fn redraw(old: &Document, new: &Document) -> Changed { let change = changed(old, new); let mut fresh = Renderer::new(); let before = fresh.render(old, W, H, view(), &opts); - patched(&mut fresh, &before, new, change, &after); + patched(&mut fresh, &before, new, change, &after, &opts); let mut r = Renderer::new(); let before = r.render(old, W, H, view(), &opts); let measured = r.changed(old, new, &opts); - patched(&mut r, &before, new, measured, &after); + patched(&mut r, &before, new, measured, &after, &opts); change } -/// `before` with `new` drawn again by `r` only where `change` says, checked against `after`. -fn patched(r: &mut Renderer, before: &Rendered, new: &Document, change: Changed, after: &Rendered) { - let opts = RenderOptions::default(); +/// `before` with `new` drawn again by `r` with `opts` only where `change` says, checked against +/// `after`. +fn patched(r: &mut Renderer, before: &Rendered, new: &Document, change: Changed, after: &Rendered, opts: &RenderOptions) { let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() }; match change { Changed::Everything => patched.pixels = after.pixels.clone(), Changed::Nothing => {} Changed::Area(area) => { if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) { - let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts); + let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), opts); for y in 0..y1 - y0 { let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize); let n = ((x1 - x0) * 4) as usize; @@ -540,36 +540,84 @@ fn a_renderer_measures_an_edit_with_the_3d_art_it_draws() { assert_eq!(apart, 1, "changed keeps nothing for the renderer"); } -/// An Image Trace object shown with its source image (`view`): its hidden source, at the left, and -/// two traced shapes, at the right. The ids of the source and of the second shape. -fn traced(view: &str) -> (Document, NodeId, NodeId) { +/// An Image Trace object shown with its source image (`view`): its hidden source, a red image at +/// the left, and two traced shapes, at the right; the object's id, and the ids of the source and +/// of the second shape. +fn traced(view: &str) -> (Document, NodeId, NodeId, NodeId) { let mut d = Document::new(W as f64, H as f64); - let mut source = rect(&mut d, Rect::new(10.0, 10.0, 40.0, 40.0), Color::rgb(0.5, 0.5, 0.5)); - source.visible = false; + let mut png = vec![]; + let red = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255])); + red.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), vectorcraft_doc::ImageBlob::new("image/png", png)); + let source = red_image(&mut d, 10.0); let a = rect(&mut d, Rect::new(60.0, 10.0, 70.0, 20.0), Color::rgb(0.0, 0.5, 0.0)); let b = rect(&mut d, Rect::new(80.0, 10.0, 90.0, 20.0), Color::rgb(1.0, 0.5, 0.0)); let ids = (source.id, b.id); let mut g = Node::group(d.alloc_id(), vec![Arc::new(source), Arc::new(a), Arc::new(b)]); g.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": view }))); let l = d.layers[0].id; - d.insert(Some(l), 0, g).unwrap(); - (d, ids.0, ids.1) + let g = d.insert(Some(l), 0, g).unwrap(); + (d, g, ids.0, ids.1) +} + +/// A hidden 30 pt square of the red image at (`x`, 10), as an Image Trace object's source. +fn red_image(d: &mut Document, x: f64) -> Node { + let xf = Affine::translate((x, 10.0)) * Affine::scale(30.0); + let im = vectorcraft_doc::ImageObject { key: "red".into(), width: 1, height: 1, xf, link: None, placement: Default::default(), pixels: None }; + let mut source = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + source.visible = false; + source +} + +/// Draws `new` again only where the renderer that drew `old` on screen, its Image Trace objects in +/// their views, measures that it changed, and checks that this is `new` drawn whole. +fn redraw_traced(old: &Document, new: &Document) -> Changed { + let opts = RenderOptions { trace_views: true, ..Default::default() }; + let after = Renderer::new().render(new, W, H, view(), &opts); + let mut r = Renderer::new(); + let before = r.render(old, W, H, view(), &opts); + let red = |r: &Rendered| r.pixels.chunks(4).any(|p| p[0] > 200 && p[1] < 60); + assert!(red(&before) || red(&after), "a source shows"); + let measured = r.changed(old, new, &opts); + patched(&mut r, &before, new, measured, &after, &opts); + measured } #[test] fn moving_a_trace_objects_source_changes_where_it_shows() { for view in ["sourceImage", "outlinesWithSourceImage"] { - let (old, source, _) = traced(view); + let (old, _, source, _) = traced(view); let mut new = old.clone(); new.node_mut(source).unwrap().transform(Affine::translate((5.0, 30.0)), false); - let a = area(redraw(&old, &new)); + let a = area(redraw_traced(&old, &new)); assert!(a.contains(Point::new(12.0, 12.0)) && a.contains(Point::new(44.0, 69.0)), "{view}: before and after: {a:?}"); } } +/// A trace object's source that is first again after an edit (undoing Send to Back on a traced +/// shape), or a new source in its place, draws where it shows. +#[test] +fn a_trace_objects_new_first_member_is_its_source() { + let (back, g, source, shape) = traced("sourceImage"); + // The shape sent to the back, before the source. + let mut sent = back.clone(); + let children = sent.node_mut(g).unwrap().children_mut().unwrap(); + let at = children.iter().position(|c| c.id == shape).unwrap(); + let moved = children.remove(at); + children.insert(0, moved); + redraw_traced(&sent, &back); + // A new source image, with an id of its own. + let mut replaced = back.clone(); + let new_source = red_image(&mut replaced, 55.0); + let children = replaced.node_mut(g).unwrap().children_mut().unwrap(); + assert_eq!(children[0].id, source); + children[0] = Arc::new(new_source); + redraw_traced(&back, &replaced); +} + #[test] fn recolouring_a_traced_shape_changes_only_the_shape() { - let (old, _, shape) = traced("outlinesWithSourceImage"); + let (old, _, _, shape) = traced("outlinesWithSourceImage"); let mut new = old.clone(); new.node_mut(shape).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); let a = area(redraw(&old, &new)); From 0f75f8e9dae1f5a7c78625109092efa272cf46ee Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 09:48:50 -0700 Subject: [PATCH 05/14] Canvas redraw: each version's trace source read from its own node; the 3D-bounds map's bound documented Diff took the Image Trace flag of the old version for both sides when it read each version's first member. Both versions have the same extras there (same_but_members), so the result was right, but each side now reads its own node. The doc of Renderer's three_d_bounds now says what adds an entry, when entries are swept, and the bound that follows: about twice the live 3D art plus 1024 entries. --- crates/render/src/changed.rs | 4 ++-- crates/render/src/lib.rs | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs index c1612f0c96..1c15247034 100644 --- a/crates/render/src/changed.rs +++ b/crates/render/src/changed.rs @@ -183,8 +183,8 @@ impl Diff<'_, '_> { { // An Image Trace object draws its hidden source image, its first member, in some views. // Each version's own: an edit can put another member first (undoing Send to Back). - let first = |m: &[Arc]| old.extras().trace.is_some().then(|| m.first().map(|s| s.id)).flatten(); - let outer = std::mem::replace(&mut self.sources, (first(a), first(b))); + let first = |n: &Node, m: &[Arc]| n.extras().trace.is_some().then(|| m.first().map(|s| s.id)).flatten(); + let outer = std::mem::replace(&mut self.sources, (first(old, a), first(new, b))); self.children(a, b); self.sources = outer; } else if let Some((area, moved, anchors)) = moved_anchors(old, new) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 32fb8fd029..4b24342826 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -175,7 +175,11 @@ pub struct Renderer { /// let go when a frame ends. pub(crate) pass_bounds: PtrMap, Option)>, /// The 3D effects' art among the art `fx_art` made, by its address, with the art (weakly: what - /// keeps the art is `fx_arts` and `measured`) and its bounds once measured. + /// keeps the art is `fx_arts` and `measured`) and its bounds once measured. An entry is added + /// for every 3D art made, measured or not, and entries whose art is gone are swept when the map + /// reaches twice the size it kept at the last sweep (at least 1024), so it holds at most about + /// twice the live 3D art plus 1024 entries. Until swept, an entry's weak pointer keeps the art's + /// allocation (not its contents) alive. three_d_bounds: PtrMap, /// How many entries `three_d_bounds` kept when it last let go of the art no longer held: it /// does so again when it holds twice as many. From baa5bab2eaa6d6694ef6deb667e6fe440d1dda1f Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 09:54:50 -0700 Subject: [PATCH 06/14] Docs: the 3D-bounds map's sweeps and bound, and the change in the parity doc's revision history --- crates/render/src/lib.rs | 7 ++++--- docs/target-app-parity.md | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4b24342826..5ccb3f0b9c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -176,9 +176,10 @@ pub struct Renderer { pub(crate) pass_bounds: PtrMap, Option)>, /// The 3D effects' art among the art `fx_art` made, by its address, with the art (weakly: what /// keeps the art is `fx_arts` and `measured`) and its bounds once measured. An entry is added - /// for every 3D art made, measured or not, and entries whose art is gone are swept when the map - /// reaches twice the size it kept at the last sweep (at least 1024), so it holds at most about - /// twice the live 3D art plus 1024 entries. Until swept, an entry's weak pointer keeps the art's + /// for every 3D art made, measured or not. Entries whose art is gone are swept when a frame + /// ends (`let_go_of_measured`) and, for a renderer that only measures, when the map reaches + /// twice the size it kept at the last sweep (at least 1024): it holds at most max(twice the + /// entries kept at the last sweep, 1024). Until swept, an entry's weak pointer keeps the art's /// allocation (not its contents) alive. three_d_bounds: PtrMap, /// How many entries `three_d_bounds` kept when it last let go of the art no longer held: it diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6e0a9b1edd..d3580fcea3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1903,6 +1903,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack (evidence in Feature revisions). Scores and estimates unchanged. | | 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. | | 2026-11-09 | minor | Strokes/brushes: Art brush Overlap and Pattern brush auto corners removed from remaining (evidence already covered them); symbols remaining clarified for dynamic on-canvas beyond solid fill/stroke; scores and hours unchanged. | | 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. | From 049f8c396337c035eef0c4a51996a217d72d4268 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 13:10:41 -0700 Subject: [PATCH 07/14] Canvas redraw: tests for edits measured under overprint, with Vector Halftone, and to the instance symbol editing came from Review follow-up: these three branches of what a renderer measures had no test. Under Overprint Preview a renderer measures an edit as changed() does and holds none of the 3D art it made for it; a 3D effect with Vector Halftone is measured by the free measure, with no solid built and no art held; and on screen in symbol editing mode, an edit to the instance it was entered from changes nothing. Each test fails with its branch taken out. --- crates/render/src/fx/tests.rs | 15 +++++++ crates/render/src/tests/changed.rs | 70 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 211d62066f..632f8b7236 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -674,6 +674,21 @@ fn the_renderer_measures_3d_art_with_crop_marks_as_drawn() { assert_eq!(Renderer::new().bounds_of(&symbols, &n), Some(free)); } +/// Vector Halftone draws dots in place of a 3D effect's solid: the renderer measures the object as +/// the free measure does, building no solid and holding no art to draw it with. +#[test] +fn the_renderer_measures_3d_art_with_vector_halftone_as_drawn() { + let fx = vec![("threeD.extrude", json!({"depth": 30.0, "position": "isometricTop"})), (crate::effects::addons::halftone::HALFTONE, json!({}))]; + let d = doc_with(Rect::new(40.0, 40.0, 60.0, 60.0), Color::BLACK, fx); + let n = d.layers[0].children().unwrap()[0].clone(); + let symbols = d.symbol_boxes(); + let free = super::cull_bounds(&symbols, &n).unwrap(); + let mut r = Renderer::new(); + assert_eq!(r.bounds_of(&symbols, &n), Some(free)); + assert_eq!(r.stats.three_d_built, 0); + assert_eq!(r.measured_bytes(), 0); +} + /// A renderer measures the art over each artboard as [`crate::encode::art_bounds_on`] does, and /// builds each 3D solid once for every artboard it measures. #[test] diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index 8d2a58bc71..43a265a9ad 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -651,3 +651,73 @@ fn an_edit_to_what_the_view_hides_changes_nothing() { assert_eq!(r.changed(&old, &new, &opts), Changed::Nothing); assert!(matches!(changed(&old, &new), Changed::Area(_)), "shown, they change"); } + +/// Overprint Preview and separations draw objects through the inks they print, not through the +/// art a renderer keeps for them: a renderer measures an edit there as [`changed`] does and holds +/// none of the 3D art it made to measure it. +#[test] +fn an_edit_measured_under_overprint_holds_no_art() { + use vectorcraft_doc::{CharStyle, TextObject}; + let mut old = Document::new(110.0, 80.0); + let style = CharStyle { size: 30.0, fill: Paint::solid(Color::rgb(0.1, 0.2, 0.6)), ..Default::default() }; + let mut n = Node::new(old.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(40.0, 50.0), "K", style)))); + let extrude = |depth: f64| vectorcraft_doc::Effect { + id: "threeD.extrude".into(), + params: serde_json::json!({"depth": depth, "position": "isometricTop"}), + visible: true, + }; + n.appearance.effects.push(extrude(60.0)); + let l = old.layers[0].id; + let id = old.insert(Some(l), 0, n).unwrap(); + let mut new = old.clone(); + new.node_mut(id).unwrap().appearance.effects[0] = extrude(5.0); + let _fonts = crate::tests_fontchange::fonts_steady(); + let held = |opts: &RenderOptions| { + crate::tests_fontchange::with_fonts_unchanged(|| { + let mut r = Renderer::new(); + r.render(&old, W, H, view(), opts); + (area(r.changed(&old, &new, opts)), r.measured_bytes()) + }) + }; + let (_, on_screen) = held(&RenderOptions::default()); + assert!(on_screen > 0, "the screen view holds the art it measured with"); + let (measured, overprinted) = held(&RenderOptions { overprint_preview: true, ..Default::default() }); + assert_eq!(overprinted, 0); + assert_eq!(measured, area(changed(&old, &new)), "as changed measures it"); +} + +/// Symbol editing mode on screen doesn't draw the instance it was entered from: an edit to that +/// instance changes nothing there, and changes where it draws in an export. +#[test] +fn an_edit_to_the_instance_symbol_editing_came_from_changes_nothing_on_screen() { + use vectorcraft_doc::{Symbol, SymbolEdit}; + let mut old = Document::new(W as f64, H as f64); + let art = rect(&mut old, Rect::new(20.0, 20.0, 50.0, 50.0), Color::rgb(0.0, 0.5, 0.0)); + old.symbols.push(Symbol { dynamic: false, name: "Square".into(), art: Arc::new(art.clone()) }); + let inst = Node::new(old.alloc_id(), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::IDENTITY }); + let l = old.layers[0].id; + let inst = old.insert(Some(l), 0, inst).unwrap(); + let layer = old.add_layer(Some("Square")); + let copy = old.reid(&art); + old.insert(Some(layer), usize::MAX, copy).unwrap(); + old.symbol_edit = Some(SymbolEdit { + symbol: "Square".into(), + layer, + instance: Some(inst), + xf: Affine::IDENTITY, + group: false, + changed: false, + back_isolation: None, + back_layer: None, + }); + let mut new = old.clone(); + new.node_mut(inst).unwrap().transform(Affine::translate((30.0, 30.0)), false); + let screen = RenderOptions { isolated: Some(layer), ..Default::default() }; + let mut r = Renderer::new(); + r.render(&old, W, H, view(), &screen); + assert_eq!(r.changed(&old, &new, &screen), Changed::Nothing); + let export = RenderOptions::default(); + let mut r = Renderer::new(); + r.render(&old, W, H, view(), &export); + assert!(matches!(r.changed(&old, &new, &export), Changed::Area(_)), "an export draws it"); +} From af754bcb23dc0bf226d7f451b5737b8c4faffb47 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 13:13:17 -0700 Subject: [PATCH 08/14] Canvas redraw: the renderer's measure tests check what their names say Review follow-ups: - The tests that a group with effects of its own is measured once a pass plant its kept bounds after the first artboard and read them back over the next two, so a measure made again fails. Its solid is built by the free measure, which the renderer's three_d_built doesn't count. - The budget test checks that every object, past the budget too, is measured as the free measure does and draws as a new renderer draws it. - A 3D effect that builds nothing draws the object exactly as without it, with a Drop Shadow or Gaussian Blur too, which reach past the path. The doc comment said those differ past the path; they don't. - The brush widened in view first checks that the wider brush draws otherwise. - Recoloring a traced shape changes no more than the shape, its stroke and a pixel's anti-aliasing. --- crates/render/src/fx/tests.rs | 54 ++++++++++++++++++++---------- crates/render/src/tests/changed.rs | 4 ++- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 632f8b7236..a754a25a5f 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -771,6 +771,14 @@ fn measured_art_is_held_within_its_budget() { r.bounds_of(&symbols, n); } assert!(r.measured_bytes <= one * 3 && r.measured.len() <= 3, "{} bytes in {} entries", r.measured_bytes, r.measured.len()); + // Every object is measured right, those past the budget too, and draws as a new renderer + // draws it, its art made again. + for n in d.layers[0].children().unwrap() { + assert_eq!(r.bounds_of(&symbols, n), super::cull_bounds(&symbols, n), "{:?}", n.id); + } + let (view, opts) = (Affine::translate((-500.0, 0.0)), RenderOptions::default()); + let drawn = r.render(&d, 800, 100, view, &opts).pixels; + assert!(drawn == Renderer::new().render(&d, 800, 100, view, &opts).pixels); } /// An edit to 3D objects the frame doesn't draw (hidden, in a hidden group, drawn hidden) builds @@ -920,11 +928,18 @@ fn a_group_holding_3d_art_is_measured_once_a_pass() { d.insert(Some(l), 0, g).unwrap(); let g = d.layers[0].children().unwrap()[0].clone(); let mut r = Renderer::new(); - for i in 0..3 { + let first = Rect::new(0.0, 0.0, 100.0, 100.0); + assert_eq!(r.art_bounds_on(&d, first), crate::encode::art_bounds_on(&d, first)); + let key = Arc::as_ptr(&g) as usize; + assert!(r.geom.contains_key(&key), "kept for the next artboard"); + // The group's bounds, planted over the next artboards: they read the kept bounds, measuring + // nothing again. + let planted = Rect::new(100.0, 0.0, 300.0, 100.0); + r.geom.get_mut(&key).unwrap().bounds = Some(planted); + for i in 1..3 { let rect = Rect::new(f64::from(i) * 100.0, 0.0, f64::from(i) * 100.0 + 100.0, 100.0); - assert_eq!(r.art_bounds_on(&d, rect), crate::encode::art_bounds_on(&d, rect)); + assert_eq!(r.art_bounds_on(&d, rect), Some(planted), "artboard {i}"); } - assert!(r.geom.contains_key(&(Arc::as_ptr(&g) as usize)), "kept for the next artboard"); } /// A pass that only measures (Fit to Artwork Bounds, an export) holds no 3D art past its budget: @@ -1070,6 +1085,8 @@ fn a_rasterized_3d_objects_brush_widened_is_drawn() { #[test] fn a_rasterized_objects_brush_widened_in_view_is_drawn_again() { let (drawn, fresh) = drawn_after_a_brush_widens(40.0, 12.0); + let (_, before) = drawn_after_a_brush_widens(40.0, 3.0); + assert!(fresh != before, "the wider brush draws otherwise"); assert!(drawn == fresh, "as a new renderer draws it"); } @@ -1096,11 +1113,18 @@ fn a_group_with_a_hidden_member_is_measured_once_a_pass() { d.insert(Some(l), 0, g).unwrap(); let g = d.layers[0].children().unwrap()[0].clone(); let mut r = Renderer::new(); - for i in 0..3 { + let first = Rect::new(0.0, 0.0, 100.0, 100.0); + assert_eq!(r.art_bounds_on(&d, first), crate::encode::art_bounds_on(&d, first)); + let key = Arc::as_ptr(&g) as usize; + assert!(r.pass_bounds.contains_key(&key), "kept for the next artboard"); + // The group's bounds, planted over the next artboards: they read the kept bounds, measuring + // nothing again. + let planted = Rect::new(100.0, 0.0, 300.0, 100.0); + r.pass_bounds.get_mut(&key).unwrap().1 = Some(planted); + for i in 1..3 { let rect = Rect::new(f64::from(i) * 100.0, 0.0, f64::from(i) * 100.0 + 100.0, 100.0); - assert_eq!(r.art_bounds_on(&d, rect), crate::encode::art_bounds_on(&d, rect)); + assert_eq!(r.art_bounds_on(&d, rect), Some(planted), "artboard {i}"); } - assert!(r.pass_bounds.contains_key(&(Arc::as_ptr(&g) as usize)), "kept for the next artboard"); } /// Art made again for an object, after what it read from outside it changed, takes the place of @@ -1724,11 +1748,9 @@ fn an_image_inner_glow_follows_its_visible_pixels() { /// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen) draws the /// item as without it: the art made for the object is drawn without it, not as an object with -/// effects again, and that art again, without end. Without effects of its own the object draws -/// exactly as without the 3D effect. With a raster effect (Drop Shadow, Gaussian Blur) it draws from -/// that art, where without the 3D effect it draws as a path, whose raster effects are clipped to -/// the path's own geometry: there the item's Roughen spikes are cut off, so the two differ past -/// the path, and match inside it. +/// effects again, and that art again, without end. The object draws exactly as without the 3D +/// effect, with a raster effect of its own (Drop Shadow, Gaussian Blur) too, which reaches past +/// the path. #[test] fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { let rough = @@ -1756,12 +1778,10 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixels }; let (with, without) = (draw(true), draw(false)); - if object.is_empty() { - assert!(with == without, "on the fill {on_fill}: as without it"); - } else { - // Inside the path, well within its Roughen. - let at = |p: &[u8], x: usize, y: usize| p[(y * 100 + x) * 4..][..4].to_vec(); - assert_eq!(at(&with, 45, 45), at(&without, 45, 45), "{object:?}"); + assert!(with == without, "{object:?} on the fill {on_fill}: as without it"); + if !object.is_empty() { + let past = (0..100usize).flat_map(|y| (0..100usize).map(move |x| (x, y))).filter(|(x, y)| *x > 62 || *y > 62); + assert!(past.into_iter().any(|(x, y)| with[(y * 100 + x) * 4 + 3] > 0), "{object:?}: drawn past the path"); } } } diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index 43a265a9ad..b7be9f715a 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -621,7 +621,9 @@ fn recolouring_a_traced_shape_changes_only_the_shape() { let mut new = old.clone(); new.node_mut(shape).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); let a = area(redraw(&old, &new)); - assert!(a.x0 > 72.0, "not the source or the other shape: {a:?}"); + // The shape, its 2 pt stroke and a pixel's anti-aliasing, at most. + let shape = Rect::new(80.0, 10.0, 90.0, 20.0).inflate(3.0, 3.0); + assert!(a.union(shape) == shape, "only the shape: {a:?}"); } /// Templates and guides that the view hides draw nothing: an edit to them changes nothing. From d0b26c077293504c525347c24e0e27fde07b388c Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 13:19:55 -0700 Subject: [PATCH 09/14] Canvas redraw: the let-go test receives the worker's job; the fit cache's key documented Review follow-ups: - The test that art measured for an edit not drawn on this thread is let go first checks that measuring the edit through the canvas's renderer holds art, so a measure that held nothing can't pass it. Its worker is a test channel whose job the test receives, in place of a real thread's pending job, which a finished frame could clear. Taking out either let_go_of_measured fails it. - fit_has_art's comment says why its one kept answer can't be stale: a document's uid is never reused in the process and its revision only moves forward. --- crates/ui-egui/src/canvas/tests.rs | 15 ++++++++++++--- crates/ui-egui/src/panels/artboards.rs | 5 ++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index f37bacf64c..15d26a674c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10693,14 +10693,23 @@ fn art_measured_for_an_edit_not_drawn_here_is_let_go() { app.run("effect.apply", json!({"effect": "threeD.extrude", "ids": [id], "params": {"depth": 60.0}})).unwrap(); let ctx = egui::Context::default(); shapes(&mut app, &ctx); + // A worker whose jobs the test receives: none finishes unless the test says so. + let mut jobs = None; if worker { - app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone()); + let (w, rx, done) = crate::render_worker::Worker::test_channel(); + app.canvas.worker = Some(w); + jobs = Some((rx, done)); app.canvas.last_ms = 100.0; } app.run("effect.setParams", json!({"ids": [id], "index": 0, "item": null, "params": {"depth": 5.0}})).unwrap(); + // Measured through the canvas's renderer, the edit holds the art made for it. + let (shown, opts) = app.canvas.shown.clone().unwrap(); + let now = app.session.active().unwrap().doc.clone(); + app.canvas.renderer.changed(&shown, &now, &opts); + assert!(app.canvas.renderer.measured_bytes() > 0, "worker {worker}: art held"); shapes(&mut app, &ctx); - if worker { - assert!(app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()), "the worker draws it"); + if let Some((rx, _)) = &jobs { + assert!(rx.try_recv().is_ok(), "the worker draws it"); } assert_eq!(app.canvas.renderer.measured_bytes(), 0, "worker {worker}"); app.canvas.worker = None; diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 269395b5da..b167b1175b 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -314,7 +314,10 @@ pub struct MenuFacts { /// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. The /// answer is kept for the document's revision: the canvas menu asks on every frame it is open -/// without its [`MenuFacts`], and measuring builds the solids of 3D effects. +/// without its [`MenuFacts`], and measuring builds the solids of 3D effects. One answer is kept, +/// the last asked: a document's uid is never given to another in the process, and its revision +/// only moves forward (a document read again, or a batch rolled back, moves past it), so a kept +/// answer is never another document's or another state's. pub(crate) fn fit_has_art(app: &VectorcraftApp, indices: &[usize]) -> bool { /// The document's uid and revision, the artboards asked about, and the answer. type Kept = Option<(u64, u64, Vec, bool)>; From bd9f6a04ef365d7cac513982ad6f714efdc73fa6 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 14:18:05 -0700 Subject: [PATCH 10/14] Canvas redraw: review follow-ups to the strengthened tests - A 3D effect that builds nothing: af754bcb2 said the object draws exactly as without it under a Drop Shadow or Gaussian Blur. That holds only for a stroked object. With only a fill, the raster effect draws from the art made for the object, where without the 3D effect it is clipped to the path, so Roughen's spikes differ: the earlier wording was right. The test keeps the stroked cases exact and restores fill-only cases with a Drop Shadow and a Gaussian Blur, which differ in under 4% of the pixels and nowhere inside the path. Its reach check compares with the object drawn without its raster effect, since Roughen alone reaches past the path. - The Vector Halftone test's bounds are a consistency check, and only holding no art tests the branch: its doc no longer says no solid is built (the free measure's halftone arm builds one). The overprint test's area check is labeled likewise. - Recoloring a traced shape bounds the area the renderer that drew the trace object measures too, inflated by half the stroke and a pixel. - The let-go test pins the nothing-to-draw branch: no frame, whole or part, began since the measure. - The parity doc's feature row names the stack overflow fix and its test; a group test's doc says what its fixture holds. --- crates/render/src/fx/tests.rs | 58 +++++++++++++++++++++--------- crates/render/src/tests/changed.rs | 19 ++++++---- crates/ui-egui/src/canvas/tests.rs | 8 +++++ docs/target-app-parity.md | 2 +- 4 files changed, 64 insertions(+), 23 deletions(-) diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index a754a25a5f..7bdd8c3c66 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -674,8 +674,8 @@ fn the_renderer_measures_3d_art_with_crop_marks_as_drawn() { assert_eq!(Renderer::new().bounds_of(&symbols, &n), Some(free)); } -/// Vector Halftone draws dots in place of a 3D effect's solid: the renderer measures the object as -/// the free measure does, building no solid and holding no art to draw it with. +/// Vector Halftone draws dots in place of a 3D effect's solid: the renderer doesn't measure the +/// object by art it holds to draw it with, and measures it as the free measure does. #[test] fn the_renderer_measures_3d_art_with_vector_halftone_as_drawn() { let fx = vec![("threeD.extrude", json!({"depth": 30.0, "position": "isometricTop"})), (crate::effects::addons::halftone::HALFTONE, json!({}))]; @@ -684,9 +684,9 @@ fn the_renderer_measures_3d_art_with_vector_halftone_as_drawn() { let symbols = d.symbol_boxes(); let free = super::cull_bounds(&symbols, &n).unwrap(); let mut r = Renderer::new(); + // A consistency check: measured from the dots' art, the bounds come out the same. assert_eq!(r.bounds_of(&symbols, &n), Some(free)); - assert_eq!(r.stats.three_d_built, 0); - assert_eq!(r.measured_bytes(), 0); + assert_eq!(r.measured_bytes(), 0, "no art held"); } /// A renderer measures the art over each artboard as [`crate::encode::art_bounds_on`] does, and @@ -911,7 +911,7 @@ fn a_3d_effect_that_builds_nothing_is_measured_alike() { assert_eq!(Renderer::new().bounds_of(&symbols, &n), crate::brush_fx::cull_bounds(&symbols, &n)); } -/// A group with effects of its own that holds 3D type is measured once for every artboard of a +/// A group with effects of its own that holds an extruded path is measured once for every artboard of a /// pass: its bounds are kept after the first. #[test] fn a_group_holding_3d_art_is_measured_once_a_pass() { @@ -1748,9 +1748,12 @@ fn an_image_inner_glow_follows_its_visible_pixels() { /// A 3D effect on a path's fill or stroke that builds nothing (Revolve after Roughen) draws the /// item as without it: the art made for the object is drawn without it, not as an object with -/// effects again, and that art again, without end. The object draws exactly as without the 3D -/// effect, with a raster effect of its own (Drop Shadow, Gaussian Blur) too, which reaches past -/// the path. +/// effects again, and that art again, without end. A stroked object draws exactly as without the +/// 3D effect, with a raster effect of its own (Drop Shadow, Gaussian Blur) too. An object with +/// only a fill and a raster effect draws from that art, where without the 3D effect it draws as a +/// path, whose raster effects are clipped to the path's own geometry: there Roughen's spikes are +/// cut off, so the two differ in a few pixels at the edge and match inside the path. Either way +/// the raster effect reaches past what the object draws without it. #[test] fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { let rough = @@ -1758,15 +1761,25 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { let revolve = Effect { id: "threeD.revolve".into(), params: json!({}), visible: true }; let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true }; let blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 2}), visible: true }; - for (object, on_fill) in [(vec![], true), (vec![], false), (vec![shadow], true), (vec![blur], false)] { - let draw = |three: bool| { + let cases = [ + (vec![], true, true), + (vec![], false, true), + (vec![shadow.clone()], true, true), + (vec![blur.clone()], false, true), + (vec![], true, false), + (vec![shadow], true, false), + (vec![blur], true, false), + ]; + for (object, on_fill, stroked) in cases { + let draw = |object: &[Effect], three: bool| { let mut d = Document::new(100.0, 100.0); - let ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0); + let stroke = if stroked { Paint::solid(Color::rgb(0.0, 0.0, 1.0)) } else { Paint::None }; + let ap = Appearance::basic(Paint::solid(Color::BLACK), stroke, 2.0); let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(30.0, 30.0, 60.0, 60.0)), ap); let l = d.layers[0].id; let id = d.insert(Some(l), 0, n).unwrap(); let n = d.node_mut(id).unwrap(); - n.appearance.effects = object.clone(); + n.appearance.effects = object.to_vec(); let item = if on_fill { n.appearance.fill_mut().unwrap().effects = vec![]; &mut n.appearance.items[0] @@ -1777,11 +1790,24 @@ fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { assert!(crate::effects::revolve_art(d.node(id).unwrap()).is_none(), "builds nothing"); Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()).pixels }; - let (with, without) = (draw(true), draw(false)); - assert!(with == without, "{object:?} on the fill {on_fill}: as without it"); + let what = format!("{object:?} on the fill {on_fill}, stroked {stroked}"); + let (with, without) = (draw(&object, true), draw(&object, false)); + if stroked || object.is_empty() { + assert!(with == without, "{what}: as without it"); + } else { + // Under 4% of the pixels, all at the edge, where Roughen's spikes are: inside the path + // they match. + let differ: Vec<(usize, usize)> = + (0..100 * 100).filter(|i| with[i * 4..i * 4 + 4] != without[i * 4..i * 4 + 4]).map(|i| (i % 100, i / 100)).collect(); + assert!(differ.len() * 25 < 100 * 100, "{what}: {} pixels differ", differ.len()); + let inside = differ.iter().find(|(x, y)| (33..57).contains(x) && (33..57).contains(y)); + assert!(inside.is_none(), "{what}: differs inside the path at {inside:?}"); + } if !object.is_empty() { - let past = (0..100usize).flat_map(|y| (0..100usize).map(move |x| (x, y))).filter(|(x, y)| *x > 62 || *y > 62); - assert!(past.into_iter().any(|(x, y)| with[(y * 100 + x) * 4 + 3] > 0), "{object:?}: drawn past the path"); + // Ink where the object without its raster effect draws none. + let plain = draw(&[], true); + let past = (0..100 * 100).filter(|i| plain[i * 4 + 3] == 0 && with[i * 4 + 3] > 0).count(); + assert!(past > 0, "{what}: drawn past the object"); } } } diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index b7be9f715a..67c476ee36 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -620,10 +620,16 @@ fn recolouring_a_traced_shape_changes_only_the_shape() { let (old, _, _, shape) = traced("outlinesWithSourceImage"); let mut new = old.clone(); new.node_mut(shape).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); - let a = area(redraw(&old, &new)); - // The shape, its 2 pt stroke and a pixel's anti-aliasing, at most. - let shape = Rect::new(80.0, 10.0, 90.0, 20.0).inflate(3.0, 3.0); - assert!(a.union(shape) == shape, "only the shape: {a:?}"); + // The shape, half its 2 pt stroke and a pixel's anti-aliasing, at most: measured by the + // renderer that drew the trace object in its view, and by the free measure. + let opts = RenderOptions { trace_views: true, ..Default::default() }; + let mut r = Renderer::new(); + r.render(&old, W, H, view(), &opts); + let measured = area(r.changed(&old, &new, &opts)); + let shape = Rect::new(80.0, 10.0, 90.0, 20.0).inflate(2.0, 2.0); + for (what, a) in [("measured", measured), ("free", area(redraw(&old, &new)))] { + assert!(a.union(shape) == shape, "{what}: only the shape: {a:?}"); + } } /// Templates and guides that the view hides draw nothing: an edit to them changes nothing. @@ -655,8 +661,8 @@ fn an_edit_to_what_the_view_hides_changes_nothing() { } /// Overprint Preview and separations draw objects through the inks they print, not through the -/// art a renderer keeps for them: a renderer measures an edit there as [`changed`] does and holds -/// none of the 3D art it made to measure it. +/// art a renderer keeps for them: a renderer measures an edit there holding none of the 3D art it +/// made to measure it, and as [`changed`] does. #[test] fn an_edit_measured_under_overprint_holds_no_art() { use vectorcraft_doc::{CharStyle, TextObject}; @@ -685,6 +691,7 @@ fn an_edit_measured_under_overprint_holds_no_art() { assert!(on_screen > 0, "the screen view holds the art it measured with"); let (measured, overprinted) = held(&RenderOptions { overprint_preview: true, ..Default::default() }); assert_eq!(overprinted, 0); + // A consistency check: the area itself doesn't tell the branch taken. assert_eq!(measured, area(changed(&old, &new)), "as changed measures it"); } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index a74659893b..ab196f3cfc 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -10707,7 +10707,15 @@ fn art_measured_for_an_edit_not_drawn_here_is_let_go() { let now = app.session.active().unwrap().doc.clone(); app.canvas.renderer.changed(&shown, &now, &opts); assert!(app.canvas.renderer.measured_bytes() > 0, "worker {worker}: art held"); + if !worker { + app.canvas.last_ms = 7.25; + } shapes(&mut app, &ctx); + if !worker { + // Nothing in view to draw: no frame, whole or part, began since the measure. + assert_eq!(app.canvas.last_ms, 7.25, "not drawn whole"); + assert!(app.canvas.renderer.stats.three_d_built > 0, "no part drawn"); + } if let Some((rx, _)) = &jobs { assert!(rx.try_recv().is_ok(), "the worker draws it"); } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8022707cbf..49f7d0c9ba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -41,7 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`. Scores and estimates unchanged. | +| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen), under an effect of the object's own, no longer overflows the stack on the canvas and in Object › Rasterize: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | From 758a31890dcc09de03dc9e11bc932a807aa2b090 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 14:18:05 -0700 Subject: [PATCH 11/14] Artboards: Fit to Artwork Bounds' kept answer follows the document's version and the plug-ins fit_has_art kept its answer for the document's uid and revision, which don't follow every change of what it measures: a document's tab coming to the front replaces it with its composite fonts synced, and a plug-in installed or removed changes what its effects draw, with no revision moving either time. The answer is now kept for that document Arc, held weakly as DocState::any_empty_artboards keeps its own, and the artboards asked about, until the plug-ins' revision moves. A selection or view change keeps it. The test replaces the document with art on an artboard, its revision as it was, and asks again: kept by revision, the answer was stale. --- crates/ui-egui/src/panels/artboards.rs | 28 +++++++++++++------- crates/ui-egui/src/panels/artboards/tests.rs | 12 ++++----- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index b167b1175b..a950a24c79 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -313,23 +313,31 @@ pub struct MenuFacts { } /// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. The -/// answer is kept for the document's revision: the canvas menu asks on every frame it is open -/// without its [`MenuFacts`], and measuring builds the solids of 3D effects. One answer is kept, -/// the last asked: a document's uid is never given to another in the process, and its revision -/// only moves forward (a document read again, or a batch rolled back, moves past it), so a kept -/// answer is never another document's or another state's. +/// canvas menu asks on every frame it is open without its [`MenuFacts`], and measuring builds the +/// solids of 3D effects, so one answer is kept, the last asked: for that version of the document +/// (the same `Arc`, held weakly so its address isn't reused, as +/// `DocState::any_empty_artboards` keeps its answer) and those artboards, until a plug-in is +/// installed or removed, which can change what its effects draw. A change of the selection or the +/// view keeps it. pub(crate) fn fit_has_art(app: &VectorcraftApp, indices: &[usize]) -> bool { - /// The document's uid and revision, the artboards asked about, and the answer. - type Kept = Option<(u64, u64, Vec, bool)>; - thread_local!(static KEPT: std::cell::RefCell = const { std::cell::RefCell::new(None) }); + /// The document measured, the artboards asked about, the plug-ins' revision, and the answer. + struct Kept { + doc: std::sync::Weak, + indices: Vec, + plugins: u64, + any: bool, + } + thread_local!(static KEPT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }); app.session.active().is_some_and(|st| { - if let Some(any) = KEPT.with_borrow(|k| k.as_ref().filter(|k| k.0 == st.uid && k.1 == st.revision && k.2 == indices).map(|k| k.3)) { + let plugins = crate::menus::plugin_revision(); + let same = |k: &Kept| std::ptr::eq(k.doc.as_ptr(), std::sync::Arc::as_ptr(&st.doc)) && k.indices == indices && k.plugins == plugins; + if let Some(any) = KEPT.with_borrow(|k| k.as_ref().filter(|k| same(k)).map(|k| k.any)) { return any; } // One renderer measures every artboard: each object once. let mut r = vectorcraft_render::Renderer::new(); let any = indices.iter().filter_map(|i| st.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&st.doc, a.rect).is_some()); - KEPT.set(Some((st.uid, st.revision, indices.to_vec(), any))); + KEPT.set(Some(Kept { doc: std::sync::Arc::downgrade(&st.doc), indices: indices.to_vec(), plugins, any })); any }) } diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index e10fd8ab33..23aab10159 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -1656,13 +1656,14 @@ fn the_open_menu_reads_what_the_right_click_measured() { assert!(labels.contains(&"Delete Empty Artboards") && !labels.contains(&"Fit to Artwork Bounds"), "{labels:?}"); } -/// Whether Fit to Artwork Bounds has art to fit is measured once for a revision of the document: -/// the canvas menu asks on every frame it is open. +/// Whether Fit to Artwork Bounds has art to fit is kept for a version of the document, not its +/// revision: a document replaced with no revision moved (composite fonts synced as its tab comes +/// to the front) is measured again. #[test] -fn fit_to_art_is_measured_once_a_revision() { +fn fit_to_art_is_measured_for_each_version_of_the_document() { let mut app = menu_app(2); assert!(!super::fit_has_art(&app, &[0, 1])); - // Art on the first artboard, the revision left as it was: the answer kept stands. + // Art on the first artboard, the revision left as it was. let st = app.session.active_mut().unwrap(); let mut doc = (*st.doc).clone(); let l = doc.layers[0].id; @@ -1674,9 +1675,8 @@ fn fit_to_art_is_measured_once_a_revision() { ); doc.insert(Some(l), 0, art).unwrap(); st.doc = std::sync::Arc::new(doc); - assert!(!super::fit_has_art(&app, &[0, 1]), "kept for the revision"); - app.session.active_mut().unwrap().revision += 1; assert!(super::fit_has_art(&app, &[0, 1]), "measured again"); + assert!(!super::fit_has_art(&app, &[1]), "measured for the artboards asked about"); } /// `artboard.selectAll` chooses the Artboard tool first when another tool is in use, and fails From 78b31831c2297ec0048df39c3d63dc186c5ed41a Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 15:13:41 -0700 Subject: [PATCH 12/14] Artboards: Fit to Artwork Bounds' kept answer kept by the document, with the plug-ins and fonts it reads The answer whether Fit to Artwork Bounds has art to fit moves from a function-local thread_local to DocState, beside any_empty_artboards, and drops with the document. It is kept for that version of the document (held weakly, as any_empty_artboards holds its own) and the artboards asked about, and measured again when the installed plug-ins, whose effects draw art, or the installed fonts, which set type drawn by its effects' art, change: their generations are an explicit input of the measure. A change of the selection or the view keeps it. The test counts the measures, as the Delete Empty Artboards check's does: a later frame, select.set and a view change keep the answer; an edit, other artboards, and each generation measure again. Taking any part out of the key fails it. --- crates/engine/src/doc_state.rs | 43 ++++++++++++++++++++++++++ crates/engine/src/tests/panelcmds.rs | 32 +++++++++++++++++++ crates/ui-egui/src/panels/artboards.rs | 31 +++---------------- 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index a0ae4cd655..8c8079fd91 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -112,6 +112,8 @@ pub struct DocState { pub opened_by: Option>, /// What [`DocState::any_empty_artboards`] last measured. Not saved. empty_artboards: std::cell::RefCell, + /// What [`DocState::any_art_to_fit`] last measured. Not saved. + art_to_fit: std::cell::RefCell>, } /// Whether a document has an artboard for Delete Empty Artboards to delete, with the document that @@ -124,10 +126,23 @@ struct EmptyArtboards { any: bool, } +/// Whether an unlocked artboard of those asked about has art that Fit to Artwork Bounds fits it +/// to, with what was measured: the document (held as [`EmptyArtboards::doc`] holds it), the +/// artboards, and the generations of what the measure reads outside the document. +#[derive(Clone, Debug)] +struct ArtToFit { + doc: std::sync::Weak, + indices: Vec, + outside: (u64, u64), + any: bool, +} + #[cfg(test)] thread_local! { /// Tests: how many times [`DocState::any_empty_artboards`] measured a document on this thread. pub(crate) static EMPTY_ARTBOARD_MEASURES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Tests: how many times [`DocState::any_art_to_fit`] measured a document on this thread. + pub(crate) static ART_TO_FIT_MEASURES: std::cell::Cell = const { std::cell::Cell::new(0) }; } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -309,6 +324,7 @@ impl DocState { last_perspective: None, opened_by: file_access::current(), empty_artboards: Default::default(), + art_to_fit: Default::default(), } } /// `measure`'s answer for the document now. `measure` runs once for each version of the document, @@ -323,6 +339,33 @@ impl DocState { } last.any } + /// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. A + /// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the + /// answer is kept for this version of the document and those artboards, and a change of the + /// selection or the view keeps it. It is measured again when what the measure reads outside the + /// document changes: the installed plug-ins, whose effects draw art, and the installed fonts, + /// which set type drawn by its effects' art (an envelope, a 3D effect). + pub fn any_art_to_fit(&self, indices: &[usize]) -> bool { + self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation())) + } + /// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts. + pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool { + let mut last = self.art_to_fit.borrow_mut(); + if let Some(k) = last.as_ref() + && std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(&self.doc)) + && k.indices == indices + && k.outside == outside + { + return k.any; + } + #[cfg(test)] + ART_TO_FIT_MEASURES.with(|c| c.set(c.get() + 1)); + // One renderer measures every artboard: each object once. + let mut r = vectorcraft_render::Renderer::new(); + let any = indices.iter().filter_map(|i| self.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&self.doc, a.rect).is_some()); + *last = Some(ArtToFit { doc: Arc::downgrade(&self.doc), indices: indices.to_vec(), outside, any }); + any + } /// Unsaved changes: the document differs from the saved one (selection changes and changes that /// are not edits to save don't count, [`DocState::same_content`]). pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 0870ffb87d..8dda7196c6 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -1786,6 +1786,38 @@ fn delete_empty_artboards_check_measures_a_document_once() { assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after another change of the selection"); } +/// Whether Fit to Artwork Bounds has art to fit on `indices`, as a menu asks for it, with the +/// plug-ins' and fonts' generations `outside`, and how many times asking measured a document on +/// this thread. +fn art_to_fit_counted(s: &Session, indices: &[usize], outside: (u64, u64)) -> (bool, usize) { + let measures = || crate::doc_state::ART_TO_FIT_MEASURES.with(std::cell::Cell::get); + let before = measures(); + let any = s.doc().unwrap().any_art_to_fit_with(indices, outside); + (any, measures() - before) +} + +/// A menu asks whether Fit to Artwork Bounds has art on every frame while it is open: the answer is +/// measured once for a version of the document and the artboards asked about, kept through a change +/// of the selection or the view, and measured again after an edit, for other artboards, and when +/// the plug-ins or the fonts change. +#[test] +fn art_to_fit_is_measured_once_for_a_version_of_the_document() { + let mut s = row_of_artboards(2); + let both = [0, 1]; + assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (false, 1), "the first frame"); + assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (false, 0), "a later frame"); + let art = box_at(&mut s, 10.0, 20.0); + assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 1), "an edit puts art on artboard 1"); + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 0), "after a change of the selection"); + s.execute("view.transparencyGrid", &json!({})).unwrap(); + assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 0), "after a change of the view"); + assert_eq!(art_to_fit_counted(&s, &[1], (0, 0)), (false, 1), "other artboards"); + assert_eq!(art_to_fit_counted(&s, &[1], (1, 0)), (false, 1), "a plug-in installed or removed"); + assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 1), "fonts installed"); + assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 0)); +} + /// The enabled state follows an edit, an undo, a redo, an added and a removed artboard, a locked /// artboard, and each change measures the document once. #[test] diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index a950a24c79..d46e5c4ac3 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -312,34 +312,11 @@ pub struct MenuFacts { pub(crate) any_art: bool, } -/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. The -/// canvas menu asks on every frame it is open without its [`MenuFacts`], and measuring builds the -/// solids of 3D effects, so one answer is kept, the last asked: for that version of the document -/// (the same `Arc`, held weakly so its address isn't reused, as -/// `DocState::any_empty_artboards` keeps its answer) and those artboards, until a plug-in is -/// installed or removed, which can change what its effects draw. A change of the selection or the -/// view keeps it. +/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to, kept by +/// the document ([`vectorcraft_engine::DocState::any_art_to_fit`]): the canvas menu asks on every +/// frame it is open without its [`MenuFacts`]. pub(crate) fn fit_has_art(app: &VectorcraftApp, indices: &[usize]) -> bool { - /// The document measured, the artboards asked about, the plug-ins' revision, and the answer. - struct Kept { - doc: std::sync::Weak, - indices: Vec, - plugins: u64, - any: bool, - } - thread_local!(static KEPT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }); - app.session.active().is_some_and(|st| { - let plugins = crate::menus::plugin_revision(); - let same = |k: &Kept| std::ptr::eq(k.doc.as_ptr(), std::sync::Arc::as_ptr(&st.doc)) && k.indices == indices && k.plugins == plugins; - if let Some(any) = KEPT.with_borrow(|k| k.as_ref().filter(|k| same(k)).map(|k| k.any)) { - return any; - } - // One renderer measures every artboard: each object once. - let mut r = vectorcraft_render::Renderer::new(); - let any = indices.iter().filter_map(|i| st.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&st.doc, a.rect).is_some()); - KEPT.set(Some(Kept { doc: std::sync::Arc::downgrade(&st.doc), indices: indices.to_vec(), plugins, any })); - any - }) + app.session.active().is_some_and(|st| st.any_art_to_fit(indices)) } /// A right-click on the canvas at `p` with the Artboard tool: an artboard under the pointer that From 59cc303912ce9b68ba8fcd150e17fb075c889ecf Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 15:13:42 -0700 Subject: [PATCH 13/14] Canvas redraw: the stack overflow fix's evidence, and two tests' known differences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The parity rows say what the fix covers: a 3D effect on a fill or stroke that builds nothing overflowed the stack with no effect of the object's own too, on the canvas, in Object › Rasterize and in raster exports. - The builds-nothing test says the fill-only difference (a raster effect clipped to the path, cutting Roughen's spikes, when drawn without the 3D effect) is an older bug, not the design. - The traced-shape test says why its area is bounded rather than drawn again: a partial render in outline view differs from the whole by one in alpha on 37 pixels, older than the test and unrelated to the edit. - A doc comment rewrapped. --- crates/render/src/fx/tests.rs | 9 +++++---- crates/render/src/tests/changed.rs | 5 +++++ docs/target-app-parity.md | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 7bdd8c3c66..974557e501 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -911,8 +911,8 @@ fn a_3d_effect_that_builds_nothing_is_measured_alike() { assert_eq!(Renderer::new().bounds_of(&symbols, &n), crate::brush_fx::cull_bounds(&symbols, &n)); } -/// A group with effects of its own that holds an extruded path is measured once for every artboard of a -/// pass: its bounds are kept after the first. +/// A group with effects of its own that holds an extruded path is measured once for every artboard +/// of a pass: its bounds are kept after the first. #[test] fn a_group_holding_3d_art_is_measured_once_a_pass() { let mut d = Document::new(100.0, 100.0); @@ -1752,8 +1752,9 @@ fn an_image_inner_glow_follows_its_visible_pixels() { /// 3D effect, with a raster effect of its own (Drop Shadow, Gaussian Blur) too. An object with /// only a fill and a raster effect draws from that art, where without the 3D effect it draws as a /// path, whose raster effects are clipped to the path's own geometry: there Roughen's spikes are -/// cut off, so the two differ in a few pixels at the edge and match inside the path. Either way -/// the raster effect reaches past what the object draws without it. +/// cut off, so the two differ in a few pixels at the edge and match inside the path. That clipping +/// is a known bug of drawing without the 3D effect, older than this test, not the design. Either +/// way the raster effect reaches past what the object draws without it. #[test] fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { let rough = diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index 67c476ee36..5d6272eca1 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -615,6 +615,11 @@ fn a_trace_objects_new_first_member_is_its_source() { redraw_traced(&back, &replaced); } +/// Recoloring a traced shape changes only the shape, as the free measure and the renderer that drew +/// the trace object in its view measure it. The area is bounded, not drawn again and compared: in +/// this view (outlines with the source image) a partial render of the outlines differs from the +/// whole by one in alpha on 37 pixels (230 against 229), which is older than this test and has +/// nothing to do with what changed (the recolor changes no pixel in this view). #[test] fn recolouring_a_traced_shape_changes_only_the_shape() { let (old, _, _, shape) = traced("outlinesWithSourceImage"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 49f7d0c9ba..8754a12f68 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -41,7 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen), under an effect of the object's own, no longer overflows the stack on the canvas and in Object › Rasterize: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | +| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | @@ -1906,7 +1906,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| -| 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack (evidence in Feature revisions). Scores and estimates unchanged. | +| 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster exports (evidence in Feature revisions). Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. | | 2026-11-09 | minor | Bundled content: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); symbol libraries evidence updated; ~30% → ~32%; more built-in symbol libraries remain. | | 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. | From b242f5acf58b8ad2ab12ecd72f9b7c6c12641168 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Mon, 9 Nov 2026 16:50:36 -0700 Subject: [PATCH 14/14] Fit to Artwork Bounds: a change in place is measured again; the fix's evidence names EPS export A new test changes the document in place (Arc::make_mut, with no other holder and no change of revision) and checks the answer is measured again: keyed on the document's address alone, it would keep the old answer. The kept answer's doc says what it holds after the measure, and that the fonts set the bounds of all type. Should measuring ever ask again while it runs, it is measured without being kept, rather than borrowed twice. The stack overflow fixed in d5d04f868 also hit EPS export, and the parity rows now say so. Two test docs are corrected: the fill-only clipping is an older bug on main, and the outline-view difference runs both ways on a few dozen hairline pixels. --- crates/engine/src/doc_state.rs | 13 ++++++++++--- crates/engine/src/tests/panelcmds.rs | 16 ++++++++++++++++ crates/render/src/fx/tests.rs | 2 +- crates/render/src/tests/changed.rs | 9 +++++---- docs/target-app-parity.md | 4 ++-- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index 8c8079fd91..736b873d06 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -128,7 +128,9 @@ struct EmptyArtboards { /// Whether an unlocked artboard of those asked about has art that Fit to Artwork Bounds fits it /// to, with what was measured: the document (held as [`EmptyArtboards::doc`] holds it), the -/// artboards, and the generations of what the measure reads outside the document. +/// artboards, and the generations of what the measure reads outside the document. One is kept for +/// each open document, and its weak reference keeps the measured document's allocation (not its +/// contents) until the next measure or until the document closes. #[derive(Clone, Debug)] struct ArtToFit { doc: std::sync::Weak, @@ -344,13 +346,18 @@ impl DocState { /// answer is kept for this version of the document and those artboards, and a change of the /// selection or the view keeps it. It is measured again when what the measure reads outside the /// document changes: the installed plug-ins, whose effects draw art, and the installed fonts, - /// which set type drawn by its effects' art (an envelope, a 3D effect). + /// which set the bounds of all type. pub fn any_art_to_fit(&self, indices: &[usize]) -> bool { self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation())) } /// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts. pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool { - let mut last = self.art_to_fit.borrow_mut(); + // Measuring runs renderer and plug-in code; should it ever ask again while measuring, it + // is measured without being kept rather than borrowed twice. + let Ok(mut last) = self.art_to_fit.try_borrow_mut() else { + let mut r = vectorcraft_render::Renderer::new(); + return indices.iter().filter_map(|i| self.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&self.doc, a.rect).is_some()); + }; if let Some(k) = last.as_ref() && std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(&self.doc)) && k.indices == indices diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 8dda7196c6..239d6bee7b 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -1818,6 +1818,22 @@ fn art_to_fit_is_measured_once_for_a_version_of_the_document() { assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 0)); } +/// A change made to the document in place (`Arc::make_mut`, with no other holder and no change +/// of revision) is measured again: the kept answer holds the document it measured weakly, so a +/// changed document is a new allocation, not the old address. +#[test] +fn art_to_fit_follows_a_change_in_place() { + let mut s = row_of_artboards(2); + box_at(&mut s, 10.0, 20.0); + assert_eq!(art_to_fit_counted(&s, &[0], (0, 0)), (true, 1), "art on artboard 1"); + let st = s.doc_mut().unwrap(); + assert_eq!(Arc::strong_count(&st.doc), 1, "the undo history holds the document before the edit"); + let (revision, empty_spot) = (st.revision, st.doc.artboards[1].rect); + Arc::make_mut(&mut st.doc).artboards[0].rect = empty_spot; + assert_eq!(st.revision, revision); + assert_eq!(art_to_fit_counted(&s, &[0], (0, 0)), (false, 1), "artboard 1 moved off the art"); +} + /// The enabled state follows an edit, an undo, a redo, an added and a removed artboard, a locked /// artboard, and each change measures the document once. #[test] diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 31ffc63513..560a3675cc 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -1755,7 +1755,7 @@ fn an_image_inner_glow_follows_its_visible_pixels() { /// only a fill and a raster effect draws from that art, where without the 3D effect it draws as a /// path, whose raster effects are clipped to the path's own geometry: there Roughen's spikes are /// cut off, so the two differ in a few pixels at the edge and match inside the path. That clipping -/// is a known bug of drawing without the 3D effect, older than this test, not the design. Either +/// is an older bug on main of drawing without the 3D effect, not the design. Either /// way the raster effect reaches past what the object draws without it. #[test] fn a_3d_effect_that_builds_nothing_draws_the_item_as_without_it() { diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index 5d6272eca1..27c299c49c 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -616,10 +616,11 @@ fn a_trace_objects_new_first_member_is_its_source() { } /// Recoloring a traced shape changes only the shape, as the free measure and the renderer that drew -/// the trace object in its view measure it. The area is bounded, not drawn again and compared: in -/// this view (outlines with the source image) a partial render of the outlines differs from the -/// whole by one in alpha on 37 pixels (230 against 229), which is older than this test and has -/// nothing to do with what changed (the recolor changes no pixel in this view). +/// the trace object in its view measure it. Through the renderer the area is bounded, not drawn +/// again and compared: in this view (outlines with the source image) a partial render of the +/// outlines differs from the whole by one in alpha on a few dozen hairline pixels, either way, +/// which is older than this test and has nothing to do with what changed (the recolor changes no +/// pixel in this view). #[test] fn recolouring_a_traced_shape_changes_only_the_shape() { let (old, _, _, shape) = traced("outlinesWithSourceImage"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b8d7604ed6..a93770b88a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -41,7 +41,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| -| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | +| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6147): `.ai`/EPS type keeps its paragraphs' tab stops (positions and alignments; leaders aren't read; at most 1,024 a paragraph and 2^20 a story), and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops; centred and justified lines are aligned as a whole as before; evidence added. Scores and estimates unchanged. | | 2026-11-07 | minor | File formats and Type core (#6137): `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them, and a line a soft return ends is justified; evidence added. Limits: frames grow only for area type of one frame, horizontal, by its lines' deepest descent plus 0.05 pt; type in a font that isn't installed, and hyphenation in a language that isn't imported, still lay out differently. Scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | @@ -1912,7 +1912,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| | 2026-11-10 | minor | Raster effects: the Effect Gallery shows a thumbnail of each filter and a preview of the visible stack (or the filter being browsed) inside the dialog, both an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)); 52% → 55%, 1–2 h → none (estimated). The canvas preview and one-undo OK are unchanged. Headline numbers unchanged. | -| 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster exports (evidence in Feature revisions). Scores and estimates unchanged. | +| 2026-11-09 | minor | Canvas: a 3D effect changed on type or a group redraws where its solid was, measured by the effect's art through the renderer that drew the frame; exports and Object › Rasterize take the solid of 3D type; Fit to Artwork Bounds and per-artboard SVG export measure all artboards through one renderer; a 3D effect on a fill or stroke that builds nothing no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports (evidence in Feature revisions). Scores and estimates unchanged. | | 2026-11-09 | minor | Type advanced: Auto Spell Check (`edit.autoSpellCheck`), #4936; evidence added; the dialogs and the right-click Spelling menu remain; scores and hours unchanged. | | 2026-11-09 | minor | Bundled content: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); symbol libraries evidence updated; ~30% → ~32%; more built-in symbol libraries remain. | | 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. |