From 9139883075f84fe26e80950a90d5ac29139286cb Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Sun, 11 Oct 2026 23:28:11 +0530 Subject: [PATCH] #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 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/asset_export.rs | 4 +- crates/ui-egui/src/panels/layers.rs | 275 +++++++++++++++++++--- crates/ui-egui/src/panels/links.rs | 8 +- crates/ui-egui/src/panels/navigator.rs | 199 ++++++++++++++-- crates/ui-egui/src/panels/transparency.rs | 14 +- crates/ui-egui/src/thumb_worker.rs | 177 ++++++++++++++ 8 files changed, 613 insertions(+), 67 deletions(-) create mode 100644 crates/ui-egui/src/thumb_worker.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9034e1d7..f51c6992 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2def8922..f2a5cbc0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index ced2d907..2583f74a 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -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); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 8cc08516..43d62fcd 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -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, 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, + /// Where the row thumbnails come from. + thumbs: ThumbSource<'a>, sel: HashSet, /// The selected ruler guides. guides: Vec, @@ -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 = RefCell::new(vectorcraft_render::Renderer::new()); - static CACHE: crate::graphics::TexCache> = 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, + 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) -> 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> { + 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, + px: u32, + /// None: the node draws nothing. + tex: Option, + /// 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, u32); + +#[derive(Default)] +struct Thumbs { + entries: HashMap<(u64, NodeId), ThumbEntry>, + /// What the worker is rendering, by row. + pending: HashMap<(u64, NodeId), (Weak, u32)>, + worker: Option>, + 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 = 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, 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 = 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, px: u32) -> Option { + 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 = 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, 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(); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 154b26eb..8ba4fb17 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -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; diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index 70ef3144..d2ac1603 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -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, - key: Option<(usize, u64, u32, u32, bool)>, + /// Renders heavy documents' images (None: on this thread). + worker: Option>, + 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, + /// The image the worker is making. + pending: Option, + /// 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 = 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, 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 = 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)); + } } diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index c38d7ac7..7a5375d1 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -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 }), }; diff --git a/crates/ui-egui/src/thumb_worker.rs b/crates/ui-egui/src/thumb_worker.rs new file mode 100644 index 00000000..31ee3dd0 --- /dev/null +++ b/crates/ui-egui/src/thumb_worker.rs @@ -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 Option + Send>; + +/// A finished job: the key it was submitted with, its image and how long it took. +pub struct Done { + pub key: K, + pub img: Option, + pub ms: f64, +} + +struct Job { + 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 { + tx: Sender>, + rx: Receiver>, +} + +impl ThumbWorker { + /// 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 { + 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 { + let (tx, jobs) = channel::>(); + let (done_tx, rx) = channel::>(); + let ctx = ctx.clone(); + std::thread::Builder::new() + .name(format!("vectorcraft-{name}")) + .spawn(move || { + let mut r = Renderer::new(); + let mut queue: VecDeque> = VecDeque::new(); + let add = |queue: &mut VecDeque>, job: Job| 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>> { + 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::::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 = 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"); + } +}