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
+19 -14
View File
@@ -138,24 +138,29 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
for (let i = 0; i < copies.length; i++) {
copies[i].visible = newMode === "torus" || i === ORIGIN_COPY_INDEX;
}
// Always reset clamp plugins; reattach for no-wrap.
// Always reset clamp plugins; reattach per mode.
viewport.plugins.remove("clamp");
viewport.plugins.remove("clamp-zoom");
viewport.off("moved", enforceCentreWhenLarger);
const minScale = minScaleNoWrap(
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
opts.world,
);
// Both modes enforce minScale on zoom-out: the world (origin
// copy) always fills at least the viewport. Without this, in
// torus mode the user would zoom out far enough to see the
// 3×3 grid of wrap copies at once; the copies are there to
// fill the partial slack near a panned edge, not to be
// visible simultaneously.
viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
if (newMode === "no-wrap") {
const minScale = minScaleNoWrap(
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
opts.world,
);
viewport.clampZoom({ minScale });
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
viewport.clamp({ direction: "all" });
viewport.on("moved", enforceCentreWhenLarger);
enforceCentreWhenLarger();
} else {
// Torus mode: drop tight bounds, allow free pan.
viewport.moveCenter(viewport.center.x, viewport.center.y);
}
// Torus mode keeps free pan (no `clamp()`); the visible wrap
// copies handle the cross-edge case naturally.
};
applyMode(mode);
@@ -180,11 +185,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
resize: (w, h) => {
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();
}
},