Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b9dbd86
Canvas: a 3D effect changed on type or a group redraws where its soli…
addibble Nov 9, 2026
3617c67
Canvas redraw: what the renderer leaves out measured as nothing, alike
addibble Nov 9, 2026
d5d04f8
Canvas: a 3D effect on a fill that builds nothing draws without it
addibble Nov 9, 2026
d8472b2
Canvas redraw: a trace object's source in each version; review follow…
addibble Nov 9, 2026
0f75f8e
Canvas redraw: each version's trace source read from its own node; th…
addibble Nov 9, 2026
bd77907
Merge main into type-3d-redraw
addibble Nov 9, 2026
baa5bab
Docs: the 3D-bounds map's sweeps and bound, and the change in the par…
addibble Nov 9, 2026
049f8c3
Canvas redraw: tests for edits measured under overprint, with Vector …
addibble Nov 9, 2026
af754bc
Canvas redraw: the renderer's measure tests check what their names say
addibble Nov 9, 2026
d0b26c0
Canvas redraw: the let-go test receives the worker's job; the fit cac…
addibble Nov 9, 2026
2fcaaed
Merge main into type-3d-redraw
addibble Nov 9, 2026
bd9f6a0
Canvas redraw: review follow-ups to the strengthened tests
addibble Nov 9, 2026
758a318
Artboards: Fit to Artwork Bounds' kept answer follows the document's …
addibble Nov 9, 2026
78b3183
Artboards: Fit to Artwork Bounds' kept answer kept by the document, w…
addibble Nov 9, 2026
59cc303
Canvas redraw: the stack overflow fix's evidence, and two tests' know…
addibble Nov 9, 2026
a9a061c
Merge main into type-3d-redraw
addibble Nov 9, 2026
b242f5a
Fit to Artwork Bounds: a change in place is measured again; the fix's…
addibble Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions crates/engine/src/cmd/fileio/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/cmd/fileio/svg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion crates/engine/src/cmd/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))));
}
Expand Down
50 changes: 50 additions & 0 deletions crates/engine/src/doc_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ pub struct DocState {
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
/// What [`DocState::any_empty_artboards`] last measured. Not saved.
empty_artboards: std::cell::RefCell<EmptyArtboards>,
/// What [`DocState::any_art_to_fit`] last measured. Not saved.
art_to_fit: std::cell::RefCell<Option<ArtToFit>>,
}

/// Whether a document has an artboard for Delete Empty Artboards to delete, with the document that
Expand All @@ -124,10 +126,25 @@ struct EmptyArtboards {
any: bool,
}

/// Whether an unlocked artboard of those asked about has art that Fit to Artwork Bounds fits it
/// to, with what was measured: the document (held as [`EmptyArtboards::doc`] holds it), the
/// artboards, and the generations of what the measure reads outside the document. One is kept for
/// each open document, and its weak reference keeps the measured document's allocation (not its
/// contents) until the next measure or until the document closes.
#[derive(Clone, Debug)]
struct ArtToFit {
doc: std::sync::Weak<Document>,
indices: Vec<usize>,
outside: (u64, u64),
any: bool,
}

#[cfg(test)]
thread_local! {
/// Tests: how many times [`DocState::any_empty_artboards`] measured a document on this thread.
pub(crate) static EMPTY_ARTBOARD_MEASURES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Tests: how many times [`DocState::any_art_to_fit`] measured a document on this thread.
pub(crate) static ART_TO_FIT_MEASURES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}

