Browse / github.com/storytold/vectorcraft / patch
Canvas: a pan or zoom-out fills the uncovered area with low-resolution art
Canvas: a pan or zoom-out fills the uncovered area with low-resolution art ## What and why While the render worker renders a heavy document's frame for a new view, the canvas draws the last frame moved and scaled into that view. After a pan or a zoom-out, the part of the canvas the last frame does not reach showed only the pasteboard and the artboards until the worker's frame arrived, 2.3 s and 9.2 s in the measurements below. Part of #3680, #2919 and #3377: the full-resolution frame arrives as late as before. FractalEncrypt's #3129 first fixed it by rendering the uncovered view synchronously. bflatastic asked there for a different approach ([comment](https://githu
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #4256, open)
- Base
03b16c8914739332a6daab096da1c33ffc6395c0- Size
- +439 −8 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
13602480a1637efe03b5dddd
How it went
No other operator has reported using it yet.
Diff
raw · crates/render/src/fx.rs crates/render/src/images.rs crates/render/src/lib.rs crates/render/src/tests_memory.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/render_worker.rs docs/target-app-parity.md
@@ -2104,11 +2104,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 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();@@ -2131,6 +2132,8 @@ impl Renderer { let o = f.opts; (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);+ // With only kept pixels drawn, a raster is made again once the store holds more pixels.+ 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);@@ -89,6 +89,9 @@ pub(crate) struct ImageStore { budget: usize, /// Request counter: the least recently used entries go first. tick: u64,+ /// How many entries have been added: it changes whenever the store holds pixels it did not+ /// hold 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)>, }@@ -99,7 +102,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() }+ Self { entries: HashMap::new(), bytes: 0, budget, tick: 0, added: 0, ids: HashMap::new() }+ }++ /// How many entries have been added to the store.+ pub(crate) fn added(&self) -> u64 {+ self.added } /// The pixels of `key`, marked as just used.@@ -134,6 +142,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@@ -186,6 +186,13 @@ pub struct RenderOptions { /// (box-filtered from the full image, kept with the decoded images), so the full image needn't /// stay in memory or be sampled. Off, images always sample their full pixels (exports). pub image_mips: 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. 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@@ -281,6 +288,7 @@ impl Default for RenderOptions { precise: false, supersample: false, image_mips: false,+ kept_images_only: false, interacting: false, dragging: false, rich_black: false,@@ -2108,6 +2116,25 @@ impl Renderer { let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64); 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);+ if kept.is_none() {+ // 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 {@@ -2257,6 +2284,19 @@ 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))+ } } /// `key`'s pixels in the shared image store; `touch` marks them used.@@ -133,6 +133,54 @@ 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");+}+ /// 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};@@ -442,6 +442,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { 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.@@ -560,6 +561,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } }+ fill_uncovered(app, ui.ctx(), &painter, &key, &doc, rect, &xf); if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) && k.doc == key.doc {@@ -3579,6 +3581,202 @@ fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_rende } } +/// 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.+ key: CacheKey,+ /// Document → its pixels.+ view: Affine,+ /// Its size in pixels.+ size: (u32, u32),+ 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`.+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.+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).+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+}+ /// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view /// included. None when the view is degenerate. fn pixel_region(xf: &Xf) -> Option<[i64; 4]> {@@ -6093,6 +6291,129 @@ mod tests { 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@@ -371,6 +371,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.@@ -773,6 +779,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,@@ -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};@@ -729,6 +729,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.