From 28342f1ff8947900167af38a46f245cea3849257 Mon Sep 17 00:00:00 2001 From: gherkin Date: Tue, 27 Oct 2026 16:18:44 +0100 Subject: [PATCH 1/2] Show the backdrop only where the buffer does not reach (#131, #130) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- tests/browser/backdrop.spec.js | 111 ++++++++++++++++++++++++++------- web/render.js | 34 +++++++++- 2 files changed, 119 insertions(+), 26 deletions(-) diff --git a/tests/browser/backdrop.spec.js b/tests/browser/backdrop.spec.js index ca37dc1..fe3b854 100644 --- a/tests/browser/backdrop.spec.js +++ b/tests/browser/backdrop.spec.js @@ -39,6 +39,16 @@ async function backdrops(page) { return page.evaluate(() => window.__pcbaTest.counters().backdrops); } +// A backdrop for the current view is drawn. It is displayed only where the +// buffer does not reach, so this is not the same as being on screen. +async function ready(page) { + return page.evaluate(() => allcanvas.front._backdropReady === true); +} + +function shown(page) { + return page.evaluate(() => allcanvas.front.backdrop.style.display); +} + async function center(page) { const box = await page.locator('#frontcanvas').boundingBox(); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; @@ -147,6 +157,58 @@ test('a fast zoom out shows the board at the edges', async ({ page }) => { expect(agreement(without, fit)).toBeLessThan(0.9); }); +// The buffer is see-through outside the board outline. A backdrop under it +// showed there, scaled up and blurred, when the buffer covered the whole view. +test('the backdrop is not shown while the buffer covers the view', async ({ page }) => { + await load(page); + expect(await ready(page)).toBe(true); + expect(await shown(page)).toBe('none'); + + await zoomIn(page, 8); + expect(await shown(page)).toBe('none'); + + // A fast zoom out leaves the edges without buffer, and the backdrop shows. + expect(await page.evaluate(() => { + const t = allcanvas.front.transform; + t.zoom = 1; t.panx = 0; t.pany = 0; + updateCSSTransform(allcanvas.front); + return allcanvas.front.backdrop.style.display; + })).toBe('block'); + + // The renders catch up and it is hidden again. + await page.evaluate(() => redrawCanvas(allcanvas.front)); + await waitIdle(page); + expect(await shown(page)).toBe('none'); +}); + +// After a layer toggle the old backdrop showed outside the board outline +// until the new one came. The first render of the new state covers the view, +// so the old backdrop must be hidden from then on. +test('the old backdrop is not shown after a layer toggle', async ({ page }) => { + await load(page); + const first = await backdrops(page); + // Hold the worker's next backdrop, so only the renders come in. + await page.evaluate(() => { + window.__held = null; + _worker.onmessage = (e) => { + if (e.data.type === 'backdrop' && !window.__held) window.__held = e; + else handleWorkerMessage(e); + }; + }); + await page.keyboard.press('s'); + await page.waitForFunction(() => window.__held !== null, null, { timeout: 30000 }); + expect(await backdrops(page)).toBe(first); + expect(await shown(page)).toBe('none'); + + await page.evaluate(() => { + _worker.onmessage = handleWorkerMessage; + handleWorkerMessage(window.__held); + }); + await waitIdle(page); + expect(await backdrops(page)).toBe(first + 1); + expect(await shown(page)).toBe('none'); +}); + // The worker keeps the backdrop's layer canvases between draws. Cleared under // the last draw's transform, they kept part of the board at the old rotation. // Only a board off the positive quadrant shows it. After a few turns the @@ -157,6 +219,8 @@ test('the backdrop shows no old rotation after rotating', async ({ page }) => { for (const c of document.querySelectorAll('#frontcanvas canvas')) { if (c !== allcanvas.front.backdrop) c.style.visibility = 'hidden'; } + // The buffer covers the view, so the backdrop is not displayed. + allcanvas.front.backdrop.style.display = 'block'; }); return boardMask(page); }; @@ -184,22 +248,21 @@ test('the backdrop shows no old rotation after rotating', async ({ page }) => { // one, as a second board, until the new backdrop arrived. test('the old backdrop is hidden while the new rotation is drawn', async ({ page }) => { await load(page); - const shown = () => page.evaluate(() => allcanvas.front.backdrop.style.display); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); const first = await backdrops(page); expect(await page.evaluate(() => { rotateBoard(1); - return allcanvas.front.backdrop.style.display; - })).toBe('none'); + return [allcanvas.front.backdrop.style.display, allcanvas.front._backdropReady]; + })).toEqual(['none', false]); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); expect(await backdrops(page)).toBe(first + 1); // A full turn brings back the old key, and it must still be drawn again. for (let i = 0; i < 3; i++) await page.evaluate(() => rotateBoard(1)); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); }); // A new size of the view gives a new fit. The backdrop at the old fit showed @@ -208,45 +271,45 @@ test('the old backdrop is hidden when the view changes size', async ({ page }) = // Both sides are drawn from FB on, which takes several seconds a step. test.setTimeout(60000); await load(page); - const shown = () => page.evaluate(() => allcanvas.front.backdrop.style.display); // The window. The resize event comes later, so record whether it was hidden. await page.evaluate(() => { window.__hidden = false; - const c = allcanvas.front.backdrop; - new MutationObserver(() => { - if (c.style.display === 'none') window.__hidden = true; - }).observe(c, { attributes: true, attributeFilter: ['style'] }); + const hide = window.hideBackdrop; + window.hideBackdrop = (ld) => { + if (ld === allcanvas.front) window.__hidden = true; + hide(ld); + }; }); await page.setViewportSize({ width: 1000, height: 650 }); await page.waitForFunction(() => window.__hidden, null, { timeout: 10000 }); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); // F to FB, as the G key does. expect(await page.evaluate(() => { showSide('FB'); - return allcanvas.front.backdrop.style.display; - })).toBe('none'); + return allcanvas.front._backdropReady; + })).toBe(false); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); // Row to column. expect(await page.evaluate(() => { setCanvasDirection('column'); - return allcanvas.front.backdrop.style.display; - })).toBe('none'); + return allcanvas.front._backdropReady; + })).toBe(false); await waitIdle(page); - expect(await shown()).toBe('block'); + expect(await ready(page)).toBe(true); }); // The same fit keeps the backdrop. A resize to the same size must not hide it. -test('a resize with the same fit keeps the backdrop shown', async ({ page }) => { +test('a resize with the same fit keeps the backdrop', async ({ page }) => { await load(page); expect(await page.evaluate(() => { resizeAll(); - return allcanvas.front.backdrop.style.display; - })).toBe('block'); + return allcanvas.front._backdropReady; + })).toBe(true); }); // A backdrop the worker is still drawing when the board turns is for the old @@ -269,12 +332,12 @@ test('a backdrop drawn for the old rotation is dropped', async ({ page }) => { rotateBoard(1); _worker.onmessage = handleWorkerMessage; handleWorkerMessage(window.__held); - return allcanvas.front.backdrop.style.display; - })).toBe('none'); + return allcanvas.front._backdropReady; + })).toBe(false); expect(await backdrops(page)).toBe(first); await waitIdle(page); - expect(await page.evaluate(() => allcanvas.front.backdrop.style.display)).toBe('block'); + expect(await ready(page)).toBe(true); expect(await backdrops(page)).toBe(first + 1); }); diff --git a/web/render.js b/web/render.js index f238a4e..8a0260f 100644 --- a/web/render.js +++ b/web/render.js @@ -200,6 +200,10 @@ function handleWorkerMessage(e) { // faster than the renders arrive, or past what the buffer holds, leaves the // edges empty. The backdrop shows there instead, blurred. It is moved by // CSS for pan and zoom, and drawn again only when what is drawn changes. +// +// It is shown only where the buffer does not reach. The buffer is see-through +// outside the board outline, and a backdrop under it shows there: blurred at +// a high zoom, and in the old state for a moment after a layer toggle. var _backdropTimers = {}; // side -> timeout var _backdropPending = {}; // side -> true while the worker draws it @@ -211,6 +215,7 @@ var _backdropStale = {}; // side -> drop the one the worker is drawing function hideBackdrop(layerdict) { if (!layerdict.backdrop) return; layerdict.backdrop.style.display = "none"; + layerdict._backdropReady = false; layerdict._backdropKey = null; if (_backdropPending[layerdict.layer]) _backdropStale[layerdict.layer] = true; } @@ -277,7 +282,7 @@ function blitBackdrop(msg) { ctx.clearRect(0, 0, c.width, c.height); ctx.drawImage(bitmap, 0, 0); bitmap.close(); - c.style.display = "block"; + layerdict._backdropReady = true; updateBackdropTransform(layerdict); if (!_stats[msg.side]) _stats[msg.side] = makeStatsTracker(); @@ -294,6 +299,26 @@ function updateBackdropTransform(layerdict) { var tx = t.zoom * t.panx / devicePixelRatio; var ty = t.zoom * t.pany / devicePixelRatio; c.style.transform = "translate(" + tx + "px," + ty + "px) scale(" + t.zoom + ")"; + c.style.display = layerdict._backdropReady && !bufferCoversView(layerdict) ? "block" : "none"; +} + +// Whether the drawn part of the buffer covers the whole view. Placed on +// screen as updateCSSTransform() places it. Until the overscan pieces are +// all in, only the viewport of the render is drawn. +function bufferCoversView(layerdict) { + var buf = layerdict._bufferState, r = layerdict._drawnRect; + if (!buf || !r) return false; + if (!_piecesPending[layerdict.layer]) r = { x: 0, y: 0, w: layerdict._bufW, h: layerdict._bufH }; + var div = document.getElementById(layerdict.layer === "B" ? "backcanvas" : "frontcanvas"); + if (!div) return false; + var t = layerdict.transform; + var os = layerdict._overscan || { x: 0, y: 0 }; + var k = t.zoom / buf.zoom; + var tx = t.zoom * (t.panx - buf.panx) - k * os.x; + var ty = t.zoom * (t.pany - buf.pany) - k * os.y; + var x0 = (tx + k * r.x) / devicePixelRatio, x1 = (tx + k * (r.x + r.w)) / devicePixelRatio; + var y0 = (ty + k * r.y) / devicePixelRatio, y1 = (ty + k * (r.y + r.h)) / devicePixelRatio; + return x0 <= 0 && y0 <= 0 && x1 >= div.clientWidth && y1 >= div.clientHeight; } // ---- Render performance stats ---- @@ -485,6 +510,7 @@ function blitBitmaps(msg) { // The canvases no longer match _bufferState, so the next render must not // carry them. layerdict._content = null; + layerdict._drawnRect = null; return; } @@ -505,6 +531,7 @@ function blitBitmaps(msg) { layerdict._bufW = msg.bufW; layerdict._bufH = msg.bufH; layerdict._content = content; + layerdict._drawnRect = msg.rect; // A layer with no bitmap has no content and is cleared. var canvases = pieceCanvases(layerdict, msg); @@ -632,7 +659,10 @@ function blitPiece(msg) { return; } drawPieceBitmaps(pieceCanvases(layerdict, msg), msg.rect); - if (msg.done) _piecesPending[msg.side] = false; + if (msg.done) { + _piecesPending[msg.side] = false; + updateBackdropTransform(layerdict); + } if (!_stats[msg.side]) _stats[msg.side] = makeStatsTracker(); _stats[msg.side].drawCalls += msg.drawCalls || 0; From 4056597ffeb64cb99664c7f1cb20ecca352c8ada Mon Sep 17 00:00:00 2001 From: gherkin Date: Tue, 27 Oct 2026 16:49:29 +0100 Subject: [PATCH 2/2] Compare the buffer to the view in device pixels Back in CSS pixels the viewport rect came out a hair short of the view at some pixel ratios (999.9999999999999 for 1000 px at 1.1). The view then never counted as covered while pieces were pending, and the old backdrop showed after a toggle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UCbPYsA5LYNX8HZaGGZCpK --- web/render.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/web/render.js b/web/render.js index 8a0260f..8124a68 100644 --- a/web/render.js +++ b/web/render.js @@ -304,7 +304,9 @@ function updateBackdropTransform(layerdict) { // Whether the drawn part of the buffer covers the whole view. Placed on // screen as updateCSSTransform() places it. Until the overscan pieces are -// all in, only the viewport of the render is drawn. +// all in, only the viewport of the render is drawn. Compared in device +// pixels, as the worker sizes it: back in CSS pixels it can come out a hair +// short of the view. function bufferCoversView(layerdict) { var buf = layerdict._bufferState, r = layerdict._drawnRect; if (!buf || !r) return false; @@ -316,9 +318,9 @@ function bufferCoversView(layerdict) { var k = t.zoom / buf.zoom; var tx = t.zoom * (t.panx - buf.panx) - k * os.x; var ty = t.zoom * (t.pany - buf.pany) - k * os.y; - var x0 = (tx + k * r.x) / devicePixelRatio, x1 = (tx + k * (r.x + r.w)) / devicePixelRatio; - var y0 = (ty + k * r.y) / devicePixelRatio, y1 = (ty + k * (r.y + r.h)) / devicePixelRatio; - return x0 <= 0 && y0 <= 0 && x1 >= div.clientWidth && y1 >= div.clientHeight; + return tx + k * r.x <= 0 && ty + k * r.y <= 0 && + tx + k * (r.x + r.w) >= div.clientWidth * devicePixelRatio && + ty + k * (r.y + r.h) >= div.clientHeight * devicePixelRatio; } // ---- Render performance stats ----