From cc004f935d7528da9ae6487ffcf45cafd7de268d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 8 May 2026 21:45:01 +0200 Subject: [PATCH] ui/map-renderer: clamp torus zoom-out to minScaleNoWrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer's torus mode laid out the world in a 3×3 grid of wrap copies (TORUS_OFFSETS) so the user could pan past an edge without seeing a void. Below `minScale = max(viewport/world)` the world shrinks below the viewport along at least one axis and the wrap copies become visible side-by-side — the user reported a 9-tile mosaic that pans and zooms as one rigid unit. The doc explicitly deferred the fix ("if profiling ever reveals that users do this"); real usage is the trigger. Apply `clampZoom({ minScale })` in both modes; torus still keeps free pan (no `clamp({ direction: "all" })`) so the wrap copies fill the cross-edge slack as designed. Resize re-evaluates the clamp so a window resize does not strand the camera below the new floor. Documentation in `ui/docs/renderer.md` updated to describe the new shared invariant. Regression test in `tests/e2e/playground-map.spec.ts` wheels out aggressively in torus mode and asserts `camera.scale >= minScale` across all four Playwright projects. Co-Authored-By: Claude Opus 4.7 --- ui/docs/renderer.md | 34 ++++++++++---------- ui/frontend/src/map/render.ts | 33 +++++++++++-------- ui/frontend/tests/e2e/playground-map.spec.ts | 34 ++++++++++++++++++++ 3 files changed, 70 insertions(+), 31 deletions(-) diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md index 7edd74f..47b46de 100644 --- a/ui/docs/renderer.md +++ b/ui/docs/renderer.md @@ -147,28 +147,28 @@ each copy renders the full line at its offset, and PixiJS' culling naturally drops the parts outside its container's reachable area. The nine-copy upper bound assumes the visible viewport never -exceeds three tile-widths or three tile-heights of the world. In -no-wrap mode we enforce `clampZoom({ minScale })` directly. In -torus mode we do not enforce a minScale; the playground starts at -`minScale * 1.2` so a user has to zoom out aggressively before -seeing more than nine copies. If profiling ever reveals that -users do this, the renderer should switch to a generalised tile -loop. +exceeds three tile-widths or three tile-heights of the world. To +hold this assumption in both modes, the renderer enforces +`clampZoom({ minScale })` with `minScale = max(viewport.W/world.W, +viewport.H/world.H)` regardless of wrap mode. Without this, in +torus mode the user could zoom out far enough to see the 3×3 grid +of wrap copies at once — the copies are there to fill partial slack +near a panned edge, not to be visible simultaneously. The clamp is +re-evaluated on every viewport resize so a window resize does not +strand the camera below the new minimum. ## No-wrap camera `pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin keeps the camera inside the world rectangle by default. We layer -two project-specific rules on top, both implemented via the -`'moved'` event: - -1. When the visible viewport is larger than the world along an - axis, the camera is **centred** on that axis. `pixi-viewport`'s - default would pin the world to the top-left of the screen, - which is jarring at low zoom. -2. `clampZoom({ minScale })` enforces `minScale = max(viewport.W/world.W, - viewport.H/world.H)` so the user cannot zoom out below - "viewport fits world". +the project-specific centring rule on top, implemented via the +`'moved'` event: when the visible viewport is larger than the world +along an axis, the camera is **centred** on that axis. +`pixi-viewport`'s default would pin the world to the top-left of +the screen, which is jarring at low zoom. The shared +`clampZoom({ minScale })` (described above) prevents this case in +practice, but the centring rule stays as a defensive layer for +windowed-resize transients. `pivotZoom` keeps the world point under the cursor stable during zoom. The math is symmetric and tested in diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index b85336b..24a8115 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -138,24 +138,29 @@ export async function createRenderer(opts: RendererOptions): Promise { app.renderer.resize(w, h); viewport.resize(w, h, opts.world.width, opts.world.height); + const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world); + viewport.plugins.remove("clamp-zoom"); + viewport.clampZoom({ minScale }); + if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (mode === "no-wrap") { - const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world); - viewport.plugins.remove("clamp-zoom"); - viewport.clampZoom({ minScale }); - if (viewport.scaled < minScale) viewport.setZoom(minScale, true); enforceCentreWhenLarger(); } }, diff --git a/ui/frontend/tests/e2e/playground-map.spec.ts b/ui/frontend/tests/e2e/playground-map.spec.ts index 6005a23..4b2c838 100644 --- a/ui/frontend/tests/e2e/playground-map.spec.ts +++ b/ui/frontend/tests/e2e/playground-map.spec.ts @@ -86,6 +86,40 @@ test("mode toggle switches between torus and no-wrap", async ({ page }, testInfo expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); }); +test("torus mode clamps zoom-out to minScale so the world is never smaller than the viewport", async ({ + page, +}, testInfo) => { + await bootMap(page, preferenceFor(testInfo.project.name)); + expect(await page.evaluate(() => window.__galaxyMap!.getMode())).toBe("torus"); + const canvas = page.locator("canvas"); + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (box === null) return; + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + await page.mouse.move(cx, cy); + // Wheel out aggressively. Without the torus-mode clampZoom the + // camera scale would drop below `minScale = max(viewport/world)`, + // exposing the 3×3 grid of wrap copies. With the fix in place the + // scale floor is never crossed. + for (let i = 0; i < 15; i++) { + await page.mouse.wheel(0, 600); + await page.waitForTimeout(40); + } + await page.waitForTimeout(200); + const snapshot = await page.evaluate(() => ({ + cam: window.__galaxyMap!.getCamera(), + vp: window.__galaxyMap!.getViewport(), + world: window.__galaxyMap!.getWorldSize(), + })); + const minScale = Math.max( + snapshot.vp.widthPx / snapshot.world.width, + snapshot.vp.heightPx / snapshot.world.height, + ); + const tol = 1e-3; + expect(snapshot.cam.scale).toBeGreaterThanOrEqual(minScale - tol); +}); + test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({ page, }, testInfo) => {