static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
Expand Down Expand Up @@ -309,6 +326,7 @@ impl DocState {
last_perspective: None,
opened_by: file_access::current(),
empty_artboards: Default::default(),
art_to_fit: Default::default(),
}
}
/// `measure`'s answer for the document now. `measure` runs once for each version of the document,
Expand All @@ -323,6 +341,38 @@ impl DocState {
}
last.any
}
/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. A
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
/// answer is kept for this version of the document and those artboards, and a change of the
/// selection or the view keeps it. It is measured again when what the measure reads outside the
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
/// which set the bounds of all type.
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
}
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
// Measuring runs renderer and plug-in code; should it ever ask again while measuring, it
// is measured without being kept rather than borrowed twice.
let Ok(mut last) = self.art_to_fit.try_borrow_mut() else {
let mut r = vectorcraft_render::Renderer::new();
return indices.iter().filter_map(|i| self.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&self.doc, a.rect).is_some());
};
if let Some(k) = last.as_ref()
&& std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(&self.doc))
&& k.indices == indices
&& k.outside == outside
{
return k.any;
}
#[cfg(test)]
ART_TO_FIT_MEASURES.with(|c| c.set(c.get() + 1));
// One renderer measures every artboard: each object once.
let mut r = vectorcraft_render::Renderer::new();
let any = indices.iter().filter_map(|i| self.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&self.doc, a.rect).is_some());
*last = Some(ArtToFit { doc: Arc::downgrade(&self.doc), indices: indices.to_vec(), outside, any });
any
}
/// Unsaved changes: the document differs from the saved one (selection changes and changes that
/// are not edits to save don't count, [`DocState::same_content`]).
pub fn is_dirty(&self) -> bool {
Expand Down
48 changes: 48 additions & 0 deletions crates/engine/src/tests/panelcmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,54 @@ fn delete_empty_artboards_check_measures_a_document_once() {
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after another change of the selection");
}

/// Whether Fit to Artwork Bounds has art to fit on `indices`, as a menu asks for it, with the
/// plug-ins' and fonts' generations `outside`, and how many times asking measured a document on
/// this thread.
fn art_to_fit_counted(s: &Session, indices: &[usize], outside: (u64, u64)) -> (bool, usize) {
let measures = || crate::doc_state::ART_TO_FIT_MEASURES.with(std::cell::Cell::get);
let before = measures();
let any = s.doc().unwrap().any_art_to_fit_with(indices, outside);
(any, measures() - before)
}

/// A menu asks whether Fit to Artwork Bounds has art on every frame while it is open: the answer is
/// measured once for a version of the document and the artboards asked about, kept through a change
/// of the selection or the view, and measured again after an edit, for other artboards, and when
/// the plug-ins or the fonts change.
#[test]
fn art_to_fit_is_measured_once_for_a_version_of_the_document() {
let mut s = row_of_artboards(2);
let both = [0, 1];
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (false, 1), "the first frame");
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (false, 0), "a later frame");
let art = box_at(&mut s, 10.0, 20.0);
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 1), "an edit puts art on artboard 1");
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 0), "after a change of the selection");
s.execute("view.transparencyGrid", &json!({})).unwrap();
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 0), "after a change of the view");
assert_eq!(art_to_fit_counted(&s, &[1], (0, 0)), (false, 1), "other artboards");
assert_eq!(art_to_fit_counted(&s, &[1], (1, 0)), (false, 1), "a plug-in installed or removed");
assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 1), "fonts installed");
assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 0));
}

/// A change made to the document in place (`Arc::make_mut`, with no other holder and no change
/// of revision) is measured again: the kept answer holds the document it measured weakly, so a
/// changed document is a new allocation, not the old address.
#[test]
fn art_to_fit_follows_a_change_in_place() {
let mut s = row_of_artboards(2);
box_at(&mut s, 10.0, 20.0);
assert_eq!(art_to_fit_counted(&s, &[0], (0, 0)), (true, 1), "art on artboard 1");
let st = s.doc_mut().unwrap();
assert_eq!(Arc::strong_count(&st.doc), 1, "the undo history holds the document before the edit");
let (revision, empty_spot) = (st.revision, st.doc.artboards[1].rect);
Arc::make_mut(&mut st.doc).artboards[0].rect = empty_spot;
assert_eq!(st.revision, revision);
assert_eq!(art_to_fit_counted(&s, &[0], (0, 0)), (false, 1), "artboard 1 moved off the art");
}

/// The enabled state follows an edit, an undo, a redo, an added and a removed artboard, a locked
/// artboard, and each change measures the document once.
#[test]
Expand Down
33 changes: 31 additions & 2 deletions crates/render/src/bounds.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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),
},
}
}

Expand Down
7 changes: 7 additions & 0 deletions crates/render/src/brush_fx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading