Artboards: Fit to Artwork Bounds' kept answer kept by the document, with the plug-ins and fonts it reads

The answer whether Fit to Artwork Bounds has art to fit moves from a
function-local thread_local to DocState, beside any_empty_artboards, and
drops with the document. It is kept for that version of the document
(held weakly, as any_empty_artboards holds its own) and the artboards
asked about, and measured again when the installed plug-ins, whose
effects draw art, or the installed fonts, which set type drawn by its
effects' art, change: their generations are an explicit input of the
measure. A change of the selection or the view keeps it.

The test counts the measures, as the Delete Empty Artboards check's
does: a later frame, select.set and a view change keep the answer; an
edit, other artboards, and each generation measure again. Taking any
part out of the key fails it.
This commit is contained in:
Drew Dibble
2026-11-09 15:13:41 -07:00
parent 758a31890d
commit 78b31831c2
3 changed files with 79 additions and 27 deletions
+43
View File
@@ -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
@@ -124,10 +126,23 @@ struct EmptyArtboards {
any: bool,
}
/// Whether an unlocked artboard of those asked about has art that Fit to Artwork Bounds fits it
/// to, with what was measured: the document (held as [`EmptyArtboards::doc`] holds it), the
/// artboards, and the generations of what the measure reads outside the document.
#[derive(Clone, Debug)]
struct ArtToFit {
doc: std::sync::Weak<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);
@@ -309,6 +324,7 @@ impl DocState {
last_perspective: None,
opened_by: file_access::current(),
empty_artboards: Default::default(),
art_to_fit: Default::default(),
}
}
/// `measure`'s answer for the document now. `measure` runs once for each version of the document,
@@ -323,6 +339,33 @@ impl DocState {
}
last.any
}
/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. A
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
/// answer is kept for this version of the document and those artboards, and a change of the
/// selection or the view keeps it. It is measured again when what the measure reads outside the
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
/// which set type drawn by its effects' art (an envelope, a 3D effect).
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
}
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
let mut last = self.art_to_fit.borrow_mut();
if let Some(k) = last.as_ref()
&& std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(&self.doc))
&& k.indices == indices
&& k.outside == outside
{
return k.any;
}
#[cfg(test)]
ART_TO_FIT_MEASURES.with(|c| c.set(c.get() + 1));
// One renderer measures every artboard: each object once.
let mut r = vectorcraft_render::Renderer::new();
let any = indices.iter().filter_map(|i| self.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&self.doc, a.rect).is_some());
*last = Some(ArtToFit { doc: Arc::downgrade(&self.doc), indices: indices.to_vec(), outside, any });
any
}
/// Unsaved changes: the document differs from the saved one (selection changes and changes that
/// are not edits to save don't count, [`DocState::same_content`]).
pub fn is_dirty(&self) -> bool {
+32
View File
@@ -1786,6 +1786,38 @@ fn delete_empty_artboards_check_measures_a_document_once() {
assert_eq!(delete_empty_enabled_counted(&s), (true, 0), "after another change of the selection");
}
/// Whether Fit to Artwork Bounds has art to fit on `indices`, as a menu asks for it, with the
/// plug-ins' and fonts' generations `outside`, and how many times asking measured a document on
/// this thread.
fn art_to_fit_counted(s: &Session, indices: &[usize], outside: (u64, u64)) -> (bool, usize) {
let measures = || crate::doc_state::ART_TO_FIT_MEASURES.with(std::cell::Cell::get);
let before = measures();
let any = s.doc().unwrap().any_art_to_fit_with(indices, outside);
(any, measures() - before)
}
/// A menu asks whether Fit to Artwork Bounds has art on every frame while it is open: the answer is
/// measured once for a version of the document and the artboards asked about, kept through a change
/// of the selection or the view, and measured again after an edit, for other artboards, and when
/// the plug-ins or the fonts change.
#[test]
fn art_to_fit_is_measured_once_for_a_version_of_the_document() {
let mut s = row_of_artboards(2);
let both = [0, 1];
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (false, 1), "the first frame");
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (false, 0), "a later frame");
let art = box_at(&mut s, 10.0, 20.0);
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 1), "an edit puts art on artboard 1");
s.execute("select.set", &json!({"ids": [art.0]})).unwrap();
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 0), "after a change of the selection");
s.execute("view.transparencyGrid", &json!({})).unwrap();
assert_eq!(art_to_fit_counted(&s, &both, (0, 0)), (true, 0), "after a change of the view");
assert_eq!(art_to_fit_counted(&s, &[1], (0, 0)), (false, 1), "other artboards");
assert_eq!(art_to_fit_counted(&s, &[1], (1, 0)), (false, 1), "a plug-in installed or removed");
assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 1), "fonts installed");
assert_eq!(art_to_fit_counted(&s, &[1], (1, 1)), (false, 0));
}
/// The enabled state follows an edit, an undo, a redo, an added and a removed artboard, a locked
/// artboard, and each change measures the document once.
#[test]
+4 -27
View File
@@ -312,34 +312,11 @@ pub struct MenuFacts {
pub(crate) any_art: bool,
}
/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. The
/// canvas menu asks on every frame it is open without its [`MenuFacts`], and measuring builds the
/// solids of 3D effects, so one answer is kept, the last asked: for that version of the document
/// (the same `Arc`, held weakly so its address isn't reused, as
/// `DocState::any_empty_artboards` keeps its answer) and those artboards, until a plug-in is
/// installed or removed, which can change what its effects draw. A change of the selection or the
/// view keeps it.
/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to, kept by
/// the document ([`vectorcraft_engine::DocState::any_art_to_fit`]): the canvas menu asks on every
/// frame it is open without its [`MenuFacts`].
pub(crate) fn fit_has_art(app: &VectorcraftApp, indices: &[usize]) -> bool {
/// The document measured, the artboards asked about, the plug-ins' revision, and the answer.
struct Kept {
doc: std::sync::Weak<vectorcraft_doc::Document>,
indices: Vec<usize>,
plugins: u64,
any: bool,
}
thread_local!(static KEPT: std::cell::RefCell<Option<Kept>> = const { std::cell::RefCell::new(None) });
app.session.active().is_some_and(|st| {
let plugins = crate::menus::plugin_revision();
let same = |k: &Kept| std::ptr::eq(k.doc.as_ptr(), std::sync::Arc::as_ptr(&st.doc)) && k.indices == indices && k.plugins == plugins;
if let Some(any) = KEPT.with_borrow(|k| k.as_ref().filter(|k| same(k)).map(|k| k.any)) {
return any;
}
// One renderer measures every artboard: each object once.
let mut r = vectorcraft_render::Renderer::new();
let any = indices.iter().filter_map(|i| st.doc.artboards.get(*i)).any(|a| !a.locked && r.art_bounds_on(&st.doc, a.rect).is_some());
KEPT.set(Some(Kept { doc: std::sync::Arc::downgrade(&st.doc), indices: indices.to_vec(), plugins, any }));
any
})
app.session.active().is_some_and(|st| st.any_art_to_fit(indices))
}
/// A right-click on the canvas at `p` with the Artboard tool: an artboard under the pointer that