From 1c4ec81ec22070c613c28a3890ab62b8bb0d5842 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 26 Oct 2026 06:16:58 -0700 Subject: [PATCH] Placed documents: a ready bitmap redraws only its objects, and bitmaps on screen are not made again On screen, placed documents (PDF, .ai and .vectorcraft) draw from bitmaps made in the background. Each bitmap that became ready changed a generation in the canvas key, so the canvas rendered the whole frame again. The bitmaps share a 512 MiB budget, and making one dropped the least recently drawn others, even those on screen: with more than that on screen, every frame dropped a bitmap it drew and asked for it again, without end. A bitmap that could not be made (the file can't be read and there is no preview) also changed the generation and was asked for again in every frame. When a bitmap is ready, placed_document::made_between names its file, and the canvas renders again only where the objects showing it are (placed_area), as it does after an edit, when the frame shown was rendered on the UI thread and that part is at most half the frame. Art of a symbol, a graph design, a pattern or a brush that shows the file still renders the whole frame. Bitmaps drawn or made since a frame began are not dropped to make room for the ones it asks for. They stay past the budget up to 2 GiB. A bitmap that would take them past 2 GiB is not kept, and its file draws exactly at that zoom. A bitmap that can't be made shows the placeholder, does not change the generation, and is not asked for again. Docs: docs/target-app-parity.md. --- crates/render/src/changed.rs | 64 +++++++- crates/render/src/lib.rs | 7 +- crates/render/src/placed_document.rs | 163 ++++++++++++++++++--- crates/render/src/tests_placed_document.rs | 151 ++++++++++++++++++- crates/ui-egui/src/canvas.rs | 83 ++++++++++- docs/target-app-parity.md | 1 + 6 files changed, 432 insertions(+), 37 deletions(-) diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs index 8af4c07d9..8516d5b0b 100644 --- a/crates/render/src/changed.rs +++ b/crates/render/src/changed.rs @@ -1,5 +1,7 @@ -//! What draws differently after an edit, so a view can draw again only there. +//! What draws differently after an edit, or once placed documents' bitmaps are ready, so a view +//! can draw again only there. +use std::collections::HashSet; use std::sync::Arc; use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes}; @@ -14,6 +16,66 @@ pub enum Changed { Everything, } +impl Changed { + /// What may draw differently after this change and `other`: everything when either is, + /// else both areas. + pub fn and(self, other: Changed) -> Changed { + match (self, other) { + (Changed::Everything, _) | (_, Changed::Everything) => Changed::Everything, + (Changed::Area(a), Changed::Area(b)) => Changed::Area(a.union(b)), + (Changed::Area(a), Changed::Nothing) | (Changed::Nothing, Changed::Area(a)) => Changed::Area(a), + (Changed::Nothing, Changed::Nothing) => Changed::Nothing, + } + } +} + +/// What draws differently in `doc` once the bitmaps of the placed documents' `files` are ready +/// ([`crate::placed_document::made_between`]): the bounds of everything the objects that show +/// them paint, measured as [`changed`] measures an object that changed. Everything when the art +/// of a symbol, a graph design, a pattern or a brush shows one of them (it draws wherever it is +/// used), or while a pattern is edited. +pub fn placed_area(doc: &Document, files: &HashSet) -> Changed { + if files.is_empty() { + return Changed::Nothing; + } + let mut elsewhere = + doc.symbols.iter().map(|s| &s.art).chain(doc.graph_designs.iter().map(|g| &g.art)).chain(doc.patterns.iter().flat_map(|p| &p.art)); + if doc.pattern_edit.is_some() + || elsewhere.any(|n| shows(n, files)) + || vectorcraft_brush::library(doc).iter().any(|b| b.art().into_iter().any(|n| shows(n, files))) + { + return Changed::Everything; + } + let symbols = doc.symbol_boxes(); + let mut area = None; + for layer in &doc.layers { + add_placed(&symbols, layer, files, &mut area); + } + area.map_or(Changed::Nothing, Changed::Area) +} + +/// Does `n`, a member of it or the art of an opacity mask in it show one of `files`? +fn shows(n: &Node, files: &HashSet) -> bool { + matches!(&n.kind, NodeKind::PlacedDocument(p) if files.contains(&p.key)) + || n.extras().mask.as_ref().is_some_and(|m| shows(&m.art, files)) + || n.children().is_some_and(|c| c.iter().any(|c| shows(c, files))) +} + +/// Add to `area` what `n` paints where it shows one of `files`: each member of a group or layer +/// whose members draw each on their own (unless its opacity mask shows one), and any other object +/// whole. +fn add_placed(symbols: &SymbolBoxes, n: &Node, files: &HashSet, area: &mut Option) { + match members(n) { + Some(children) if !n.extras().mask.as_ref().is_some_and(|m| shows(&m.art, files)) => { + for c in children { + add_placed(symbols, c, files, area); + } + } + _ if shows(n, files) => *area = vectorcraft_geom::union_opt(*area, crate::brush_fx::cull_bounds(symbols, n)), + _ => {} + } +} + /// What may draw differently in `new` than in `old`, the version before an edit: the bounds of /// everything the objects that changed paint, before and after the edit ([`crate::painted_bounds`]), /// or everything when more than the art in the layers changed, or while a pattern is edited (its diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a55ba04de..83b0f74c4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -37,7 +37,7 @@ use ink::Ink; use lru::FrameCache; pub use brush_fx::instance_art; -pub use changed::{Changed, changed, view_pixels}; +pub use changed::{Changed, changed, placed_area, view_pixels}; pub use effects::stroke::width_outline; pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, newly_undecodable, set_image_cache_budget}; pub use live::expand_live; @@ -543,6 +543,9 @@ pub struct Renderer { fx_arts: FrameCache, /// Symbol art as instances and inline graphics draw it (see [`Self::placed_art`]). placed_arts: FrameCache, + /// The bitmap clock of [`placed_document`] when this frame first drew a placed document from + /// a bitmap: the bitmaps drawn since then are not dropped to make room for those it asks for. + placed_frame: Option, /// Layers open around the drawing point in the current context: a non-isolated group can copy /// its backdrop only when there are none (see [`Self::group`]). nested: u32, @@ -692,6 +695,7 @@ impl Renderer { shape_of: 0, fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256), placed_arts: FrameCache::new(lru::PLACED_ART_BUDGET, 256), + placed_frame: None, nested: 0, backdrop: None, blends: PtrMap::default(), @@ -792,6 +796,7 @@ impl Renderer { (self.offscreen_live, self.offscreen_budget) = (0, 0); self.symbol_nodes = 0; self.twice_drawn = 0; + self.placed_frame = None; } /// End a frame: drop cache entries not used for a few frames (every render, thumbnails too, diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index c26336de1..684025330 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -8,9 +8,12 @@ //! it shows is cached as a bitmap per file and zoom step (a power of two, in device pixels per //! point), shared by every object showing that file. A bitmap not made yet is made on a //! background worker while the frame shows another step's bitmap, or a light placeholder; -//! [`generation`] changes when one is ready and [`busy`] says whether any is coming, so the screen -//! can draw again. Past [`MAX_SIDE`] pixels, and in outline, proofing and ink frames, it is drawn -//! exactly. +//! [`generation`] changes when one is ready, [`made_between`] says for which files, and [`busy`] +//! says whether any is coming, so the screen can draw again where those files show +//! ([`crate::placed_area`]). Bitmaps drawn since a frame began are not dropped to make room for +//! the ones it asks for, up to [`MAX_BYTES`], and a bitmap that can't be made is not asked for +//! again. Past [`MAX_SIDE`] pixels, past [`MAX_BYTES`], and in outline, proofing and ink frames, +//! it is drawn exactly. //! //! A document opened with only its placed documents' previews ([`ImageBlob::is_proxy`]) shows them //! up to their resolution; past it the file is read again in the background @@ -19,7 +22,7 @@ //! same way, up to [`MAX_DEPTH`] deep; deeper ones show their previews. use std::cell::Cell; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError}; @@ -33,8 +36,12 @@ use crate::{Frame, Renderer}; /// The longest side of a cached bitmap (pixels); larger steps draw exactly. pub const MAX_SIDE: f64 = 4096.0; -/// The most bytes of bitmaps kept. +/// The bytes of bitmaps kept, past which those not drawn since the frame that asks for another +/// one began are dropped. const BUDGET: usize = 512 << 20; +/// The most bytes of bitmaps kept, those drawn in the frame included: a bitmap that would take +/// them past it is not kept, and its file draws exactly at that zoom step. +const MAX_BYTES: usize = 4 * BUDGET; /// The smallest zoom step (device pixels per point). const MIN_STEP: f64 = 1.0 / 64.0; @@ -57,7 +64,19 @@ struct Bitmaps { static BITMAPS: LazyLock> = LazyLock::new(Default::default); /// Bitmaps being made. static PENDING: LazyLock>> = LazyLock::new(Default::default); +/// Bitmaps that could not be made (neither the file nor a preview could be read): not asked for +/// again. +static FAILED: LazyLock>> = LazyLock::new(Default::default); +/// Bitmaps made but not kept, past [`MAX_BYTES`]: their files draw exactly at those zoom steps. +static UNKEPT: LazyLock>> = LazyLock::new(Default::default); static GENERATION: AtomicU64 = AtomicU64::new(0); +/// The files of the last [`MADE_KEPT`] bitmaps made, each with the [`generation`] it gave. +static MADE: LazyLock>> = LazyLock::new(Default::default); +/// How many bitmaps [`MADE`] keeps. +const MADE_KEPT: usize = 256; +/// How many bitmaps of each file were asked for (tests). +#[cfg(test)] +static SCHEDULED: LazyLock>> = LazyLock::new(Default::default); fn lock(m: &Mutex) -> MutexGuard<'_, T> { // The caches stay whole if a holder panicked (entries are only inserted and removed). @@ -65,11 +84,35 @@ fn lock(m: &Mutex) -> MutexGuard<'_, T> { } /// Changes whenever a bitmap made in the background is ready: a screen showing placed documents -/// draws again when it does. +/// draws again when it does, where [`made_between`] says. pub fn generation() -> u64 { GENERATION.load(Ordering::Relaxed) } +/// The files whose bitmaps were made after [`generation`] `from`, up to `to`: a screen drawn at +/// `from` draws again where they show ([`crate::placed_area`]). None when they are no longer +/// known (more than [`MADE_KEPT`] bitmaps were made since). +pub fn made_between(from: u64, to: u64) -> Option> { + let made = lock(&MADE); + let oldest = made.front().map_or(u64::MAX, |(g, _)| *g); + if to > from && oldest > from.saturating_add(1) { + return None; + } + Some(made.iter().filter(|(g, _)| *g > from && *g <= to).map(|(_, key)| key.clone()).collect()) +} + +/// A bitmap of file `key` is ready: the generation changes, and [`MADE`] records which file +/// changed it. Both happen under [`MADE`]'s lock, so a generation read is in [`MADE`] by the time +/// [`made_between`] reads it. +fn made(key: &str) { + let mut made = lock(&MADE); + let g = GENERATION.fetch_add(1, Ordering::Relaxed).wrapping_add(1); + made.push_back((g, key.to_owned())); + while made.len() > MADE_KEPT { + made.pop_front(); + } +} + /// Are bitmaps being made in the background? pub fn busy() -> bool { !lock(&PENDING).is_empty() @@ -81,6 +124,18 @@ pub(crate) fn pending_for(key: &str) -> bool { lock(&PENDING).iter().any(|k| k.0 == key) } +/// How many bitmaps of file `key` were asked for. +#[cfg(test)] +pub(crate) fn scheduled(key: &str) -> usize { + lock(&SCHEDULED).get(key).copied().unwrap_or(0) +} + +/// Mark the bitmap of file `key` at zoom `step` as not kept past [`MAX_BYTES`]. +#[cfg(test)] +pub(crate) fn mark_unkept(key: &str, step: f64) { + lock(&UNKEPT).insert((key.to_string(), step.to_bits())); +} + /// The zoom step for `scale` device pixels per point: the power of two at or above it. pub(crate) fn step(scale: f64) -> Option { (scale.is_finite() && scale > 0.0).then(|| 2f64.powf(scale.log2().ceil()).max(MIN_STEP)) @@ -104,19 +159,33 @@ impl Bitmaps { .map(|(_, b)| (b.pm.clone(), b.step)) } - fn insert(&mut self, k: BitmapKey, pm: Pixmap, step: f64) { + /// Keep `pm` as bitmap `k`, and past `budget` bytes drop the bitmaps drawn least recently, + /// but not those drawn or made since clock `keep` (when the frame that asked for `k` began), + /// which stay even past it. When the bitmaps would still be past `max` bytes, `k` is not kept: + /// false. + fn insert(&mut self, k: BitmapKey, pm: Pixmap, step: f64, keep: u64, (budget, max): (usize, usize)) -> bool { self.clock += 1; let size = pm.width() as usize * pm.height() as usize * 4; self.bytes += size; - if let Some(old) = self.map.insert(k, Bitmap { pm: Arc::new(pm), step, used: self.clock }) { + if let Some(old) = self.map.insert(k.clone(), Bitmap { pm: Arc::new(pm), step, used: self.clock }) { self.bytes = self.bytes.saturating_sub(old.pm.width() as usize * old.pm.height() as usize * 4); } - while self.bytes > BUDGET && self.map.len() > 1 { - let Some(k) = self.map.iter().min_by_key(|(_, b)| b.used).map(|(k, _)| k.clone()) else { break }; + while self.bytes > budget { + let Some(k) = self.map.iter().filter(|(key, b)| b.used <= keep && **key != k).min_by_key(|(_, b)| b.used).map(|(key, _)| key.clone()) + else { + break; + }; if let Some(b) = self.map.remove(&k) { self.bytes = self.bytes.saturating_sub(b.pm.width() as usize * b.pm.height() as usize * 4); } } + if self.bytes <= max { + return true; + } + if self.map.remove(&k).is_some() { + self.bytes = self.bytes.saturating_sub(size); + } + false } } @@ -137,6 +206,8 @@ struct Job { object: PlacedDocument, blob: ImageBlob, step: f64, + /// The bitmap clock when the frame that asked for it began ([`Bitmaps::insert`]). + keep: u64, /// The scheduler's file-access scope, which the worker runs the job in. scope: Option, } @@ -204,15 +275,24 @@ fn pixels(v: f64) -> u16 { v.round().clamp(1.0, MAX_SIDE) as u16 } -/// Make `job`'s bitmap and keep it. +/// Make `job`'s bitmap and keep it (or remember that it was not kept), or remember that it can't +/// be made. The generation changes before the job stops being pending, so a screen that sees +/// nothing pending sees the change. fn run(r: &mut Renderer, mut job: Job) { let scope = job.scope.take(); let mut body = || { - if let Some(pm) = make(r, &job.object, &job.blob, job.step) { - lock(&BITMAPS).insert(job.key.clone(), pm, job.step); + match make(r, &job.object, &job.blob, job.step) { + Some(pm) => { + if !lock(&BITMAPS).insert(job.key.clone(), pm, job.step, job.keep, (BUDGET, MAX_BYTES)) { + lock(&UNKEPT).insert(job.key.clone()); + } + made(&job.key.0); + } + None => { + lock(&FAILED).insert(job.key.clone()); + } } lock(&PENDING).remove(&job.key); - GENERATION.fetch_add(1, Ordering::Relaxed); }; match scope { Some(scoped) => scoped(&mut body), @@ -241,9 +321,10 @@ mod workers { loop { let job = super::lock(&rx).recv(); let Ok(job) = job else { break }; - // A panic loses this bitmap, not the worker. + // A panic loses this bitmap, not the worker; it is not asked for again. let key = job.key.clone(); if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| super::run(&mut r, *job))).is_err() { + super::lock(&super::FAILED).insert(key.clone()); super::lock(&super::PENDING).remove(&key); } } @@ -267,6 +348,10 @@ fn schedule(job: Job) { if !lock(&PENDING).insert(job.key.clone()) { return; } + #[cfg(test)] + { + *lock(&SCHEDULED).entry(job.key.0.clone()).or_default() += 1; + } #[cfg(not(target_arch = "wasm32"))] let job = match workers::submit(Box::new(job)) { Ok(()) => return, @@ -318,8 +403,9 @@ impl Renderer { self.draw_arc(ctx, &inner, &e.art); } - /// Draw `p` from its bitmap at this zoom step, or (while that is made) another step's, or a - /// placeholder. False when bitmaps don't suit (too large). + /// Draw `p` from its bitmap at this zoom step, or (while that is made, or when it can't be) + /// another step's, or a placeholder. False when bitmaps don't suit (too large, or not kept + /// past [`MAX_BYTES`]). fn draw_placed_bitmap(&mut self, ctx: &mut RenderContext, f: &Frame, p: &PlacedDocument) -> bool { let m = f.view * p.xf; let Some(step) = step(mean_scale(m)) else { return false }; @@ -328,16 +414,24 @@ impl Renderer { } let Some(blob) = f.doc.images.get(&p.key).cloned() else { return true }; let key = (p.key.clone(), step.to_bits()); - let (found, nearest) = { + // Not kept past [`MAX_BYTES`]: drawn exactly. + if lock(&UNKEPT).contains(&key) { + return false; + } + let (found, nearest, keep) = { let mut b = lock(&BITMAPS); + // The clock when this frame first drew a placed document's bitmap. + let keep = *self.placed_frame.get_or_insert(b.clock); let found = b.get(&key); let nearest = if found.is_none() { b.nearest(&p.key, step) } else { None }; - (found, nearest) + (found, nearest, keep) }; let shown = match found { Some(pm) => Some(pm), + // It can't be made: another step's, or the placeholder. + None if lock(&FAILED).contains(&key) => nearest.map(|n| n.0), None => { - schedule(Job { key, object: p.clone(), blob, step, scope: SCOPE_CAPTURE.get().map(|capture| capture()) }); + schedule(Job { key, object: p.clone(), blob, step, keep, scope: SCOPE_CAPTURE.get().map(|capture| capture()) }); // Made here and now (no threads) or meanwhile another step's. let mut b = lock(&BITMAPS); b.get(&(p.key.clone(), step.to_bits())).or(nearest.map(|n| n.0)) @@ -354,7 +448,7 @@ impl Renderer { ctx.reset_paint_transform(); } None => { - // Coming: a light box where it will be. + // Coming, or it can't be made: a light box where it shows. ctx.set_transform(m); ctx.set_paint(peniko::Color::from_rgba8(128, 128, 128, 40)); ctx.fill_rect(&kurbo::Rect::new(rect.x0, rect.y0, rect.x1, rect.y1)); @@ -378,4 +472,31 @@ mod tests { assert_eq!(step(f64::NAN), None); assert_eq!(step(0.0), None); } + + /// Past the budget, the bitmaps drawn least recently are dropped, but not those drawn since + /// the frame that asked for the new one began; past the most, the new one is not kept. + #[test] + fn bitmaps_drawn_in_the_frame_stay_up_to_the_most_kept() { + let mut b = Bitmaps::default(); + // 400 bytes each: a budget of 1,000 bytes and at most 2,000. + let limits = (1000, 2000); + let key = |name: &str| (name.to_string(), 1f64.to_bits()); + for name in ["a", "b", "c"] { + assert!(b.insert(key(name), Pixmap::new(10, 10), 1.0, 0, limits)); + } + assert_eq!(b.bytes, 1200, "all made in the frame: kept past the budget"); + // The next frame begins and draws "a", then asks for "d". + let frame = b.clock; + assert!(b.get(&key("a")).is_some()); + assert!(b.insert(key("d"), Pixmap::new(10, 10), 1.0, frame, limits)); + let mut kept: Vec<&str> = b.map.keys().map(|(name, _)| name.as_str()).collect(); + kept.sort_unstable(); + assert_eq!((kept, b.bytes), (vec!["a", "d"], 800), "b and c, not drawn since, are dropped"); + for name in ["e", "f", "g"] { + assert!(b.insert(key(name), Pixmap::new(10, 10), 1.0, frame, limits), "{name}"); + } + assert_eq!(b.bytes, 2000); + assert!(!b.insert(key("h"), Pixmap::new(10, 10), 1.0, frame, limits), "past the most: not kept"); + assert!(!b.map.contains_key(&key("h")) && b.bytes == 2000); + } } diff --git a/crates/render/src/tests_placed_document.rs b/crates/render/src/tests_placed_document.rs index cc1af75ce..add7e3998 100644 --- a/crates/render/src/tests_placed_document.rs +++ b/crates/render/src/tests_placed_document.rs @@ -8,8 +8,11 @@ use vectorcraft_geom::shapes; use super::*; /// Every file reads as a 40×20 artboard filled blue; a file whose bytes start with `loop` also -/// places itself (the same key) in its middle. +/// places itself (the same key) in its middle, and one whose bytes start with `bad` can't be read. fn blue_page(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { + if bytes.starts_with(b"bad") { + return None; + } let mut d = Document::new(40.0, 20.0); d.layers.clear(); let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0); @@ -27,19 +30,32 @@ fn doc_with(key: &str, bytes: &[u8]) -> Document { vectorcraft_doc::placed_document::set_loader(blue_page); let mut d = Document::new(100.0, 100.0); d.images.insert(key.into(), ImageBlob::new("application/json", bytes.to_vec())); + let l = d.layers[0].id; + d.insert(Some(l), 0, placed_node(50, key, 40.0, 20.0, Affine::translate((10.0, 10.0)) * Affine::scale(2.0))).unwrap(); + d +} + +/// Object `id` showing file `key` in a `width`×`height` box placed by `xf`. +fn placed_node(id: u64, key: &str, width: f64, height: f64, xf: Affine) -> Node { let p = PlacedDocument { link: LinkInfo::new("/art/card.vectorcraft"), key: key.into(), bounding: false, page_box: Default::default(), - width: 40.0, - height: 20.0, - xf: Affine::translate((10.0, 10.0)) * Affine::scale(2.0), + width, + height, + xf, placement: Default::default(), }; - let l = d.layers[0].id; - d.insert(Some(l), 0, Node::new(NodeId(50), NodeKind::PlacedDocument(Box::new(p)))).unwrap(); - d + Node::new(NodeId(id), NodeKind::PlacedDocument(Box::new(p))) +} + +/// Held by the tests that draw placed documents from bitmaps, which share the bitmaps, their +/// generation and the jobs being made: one test's bitmaps don't push out another's, and +/// [`settle`] waits for its own. +fn serial() -> std::sync::MutexGuard<'static, ()> { + static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } fn doc(key: &str) -> Document { @@ -117,6 +133,7 @@ fn a_placed_document_with_a_drop_shadow_shows_its_file_once_read() { #[test] fn on_screen_a_placed_document_shows_a_placeholder_then_its_bitmap() { + let _serial = serial(); let d = doc("progressive-1"); let generation = placed_document::generation(); let first = render(&d, true); @@ -135,6 +152,7 @@ fn on_screen_a_placed_document_shows_a_placeholder_then_its_bitmap() { #[test] fn copies_of_one_placed_document_share_its_bitmap() { + let _serial = serial(); let mut d = doc("shared-1"); let n = d.node(NodeId(50)).unwrap().clone(); let l = d.layers[0].id; @@ -150,8 +168,126 @@ fn copies_of_one_placed_document_share_its_bitmap() { assert_eq!(&r.pixel(50, 75)[..3], &[0, 0, 255]); } +/// Nine files on screen, each drawn from a 4096 × 4096 bitmap (576 MiB together, past the 512 MiB +/// the bitmaps keep): once made, the next frames draw all nine and ask for none again. +#[test] +fn nine_large_bitmaps_on_screen_are_not_made_again() { + let _serial = serial(); + vectorcraft_doc::placed_document::set_loader(blue_page); + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let keys: Vec = (0..9).map(|i| format!("nine-{i}")).collect(); + for (i, key) in keys.iter().enumerate() { + d.images.insert(key.clone(), ImageBlob::new("application/json", format!("{{\"doc\": \"{key}\"}}").into_bytes())); + // A 1 × 1 point box at 4096 pixels a point: a 4096-pixel bitmap. + d.insert(Some(l), i, placed_node(100 + i as u64, key, 1.0, 1.0, Affine::translate((i as f64 * 0.001, 0.0)))).unwrap(); + } + let (view, opts) = (Affine::scale(4096.0), RenderOptions { progressive_placed: true, ..Default::default() }); + let mut r = Renderer::new(); + r.render(&d, 32, 32, view, &opts); + settle(); + let asked = || keys.iter().map(|k| placed_document::scheduled(k)).collect::>(); + assert_eq!(asked(), vec![1; 9], "each asked for once"); + for frame in 1..=2 { + let shown = r.render(&d, 32, 32, view, &opts); + assert_eq!(asked(), vec![1; 9], "frame {frame} asks for none again"); + assert_eq!(&shown.pixel(16, 16)[..3], &[0, 0, 255], "frame {frame} draws the art"); + } +} + +/// A bitmap that was not kept (it would have taken the bitmaps past the most they may hold) is +/// not asked for again: its file draws exactly at that zoom step, at once. +#[test] +fn a_bitmap_not_kept_draws_exactly_without_being_asked_for() { + let _serial = serial(); + let d = doc("unkept-1"); + // The box is drawn at 2 pixels a point: zoom step 2. + placed_document::mark_unkept("unkept-1", 2.0); + let shown = render(&d, true); + assert_eq!(&shown.pixel(50, 30)[..3], &[0, 0, 255], "the art, drawn exactly"); + assert_eq!(placed_document::scheduled("unkept-1"), 0, "not asked for"); +} + +/// A file that can't be read, without a preview: its bitmap is asked for once, the screen is +/// not told to draw again, and later frames draw the placeholder without asking again. +#[test] +fn a_bitmap_that_cant_be_made_is_not_asked_for_again() { + let _serial = serial(); + let d = doc_with("unreadable-1", b"bad"); + let generation = placed_document::generation(); + render(&d, true); + settle(); + assert_eq!(placed_document::generation(), generation, "nothing new to draw"); + let later = render(&d, true); + assert_eq!(placed_document::scheduled("unreadable-1"), 1, "asked for once"); + let shown = &later.pixel(50, 30)[..3]; + assert!(shown != [255, 255, 255] && shown != [0, 0, 255], "the placeholder: {shown:?}"); +} + +/// Once bitmaps are ready, drawing again where [`placed_area`] says gives what drawing the whole +/// frame again gives. The area of a file covers every object showing it (a group's member and +/// an object with a drop shadow too) and no object showing another file; art of a brush or a +/// symbol showing it makes it everything. +#[test] +fn a_ready_bitmap_draws_again_where_its_file_shows() { + let _serial = serial(); + vectorcraft_doc::placed_document::set_loader(blue_page); + let mut d = Document::new(100.0, 100.0); + for key in ["area-a", "area-b"] { + d.images.insert(key.into(), ImageBlob::new("application/json", format!("{{\"doc\": \"{key}\"}}").into_bytes())); + } + let l = d.layers[0].id; + // 20 × 10 point boxes: A loose at (5, 5), with a shadow at (30, 40) and in a group at (5, 80); + // B at (75, 5). Only the shadow reaches past x = 50, and only the group's member past y = 50. + let at = |x: f64, y: f64| Affine::translate((x, y)) * Affine::scale(0.5); + d.insert(Some(l), 0, placed_node(60, "area-a", 40.0, 20.0, at(5.0, 5.0))).unwrap(); + let group = NodeKind::Group { children: vec![Arc::new(placed_node(61, "area-a", 40.0, 20.0, at(5.0, 80.0)))], clip: false }; + d.insert(Some(l), 1, Node::new(NodeId(62), group)).unwrap(); + let mut shadowed = placed_node(63, "area-a", 40.0, 20.0, at(30.0, 40.0)); + let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100}); + shadowed.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params, visible: true }); + d.insert(Some(l), 2, shadowed).unwrap(); + d.insert(Some(l), 3, placed_node(64, "area-b", 40.0, 20.0, at(75.0, 5.0))).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), progressive_placed: true, ..Default::default() }; + let mut r = Renderer::new(); + let generation = placed_document::generation(); + let before = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + settle(); + let made = placed_document::made_between(generation, placed_document::generation()).expect("known"); + let files = |keys: &[&str]| keys.iter().map(|k| k.to_string()).collect::>(); + assert_eq!(made, files(&["area-a", "area-b"]), "both files' bitmaps"); + let only_a = files(&["area-a"]); + let Changed::Area(a) = placed_area(&d, &only_a) else { panic!("an area") }; + for (x, y) in [(6.0, 6.0), (24.0, 14.0), (31.0, 41.0), (53.0, 53.0), (6.0, 81.0), (24.0, 89.0)] { + assert!(a.contains(vectorcraft_geom::Point::new(x, y)), "({x}, {y}) in {a:?}"); + } + assert!(a.x1 < 75.0, "not B: {a:?}"); + // Drawn again only there, it is what the whole frame drawn again shows. + let Changed::Area(area) = placed_area(&d, &made) else { panic!("an area") }; + let after = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); + let [x0, y0, x1, y1] = view_pixels(area, Affine::IDENTITY, 100, 100).expect("on screen"); + let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))), &opts); + let mut patched = before.pixels.clone(); + for y in 0..y1 - y0 { + let (from, to, n) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * 100 + x0) * 4) as usize, ((x1 - x0) * 4) as usize); + patched[to..to + n].copy_from_slice(&part.pixels[from..from + n]); + } + assert!(patched == after.pixels, "drawn again in {area:?}, it differs from drawing it all"); + assert!(before.pixels != after.pixels, "the bitmaps show"); + // Other files change nothing; art of a brush or a symbol showing the file changes everything. + assert_eq!(placed_area(&d, &files(&["area-z"])), Changed::Nothing); + let mut brushed = d.clone(); + let scatter = vectorcraft_brush::Scatter { art: placed_node(66, "area-a", 40.0, 20.0, Affine::IDENTITY), ..Default::default() }; + let brush = vectorcraft_brush::Brush { name: "Cards".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) }; + vectorcraft_brush::store(&mut brushed, &[brush]); + assert_eq!(placed_area(&brushed, &only_a), Changed::Everything); + d.symbols.push(vectorcraft_doc::Symbol { name: "Card".into(), art: Arc::new(placed_node(65, "area-a", 40.0, 20.0, Affine::IDENTITY)) }); + assert_eq!(placed_area(&d, &only_a), Changed::Everything); +} + #[test] fn a_placed_document_kept_as_its_preview_draws_the_preview() { + let _serial = serial(); let mut d = doc("preview-1"); // Only the preview (a 20×10 green PNG) is there, and the file can't be read here. let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([0, 200, 0, 255])); @@ -172,6 +308,7 @@ fn a_placed_document_kept_as_its_preview_draws_the_preview() { #[test] fn a_document_that_places_itself_draws_a_few_levels_deep_then_stops() { + let _serial = serial(); let d = doc_with("loop-1", b"loop"); let t = std::time::Instant::now(); let r = render(&d, false); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 627c0de95..089966777 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3633,11 +3633,12 @@ pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImag } /// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document -/// changed since the frame shown (its key `key` but for the revision, and its render options -/// `opts`), which this thread rendered, and no frame is on its way from the worker: those it -/// changed in (`Some(None)`: none), grown to whole eighths of the frame, as long as they are at -/// most half of it. None: render the whole frame. Frames the worker renders stay -/// whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. +/// changed, or placed documents' bitmaps were made, since the frame shown (its key `key` but for +/// the revision and [`CacheKey::placed`], and its render options `opts`), which this thread +/// rendered, and no frame is on its way from the worker: those it changed in, and those the +/// objects showing those bitmaps' files cover (`Some(None)`: none), grown to whole eighths of the +/// frame, as long as they are at most half of it. None: render the whole frame. Frames the worker +/// renders stay whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. fn changed_pixels( app: &VectorcraftApp, key: &CacheKey, @@ -3652,11 +3653,18 @@ fn changed_pixels( if opts.mask_view.is_some() { return None; } - let same_view = app.canvas.key.as_ref().is_some_and(|k| *k == CacheKey { revision: k.revision, ..key.clone() }); + let shown_key = app.canvas.key.as_ref()?; + let same_view = *shown_key == CacheKey { revision: shown_key.revision, placed: shown_key.placed, ..key.clone() }; if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) { return None; } - match vectorcraft_render::changed(shown, doc) { + // The placed documents' files whose bitmaps were made since the frame shown. + let placed = if shown_key.placed == key.placed { + vectorcraft_render::Changed::Nothing + } else { + vectorcraft_render::placed_area(doc, &vectorcraft_render::placed_document::made_between(shown_key.placed, key.placed)?) + }; + match vectorcraft_render::changed(shown, doc).and(placed) { vectorcraft_render::Changed::Nothing => Some(None), vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { None => Some(None), @@ -5464,6 +5472,67 @@ mod tests { assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); } + /// A placed document's bitmap made in the background renders the canvas again only where the + /// objects showing its file are: both copies of the file are in the part rendered again. + #[test] + fn a_ready_placed_document_bitmap_renders_only_its_part_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + // A document of one 60 × 30 artboard with a rectangle, placed linked twice. + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 60, "height": 30})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 30})).unwrap(); + let dir = std::env::temp_dir().join(format!("vectorcraft-placed-redraw-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("card.vectorcraft"); + std::fs::write(&path, vectorcraft_format::save_file(&src.active().unwrap().doc)).unwrap(); + let path = path.to_string_lossy().into_owned(); + let ids: Vec = [[60, 50], [140, 50]] + .iter() + .map(|at| { + let r = app.session.execute("file.place", &json!({"path": path, "at": at})).unwrap(); + assert_eq!(r["linked"], true, "{r}"); + r["ids"][0].as_u64().unwrap() + }) + .collect(); + app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // Where the canvas texture changed in a frame, and how much of it. + let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); + delta.clear(); + set + }; + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); + let t = std::time::Instant::now(); + while vectorcraft_render::placed_document::busy() && t.elapsed().as_secs() < 20 { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let (_, delta) = frame_output(&mut app, &ctx); + let (pos, size) = canvas(&app, delta).expect("the bitmap shows"); + let [px, py] = pos.expect("a part of the canvas"); + let key = app.canvas.key.clone().unwrap(); + let view = Affine::translate((f64::from(key.w) / 2.0, f64::from(key.h) / 2.0)) + * Affine::rotate(key.rot.to_radians()) + * Affine::scale(key.zoom * f64::from(key.ppp)) + * Affine::translate((-key.cx, -key.cy)); + let doc = &app.session.active().unwrap().doc; + for id in ids { + let b = doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + let [x0, y0, x1, y1] = vectorcraft_render::view_pixels(b, view, key.w, key.h).unwrap(); + let inside = x0 as usize >= px && y0 as usize >= py && x1 as usize <= px + size[0] && y1 as usize <= py + size[1]; + assert!(inside, "object {id} at {:?} in the part at {:?} of size {size:?}", [x0, y0, x1, y1], [px, py]); + } + assert!(size[0] * size[1] * 2 <= key.w as usize * key.h as usize, "a part of it: {size:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + /// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the /// mask's coverage wherever its art reaches, past its object's bounds. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 78d110d72..4f5dbcba1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -950,6 +950,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); CNC preset and VCarve QA remain pending. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. + - Placed documents' bitmaps on screen: when a bitmap made in the background is ready, the desktop app renders the canvas again only where the objects that show its file are, when the frame shown was rendered on the UI thread and that part is at most half the canvas. Bitmaps drawn or made since a frame began are kept for that frame past the 512 MiB budget, up to 2 GiB, so a view showing more than the budget does not make them again in every frame. A bitmap that would take them past 2 GiB is not kept, and its file draws exactly at that zoom. A bitmap that can't be made (the file can't be read and there is no preview) shows the light placeholder and is not asked for again. Each ready bitmap used to render the whole canvas again, and bitmaps past the budget and bitmaps that could not be made were asked for again in every frame (`crates/render/src/tests_placed_document.rs`). - Linked PDF and `.ai` pages: File › Place with Link (the default) places a page of a PDF, `.ai` or `.ait` file read as PDF pages as a placed document, as it links an image, the page and its box (`crop`) kept with the link: Links check, update and relink it, Break Link and Object › Expand give the group placing it without Link gives, every output draws the page's vectors, and Package copies the file. Without Link, or with a password or `textAs: "outlines"`, the page is embedded. EPS and PostScript `.ai` files are still embedded (`crates/engine/src/tests_placed_document.rs`). - File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests_place.rs`). - Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests_print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.