The wheel zoomed smoothly and fit computed any factor, so the scene view almost never showed pixel art with even texels. Under Windows scaling even zoom 1 was off. The zoom now only takes steps that give every texel the same number of screen pixels. - The zoom counts device pixels per world pixel. freeView divides by the renderer resolution, so zoom 1 is one screen pixel per texel. - FREE_ZOOM_STEPS: 1/8, 1/4, 1/2, then 1 to 6, 8, 12, 16 and 24. - The wheel moves one step per notch. A trackpad collects its travel and steps every 50 px. - Fit rounds down to the next step. A saved zoom from before snaps to the nearest step on a log scale. - Pan, drag, grid and resize use CSS pixels per world unit, so they still follow the pointer under Windows scaling. - Always on, no toggle, as decided in the session. Tests: - 4 unit tests: zoom 1 at 125%, whole steps, wheel steps and ends, fit rounding and snapping of a saved zoom. - Counter-check: without the ratio in freeView the 125% test fails. - check.sh: 156 files, 2320 tests, CHECK EXIT 0. - e2e 1-17,28,30,38-40: 726 checks passed on the steps before the final values (up to 8). Not rerun on the final steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
6.5 KiB
TypeScript
167 lines
6.5 KiB
TypeScript
/** The viewport transforms: plain geometry, so pinned here rather than through the browser. */
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
cameraFactor,
|
||
cameraView,
|
||
FREE_ZOOM_STEPS,
|
||
freeView,
|
||
GAME_SCALE_STEPS,
|
||
gameScaleFactor,
|
||
isInside,
|
||
nearestZoomStep,
|
||
screenToWorld,
|
||
stepFreeZoom,
|
||
worldBounds,
|
||
worldToScreen,
|
||
zoomStepBelow,
|
||
} from '../../src/viewport/view';
|
||
|
||
describe('freeView', () => {
|
||
it('puts the view centre in the middle of the widget', () => {
|
||
const view = freeView(50, -20, 2, 800, 600);
|
||
expect(worldToScreen(view, 50, -20)).toEqual({ x: 400, y: 300 });
|
||
expect(screenToWorld(view, 400, 300)).toEqual({ x: 50, y: -20 });
|
||
});
|
||
|
||
it('scales distances by the zoom', () => {
|
||
const view = freeView(0, 0, 2, 800, 600);
|
||
expect(worldToScreen(view, 100, 0)).toEqual({ x: 600, y: 300 });
|
||
});
|
||
|
||
it('round-trips any point', () => {
|
||
const view = freeView(13, -7, 0.35, 640, 480);
|
||
const back = screenToWorld(view, ...(Object.values(worldToScreen(view, 123, -456)) as [number, number]));
|
||
expect(back.x).toBeCloseTo(123, 9);
|
||
expect(back.y).toBeCloseTo(-456, 9);
|
||
});
|
||
|
||
it('covers the widget in world units', () => {
|
||
const bounds = worldBounds(freeView(0, 0, 2, 800, 600));
|
||
expect(bounds).toEqual({ left: -200, top: -150, right: 200, bottom: 150 });
|
||
});
|
||
});
|
||
|
||
describe('free view zoom steps under Windows scaling', () => {
|
||
it('zoom counts device pixels, so zoom 1 at 125% is one screen pixel per world pixel', () => {
|
||
const view = freeView(0, 0, 1, 800, 600, 1.25);
|
||
expect(view.scale * 1.25).toBe(1);
|
||
expect(freeView(0, 0, 3, 800, 600, 1.25).scale * 1.25).toBeCloseTo(3, 12);
|
||
});
|
||
|
||
it('every step gives each texel a whole number of device pixels, or a whole fraction below 1', () => {
|
||
for (const step of FREE_ZOOM_STEPS)
|
||
expect(step >= 1 ? Number.isInteger(step) : Number.isInteger(1 / step)).toBe(true);
|
||
});
|
||
|
||
it('the wheel moves one step and stays at the ends', () => {
|
||
expect(stepFreeZoom(1, 1)).toBe(2);
|
||
expect(stepFreeZoom(2, -1)).toBe(1);
|
||
expect(stepFreeZoom(1, -1)).toBe(0.5);
|
||
expect(stepFreeZoom(6, 1)).toBe(8);
|
||
expect(stepFreeZoom(24, 1)).toBe(24);
|
||
expect(stepFreeZoom(0.125, -1)).toBe(0.125);
|
||
});
|
||
|
||
it('fit rounds down to a step, and a saved zoom snaps to the nearest', () => {
|
||
expect(zoomStepBelow(2.9)).toBe(2);
|
||
expect(zoomStepBelow(0.7)).toBe(0.5);
|
||
expect(zoomStepBelow(0.01)).toBe(0.125);
|
||
expect(zoomStepBelow(20)).toBe(16);
|
||
expect(zoomStepBelow(100)).toBe(24);
|
||
expect(nearestZoomStep(1.37)).toBe(1);
|
||
expect(nearestZoomStep(0.4)).toBe(0.5);
|
||
});
|
||
});
|
||
|
||
describe('cameraView', () => {
|
||
const camera = { x: 10, y: 20, props: { width: 640, height: 360 } };
|
||
|
||
it('fits the camera frame into the widget and centres it', () => {
|
||
// 640×360 into 800×600: width is the tighter fit (1.25 vs 1.67), so bars top and bottom.
|
||
const view = cameraView(camera, 800, 600);
|
||
expect(view.scale).toBeCloseTo(1.25, 9);
|
||
expect(view.rect).toEqual({ x: 0, y: 75, w: 800, h: 450 });
|
||
// The camera position sits at the centre of the frame, not of the widget.
|
||
expect(worldToScreen(view, 10, 20)).toEqual({ x: 400, y: 300 });
|
||
});
|
||
|
||
it('reports the letterbox as outside', () => {
|
||
const view = cameraView(camera, 800, 600);
|
||
expect(isInside(view, 400, 300)).toBe(true);
|
||
expect(isInside(view, 400, 20)).toBe(false); // top bar
|
||
expect(isInside(view, 400, 580)).toBe(false); // bottom bar
|
||
});
|
||
|
||
it('draws a fixed step at exactly that many pixels per unit, starting on a whole pixel', () => {
|
||
const view = cameraView(camera, 801, 601, 0.5);
|
||
expect(view.scale).toBe(0.5);
|
||
expect(view.rect).toEqual({ x: 241, y: 211, w: 320, h: 180 });
|
||
});
|
||
|
||
it('cuts a frame larger than the view to the view, keeping the camera in the middle', () => {
|
||
const view = cameraView(camera, 800, 600, 2);
|
||
expect(view.scale).toBe(2);
|
||
expect(view.rect).toEqual({ x: 0, y: 0, w: 800, h: 600 });
|
||
expect(worldToScreen(view, 10, 20)).toEqual({ x: 400, y: 300 });
|
||
expect(worldBounds(view)).toEqual({ left: -190, top: -130, right: 210, bottom: 170 });
|
||
expect(isInside(view, 1, 1)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('cameraView with a zoomed camera', () => {
|
||
const zoomed = { x: 0, y: 0, props: { width: 640, height: 360, zoom: 2 } };
|
||
|
||
it('100% shows the camera picture pixel for pixel, the zoom magnifies the world on top', () => {
|
||
const view = cameraView(zoomed, 800, 600, 1);
|
||
expect(view.scale).toBe(2);
|
||
expect(view.rect).toEqual({ x: 80, y: 120, w: 640, h: 360 });
|
||
});
|
||
|
||
it('fit and auto size the picture, not the world region', () => {
|
||
expect(cameraFactor(zoomed.props, 800, 600, 'fit')).toBeCloseTo(1.25, 9);
|
||
expect(cameraView(zoomed, 800, 600, 'fit').scale).toBeCloseTo(2.5, 9);
|
||
expect(cameraFactor(zoomed.props, 800, 600, 'auto')).toBe(1);
|
||
});
|
||
});
|
||
|
||
describe('cameraView under Windows scaling at 125%', () => {
|
||
const plain = { x: 0, y: 0, props: { width: 640, height: 360, zoom: 1 } };
|
||
|
||
it('100% is one device pixel per camera pixel, starting on a whole device pixel', () => {
|
||
const view = cameraView(plain, 801, 601, 1, 1.25);
|
||
expect(view.scale * 1.25).toBeCloseTo(1, 12);
|
||
expect(view.rect.w).toBeCloseTo(512, 12);
|
||
expect(view.rect.h).toBeCloseTo(288, 12);
|
||
expect(view.rect.x * 1.25).toBeCloseTo(181, 9);
|
||
expect(view.rect.y * 1.25).toBeCloseTo(196, 9);
|
||
});
|
||
|
||
it('auto picks the step in device pixels, and fit reports device pixels', () => {
|
||
expect(cameraFactor(plain.props, 1100, 700, 'auto', 1.25)).toBe(2);
|
||
expect(cameraFactor(plain.props, 1100, 700, 'auto')).toBe(1);
|
||
expect(cameraFactor(plain.props, 800, 600, 'fit', 1.25)).toBeCloseTo(1.5625, 9);
|
||
expect(cameraView(plain, 800, 600, 'fit', 1.25).scale).toBeCloseTo(1.25, 9);
|
||
});
|
||
});
|
||
|
||
describe('gameScaleFactor', () => {
|
||
it('fit is the exact factor that fills the tighter side', () => {
|
||
expect(gameScaleFactor('fit', 640, 360, 800, 600)).toBeCloseTo(1.25, 9);
|
||
});
|
||
|
||
it('auto takes the largest step that still fits', () => {
|
||
expect(gameScaleFactor('auto', 640, 360, 800, 600)).toBe(1);
|
||
expect(gameScaleFactor('auto', 640, 360, 1280, 720)).toBe(2);
|
||
expect(gameScaleFactor('auto', 640, 360, 639, 600)).toBe(0.5);
|
||
expect(gameScaleFactor('auto', 320, 180, 2000, 2000)).toBe(4);
|
||
});
|
||
|
||
it('auto falls back to the smallest step when not even that fits', () => {
|
||
expect(gameScaleFactor('auto', 4000, 4000, 800, 600)).toBe(0.25);
|
||
});
|
||
|
||
it('a fixed step ignores the view size', () => {
|
||
for (const step of GAME_SCALE_STEPS) expect(gameScaleFactor(step, 640, 360, 10, 10)).toBe(step);
|
||
});
|
||
});
|