Files
feuerwerk/tests/viewport/view.test.ts
T
adminandClaude Opus 5.5 5399ef17e5 Snap the scene view zoom to pixel exact steps
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>
2026-09-24 10:40:54 +02:00

167 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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);
});
});