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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user