Render: placed-document bitmaps draw undecoded-image marks when the view does

On screen, a placed document is drawn from a bitmap made in the
background, and that bitmap was rendered with the default options. An
image inside the placed file whose pixels cannot be decoded therefore
drew nothing in the bitmap, even though the canvas marks such images
with a crossed box. The bitmap now follows the frame's
mark_undecoded_images setting, the bitmap cache keys on it, and a
bitmap standing in for one still being made has the same setting.
This commit is contained in:
dotCooCoo
2026-11-04 14:56:36 +07:00
committed by bflatastic
parent b9d3881872
commit f15a16f2c1
3 changed files with 113 additions and 21 deletions
+61 -18
View File
@@ -6,7 +6,10 @@
//!
//! 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
//! point), shared by every object showing that file. Images inside it whose pixels can't be
//! decoded are drawn as crossed boxes in the bitmap when the frame marks them
//! ([`RenderOptions::mark_undecoded_images`]), and the bitmaps with and without marks are kept
//! apart. 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, [`made_between`] says for which files, and [`busy`]
//! says whether any is coming, so the screen can draw again where those files show
@@ -45,8 +48,9 @@ const MAX_BYTES: usize = 4 * BUDGET;
/// 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);
/// A bitmap's identity: the file shown (its key), the zoom step's bits, and whether images whose
/// pixels can't be decoded are marked ([`crate::RenderOptions::mark_undecoded_images`]).
type BitmapKey = (String, u64, bool);
struct Bitmap {
pm: Arc<Pixmap>,
@@ -130,10 +134,19 @@ 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`].
/// Mark the bitmaps of file `key` at zoom `step`, with and without marks on undecoded images, 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()));
for mark in [false, true] {
lock(&UNKEPT).insert((key.to_string(), step.to_bits(), mark));
}
}
/// `object`'s file (`blob`) as a bitmap of `step` device pixels per point, made here and now.
#[cfg(test)]
pub(crate) fn bitmap(object: &PlacedDocument, blob: &ImageBlob, step: f64, mark_undecoded_images: bool) -> Option<Pixmap> {
make(&mut Renderer::new(), object, blob, step, mark_undecoded_images)
}
/// The zoom step for `scale` device pixels per point: the power of two at or above it.
@@ -150,11 +163,12 @@ impl Bitmaps {
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)> {
/// The bitmap of another step of the same file, with the same marks on undecoded images, the
/// nearest to `step`.
fn nearest(&self, key: &str, step: f64, mark: bool) -> Option<(Arc<Pixmap>, f64)> {
self.map
.iter()
.filter(|((k, _), _)| k == key)
.filter(|((k, _, m), _)| k == key && *m == mark)
.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))
}
@@ -245,14 +259,30 @@ fn depth() -> usize {
/// Render `art` of a placed document's file (`doc`, its resources) into `w`×`h` transparent
/// pixels through `view`, the placed documents inside it one level deeper.
pub fn render_inside(r: &mut Renderer, doc: &vectorcraft_doc::Document, art: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<crate::Rendered> {
render_inside_marked(r, doc, art, w, h, view, false)
}
/// [`render_inside`], drawing images whose pixels can't be decoded as crossed boxes when
/// `mark_undecoded_images` is set ([`crate::RenderOptions::mark_undecoded_images`]).
fn render_inside_marked(
r: &mut Renderer,
doc: &vectorcraft_doc::Document,
art: &Arc<Node>,
w: u16,
h: u16,
view: Affine,
mark_undecoded_images: bool,
) -> Option<crate::Rendered> {
let _inside = Inside::enter();
r.render_node(doc, art, w, h, view)
let opts = crate::RenderOptions { mark_undecoded_images, ..Default::default() };
r.render_node_with(doc, art, w, h, view, &opts)
}
/// `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> {
/// needed; the preview when it can't be). Images whose pixels can't be decoded are drawn as
/// crossed boxes when `mark_undecoded_images` is set.
fn make(r: &mut Renderer, object: &PlacedDocument, blob: &ImageBlob, step: f64, mark_undecoded_images: bool) -> 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() {
@@ -265,7 +295,7 @@ fn make(r: &mut Renderer, object: &PlacedDocument, blob: &ImageBlob, step: f64)
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 img = render_inside_marked(r, &e.doc, &e.art, w, h, view, mark_undecoded_images)?;
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))
}
@@ -281,7 +311,7 @@ fn pixels(v: f64) -> u16 {
fn run(r: &mut Renderer, mut job: Job) {
let scope = job.scope.take();
let mut body = || {
match make(r, &job.object, &job.blob, job.step) {
match make(r, &job.object, &job.blob, job.step, job.key.2) {
Some(pm) => {
if !lock(&BITMAPS).insert(job.key.clone(), pm, job.step, job.keep, (BUDGET, MAX_BYTES)) {
lock(&UNKEPT).insert(job.key.clone());
@@ -413,7 +443,7 @@ impl Renderer {
return false;
}
let Some(blob) = f.doc.images.get(&p.key).cloned() else { return true };
let key = (p.key.clone(), step.to_bits());
let key = (p.key.clone(), step.to_bits(), f.opts.mark_undecoded_images);
// Not kept past [`MAX_BYTES`]: drawn exactly.
if lock(&UNKEPT).contains(&key) {
return false;
@@ -423,7 +453,7 @@ impl Renderer {
// 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 };
let nearest = if found.is_none() { b.nearest(&p.key, step, f.opts.mark_undecoded_images) } else { None };
(found, nearest, keep)
};
let shown = match found {
@@ -434,7 +464,7 @@ impl Renderer {
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))
b.get(&(p.key.clone(), step.to_bits(), f.opts.mark_undecoded_images)).or(nearest.map(|n| n.0))
}
};
let rect = Rect::new(0.0, 0.0, p.width, p.height);
@@ -480,7 +510,7 @@ mod tests {
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());
let key = |name: &str| (name.to_string(), 1f64.to_bits(), false);
for name in ["a", "b", "c"] {
assert!(b.insert(key(name), Pixmap::new(10, 10), 1.0, 0, limits));
}
@@ -489,7 +519,7 @@ mod tests {
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();
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"] {
@@ -499,4 +529,17 @@ mod tests {
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);
}
/// While a bitmap is made, another step's bitmap stands in only when it has the same marks on
/// undecoded images.
#[test]
fn a_stand_in_bitmap_has_the_same_marks() {
let mut b = Bitmaps::default();
let limits = (BUDGET, MAX_BYTES);
assert!(b.insert(("a".into(), 1f64.to_bits(), false), Pixmap::new(10, 10), 1.0, 0, limits));
assert!(b.insert(("a".into(), 4f64.to_bits(), true), Pixmap::new(40, 40), 4.0, 0, limits));
assert_eq!(b.nearest("a", 2.0, false).map(|n| n.1), Some(1.0));
assert_eq!(b.nearest("a", 2.0, true).map(|n| n.1), Some(4.0));
assert!(b.nearest("b", 2.0, false).is_none());
}
}
+6 -2
View File
@@ -307,15 +307,19 @@ impl Renderer {
/// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`.
pub fn render_node(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<Rendered> {
self.render_node_with(doc, n, w, h, view, &RenderOptions::default())
}
/// [`Self::render_node`] with `opts` (e.g. [`RenderOptions::mark_undecoded_images`]).
pub fn render_node_with(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine, opts: &RenderOptions) -> Option<Rendered> {
self.check_plugins();
self.check_symbols(doc);
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let mut ctx = single_threaded_context(w, h);
let opts = RenderOptions::default();
let symbols = doc.symbol_boxes().quick();
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts: &opts, ink: Ink::Display, plates: None };
let frame = Frame { mt: false, doc, symbols: &symbols, view, visible, px, opts, ink: Ink::Display, plates: None };
self.begin_frame();
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
self.clip_paths.clear();
+46 -1
View File
@@ -8,7 +8,9 @@ use vectorcraft_geom::shapes;
use crate::*;
/// 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, and one whose bytes start with `bad` can't be read.
/// places itself (the same key) in its middle, one whose bytes start with `cut` also shows an
/// image whose pixels can't be decoded over its left half, 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;
@@ -17,6 +19,19 @@ fn blue_page(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)>
d.layers.clear();
let blue = Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
let mut children = vec![Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), blue))];
if bytes.starts_with(b"cut") {
d.images.insert("cut-image".into(), ImageBlob::new("image/png", b"not a PNG: placed document".to_vec()));
let im = vectorcraft_doc::ImageObject {
key: "cut-image".into(),
width: 20,
height: 20,
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
children.push(Arc::new(Node::new(NodeId(3), NodeKind::Image(Box::new(im)))));
}
if bytes.starts_with(b"loop") {
d.images.insert(p.key.clone(), ImageBlob::new("application/json", bytes.to_vec()));
let me = PlacedDocument { xf: Affine::translate((10.0, 5.0)) * Affine::scale(0.5), ..p.clone() };
@@ -254,6 +269,36 @@ fn a_bitmap_that_cant_be_made_is_not_asked_for_again() {
assert!(shown != [255, 255, 255] && shown != [0, 0, 255], "the placeholder: {shown:?}");
}
/// A bitmap made for a frame that marks images whose pixels can't be decoded shows such an image
/// in the file as a crossed box; one made for a frame that does not shows the art under it.
#[test]
fn a_bitmap_marks_undecoded_images_when_the_frame_does() {
let d = doc_with("cut-1", b"cut");
let Some(NodeKind::PlacedDocument(p)) = d.node(NodeId(50)).map(|n| &n.kind) else { panic!("a placed document") };
let Some(blob) = d.images.get("cut-1") else { panic!("the file's bytes") };
for mark in [false, true] {
// 40 × 20 pixels at one pixel a point; the image covers x 0 to 20.
let Some(pm) = placed_document::bitmap(p, blob, 1.0, mark) else { panic!("a bitmap") };
let pixel = |x: usize, y: usize| pm.data_as_u8_slice()[(y * 40 + x) * 4..][..4].to_vec();
assert_eq!(pixel(30, 10), [0, 0, 255, 255], "mark {mark}: the art beside the image");
assert_eq!(pixel(5, 10) != [0, 0, 255, 255], mark, "mark {mark}: the crossed box over the art: {:?}", pixel(5, 10));
}
}
/// Frames with and without marks on undecoded images ask for bitmaps of their own, so a bitmap
/// made for one is not shown in the other. The file can't be read, so no bitmap is made after
/// the test ends.
#[test]
fn bitmaps_with_and_without_undecoded_marks_are_asked_for_apart() {
let _serial = serial();
let d = doc_with("marks-1", b"bad");
for mark in [false, true] {
let opts = RenderOptions { progressive_placed: true, mark_undecoded_images: mark, ..Default::default() };
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
}
assert_eq!(placed_document::scheduled("marks-1"), 2, "one for each");
}
/// 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