#1244: navigator and layer thumbnails render off the UI thread and wait for drags to end

The navigator re-rendered the whole document on the UI thread on every revision, including
each drag step, and layer thumbnails re-rendered whole layers the same way. Both now keep the
last image while an interaction runs, render on a background worker once the document settles
(synchronously on wasm and in tests), and turn on image mips. Thumbnails are keyed by node id
with a Weak, not by a reusable address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
arhammusheer 2026-10-11 23:28:11 +05:30 committed by bflatastic
parent bbbafdef6b
commit 9139883075
8 changed files with 613 additions and 67 deletions

View File

@ -305,6 +305,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
smooth_images,
// Isolation mode: the art around the isolated group or layer is dimmed (#833).
isolated: key.isolated.map(vectorcraft_doc::NodeId),
// Images far smaller on screen than they are sample their mip levels (#1244).
image_mips: true,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.

View File

@ -51,6 +51,7 @@ pub mod shortcuts;
pub mod state;
pub mod sysclip;
pub mod theme;
pub mod thumb_worker;
pub mod titlebar;
pub mod toolbar;
mod touch;

View File

@ -68,10 +68,10 @@ pub(crate) fn paint_thumb(ui: &Ui, st: &DocState, a: &ExportAsset, r: Rect) {
let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| {
let art: Vec<&Node> = ids().iter().filter_map(|id| st.doc.node(*id)).collect();
let img = RENDERER.with(|rr| match art.as_slice() {
[one] => rr.borrow_mut().render_node_thumbnail(&st.doc, one, px, None),
[one] => rr.borrow_mut().render_node_thumbnail_with(&st.doc, one, px, None, &super::layers::thumb_options()),
many => {
let group = Node::group(NodeId(0), many.iter().map(|n| Arc::new((*n).clone())).collect());
rr.borrow_mut().render_node_thumbnail(&st.doc, &group, px, None)
rr.borrow_mut().render_node_thumbnail_with(&st.doc, &group, px, None, &super::layers::thumb_options())
}
})?;
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);

View File

@ -30,8 +30,8 @@
//! those scrolled into view are laid out, so a document of many thousand objects costs no more
//! per frame than a small one.
use std::collections::HashSet;
use std::sync::Arc;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Weak};
use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2};
use serde::{Deserialize, Serialize};
@ -40,6 +40,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_engine::OpenRows;
use crate::theme::Tokens;
use crate::thumb_worker::{Settle, ThumbWorker};
use crate::widgets::{Live, PanelDrag};
use crate::{VectorcraftApp, icons, widgets};
@ -136,12 +137,12 @@ fn colour_bar(ui: &Ui, n: &Node, r: egui::Rect, x: f32, colour: Color32) {
}
/// The thumbnail of row `n` (rect `r`) at `x`; returns where it went.
fn row_thumb(ui: &Ui, view: &View, n: &Node, r: egui::Rect, x: f32) -> egui::Rect {
fn row_thumb(ui: &Ui, view: &View, n: &Arc<Node>, r: egui::Rect, x: f32) -> egui::Rect {
let s = (view.h - 4.0).min(40.0);
let th = egui::Rect::from_min_size(egui::pos2(x, r.center().y - s / 2.0), vec2(s, s));
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, view.t.border), StrokeKind::Outside);
if !real_thumb(ui, view.doc, n, th) {
if !real_thumb(ui, &view.thumbs, n, th) {
thumb(ui, n, th);
}
th
@ -151,7 +152,8 @@ fn row_thumb(ui: &Ui, view: &View, n: &Node, r: egui::Rect, x: f32) -> egui::Rec
/// colour bar, thumbnail and name.
fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) {
let (doc, t, h) = (view.doc, &view.t, view.h);
let Some(n) = doc.node(src) else { return };
let Some(n) = node_arc(doc, src) else { return };
let n = &n;
let (grab, width, depth) = ui.data(|d| d.get_temp::<(egui::Vec2, f32, usize)>(key("grab"))).unwrap_or((vec2(h, h / 2.0), 240.0, 0));
// A plain Ui on the tooltip layer, not an Area: a new Area is invisible for a frame and fades in.
let r = egui::Rect::from_min_size(pos - grab, vec2(width, h));
@ -312,7 +314,9 @@ fn rows_of_doc(ui: &Ui, st: &vectorcraft_engine::DocState, opts: &PanelOptions,
/// Everything one frame of the panel reads.
struct View<'a> {
doc: &'a Document,
doc: &'a Arc<Document>,
/// Where the row thumbnails come from.
thumbs: ThumbSource<'a>,
sel: HashSet<NodeId>,
/// The selected ruler guides.
guides: Vec<usize>,
@ -375,6 +379,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let list = rows_of_doc(ui, st, &app.ui.layers_panel, &query);
let view = View {
doc: &doc,
thumbs: ThumbSource::of(app, st, &doc),
sel: st.selection.objects.iter().copied().collect(),
guides: st.selection.guides.clone(),
target: st.selection.target,
@ -676,7 +681,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
x += 16.0;
// Thumbnail.
let thumb_rect = view.opts.thumb(n).then(|| {
let th = row_thumb(ui, view, n, r, x);
let th = row_thumb(ui, view, &item.node, r, x);
x = th.right() + 4.0;
th
});
@ -941,35 +946,215 @@ fn has_styled_target(n: &Node) -> bool {
|| !n.has_default_transparency()
}
/// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc`
/// allocation, so the address is a free change detector). Only rendered for visible rows.
pub(crate) fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool {
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
static CACHE: crate::graphics::TexCache<HashMap<(usize, u64), egui::TextureHandle>> = crate::graphics::TexCache::default();
/// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in
/// progress (the thumbnails shown then stay until it ends).
pub(crate) struct ThumbSource<'a> {
pub uid: u64,
pub revision: u64,
pub doc: &'a Arc<Document>,
pub busy: bool,
}
impl<'a> ThumbSource<'a> {
/// The thumbnails of the document `st` holds (`doc`, its document).
pub(crate) fn of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, doc: &'a Arc<Document>) -> Self {
Self { uid: st.uid, revision: st.revision, doc, busy: st.interaction.is_some() || app.session.tool_busy() }
}
}
/// Node `id` of `doc`, as the `Arc` its parent holds.
pub(crate) fn node_arc(doc: &Document, id: NodeId) -> Option<Arc<Node>> {
let path = doc.index_path(id)?;
let (first, rest) = path.split_first()?;
let mut cur = doc.layers.get(*first)?;
for &i in rest {
cur = cur.children()?.get(i)?;
}
Some(cur.clone())
}
/// A row thumbnail's image.
struct ThumbEntry {
/// The node it shows. Weak, so the cache doesn't keep old art alive, but its allocation stays
/// and no other node can come to have its address.
node: Weak<Node>,
px: u32,
/// None: the node draws nothing.
tex: Option<egui::TextureHandle>,
/// How long it took to render (ms).
ms: f64,
/// The pass it was last drawn in.
used: u64,
}
/// A row thumbnail job: the row (document uid, node), the node and the size rendered.
type ThumbJob = ((u64, NodeId), Weak<Node>, u32);
#[derive(Default)]
struct Thumbs {
entries: HashMap<(u64, NodeId), ThumbEntry>,
/// What the worker is rendering, by row.
pending: HashMap<(u64, NodeId), (Weak<Node>, u32)>,
worker: Option<ThumbWorker<(u64, NodeId), ThumbJob>>,
worker_started: bool,
settle: Settle,
/// (pass, ms, count) rendered on the UI thread in that pass.
spent: (u64, f64, u32),
/// Thumbnails rendered on the UI thread (tests count them).
renders: u64,
}
thread_local! {
static THUMBS: crate::graphics::TexCache<Thumbs> = crate::graphics::TexCache::default();
}
/// The most row thumbnails kept: past it the ones drawn longest ago go.
const MAX_THUMBS: usize = 512;
/// Rendering a thumbnail that took longer than this (ms) again goes to the worker.
const LIGHT_THUMB_MS: f64 = 4.0;
/// A node with more objects than this inside has its first thumbnail made on the worker.
const LIGHT_THUMB_NODES: usize = 200;
/// At most this long (ms) and this many thumbnails are rendered on the UI thread in a pass; the
/// rest go to the worker, or wait for the next pass where there is none.
const THUMB_BUDGET_MS: f64 = 8.0;
const THUMB_BUDGET_COUNT: u32 = 16;
/// How row thumbnails render: images far smaller than they are sample their mip levels.
pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions {
vectorcraft_render::RenderOptions { image_mips: true, ..Default::default() }
}
/// Has `n` at most `limit` objects inside it (counting stops there)?
fn small_node(n: &Node, limit: usize) -> bool {
fn count(n: &Node, left: &mut usize) -> bool {
for c in n.children().into_iter().flatten() {
if *left == 0 {
return false;
}
*left -= 1;
if !count(c, left) {
return false;
}
}
true
}
let mut left = limit;
count(n, &mut left)
}
impl Thumbs {
/// Keep `img` (None: nothing drawn) as the thumbnail of row `key` showing `node`.
fn upload(&mut self, ctx: &egui::Context, key: (u64, NodeId), node: Weak<Node>, px: u32, img: Option<&vectorcraft_render::Rendered>, ms: f64) {
let color = img.map(|img| egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels));
let used = ctx.cumulative_pass_nr();
let name = format!("layer-thumb-{}", key.1.0);
match self.entries.get_mut(&key) {
Some(e) => {
match (color, &mut e.tex) {
(Some(color), Some(tex)) => tex.set(color, egui::TextureOptions::LINEAR),
(Some(color), tex) => *tex = Some(ctx.load_texture(name, color, egui::TextureOptions::LINEAR)),
(None, tex) => *tex = None,
}
(e.node, e.px, e.ms, e.used) = (node, px, ms, used);
}
None => {
let tex = color.map(|color| ctx.load_texture(name, color, egui::TextureOptions::LINEAR));
self.entries.insert(key, ThumbEntry { node, px, tex, ms, used });
if self.entries.len() > MAX_THUMBS {
// Keep the three quarters drawn last.
let mut by_use: Vec<u64> = self.entries.values().map(|e| e.used).collect();
by_use.sort_unstable();
let cut = by_use.get(by_use.len() - MAX_THUMBS * 3 / 4).copied().unwrap_or(0);
self.entries.retain(|_, e| e.used >= cut);
}
}
}
}
/// The texture of row `n` (`px` pixels square) to draw: made or updated now, or on the
/// worker (the old one is drawn meanwhile). Thumbnails shown stay during a drag and until
/// the document settles.
fn texture(&mut self, ctx: &egui::Context, src: &ThumbSource, n: &Arc<Node>, px: u32) -> Option<egui::TextureHandle> {
if !self.worker_started {
self.worker_started = true;
self.worker = ThumbWorker::spawn(ctx, "thumbnails");
}
let done = match self.worker.as_ref().map(ThumbWorker::poll) {
Some(Some(done)) => done,
Some(None) => {
// The thread is gone: render here from now on.
self.worker = None;
self.pending.clear();
vec![]
}
None => vec![],
};
for d in done {
let (key, node, px) = d.key;
if self.pending.get(&key).is_some_and(|(p, ppx)| p.ptr_eq(&node) && *ppx == px) {
self.pending.remove(&key);
}
self.upload(ctx, key, node, px, d.img.as_ref(), d.ms);
}
let key = (src.uid, n.id);
let pass = ctx.cumulative_pass_nr();
let (fresh, light) = match self.entries.get_mut(&key) {
Some(e) => {
e.used = pass;
(std::ptr::eq(e.node.as_ptr(), Arc::as_ptr(n)) && e.px == px, Some(e.ms <= LIGHT_THUMB_MS))
}
None => (false, None),
};
let shown = self.entries.get(&key).and_then(|e| e.tex.clone());
if fresh || self.pending.get(&key).is_some_and(|(p, ppx)| std::ptr::eq(p.as_ptr(), Arc::as_ptr(n)) && *ppx == px) {
return shown;
}
if light.is_some() && (src.busy || !self.settle.settled(ctx, src.uid, src.revision, ctx.input(|i| i.time))) {
return shown;
}
if self.spent.0 != pass {
self.spent = (pass, 0.0, 0);
}
let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT;
let light = light.unwrap_or_else(|| small_node(n, LIGHT_THUMB_NODES)) && in_budget;
if !light && let Some(w) = &self.worker {
let (doc, node) = (src.doc.clone(), n.clone());
let job: ThumbJob = (key, Arc::downgrade(n), px);
if w.submit(key, job, Box::new(move |r| r.render_node_thumbnail_with(&doc, &node, px, None, &thumb_options()))) {
self.pending.insert(key, (Arc::downgrade(n), px));
return shown;
}
self.worker = None;
self.pending.clear();
}
if !in_budget {
ctx.request_repaint();
return shown;
}
thread_local! {
static RENDERER: std::cell::RefCell<vectorcraft_render::Renderer> = std::cell::RefCell::new(vectorcraft_render::Renderer::new());
}
let t0 = crate::now_ms();
let img = RENDERER.with(|r| r.borrow_mut().render_node_thumbnail_with(src.doc, n, px, None, &thumb_options()));
let ms = crate::now_ms() - t0;
self.spent.1 += ms;
self.spent.2 += 1;
self.renders += 1;
self.upload(ctx, key, Arc::downgrade(n), px, img.as_ref(), ms);
self.entries.get(&key).and_then(|e| e.tex.clone())
}
}
/// A real rendered thumbnail of row `n`, cached by node identity (unchanged nodes keep their `Arc`
/// allocation, so the address is a free change detector). Only rendered for visible rows; see
/// [`Thumbs::texture`] for when.
pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc<Node>, r: egui::Rect) -> bool {
if !ui.is_rect_visible(r) {
return true;
}
let key = (n as *const Node as usize, n.id.0);
let ppp = ui.ctx().pixels_per_point();
let px = (r.width() * ppp).round() as u32;
let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| {
let img = RENDERER.with(|rr| rr.borrow_mut().render_thumbnail(doc, n.id, px.max(8)))?;
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ui.ctx().load_texture(format!("layer-thumb-{}", n.id.0), color, egui::TextureOptions::LINEAR);
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() > 2000 {
c.clear();
}
c.insert(key, tex.clone());
});
Some(tex)
});
match tex {
let px = ((r.width() * ppp).round() as u32).max(8);
match THUMBS.with(|c| c.borrow_mut().texture(ui.ctx(), src, n, px)) {
Some(t) => {
ui.painter().image(t.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
true
@ -1303,6 +1488,34 @@ mod tests {
(app, layer, [NodeId(a), NodeId(b)])
}
#[test]
fn row_thumbnails_wait_for_the_end_of_a_drag() {
let (mut app, _, _) = two_rects();
app.session.execute("select.all", &json!({})).unwrap();
let ctx = egui::Context::default();
let renders = || THUMBS.with(|c| c.borrow().renders);
frame(&mut app, &ctx, vec![], false);
let first = renders();
assert!(first >= 1, "the rows' thumbnails render at once");
frame(&mut app, &ctx, vec![], false);
assert_eq!(renders(), first, "and stay");
// A move drag: the layer (and the moved objects) change on every preview.
app.session.begin_interaction("Move").unwrap();
for i in 1..=10 {
app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, i, 0]})).unwrap();
frame(&mut app, &ctx, vec![], false);
}
assert_eq!(renders(), first, "nothing renders while the drag goes on");
app.session.commit_interaction().unwrap();
for _ in 0..12 {
frame(&mut app, &ctx, vec![], false);
}
assert!(renders() > first, "the changed rows render after it");
let after = renders();
frame(&mut app, &ctx, vec![], false);
assert_eq!(renders(), after, "once");
}
#[test]
fn clicking_a_target_circle_targets_the_layer() {
let (mut app, layer, [a, b]) = two_rects();

View File

@ -234,7 +234,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::dim_label(ui, tl!("No document"));
return;
};
let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return };
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let thumbs = super::layers::ThumbSource::of(app, st, &doc);
let selected = selected_images(app);
let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice);
let mut clicked: Option<(u64, bool, bool)> = None;
@ -267,8 +269,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if o.thumb > 0.0 {
let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb));
ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE);
if let Some(n) = doc.node(NodeId(id)) {
super::layers::real_thumb(ui, &doc, n, tr);
if let Some(n) = super::layers::node_arc(&doc, NodeId(id)) {
super::layers::real_thumb(ui, &thumbs, &n, tr);
}
ui.painter().rect_stroke(tr, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
x = tr.right() + 6.0;

View File

@ -1,7 +1,10 @@
//! Navigator panel: a thumbnail of the document (rendered with `Renderer::render_region`, cached by
//! document revision) with the red view box you drag to pan, zoom out/in buttons, a zoom slider
//! Navigator panel: a thumbnail of the document (rendered with `Renderer::render_region_with`,
//! cached by document revision, off the UI thread for heavy documents and not during drags: see
//! [`crate::thumb_worker`]) with the red view box you drag to pan, zoom out/in buttons, a zoom slider
//! and a zoom field.
use std::sync::Arc;
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use vectorcraft_geom::{Point, Rect as DRect};
@ -9,19 +12,135 @@ use vectorcraft_geom::{Point, Rect as DRect};
use super::{pstate, set_pstate};
use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::thumb_worker::{Settle, ThumbWorker};
use crate::widgets::{self, menu_item};
/// What a navigator image shows: document `uid` at `revision`, `w`×`h` pixels, the artboards only.
#[derive(Clone, Copy, Debug, PartialEq)]
struct ImageKey {
uid: u64,
revision: u64,
w: u32,
h: u32,
artboard_only: bool,
}
#[derive(Default)]
struct Cache {
renderer: Option<vectorcraft_render::Renderer>,
key: Option<(usize, u64, u32, u32, bool)>,
/// Renders heavy documents' images (None: on this thread).
worker: Option<ThumbWorker<(), (ImageKey, DRect)>>,
worker_started: bool,
/// The region shown for (uid, revision, artboard only): the art bounds walk the whole document.
region: Option<((u64, u64, bool), DRect)>,
/// The image shown: what it shows and the document rect it covers.
shown: Option<(ImageKey, DRect)>,
tex: Option<egui::TextureHandle>,
/// The image the worker is making.
pending: Option<ImageKey>,
/// How long the last image took: past [`HEAVY_MS`] the next goes to the worker.
last_ms: f64,
settle: Settle,
/// Images made (tests count them).
renders: u64,
}
/// An image that took longer than this (ms) makes the next one render off the UI thread.
const HEAVY_MS: f64 = 8.0;
thread_local! {
static CACHE: crate::graphics::TexCache<Cache> = crate::graphics::TexCache::default();
}
impl Cache {
/// Upload image `r` showing `key` over `region`.
fn upload(&mut self, ctx: &egui::Context, key: ImageKey, region: DRect, r: &vectorcraft_render::Rendered, ms: f64) {
let img = egui::ColorImage::from_rgba_premultiplied([r.width as usize, r.height as usize], &r.pixels);
match &mut self.tex {
Some(tx) => tx.set(img, egui::TextureOptions::LINEAR),
None => self.tex = Some(ctx.load_texture("navigator", img, egui::TextureOptions::LINEAR)),
}
self.shown = Some((key, region));
self.last_ms = ms;
self.renders += 1;
}
/// The region (artboards, and the art unless `artboard_only`, with a margin) to show
/// `doc` (`uid` at `revision`) in. While `busy` (a drag) the last one of the document stays,
/// rather than walking the whole document every frame.
fn region(&mut self, doc: &vectorcraft_doc::Document, uid: u64, revision: u64, artboard_only: bool, busy: bool) -> DRect {
let key = (uid, revision, artboard_only);
if let Some((k, r)) = self.region
&& (k == key || (busy && k.0 == uid && k.2 == artboard_only))
{
return r;
}
let mut region = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap_or(DRect::new(0.0, 0.0, 612.0, 792.0));
if !artboard_only && let Some(b) = doc.art_bounds() {
region = region.union(b);
}
let region = region.inflate(region.width() * 0.04 + 4.0, region.height() * 0.04 + 4.0);
self.region = Some((key, region));
region
}
/// Bring the image up to `key` (`doc` over `region`): heavy documents render on the worker
/// while the last image stays, and nothing renders during a drag (`busy`) or before the
/// document has settled, unless there's no image of it yet.
fn update(&mut self, ctx: &egui::Context, doc: &Arc<vectorcraft_doc::Document>, key: ImageKey, region: DRect, busy: bool) {
if !self.worker_started {
self.worker_started = true;
self.worker = ThumbWorker::spawn(ctx, "navigator");
}
let done = match self.worker.as_ref().map(ThumbWorker::poll) {
Some(Some(done)) => done,
Some(None) => {
// The thread is gone: render here from now on.
(self.worker, self.pending) = (None, None);
vec![]
}
None => vec![],
};
for d in done {
if self.pending == Some(d.key.0) {
self.pending = None;
}
if let Some(img) = d.img {
self.upload(ctx, d.key.0, d.key.1, &img, d.ms);
}
}
let shown = self.shown.map(|(k, _)| k);
if shown == Some(key) || self.pending == Some(key) {
return;
}
let have = self.tex.is_some() && shown.is_some_and(|k| k.uid == key.uid);
let now = ctx.input(|i| i.time);
if have && (busy || !self.settle.settled(ctx, key.uid, key.revision, now)) {
return;
}
let s = (key.w as f64 / region.width()).min(key.h as f64 / region.height());
// As exported (template layers and guides left out), transparent; images far smaller than
// they are sample their mip levels.
let opts = vectorcraft_render::RenderOptions { skip_templates: true, image_mips: true, ..Default::default() };
let heavy = self.last_ms > HEAVY_MS && have;
if heavy && let Some(w) = &self.worker {
let (doc, opts) = (doc.clone(), opts.clone());
if w.submit((), (key, region), Box::new(move |r| Some(r.render_region_with(&doc, region, s, &opts)))) {
self.pending = Some(key);
return;
}
self.worker = None;
}
let t0 = crate::now_ms();
let img = NAV_RENDERER.with(|r| r.borrow_mut().render_region_with(doc, region, s, &opts));
self.upload(ctx, key, region, &img, crate::now_ms() - t0);
}
}
thread_local! {
/// Renders light documents' images on the UI thread.
static NAV_RENDERER: std::cell::RefCell<vectorcraft_render::Renderer> = std::cell::RefCell::new(vectorcraft_render::Renderer::new());
}
/// Map a document point into the thumbnail rect (region → rect, aspect preserved).
pub fn doc_to_thumb(region: DRect, thumb: Rect, p: Point) -> egui::Pos2 {
let s = (thumb.width() as f64 / region.width()).min(thumb.height() as f64 / region.height());
@ -54,33 +173,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
};
let artboard_only: bool = pstate(ui.ctx(), "nav-ab-only");
let doc = st.doc.clone();
let rev = st.revision;
let doc_idx = app.session.documents().iter().position(|d| std::sync::Arc::ptr_eq(&d.doc, &doc)).unwrap_or(0);
let mut region = doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap_or(DRect::new(0.0, 0.0, 612.0, 792.0));
if !artboard_only && let Some(b) = doc.art_bounds() {
region = region.union(b);
}
let region = region.inflate(region.width() * 0.04 + 4.0, region.height() * 0.04 + 4.0);
let (uid, revision) = (st.uid, st.revision);
// A drag in progress: every pointer move makes a new revision. The image waits for its end.
let busy = st.interaction.is_some() || app.session.tool_busy();
let region = CACHE.with(|c| c.borrow_mut().region(&doc, uid, revision, artboard_only, busy));
let w = ui.available_width();
let h = (w * 0.72).clamp(100.0, 200.0);
let (thumb, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click_and_drag());
ui.painter().rect_filled(thumb, 0.0, t.pasteboard);
let ppp = ui.ctx().pixels_per_point();
let (pw, ph) = ((w * ppp) as u32, (h * ppp) as u32);
let key = (doc_idx, rev, pw, ph, artboard_only);
let tex = CACHE.with(|c| {
let key = ImageKey { uid, revision, w: pw, h: ph, artboard_only };
let (tex, tex_region) = CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.key != Some(key) || c.tex.is_none() {
let s = (pw as f64 / region.width()).min(ph as f64 / region.height());
let r = c.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render_region(&doc, region, s, false);
let img = egui::ColorImage::from_rgba_premultiplied([r.width as usize, r.height as usize], &r.pixels);
match &mut c.tex {
Some(tx) => tx.set(img, egui::TextureOptions::LINEAR),
None => c.tex = Some(ui.ctx().load_texture("navigator", img, egui::TextureOptions::LINEAR)),
}
c.key = Some(key);
c.update(ui.ctx(), &doc, key, region, busy);
// Never another document's image.
match c.shown {
Some((k, r)) if k.uid == uid => (c.tex.clone(), r),
_ => (None, region),
}
c.tex.clone()
});
// Artboards (white) under the art thumbnail, like the canvas.
for a in &doc.artboards {
@ -88,8 +199,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().rect_filled(r, 0.0, Color32::WHITE);
}
if let Some(tex) = tex {
let tl = doc_to_thumb(region, thumb, region.origin());
let br = doc_to_thumb(region, thumb, Point::new(region.x1, region.y1));
let tl = doc_to_thumb(region, thumb, tex_region.origin());
let br = doc_to_thumb(region, thumb, Point::new(tex_region.x1, tex_region.y1));
ui.painter().image(tex.id(), Rect::from_min_max(tl, br), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
// The view box.
@ -164,4 +275,42 @@ mod tests {
assert!((zoom_to_slider(64000.0) - 1.0).abs() < 1e-9);
assert!((slider_to_zoom(zoom_to_slider(100.0)) - 100.0).abs() < 1e-6);
}
/// The navigator images made on this thread so far.
fn renders() -> u64 {
CACHE.with(|c| c.borrow().renders)
}
#[test]
fn no_image_is_rendered_during_a_drag_and_one_is_after_it() {
use vectorcraft_engine::Session;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
// One context, so its clock runs on (1/60 s a pass) and the document can settle.
let ctx = egui::Context::default();
let frame = |app: &mut VectorcraftApp| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
};
frame(&mut app);
let first = renders();
assert!(first >= 1, "the first image renders at once");
// A move drag: every preview is a new revision.
app.session.begin_interaction("Move").unwrap();
for i in 1..=20 {
app.session.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, i, 0]})).unwrap();
frame(&mut app);
}
assert_eq!(renders(), first, "nothing renders while the drag goes on");
app.session.commit_interaction().unwrap();
frame(&mut app);
assert_eq!(renders(), first, "the document settles first");
for _ in 0..12 {
frame(&mut app);
}
assert_eq!(renders(), first + 1, "one image after the drag");
let shown = CACHE.with(|c| c.borrow().shown.map(|(k, _)| k.revision));
assert_eq!(shown, Some(app.session.active().unwrap().revision));
}
}

