From 0f9839f7f60b1914b18348cf477c026d4d6fb99e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 18:20:25 -0800 Subject: [PATCH 1/2] Layers: heavy type, symbols, meshes and images get their first thumbnail off the UI thread The Layers panel decided whether to make a row's first thumbnail on the UI thread by counting path anchors and type frames only, so point type, symbol instances, gradient meshes, images and placed documents counted as one object with no anchors, and a heavy one was rendered on the UI thread. - small_node takes the row's document. Each glyph of type counts as 4 anchors, each mesh point as 1 and every 100 image pixels as 1, against the same 2,000-anchor limit; glyphs are counted only up to the limit. - A symbol instance and an inline graphic count their symbol's art, and a placed document counts its file's art once the file has been read; one not read yet goes to the thumbnail thread. - Symbols and placed documents nested more than 8 deep go to the thumbnail thread, so a symbol holding an instance of itself is counted to an end. - The painted fallback thumbnail counts as before. --- crates/ui-egui/src/panels/layers/thumbs.rs | 262 +++++++++++++++++++-- docs/target-app-parity.md | 1 + 2 files changed, 238 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index dea908ea0..f86efc034 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -76,9 +76,25 @@ const LIGHT_THUMB_MS: f64 = 4.0; 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. +/// worker. Type's glyphs, mesh points and image pixels count against it too, each as the anchors +/// that take about as long to render: [`GLYPH_ANCHORS`], [`MESH_POINT_ANCHORS`] and +/// [`IMAGE_PIXELS_PER_ANCHOR`]. const LIGHT_THUMB_ANCHORS: usize = 2_000; +/// Anchors one glyph of type counts as. +const GLYPH_ANCHORS: usize = 4; + +/// Anchors one point of a gradient mesh counts as. +const MESH_POINT_ANCHORS: usize = 1; + +/// Pixels of an image that count as one anchor. +const IMAGE_PIXELS_PER_ANCHOR: usize = 100; + +/// The most symbols and placed documents counted inside one another: a node holding them deeper +/// (a symbol whose art holds an instance of itself, for one) has its first thumbnail made on the +/// worker. +const MAX_THUMB_NESTING: usize = 8; + /// 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; @@ -93,8 +109,40 @@ pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions { /// Has `n` at most `nodes` objects inside it, and at most `anchors` anchors in its paths and the /// paths inside it, the frames of area type and the paths of type on a path included (counting /// stops at either limit)? With `masks`, the art of the opacity masks that are not disabled, and -/// the objects and anchors inside it, count as well. -fn small_node(n: &Node, nodes: usize, anchors: usize, masks: bool) -> bool { +/// the objects and anchors inside it, count as well. With `doc` (the document `n` is in), type's +/// glyphs, mesh points and image pixels count as anchors too ([`LIGHT_THUMB_ANCHORS`]), and a +/// symbol instance and a placed document count the objects and anchors of the art they show: the +/// symbol's art in `doc`, and the placed file's art when it has been read (a placed document whose +/// file has not been read, or art nested deeper than [`MAX_THUMB_NESTING`], is not small). +fn small_node(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<&Document>) -> bool { + /// What may still be counted. + struct Left { + nodes: usize, + anchors: usize, + masks: bool, + } + impl Left { + /// Count `anchors` more: false past the limit. + fn take(&mut self, anchors: usize) -> bool { + match self.anchors.checked_sub(anchors) { + Some(left) => { + self.anchors = left; + true + } + None => false, + } + } + /// Count one more object: false past the limit. + fn object(&mut self) -> bool { + match self.nodes.checked_sub(1) { + Some(left) => { + self.nodes = left; + true + } + None => false, + } + } + } fn own_anchors(n: &Node) -> usize { match &n.kind { NodeKind::Text(t) => match &t.kind { @@ -104,23 +152,41 @@ fn small_node(n: &Node, nodes: usize, anchors: usize, masks: bool) -> bool { _ => n.path_data().map_or(0, PathData::anchor_count), } } - fn count(n: &Node, nodes: &mut usize, anchors: &mut usize, masks: bool) -> bool { - let Some(left) = anchors.checked_sub(own_anchors(n)) else { return false }; - *anchors = left; - let mask = n.extras().mask.as_deref().filter(|m| masks && !m.disabled).map(|m| &m.art); - for c in n.children().into_iter().flatten().chain(mask) { - if *nodes == 0 { - return false; - } - *nodes -= 1; - if !count(c, nodes, anchors, masks) { - return false; - } - } - true + /// Count `art`, drawn in place of an object (`doc` its document), one level deeper. + fn inside(art: &Node, doc: &Document, left: &mut Left, depth: usize) -> bool { + depth < MAX_THUMB_NESTING && left.object() && count(art, Some(doc), left, depth + 1) } - let (mut nodes, mut anchors) = (nodes, anchors); - count(n, &mut nodes, &mut anchors, masks) + /// Count symbol `name` of `doc` (one that doesn't exist draws nothing). + fn symbol(name: &str, doc: &Document, left: &mut Left, depth: usize) -> bool { + doc.symbols.iter().find(|s| s.name == name).is_none_or(|s| inside(&s.art, doc, left, depth)) + } + /// Count what `n` draws besides its children. + fn own(n: &Node, doc: Option<&Document>, left: &mut Left, depth: usize) -> bool { + if !left.take(own_anchors(n)) { + return false; + } + let Some(doc) = doc else { return true }; + match &n.kind { + // Glyphs are counted only up to one past what the anchors left allow. + NodeKind::Text(t) => t.runs.iter().all(|r| { + let glyphs = r.text.chars().take(left.anchors / GLYPH_ANCHORS + 1).count(); + left.take(glyphs.saturating_mul(GLYPH_ANCHORS)) && r.inline.as_ref().is_none_or(|a| symbol(&a.symbol, doc, left, depth)) + }), + NodeKind::SymbolInstance { symbol: name, .. } => symbol(name, doc, left, depth), + NodeKind::Mesh(m) => left.take(m.points.len().saturating_mul(MESH_POINT_ANCHORS)), + NodeKind::Image(im) => left.take((im.width as usize).saturating_mul(im.height as usize) / IMAGE_PIXELS_PER_ANCHOR), + NodeKind::PlacedDocument(p) => vectorcraft_doc::placed_document::read_cached(p).is_some_and(|e| inside(&e.art, &e.doc, left, depth)), + _ => true, + } + } + fn count(n: &Node, doc: Option<&Document>, left: &mut Left, depth: usize) -> bool { + if !own(n, doc, left, depth) { + return false; + } + let mask = n.extras().mask.as_deref().filter(|m| left.masks && !m.disabled).map(|m| &m.art); + n.children().into_iter().flatten().chain(mask).all(|c| left.object() && count(c, doc, left, depth)) + } + count(n, doc, &mut Left { nodes, anchors, masks }, 0) } /// The thumbnail of `n` (`px` pixels square) as `r` renders it, on the UI thread or the worker. @@ -209,9 +275,10 @@ impl Thumbs { let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT; // A row drawn before is light when its last thumbnail was quick to render, unless its // object now has more anchors than the limit. + let doc = Some(src.doc.as_ref()); let light = match light { - Some(quick) => quick && small_node(n, usize::MAX, LIGHT_THUMB_ANCHORS, true), - None => small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS, true), + Some(quick) => quick && small_node(n, usize::MAX, LIGHT_THUMB_ANCHORS, true, doc), + None => small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS, true, doc), } && in_budget; if !light && let Some(w) = &self.worker { let (doc, node) = (src.doc.clone(), n.clone()); @@ -263,7 +330,7 @@ pub(crate) fn real_thumb(ui: &Ui, src: &ThumbSource, n: &Arc, r: egui::Rec /// more than [`LIGHT_THUMB_ANCHORS`] anchors paints nothing, since this runs on every frame and /// measuring its bounds takes time in proportion to its anchor count. pub(super) fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { - if !small_node(n, usize::MAX, LIGHT_THUMB_ANCHORS, false) { + if !small_node(n, usize::MAX, LIGHT_THUMB_ANCHORS, false, None) { return; } let Some(b) = n.visual_bounds() else { return }; @@ -293,7 +360,7 @@ pub(super) fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { mod tests { use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, CharStyle, LayerColor, NodeKind, OpacityMask, TextKind, TextObject}; - use vectorcraft_geom::{Point, SubPath}; + use vectorcraft_geom::{Affine, Point, SubPath}; use super::*; @@ -330,7 +397,12 @@ mod tests { } fn small(n: &Node) -> bool { - small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS, true) + small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS, true, None) + } + + /// Is `n`, an object of `doc`, small? + fn small_in(doc: &Document, n: &Node) -> bool { + small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS, true, Some(doc)) } /// `n` with an opacity mask of `art`, disabled or not. @@ -350,7 +422,7 @@ mod tests { assert!(!small(&masked(rect(1), inner, false)), "a mask holding a long path"); let child = Node::group(NodeId(1), vec![Arc::new(masked(rect(4), long(), false))]); assert!(!small(&child), "a group holding an object with a long mask"); - assert!(small_node(&masked(rect(1), long(), false), usize::MAX, LIGHT_THUMB_ANCHORS, false), "masks left out"); + assert!(small_node(&masked(rect(1), long(), false), usize::MAX, LIGHT_THUMB_ANCHORS, false, None), "masks left out"); } #[test] @@ -373,6 +445,124 @@ mod tests { assert!(!small(&both), "a path and an area frame whose anchors together pass the limit"); } + /// Point type `id` of `glyphs` glyphs. + fn glyphs(id: u64, glyphs: usize) -> Node { + let t = TextObject::point(Point::ZERO, &"a".repeat(glyphs), CharStyle::default()); + Node::new(NodeId(id), NodeKind::Text(Box::new(t))) + } + + /// A gradient mesh `id` of `rows` x `rows` patches. + fn mesh(id: u64, rows: u32) -> Node { + let m = vectorcraft_doc::GradientMesh::from_surface(rows, rows, &|u, v| Point::new(u * 100.0, v * 100.0), &|_, _| Color::BLACK); + Node::new(NodeId(id), NodeKind::Mesh(m)) + } + + /// An image `id` of `w` x `h` pixels. + fn image(id: u64, w: u32, h: u32) -> Node { + let im = vectorcraft_doc::ImageObject { + key: "im".into(), + width: w, + height: h, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + Node::new(NodeId(id), NodeKind::Image(Box::new(im))) + } + + /// An instance `id` of symbol `name`. + fn instance(id: u64, name: &str) -> Node { + Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }) + } + + /// A document whose symbol `name` is `art`. + fn with_symbol(name: &str, art: Node) -> Document { + let mut doc = Document::new(200.0, 200.0); + doc.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); + doc + } + + /// A placed document `id` showing file `key`, read already when `art` is given. + fn placed(id: u64, key: &str, art: Option) -> Node { + let p = vectorcraft_doc::PlacedDocument { + link: vectorcraft_doc::LinkInfo::new("/art/placed.vectorcraft"), + key: key.into(), + bounding: false, + page_box: Default::default(), + width: 100.0, + height: 100.0, + xf: Affine::IDENTITY, + placement: Default::default(), + }; + if let Some(art) = art { + let frame = vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0); + vectorcraft_doc::placed_document::prime(&p, Arc::new(key.as_bytes().to_vec()), (Document::new(100.0, 100.0), art, frame)); + } + Node::new(NodeId(id), NodeKind::PlacedDocument(Box::new(p))) + } + + #[test] + fn type_meshes_and_images_count_their_work_as_anchors() { + let doc = Document::new(200.0, 200.0); + let limit = LIGHT_THUMB_ANCHORS / GLYPH_ANCHORS; + assert!(small_in(&doc, &glyphs(1, limit)), "type of as many glyphs as the limit"); + assert!(!small_in(&doc, &glyphs(1, limit + 1)), "type of more glyphs"); + assert!(!small_in(&doc, &glyphs(1, 5_000)), "long type"); + let beside = Node::group(NodeId(1), vec![Arc::new(glyphs(2, limit / 2)), Arc::new(path(3, LIGHT_THUMB_ANCHORS / 2 + 1))]); + assert!(!small_in(&doc, &beside), "type and a path that together pass the limit"); + // (rows + 1)² points: 1,936 and 2,025. + assert!(small_in(&doc, &mesh(1, 43)), "a small mesh"); + assert!(!small_in(&doc, &mesh(1, 44)), "a mesh of more points"); + let px = (LIGHT_THUMB_ANCHORS * IMAGE_PIXELS_PER_ANCHOR) as u32; + assert!(small_in(&doc, &image(1, px / 100, 100)), "an image of as many pixels as the limit"); + assert!(!small_in(&doc, &image(1, px / 100, 101)), "a larger image"); + assert!(!small_in(&doc, &image(1, 4_000, 3_000)), "a photo"); + // Without the document only paths and frames count, as the painted thumbnail needs. + assert!(small(&glyphs(1, 5_000)) && small(&mesh(1, 100)) && small(&image(1, 4_000, 3_000))); + } + + #[test] + fn a_symbol_instance_and_a_placed_document_count_the_art_they_show() { + let doc = with_symbol("Long", path(10, LIGHT_THUMB_ANCHORS + 1)); + assert!(!small_in(&doc, &instance(1, "Long")), "an instance of a symbol of a long path"); + assert!(small(&instance(1, "Long")), "without the document"); + assert!(small_in(&doc, &instance(1, "Missing")), "an instance of a symbol that doesn't exist"); + let doc = with_symbol("Short", path(10, LIGHT_THUMB_ANCHORS)); + assert!(small_in(&doc, &instance(1, "Short")), "an instance of a symbol of a path at the limit"); + let two = Node::group(NodeId(1), vec![Arc::new(instance(2, "Short")), Arc::new(instance(3, "Short"))]); + assert!(!small_in(&doc, &two), "two instances whose art together passes the limit"); + let many = with_symbol("Group", Node::group(NodeId(10), (0..LIGHT_THUMB_NODES).map(|i| Arc::new(rect(i as u64 + 11))).collect())); + assert!(!small_in(&many, &instance(1, "Group")), "a symbol of more objects than the limit"); + // Inline graphics in type draw their symbol too. + let mut t = TextObject::point(Point::ZERO, "a", CharStyle::default()); + t.runs[0].inline = Some(vectorcraft_doc::InlineArt { symbol: "Long".into(), scale: 1.0, baseline_shift: 0.0, bounds: None }); + let long = with_symbol("Long", path(10, LIGHT_THUMB_ANCHORS + 1)); + assert!(!small_in(&long, &Node::new(NodeId(1), NodeKind::Text(Box::new(t)))), "type showing a long symbol inline"); + + let doc = Document::new(200.0, 200.0); + assert!(small_in(&doc, &placed(1, "thumbs-small-placed", Some(path(10, LIGHT_THUMB_ANCHORS)))), "a placed file of a short path"); + assert!(!small_in(&doc, &placed(1, "thumbs-long-placed", Some(path(10, LIGHT_THUMB_ANCHORS + 1)))), "a placed file of a long path"); + assert!(!small_in(&doc, &placed(1, "thumbs-unread-placed", None)), "a placed file not read yet"); + } + + #[test] + fn a_symbol_that_holds_itself_is_not_small_and_is_counted_to_an_end() { + let doc = with_symbol("Loop", Node::group(NodeId(10), vec![Arc::new(rect(11)), Arc::new(instance(12, "Loop"))])); + assert!(!small_in(&doc, &instance(1, "Loop"))); + assert!(!small_node(&instance(1, "Loop"), usize::MAX, usize::MAX, true, Some(&doc)), "without limits on objects and anchors"); + // Nested as deep as counted, the art is small. + let mut doc = with_symbol("S0", rect(10)); + for i in 1..MAX_THUMB_NESTING { + let art = Node::group(NodeId(10 + i as u64), vec![Arc::new(instance(100 + i as u64, &format!("S{}", i - 1)))]); + doc.symbols.push(vectorcraft_doc::Symbol { name: format!("S{i}"), art: Arc::new(art), dynamic: false }); + } + assert!(small_in(&doc, &instance(1, &format!("S{}", MAX_THUMB_NESTING - 1))), "symbols as deep as counted"); + let art = Node::group(NodeId(9), vec![Arc::new(instance(99, &format!("S{}", MAX_THUMB_NESTING - 1)))]); + doc.symbols.push(vectorcraft_doc::Symbol { name: "Deeper".into(), art: Arc::new(art), dynamic: false }); + assert!(!small_in(&doc, &instance(1, "Deeper")), "one level deeper"); + } + #[test] fn the_painted_thumbnail_leaves_out_a_long_path() { let ctx = egui::Context::default(); @@ -435,6 +625,28 @@ mod tests { assert!(thumbs.pending.is_empty()); } + #[test] + fn an_instance_of_a_long_symbols_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 mut doc = with_symbol("Long", path(900, 50_000)); + let layer = doc.layers.first().map(|l| l.id); + let id = doc.alloc_id(); + doc.insert(layer, usize::MAX, instance(id.0, "Long")).unwrap(); + let doc = Arc::new(doc); + let n = node_arc(&doc, id).unwrap(); + 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 symbol"); + let here = render_thumb(&mut vectorcraft_render::Renderer::new(), &doc, &n, 24).expect("this thread draws the symbol"); + assert!(here.pixels.chunks(4).any(|p| p[3] > 0), "the thumbnail shows the symbol's art"); + assert_eq!((there.width, there.height, &there.pixels), (here.width, here.height, &here.pixels), "the same pixels"); + } + #[test] fn a_row_whose_object_becomes_a_long_path_is_drawn_again_on_the_worker() { let ctx = egui::Context::default(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 819478783..12fc7894b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1274,6 +1274,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 a row's thumbnail on its thumbnail thread when the row's paths, and the paths of its opacity masks that are not disabled, hold more than 2,000 anchors in all. It already did this for the first thumbnail of a row of more than 200 objects. Until its first thumbnail is ready, the row shows boxes at the bounds of up to 41 of its paths, each in its path's solid fill or stroke color or in gray, when its own paths, without its opacity masks, hold at most 2,000 anchors in all, and an empty white box otherwise. 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). + The same 2,000-anchor limit counts each glyph of type as 4 anchors, each point of a gradient mesh as 1 and every 100 pixels of an image as 1, and a symbol instance or a placed document counts the objects and anchors of the art it shows. A placed document whose file has not been read yet, or a symbol nested more than 8 deep (one that holds an instance of itself, for example), gets its first thumbnail on the thumbnail thread. The first frame after the panel opens takes 2.5 ms instead of 23.4 ms with 5,000 glyphs of point type, 2.1 ms instead of 346 ms with one instance of a symbol of a 200,000-anchor path, 1.7 ms instead of 11.7 ms with a 100 × 100 mesh, and 2.3 ms instead of 273 ms with a placed document of a 200,000-anchor path (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). From 5baf83f1dea316e37310ff835e4d08eb9dd93cc9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 18:44:39 -0800 Subject: [PATCH 2/2] Layers: counting a row's art for its first thumbnail stops after 20,000 steps A row drawn before was counted with no limit on its objects, so symbols that each hold many instances of the next made the count grow with the product of the instances, and the Layers panel could stall on such a file. - Counting stops after 20,000 steps: each object, subpath, text run and symbol looked at is one step, and every 64 bytes of a symbol or placed file name looked up is one more. A row that needs more gets its thumbnail on the thumbnail thread, and the painted thumbnail draws nothing for it. - A row drawn before counts at most 20,000 objects. --- crates/ui-egui/src/panels/layers/thumbs.rs | 146 ++++++++++++++++++--- docs/target-app-parity.md | 2 +- 2 files changed, 126 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index f86efc034..db303a11c 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -95,6 +95,12 @@ const IMAGE_PIXELS_PER_ANCHOR: usize = 100; /// worker. const MAX_THUMB_NESTING: usize = 8; +/// The most steps counting a node takes: each object, subpath, text run and symbol looked at is +/// one step, and so is every 64 bytes of a symbol or placed file name looked up. A node that +/// needs more has its thumbnail made on the worker, and the painted thumbnail draws nothing for +/// it. A row drawn before counts at most this many objects too. +const THUMB_COUNT_STEPS: usize = 20_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; @@ -113,12 +119,19 @@ pub(crate) fn thumb_options() -> vectorcraft_render::RenderOptions { /// glyphs, mesh points and image pixels count as anchors too ([`LIGHT_THUMB_ANCHORS`]), and a /// symbol instance and a placed document count the objects and anchors of the art they show: the /// symbol's art in `doc`, and the placed file's art when it has been read (a placed document whose -/// file has not been read, or art nested deeper than [`MAX_THUMB_NESTING`], is not small). +/// file has not been read, or art nested deeper than [`MAX_THUMB_NESTING`], is not small). A node +/// that takes more than [`THUMB_COUNT_STEPS`] steps to count is not small. fn small_node(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<&Document>) -> bool { + weigh(n, nodes, anchors, masks, doc).0 +} + +/// [`small_node`], and the steps counting took. +fn weigh(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<&Document>) -> (bool, usize) { /// What may still be counted. struct Left { nodes: usize, anchors: usize, + steps: usize, masks: bool, } impl Left { @@ -132,6 +145,20 @@ fn small_node(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<& None => false, } } + /// Take `steps` more: false past the limit. + fn step(&mut self, steps: usize) -> bool { + match self.steps.checked_sub(steps) { + Some(left) => { + self.steps = left; + true + } + None => false, + } + } + /// Look up `name`: false past the step limit. + fn look_up(&mut self, name: &str) -> bool { + self.step(1 + name.len() / 64) + } /// Count one more object: false past the limit. fn object(&mut self) -> bool { match self.nodes.checked_sub(1) { @@ -142,14 +169,9 @@ fn small_node(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<& None => false, } } - } - fn own_anchors(n: &Node) -> usize { - match &n.kind { - NodeKind::Text(t) => match &t.kind { - TextKind::Area { frame: p } | TextKind::OnPath { path: p, .. } => p.anchor_count(), - TextKind::Point => 0, - }, - _ => n.path_data().map_or(0, PathData::anchor_count), + /// Count the anchors of `p`, a step for each subpath. + fn path(&mut self, p: &PathData) -> bool { + p.subpaths.iter().all(|s| self.step(1) && self.take(s.anchors.len())) } } /// Count `art`, drawn in place of an object (`doc` its document), one level deeper. @@ -158,11 +180,26 @@ fn small_node(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<& } /// Count symbol `name` of `doc` (one that doesn't exist draws nothing). fn symbol(name: &str, doc: &Document, left: &mut Left, depth: usize) -> bool { - doc.symbols.iter().find(|s| s.name == name).is_none_or(|s| inside(&s.art, doc, left, depth)) + for s in &doc.symbols { + if !left.look_up(name) { + return false; + } + if s.name == name { + return inside(&s.art, doc, left, depth); + } + } + true } /// Count what `n` draws besides its children. fn own(n: &Node, doc: Option<&Document>, left: &mut Left, depth: usize) -> bool { - if !left.take(own_anchors(n)) { + let path = match &n.kind { + NodeKind::Text(t) => match &t.kind { + TextKind::Area { frame: p } | TextKind::OnPath { path: p, .. } => Some(p), + TextKind::Point => None, + }, + _ => n.path_data(), + }; + if !path.is_none_or(|p| left.path(p)) { return false; } let Some(doc) = doc else { return true }; @@ -170,23 +207,38 @@ fn small_node(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<& // Glyphs are counted only up to one past what the anchors left allow. NodeKind::Text(t) => t.runs.iter().all(|r| { let glyphs = r.text.chars().take(left.anchors / GLYPH_ANCHORS + 1).count(); - left.take(glyphs.saturating_mul(GLYPH_ANCHORS)) && r.inline.as_ref().is_none_or(|a| symbol(&a.symbol, doc, left, depth)) + left.step(1) + && left.take(glyphs.saturating_mul(GLYPH_ANCHORS)) + && r.inline.as_ref().is_none_or(|a| symbol(&a.symbol, doc, left, depth)) }), NodeKind::SymbolInstance { symbol: name, .. } => symbol(name, doc, left, depth), NodeKind::Mesh(m) => left.take(m.points.len().saturating_mul(MESH_POINT_ANCHORS)), NodeKind::Image(im) => left.take((im.width as usize).saturating_mul(im.height as usize) / IMAGE_PIXELS_PER_ANCHOR), - NodeKind::PlacedDocument(p) => vectorcraft_doc::placed_document::read_cached(p).is_some_and(|e| inside(&e.art, &e.doc, left, depth)), + NodeKind::PlacedDocument(p) => { + left.look_up(&p.key) && vectorcraft_doc::placed_document::read_cached(p).is_some_and(|e| inside(&e.art, &e.doc, left, depth)) + } _ => true, } } fn count(n: &Node, doc: Option<&Document>, left: &mut Left, depth: usize) -> bool { - if !own(n, doc, left, depth) { + if !(left.step(1) && own(n, doc, left, depth)) { return false; } let mask = n.extras().mask.as_deref().filter(|m| left.masks && !m.disabled).map(|m| &m.art); n.children().into_iter().flatten().chain(mask).all(|c| left.object() && count(c, doc, left, depth)) } - count(n, doc, &mut Left { nodes, anchors, masks }, 0) + let mut left = Left { nodes, anchors, steps: THUMB_COUNT_STEPS, masks }; + let small = count(n, doc, &mut left, 0); + (small, THUMB_COUNT_STEPS - left.steps) +} + +/// Is row `n` (an object of `doc`) light enough to make its thumbnail on the UI thread? `quick`: +/// whether its last thumbnail was quick to render, if it has one. +fn light_row(n: &Node, quick: Option, doc: &Document) -> bool { + match quick { + Some(quick) => quick && small_node(n, THUMB_COUNT_STEPS, LIGHT_THUMB_ANCHORS, true, Some(doc)), + None => small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS, true, Some(doc)), + } } /// The thumbnail of `n` (`px` pixels square) as `r` renders it, on the UI thread or the worker. @@ -275,11 +327,7 @@ impl Thumbs { let in_budget = self.spent.1 < THUMB_BUDGET_MS && self.spent.2 < THUMB_BUDGET_COUNT; // A row drawn before is light when its last thumbnail was quick to render, unless its // object now has more anchors than the limit. - let doc = Some(src.doc.as_ref()); - let light = match light { - Some(quick) => quick && small_node(n, usize::MAX, LIGHT_THUMB_ANCHORS, true, doc), - None => small_node(n, LIGHT_THUMB_NODES, LIGHT_THUMB_ANCHORS, true, doc), - } && in_budget; + let light = light_row(n, light, src.doc) && 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); @@ -327,7 +375,8 @@ 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). A node with -/// more than [`LIGHT_THUMB_ANCHORS`] anchors paints nothing, since this runs on every frame and +/// more than [`LIGHT_THUMB_ANCHORS`] anchors, or that takes more than [`THUMB_COUNT_STEPS`] steps +/// to count, paints nothing, since this runs on every frame and /// measuring its bounds takes time in proportion to its anchor count. pub(super) fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { if !small_node(n, usize::MAX, LIGHT_THUMB_ANCHORS, false, None) { @@ -563,6 +612,61 @@ mod tests { assert!(!small_in(&doc, &instance(1, "Deeper")), "one level deeper"); } + /// A document of symbols `L0` to `L{levels}`: `L0` is an empty group, and each one above it a + /// group of `fan` instances of the one below. + fn fan_out(levels: usize, fan: usize) -> Document { + let mut doc = with_symbol("L0", Node::group(NodeId(10), vec![])); + let mut id = 100; + for level in 1..=levels { + let below = format!("L{}", level - 1); + let kids = (0..fan) + .map(|_| { + id += 1; + Arc::new(instance(id, &below)) + }) + .collect(); + id += 1; + doc.symbols.push(vectorcraft_doc::Symbol { name: format!("L{level}"), art: Arc::new(Node::group(NodeId(id), kids)), dynamic: false }); + } + doc + } + + #[test] + fn symbols_that_each_hold_many_instances_are_counted_within_the_step_limit() { + // 40 instances on each of 4 levels: 40⁴ (2,560,000) empty groups, and no anchors. + let doc = fan_out(4, 40); + let top = instance(1, "L4"); + assert!(!light_row(&top, Some(true), &doc), "a row drawn before, quick to render"); + assert!(!light_row(&top, None, &doc), "a row not drawn yet"); + let (small, steps) = weigh(&top, THUMB_COUNT_STEPS, usize::MAX, true, Some(&doc)); + assert!(!small && steps <= THUMB_COUNT_STEPS, "{steps} steps"); + let (small, steps) = weigh(&top, usize::MAX, usize::MAX, true, Some(&doc)); + assert!(!small && steps <= THUMB_COUNT_STEPS, "{steps} steps without a limit on objects"); + // 5 instances on each of 3 levels stay light once drawn quickly. + let doc = fan_out(3, 5); + assert!(light_row(&instance(1, "L3"), Some(true), &doc)); + assert!(!light_row(&instance(1, "L3"), Some(false), &doc), "drawn slowly"); + } + + #[test] + fn empty_subpaths_text_runs_and_symbol_lookups_take_steps() { + let doc = Document::new(200.0, 200.0); + let style = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + let empty = Node::path(NodeId(1), PathData::new(vec![SubPath::default(); THUMB_COUNT_STEPS]), style.clone()); + assert!(!small_in(&doc, &empty), "a path of many empty subpaths"); + let fewer = Node::path(NodeId(1), PathData::new(vec![SubPath::default(); 100]), style); + assert!(small_in(&doc, &fewer), "a path of a few"); + let mut t = TextObject::point(Point::ZERO, "", CharStyle::default()); + t.runs = vec![vectorcraft_doc::TextRun::new("", CharStyle::default()); THUMB_COUNT_STEPS]; + assert!(!small_in(&doc, &Node::new(NodeId(2), NodeKind::Text(Box::new(t)))), "type of many empty runs"); + let mut doc = with_symbol("S", rect(10)); + for i in 0..THUMB_COUNT_STEPS { + doc.symbols.push(vectorcraft_doc::Symbol { name: format!("Other {i}"), art: Arc::new(rect(11)), dynamic: false }); + } + assert!(small_in(&doc, &instance(1, "S")), "the first of many symbols"); + assert!(!small_in(&doc, &instance(1, &format!("Other {}", THUMB_COUNT_STEPS - 1))), "the last of many symbols"); + } + #[test] fn the_painted_thumbnail_leaves_out_a_long_path() { let ctx = egui::Context::default(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 12fc7894b..ae219584b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1274,7 +1274,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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 a row's thumbnail on its thumbnail thread when the row's paths, and the paths of its opacity masks that are not disabled, hold more than 2,000 anchors in all. It already did this for the first thumbnail of a row of more than 200 objects. Until its first thumbnail is ready, the row shows boxes at the bounds of up to 41 of its paths, each in its path's solid fill or stroke color or in gray, when its own paths, without its opacity masks, hold at most 2,000 anchors in all, and an empty white box otherwise. 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). - The same 2,000-anchor limit counts each glyph of type as 4 anchors, each point of a gradient mesh as 1 and every 100 pixels of an image as 1, and a symbol instance or a placed document counts the objects and anchors of the art it shows. A placed document whose file has not been read yet, or a symbol nested more than 8 deep (one that holds an instance of itself, for example), gets its first thumbnail on the thumbnail thread. The first frame after the panel opens takes 2.5 ms instead of 23.4 ms with 5,000 glyphs of point type, 2.1 ms instead of 346 ms with one instance of a symbol of a 200,000-anchor path, 1.7 ms instead of 11.7 ms with a 100 × 100 mesh, and 2.3 ms instead of 273 ms with a placed document of a 200,000-anchor path (release build, median of three runs). + The same 2,000-anchor limit counts each glyph of type as 4 anchors, each point of a gradient mesh as 1 and every 100 pixels of an image as 1, and a symbol instance or a placed document counts the objects and anchors of the art it shows. A placed document whose file has not been read yet, or a symbol nested more than 8 deep (one that holds an instance of itself, for example), gets its first thumbnail on the thumbnail thread, and so does a row whose art takes more than 20,000 steps to count (each object, subpath, text run and symbol looked at is one step). The first frame after the panel opens takes 2.4 ms instead of 19.8 ms with 5,000 glyphs of point type, 1.8 ms instead of 343 ms with one instance of a symbol of a 200,000-anchor path, 2.0 ms instead of 11.0 ms with a 100 × 100 mesh, and 2.1 ms instead of 274 ms with a placed document of a 200,000-anchor path (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).