Placed documents: on screen, cached bitmaps made in the background

This commit is contained in:
haven
2026-10-08 12:20:51 -07:00
parent e04b94938b
commit 2418921760
6 changed files with 378 additions and 20 deletions
+4
View File
@@ -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,
}
}
}
+16 -1
View File
@@ -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<vello_cpu::Pixmap> {
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<vello_cpu::Pixmap> {
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<vello_cpu::Pixmap> {
let (w, h) = img.dimensions();
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return None;
+283 -12
View File
@@ -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<Pixmap>,
step: f64,
used: u64,
}
#[derive(Default)]
struct Bitmaps {
map: HashMap<BitmapKey, Bitmap>,
clock: u64,
bytes: usize,
}
static BITMAPS: LazyLock<Mutex<Bitmaps>> = LazyLock::new(Default::default);
/// Bitmaps being made.
static PENDING: LazyLock<Mutex<HashSet<BitmapKey>>> = LazyLock::new(Default::default);
static GENERATION: AtomicU64 = AtomicU64::new(0);
fn lock<T>(m: &Mutex<T>) -> 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<f64> {
(scale.is_finite() && scale > 0.0).then(|| 2f64.powf(scale.log2().ceil()).max(MIN_STEP))
}
impl Bitmaps {
fn get(&mut self, k: &BitmapKey) -> Option<Arc<Pixmap>> {
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<Pixmap>, 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<usize> = 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<Pixmap> {
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<Option<Mutex<Sender<Box<Job>>>>> = LazyLock::new(|| {
let (tx, rx) = channel::<Box<Job>>();
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<Job>) -> Result<(), Box<Job>> {
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);
}
}
+60 -7
View File
@@ -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]);
}
+13
View File
@@ -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) {
+2
View File
@@ -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)]