Merge pull request #4256 from dotCooCoo/fix/canvas-fills-uncovered-area

Canvas: a pan or zoom-out fills the uncovered area with low-resolution art
This commit is contained in:
BFlat
2026-10-29 13:49:21 +07:00
committed by GitHub
13 changed files with 513 additions and 11 deletions
+9 -5
View File
@@ -367,11 +367,12 @@ impl Renderer {
/// Rasterize or De-Interlace anywhere in the content follows), the knockout `n` inherits and,
/// while `n` holds the isolated group, isolation mode; the pattern definitions, symbols and
/// brush library it reads; for images and placed documents their pixels and files, the
/// layer's Dim Images and the options they are drawn with; for type the fonts, whether
/// overprinting shows ([`crate::proof::overprints`]) and Document Setup's substitution
/// highlights; and the plug-ins installed, when an object in it has a plug-in effect, and
/// when it shows symbol or pattern art and an object in the document's symbol or pattern art
/// has one.
/// layer's Dim Images and the options they are drawn with (with
/// [`crate::RenderOptions::kept_images_only`], also which pixels and undecodable notes the
/// image store holds); for type the fonts, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
/// plug-ins installed, when an object in it has a plug-in effect, and when it shows symbol or
/// pattern art and an object in the document's symbol or pattern art has one.
pub(crate) fn raster_inputs(&mut self, f: &Frame, n: &Node, reads: Reads) -> Inputs {
let doc = self.doc_inputs(f.doc);
let mut h = DefaultHasher::new();
@@ -395,6 +396,9 @@ impl Renderer {
(doc.images, self.dim_images.map(f32::to_bits), o.smooth_images, o.image_mips, o.trace_views, o.progressive_placed).hash(&mut h);
o.trace_source.as_deref().hash(&mut h);
o.mark_undecoded_images.hash(&mut h);
// With only kept pixels drawn, a raster is made again once the store holds more pixels
// or notes an image as undecodable.
o.kept_images_only.then(|| crate::images::store().added()).hash(&mut h);
}
if reads.brushes {
self.brushes.library_key(f.doc, self.stamp).hash(&mut h);
+14 -2
View File
@@ -117,6 +117,9 @@ pub(crate) struct ImageStore {
budget: usize,
/// Request counter: the least recently used entries go first.
tick: u64,
/// How many entries and undecodable notes have been added: it changes whenever the store
/// holds pixels, or notes an image as undecodable, that it did not before.
added: u64,
/// [`BlobId`]s per blob byte buffer, by address (the `Weak` keeps it from being reused).
ids: HashMap<usize, (Weak<Vec<u8>>, BlobId)>,
/// The content keys of images whose pixels can't be decoded ([`Undecoded::Unreadable`]), so
@@ -132,7 +135,12 @@ fn pixmap_bytes(pm: &Pixmap) -> usize {
impl ImageStore {
pub(crate) fn new(budget: usize) -> Self {
Self { entries: HashMap::new(), bytes: 0, budget, tick: 0, ids: HashMap::new(), undecodable: HashMap::new() }
Self { entries: HashMap::new(), bytes: 0, budget, tick: 0, added: 0, ids: HashMap::new(), undecodable: HashMap::new() }
}
/// How many entries and undecodable notes have been added to the store.
pub(crate) fn added(&self) -> u64 {
self.added
}
/// Whether the image with content key `content` is known not to decode.
@@ -146,7 +154,10 @@ impl ImageStore {
if self.undecodable.len() >= 1024 {
self.undecodable.clear();
}
self.undecodable.entry(content).or_insert(false);
if !self.undecodable.contains_key(&content) {
self.added += 1;
self.undecodable.insert(content, false);
}
}
/// The pixels of `key`, marked as just used.
@@ -181,6 +192,7 @@ impl ImageStore {
return e.pm.clone();
}
self.bytes += pixmap_bytes(&pm);
self.added += 1;
self.entries.insert(key, Entry { pm: pm.clone(), used: tick });
self.evict();
pm
+9
View File
@@ -111,6 +111,14 @@ pub struct RenderOptions {
/// Screen views: an image whose pixels can't be decoded draws as a light gray box crossed
/// from corner to corner. Off, it draws nothing (exports, which report it instead).
pub mark_undecoded_images: bool,
/// Screen views that must render quickly (the canvas's low-resolution fill): an image object
/// draws from pixels the shared store already holds for it: the mip
/// [`RenderOptions::image_mips`] would draw if the store holds it, else the nearest finer one,
/// else the nearest coarser one. An image object the store holds no pixels of draws as a
/// light gray box, not crossed, unless the store notes it as undecodable: then it draws as it
/// does with this option off ([`RenderOptions::mark_undecoded_images`]). No image object is decoded and
/// no mip of one is made. Off, image objects are decoded as they are drawn.
pub kept_images_only: bool,
/// Screen views while an interaction is in progress (a drag, a dialog's preview): an object
/// whose content only moved since an earlier frame draws its cached drop shadows, outer glows
/// and filtered content from the rasters made then, at the nearest whole pixel, without
@@ -207,6 +215,7 @@ impl Default for RenderOptions {
supersample: false,
image_mips: false,
mark_undecoded_images: false,
kept_images_only: false,
interacting: false,
dragging: false,
rich_black: false,
+49 -1
View File
@@ -29,8 +29,31 @@ impl Renderer {
// pixelated image is drawn from its full pixels: a mip averages them.
let mips = f.opts.image_mips && !pixelated;
let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64);
// With `kept_images_only`, an image still to be decoded: it draws as a light box, not as
// an undecoded image.
let mut pending = false;
let shown = if outline && !f.doc.setup.outline_images {
None
} else if f.opts.kept_images_only {
let kept = self.nearest_kept(f, &im.key, im.pixels.as_deref(), outline, unit);
pending = kept.is_none() && !self.noted_undecodable(f.doc, &im.key, im.pixels.as_deref());
if pending {
// Not decoded: a light box where the image will be, as a placed document's
// bitmap shows while it is made (`placed_document`).
ctx.set_paint(f.ink.fixed([128, 128, 128, 40]));
match area {
Some((bp, rule)) => {
ctx.set_transform(f.view);
ctx.set_fill_rule(fill_rule(*rule));
ctx.fill_path(bp);
}
None => {
ctx.set_transform(f.view * im.xf);
ctx.fill_rect(&rect);
}
}
}
kept
} else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() {
Some(pm)
} else {
@@ -74,7 +97,7 @@ impl Renderer {
}
}
}
} else if f.opts.mark_undecoded_images && (!outline || f.doc.setup.outline_images) {
} else if f.opts.mark_undecoded_images && !pending && (!outline || f.doc.setup.outline_images) {
self.draw_undecoded_mark(ctx, f, im, area);
}
if outline {
@@ -208,4 +231,29 @@ impl Renderer {
}
images::store().get(&images::ImageKey { mip: level, ..key })
}
/// The pixels of image `key` (or `own`) that [`RenderOptions::kept_images_only`] draws through
/// `m`, in greyscale with `grey` (the outline view's): of the mips the store holds, the one
/// [`image_mip`] would draw, else the nearest finer one (the full pixels last), else the
/// nearest coarser one. `None` when the store holds none. Nothing is marked used.
fn nearest_kept(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option<Arc<Pixmap>> {
let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?;
let key = if grey { key } else { ink::ink_key(&key, f.ink) };
let level = mip_level(m, w, h);
let s = images::store();
let at = |l: u8| s.peek(&images::ImageKey { mip: l, ..key.clone() });
(0..=level).rev().find_map(at).or_else(|| (level + 1..=16).find_map(at))
}
/// Whether the shared store notes image `key` (or `own`), or the image an adjusted image is
/// made from, as one whose pixels can't be decoded. Nothing is decoded.
fn noted_undecodable(&self, doc: &Document, key: &str, own: Option<&vectorcraft_doc::ImageBlob>) -> bool {
match own.or_else(|| doc.images.get(key)) {
Some(blob) => {
let content = blob_key(blob).0.content;
images::store().is_undecodable(&content)
}
None => self.adjusts.get(key).is_some_and(|(src, ..)| self.noted_undecodable(doc, src, None)),
}
}
}
+91
View File
@@ -6,6 +6,7 @@
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use crate::blobs::blob_key;
use crate::tests_fontchange::fonts_steady;
use crate::*;
@@ -133,6 +134,96 @@ fn kept_mips_draw_without_decoding_the_full_pixels_again() {
assert!(frame.pixels == Renderer::new().render(&moved, 32, 16, quarter, &on).pixels);
}
/// With `kept_images_only` (the canvas's low-resolution fill), an image the store holds no pixels
/// of draws as a light gray box and is not decoded. Once decoded, it draws from the pixels the
/// store holds: the full pixels, when the mip for its size is not kept, and that mip is not made.
#[test]
fn kept_images_only_draw_from_the_store_without_decoding() {
let _fonts = fonts_steady();
let d = doc_with_image("img", png(64, 64, 105), 64, 64);
let kept = RenderOptions { kept_images_only: true, image_mips: true, ..Default::default() };
let quarter = Affine::scale(0.25);
let full = blob_key(&d.images["img"]).0;
let mut r = Renderer::new();
let boxed = r.render(&d, 16, 16, quarter, &kept);
assert!(images::store().peek(&full).is_none(), "not decoded");
assert!(boxed.pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 40), "a light box where the image is");
// A frame at full size decodes the full pixels and makes no mip.
let _ = Renderer::new().render(&d, 64, 64, Affine::IDENTITY, &RenderOptions { image_mips: true, ..Default::default() });
let quarter_mip = images::ImageKey { mip: 2, ..full.clone() };
assert!(images::store().peek(&full).is_some() && images::store().peek(&quarter_mip).is_none());
let drawn = r.render(&d, 16, 16, quarter, &kept);
assert!(images::store().peek(&quarter_mip).is_none(), "no mip made");
assert!(drawn.pixels == Renderer::new().render(&d, 16, 16, quarter, &RenderOptions::default()).pixels, "the full pixels, drawn small");
}
/// With `kept_images_only`, a blur on an image the store holds no pixels of blurs the light gray
/// box. Once the image is decoded, the same renderer blurs the image's pixels and does not draw
/// the blurred box again from its raster cache.
#[test]
fn kept_images_only_make_effect_rasters_again_once_the_image_is_decoded() {
let _fonts = fonts_steady();
let mut d = Document::new(64.0, 64.0);
d.images.insert("img".into(), ImageBlob::new("image/png", png(64, 64, 107)));
let (id, layer) = (d.alloc_id(), d.layers[0].id);
let im = ImageObject { key: "img".into(), width: 64, height: 64, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let mut n = Node::new(id, NodeKind::Image(Box::new(im)));
n.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 2.0})));
d.insert(Some(layer), 0, n).unwrap();
let kept = RenderOptions { kept_images_only: true, image_mips: true, dragging: true, ..Default::default() };
let quarter = Affine::scale(0.25);
let full = blob_key(&d.images["img"]).0;
let mut r = Renderer::new();
let boxed = r.render(&d, 16, 16, quarter, &kept);
assert!(images::store().peek(&full).is_none(), "not decoded");
let _ = Renderer::new().render(&d, 64, 64, Affine::IDENTITY, &RenderOptions { image_mips: true, ..Default::default() });
let drawn = r.render(&d, 16, 16, quarter, &kept);
assert!(drawn.pixels != boxed.pixels, "the image's pixels, not the box");
assert!(drawn.pixels == Renderer::new().render(&d, 16, 16, quarter, &kept).pixels, "as a new renderer draws it");
}
/// With `kept_images_only`, an image still to be decoded draws as a light gray box, not crossed
/// with `mark_undecoded_images`. An image the store notes as undecodable is not tried again and
/// draws without that box: as it draws with the option off,
/// nothing, or the crossed box with `mark_undecoded_images`. A blur on such an image, cached while
/// the image was still to be decoded, is made again once the image is noted.
#[test]
fn kept_images_only_leave_out_an_image_noted_as_undecodable() {
let _fonts = fonts_steady();
let mut cut = png(64, 64, 109);
cut.truncate(cut.len() / 2);
let d = doc_with_image("cut", cut.clone(), 64, 64);
let mut blurred = Document::new(64.0, 64.0);
blurred.images.insert("cut".into(), ImageBlob::new("image/png", cut));
let (id, layer) = (blurred.alloc_id(), blurred.layers[0].id);
let im = ImageObject { key: "cut".into(), width: 64, height: 64, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let mut n = Node::new(id, NodeKind::Image(Box::new(im)));
n.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 2.0})));
blurred.insert(Some(layer), 0, n).unwrap();
let kept = RenderOptions { kept_images_only: true, image_mips: true, dragging: true, ..Default::default() };
let marked = RenderOptions { mark_undecoded_images: true, ..kept.clone() };
let quarter = Affine::scale(0.25);
let attempts = || crate::paint::DECODE_ATTEMPTS.with(std::cell::Cell::get);
let tried = attempts();
let (mut r, mut rb) = (Renderer::new(), Renderer::new());
let boxed = r.render(&d, 16, 16, quarter, &kept);
assert!(boxed.pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 40), "a light box while it is still to be decoded");
assert!(r.render(&d, 16, 16, quarter, &marked).pixels == boxed.pixels, "with mark_undecoded_images too, and not crossed");
let blurred_box = rb.render(&blurred, 16, 16, quarter, &kept);
assert!(blurred_box.pixels.iter().any(|&b| b > 0), "the blurred box");
assert_eq!(attempts(), tried, "not tried");
// The frame for the view tries it once and notes it.
let _ = Renderer::new().render(&d, 64, 64, Affine::IDENTITY, &RenderOptions { image_mips: true, ..Default::default() });
let tried = attempts();
assert!(rb.render(&blurred, 16, 16, quarter, &kept).pixels.iter().all(|&b| b == 0), "no blurred box from the raster cache");
assert!(r.render(&d, 16, 16, quarter, &kept).pixels.iter().all(|&b| b == 0), "no box");
let off = RenderOptions { mark_undecoded_images: true, image_mips: true, dragging: true, ..Default::default() };
let crossed = Renderer::new().render(&d, 16, 16, quarter, &off);
assert!(crossed.pixels != boxed.pixels, "the crossed box is not the light box");
assert!(r.render(&d, 16, 16, quarter, &marked).pixels == crossed.pixels, "the crossed box, as with the option off");
assert_eq!(attempts(), tried, "not tried again");
}
/// A document of `n` point type objects reading `word`, in rows.
fn text_doc(n: usize, word: &str) -> Document {
use vectorcraft_doc::text::{CharStyle, TextObject};
+2
View File
@@ -59,6 +59,8 @@ impl VectorcraftApp {
from_worker: false,
waits_for_still: cfg!(target_arch = "wasm32"),
still: None,
fill: None,
fill_worker: None,
worker: None,
worker_started: false,
slices: None,
+6
View File
@@ -24,6 +24,12 @@ pub struct CanvasCache {
/// The cache key the canvas last asked for and when it first asked for it (seconds, egui's
/// input time), kept while [`Self::waits_for_still`] is set.
pub still: Option<(CacheKey, f64)>,
/// The low-resolution frame that covers the parts of the canvas the frame shown leaves
/// uncovered while it is drawn for a view it was not rendered for ([`canvas::Fill`]).
pub fill: Option<canvas::Fill>,
/// The thread that renders [`Self::fill`], beside [`Self::worker`]. None on wasm, which has no
/// fill.
pub fill_worker: Option<render_worker::Worker>,
pub worker: Option<render_worker::Worker>,
pub worker_started: bool,
/// The slices as laid out for (document uid, revision): the canvas draws them every frame.
+7
View File
@@ -19,6 +19,7 @@ mod artboards;
mod context;
mod cursor;
mod drag;
mod fill;
mod grid;
mod home;
mod input;
@@ -40,6 +41,10 @@ pub use drag::{apply_requests, dispatch, mods};
pub(crate) use drag::{end_document_drag, release_art_in_window, release_over_panel};
#[cfg(test)]
use drag::{frame_moves, tool_drag_doc_id, tool_drag_moved, tool_frame_moves, tool_press_doc_id};
pub use fill::Fill;
use fill::fill_uncovered;
#[cfg(test)]
use fill::{fill_view, polygon_area};
#[cfg(test)]
use grid::ruler_rects;
use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds};
@@ -281,6 +286,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if undecoded > 0 {
app.status(format!("{undecoded} image(s) couldn't be decoded and show as crossed boxes"));
}
fill_uncovered(app, ui.ctx(), &painter, &key, &doc, rect, &xf);
reproject_frame(app, &painter, &xf, rect, (key.doc, v.rotation));
if let Some(look) = grid_look.filter(|_| !grids_in_back) {
grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look);
@@ -489,6 +495,7 @@ fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) {
app.canvas.worker_started = true;
if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() {
app.canvas.worker = crate::render_worker::Worker::spawn(ui.ctx().clone());
app.canvas.fill_worker = crate::render_worker::Worker::spawn(ui.ctx().clone());
}
}
// Upload finished background renders.
+1 -1
View File
@@ -66,7 +66,7 @@ pub(super) fn still_wait(still: &mut Option<(CacheKey, f64)>, shown: Option<&Cac
/// Whether two cache keys differ only in the view (zoom, center, rotation, canvas size, pixel
/// density and Pixel Preview's document pixels), with Pixel Preview on in both or off in both.
fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool {
pub(super) fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool {
a.pixel.is_some() == b.pixel.is_some()
&& *a == CacheKey { zoom: a.zoom, cx: a.cx, cy: a.cy, w: a.w, h: a.h, ppp: a.ppp, rot: a.rot, pixel: a.pixel, ..b.clone() }
}
+198
View File
@@ -0,0 +1,198 @@
use super::cursor::only_view_differs;
use super::{Affine, CacheKey, Color32, Point, Pos2, Shape, VectorcraftApp, Xf, color_image, pos2, vec2};
/// The fraction of the canvas's resolution, each way, that [`Fill`] is rendered at.
const FILL_SCALE: f64 = 0.25;
/// How many times the canvas's width and height [`Fill`] covers, around the view it is rendered
/// for. A pan or zoom-out that stays within it draws it again without rendering it.
const FILL_REACH: f64 = 2.0;
/// [`crate::CanvasCache::fill`]: a frame of the art from [`crate::CanvasCache::fill_worker`],
/// rendered for the canvas key `key` as [`fill_view`] gives, and its texture.
pub struct Fill {
/// The document, revision and view it was rendered for.
pub(super) key: CacheKey,
/// Document → its pixels.
pub(super) view: Affine,
/// Its size in pixels.
pub(super) size: (u32, u32),
pub(super) tex: egui::TextureHandle,
}
/// The size in pixels and the view (document → its pixels) of the fill for the canvas key `key`:
/// [`FILL_SCALE`] of the canvas's resolution, covering [`FILL_REACH`] times the canvas's width and
/// height around the view of `key`.
pub(super) fn fill_view(key: &CacheKey) -> (u32, u32, Affine) {
let fw = (f64::from(key.w) * FILL_REACH * FILL_SCALE).ceil().clamp(1.0, f64::from(u16::MAX)) as u32;
let fh = (f64::from(key.h) * FILL_REACH * FILL_SCALE).ceil().clamp(1.0, f64::from(u16::MAX)) as u32;
let ppp = if key.ppp > 0.0 { f64::from(key.ppp) } else { 1.0 };
let view = Affine::translate((f64::from(fw) / 2.0, f64::from(fh) / 2.0))
* Affine::rotate(key.rot.to_radians())
* Affine::scale(key.zoom * ppp * FILL_SCALE)
* Affine::translate((-key.cx, -key.cy));
(fw, fh, view)
}
/// While the frame shown is drawn for a view it was not rendered for, and its key differs from
/// `key` in the view alone (the frame for `key` is on its way from the render worker), the parts
/// of the canvas `rect` it leaves uncovered show `doc` from [`crate::CanvasCache::fill`]. The fill
/// worker ([`crate::CanvasCache::fill_worker`]) renders it with the frame shown's options and these
/// changes: images draw only from pixels already decoded
/// ([`vectorcraft_render::RenderOptions::kept_images_only`]) and draw mips
/// ([`vectorcraft_render::RenderOptions::image_mips`]), raster effects render within a drag's
/// budget ([`vectorcraft_render::RenderOptions::dragging`]), and rasters cached for moved objects
/// are not reused ([`vectorcraft_render::RenderOptions::interacting`] off). This thread only copies a
/// finished fill into its texture and draws it. A fill is asked for again when the one shown or on
/// its way does not cover the canvas, when the view's zoom is more than twice or less than half
/// its own, and when the document, its revision or anything but the view changed; until it
/// arrives, the fill shown is drawn where it reaches. It is dropped once the canvas shows the frame
/// for `key`, and while the frame shown differs from `key` in more than the view.
pub(super) fn fill_uncovered(
app: &mut VectorcraftApp,
ctx: &egui::Context,
painter: &egui::Painter,
key: &CacheKey,
doc: &std::sync::Arc<vectorcraft_doc::Document>,
rect: egui::Rect,
xf: &Xf,
) {
// Taken every frame, so a fill that is no longer needed does not stay queued.
let done = app.canvas.fill_worker.as_mut().and_then(|w| w.poll());
let (Some(k), Some((_, opts))) = (&app.canvas.key, &app.canvas.shown) else {
app.canvas.fill = None;
return;
};
// The fill takes the frame shown's options, which match `key`'s only when the two keys differ
// in the view alone.
if k == key || !only_view_differs(k, key) || app.canvas.texture.is_none() {
app.canvas.fill = None;
return;
}
let screen_to_doc = xf.affine().inverse();
let corners =
[rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()].map(|p| screen_to_doc * Point::new(p.x as f64, p.y as f64));
let usable = |f: &CacheKey| only_view_differs(f, key) && (0.5..=2.0).contains(&(key.zoom / f.zoom));
let covers = |f: &CacheKey| {
let (w, h, view) = fill_view(f);
usable(f)
&& corners.iter().all(|c| {
let p = view * *c;
(-0.5..=f64::from(w) + 0.5).contains(&p.x) && (-0.5..=f64::from(h) + 0.5).contains(&p.y)
})
};
let opts = vectorcraft_render::RenderOptions { kept_images_only: true, image_mips: true, interacting: false, dragging: true, ..opts.clone() };
let gaps = shown_frame(k, rect, xf).map_or_else(Vec::new, |shown| uncovered(shown, rect));
if let Some(done) = done.filter(|d| usable(&d.key)) {
let color = color_image(&done.img);
let tex = match app.canvas.fill.take() {
Some(mut f) => {
f.tex.set(color, egui::TextureOptions::LINEAR);
f.tex
}
None => ctx.load_texture("canvas-fill", color, egui::TextureOptions::LINEAR),
};
let (_, _, view) = fill_view(&done.key);
app.canvas.fill = Some(Fill { key: done.key, view, size: (done.img.width, done.img.height), tex });
}
let Some(worker) = app.canvas.fill_worker.as_mut().filter(|_| !gaps.is_empty()) else { return };
if !app.canvas.fill.as_ref().is_some_and(|f| covers(&f.key)) && !worker.pending.as_ref().is_some_and(covers) {
let (w, h, view) = fill_view(key);
worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts });
}
let Some(f) = app.canvas.fill.as_ref().filter(|f| usable(&f.key)) else { return };
let to_uv = Affine::scale_non_uniform(1.0 / f64::from(f.size.0.max(1)), 1.0 / f64::from(f.size.1.max(1))) * f.view * screen_to_doc;
let from_uv = to_uv.inverse();
let mut mesh = egui::Mesh::with_texture(f.tex.id());
for gap in gaps {
// Only where the fill reaches: its pixels are 0..1 each way in uv.
let uv: Vec<Point> = gap.iter().map(|p| to_uv * Point::new(p.x as f64, p.y as f64)).collect();
let uv = clip_polygon(&clip_polygon(&uv, |p| p.x, 0.0, false), |p| p.x, 1.0, true);
let uv = clip_polygon(&clip_polygon(&uv, |p| p.y, 0.0, false), |p| p.y, 1.0, true);
if uv.len() < 3 {
continue;
}
let first = mesh.vertices.len() as u32;
for p in &uv {
let pos = from_uv * *p;
mesh.vertices.push(egui::epaint::Vertex {
pos: pos2(pos.x as f32, pos.y as f32),
uv: pos2(p.x as f32, p.y as f32),
color: Color32::WHITE,
});
}
for i in 1..uv.len() as u32 - 1 {
mesh.indices.extend([first, first + i, first + i + 1]);
}
}
painter.add(Shape::mesh(mesh));
}
/// Where the canvas draws the frame shown (its key `k`) in the view `xf` of the canvas `rect`:
/// the unit square (the frame's texture coordinates) → screen points. None when the mapping is
/// degenerate.
fn shown_frame(k: &CacheKey, rect: egui::Rect, xf: &Xf) -> Option<Affine> {
let to_screen = match k.pixel {
Some([x0, y0, x1, y1]) => {
xf.affine() * Affine::translate((x0 as f64, y0 as f64)) * Affine::scale_non_uniform((x1 - x0) as f64, (y1 - y0) as f64)
}
None => {
let ppp = if k.ppp > 0.0 { k.ppp } else { 1.0 };
let old_rect = egui::Rect::from_center_size(rect.center(), vec2(k.w as f32 / ppp, k.h as f32 / ppp));
let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() };
xf.affine()
* old.affine().inverse()
* Affine::translate((old_rect.min.x as f64, old_rect.min.y as f64))
* Affine::scale_non_uniform(old_rect.width() as f64, old_rect.height() as f64)
}
};
let det = to_screen.determinant();
(det.is_finite() && det.abs() > 1e-12).then_some(to_screen)
}
/// The parts of `rect` outside the parallelogram `shown` maps the unit square to, as convex
/// polygons of screen points, leaving out those thinner than half a point (a frame whose pixel
/// size was rounded from the canvas's leaves such slivers along its edges).
fn uncovered(shown: Affine, rect: egui::Rect) -> Vec<Vec<Pos2>> {
// In the unit square's coordinates the canvas is a convex polygon and the frame shown is
// 0..1 each way: the parts left of it, right of it, and above and below it in between.
let inv = shown.inverse();
let canvas: Vec<Point> = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()]
.iter()
.map(|p| inv * Point::new(p.x as f64, p.y as f64))
.collect();
let left = clip_polygon(&canvas, |p| p.x, 0.0, true);
let right = clip_polygon(&canvas, |p| p.x, 1.0, false);
let between = clip_polygon(&clip_polygon(&canvas, |p| p.x, 0.0, false), |p| p.x, 1.0, true);
let above = clip_polygon(&between, |p| p.y, 0.0, true);
let below = clip_polygon(&between, |p| p.y, 1.0, false);
[left, right, above, below]
.into_iter()
.map(|part| part.into_iter().map(|p| shown * p).map(|p| pos2(p.x as f32, p.y as f32)).collect::<Vec<_>>())
.filter(|part| {
// Twice the area over the perimeter: about the width of a thin strip.
let perimeter: f32 = part.iter().zip(part.iter().cycle().skip(1)).map(|(a, b)| a.distance(*b)).sum();
perimeter > 0.0 && 2.0 * polygon_area(part) / perimeter > 0.5
})
.collect()
}
/// The part of convex polygon `poly` where `along(p)` is below `at` (`below`) or above it.
fn clip_polygon(poly: &[Point], along: impl Fn(&Point) -> f64, at: f64, below: bool) -> Vec<Point> {
let inside = |p: &Point| if below { along(p) <= at } else { along(p) >= at };
let mut out = Vec::with_capacity(poly.len() + 1);
for (a, b) in poly.iter().zip(poly.iter().cycle().skip(1)) {
if inside(a) {
out.push(*a);
}
if inside(a) != inside(b) {
let t = (at - along(a)) / (along(b) - along(a));
out.push(a.lerp(*b, t));
}
}
out
}
/// The area of polygon `poly` (shoelace formula).
pub(super) fn polygon_area(poly: &[Pos2]) -> f32 {
poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f32>().abs() / 2.0
}
+123
View File
@@ -2335,6 +2335,129 @@ fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() {
app.canvas.worker = None;
}
/// While the worker renders the frame for a panned view, the fill worker renders a
/// low-resolution frame, and the part of the canvas the last frame no longer covers shows the
/// art from it, drawn only there. When the worker's frame arrives, it covers the canvas and the
/// fill is dropped.
#[test]
fn a_pan_fills_the_area_the_last_frame_leaves_until_the_worker_frame_arrives() {
let ctx = egui::Context::default();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 80, "height": 100})).unwrap();
app.canvas.worker_started = true; // render on this thread
app.ui.view.rulers = false;
app.ui.screen_mode = 2; // no scroll bars: the canvas is the window
// One frame → the meshes drawn and the fill's pixels, when this frame uploaded them.
let frame = |app: &mut VectorcraftApp| {
let (shapes, mut delta) = frame_output(app, &ctx);
let fill = app.canvas.fill.as_ref().map(|f| f.tex.id());
let pixels = delta.set.iter().find(|(id, _)| Some(**id) == fill).and_then(|(_, d)| d.last()).map(|d| match &d.image {
egui::ImageData::Color(img) => img.clone(),
});
delta.clear();
let meshes: Vec<egui::Mesh> = shapes
.into_iter()
.filter_map(|s| match s {
Shape::Mesh(m) => Some(std::sync::Arc::unwrap_or_clone(m)),
_ => None,
})
.collect();
(meshes, pixels)
};
frame(&mut app);
{
let v = app.view_mut().unwrap();
v.zoom = 4.0;
v.center = Point::new(100.0, 150.0);
}
frame(&mut app); // the art from about x 0 to 200, without the rectangle
let old = app.canvas.key.clone().unwrap();
let before = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
assert!(app.canvas.fill.is_none());
let (worker, jobs, completed) = crate::render_worker::Worker::test_channel();
app.canvas.worker = Some(worker);
let (fill_worker, fill_jobs, fill_completed) = crate::render_worker::Worker::test_channel();
app.canvas.fill_worker = Some(fill_worker);
app.canvas.last_ms = 50.0;
// Pan right by 150 pt: the last frame covers the canvas's left quarter, and the rectangle
// comes into its right quarter.
app.view_mut().unwrap().center = Point::new(250.0, 150.0);
frame(&mut app);
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown");
let job = jobs.try_recv().expect("the frame for the new view is on the worker");
let fill_job = fill_jobs.try_recv().expect("the fill is on the fill worker");
let (fw, fh, _) = fill_view(&job.key);
assert!(fill_job.opts.kept_images_only && (fill_job.w, fill_job.h) == (fw, fh), "{:?}", (fill_job.w, fill_job.h));
assert!(app.canvas.fill.is_none(), "nothing to draw until the fill arrives");
// The fill arrives; the next frame draws it, and asks for no other fill.
let img = app.canvas.renderer.render(&fill_job.doc, fill_job.w, fill_job.h, fill_job.view, &fill_job.opts);
let (doc, opts) = (fill_job.doc, fill_job.opts);
fill_completed.send(crate::render_worker::Done { key: fill_job.key, doc, opts, img, ms: 5.0, moved: 0 }).unwrap();
let (meshes, pixels) = frame(&mut app);
assert!(fill_jobs.try_recv().is_err(), "the fill covers the canvas");
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the last frame is still shown");
let fill = app.canvas.fill.as_ref().expect("the fill is kept");
let mesh = meshes.iter().find(|m| m.texture_id == fill.tex.id()).expect("the fill is drawn");
let rect = app.canvas_rect.unwrap();
let xf = Xf::new(rect, app.view().unwrap());
let edge = xf.to_screen(before.to_doc(rect.right_top())).x; // the last frame's right edge
assert!(edge > rect.left() + 100.0 && edge < xf.to_screen(Point::new(300.0, 0.0)).x, "{edge} in {rect:?}");
assert!(
mesh.vertices.iter().all(|v| v.pos.x >= edge - 1e-3 && rect.expand(1e-3).contains(v.pos)),
"only right of the last frame: {:?}",
mesh.vertices
);
let area: f32 = mesh.indices.as_chunks::<3>().0.iter().map(|t| polygon_area(&t.map(|i| mesh.vertices[i as usize].pos))).sum();
let uncovered = (rect.right() - edge) * rect.height();
assert!((area - uncovered).abs() < 1.0, "the whole uncovered area, {uncovered} square points: {area}");
// The fill's pixel under a point of the canvas, from the uv the mesh gives that point.
let img = pixels.expect("the fill's pixels were uploaded");
let to_uv = Affine::scale_non_uniform(1.0 / fill.size.0 as f64, 1.0 / fill.size.1 as f64) * fill.view * xf.affine().inverse();
let alpha = |p: Pos2| {
let uv = to_uv * Point::new(p.x as f64, p.y as f64);
img.pixels[(uv.y * img.size[1] as f64) as usize * img.size[0] + (uv.x * img.size[0] as f64) as usize].a()
};
assert!(alpha(xf.to_screen(Point::new(340.0, 150.0))) > 0, "the rectangle, in the uncovered area");
assert_eq!(alpha(xf.to_screen(Point::new(250.0, 150.0))), 0, "no art there");
for v in &mesh.vertices {
assert!((to_uv * Point::new(v.pos.x as f64, v.pos.y as f64) - Point::new(v.uv.x as f64, v.uv.y as f64)).hypot() < 1e-4);
}
// Panned until the fill reaches only the canvas's left half: it is drawn only there, and the
// fill for the new view is asked for.
let fill_tex = fill.tex.id();
let reach = fill.view.inverse() * Point::new(fill.size.0 as f64, 0.0); // the fill's right edge
app.view_mut().unwrap().center = Point::new(reach.x, 150.0);
let (meshes, _) = frame(&mut app);
let right = Xf::new(rect, app.view().unwrap()).to_screen(reach).x;
assert!((right - rect.center().x).abs() < 1.0, "{right} in {rect:?}");
let mesh = meshes.iter().find(|m| m.texture_id == fill_tex).expect("the fill is drawn where it reaches");
assert!(mesh.vertices.iter().all(|v| v.pos.x <= right + 1e-3), "only where the fill reaches: {:?}", mesh.vertices);
let next = fill_jobs.try_recv().expect("the next fill");
assert_eq!(Some(&next.key), app.canvas.worker.as_ref().unwrap().pending.as_ref());
app.view_mut().unwrap().center = Point::new(250.0, 150.0);
// That fill finishes after the worker's frame: it is taken and not kept.
let img = app.canvas.renderer.render(&next.doc, next.w, next.h, next.view, &next.opts);
fill_completed.send(crate::render_worker::Done { key: next.key, doc: next.doc, opts: next.opts, img, ms: 5.0, moved: 0 }).unwrap();
// The worker's frame arrives: it covers the canvas, and the fill is no longer drawn or kept.
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
let expected = job.key.clone();
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 50.0, moved: 0 }).unwrap();
let (meshes, _) = frame(&mut app);
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame is shown");
assert!(app.canvas.fill.is_none() && meshes.iter().all(|m| m.texture_id != fill_tex), "the fill is dropped");
assert!(app.canvas.fill_worker.as_ref().unwrap().pending.is_none(), "the late fill was taken");
// A pan with Outline turned on: the frame shown's options are not the ones asked for, and
// no fill is asked for.
app.ui.view.outline = true;
app.view_mut().unwrap().center = Point::new(400.0, 150.0);
frame(&mut app);
assert!(jobs.try_iter().last().is_some_and(|j| j.key.outline), "the outline frame is on the worker");
assert!(fill_jobs.try_recv().is_err() && app.canvas.fill.is_none(), "no fill");
app.canvas.worker = None;
app.canvas.fill_worker = None;
}
/// With the web build's setting (`waits_for_still`, no render worker), a heavy document's canvas
/// is not rendered while the view moves: each frame draws the frame shown moved and scaled with
/// the view and asks for a frame within STILL_SECS. The canvas renders once the view has been
+3 -2
View File
@@ -1,6 +1,7 @@
//! Background rendering (native): heavy documents render off the UI thread while the canvas shows
//! the previous frame reprojected to the current view. Light documents render synchronously so
//! edits never lag behind overlays.
//! the previous frame reprojected to the current view. A second worker renders a low-resolution
//! frame for the parts that frame does not reach (`canvas::fill_uncovered`). Light documents
//! render synchronously so edits never lag behind overlays.
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
+1
View File
@@ -757,6 +757,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The canvas draws the active artboard's edge in black, as the public [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html) page describes. It draws the other artboards' edges in gray. With several artboards selected by the Artboard tool, the canvas draws the black edge around the active one, which the tool shows with handles and its name. It used to draw artboard 1's edge in black whichever artboard was active.
View › Rotate View turns the view to the status bar's preset angles (`view.rotate {angle}`, which takes any angle). View › Rotate View to Selection turns the view to the angle of the selected objects' bounding box, so the box is square to the window (`view.rotateToSelection`).
When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then.
While the desktop app's render worker renders the frame for a new view, the parts of the canvas the last frame does not cover after a pan, a zoom-out or a turn show the art from a frame that a second worker renders at a quarter of the resolution, covering twice the canvas's width and height. That frame draws images only from pixels already decoded (an image not yet decoded shows as a light gray box), and it renders large raster effects at the lower resolution a drag uses. Until it is ready, those parts show the pasteboard and the artboards. They used to show only the pasteboard and the artboards until the frame for the new view was ready.
With the view rotated, a second Escape less than a second after the first resets the rotation, as Reset Rotate View does. The first Escape may end something else, such as isolation mode or a path being drawn. The second resets the view only when it has nothing else to end, and an Escape that closes a menu does not count. After a click, or a press of another key that is not a modifier (Shift, Control, Alt/Option or Command), the next Escape counts as the first. With the Rotate View tool chosen, one Escape resets the view.
A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default).
The status bar navigator's field shows the active artboard's number and name, and its drop-down lists every artboard by number and name, as the Artboards panel does. Choosing an artboard in the list makes it the active artboard and fits it in the window (`view.goToArtboard`). The list opens on the active artboard's row and lays out only the rows in view and at most one more.