Canvas: a 3D effect changed on type or a group redraws where its solid was

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.
This commit is contained in:
Drew Dibble
2026-11-08 23:17:50 -07:00
parent 3b92bef27c
commit b9dbd867f6
24 changed files with 1200 additions and 78 deletions
+14 -8
View File
@@ -126,13 +126,20 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
/// 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<Node>, doc: &Document, symbols: &SymbolBoxes, rect: vectorcraft_geom::Rect) -> Option<Arc<Node>> {
/// 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<Node>,
doc: &Document,
symbols: &SymbolBoxes,
rect: vectorcraft_geom::Rect,
renderer: &mut vectorcraft_render::Renderer,
) -> Option<Arc<Node>> {
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
}
+3 -1
View File
@@ -180,9 +180,11 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
let flat = crate::cmd::rasterfx::flatten_pixel_effects_for(doc, opts.profile != vectorcraft_svg::Profile::Tiny12);
let doc = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded { warnings, ..Encoded::default() };
// One renderer measures the art over every artboard: each 3D solid is built once.
let mut renderer = vectorcraft_render::Renderer::new();
for artboard in boards {
// A chosen artboard's file holds the art over it (#550) and is named after it (#3544).
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect));
let over = artboard.filter(|_| chosen).and_then(|b| doc.artboards.get(b)).map(|a| super::export::art_over(doc, a.rect, &mut renderer));
let file = ExportOptions { artboard, artboard_title: chosen, ..opts.clone() };
let mut out = vectorcraft_svg::export_full(over.as_ref().unwrap_or(doc), &file, native.as_deref());
let bytes = out.take_bytes();
+3 -1
View File
@@ -1167,9 +1167,11 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
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))));
}
+31 -2
View File
@@ -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<Rect> {
/// 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<Rect> {
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<Node>) -> Option<Rect> {
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<Node>>,
mut renderer: Option<&mut crate::Renderer>,
) -> Option<Rect> {
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),
},
}
}
+7
View File
@@ -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
+83 -13
View File
@@ -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<String>, 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<Rect>,
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<Node>], new: &[Arc<Node>]) {
@@ -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<Node>, new: &Arc<Node>) {
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<Node>) -> Option<Rect> {
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<Node>>) {
let b = n.and_then(|n| self.bounds(Side::Old, n));
self.add(b);
}
fn add_new(&mut self, n: Option<&Arc<Node>>) {
let b = n.and_then(|n| self.bounds(Side::New, n));
self.add(b);
}
fn add(&mut self, r: Option<Rect>) {
@@ -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<Node>]> {
+41 -6
View File
@@ -205,18 +205,32 @@ pub fn art_bounds(doc: &Document) -> Option<Rect> {
/// 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<Rect> {
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<Rect> {
art_bounds_on_with(doc, rect, Some(self))
}
}
fn art_bounds_on_with(doc: &Document, rect: Rect, mut renderer: Option<&mut crate::Renderer>) -> Option<Rect> {
// 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<Rect> {
fn on(symbols: &SymbolBoxes, n: &Arc<Node>, rect: Rect, mut renderer: Option<&mut crate::Renderer>) -> Option<Rect> {
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<Rect> {
/// 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<Rect> {
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<Node>>, mut renderer: Option<&mut crate::Renderer>) -> Option<Rect> {
if !n.visible {
return None;
}
@@ -232,8 +252,23 @@ pub fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
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),
},
}
}
+6
View File
@@ -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,
}
+1 -1
View File
@@ -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};
+70 -23
View File
@@ -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<Rect> {
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<Rect> {
// 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<Rect> {
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<Rect> {
}
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<Rect> {
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<Option<Rect>> {
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<Rect> {
fn pieces(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
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<Rect> {
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
+108 -8
View File
@@ -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<Node>) -> Arc<Node> {
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<Node>) -> Option<Option<Rect>> {
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<Node>) -> Option<u64> {
(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<Node>) -> Arc<Node> {
// 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
}
+520
View File
@@ -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<u8>, Vec<u8>) {
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]
+30
View File
@@ -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<Node>, Arc<Node>, Option<u64>);
/// 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<Node>, Option<((u64, u64), Option<Rect>)>);
/// 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<usize, FxArtEntry>,
/// 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<usize, MeasuredArt>,
/// 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<usize, (Arc<Node>, Option<Rect>)>,
/// 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<usize, ThreeDArt>,
/// 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<PlacedKey, PlacedEntry>,
/// The bitmap clock of [`placed_document`] when this frame first drew a placed document from
+4
View File
@@ -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`).
+9
View File
@@ -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);
+37 -1
View File
@@ -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
+123 -5
View File
@@ -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");
}
+31
View File
@@ -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");
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub struct CanvasCache {
pub key: Option<CacheKey>,
/// 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_doc::Document>, vectorcraft_render::RenderOptions)>,
pub last_ms: f64,
/// The frame shown drew raster effects from rasters made before their objects moved, or at a
+7
View File
@@ -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(),
+4 -2
View File
@@ -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),
+50
View File
@@ -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;
}
}
+14 -5
View File
@@ -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<usize>, bool)>;
thread_local!(static KEPT: std::cell::RefCell<Kept> = 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
})
}
+3 -1
View File
@@ -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.