View File

@ -248,7 +248,8 @@ fn mask_items(mask: Option<&OpacityMask>, can_make: bool) -> [(&'static str, &'s
}
/// A rendered thumbnail of `n` (which may live outside the tree, like mask art). One texture per
/// slot, re-rendered only when the document revision, object or size changes.
/// slot, re-rendered only when the document revision, object or size changes, and not during a
/// drag (every pointer move is a new revision): the last one stays until it ends.
fn node_thumb(app: &VectorcraftApp, ui: &Ui, slot: &str, n: &vectorcraft_doc::Node, r: egui::Rect, bg: Option<[u8; 4]>) -> bool {
use std::cell::RefCell;
thread_local! {
@ -256,14 +257,15 @@ fn node_thumb(app: &VectorcraftApp, ui: &Ui, slot: &str, n: &vectorcraft_doc::No
}
let Some(st) = app.session.active() else { return false };
let px = (r.width().min(r.height()) * ui.ctx().pixels_per_point()).round().max(8.0) as u32;
let (slot_id, key) = (egui::Id::new(slot), egui::Id::new((st.uid, st.revision, n.id, bg, px)));
let cached: Option<(egui::Id, egui::TextureHandle)> = ui.ctx().data(|d| d.get_temp(slot_id));
let (slot_id, key) = (egui::Id::new(slot), egui::Id::new((st.uid, n.id, bg, px)));
let busy = st.interaction.is_some() || app.session.tool_busy();
let cached: Option<(egui::Id, u64, egui::TextureHandle)> = ui.ctx().data(|d| d.get_temp(slot_id));
let tex = match cached {
Some((k, tex)) if k == key => Some(tex),
_ => RENDERER.with(|rr| rr.borrow_mut().render_node_thumbnail(&st.doc, n, px, bg)).map(|img| {
Some((k, rev, tex)) if k == key && (rev == st.revision || busy) => Some(tex),
_ => RENDERER.with(|rr| rr.borrow_mut().render_node_thumbnail_with(&st.doc, n, px, bg, &super::layers::thumb_options())).map(|img| {
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ui.ctx().load_texture(slot, color, egui::TextureOptions::LINEAR);
ui.ctx().data_mut(|d| d.insert_temp(slot_id, (key, tex.clone())));
ui.ctx().data_mut(|d| d.insert_temp(slot_id, (key, st.revision, tex.clone())));
tex
}),
};

View File

@ -0,0 +1,177 @@
//! Panel thumbnails (the Navigator's view of the document, the Layers and Links rows) rendered
//! off the UI thread on native builds, so a heavy document never stalls a frame. The panels keep
//! showing their last image until the new one is back, and leave thumbnails alone while the user
//! drags (every pointer move makes a new document revision): they update once the drag ends and
//! the document has [`Settle`]d.
//!
//! On wasm (no threads) and in unit tests [`ThumbWorker::spawn`] gives `None` and the panels
//! render on the UI thread, with the same rules about drags and settling.
use std::collections::VecDeque;
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use vectorcraft_render::{Rendered, Renderer};
/// How long (seconds) the document must stay unchanged before a thumbnail that is already shown
/// is drawn again.
pub const SETTLE_SECS: f64 = 0.1;
/// A render a panel asks for: run on the worker's own [`Renderer`].
pub type JobFn = Box<dyn FnOnce(&mut Renderer) -> Option<Rendered> + Send>;
/// A finished job: the key it was submitted with, its image and how long it took.
pub struct Done<K> {
pub key: K,
pub img: Option<Rendered>,
pub ms: f64,
}
struct Job<S, K> {
slot: S,
key: K,
run: JobFn,
}
/// A render thread for one panel's thumbnails. Jobs go in by slot (the Navigator has one, the
/// Layers panel one per row): a job not started yet is replaced by a newer one for its slot, so
/// only the latest image of anything is drawn.
pub struct ThumbWorker<S, K> {
tx: Sender<Job<S, K>>,
rx: Receiver<Done<K>>,
}
impl<S: PartialEq + Send + 'static, K: Send + 'static> ThumbWorker<S, K> {
/// Spawn the worker thread, `None` where rendering stays on the UI thread: on wasm, in unit
/// tests (they look at the panel right after the frame that asks), with
/// `VECTORCRAFT_SYNC_RENDER` set, or if the thread can't start.
pub fn spawn(ctx: &egui::Context, name: &str) -> Option<Self> {
if cfg!(target_arch = "wasm32") || cfg!(test) || std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_some() {
return None;
}
Self::start(ctx, name)
}
/// Spawn the thread.
fn start(ctx: &egui::Context, name: &str) -> Option<Self> {
let (tx, jobs) = channel::<Job<S, K>>();
let (done_tx, rx) = channel::<Done<K>>();
let ctx = ctx.clone();
std::thread::Builder::new()
.name(format!("vectorcraft-{name}"))
.spawn(move || {
let mut r = Renderer::new();
let mut queue: VecDeque<Job<S, K>> = VecDeque::new();
let add = |queue: &mut VecDeque<Job<S, K>>, job: Job<S, K>| match queue.iter_mut().find(|j| j.slot == job.slot) {
Some(old) => *old = job,
None => queue.push_back(job),
};
loop {
if queue.is_empty() {
// The panel is gone (its sender dropped): end the thread.
let Ok(job) = jobs.recv() else { break };
add(&mut queue, job);
}
while let Ok(job) = jobs.try_recv() {
add(&mut queue, job);
}
let Some(job) = queue.pop_front() else { continue };
let t0 = crate::now_ms();
let img = (job.run)(&mut r);
if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0 }).is_err() {
break;
}
ctx.request_repaint();
}
})
.ok()?;
Some(Self { tx, rx })
}
/// Queue `run` for `slot` (replacing a job for the slot that hasn't started). False when the
/// thread is gone: the caller renders on the UI thread from now on.
#[must_use]
pub fn submit(&self, slot: S, key: K, run: JobFn) -> bool {
self.tx.send(Job { slot, key, run }).is_ok()
}
/// The jobs finished since the last call, oldest first; None when the thread is gone (the
/// jobs it had will never finish).
pub fn poll(&self) -> Option<Vec<Done<K>>> {
let mut done = vec![];
loop {
match self.rx.try_recv() {
Ok(d) => done.push(d),
Err(TryRecvError::Empty) => return Some(done),
Err(TryRecvError::Disconnected) => return None,
}
}
}
}
/// When the document last changed, for [`SETTLE_SECS`]: thumbnails already shown are drawn again
/// only once it has stayed the same that long.
#[derive(Default)]
pub struct Settle {
seen: Option<(u64, u64)>,
since: f64,
}
impl Settle {
/// Note that document `uid` is at `revision` at time `now` (seconds, egui's input time) →
/// whether it has stayed there [`SETTLE_SECS`]. Otherwise `ctx` is asked to draw again when
/// it will have.
pub fn settled(&mut self, ctx: &egui::Context, uid: u64, revision: u64, now: f64) -> bool {
if self.seen != Some((uid, revision)) {
self.seen = Some((uid, revision));
self.since = now;
}
let waited = now - self.since;
// The clock went back (a new egui context): don't wait for it to catch up.
if waited >= SETTLE_SECS || waited < 0.0 {
return true;
}
ctx.request_repaint_after(std::time::Duration::from_secs_f64((SETTLE_SECS - waited).max(0.0)));
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jobs_come_back_newest_last_per_slot() {
let Some(w) = ThumbWorker::<u8, u32>::start(&egui::Context::default(), "test") else { return };
let job = |n: u32| -> JobFn { Box::new(move |_| Some(Rendered { width: n, height: 1, pixels: vec![0; 4] })) };
for k in 1..=3 {
assert!(w.submit(0, k, job(k)));
}
assert!(w.submit(1, 10, job(10)));
let mut got = vec![];
let t0 = std::time::Instant::now();
while !(got.contains(&3) && got.contains(&10)) && t0.elapsed().as_secs() < 30 {
got.extend(w.poll().unwrap().into_iter().map(|d| {
assert_eq!(d.img.map(|i| i.width), Some(d.key));
d.key
}));
std::thread::yield_now();
}
let slot0: Vec<u32> = got.iter().copied().filter(|k| *k < 10).collect();
assert_eq!(slot0.last(), Some(&3), "the newest job of a slot is the last done: {got:?}");
assert!(slot0.windows(2).all(|p| p[0] < p[1]), "{got:?}");
assert!(got.contains(&10));
drop(w);
}
#[test]
fn settles_after_the_document_stays_unchanged() {
let ctx = egui::Context::default();
let mut s = Settle::default();
assert!(!s.settled(&ctx, 1, 1, 10.0), "a change starts the wait");
assert!(!s.settled(&ctx, 1, 1, 10.05));
assert!(s.settled(&ctx, 1, 1, 10.15));
assert!(!s.settled(&ctx, 1, 2, 10.2), "a new revision starts it again");
assert!(!s.settled(&ctx, 2, 2, 10.25), "so does another document");
assert!(s.settled(&ctx, 2, 2, 1.0), "a clock that went back doesn't hold it up");
}
}