diff --git a/Cargo.lock b/Cargo.lock index 467e7a84..48bd7129 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1708,6 +1708,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 beec9aa3..056a9b12 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -11,16 +11,27 @@ 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 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 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, space_pan: bool, middle_pan: Option<(Pos2, f64, f64)>, @@ -45,8 +56,13 @@ impl Default for CanvasState { rect: Rect::NOTHING, texture: None, tex_view: None, + tex_of: None, + tex_coarse: false, submitted: None, + want_view: None, render_work: None, + preview_latency_ms: None, + refine_latency_ms: None, dragging: false, space_pan: false, middle_pan: None, @@ -172,7 +188,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { 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. - 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) + { + if !done.coarse { + 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 @@ -183,9 +206,15 @@ 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.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 +229,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,6 +297,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 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() + .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) +} + +/// 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; @@ -313,7 +366,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: web_time::Instant::now(), }; + app.canvas.want_view = Some(job.view.clone()); worker.submit(job); app.canvas.submitted = Some(key); } @@ -1364,3 +1421,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 5a36b712..3b593dae 100644 --- a/crates/ui-egui/src/canvas_fixes_tests.rs +++ b/crates/ui-egui/src/canvas_fixes_tests.rs @@ -63,6 +63,9 @@ fn big_job(doc: &Arc, page: figcraft_doc::NodeId, scale: skip: vec![], solo: None, proof: false, + uid: 0, + zoom_preview: false, + at: web_time::Instant::now(), } } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e5757..0b848747 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: web_time::Instant::now(), }); 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 9f166dfa..d026e00c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1213,6 +1213,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..3ad9c54c 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -3,12 +3,24 @@ //! 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; 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; +/// 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 { @@ -25,6 +37,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: how long the image took to reach the screen. + pub at: Instant, } pub struct Done { @@ -34,6 +53,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: Instant, } /// A render of a document in `profile` as a texture image for the screen (sRGB). @@ -58,9 +84,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP } } -fn run(job: &Job, retained: &mut Retained) -> Option { - #[cfg(not(target_arch = "wasm32"))] - let t0 = std::time::Instant::now(); +/// 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 { + 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, @@ -71,16 +97,86 @@ 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); } - #[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 }) + 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 +200,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 +255,44 @@ impl Worker { } } -#[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, - 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 type Worker = Inline; + +impl Inline { + 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..68704571 --- /dev/null +++ b/crates/ui-egui/src/zoom_preview_tests.rs @@ -0,0 +1,284 @@ +//! 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 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; + +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, 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) { + 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: web_time::Instant::now(), + } +} + +/// 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_of_its_view() { + 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_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 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 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_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()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3a4d10ae..14840ad7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -95,7 +95,12 @@ 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 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 diff --git a/docs/gaps.md b/docs/gaps.md index 9a2153f8..07192dfd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -194,7 +194,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.