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
This commit is contained in:
tylerjenningsw
2026-10-14 11:57:58 -04:00
parent d72e5e0516
commit 72390d7b51
7 changed files with 124 additions and 41 deletions
Generated
+1
View File
@@ -1707,6 +1707,7 @@ dependencies = [
"serde_json",
"toml",
"wat",
"web-time",
]
[[package]]
+1
View File
@@ -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 }
+32 -16
View File
@@ -24,11 +24,12 @@ pub struct CanvasState {
/// The texture is a coarse preview (see [`crate::render_worker`]).
pub tex_coarse: bool,
submitted: Option<JobKey>,
/// 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<figcraft_render::View>,
/// What the last canvas render had to do (shown by `ui.inspect`).
pub render_work: Option<figcraft_render::Work>,
/// 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<f64>,
pub refine_latency_ms: Option<f64>,
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);
}
+1 -1
View File
@@ -64,7 +64,7 @@ fn big_job(doc: &Arc<figcraft_doc::Document>, page: figcraft_doc::NodeId, scale:
proof: false,
uid: 0,
zoom_preview: false,
at: 0.0,
at: web_time::Instant::now(),
}
}
+1 -1
View File
@@ -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 {
+13 -13
View File
@@ -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<Done> {
#[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<Job>,
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() }
}
+75 -10
View File
@@ -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<Document>, 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());
}