Files
figcraft/crates/render/src/large_image_tests.rs
T
BFlat e32f8f2e07 M8: large images on the canvas draw from a smaller copy (#1483) (#1518)
* M8: large images on the canvas draw from a smaller copy (#1483)

An image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, cached beside the image in the same budget. Two 16000 x 10000 images (640 MB each) evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out. Moving one over the other: about 2.1 s a frame before, 11 ms at fit and 15 ms at 30% after. Exports, thumbnails and views at 100% and closer draw from the image itself. Tests in large_image_tests; timings in large_image_bench_tests (ignored).

* M8: drop the ignored large-image benchmark; its numbers are in the PR (#1483)
2026-10-29 18:14:18 +07:00

116 lines
5.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Large images on the canvas (#1483): a large image shown smaller than its pixels is drawn from a
//! copy no larger than it shows, so two of them stay decoded together, while exports and close-up
//! views draw from the image itself.
use std::sync::Arc;
use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
use tiny_skia::Pixmap;
use crate::images::{self, DISPLAY_SIDES, MAX_CACHED_BYTES};
use crate::{Options, View, render_view};
const W: u32 = 6000;
const H: u32 = 8;
/// A `W` × `H` PNG of one-pixel black and white columns, black first unless `phase` is odd.
fn stripes(phase: u32) -> Vec<u8> {
let img = image::RgbaImage::from_fn(W, H, |x, _| image::Rgba(if (x + phase).is_multiple_of(2) { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// Two `W` × `H` images at their own size, one under the other (keys starting with `tag`: the
/// cache is shared by every test).
fn doc(tag: &str) -> (Document, NodeId, [String; 2]) {
let mut d = Document::new("large images");
let page = d.first_page().unwrap();
let keys = [format!("{tag}-a"), format!("{tag}-b")];
for (i, key) in (0u32..).zip(&keys) {
d.images.insert(key.clone(), ImageData { bytes: Arc::new(stripes(i)), width: W, height: H });
let r = d.create(page, NodeKind::Rectangle, "Image", 0.0, f64::from(i * 2 * H), f64::from(W), f64::from(H)).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::image(key, ImageScaleMode::Fill)];
}
(d, page, keys)
}
fn canvas() -> Options {
Options { lod: true, magnified_image_pixels: true, ..Default::default() }
}
/// The first `W` units of both rows at `scale`.
fn view(scale: f64) -> View {
View { origin: (0.0, 0.0), scale, width: (f64::from(W) * scale).ceil() as u32, height: (f64::from(4 * H) * scale).ceil() as u32 }
}
/// The canvas's copy of image `key` of `d` shown `side` device pixels long.
fn shown(d: &Document, key: &str, side: f64) -> Arc<Pixmap> {
images::shown(key, d.images.get(key), &Default::default(), d.color_profile, side).unwrap()
}
/// Whether row `y` of `p` shows one-pixel columns, black at even `x` when `black_first`, `each`
/// device pixels wide.
fn sharp_stripes(p: &Pixmap, y: u32, black_first: bool, each: u32) -> bool {
(0..200).chain(4000..4200).all(|x| {
let red = p.pixel(x, y).unwrap().red();
let black = (x / each) % 2 == u32::from(!black_first);
if black { red < 8 } else { red > 247 }
})
}
#[test]
fn zoomed_out_the_canvas_draws_both_images_from_copies_that_stay_decoded() {
let (d, page, keys) = doc("zoomed-out");
// At 25% each shows 1500 px long: drawn from 4096 px copies, not decoded in full.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
let first = keys.each_ref().map(|k| shown(&d, k, 1500.0));
for (k, p) in keys.iter().zip(&first) {
assert_eq!((p.width(), p.height()), (DISPLAY_SIDES[0], 5), "{k}");
assert_eq!(images::cached_sides(k), vec![DISPLAY_SIDES[0]], "{k}: only its copy is decoded");
}
// Drawn again (a drag, a pan), both are still the copies decoded the first time.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
for (k, p) in keys.iter().zip(&first) {
assert!(Arc::ptr_eq(p, &shown(&d, k, 1500.0)), "{k} was decoded again");
}
}
#[test]
fn exports_and_close_ups_draw_from_the_image_itself() {
let (d, page, keys) = doc("close-up");
// An export at 1x, and the canvas at 100% and 200%, show every pixel column.
let export = render_view(&d, page, &view(1.0), &Options::default()).unwrap();
assert!(sharp_stripes(&export, 4, true, 1) && sharp_stripes(&export, 2 * H + 4, false, 1));
let at_100 = render_view(&d, page, &view(1.0), &canvas()).unwrap();
assert!(sharp_stripes(&at_100, 4, true, 1) && sharp_stripes(&at_100, 2 * H + 4, false, 1));
let at_200 = render_view(&d, page, &view(2.0), &canvas()).unwrap();
assert!(sharp_stripes(&at_200, 8, true, 2));
assert_eq!(images::cached_sides(&keys[0]), vec![16_384], "the image itself, as exports draw it");
}
#[test]
fn the_copy_is_the_smallest_that_holds_what_shows() {
let side = |long, shown| images::display_side(long, shown);
assert_eq!(side(6000, 1500.0), 4096);
assert_eq!(side(16_000, 4096.0), 4096);
assert_eq!(side(16_000, 4097.0), 8192);
// Shown at more than the largest copy, or an image no larger than the copy: the image itself.
assert_eq!(side(16_000, 9000.0), 16_384);
assert_eq!(side(6000, 5000.0), 16_384);
assert_eq!(side(4096, 100.0), 16_384);
// An unknown size or a meaningless scale: the image itself.
assert_eq!(side(0, 100.0), 16_384);
assert_eq!(side(16_000, f64::NAN), 16_384);
}
#[test]
fn two_16k_images_fit_the_cache_as_copies_but_not_in_full() {
// 16 000 × 10 000 (#1483's size class): 640 MB each in full, 42 MB at 4096 and 168 MB at 8192.
let bytes = |w: u64, h: u64| w * h * 4;
assert!(2 * bytes(16_000, 10_000) > MAX_CACHED_BYTES);
assert!(2 * bytes(8192, 5120) <= MAX_CACHED_BYTES);
assert!(2 * bytes(4096, 2560) <= MAX_CACHED_BYTES);
}