From 24189217609d51af9bbe9c0fb229c84d988bd4d5 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:58:04 -0700 Subject: [PATCH] Placed documents: on screen, cached bitmaps made in the background --- crates/render/src/lib.rs | 4 + crates/render/src/paint.rs | 17 +- crates/render/src/placed_document.rs | 295 ++++++++++++++++++++- crates/render/src/tests_placed_document.rs | 67 ++++- crates/ui-egui/src/canvas.rs | 13 + crates/ui-egui/src/lib.rs | 2 + 6 files changed, 378 insertions(+), 20 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a6e435386..42d68dc0c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -97,6 +97,9 @@ pub struct RenderOptions { pub highlight_substitutions: bool, /// Edge smoothing (raster export option). pub anti_alias: AntiAlias, + /// Screen view: placed documents draw from cached bitmaps made in the background (see + /// [`placed_document`]); off, they draw exactly, read when needed. + pub progressive_placed: bool, } /// How edges are rasterized (raster export option). @@ -161,6 +164,7 @@ impl Default for RenderOptions { mask_view: None, highlight_substitutions: false, anti_alias: AntiAlias::Art, + progressive_placed: false, } } } diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index e99adfae3..0f6e41bbe 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -52,7 +52,22 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr /// Decode encoded image bytes into a premultiplied pixmap. pub fn decode_pixmap(bytes: &[u8]) -> Option { - let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + pixmap_of(image::load_from_memory(bytes).ok()?.to_rgba8()) +} + +/// [`decode_pixmap`] shrunk to `w`×`h` pixels when the image is larger (never enlarged). +pub fn decode_pixmap_fit(bytes: &[u8], w: u16, h: u16) -> Option { + let img = image::load_from_memory(bytes).ok()?; + let img = if img.width() > w as u32 || img.height() > h as u32 { + img.resize_exact(w as u32, h as u32, image::imageops::FilterType::Triangle) + } else { + img + }; + pixmap_of(img.to_rgba8()) +} + +/// An RGBA image as a premultiplied pixmap. +fn pixmap_of(img: image::RgbaImage) -> Option { let (w, h) = img.dimensions(); if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { return None; diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index 8955b6b04..65e886f55 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -1,22 +1,138 @@ -//! Drawing placed documents ([`PlacedDocument`]): the art read from the file -//! ([`vectorcraft_doc::placed_document::read`]) drawn with its own resources, through a view that -//! maps its artboard into the object's box (nothing is copied into the document). +//! Drawing placed documents ([`PlacedDocument`]). //! -//! A document opened with only its placed documents' previews -//! ([`vectorcraft_doc::ImageBlob::is_proxy`]) shows them until the file is at hand -//! ([`vectorcraft_doc::placed_document::full_bytes_cached`]), and for good when it can't be found. Placed documents inside a placed document are read from their -//! files, up to [`MAX_DEPTH`] deep; deeper ones show their previews. +//! Exactly: the art read from the file ([`vectorcraft_doc::placed_document::read`]) drawn with its +//! own resources, through a view that maps its artboard into the object's box (nothing is copied +//! into the document). +//! +//! On screen ([`RenderOptions::progressive_placed`]): a placed document can't be edited, so what +//! 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. +//! +//! 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 +//! ([`vectorcraft_doc::placed_document::full_bytes`]), the preview standing in until then (and for +//! good when the file can't be found). Placed documents inside a placed document are drawn the +//! same way, up to [`MAX_DEPTH`] deep; deeper ones show their previews. use std::cell::Cell; -use std::sync::Arc; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, PoisonError}; use vectorcraft_doc::placed_document::MAX_DEPTH; -use vectorcraft_doc::{Node, PlacedDocument}; -use vectorcraft_geom::Affine; -use vello_cpu::RenderContext; +use vectorcraft_doc::{ImageBlob, Node, PlacedDocument}; +use vectorcraft_geom::{Affine, Rect}; +use vello_cpu::{Pixmap, RenderContext, kurbo, peniko}; +use crate::ink::Ink; 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. +const BUDGET: usize = 512 << 20; +/// The smallest zoom step (device pixels per point). +const MIN_STEP: f64 = 1.0 / 64.0; + +/// A bitmap's identity: the file shown (its key) and the zoom step's bits. +type BitmapKey = (String, u64); + +struct Bitmap { + pm: Arc, + step: f64, + used: u64, +} + +#[derive(Default)] +struct Bitmaps { + map: HashMap, + clock: u64, + bytes: usize, +} + +static BITMAPS: LazyLock> = LazyLock::new(Default::default); +/// Bitmaps being made. +static PENDING: LazyLock>> = LazyLock::new(Default::default); +static GENERATION: AtomicU64 = AtomicU64::new(0); + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + // The caches stay whole if a holder panicked (entries are only inserted and removed). + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Changes whenever a bitmap made in the background is ready: a screen showing placed documents +/// draws again when it does. +pub fn generation() -> u64 { + GENERATION.load(Ordering::Relaxed) +} + +/// Are bitmaps being made in the background? +pub fn busy() -> bool { + !lock(&PENDING).is_empty() +} + +/// Is a bitmap of file `key` being made? +#[cfg(test)] +pub(crate) fn pending_for(key: &str) -> bool { + lock(&PENDING).iter().any(|k| k.0 == key) +} + +/// 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)) +} + +impl Bitmaps { + fn get(&mut self, k: &BitmapKey) -> Option> { + self.clock += 1; + let clock = self.clock; + let b = self.map.get_mut(k)?; + b.used = clock; + Some(b.pm.clone()) + } + + /// The bitmap of another step of the same file, the nearest to `step`. + fn nearest(&self, key: &str, step: f64) -> Option<(Arc, f64)> { + self.map + .iter() + .filter(|((k, _), _)| k == key) + .min_by(|a, b| (a.1.step / step).log2().abs().total_cmp(&(b.1.step / step).log2().abs())) + .map(|(_, b)| (b.pm.clone(), b.step)) + } + + fn insert(&mut self, k: BitmapKey, pm: Pixmap, step: f64) { + 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 }) { + 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 }; + 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); + } + } + } +} + +/// What a bitmap is made from. +struct Job { + key: BitmapKey, + object: PlacedDocument, + blob: ImageBlob, + step: f64, +} + +/// Device pixels per point of `object`'s box its preview `bytes` have. +fn preview_density(object: &PlacedDocument, bytes: &[u8]) -> f64 { + vectorcraft_doc::placed_document::image_size(bytes).map_or(0.0, |(w, _)| w as f64 / object.width.max(1e-9)) +} + thread_local! { /// How deep inside placed documents this thread is drawing. static DEPTH: Cell = const { Cell::new(0) }; @@ -49,14 +165,108 @@ pub fn render_inside(r: &mut Renderer, doc: &vectorcraft_doc::Document, art: &Ar r.render_node(doc, art, w, h, view) } +/// `object`'s file (`blob`) as a bitmap of `step` device pixels per point of its box: from its +/// preview when that is all there is and is fine enough, else from the file (read again when +/// needed; the preview when it can't be). +fn make(r: &mut Renderer, object: &PlacedDocument, blob: &ImageBlob, step: f64) -> Option { + let (w, h) = (pixels(object.width * step), pixels(object.height * step)); + let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() }; + let full = match blob.is_proxy() { + true if step <= preview_density(object, &blob.bytes) * 1.01 => None, + true => vectorcraft_doc::placed_document::full_bytes(object, blob), + false => Some(blob.bytes.clone()), + }; + let read = full.and_then(|bytes| vectorcraft_doc::placed_document::read(object, &bytes)); + let Some(e) = read else { return crate::paint::decode_pixmap_fit(&preview?, w, h) }; + let f = e.frame; + let (sx, sy) = (w as f64 / f.width(), h as f64 / f.height()); + let view = Affine::scale_non_uniform(sx, sy) * Affine::translate((-f.x0, -f.y0)); + let img = render_inside(r, &e.doc, &e.art, w, h, view)?; + let data = img.pixels.as_chunks::<4>().0.iter().map(|&[r, g, b, a]| vello_cpu::color::PremulRgba8 { r, g, b, a }).collect(); + Some(Pixmap::from_parts(data, w, h)) +} + +/// A bitmap side for `v` pixels (at least 1). +fn pixels(v: f64) -> u16 { + v.round().clamp(1.0, MAX_SIDE) as u16 +} + +/// Make `job`'s bitmap and keep it. +fn run(r: &mut Renderer, job: Job) { + if let Some(pm) = make(r, &job.object, &job.blob, job.step) { + lock(&BITMAPS).insert(job.key.clone(), pm, job.step); + } + lock(&PENDING).remove(&job.key); + GENERATION.fetch_add(1, Ordering::Relaxed); +} + +#[cfg(not(target_arch = "wasm32"))] +mod workers { + use std::sync::mpsc::{Sender, channel}; + use std::sync::{Arc, LazyLock, Mutex}; + + use super::Job; + + /// The queue the workers take jobs from; `None` when no worker could start. + static QUEUE: LazyLock>>>> = LazyLock::new(|| { + let (tx, rx) = channel::>(); + let rx = Arc::new(Mutex::new(rx)); + let n = std::thread::available_parallelism().map_or(2, |n| (n.get() / 2).clamp(1, 4)); + let mut started = 0; + for i in 0..n { + let rx = rx.clone(); + let spawned = std::thread::Builder::new().name(format!("vectorcraft-placed-{i}")).spawn(move || { + let mut r = crate::Renderer::new(); + r.threads = 0; + loop { + let job = super::lock(&rx).recv(); + let Ok(job) = job else { break }; + // A panic loses this bitmap, not the worker. + let key = job.key.clone(); + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| super::run(&mut r, *job))).is_err() { + super::lock(&super::PENDING).remove(&key); + } + } + }); + started += usize::from(spawned.is_ok()); + } + (started > 0).then(|| Mutex::new(tx)) + }); + + /// Hand `job` to a worker; back when there is none. + pub(super) fn submit(job: Box) -> Result<(), Box> { + match QUEUE.as_ref() { + Some(q) => super::lock(q).send(job).map_err(|e| e.0), + None => Err(job), + } + } +} + +/// Make `job`'s bitmap in the background (here and now where there are no threads). +fn schedule(job: Job) { + if !lock(&PENDING).insert(job.key.clone()) { + return; + } + #[cfg(not(target_arch = "wasm32"))] + let job = match workers::submit(Box::new(job)) { + Ok(()) => return, + Err(job) => *job, + }; + run(&mut Renderer::new(), job); +} + /// The mean scale of `a`. fn mean_scale(a: Affine) -> f64 { a.determinant().abs().sqrt() } impl Renderer { - /// Draw placed document `p` in frame `f`. + /// Draw placed document `p` in frame `f`: from a cached bitmap on screen, else exactly. pub(crate) fn draw_placed(&mut self, ctx: &mut RenderContext, f: &Frame, p: &PlacedDocument) { + let plain = f.ink == Ink::Display && !f.opts.outline && f.opts.proof.is_none() && !f.opts.overprint_preview; + if f.opts.progressive_placed && plain && depth() == 0 && self.draw_placed_bitmap(ctx, f, p) { + return; + } let Some(blob) = f.doc.images.get(&p.key) else { return }; // The file's bytes: stored, read again already, or (inside another placed document, while // not too deep) read again now. @@ -85,4 +295,65 @@ impl Renderer { let _inside = Inside::enter(); 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). + 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 }; + if p.width * step > MAX_SIDE || p.height * step > MAX_SIDE { + return false; + } + let Some(blob) = f.doc.images.get(&p.key).cloned() else { return true }; + let key = (p.key.clone(), step.to_bits()); + let (found, nearest) = { + let mut b = lock(&BITMAPS); + let found = b.get(&key); + let nearest = if found.is_none() { b.nearest(&p.key, step) } else { None }; + (found, nearest) + }; + let shown = match found { + Some(pm) => Some(pm), + None => { + schedule(Job { key, object: p.clone(), blob, step }); + // 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)) + } + }; + let rect = Rect::new(0.0, 0.0, p.width, p.height); + match shown { + Some(pm) => { + let (sx, sy) = (p.width / pm.width().max(1) as f64, p.height / pm.height().max(1) as f64); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); + ctx.set_transform(m); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&kurbo::Rect::new(rect.x0, rect.y0, rect.x1, rect.y1)); + ctx.reset_paint_transform(); + } + None => { + // Coming: a light box where it will be. + 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)); + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zoom_steps_are_powers_of_two_at_or_above_the_scale() { + assert_eq!(step(1.0), Some(1.0)); + assert_eq!(step(1.2), Some(2.0)); + assert_eq!(step(0.3), Some(0.5)); + assert_eq!(step(5.0), Some(8.0)); + assert_eq!(step(1e-9), Some(MIN_STEP)); + assert_eq!(step(f64::NAN), None); + assert_eq!(step(0.0), None); + } } diff --git a/crates/render/src/tests_placed_document.rs b/crates/render/src/tests_placed_document.rs index 0224fa394..c04af829a 100644 --- a/crates/render/src/tests_placed_document.rs +++ b/crates/render/src/tests_placed_document.rs @@ -1,5 +1,5 @@ -//! Placed documents drawn exactly: with their own art, as their previews when that is all there -//! is, and a few levels deep when they place themselves. +//! Placed documents on screen: drawn from bitmaps made in the background (a placeholder until +//! then), the same as drawn exactly; exports draw them exactly at once. use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, ImageBlob, LinkInfo, Node, NodeId, NodeKind, PlacedDocument}; @@ -45,19 +45,63 @@ fn doc(key: &str) -> Document { doc_with(key, format!("{{\"doc\": \"{key}\"}}").as_bytes()) } -fn render(d: &Document) -> Rendered { - let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; +fn render(d: &Document, progressive: bool) -> Rendered { + let opts = RenderOptions { background: Some([255, 255, 255, 255]), progressive_placed: progressive, ..Default::default() }; Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts) } +/// Wait for the background bitmaps (at most 10 s). +fn settle() { + let t = std::time::Instant::now(); + while placed_document::busy() && t.elapsed().as_secs() < 10 { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!placed_document::busy(), "bitmaps still coming"); +} + #[test] fn a_placed_document_draws_exactly_with_its_own_art() { let d = doc("exact-1"); - let r = render(&d); + let r = render(&d, false); assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255], "blue inside the box"); assert_eq!(&r.pixel(95, 95)[..3], &[255, 255, 255], "nothing outside"); } +#[test] +fn on_screen_a_placed_document_shows_a_placeholder_then_its_bitmap() { + let d = doc("progressive-1"); + let generation = placed_document::generation(); + let first = render(&d, true); + let shown = &first.pixel(50, 30)[..3]; + assert!(shown == [0, 0, 255] || shown != [255, 255, 255], "a placeholder or the art: {shown:?}"); + settle(); + assert!(placed_document::generation() > generation || shown == [0, 0, 255], "a bitmap was made"); + let later = render(&d, true); + let exact = render(&d, false); + assert_eq!(&later.pixel(50, 30)[..3], &[0, 0, 255]); + for (x, y) in [(12, 12), (50, 30), (88, 48), (95, 95), (5, 5)] { + let (a, b) = (later.pixel(x, y), exact.pixel(x, y)); + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 24), "at ({x}, {y}): {a:?} vs {b:?}"); + } +} + +#[test] +fn copies_of_one_placed_document_share_its_bitmap() { + let mut d = doc("shared-1"); + let n = d.node(NodeId(50)).unwrap().clone(); + let l = d.layers[0].id; + let mut copy = n.clone(); + copy.id = NodeId(51); + copy.transform(Affine::translate((0.0, 45.0)), false); + d.insert(Some(l), 1, copy).unwrap(); + render(&d, true); + settle(); + let r = render(&d, true); + assert!(!placed_document::pending_for("shared-1"), "nothing more to make"); + assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]); + assert_eq!(&r.pixel(50, 75)[..3], &[0, 0, 255]); +} + #[test] fn a_placed_document_kept_as_its_preview_draws_the_preview() { let mut d = doc("preview-1"); @@ -68,16 +112,25 @@ fn a_placed_document_kept_as_its_preview_draws_the_preview() { let mut blob = ImageBlob::new("image/png", png); blob.proxy = Some(blob.bytes.clone()); d.images.insert("preview-1".into(), blob); - let exact = render(&d); + let exact = render(&d, false); assert_eq!(&exact.pixel(50, 30)[..3], &[0, 200, 0], "the preview, in the box"); assert_eq!(&exact.pixel(95, 95)[..3], &[255, 255, 255]); + // On screen past the preview's resolution (it has 0.5 px a point; this is 2): still the preview. + render(&d, true); + settle(); + let shown = render(&d, true); + assert_eq!(&shown.pixel(50, 30)[..3], &[0, 200, 0]); } #[test] fn a_document_that_places_itself_draws_a_few_levels_deep_then_stops() { let d = doc_with("loop-1", b"loop"); let t = std::time::Instant::now(); - let r = render(&d); + let r = render(&d, false); assert!(t.elapsed().as_secs() < 10); assert_eq!(&r.pixel(50, 30)[..3], &[0, 0, 255]); + // On screen too. + render(&d, true); + settle(); + assert_eq!(&render(&d, true).pixel(50, 30)[..3], &[0, 0, 255]); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4937c4c9d..588924fa7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -230,7 +230,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { hidden: vec![], rot: v.rotation, anti_alias: app.session.prefs.anti_aliased_artwork, + placed: vectorcraft_render::placed_document::generation(), }; + // Placed documents' bitmaps are being made: draw again when they are ready. + if vectorcraft_render::placed_document::busy() { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + } if !app.canvas.worker_started { app.canvas.worker_started = true; if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() { @@ -265,6 +270,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and // pattern tiles stay smooth), as in Illustrator. anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None }, + progressive_placed: true, ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. @@ -1030,6 +1036,13 @@ fn node_outline(n: &Node) -> BezPath { bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); } } + // A placed document shows its box, turned with it. + NodeKind::PlacedDocument(p) => { + let [a, b, c, d] = p.corners(); + bp.move_to(a); + [b, c, d].into_iter().for_each(|q| bp.line_to(q)); + bp.close_path(); + } // An envelope shows its mesh (or top object), not its content. NodeKind::Envelope { .. } => { if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eea9cbecc..bf3ecb07c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -277,6 +277,8 @@ pub struct CacheKey { pub rot: f64, /// General › Anti-aliased Artwork. pub anti_alias: bool, + /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. + pub placed: u64, } #[derive(Clone, Copy, Debug, Default)]