diff --git a/crates/render/src/blobs.rs b/crates/render/src/blobs.rs index 99afde077..ab5d831a0 100644 --- a/crates/render/src/blobs.rs +++ b/crates/render/src/blobs.rs @@ -21,6 +21,7 @@ pub(crate) fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey, luts: (lut.as_ref().map_or(0, |l| l.1), 0), plates: 0, mip: 0, + tile: None, }; (key, lut.map(|l| l.0), id.size) } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 644325f4a..dbdbf0066 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -108,6 +108,8 @@ pub(crate) struct ImageKey { pub plates: u64, /// Halved this many times ([`half`]). pub mip: u8, + /// A [`TILE`]-pixel square of the pixels (column, row), when only part of them is made. + pub tile: Option<(u16, u16)>, } /// A blob's content key and whether it holds CMYK inks, worked out once per blob allocation, and @@ -413,6 +415,36 @@ fn mean(v: [u8; 4]) -> u8 { ((v.iter().map(|&c| u32::from(c)).sum::() + 2) / 4) as u8 } +/// The side of the squares whose colours are made one by one when only part of an image's +/// pixels show ([`ImageKey::tile`]): a large photo zoomed in on is separated, colorized or proofed +/// only where it shows. +pub(crate) const TILE: u32 = 1024; + +/// The `w`×`h` pixels of `pm` from (`x`, `y`). `None` unless they lie inside `pm`. +pub(crate) fn crop(pm: &Pixmap, x: u32, y: u32, w: u32, h: u32) -> Option { + let (pw, ph) = (u32::from(pm.width()), u32::from(pm.height())); + if w == 0 || h == 0 || x.checked_add(w)? > pw || y.checked_add(h)? > ph { + return None; + } + let (stride, x, w) = (pw as usize, x as usize, w as usize); + let rows = pm.data().chunks_exact(stride).skip(y as usize).take(h as usize); + let data: Vec = rows.flat_map(|row| row.get(x..x + w).unwrap_or_default().iter().copied()).collect(); + (data.len() == w * h as usize).then(|| Pixmap::from_parts(data, w as u16, h as u16)) +} + +/// `src` copied into `dst` with its top left at (`x`, `y`), as far as `dst` reaches. +pub(crate) fn paste(dst: &mut Pixmap, src: &Pixmap, x: usize, y: usize) { + let (dw, sw) = (usize::from(dst.width()), usize::from(src.width())); + let dst_rows = dst.data_mut().chunks_exact_mut(dw).skip(y); + for (to, from) in dst_rows.zip(src.data().chunks_exact(sw)) { + let row = to.get_mut(x..).unwrap_or_default(); + let n = row.len().min(from.len()); + if let (Some(to), Some(from)) = (row.get_mut(..n), from.get(..n)) { + to.copy_from_slice(from); + } + } +} + /// Pixels `pm` colorized with `rgb`: each pixel takes the color, as opaque as the pixel is dark /// (255 less its Rec. 709 luma) times its alpha, so white pixels are transparent. pub(crate) fn colorized(pm: &Pixmap, [r, g, b]: [u8; 3]) -> Pixmap { @@ -448,7 +480,7 @@ mod tests { use super::*; fn key(content: &str) -> ImageKey { - ImageKey { content: content.into(), adjust: None, grey: false, tint: None, ink: Ink::Display, luts: (0, 0), plates: 0, mip: 0 } + ImageKey { content: content.into(), adjust: None, grey: false, tint: None, ink: Ink::Display, luts: (0, 0), plates: 0, mip: 0, tile: None } } #[test] diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index c4e1d0bb7..dee93e903 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -395,11 +395,17 @@ pub(crate) fn cmyk_pixmap(blob: &ImageBlob, lut: &InkLut) -> Option { inks_pixmap(&blob.cmyk()?, lut) } +/// Whether image pixels `key` are a CMYK image's inks as shown (not adjusted or greyscale), so +/// they paint those inks ([`key_inks`]). +pub(crate) fn shows_own_inks(key: &ImageKey) -> bool { + key.luts.0 != 0 && key.adjust.is_none() && !key.grey +} + /// The ink amounts behind image pixels `key` when they are CMYK image `blob`'s inks as shown (not /// adjusted or greyscale): its own inks, halved as its pixels are to `key`'s mip /// ([`images::half_inks`]). `None` for other pixels. pub(crate) fn key_inks(key: &ImageKey, blob: Option<&ImageBlob>) -> Option { - if key.luts.0 == 0 || key.adjust.is_some() || key.grey { + if !shows_own_inks(key) { return None; } (0..key.mip).try_fold(blob?.cmyk()?, |inks, _| images::half_inks(&inks)) @@ -538,6 +544,7 @@ mod tests { luts: (0, 0), plates: 0, mip: 0, + tile: None, }; let pm = Arc::new(Pixmap::from_parts(vec![PremulRgba8 { r: 204, g: 102, b: 51, a: 255 }, PremulRgba8 { r: 102, g: 51, b: 26, a: 128 }], 2, 1)); diff --git a/crates/render/src/renderer/images.rs b/crates/render/src/renderer/images.rs index 0f22fbae3..a604481dd 100644 --- a/crates/render/src/renderer/images.rs +++ b/crates/render/src/renderer/images.rs @@ -71,45 +71,48 @@ impl Renderer { } } } - kept + kept.map(Shown::whole) } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, tint, unit)).flatten() { - Some(pm) + Some(Shown::whole(pm)) } else { self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| { // Shrunk first, so what is made of the pixels below is made of the ones drawn: a // 141-megapixel photo is separated at the size it shows, not at its own (#4155). let (key, pm) = if mips { image_mip(key, pm, unit) } else { (key, pm) }; let blob = f.doc.image_blob(im); - let (key, pm) = match tint { - Some(rgb) => colorized_pixmap(key, &pm, rgb), - None => (key, pm), - }; - // A colorized image is already in the frame's ink ([`image_tint`]), and with - // Separations Preview its color is already the visible plates' (`proof::prepare`). - let (key, pm) = if outline || tint.is_some() { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, blob, true) }; - match f.plates.filter(|_| !outline && tint.is_none()) { - Some(lut) => proof::plate_image(lut, &key, &pm, blob, true).1, - None => pm, + // Zoomed in on it, only the tiles in view. + if let Some(window) = in_view(f, im, &key, &pm, outline, tint) { + return window_pixmap(f, &key, &pm, window, outline, tint, blob); } + Shown::whole(shown_pixmap(f, key, pm, outline, tint, blob)) }) }; - if let Some(pm) = shown { - let sx = im.width as f64 / pm.width().max(1) as f64; - let sy = im.height as f64 / pm.height().max(1) as f64; + if let Some(Shown { pm, level: (lw, lh), at: (ax, ay) }) = shown { + // The pixels drawn are `pm`, from (`ax`, `ay`) of the `lw`×`lh` pixels of the level. + let sx = im.width as f64 / f64::from(lw.max(1)); + let sy = im.height as f64 / f64::from(lh.max(1)); + let drawn = Affine::scale_non_uniform(sx, sy) * Affine::translate((f64::from(ax), f64::from(ay))); + let part = Rect::new( + f64::from(ax) * sx, + f64::from(ay) * sy, + f64::from(ax + u32::from(pm.width())) * sx, + f64::from(ay + u32::from(pm.height())) * sy, + ) + .intersect(rect); let quality = if f.opts.smooth_images && !pixelated { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; let sampler = peniko::ImageSampler { quality, ..Default::default() }; ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler }); match area { Some((bp, rule)) => { ctx.set_transform(f.view); - ctx.set_paint_transform(im.xf * Affine::scale_non_uniform(sx, sy)); + ctx.set_paint_transform(im.xf * drawn); ctx.set_fill_rule(fill_rule(*rule)); ctx.fill_path(bp); } None => { ctx.set_transform(f.view * im.xf); - ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); - ctx.fill_rect(&rect); + ctx.set_paint_transform(drawn); + ctx.fill_rect(&part); } } ctx.reset_paint_transform(); @@ -307,6 +310,117 @@ impl Renderer { } } +/// Image pixels [`Renderer::draw_image_in`] draws: `pm`, from (`at`) of the `level` pixels of the +/// mip drawn (all of them, or the tiles in view). +struct Shown { + pm: Arc, + level: (u32, u32), + at: (u32, u32), +} + +impl Shown { + /// All of the pixels of a level. + fn whole(pm: Arc) -> Self { + let level = (u32::from(pm.width()), u32::from(pm.height())); + Self { pm, level, at: (0, 0) } + } +} + +/// Image pixels `pm` (kept as `key`) as [`Renderer::draw_image_in`] draws them: in greyscale with +/// `grey` (the outline view's) or colorized with `tint` as they are, else painted with the frame's +/// ink and shown through its Separations Preview plates. Kept with the images under +/// [`shown_key`], and marked used there. +fn shown_pixmap( + f: &Frame, + key: images::ImageKey, + pm: Arc, + grey: bool, + tint: Option<[u8; 3]>, + blob: Option<&vectorcraft_doc::ImageBlob>, +) -> Arc { + let (key, pm) = match tint { + Some(rgb) => colorized_pixmap(key, &pm, rgb), + None => (key, pm), + }; + // A colorized image is already in the frame's ink ([`image_tint`]), and with Separations + // Preview its color is already the visible plates' (`proof::prepare`). + if grey || tint.is_some() { + return pm; + } + let (key, pm) = ink::ink_image(&key, &pm, f.ink, blob, true); + match f.plates { + Some(lut) => proof::plate_image(lut, &key, &pm, blob, true).1, + None => pm, + } +} + +/// The tiles of image pixels `pm` (kept as `key`, the level of image `im` drawn) in the frame's +/// view, as a range of columns and rows, when the pixels' colours are made again +/// ([`shown_pixmap`]: colorized, separated or proofed), more than one tile of them is made, and +/// some are out of view. `None` otherwise: all the pixels are made, as they are for a CMYK image's +/// own inks (made from the whole image's). +fn in_view( + f: &Frame, + im: &vectorcraft_doc::ImageObject, + key: &images::ImageKey, + pm: &Pixmap, + grey: bool, + tint: Option<[u8; 3]>, +) -> Option<([u32; 2], [u32; 2])> { + let made = !grey && (tint.is_some() || ink::ink_key(key, f.ink) != *key || f.plates.is_some()); + let (w, h) = (u32::from(pm.width()), u32::from(pm.height())); + if !made || (w <= images::TILE && h <= images::TILE) || ink::shows_own_inks(key) { + return None; + } + // The view in the level's pixels, a couple wider for smoothing at its edge. + let to_level = (im.xf * Affine::scale_non_uniform(im.width as f64 / f64::from(w), im.height as f64 / f64::from(h))).inverse(); + let v = f.visible; + let corners = [(v.x0, v.y0), (v.x1, v.y0), (v.x1, v.y1), (v.x0, v.y1)].map(|c| to_level * vectorcraft_geom::Point::from(c)); + let (x0, x1) = corners.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| (a.min(p.x), b.max(p.x))); + let (y0, y1) = corners.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| (a.min(p.y), b.max(p.y))); + if ![x0, x1, y0, y1].iter().all(|v| v.is_finite()) { + return None; + } + let tiles = |lo: f64, hi: f64, n: u32| { + let last = n.saturating_sub(1) / images::TILE; + let at = |v: f64| ((v.clamp(0.0, f64::from(n.saturating_sub(1))) as u32) / images::TILE).min(last); + ([at(lo - 2.0), at(hi + 2.0)], last) + }; + let ((cols, last_col), (rows, last_row)) = (tiles(x0, x1, w), tiles(y0, y1, h)); + // All of them in view: made whole, as without tiles. + (cols != [0, last_col] || rows != [0, last_row]).then_some((cols, rows)) +} + +/// The tiles `cols` × `rows` of image pixels `pm` (kept as `key`) as [`shown_pixmap`] makes them, +/// each made and kept on its own ([`images::ImageKey::tile`]), in one pixmap. +fn window_pixmap( + f: &Frame, + key: &images::ImageKey, + pm: &Pixmap, + (cols, rows): ([u32; 2], [u32; 2]), + grey: bool, + tint: Option<[u8; 3]>, + blob: Option<&vectorcraft_doc::ImageBlob>, +) -> Shown { + let (w, h) = (u32::from(pm.width()), u32::from(pm.height())); + let t = images::TILE; + let at = (cols[0] * t, rows[0] * t); + let size = (((cols[1] + 1) * t).min(w) - at.0, ((rows[1] + 1) * t).min(h) - at.1); + let mut window = Pixmap::new(size.0 as u16, size.1 as u16); + for (col, row) in (rows[0]..=rows[1]).flat_map(|r| (cols[0]..=cols[1]).map(move |c| (c, r))) { + let tile_key = images::ImageKey { tile: Some((col as u16, row as u16)), ..key.clone() }; + let (x, y) = (col * t, row * t); + let made = lookup(&shown_key(f, tile_key.clone(), grey, tint), true).or_else(|| { + let part = images::crop(pm, x, y, t.min(w - x), t.min(h - y))?; + Some(shown_pixmap(f, tile_key, Arc::new(part), grey, tint, blob)) + }); + if let Some(made) = made { + images::paste(&mut window, &made, (x - at.0) as usize, (y - at.1) as usize); + } + } + Shown { pm: Arc::new(window), level: (w, h), at } +} + /// The key [`Renderer::draw_image_in`] gives the pixels it draws of image pixels `key`: in /// greyscale with `grey` (the outline view's) or colorized with `tint` as they are, else painted /// with the frame's ink and shown through its Separations Preview plates. diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 976709e75..d8b267cb9 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -2494,3 +2494,53 @@ fn a_reduced_image_on_the_ink_planes_shows_its_average() { assert!(px.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "pixel {i}: {px:?}, the full frame averages {want:?}"); } } + +/// A `w`×`h` PNG with a different colour in every pixel, so a pixel drawn from the wrong place +/// shows; its pixels depend on `seed` (each test its own image: the store is shared). +fn varied_png(w: u32, h: u32, seed: u8) -> Vec { + let img = image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([(x * 5 + y) as u8, (x / 3 + y * 7) as u8, (x ^ y) as u8 ^ seed, 255])); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// #4155: an image drawn larger than a tile with its colours made again (here separated into ink +/// planes) has them made only for the tiles in view. Zoomed in on a 141-megapixel photo, the whole +/// photo was separated for every view, taking seconds each time. +#[test] +fn a_zoomed_in_image_is_separated_only_where_it_shows() { + let _fonts = fonts_steady(); + let (w, h) = (2 * images::TILE + 100, 40); + let d = cmyk_doc_with("wide", ImageBlob::new("image/png", varied_png(w, h, 1)), w, h); + let on = RenderOptions { image_mips: true, ..Default::default() }; + let _ = Renderer::new().render_region_inks(&d, Rect::new(0.0, 0.0, 300.0, 40.0), 1.0, &on); + let full = blob_key(&d.images["wide"]).0; + let s = images::store(); + for plane in [Ink::Cmy, Ink::K] { + assert!(s.peek(&ink::ink_key(&full, plane)).is_none(), "the whole {plane:?} plane was made"); + let tile = |col: u16| ink::ink_key(&images::ImageKey { tile: Some((col, 0)), ..full.clone() }, plane); + assert!(s.peek(&tile(0)).is_some(), "the {plane:?} plane of the tile in view was made"); + assert!(s.peek(&tile(1)).is_none() && s.peek(&tile(2)).is_none(), "tiles out of view were made"); + } +} + +/// An image drawn from tiles draws the pixels it draws whole, across the tiles' edges too. +#[test] +fn a_tiled_image_draws_what_the_whole_image_draws() { + let _fonts = fonts_steady(); + let (w, h) = (2 * images::TILE + 100, 40); + let d = cmyk_doc_with("seams", ImageBlob::new("image/png", varied_png(w, h, 2)), w, h); + let on = RenderOptions { image_mips: true, ..Default::default() }; + // Across the edge between the first two tiles. + let (x0, x1) = (f64::from(images::TILE) - 150.0, f64::from(images::TILE) + 150.0); + let drawn = Renderer::new().render_region_inks(&d, Rect::new(x0, 0.0, x1, 40.0), 1.0, &on); + let (key, full) = crate::blobs::blob_pixmap(&d.images["seams"], false).unwrap(); + let plane = |ink| ink::ink_image(&key, &full, ink, d.images.get("seams"), false).1; + let bytes = |pm: &Pixmap| pm.data().iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect::>(); + let whole = ink::amounts(&bytes(&plane(Ink::Cmy)), &bytes(&plane(Ink::K))); + for (i, got) in drawn.as_chunks::<4>().0.iter().enumerate() { + let (x, y) = (x0 as usize + i % 300, i / 300); + let want = &whole[(y * w as usize + x) * 4..][..4]; + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(*b) <= 1), "pixel ({x}, {y}): {got:?}, whole {want:?}"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 2f406c3cb..027cfdee7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -649,14 +649,16 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G15 Performance budgets and a GPU renderer +- **Improved (#4155):** an image zoomed in on has its colours separated, colorized or proofed only for the + 1,024-pixel tiles in view: zooming in to 100% on the file of #4155 went from 8.6 s to 0.05 s (measured in the app, + M3 Pro, one run). A CMYK image painting its own inks is still made whole. - **Improved (#4155):** a document's first frame renders on the render worker, not the UI thread: opening the file of #4155 kept the UI thread busy for 8.7 s at a stretch, now at most 64 ms, and a 50,000-path SVG 1.4 s, now 62 ms (measured in the app, M3 Pro, one run each). A light document's frame, ready within 16 ms, still shows at once. - **Improved (#4155):** the canvas shrinks a reduced image to the mip it draws before separating it into ink planes, colorizing it or proofing it, so that work is done at the size the image shows, not at its own. On the 141-megapixel image of #4155 the first frame went from 9.2 s to 1.6 s and the sharp frame after a pan from 3.9 s - to 8 ms (measured in the app, M3 Pro, one run each). Still to do: at 100% zoom or more an image is still separated - whole; tiles would bound that by the view. + to 8 ms (measured in the app, M3 Pro, one run each). - **Improved (2026-11-02):** the renderer keeps a compound path's assembled path with the node, as it keeps a plain path's, instead of assembling its members again in every frame; the path is assembled into a buffer sized up front. Unused geometry entries are dropped after a few frames however few there are (before, only past 1,024 entries), so @@ -767,6 +769,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | G15: an image zoomed in on is separated, colorized or proofed only for the tiles in view (#4155); 100% zoom on that file 8.6 s → 0.05 s. Estimate unchanged. | | 2026-11-05 | minor | G15: a document's first frame renders on the render worker (#4155); the longest UI-thread stall opening that file went from 8.7 s to 64 ms. Estimate unchanged. | | 2026-11-05 | minor | G15: a reduced image is shrunk before it is separated into ink planes (#4155); first frame 9.2 s → 1.6 s, sharp frame after a pan 3.9 s → 8 ms on that file. Estimate unchanged. | | 2026-11-05 | minor | G7: document tabs are dragged along the tab strip to reorder them (`document.move`, #5663). Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0994d967c..0a0061b74 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Canvas: an image drawn larger than 1,024 pixels at the size it shows, with its colours made again (separated into ink planes, colorized or shown through Separations Preview), has them made only for the 1,024-pixel tiles in view, each kept for later frames (#4155). Zooming in to 50%, 100% and 200% on the file of #4155 went from 8.6–9.0 s to 0.78 s, 0.05 s and 0.05 s (measured, M3 Pro, one run each), with the same pixels. Scores and estimates unchanged. | | 2026-11-05 | minor | Canvas: a document's first frame (opened, or its tab chosen) renders on the render worker instead of the UI thread; the window shows the document's paper and artboards until the art lands, and a frame ready within 16 ms shows at once. Opening the file of #4155 kept the UI thread busy for 8.7 s at a stretch, now at most 64 ms; a 50,000-path SVG 1.4 s, now 62 ms (measured, M3 Pro, one run each). Scores and estimates unchanged. | | 2026-11-05 | minor | Canvas: an image drawn reduced in a CMYK document is shrunk to the size it shows before it is separated into ink planes, not separated at full size and then shrunk (#4155). On the 141-megapixel image of #4155 the first frame went from 9.2 s to 1.6 s, and the sharp frame after a pan from 3.9 s to 8 ms (measured, M3 Pro, one run each). The same pixels show within 5 levels; fine detail such as 1-pixel lines now shows its true average. Scores and estimates unchanged. | | 2026-11-05 | minor | Effect dialogs: a hex colour parameter (Outer Glow's Color, Inner Glow's, Drop Shadow's) shows a swatch beside the field that follows typed hex values; a double-click opens the Color Picker over the dialog on that colour, and its OK fills the field while Cancel comes back unchanged (#5365); scores and estimates unchanged. | @@ -1367,6 +1368,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Dragging objects with raster effects when zoomed in (#2558):** during a drag with a tool on the canvas, the renderer draws filtered content (Gaussian Blur, Feather and the Photoshop-style filters) within 1,000,000 pixels, at a lower resolution where it would need more, unless it can draw the content from an earlier frame's raster. In the desktop app it does the same for drop shadows, outer glows and inner glows. No raster made that way is kept. The frame after the drag renders them as a still frame does, and a dialog's preview, exports and printing render as before. `zoomed_drag_perf` times a drag's frames at 100% to 6400%. - **Dragging other art over a wide blur (#2558):** an effect raster too large to keep whole (a wide blur, a shadow or glow, or any blur zoomed in) is cropped to what can show in the view, or made at a lower resolution and resampled to the view's pixels, and kept while the view stays the same, so dragging other art over the object draws its effects from the still frame instead of re-rendering them at the drag's lower resolution. A drag stays on the render worker once the worker drew the frame shown, since only that renderer holds the rasters. `tests_viewraster` covers each effect, and `a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame` the canvas on and off the worker. Not yet: a worker frame shows one display frame after the overlays, and the first frame of an interaction can land on the renderer that has not made the rasters yet (follow-up: render every canvas frame on the worker, waiting briefly for fast ones). - **Opening a heavy document (#4155):** the canvas renders a document's first frame (when it opens, or its tab is chosen) on the render worker, as it renders the frames of a slow document, instead of on the UI thread, where a heavy one froze the window until it was done. The UI thread waits for it up to 16 ms (`FIRST_FRAME_GRACE`), so a light document's art shows in the same frame as before, without a frame of bare paper; otherwise the document's paper and artboards show, the window stays live, and the art shows when it lands. The web build, which has no worker, renders it as before. Measured on an M3 Pro, one run each, from launch with the file: the file of #4155 kept the UI thread busy for 8.7 s at a stretch, now at most 64 ms (its art lands as before, after about 9 s of rendering); a 50,000-path SVG 1.4 s, now 62 ms. `a_documents_first_frame_renders_on_the_worker` and `the_render_worker_waits_for_a_frame_only_as_long_as_asked` (`crates/ui-egui/src/canvas/tests.rs`). +- **Zooming in on large images (#4155):** when an image is drawn from pixels (its mip, or its full pixels) more than 1,024 pixels on a side, and their colours are made again for the frame (separated into a CMYK document's ink planes, colorized, or shown through Separations Preview's plates), only the 1,024-pixel tiles in view are made, each kept with the images under its own key, and the tiles in view are drawn as one pixmap so their edges don't show. The colours are made pixel by pixel, so the result is the same as making them for the whole image (`a_tiled_image_draws_what_the_whole_image_draws`, across a tile edge); `a_zoomed_in_image_is_separated_only_where_it_shows` checks the tiles out of view are not made. A CMYK image painting its own inks is still made whole. On the file of #4155 (an 11,339 × 12,424 photo, M3 Pro, one run each), the sharp frame after zooming in to 50%, 100% and 200% went from 9.0, 8.6 and 8.7 s to 0.78, 0.05 and 0.05 s, and the app's memory after zooming from 4.4 GB to 2.6 GB; the screens at 100% match (p99 difference 0). - **Large images in CMYK documents (#4155):** the canvas draws a reduced image from a mip (halved while still at least as fine as the screen) and, in a CMYK document, separates that mip into its ink planes, colorizes it or shows it through Separations Preview's plates, instead of doing that to the full pixels and shrinking the result. A CMYK image's own inks are halved with its pixels, so it still paints them. Exports draw without mips and are unchanged. On the file of #4155 (one 11,339 × 12,424 image), measured on an M3 Pro under load, one run each: first frame 9.2 s → 1.6 s, opening to the first frame 11.7 s → 3.9 s, the sharp frame after a pan 3.9 s → 8 ms and after a zoom 3.5 s → 0.16 s; the fitted view's pixels differ by at most 5 levels (p99 2). `a_reduced_image_is_separated_from_its_mip_not_its_full_pixels`, `a_reduced_cmyk_image_paints_its_own_inks` and `a_reduced_image_on_the_ink_planes_shows_its_average` (`crates/render/src/tests/memory.rs`) cover the order, the inks and the colour: 1-pixel black and white lines show as the mid grey they average to, where separating first showed them 44 levels darker. - **Panning over a cropped effect raster:** when a pan makes a shadow, glow or filtered content raster cropped to the view again at the same zoom and rotation, the renderer makes it over a margin around the view too (up to half the view's longer side, within 8.4 million pixels), and later pans that keep the view inside that margin draw the raster instead of making it again. Zooming and the first frame at a zoom make the raster without the margin. The canvas pans by whole device pixels: the wheel, a trackpad, a touch screen and the scroll bars add the part of a pixel left over to the next scroll of the same view, and Hand tool and scroll bar thumb drags round the distance dragged. A Drop Shadow or Outer Glow raster holds the shadow or glow of group members outside the view, so a pan that shows a member draws its shadow or glow from the raster. `pans_within_a_cropped_rasters_margin_draw_it_kept` covers Drop Shadow, Gaussian Blur and Feather on a large image at 100%. `a_groups_shadow_raster_holds_the_members_outside_the_view` covers a group's members, `fractional_scrolls_moved_in_whole_pixels_draw_a_cropped_raster_kept` covers scrolls by fractions of a pixel, and `scrolls_and_pans_move_the_view_by_whole_device_pixels` covers the canvas.