From e32f8f2e075e1e433bdef770a34030b750edceb3 Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 29 Oct 2026 18:14:18 +0700 Subject: [PATCH] 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) --- ROADMAP.md | 3 +- crates/render/src/draw.rs | 5 +- crates/render/src/image_budget_tests.rs | 4 +- crates/render/src/images.rs | 55 ++++++++++-- crates/render/src/large_image_tests.rs | 115 ++++++++++++++++++++++++ crates/render/src/lib.rs | 9 +- crates/render/src/lod.rs | 3 +- crates/render/src/lod_tests.rs | 1 + crates/render/src/paint.rs | 26 ++++-- crates/render/src/pattern.rs | 1 + docs/hardware-parity.md | 5 +- docs/roadmap.md | 3 +- 12 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 crates/render/src/large_image_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1abbd777..907afe3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Large images on the canvas (#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, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged | diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 679f6287..34bb5645 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -30,6 +30,9 @@ pub struct Ctx<'a, 'd> { pub skip: &'a [NodeId], /// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]). pub lod: bool, + /// A canvas preview at any zoom ([`crate::Options::lod`]): large images shown smaller than + /// their pixels draw from smaller copies (see [`crate::images::shown`]). + pub preview: bool, pub magnified_image_pixels: bool, /// The surfaces under the one being drawn into, when it is an isolated layer (for backdrops). pub under: Option<&'a Under<'a>>, @@ -130,7 +133,7 @@ fn pixels_when_magnified(prep: Prepared, device_scale: f64) -> Prepared { fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) { let scale = xf.determinant().abs().sqrt(); for p in paints { - let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue }; + let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time, ctx.preview) else { continue }; let prep = if ctx.magnified_image_pixels && matches!(p.kind, PaintKind::Image(_)) { pixels_when_magnified(prep, scale) } else { prep }; let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() }; if p.blend == BlendMode::PlusDarker { diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs index 9a51f405..2171927a 100644 --- a/crates/render/src/image_budget_tests.rs +++ b/crates/render/src/image_budget_tests.rs @@ -82,7 +82,7 @@ fn an_image_past_the_side_limit_is_drawn_downscaled() { } fn key(name: &str) -> images::Key { - (name.to_string(), [0; 7], ColorProfile::Srgb) + (name.to_string(), [0; 7], ColorProfile::Srgb, 0) } fn pixmap(side: u32) -> Arc { @@ -132,7 +132,7 @@ fn filter_and_profile_variants_count_against_one_budget() { let mut c = images::PixmapCache::default(); for (i, profile) in [ColorProfile::Srgb, ColorProfile::DisplayP3].into_iter().enumerate() { for f in 0..4 { - c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile), pixmap(10), 1000); + c.insert(("img".to_string(), [f, 0, 0, 0, 0, 0, i as i32], profile, 0), pixmap(10), 1000); assert!(c.bytes() <= 1000, "{} bytes held", c.bytes()); } } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index ba76846b..a4d8ead0 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -1,6 +1,10 @@ -//! Decoded image cache (content hash + filters + colour profile → premultiplied pixmap). Images -//! are converted from their own colour profile (see [`crate::image_profile`]; sRGB when they have -//! none) into the document's space, once per image and profile. +//! Decoded image cache (content hash + filters + colour profile + largest side → premultiplied +//! pixmap). Images are converted from their own colour profile (see [`crate::image_profile`]; sRGB +//! when they have none) into the document's space, once per image and profile. +//! +//! The canvas draws a large image shown smaller than its pixels from a copy scaled down to one of +//! [`DISPLAY_SIDES`] (see [`shown`]), so several of them stay decoded within the cache's budget +//! (#1483); exports and every other render draw from the image itself ([`get`]). //! //! Animated GIFs keep their original bytes like any image; their first frame is the image //! everywhere (canvas, exports). The presenter plays them: [`frame_at`] decodes every frame on @@ -13,7 +17,8 @@ use figcraft_color::{ColorProfile, ImageFilters, PaintKind}; use figcraft_doc::{Document, ImageData, NodeId}; use tiny_skia::Pixmap; -pub(crate) type Key = (String, [i32; 7], ColorProfile); +/// Image, filters, colour profile and the largest side it was decoded at. +pub(crate) type Key = (String, [i32; 7], ColorProfile, u32); /// The largest side of a decoded image: larger images are scaled down to fit. const MAX_SIDE: u32 = 16_384; @@ -22,9 +27,13 @@ const MAX_SIDE: u32 = 16_384; const MAX_DECODED_BYTES: u64 = 512 << 20; /// The most bytes of pixels each decoded-image cache holds (one image larger than this is held /// alone). -const MAX_CACHED_BYTES: u64 = 512 << 20; +pub(crate) const MAX_CACHED_BYTES: u64 = 512 << 20; /// The most entries each decoded-image cache holds. const MAX_CACHED_IMAGES: usize = 256; +/// The largest sides of the copies the canvas draws larger images from while they show smaller +/// (device pixels): two 16 000 × 10 000 images take 640 MB each in full, past the cache's budget +/// together, and 42 MB each at 4096 (168 MB at 8192). +pub const DISPLAY_SIDES: [u32; 2] = [4096, 8192]; /// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding /// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so @@ -73,6 +82,15 @@ impl PixmapCache { } } +/// The sides image `hash` is decoded at in the cache (any filters and profile), smallest first. +#[cfg(test)] +pub(crate) fn cached_sides(hash: &str) -> Vec { + let c = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut sides: Vec = c.map.keys().filter(|k| k.0 == hash).map(|k| k.3).collect(); + sides.sort_unstable(); + sides +} + fn cache() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); C.get_or_init(|| Mutex::new(PixmapCache::default())) @@ -182,11 +200,30 @@ fn premultiplied(rgba: &image::RgbaImage) -> Option { /// The decoded (and filtered) image for `hash` in the colour space `profile`, cached. pub fn get(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile) -> Option> { - let key = (hash.to_string(), fkey(filters), profile); + get_within(hash, data, filters, profile, MAX_SIDE) +} + +/// [`get`] for the canvas, where the image shows `side` device pixels on its longer side: an image +/// larger than one of [`DISPLAY_SIDES`] that holds `side` comes scaled down to that side (cached +/// apart), else as [`get`] gives it. The image's size is the one `data` records (none: the image). +pub fn shown(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, side: f64) -> Option> { + get_within(hash, data, filters, profile, display_side(data.map_or(0, |d| d.width.max(d.height)), side)) +} + +/// The largest side to decode an image `long` pixels on its longer side at, to show `side` device +/// pixels long: the smallest of [`DISPLAY_SIDES`] that holds `side` and is smaller than the image, +/// else the image's own ([`MAX_SIDE`]). +pub(crate) fn display_side(long: u32, side: f64) -> u32 { + DISPLAY_SIDES.into_iter().find(|s| f64::from(*s) >= side && *s < long).unwrap_or(MAX_SIDE) +} + +/// [`get`] scaled down to at most `max_side` wide and tall. +fn get_within(hash: &str, data: Option<&ImageData>, filters: &ImageFilters, profile: ColorProfile, max_side: u32) -> Option> { + let key = (hash.to_string(), fkey(filters), profile, max_side); if let Some(p) = cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { return Some(p); } - let (mut p, srgb) = decode_in(&data?.bytes, profile)?; + let (mut p, srgb) = decode_within(&data?.bytes, profile, max_side, MAX_DECODED_BYTES)?; // Images with their own profile are in `profile` already (sRGB → sRGB is the identity). finish(&mut p, filters, if srgb { profile } else { ColorProfile::Srgb }); let p = Arc::new(p); @@ -402,9 +439,9 @@ fn padded_cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(PixmapCache::default())) } -/// `pm` with a transparent 1-pixel border (cached). +/// `pm` with a transparent 1-pixel border (cached; a smaller copy of the image apart). pub fn padded(hash: &str, filters: &ImageFilters, profile: ColorProfile, pm: &Pixmap) -> Option> { - let key = (hash.to_string(), fkey(filters), profile); + let key = (hash.to_string(), fkey(filters), profile, pm.width().max(pm.height())); if let Some(p) = padded_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { return Some(p); } diff --git a/crates/render/src/large_image_tests.rs b/crates/render/src/large_image_tests.rs new file mode 100644 index 00000000..be3de57f --- /dev/null +++ b/crates/render/src/large_image_tests.rs @@ -0,0 +1,115 @@ +//! 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 { + 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 { + 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); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 321c467e..a357033d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -78,8 +78,9 @@ pub struct Options { /// Draw only this layer of the page, where it is (Dev Mode's focus view). pub solo: Option, /// A preview (the canvas): when zoomed out, approximate what's too small on screen to see in - /// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no - /// effect in outline mode. + /// detail, see [`lod`]; at any zoom, draw a large image shown smaller than its pixels from a + /// smaller copy (see [`images::shown`]). Off for everything that must be exact (exports, + /// thumbnails); no effect in outline mode. pub lod: bool, pub magnified_image_pixels: bool, /// A dot grid over the background, under the layers (a board's canvas; drawn only where the @@ -209,6 +210,7 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti outlined: &opts.outlined, skip: &opts.skip, lod, + preview: opts.lod, magnified_image_pixels: opts.magnified_image_pixels, under: None, time: 0.0, @@ -243,6 +245,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim outlined: &[], skip: &[], lod: false, + preview: false, magnified_image_pixels: false, under: None, time, @@ -496,6 +499,8 @@ mod image_shadow_tests; #[cfg(test)] mod jpeg_sampling_tests; #[cfg(test)] +mod large_image_tests; +#[cfg(test)] mod lod_tests; #[cfg(test)] mod magnified_image_tests; diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs index e330568f..aaa77b98 100644 --- a/crates/render/src/lod.rs +++ b/crates/render/src/lod.rs @@ -12,7 +12,8 @@ //! //! Only views at [`MAX_SCALE`] or below are approximated: 1:1 and closer previews are exact, and //! so are exports, thumbnails and every other render. Whatever LOD can't approximate faithfully -//! (image paints, masks) takes the exact path at any scale. +//! (image paints, masks) takes the exact path at any scale, except that previews draw a large image +//! shown smaller than its pixels from a smaller copy ([`crate::images::shown`]). use figcraft_color::{BlendMode, Paint, PaintKind}; use figcraft_doc::{Document, ListStyle, Node, NodeKind, StrokeAlign, TextAutoResize, TextData}; diff --git a/crates/render/src/lod_tests.rs b/crates/render/src/lod_tests.rs index f2c83dba..d7cd983c 100644 --- a/crates/render/src/lod_tests.rs +++ b/crates/render/src/lod_tests.rs @@ -176,6 +176,7 @@ fn exports_never_use_lod() { outlined: &[], skip: &[], lod: true, + preview: false, magnified_image_pixels: false, under: None, time: 0.0, diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index b83aa0cb..a6c05948 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind}; -use figcraft_doc::Document; +use figcraft_doc::{Document, ImageData}; use tiny_skia::{FilterQuality, GradientStop, Pixmap, Point, Shader, SpreadMode, Transform}; pub fn ts_color(c: Color) -> tiny_skia::Color { @@ -95,11 +95,12 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option> { /// (the resolution pattern tiles are drawn at). Returns `None` when the paint draws nothing /// (invisible, fully transparent, missing image or pattern source). pub fn prepare(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document) -> Option { - prepare_at(paint, w, h, scale, doc, 0.0) + prepare_at(paint, w, h, scale, doc, 0.0, false) } -/// [`prepare`] `time` seconds into animated GIFs (0: their first frame). -pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64) -> Option { +/// [`prepare`] `time` seconds into animated GIFs (0: their first frame); on the canvas (`preview`) +/// a large image shown smaller than its pixels draws from a smaller copy (see [`still`]). +pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64, preview: bool) -> Option { if !paint.visible || paint.opacity <= 0.0 { return None; } @@ -140,7 +141,7 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim let moving = crate::images::frame_at(&img.image, data, &img.filters, doc.color_profile, time); let pm = match &moving { Some(f) => f.clone(), - None => crate::images::get(&img.image, data, &img.filters, doc.color_profile)?, + None => still(img, data, w, h, scale, doc, preview)?, }; let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h); if img.scale_mode == ImageScaleMode::Tile { @@ -164,8 +165,23 @@ pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, tim } } +/// The pixmap image paint `img` draws in a `w` × `h` box at `scale` device pixels per unit: on the +/// canvas (`preview`) a copy no larger than the image shows ([`crate::images::shown`]), else the +/// image itself. Tiles keep the image: their size is in its pixels. +fn still(img: &ImagePaint, data: Option<&ImageData>, w: f64, h: f64, scale: f64, doc: &Document, preview: bool) -> Option> { + let (iw, ih) = data.map_or((0, 0), |d| (d.width, d.height)); + if preview && img.scale_mode != ImageScaleMode::Tile && iw > 0 && ih > 0 { + let t = image_transform(img, f64::from(iw), f64::from(ih), w, h); + let side = scale * f64::from(t.sx * t.sy - t.kx * t.ky).abs().sqrt() * f64::from(iw.max(ih)); + return crate::images::shown(&img.image, data, &img.filters, doc.color_profile, side); + } + crate::images::get(&img.image, data, &img.filters, doc.color_profile) +} + /// Whether image paint `img` paints its whole `w` × `h` box opaque: an image without transparent /// pixels that covers the box (or tiles it). +/// shortcut: always looks at the image itself (exact, also on the canvas), so a large image with +/// shadows still decodes in full there; pass the canvas scale here if that turns up (#1483). pub(crate) fn fills_box_opaque(doc: &Document, img: &ImagePaint, w: f64, h: f64) -> bool { let Some(pm) = crate::images::get(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile) else { return false }; (img.scale_mode == ImageScaleMode::Tile || covers_box(img, f64::from(pm.width()), f64::from(pm.height()), w, h)) diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index efa23e7b..172638ce 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -55,6 +55,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64 outlined: &[], skip: &[], lod: false, + preview: false, magnified_image_pixels: false, under: None, time, diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 20ceb2fb..76da477d 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -12,7 +12,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu | Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours | |---|---|---|---| -| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | +| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom, large images drawn from a copy no larger than they show (#1483); when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | | Effects (blurs, glass, noise) | GPU | CPU | in the above | | HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — | | Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Large images on the canvas (#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, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | minor | macOS quit confirmation (#857): winit's default Quit menu item (⌘Q) ended the process through AppKit's `terminate:` at once, so a dirty app died without asking anything. The app now installs the craft-libs app menu (craft-menu): its Quit sends the main window a close request — the same path as its close button — so the unsaved-changes question asks before anything is lost (`windows_tests::a_quit_with_unsaved_changes_waits_for_the_answer`; checked in the app on macOS). Like the close button it closes the main window only, so with several windows open each quit closes one. A second quit before the window draws again (two quits while it is minimised, when only the app's logic runs) counts once: before, it could quit the app over another window's unsaved changes (`windows_tests::quitting_twice_before_the_window_draws_closes_it_once`). Numbers unchanged | | 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged | | 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index d9bb404c..73bc16de 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Large images on the canvas (#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, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | | 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged | | 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged | | 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged |