From 4c23cb71c09f5c1a1962a729a569c623151b10dc Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:08:55 -0400 Subject: [PATCH 1/4] perf(canvas): render coarse zoom previews before refinement (M8) A new zoom can't reuse the retained canvas image, and the previous one, re-projected, covers little of the view after a zoom out (1/64 of it from 800% to 100%), so the page background showed until the full render finished. When renders from scratch have been slow (a frame or more), the worker first renders the whole requested view with a quarter of the pixels each way, in a Retained of its own so pans and edits still build on the full image, then refines it unless a newer request is already waiting (that one gets its own preview). The web build renders the preview and the refined image in consecutive frames. Pixel preview gets none. Finished images carry the file, page and whether they are coarse: the canvas never shows an image of another file or page, and a late preview never replaces a refined image already at the zoom asked for. ui.inspect reports the canvas quality and the latency from request to the frame showing the preview and the refined image. Refs #295 --- crates/ui-egui/src/canvas.rs | 59 +++++- crates/ui-egui/src/canvas_fixes_tests.rs | 3 + crates/ui-egui/src/idle_tests.rs | 3 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/render_worker.rs | 152 ++++++++++++++-- crates/ui-egui/src/zoom_preview_tests.rs | 219 +++++++++++++++++++++++ 6 files changed, 413 insertions(+), 26 deletions(-) create mode 100644 crates/ui-egui/src/zoom_preview_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb23c8de..90a99384 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -11,16 +11,26 @@ use serde_json::json; use crate::FigApp; use crate::i18n::{tr, trf, trn}; -use crate::render_worker::{Job, Worker}; +use crate::render_worker::{Done, Job, Worker}; use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold}; pub struct CanvasState { pub rect: Rect, texture: Option, tex_view: Option, + /// The file ([`figcraft_engine::DocState::uid`]) and page the texture shows: it is drawn only + /// on them. + tex_of: Option<(u64, NodeId)>, + /// The texture is a coarse preview (see [`crate::render_worker`]). + pub tex_coarse: bool, submitted: Option, + /// The scale (device pixels per page unit) of the last render asked for. + want_scale: f64, /// What the last canvas render had to do (shown by `ui.inspect`). pub render_work: Option, + /// From asking for a render to the frame that shows its preview, and its refined image (ms). + pub preview_latency_ms: Option, + pub refine_latency_ms: Option, dragging: bool, space_pan: bool, middle_pan: Option<(Pos2, f64, f64)>, @@ -45,8 +55,13 @@ impl Default for CanvasState { rect: Rect::NOTHING, texture: None, tex_view: None, + tex_of: None, + tex_coarse: false, submitted: None, + want_scale: 0.0, render_work: None, + preview_latency_ms: None, + refine_latency_ms: None, dragging: false, space_pan: false, middle_pan: None, @@ -169,10 +184,22 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let own_cursor = input(app, ui, &resp); crate::live::track_pointer(app, &ctx); app.sync_preedit(); - request_render(app, worker, ppp); + let now = ctx.input(|i| i.time); + request_render(app, worker, ppp, now); // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. - if let Some(done) = worker.poll() { + let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); + if let Some(done) = worker.poll() + && shows(&app.canvas, &done, shown) + { + let latency = Some((now - done.at).max(0.0) * 1000.0); + if done.coarse { + app.canvas.preview_latency_ms = latency; + } else { + app.canvas.refine_latency_ms = latency; + app.canvas.render_work = Some(done.work); + app.ui.render_ms = done.ms; + } // A pixel preview render is magnified with hard pixel edges. let filter = if app.session.ds().ok().and_then(|d| d.view.pixel_preview_scale()) == Some(done.view.scale) { egui::TextureOptions::NEAREST @@ -184,8 +211,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", done.image, filter)), } app.canvas.tex_view = Some(done.view); - app.canvas.render_work = Some(done.work); - app.ui.render_ms = done.ms; + app.canvas.tex_of = Some((done.uid, done.page)); + app.canvas.tex_coarse = done.coarse; } let painter = ui.painter_at(rect); let t = Tokens::get(&ctx); @@ -200,7 +227,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { }) .unwrap_or(t.canvas); painter.rect_filled(rect, CornerRadius::ZERO, bg); - if let (Some(tex), Some(v)) = (&app.canvas.texture, &app.canvas.tex_view) { + if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) { // Re-project the last render onto the current view. let tl = app.to_screen(v.origin.0, v.origin.1); let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); @@ -268,7 +295,17 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String { ) } -fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { +/// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and +/// not a coarse preview over a refined image already at the zoom asked for. +fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool { + if shown != Some((done.uid, done.page)) { + return false; + } + let refined_here = !c.tex_coarse && c.tex_of == shown && c.tex_view.as_ref().is_some_and(|v| v.scale.to_bits() == c.want_scale.to_bits()); + !(done.coarse && refined_here) +} + +fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, now: f64) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; let (mut ox, mut oy) = ds.view.origin(); @@ -313,7 +350,11 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { skip: vec![], solo: key.solo, proof, + uid: ds.uid, + zoom_preview: ds.view.pixel_preview_scale().is_none(), + at: now, }; + app.canvas.want_scale = scale; worker.submit(job); app.canvas.submitted = Some(key); } @@ -1342,3 +1383,7 @@ pub fn fmt(v: f64) -> String { let r = (v * 100.0).round() / 100.0; if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } } + +#[cfg(test)] +#[path = "zoom_preview_tests.rs"] +mod zoom_preview_tests; diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 37b03e55..1c4938d7 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -62,6 +62,9 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: skip: vec![], solo: None, proof: false, + uid: 0, + zoom_preview: false, + at: 0.0, } } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e5757..4a277ae2 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -212,6 +212,9 @@ fn a_finished_background_render_wakes_the_ui() { skip: vec![], solo: None, proof: false, + uid: 0, + zoom_preview: false, + at: 0.0, }); let start = Instant::now(); while wakes.load(Ordering::SeqCst) == 0 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..58569700 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1181,6 +1181,9 @@ impl FigApp { figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}), figcraft_render::Work::Full => json!({"kind": "full"}), }), + "quality": if self.canvas.tex_coarse { "preview" } else { "refined" }, + "previewLatencyMs": self.canvas.preview_latency_ms, + "refineLatencyMs": self.canvas.refine_latency_ms, }, "live": live::inspect(self), "window": self.windows.current, diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 12a0ba62..ee978890 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -3,6 +3,11 @@ //! until the next one arrives. //! The renderer keeps its last image ([`figcraft_render::Retained`]): a pan renders only the //! uncovered strips and an edit only the areas of the layers that changed. +//! A new zoom can't reuse that image, and the old one, re-projected, covers little of the view +//! after a zoom out (an eighth of the width from 800% to 100%). When full renders are slow, the +//! whole view is first rendered coarsely ([`PREVIEW_FACTOR`] times fewer pixels each way, kept +//! apart from the full image so pans and edits still build on it), then refined unless a newer +//! request is already waiting. use std::sync::Arc; @@ -10,6 +15,11 @@ use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; use figcraft_render::{Pixmap, Retained, View, Work}; +/// A preview has this many times fewer pixels each way than the view it stands in for. +pub const PREVIEW_FACTOR: f64 = 4.0; +/// Full renders quicker than this (about a frame) show without a preview first. +const PREVIEW_AFTER_MS: f64 = 12.0; + #[derive(Clone)] pub struct Job { pub doc: Arc, @@ -25,6 +35,13 @@ pub struct Job { pub solo: Option, /// Soft-proof sRGB: stripe the colours sRGB can't show. pub proof: bool, + /// The document's [`figcraft_engine::DocState::uid`]: images of other files are never shown. + pub uid: u64, + /// A coarse preview may come first when the zoom changes (not in pixel preview, whose pixels + /// are the point). + pub zoom_preview: bool, + /// When the UI asked for it (`egui` input time, seconds): how long the image took to show. + pub at: f64, } pub struct Done { @@ -34,6 +51,13 @@ pub struct Done { pub view: View, pub ms: f64, pub work: Work, + /// What it is an image of ([`Job::uid`], [`Job::page`]). + pub uid: u64, + pub page: NodeId, + /// A coarse preview, refined by a later image unless a newer request replaced it. + pub coarse: bool, + /// [`Job::at`]. + pub at: f64, } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -58,7 +82,8 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP } } -fn run(job: &Job, retained: &mut Retained) -> Option { +/// Render `view` of the job (its own view, or the coarse one standing in for it). +fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); // The canvas is a preview: what's too small to see in detail at this zoom is approximated. @@ -71,7 +96,7 @@ fn run(job: &Job, retained: &mut Retained) -> Option { lod: true, ..Default::default() }; - let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?; + let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?; let mut image = screen_image(pix, job.doc.color_profile); if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); @@ -80,7 +105,80 @@ fn run(job: &Job, retained: &mut Retained) -> Option { let ms = t0.elapsed().as_secs_f64() * 1000.0; #[cfg(target_arch = "wasm32")] let ms = 0.0; - Some(Done { image, view, ms, work }) + Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at }) +} + +/// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area. +pub fn coarse_view(view: &View) -> View { + let shrink = |n: u32| (f64::from(n) / PREVIEW_FACTOR).ceil().max(1.0) as u32; + View { origin: view.origin, scale: view.scale / PREVIEW_FACTOR, width: shrink(view.width), height: shrink(view.height) } +} + +/// What the render thread keeps between jobs: the canvas image, the previews standing in for it +/// while the zoom changes, and what the last full render drew and took. +pub struct Renders { + full: Retained, + preview: Retained, + /// The last full render's document, page and scale. + last: Option<(u64, NodeId, f64)>, + /// How long the last render from scratch took. + full_ms: f64, + /// Previews come first only when renders from scratch take at least this long. + preview_after_ms: f64, +} + +impl Default for Renders { + fn default() -> Self { + Self::with_threshold(PREVIEW_AFTER_MS) + } +} + +impl Renders { + /// Previews when renders from scratch take at least `ms` (tests: 0, always). + pub fn with_threshold(ms: f64) -> Self { + Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms } + } + + /// Whether `job` gets a preview first: the kept image is of another zoom, page or file (so the + /// full render starts from scratch) and those have been slow. The first render has no + /// measure yet and comes without one. + fn wants_preview(&self, job: &Job) -> bool { + job.zoom_preview + && self.full_ms >= self.preview_after_ms + && self.last.is_some_and(|(uid, page, scale)| uid != job.uid || page != job.page || scale.to_bits() != job.view.scale.to_bits()) + } + + /// The coarse preview of the whole of `job`'s view, when one should come first. + pub fn preview(&mut self, job: &Job) -> Option { + if !self.wants_preview(job) { + return None; + } + let view = coarse_view(&job.view); + guarded(&mut self.preview, |r| run(job, &view, r, true)) + } + + /// The image of `job`'s view, built on the kept one where it can be. + pub fn refine(&mut self, job: &Job) -> Option { + let done = guarded(&mut self.full, |r| run(job, &job.view, r, false)); + if let Some(d) = &done { + self.last = Some((job.uid, job.page, job.view.scale)); + if d.work == Work::Full { + self.full_ms = d.ms; + } + } + done + } +} + +/// Run `f` on `retained`; after a panic, forget the image it may have left half drawn. +fn guarded(retained: &mut Retained, f: impl FnOnce(&mut Retained) -> Option) -> Option { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(retained))) { + Ok(d) => d, + Err(_) => { + retained.clear(); + None + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -104,22 +202,26 @@ impl Worker { // The window that renders with it (each window has its own worker). let viewport = ctx.viewport_id(); let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || { - let mut retained = Retained::default(); - while let Ok((mut seq, mut job)) = jobs.recv() { + let mut renders = Renders::default(); + let mut next = None; + loop { + let Some((mut seq, mut job)) = next.take().or_else(|| jobs.recv().ok()) else { break }; // Skip to the newest queued job. while let Ok(newer) = jobs.try_recv() { (seq, job) = newer; } - let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained))); - let done = match res { - Ok(d) => d, - Err(_) => { - // Don't build on an image a panic may have left half drawn. - retained.clear(); - None + if let Some(d) = renders.preview(&job) { + if done_tx.send(d).is_err() { + break; } - }; - if let Some(d) = done + ctx.request_repaint_of(viewport); + // A newer request makes refining this one moot: it gets its own preview. + if let Ok(newer) = jobs.try_recv() { + next = Some(newer); + continue; + } + } + if let Some(d) = renders.refine(&job) && done_tx.send(d).is_err() { break; @@ -155,30 +257,42 @@ impl Worker { } } +/// Renders inline, one image a frame: a preview first when one is due, the refined image in the +/// next frame unless a newer request replaced the job. #[cfg(target_arch = "wasm32")] pub struct Worker { + ctx: egui::Context, pending: Option, - retained: Retained, + previewed: bool, + renders: Renders, } #[cfg(target_arch = "wasm32")] impl Worker { - pub fn new(_ctx: egui::Context) -> Self { - Self { pending: None, retained: Retained::default() } + pub fn new(ctx: egui::Context) -> Self { + Self { ctx, pending: None, previewed: false, renders: Renders::default() } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); + self.previewed = false; } pub fn in_flight(&self) -> bool { self.pending.is_some() } pub fn poll(&mut self) -> Option { + if !self.previewed { + self.previewed = true; + if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { + self.ctx.request_repaint(); + return Some(d); + } + } let job = self.pending.take()?; - run(&job, &mut self.retained) + self.renders.refine(&job) } } /// Render synchronously from scratch (tests, screenshots). pub fn render_now(job: &Job) -> Option { - run(job, &mut Retained::default()) + run(job, &job.view, &mut Retained::default(), false) } diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs new file mode 100644 index 00000000..cd17a70a --- /dev/null +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -0,0 +1,219 @@ +//! Coarse previews while the zoom changes (#295): a new zoom first shows the whole view coarsely, +//! then the refined image, which matches a render from scratch; pans and edits still build on the +//! kept image; previews never replace a refined image already at the zoom asked for, and no image +//! of another file or page goes on screen. + +use std::sync::Arc; + +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::Session; +use figcraft_engine::params::id_of; +use figcraft_render::{View, Work}; +use serde_json::json; + +use super::{CanvasState, shows}; +use crate::render_worker::{Done, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; + +/// A page of ellipses with a rectangle on top; the session and the rectangle. +fn document() -> (Session, NodeId) { + let mut s = Session::new(); + for k in 0..12 { + let k = f64::from(k); + s.execute("shape.ellipse", &json!({"x": k * 60.0, "y": k * 40.0, "width": 120, "height": 90})).unwrap(); + } + let made = s.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 300, "height": 200})).unwrap(); + let rect = id_of(&made["id"]).unwrap(); + (s, rect) +} + +/// A `w` × `h` device-pixel view at `scale` centred on page point `c`. +fn view_at(c: (f64, f64), scale: f64, w: u32, h: u32) -> View { + View { origin: (c.0 - f64::from(w) / scale / 2.0, c.1 - f64::from(h) / scale / 2.0), scale, width: w, height: h } +} + +fn job(doc: &Arc, page: NodeId, uid: u64, view: View) -> Job { + Job { + doc: doc.clone(), + page, + view, + outline: false, + outline_extras: Default::default(), + outlined: vec![], + skip: vec![], + solo: None, + proof: false, + uid, + zoom_preview: true, + at: 0.0, + } +} + +/// Every pixel of `a` and `b` alike. +fn same_pixels(a: &Done, b: &Done) -> bool { + a.image.size == b.image.size && a.image.pixels == b.image.pixels +} + +fn covers(outer: &View, inner: &View) -> bool { + let (o, i) = (outer.page_rect(), inner.page_rect()); + let eps = 1e-6; + o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps +} + +#[test] +fn a_zoom_out_previews_the_whole_view_then_refines_to_a_fresh_render() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let mut r = Renders::with_threshold(0.0); + let zoomed_in = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 8.0, 400, 300)); + assert!(r.preview(&zoomed_in).is_none(), "the first render has nothing to measure previews by"); + assert!(r.refine(&zoomed_in).is_some()); + // 800% → 100% about the same centre: the old image covers 1/64 of the new view. + let out = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 1.0, 400, 300)); + let p = r.preview(&out).expect("a preview first"); + assert!(p.coarse && (p.uid, p.page) == (ds.uid, ds.page)); + assert!(covers(&p.view, &out.view), "the preview shows the whole view asked for"); + let pixels = |d: &Done| d.image.size[0] * d.image.size[1]; + let fresh = render_now(&out).unwrap(); + assert!( + (pixels(&p) as f64) * PREVIEW_FACTOR * PREVIEW_FACTOR < 1.5 * pixels(&fresh) as f64, + "a sixteenth of the pixels, give or take the snapping" + ); + let refined = r.refine(&out).unwrap(); + assert!(!refined.coarse && refined.work == Work::Full); + assert!(same_pixels(&refined, &fresh), "the settled image is a render from scratch"); +} + +/// Zoom, pan and edit with `r`; whether each step was previewed, and the pan's and the edit's +/// images. +fn zoom_pan_edit(r: &mut Renders) -> (Vec, Done, Done) { + let (mut s, rect) = document(); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + r.refine(&job(&doc, page, uid, view_at((300.0, 200.0), 4.0, 1280, 960))).unwrap(); + let mut previewed = vec![]; + // A zoom; the preview is kept apart from the full image. + let zoomed = job(&doc, page, uid, view_at((300.0, 200.0), 2.0, 1280, 960)); + previewed.push(r.preview(&zoomed).is_some()); + r.refine(&zoomed).unwrap(); + // A pan at that zoom. + let mut pan = zoomed; + pan.view.origin.0 += 10.0; + previewed.push(r.preview(&pan).is_some()); + let panned = r.refine(&pan).unwrap(); + // An edit there. + s.execute("node.set", &json!({"id": rect, "props": {"x": 120}})).unwrap(); + let edit = job(&s.ds().unwrap().doc, page, uid, pan.view); + previewed.push(r.preview(&edit).is_some()); + let edited = r.refine(&edit).unwrap(); + (previewed, panned, edited) +} + +#[test] +fn pans_and_edits_get_no_preview_and_build_on_the_kept_image() { + let (previewed, panned, edited) = zoom_pan_edit(&mut Renders::with_threshold(0.0)); + assert_eq!(previewed, [true, false, false], "the zoom only"); + assert!(matches!(panned.work, Work::Partial { .. }), "a pan after a previewed zoom renders strips only: {:?}", panned.work); + assert!(matches!(edited.work, Work::Partial { .. }), "an edit renders its area only: {:?}", edited.work); + // The same steps without previews: the same work and the same pixels. + let (none, panned_alone, edited_alone) = zoom_pan_edit(&mut Renders::with_threshold(f64::INFINITY)); + assert_eq!(none, [false, false, false]); + assert_eq!((panned.work, edited.work), (panned_alone.work, edited_alone.work)); + assert!(same_pixels(&panned, &panned_alone) && same_pixels(&edited, &edited_alone)); +} + +#[test] +fn previews_come_for_other_pages_and_files_but_not_in_pixel_preview_or_when_renders_are_quick() { + let (mut s, _) = document(); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + let v = view_at((300.0, 200.0), 1.0, 200, 150); + let mut r = Renders::with_threshold(0.0); + r.refine(&job(&doc, page, uid, v.clone())).unwrap(); + s.execute("page.add", &json!({})).unwrap(); + let ds = s.ds().unwrap(); + assert!(r.preview(&job(&ds.doc, ds.page, uid, v.clone())).is_some(), "another page"); + assert!(r.preview(&job(&doc, page, uid + 1, v.clone())).is_some(), "another file"); + let zoom = view_at((300.0, 200.0), 2.0, 200, 150); + let mut pixel_preview = job(&doc, page, uid, zoom.clone()); + pixel_preview.zoom_preview = false; + assert!(r.preview(&pixel_preview).is_none(), "pixel preview shows real pixels only"); + let mut quick = Renders::with_threshold(f64::INFINITY); + quick.refine(&job(&doc, page, uid, v)).unwrap(); + assert!(quick.preview(&job(&doc, page, uid, zoom)).is_none(), "quick renders show without one"); +} + +#[test] +fn previews_cover_the_view_for_any_zoom_and_density() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let mut r = Renders::with_threshold(0.0); + r.refine(&job(&ds.doc, ds.page, ds.uid, view_at((0.0, 0.0), 0.37, 64, 64))).unwrap(); + let mut n = 0; + // Zooms from 2% to 6400% at 1x, 1.5x and 2x displays, odd sizes, centres off the pixel grid. + for zoom in [0.02, 0.1, 0.333, 1.0, 2.5, 8.0, 64.0] { + for ppp in [1.0, 1.5, 2.0] { + for (w, h) in [(1, 1), (3, 7), (401, 299), (1280, 720)] { + let v = view_at((123.456, -78.9), zoom * ppp, w, h); + let c = coarse_view(&v); + assert!(covers(&c, &v), "coarse view of {v:?}"); + let p = r.preview(&job(&ds.doc, ds.page, ds.uid, v.clone())).expect("a preview"); + assert!(covers(&p.view, &v), "preview of {v:?} shows {:?}", p.view); + n += 1; + } + } + } + assert_eq!(n, 84); +} + +#[test] +fn outline_mode_proofing_and_glass_preview_and_settle_like_fresh_renders() { + let (mut s, rect) = document(); + s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); + let ds = s.ds().unwrap(); + for (outline, proof) in [(false, false), (true, false), (false, true)] { + let mut r = Renders::with_threshold(0.0); + let mut first = job(&ds.doc, ds.page, ds.uid, view_at((250.0, 150.0), 3.0, 300, 200)); + (first.outline, first.proof) = (outline, proof); + r.refine(&first).unwrap(); + let mut next = first.clone(); + next.view = view_at((250.0, 150.0), 0.75, 300, 200); + let p = r.preview(&next).expect("a preview"); + assert!(p.coarse && covers(&p.view, &next.view)); + let d = r.refine(&next).unwrap(); + assert!(same_pixels(&d, &render_now(&next).unwrap()), "outline {outline}, proof {proof}"); + } +} + +#[test] +fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_at_its_zoom() { + let (s, _) = document(); + let ds = s.ds().unwrap(); + let here = Some((ds.uid, ds.page)); + let mut r = Renders::with_threshold(0.0); + let near = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 2.0, 200, 150)); + let refined = r.refine(&near).unwrap(); + let far = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 200, 150)); + let preview = r.preview(&far).unwrap(); + + let mut c = CanvasState { want_scale: near.view.scale, ..Default::default() }; + assert!(shows(&c, &refined, here)); + assert!(!shows(&c, &refined, Some((ds.uid + 1, ds.page))), "another file"); + assert!(!shows(&c, &refined, Some((ds.uid, NodeId(ds.page.0 + 1)))), "another page"); + assert!(!shows(&c, &refined, None), "no file open"); + // Showing the refined image at the zoom asked for: a preview arriving late stays off. + (c.tex_view, c.tex_of, c.tex_coarse) = (Some(refined.view.clone()), here, false); + assert!(!shows(&c, &preview, here)); + // Once the zoom asked for moves on, the preview is the better match. + c.want_scale = far.view.scale; + assert!(shows(&c, &preview, here)); + // Over another preview, or a refined image of another page, it goes on. + c.want_scale = near.view.scale; + c.tex_coarse = true; + assert!(shows(&c, &preview, here)); + (c.tex_coarse, c.tex_of) = (false, Some((ds.uid, NodeId(ds.page.0 + 1)))); + assert!(shows(&c, &preview, here)); +} From d72e5e0516c45e45bcb83655fd75d298e2d167ca Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:15:11 -0400 Subject: [PATCH 2/4] docs: zoom previews in the roadmap, gaps and control protocol (M8) Refs #295 --- ROADMAP.md | 2 ++ docs/control-protocol.md | 5 ++++- docs/gaps.md | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..826a625d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Coarse zoom previews (M8, #295): a new zoom can't reuse the retained canvas image, and the old one, re-projected, covers 1/64 of the view after a zoom out from 800% to 100%, so the page colour showed until the full render was done. When renders from scratch take a frame or more, the render thread first renders the whole view with a quarter of the pixels each way (kept apart from the full image, so pans and edits still build on it) and then refines it unless a newer request waits; the web build does the two in consecutive frames; pixel preview gets none. The canvas never shows an image of another file or page, and a late preview never replaces a refined image at the zoom asked for. `ui.inspect` gives `canvas.quality`, `previewLatencyMs` and `refineLatencyMs`; `zoom_preview_tests`. Measured on Windows (600 layers with shadows, background blurs and glass, 904×860 canvas): 800% → 100% shows the whole view 7–15 ms after the request and the refined image at 63–69 ms (a 56 ms render). Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Coarse zoom previews (M8, #295): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then the refined image; images of another file or page never show. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d..1b6cef46 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -93,7 +93,10 @@ ellipse, its arc handles `arcHandles`, for a selected rectangle, frame, polygon an idle window draws none) with the most frequent repaint requests behind them (`repaintCauses`: `[{at: "file:line", count}]`, every request made in the frame before each one, delayed ones such as the autosave deadline included; egui's `context.rs` begin-pass line means input events) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with -the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter` +the `pixels` re-rendered after a pan or an edit, or `reused`), whether the canvas shows a coarse +zoom preview or the refined image (`canvas.quality`: `preview` or `refined`) and the time from +asking for a render to the frame showing its preview and its refined image (`canvas.previewLatencyMs`, +`canvas.refineLatencyMs`; `null` until there was one). While a prototype plays, `presenter` (full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its `name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the `device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame diff --git a/docs/gaps.md b/docs/gaps.md index 34c4f439..aab68806 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -193,7 +193,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 7. CPU canvas where Figma uses the GPU - **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded, - parallel, retained) and shows the image through wgpu. + parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained + image; when renders from scratch take a frame or more, the whole view is previewed with a + sixteenth of the pixels first, then refined. - **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers. Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows. From 72390d7b51a9ed9cfdbe335176097eb433638036 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:57:58 -0400 Subject: [PATCH 3/4] fix(canvas): preview in the browser and judge previews by coverage (M8) Review of the zoom previews found three problems: - The browser worker never previewed: wasm builds timed every render as 0 ms, below the 12 ms threshold. Renders are now timed with web-time's monotonic clock on every target (already in the tree via the engine), and the inline worker is built everywhere so a test runs it with the production threshold: preview one frame, refine the next, a newer request skips the old refinement. - A preview was refused whenever the texture on screen was refined at the same zoom, even when that texture showed another part of the page (back at 100% about another point after 800%: 1/64 of the view). It now has to show the whole view asked for, not just its zoom. - The latencies read the frame's start time before the inline render and before the texture was set. They now run on the same clock from the request to the image handed to egui as the canvas texture. Refs #295 --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/canvas.rs | 48 ++++++++----- crates/ui-egui/src/canvas_fixes_tests.rs | 2 +- crates/ui-egui/src/idle_tests.rs | 2 +- crates/ui-egui/src/render_worker.rs | 26 ++++---- crates/ui-egui/src/zoom_preview_tests.rs | 85 +++++++++++++++++++++--- 7 files changed, 124 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f68..c97678db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1707,6 +1707,7 @@ dependencies = [ "serde_json", "toml", "wat", + "web-time", ] [[package]] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b14c04f2..65a3592d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,7 @@ log = { workspace = true } base64 = { workspace = true } image = { workspace = true } egui_extras = { workspace = true } +web-time = "1.1.0" [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 90a99384..b42c940b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -24,11 +24,12 @@ pub struct CanvasState { /// The texture is a coarse preview (see [`crate::render_worker`]). pub tex_coarse: bool, submitted: Option, - /// The scale (device pixels per page unit) of the last render asked for. - want_scale: f64, + /// The view of the last render asked for. + want_view: Option, /// What the last canvas render had to do (shown by `ui.inspect`). pub render_work: Option, - /// From asking for a render to the frame that shows its preview, and its refined image (ms). + /// From asking for a render to its preview, and its refined image, handed to egui as the + /// canvas texture (ms; the texture is uploaded when that frame is painted). pub preview_latency_ms: Option, pub refine_latency_ms: Option, dragging: bool, @@ -58,7 +59,7 @@ impl Default for CanvasState { tex_of: None, tex_coarse: false, submitted: None, - want_scale: 0.0, + want_view: None, render_work: None, preview_latency_ms: None, refine_latency_ms: None, @@ -184,19 +185,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let own_cursor = input(app, ui, &resp); crate::live::track_pointer(app, &ctx); app.sync_preedit(); - let now = ctx.input(|i| i.time); - request_render(app, worker, ppp, now); + request_render(app, worker, ppp); // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); if let Some(done) = worker.poll() && shows(&app.canvas, &done, shown) { - let latency = Some((now - done.at).max(0.0) * 1000.0); - if done.coarse { - app.canvas.preview_latency_ms = latency; - } else { - app.canvas.refine_latency_ms = latency; + if !done.coarse { app.canvas.render_work = Some(done.work); app.ui.render_ms = done.ms; } @@ -210,6 +206,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { Some(t) => t.set(done.image, filter), None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", done.image, filter)), } + let latency = Some(done.at.elapsed().as_secs_f64() * 1000.0); + if done.coarse { + app.canvas.preview_latency_ms = latency; + } else { + app.canvas.refine_latency_ms = latency; + } app.canvas.tex_view = Some(done.view); app.canvas.tex_of = Some((done.uid, done.page)); app.canvas.tex_coarse = done.coarse; @@ -296,16 +298,30 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String { } /// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and -/// not a coarse preview over a refined image already at the zoom asked for. +/// not a coarse preview over a refined image that already shows the whole view asked for at its +/// zoom (the same zoom elsewhere on the page is no such image). fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool { if shown != Some((done.uid, done.page)) { return false; } - let refined_here = !c.tex_coarse && c.tex_of == shown && c.tex_view.as_ref().is_some_and(|v| v.scale.to_bits() == c.want_scale.to_bits()); + let refined_here = !c.tex_coarse + && c.tex_of == shown + && c.tex_view + .as_ref() + .zip(c.want_view.as_ref()) + .is_some_and(|(have, want)| have.scale.to_bits() == want.scale.to_bits() && covers(have, want)); !(done.coarse && refined_here) } -fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, now: f64) { +/// Whether `outer` shows all of the page that `inner` does. +fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool { + let (o, i) = (outer.page_rect(), inner.page_rect()); + // Rounding in the snapped origin, well under a device pixel. + let eps = 1e-6 / inner.scale.max(1e-9); + o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps +} + +fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; let (mut ox, mut oy) = ds.view.origin(); @@ -352,9 +368,9 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, now: f64) { proof, uid: ds.uid, zoom_preview: ds.view.pixel_preview_scale().is_none(), - at: now, + at: web_time::Instant::now(), }; - app.canvas.want_scale = scale; + app.canvas.want_view = Some(job.view.clone()); worker.submit(job); app.canvas.submitted = Some(key); } diff --git a/crates/ui-egui/src/canvas_fixes_tests.rs b/crates/ui-egui/src/canvas_fixes_tests.rs index 1c4938d7..0220b353 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -64,7 +64,7 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: proof: false, uid: 0, zoom_preview: false, - at: 0.0, + at: web_time::Instant::now(), } } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 4a277ae2..0b848747 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -214,7 +214,7 @@ fn a_finished_background_render_wakes_the_ui() { proof: false, uid: 0, zoom_preview: false, - at: 0.0, + at: web_time::Instant::now(), }); let start = Instant::now(); while wakes.load(Ordering::SeqCst) == 0 { diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index ee978890..3ad9c54c 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -14,6 +14,8 @@ use std::sync::Arc; use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; use figcraft_render::{Pixmap, Retained, View, Work}; +// A monotonic clock in the browser too: preview decisions rest on how long renders take there. +use web_time::Instant; /// A preview has this many times fewer pixels each way than the view it stands in for. pub const PREVIEW_FACTOR: f64 = 4.0; @@ -40,8 +42,8 @@ pub struct Job { /// A coarse preview may come first when the zoom changes (not in pixel preview, whose pixels /// are the point). pub zoom_preview: bool, - /// When the UI asked for it (`egui` input time, seconds): how long the image took to show. - pub at: f64, + /// When the UI asked for it: how long the image took to reach the screen. + pub at: Instant, } pub struct Done { @@ -57,7 +59,7 @@ pub struct Done { /// A coarse preview, refined by a later image unless a newer request replaced it. pub coarse: bool, /// [`Job::at`]. - pub at: f64, + pub at: Instant, } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -84,8 +86,7 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP /// Render `view` of the job (its own view, or the coarse one standing in for it). fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { - #[cfg(not(target_arch = "wasm32"))] - let t0 = std::time::Instant::now(); + let t0 = Instant::now(); // The canvas is a preview: what's too small to see in detail at this zoom is approximated. let opts = figcraft_render::Options { outline_mode: job.outline, @@ -101,10 +102,7 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option< if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); } - #[cfg(not(target_arch = "wasm32"))] let ms = t0.elapsed().as_secs_f64() * 1000.0; - #[cfg(target_arch = "wasm32")] - let ms = 0.0; Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at }) } @@ -257,10 +255,10 @@ impl Worker { } } -/// Renders inline, one image a frame: a preview first when one is due, the refined image in the -/// next frame unless a newer request replaced the job. -#[cfg(target_arch = "wasm32")] -pub struct Worker { +/// The browser's worker: renders inline, one image a frame: a preview first when one is due, the +/// refined image in the next frame unless a newer request replaced the job. Built everywhere so +/// tests run it. +pub struct Inline { ctx: egui::Context, pending: Option, previewed: bool, @@ -268,7 +266,9 @@ pub struct Worker { } #[cfg(target_arch = "wasm32")] -impl Worker { +pub type Worker = Inline; + +impl Inline { pub fn new(ctx: egui::Context) -> Self { Self { ctx, pending: None, previewed: false, renders: Renders::default() } } diff --git a/crates/ui-egui/src/zoom_preview_tests.rs b/crates/ui-egui/src/zoom_preview_tests.rs index cd17a70a..68704571 100644 --- a/crates/ui-egui/src/zoom_preview_tests.rs +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -1,7 +1,8 @@ //! Coarse previews while the zoom changes (#295): a new zoom first shows the whole view coarsely, //! then the refined image, which matches a render from scratch; pans and edits still build on the -//! kept image; previews never replace a refined image already at the zoom asked for, and no image -//! of another file or page goes on screen. +//! kept image; previews never replace a refined image that already shows the whole view asked +//! for, and no image of another file or page goes on screen. The browser's inline worker previews +//! by the same measure as the render thread. use std::sync::Arc; @@ -12,7 +13,7 @@ use figcraft_render::{View, Work}; use serde_json::json; use super::{CanvasState, shows}; -use crate::render_worker::{Done, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; +use crate::render_worker::{Done, Inline, Job, PREVIEW_FACTOR, Renders, coarse_view, render_now}; /// A page of ellipses with a rectangle on top; the session and the rectangle. fn document() -> (Session, NodeId) { @@ -44,7 +45,7 @@ fn job(doc: &Arc, page: NodeId, uid: u64, view: View) -> Job { proof: false, uid, zoom_preview: true, - at: 0.0, + at: web_time::Instant::now(), } } @@ -189,7 +190,7 @@ fn outline_mode_proofing_and_glass_preview_and_settle_like_fresh_renders() { } #[test] -fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_at_its_zoom() { +fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_of_its_view() { let (s, _) = document(); let ds = s.ds().unwrap(); let here = Some((ds.uid, ds.page)); @@ -199,21 +200,85 @@ fn the_canvas_shows_images_of_its_own_page_and_never_a_coarser_one_at_its_zoom() let far = job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), 0.5, 200, 150)); let preview = r.preview(&far).unwrap(); - let mut c = CanvasState { want_scale: near.view.scale, ..Default::default() }; + let mut c = CanvasState { want_view: Some(near.view.clone()), ..Default::default() }; assert!(shows(&c, &refined, here)); assert!(!shows(&c, &refined, Some((ds.uid + 1, ds.page))), "another file"); assert!(!shows(&c, &refined, Some((ds.uid, NodeId(ds.page.0 + 1)))), "another page"); assert!(!shows(&c, &refined, None), "no file open"); - // Showing the refined image at the zoom asked for: a preview arriving late stays off. + // Showing the refined image of the view asked for: a preview arriving late stays off. (c.tex_view, c.tex_of, c.tex_coarse) = (Some(refined.view.clone()), here, false); assert!(!shows(&c, &preview, here)); - // Once the zoom asked for moves on, the preview is the better match. - c.want_scale = far.view.scale; + // Once the view asked for moves on, the preview is the better match. + c.want_view = Some(far.view.clone()); assert!(shows(&c, &preview, here)); // Over another preview, or a refined image of another page, it goes on. - c.want_scale = near.view.scale; + c.want_view = Some(near.view.clone()); c.tex_coarse = true; assert!(shows(&c, &preview, here)); (c.tex_coarse, c.tex_of) = (false, Some((ds.uid, NodeId(ds.page.0 + 1)))); assert!(shows(&c, &preview, here)); } + +#[test] +fn a_preview_replaces_a_refined_image_of_the_same_zoom_elsewhere_on_the_page() { + // 800 × 600: A at 100% from the page origin, B at 800% about its top-left, A' back at 100% + // about B's bottom-right corner. A shows 100 × 75 of A', 1/64 of it. + let (s, _) = document(); + let ds = s.ds().unwrap(); + let here = Some((ds.uid, ds.page)); + let view = |origin, scale| View { origin, scale, width: 800, height: 600 }; + let a = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 1.0)); + let b = job(&ds.doc, ds.page, ds.uid, view((0.0, 0.0), 8.0)); + let a2 = job(&ds.doc, ds.page, ds.uid, view((-700.0, -525.0), 1.0)); + let mut r = Renders::with_threshold(0.0); + let refined_a = r.refine(&a).unwrap(); + // The canvas shows A; B is asked for, then A' while B renders. The thread finishes B, then + // previews A' before the canvas polls: the queue holds [refined B, preview A'], and a poll + // takes the last. + let queue = [r.refine(&b).unwrap(), r.preview(&a2).expect("A' is a new zoom for the thread")]; + let polled = queue.last().unwrap(); + assert!(polled.coarse && covers(&polled.view, &a2.view)); + let c = CanvasState { + tex_view: Some(refined_a.view.clone()), + tex_of: here, + tex_coarse: false, + want_view: Some(a2.view.clone()), + ..Default::default() + }; + assert!(shows(&c, polled, here), "A at the same zoom shows 1/64 of A': the preview of all of it goes on"); + // Once A' itself is refined on screen, a preview of it would be a step down. + let refined_a2 = r.refine(&a2).unwrap(); + let c = CanvasState { tex_view: Some(refined_a2.view.clone()), ..c }; + assert!(!shows(&c, polled, here)); +} + +#[test] +fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_frame() { + // Big enough that a render from scratch takes well over a frame: blurs and glass on 2400². + let (mut s, rect) = document(); + s.execute("effect.add", &json!({"ids": [rect], "type": "GLASS"})).unwrap(); + s.execute("effect.add", &json!({"ids": [rect], "type": "BACKGROUND_BLUR"})).unwrap(); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view_at((300.0, 200.0), scale, 2400, 2400)); + // The production threshold, as the browser has it. + let mut w = Inline::new(egui::Context::default()); + w.submit(at(2.0)); + let first = w.poll().expect("the first image"); + assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by"); + assert!(first.ms >= 12.0, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); + // A new zoom: the preview this frame, the refined image the next. + w.submit(at(1.0)); + let p = w.poll().expect("a preview"); + assert!(p.coarse && w.in_flight()); + let d = w.poll().expect("the refined image"); + assert!(!d.coarse && !w.in_flight()); + assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap())); + // A newer request before the refinement: refining the old one is skipped. + w.submit(at(0.5)); + assert!(w.poll().is_some_and(|d| d.coarse)); + w.submit(at(0.25)); + let p = w.poll().expect("the newer one's preview"); + assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12); + assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25)); + assert!(w.poll().is_none() && !w.in_flight()); +} From 82d84d86b1eb431787b9b59673913ad608895d2b Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Wed, 14 Oct 2026 11:59:24 -0400 Subject: [PATCH 4/4] docs: zoom preview latency boundary and measurements (M8) Refs #295 --- ROADMAP.md | 2 +- docs/control-protocol.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 826a625d..03cbe6c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,7 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-14** · Coarse zoom previews (M8, #295): a new zoom can't reuse the retained canvas image, and the old one, re-projected, covers 1/64 of the view after a zoom out from 800% to 100%, so the page colour showed until the full render was done. When renders from scratch take a frame or more, the render thread first renders the whole view with a quarter of the pixels each way (kept apart from the full image, so pans and edits still build on it) and then refines it unless a newer request waits; the web build does the two in consecutive frames; pixel preview gets none. The canvas never shows an image of another file or page, and a late preview never replaces a refined image at the zoom asked for. `ui.inspect` gives `canvas.quality`, `previewLatencyMs` and `refineLatencyMs`; `zoom_preview_tests`. Measured on Windows (600 layers with shadows, background blurs and glass, 904×860 canvas): 800% → 100% shows the whole view 7–15 ms after the request and the refined image at 63–69 ms (a 56 ms render). Numbers unchanged. +- **2026-10-14** · Coarse zoom previews (M8, #295): a new zoom can't reuse the retained canvas image, and the old one, re-projected, covers 1/64 of the view after a zoom out from 800% to 100%, so the page colour showed until the full render was done. When renders from scratch take a frame or more, the render thread first renders the whole view with a quarter of the pixels each way (kept apart from the full image, so pans and edits still build on it) and then refines it unless a newer request waits; the web build does the two in consecutive frames (renders are timed with a monotonic clock there too); pixel preview gets none. The canvas never shows an image of another file or page, and a late preview never replaces a refined image that already shows the whole view asked for. `ui.inspect` gives `canvas.quality`, `previewLatencyMs` and `refineLatencyMs` (request to texture handed to egui); `zoom_preview_tests`. Measured on Windows (600 layers with shadows, background blurs and glass, 904×860 canvas): 800% → 100% has the preview at 14–15 ms and the refined image at 63–73 ms (a 50 ms render); 100% → 800% about 15 ms and 190–198 ms. Left: the first render, and the first slow one after quick ones, come without a preview (the measure is the previous render from scratch). Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1b6cef46..df50a7fe 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -95,8 +95,10 @@ an idle window draws none) with the most frequent repaint requests behind them ( the autosave deadline included; egui's `context.rs` begin-pass line means input events) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with the `pixels` re-rendered after a pan or an edit, or `reused`), whether the canvas shows a coarse zoom preview or the refined image (`canvas.quality`: `preview` or `refined`) and the time from -asking for a render to the frame showing its preview and its refined image (`canvas.previewLatencyMs`, -`canvas.refineLatencyMs`; `null` until there was one). While a prototype plays, `presenter` +asking for a render to its preview and its refined image being handed to egui as the canvas +texture (`canvas.previewLatencyMs`, `canvas.refineLatencyMs`, on a monotonic clock, rendering +included in the browser too; the GPU upload and presenting the frame come after; `null` until +there was one). While a prototype plays, `presenter` (full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its `name`, `start`, `history`, open `overlays`, how many delayed mouse triggers are `waiting`, the screen's `scroll` offset and `scrollRange`, the `device` (and whether it is turned to `landscape`, its `deviceColor`) and the `viewport` of the frame