diff --git a/frontend/resources/wasm-playground/js/lib.js b/frontend/resources/wasm-playground/js/lib.js index 8096afd5ca..9692ebfa22 100644 --- a/frontend/resources/wasm-playground/js/lib.js +++ b/frontend/resources/wasm-playground/js/lib.js @@ -104,19 +104,35 @@ export function createRendererAdapter(module, scheduler = globalThis) { scheduler.clearTimeout(timer); timer = scheduler.setTimeout(finish, 100); }; + // Mirrors schedule-zoom! in app.main.ui.workspace.viewport.actions: + // accumulate wheel zoom and flush at most one _set_view + _render_from_cache + // per animation frame. When the playground migrates to ClojureScript, call + // that function directly instead of duplicating this scheduling. + const zoom = { factor: 1, pt: null, frame: undefined }; + const flushZoom = () => { + zoom.frame = undefined; + if (disposed) return; + stopRender(); + const factor = zoom.factor; + const [px, py] = zoom.pt; + zoom.factor = 1; + zoom.pt = null; + const nextScale = Math.max(0.01, Math.min(100, view.scale * factor)); + view.x += px / nextScale - px / view.scale; + view.y += py / nextScale - py / view.scale; + view.scale = nextScale; + update(); + }; listen( "wheel", (event) => { event.preventDefault(); begin(); - const nextScale = Math.max( - 0.01, - Math.min(100, view.scale * (event.deltaY < 0 ? 1.1 : 0.9)), - ); - view.x += event.offsetX / nextScale - event.offsetX / view.scale; - view.y += event.offsetY / nextScale - event.offsetY / view.scale; - view.scale = nextScale; - update(); + zoom.factor *= event.deltaY < 0 ? 1.1 : 0.9; + zoom.pt = [event.offsetX, event.offsetY]; + if (zoom.frame === undefined) { + zoom.frame = scheduler.requestAnimationFrame(flushZoom); + } }, { passive: false }, ); @@ -143,6 +159,8 @@ export function createRendererAdapter(module, scheduler = globalThis) { detach = () => { for (const remove of listeners) remove(); scheduler.clearTimeout(timer); + if (zoom.frame !== undefined) scheduler.cancelAnimationFrame(zoom.frame); + zoom.frame = undefined; panning = false; active = false; }; diff --git a/frontend/scripts/renderer-benchmarks/tests/adapter.test.js b/frontend/scripts/renderer-benchmarks/tests/adapter.test.js index e1014a6468..eb4736ffff 100644 --- a/frontend/scripts/renderer-benchmarks/tests/adapter.test.js +++ b/frontend/scripts/renderer-benchmarks/tests/adapter.test.js @@ -23,6 +23,7 @@ function fixture() { }, }; const state = { rendered: 0, cleaned: false, deleted: false, lost: false }; + const calls = { views: [], cached: 0, start: 0, end: 0 }; const gl = { getExtension: (name) => name === "WEBGL_lose_context" @@ -47,10 +48,18 @@ function fixture() { _init() {}, _set_render_options() {}, _set_browser() {}, - _set_view_start() {}, - _set_view() {}, - _set_view_end() {}, - _render_from_cache() {}, + _set_view_start() { + calls.start++; + }, + _set_view(...args) { + calls.views.push(args); + }, + _set_view_end() { + calls.end++; + }, + _render_from_cache() { + calls.cached++; + }, _render() { state.rendered++; return 2; @@ -62,22 +71,122 @@ function fixture() { const adapter = createRendererAdapter(module, scheduler); adapter.assignCanvas(canvas); adapter.setupInteraction(canvas); - return { adapter, state, canvas, frames, timers, module }; + return { adapter, state, calls, canvas, frames, timers, module }; } +test("wheel zoom flushes once per animation frame", () => { + const f = fixture(); + const wheel = (deltaY, offsetX, offsetY) => + f.canvas.dispatchEvent( + Object.assign(new Event("wheel"), { deltaY, offsetX, offsetY }), + ); + const runFrame = () => { + const [id] = f.frames.keys(); + const callback = f.frames.get(id); + f.frames.delete(id); + callback(16); + }; + + wheel(-1, 10, 20); + wheel(-1, 30, 40); + wheel(-1, 50, 60); + + assert.equal(f.frames.size, 1); + assert.equal(f.calls.start, 1); + assert.equal(f.calls.views.length, 0); + assert.equal(f.calls.cached, 0); + + runFrame(); + + assert.equal(f.calls.views.length, 1); + assert.equal(f.calls.cached, 1); + const [scale, x, y] = f.calls.views[0]; + assert.ok(Math.abs(scale - 1.1 ** 3) < 1e-9, `unexpected scale ${scale}`); + assert.ok( + Math.abs(x - (50 / 1.1 ** 3 - 50)) < 1e-9, + `unexpected x anchor ${x}`, + ); + assert.ok( + Math.abs(y - (60 / 1.1 ** 3 - 60)) < 1e-9, + `unexpected y anchor ${y}`, + ); + assert.equal(f.timers.size, 1); + + // The flush releases the frame slot and resets the accumulated factor. + wheel(-1, 10, 20); + assert.equal(f.frames.size, 1); + assert.equal(f.calls.cached, 1); + + runFrame(); + + assert.equal(f.calls.views.length, 2); + assert.equal(f.calls.cached, 2); + assert.ok( + Math.abs(f.calls.views[1][0] - 1.1 ** 4) < 1e-9, + `unexpected scale ${f.calls.views[1][0]}`, + ); + f.adapter.dispose(); +}); + +test("wheel zoom clamps the accumulated factor to the scale bounds", () => { + const f = fixture(); + const wheel = (deltaY) => + f.canvas.dispatchEvent( + Object.assign(new Event("wheel"), { deltaY, offsetX: 10, offsetY: 20 }), + ); + const runFrame = () => { + const [id] = f.frames.keys(); + const callback = f.frames.get(id); + f.frames.delete(id); + callback(16); + }; + + for (let index = 0; index < 100; index++) wheel(-1); + runFrame(); + assert.equal(f.calls.views[0][0], 100); + + for (let index = 0; index < 200; index++) wheel(1); + runFrame(); + assert.equal(f.calls.views[1][0], 0.01); + f.adapter.dispose(); +}); + +test("pointer pan still renders on every move", () => { + const f = fixture(); + f.canvas.dispatchEvent( + Object.assign(new Event("pointerdown"), { + clientX: 0, + clientY: 0, + pointerId: 1, + }), + ); + f.canvas.dispatchEvent( + Object.assign(new Event("pointermove"), { clientX: 10, clientY: 20 }), + ); + f.canvas.dispatchEvent( + Object.assign(new Event("pointermove"), { clientX: 30, clientY: 40 }), + ); + + assert.equal(f.calls.views.length, 2); + assert.equal(f.calls.cached, 2); + assert.equal(f.frames.size, 0); + f.adapter.dispose(); +}); + test("disposing an adapter settles pending work and isolates late callbacks", async () => { const first = fixture(); const second = fixture(); const pending = first.adapter.renderFull(); - const lateFrame = [...first.frames.values()][0]; + const lateRender = [...first.frames.values()][0]; first.canvas.dispatchEvent( Object.assign(new Event("wheel"), { deltaY: 1, offsetX: 10, offsetY: 20 }), ); - const lateTimer = [...first.timers.values()][0]; + assert.equal(first.frames.size, 1); + const lateZoom = [...first.frames.values()][0]; first.adapter.dispose(); first.adapter.dispose(); - lateFrame(16); - lateTimer(); + lateRender(16); + lateZoom(16); assert.equal(await pending, false); assert.equal(first.frames.size + first.timers.size, 0); assert.deepEqual(first.state, { @@ -86,8 +195,9 @@ test("disposing an adapter settles pending work and isolates late callbacks", as deleted: true, lost: true, }); + assert.equal(first.calls.cached, 0); first.canvas.dispatchEvent(new Event("wheel")); - assert.equal(first.timers.size, 0); + assert.equal(first.frames.size + first.timers.size, 0); const completed = second.adapter.renderFull(); [...second.frames.values()][0](32); assert.equal(await completed, true);