From e73f51debc6e55c0dac8fb25b9e4782e3e952fc0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 13:00:45 -0800 Subject: [PATCH] Layers: thumbnails of long paths are drawn off the UI thread The Layers panel chose between the UI thread and the thumbnail thread for a row's first thumbnail by counting objects only, so one path of 200,000 anchors was rendered on the UI thread. A row whose paths hold more than 2,000 anchors in all now goes to the thumbnail thread, as a row of more than 200 objects already did. While such a row waits, the painted fallback thumbnail draws nothing for it instead of measuring the path's bounds on every frame. Opening the panel on one path of 200,000 anchors: the first frame takes 2.4 ms instead of 393 ms (release build, median of three runs). --- crates/ui-egui/src/panels/layers/thumbs.rs | 151 ++++++++++++++++++--- crates/ui-egui/src/thumb_worker.rs | 8 +- docs/target-app-parity.md | 1 + 3 files changed, 142 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index adcadd6a2..c38925b1b 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -1,4 +1,7 @@ +use vectorcraft_geom::PathData; + use super::{Arc, Color32, Document, HashMap, Node, NodeId, Settle, ThumbWorker, Ui, VectorcraftApp, Weak, vec2}; +use crate::thumb_worker::Done; /// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in /// progress (the thumbnails shown then stay until it ends). @@ -71,6 +74,10 @@ 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; +/// A node whose paths have more anchors than this in all has its first thumbnail made on the +/// worker. +const LIGHT_THUMB_ANCHORS: usize = 2_000; + /// 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; @@ -82,22 +89,30 @@ 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 { +/// Has `n` at most `nodes` objects inside it, and at most `anchors` anchors in its paths and the +/// paths inside it (counting stops at either limit)? +fn small_node(n: &Node, nodes: usize, anchors: usize) -> bool { + fn count(n: &Node, nodes: &mut usize, anchors: &mut usize) -> bool { + let Some(left) = anchors.checked_sub(n.path_data().map_or(0, PathData::anchor_count)) else { return false }; + *anchors = left; for c in n.children().into_iter().flatten() { - if *left == 0 { + if *nodes == 0 { return false; } - *left -= 1; - if !count(c, left) { + *nodes -= 1; + if !count(c, nodes, anchors) { return false; } } true } - let mut left = limit; - count(n, &mut left) + let (mut nodes, mut anchors) = (nodes, anchors); + count(n, &mut nodes, &mut anchors) +} + +/// The thumbnail of `n` (`px` pixels square) as `r` renders it, on the UI thread or the worker. +fn render_thumb(r: &mut vectorcraft_render::Renderer, doc: &Document, n: &Node, px: u32) -> Option { + r.render_node_thumbnail_with(doc, n, px, None, &thumb_options()) } impl Thumbs { @@ -129,6 +144,15 @@ impl Thumbs { } } + /// Keep the thumbnail `d` the worker finished. + fn receive(&mut self, ctx: &egui::Context, d: 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); + } + /// 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. @@ -148,11 +172,7 @@ impl Thumbs { 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); + self.receive(ctx, d); } let key = (src.uid, n.id); let pass = ctx.cumulative_pass_nr(); @@ -174,11 +194,11 @@ impl Thumbs { 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; + let light = light.unwrap_or_else(|| small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS)) && 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()))) { + if w.submit(key, job, Box::new(move |r| render_thumb(r, &doc, &node, px))) { self.pending.insert(key, (Arc::downgrade(n), px)); return shown; } @@ -193,7 +213,7 @@ impl Thumbs { 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 img = RENDERER.with(|r| render_thumb(&mut r.borrow_mut(), src.doc, n, px)); let ms = crate::now_ms() - t0; self.spent.1 += ms; self.spent.2 += 1; @@ -221,8 +241,13 @@ pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc, r: egui::Rec } } -/// Tiny vector thumbnail painted with egui (fallback when rendering isn't possible). +/// Tiny vector thumbnail painted with egui (fallback when rendering isn't possible). A node with +/// more than [`LIGHT_THUMB_ANCHORS`] anchors paints nothing: measuring its bounds takes about as +/// long as rendering it. pub(super) fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { + if !small_node(n, usize::MAX, LIGHT_THUMB_ANCHORS) { + return; + } let Some(b) = n.visual_bounds() else { return }; let s = ((r.width() - 3.0) as f64 / b.width().max(b.height()).max(1e-6)) as f32; let off = r.center() - vec2(b.center().x as f32 * s, b.center().y as f32 * s); @@ -245,3 +270,95 @@ pub(super) fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { } }); } + +#[cfg(test)] +mod tests { + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::{Appearance, LayerColor}; + use vectorcraft_geom::{Point, SubPath}; + + use super::*; + + /// An open stroked path `id` of `anchors` anchors. + fn path(id: u64, anchors: usize) -> Node { + let pts: Vec = (0..anchors).map(|i| Point::new(i as f64 * 0.01, (i % 2) as f64 * 50.0)).collect(); + Node::path(NodeId(id), PathData::single(SubPath::polyline(&pts, false)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)) + } + + fn rect(id: u64) -> Node { + let r = vectorcraft_geom::rect_from_points(Point::new(0.0, 0.0), Point::new(10.0, 10.0)); + Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)) + } + + fn small(n: &Node) -> bool { + small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS) + } + + #[test] + fn the_first_thumbnail_goes_to_the_worker_by_anchors_as_well_as_objects() { + assert!(!small(&path(1, LIGHT_THUMB_ANCHORS + 1)), "one long path"); + assert!(small(&path(1, LIGHT_THUMB_ANCHORS)), "a path of as many anchors as the limit"); + let group = |n: usize| Node::group(NodeId(1), (0..n).map(|i| Arc::new(rect(i as u64 + 2))).collect()); + assert!(small(&group(LIGHT_THUMB_NODES)), "many small objects"); + assert!(!small(&group(LIGHT_THUMB_NODES + 1)), "more objects than the limit"); + assert!(small(&Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0))), "an empty layer"); + let inner = Node::group(NodeId(2), vec![Arc::new(path(3, LIGHT_THUMB_ANCHORS + 1))]); + assert!(!small(&Node::group(NodeId(1), vec![Arc::new(rect(4)), Arc::new(inner)])), "a group holding a long path"); + let halves = Node::group(NodeId(1), vec![Arc::new(path(2, LIGHT_THUMB_ANCHORS / 2)), Arc::new(path(3, LIGHT_THUMB_ANCHORS / 2 + 1))]); + assert!(!small(&halves), "paths whose anchors together pass the limit"); + } + + #[test] + fn the_painted_thumbnail_leaves_out_a_long_path() { + let ctx = egui::Context::default(); + let painted = |n: &Node| { + let r = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(24.0, 24.0)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| thumb(ui, n, r)); + out.textures_delta.clear(); + out.shapes.len() + }; + let none = painted(&Node::layer(NodeId(1), "Layer 1", LayerColor::Preset(0))); + assert_eq!(painted(&rect(1)), none + 1, "a small path is painted"); + assert_eq!(painted(&path(1, LIGHT_THUMB_ANCHORS)), none + 1, "a path of as many anchors as the limit"); + assert_eq!(painted(&path(1, LIGHT_THUMB_ANCHORS + 1)), none, "a long path is not"); + } + + /// A document with `n` on its layer, and `n` as the layer holds it. + fn doc_with(n: impl FnOnce(NodeId) -> Node) -> (Arc, Arc) { + let mut doc = Document::new(200.0, 200.0); + let layer = doc.layers.first().map(|l| l.id); + let id = doc.alloc_id(); + doc.insert(layer, usize::MAX, n(id)).unwrap(); + let doc = Arc::new(doc); + let node = node_arc(&doc, id).unwrap(); + (doc, node) + } + + #[test] + fn a_long_paths_thumbnail_is_made_on_the_worker_and_matches_one_made_here() { + let ctx = egui::Context::default(); + let Some(worker) = ThumbWorker::start(&ctx, "test") else { return }; + let mut thumbs = Thumbs { worker: Some(worker), worker_started: true, ..Default::default() }; + let (doc, n) = doc_with(|id| path(id.0, 50_000)); + let src = ThumbSource { uid: 1, revision: 1, doc: &doc, busy: false }; + assert!(thumbs.texture(&ctx, &src, &n, 24).is_none(), "nothing is shown until it is made"); + assert_eq!(thumbs.renders, 0, "not made on this thread"); + assert!(thumbs.pending.contains_key(&(1, n.id)), "sent to the worker"); + let done = thumbs.worker.as_ref().and_then(ThumbWorker::wait).expect("the worker finishes it"); + let there = done.img.as_ref().expect("the worker draws the path"); + let here = render_thumb(&mut vectorcraft_render::Renderer::new(), &doc, &n, 24).expect("this thread draws the path"); + assert!(here.pixels.chunks(4).any(|p| p[3] > 0), "the thumbnail shows the path"); + assert_eq!((there.width, there.height, &there.pixels), (here.width, here.height, &here.pixels), "the same pixels"); + thumbs.receive(&ctx, done); + assert!(thumbs.pending.is_empty()); + assert!(thumbs.texture(&ctx, &src, &n, 24).is_some(), "the worker's thumbnail is shown"); + assert_eq!(thumbs.renders, 0, "and not made again here"); + + // A small object's thumbnail is still made on this thread, at once. + let (doc, n) = doc_with(|id| rect(id.0)); + let src = ThumbSource { uid: 2, revision: 1, doc: &doc, busy: false }; + assert!(thumbs.texture(&ctx, &src, &n, 24).is_some()); + assert_eq!(thumbs.renders, 1); + assert!(thumbs.pending.is_empty()); + } +} diff --git a/crates/ui-egui/src/thumb_worker.rs b/crates/ui-egui/src/thumb_worker.rs index f187d06dd..ceea0b3ce 100644 --- a/crates/ui-egui/src/thumb_worker.rs +++ b/crates/ui-egui/src/thumb_worker.rs @@ -52,7 +52,7 @@ impl ThumbWorker { } /// Spawn the thread. - fn start(ctx: &egui::Context, name: &str) -> Option { + pub(crate) fn start(ctx: &egui::Context, name: &str) -> Option { let (tx, jobs) = channel::>(); let (done_tx, rx) = channel::>(); let ctx = ctx.clone(); @@ -106,6 +106,12 @@ impl ThumbWorker { } } } + + /// The next finished job, waiting for it; None when the thread is gone. + #[cfg(test)] + pub(crate) fn wait(&self) -> Option> { + self.rx.recv().ok() + } } /// When the document last changed, for [`SETTLE_SECS`]: thumbnails already shown are drawn again diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1e3541028..b79e60f53 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1269,6 +1269,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460). Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429). In the web build the status bar's render and UI times are measured with the browser's `performance.now()`, and the Layers panel stops rendering row thumbnails in a frame once they have taken 8 ms. + On the desktop, the Layers panel renders the first thumbnail of a row whose paths hold more than 2,000 anchors in all on its thumbnail thread, as it already did for a row of more than 200 objects, and the row shows no picture until that thumbnail is ready. With one path of 200,000 anchors, the first frame after the panel opens takes 2.4 ms instead of 393 ms (release build, median of three runs). In the web build, while a pan, zoom or rotation changes the view of a document whose last canvas render took more than 8 ms, the canvas draws the last frame moved with the view, and it renders the art again once the view has been still for 0.2 s. An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429). An Alt-drag of objects in perspective with the Perspective Selection tool, and an Alt-drag of a plane widget with that tool or the Perspective Grid tool, copy the objects on the first move, and the moves after it move those copies (#1429).