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:
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user