ui/map-renderer: clamp torus zoom-out to minScaleNoWrap

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 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-08 21:45:01 +02:00
parent 12e666ba91
commit cc004f935d
3 changed files with 70 additions and 31 deletions
+17 -17
View File
@@ -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