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:
+17
-17
@@ -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.
|
naturally drops the parts outside its container's reachable area.
|
||||||
|
|
||||||
The nine-copy upper bound assumes the visible viewport never
|
The nine-copy upper bound assumes the visible viewport never
|
||||||
exceeds three tile-widths or three tile-heights of the world. In
|
exceeds three tile-widths or three tile-heights of the world. To
|
||||||
no-wrap mode we enforce `clampZoom({ minScale })` directly. In
|
hold this assumption in both modes, the renderer enforces
|
||||||
torus mode we do not enforce a minScale; the playground starts at
|
`clampZoom({ minScale })` with `minScale = max(viewport.W/world.W,
|
||||||
`minScale * 1.2` so a user has to zoom out aggressively before
|
viewport.H/world.H)` regardless of wrap mode. Without this, in
|
||||||
seeing more than nine copies. If profiling ever reveals that
|
torus mode the user could zoom out far enough to see the 3×3 grid
|
||||||
users do this, the renderer should switch to a generalised tile
|
of wrap copies at once — the copies are there to fill partial slack
|
||||||
loop.
|
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
|
## No-wrap camera
|
||||||
|
|
||||||
`pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin
|
`pixi-viewport`'s built-in `clamp({ direction: 'all' })` plugin
|
||||||
keeps the camera inside the world rectangle by default. We layer
|
keeps the camera inside the world rectangle by default. We layer
|
||||||
two project-specific rules on top, both implemented via the
|
the project-specific centring rule on top, implemented via the
|
||||||
`'moved'` event:
|
`'moved'` event: when the visible viewport is larger than the world
|
||||||
|
along an axis, the camera is **centred** on that axis.
|
||||||
1. When the visible viewport is larger than the world along an
|
`pixi-viewport`'s default would pin the world to the top-left of
|
||||||
axis, the camera is **centred** on that axis. `pixi-viewport`'s
|
the screen, which is jarring at low zoom. The shared
|
||||||
default would pin the world to the top-left of the screen,
|
`clampZoom({ minScale })` (described above) prevents this case in
|
||||||
which is jarring at low zoom.
|
practice, but the centring rule stays as a defensive layer for
|
||||||
2. `clampZoom({ minScale })` enforces `minScale = max(viewport.W/world.W,
|
windowed-resize transients.
|
||||||
viewport.H/world.H)` so the user cannot zoom out below
|
|
||||||
"viewport fits world".
|
|
||||||
|
|
||||||
`pivotZoom` keeps the world point under the cursor stable during
|
`pivotZoom` keeps the world point under the cursor stable during
|
||||||
zoom. The math is symmetric and tested in
|
zoom. The math is symmetric and tested in
|
||||||
|
|||||||
@@ -138,24 +138,29 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
for (let i = 0; i < copies.length; i++) {
|
for (let i = 0; i < copies.length; i++) {
|
||||||
copies[i].visible = newMode === "torus" || i === ORIGIN_COPY_INDEX;
|
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");
|
||||||
viewport.plugins.remove("clamp-zoom");
|
viewport.plugins.remove("clamp-zoom");
|
||||||
viewport.off("moved", enforceCentreWhenLarger);
|
viewport.off("moved", enforceCentreWhenLarger);
|
||||||
if (newMode === "no-wrap") {
|
|
||||||
const minScale = minScaleNoWrap(
|
const minScale = minScaleNoWrap(
|
||||||
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
|
{ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight },
|
||||||
opts.world,
|
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 });
|
viewport.clampZoom({ minScale });
|
||||||
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
|
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
|
||||||
|
if (newMode === "no-wrap") {
|
||||||
viewport.clamp({ direction: "all" });
|
viewport.clamp({ direction: "all" });
|
||||||
viewport.on("moved", enforceCentreWhenLarger);
|
viewport.on("moved", enforceCentreWhenLarger);
|
||||||
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);
|
applyMode(mode);
|
||||||
@@ -180,11 +185,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
resize: (w, h) => {
|
resize: (w, h) => {
|
||||||
app.renderer.resize(w, h);
|
app.renderer.resize(w, h);
|
||||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||||
if (mode === "no-wrap") {
|
|
||||||
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
|
const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world);
|
||||||
viewport.plugins.remove("clamp-zoom");
|
viewport.plugins.remove("clamp-zoom");
|
||||||
viewport.clampZoom({ minScale });
|
viewport.clampZoom({ minScale });
|
||||||
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
|
if (viewport.scaled < minScale) viewport.setZoom(minScale, true);
|
||||||
|
if (mode === "no-wrap") {
|
||||||
enforceCentreWhenLarger();
|
enforceCentreWhenLarger();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
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 ({
|
test("no-wrap clamps the camera within world bounds after a drag past the edge", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user