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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-24 10:40:54 +02:00
1 parent 22a89ed0f0
commit 5399ef17e5
4 files changed
+117 -25

No files matched your search

+44 -19
View File
@@ -2,25 +2,35 @@ import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js';
import { footprintExtent } from '../../core/properties/bounds'; import { footprintExtent } from '../../core/properties/bounds';
import { boundsOf } from '../framing'; import { boundsOf } from '../framing';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import { clamp, zoomFactor } from '../../ui'; import { ui } from '../../ui';
import { freeView, screenToWorld, type View } from '../../viewport/view'; import {
freeView,
nearestZoomStep,
screenToWorld,
stepFreeZoom,
type View,
zoomStepBelow,
} from '../../viewport/view';
import { WorldViewport } from './WorldViewport'; import { WorldViewport } from './WorldViewport';
const MIN_ZOOM = 0.1; /** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */
const MAX_ZOOM = 8; const WHEEL_STEP = 50;
/** Breathing room left around the content by `fit()`, in screen pixels. */ /** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40; const FIT_MARGIN = 40;
/** /**
* A world view with a camera of its own – the Scene and Container tabs and the Animation tab's * A world view with a camera of its own – the Scene and Container tabs and the Animation tab's
* preview: the wheel zooms about the pointer, a pan drags the world along, `fit()` frames the content. * preview: the wheel zooms about the pointer, a pan drags the world along, `fit()` frames the content.
* The zoom only takes whole steps in device pixels (`FREE_ZOOM_STEPS`), so pixel art stays even.
*/ */
export abstract class FreeViewport extends WorldViewport { export abstract class FreeViewport extends WorldViewport {
/** World point at the viewport centre, and zoom. */ /** World point at the viewport centre, and zoom in device pixels per world unit. */
protected view = { x: 0, y: 0, zoom: 1 }; protected view = { x: 0, y: 0, zoom: 1 };
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null; private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
/** Global position of the top-left corner at the last resize, for `resize()` to keep the world in place. */ /** Global position of the top-left corner at the last resize, for `resize()` to keep the world in place. */
private screenOrigin = { x: 0, y: 0 }; private screenOrigin = { x: 0, y: 0 };
/** Wheel travel not yet spent on a step; reset when the direction turns. */
private wheel = 0;
protected constructor(state: EditorState) { protected constructor(state: EditorState) {
super(state); super(state);
@@ -43,10 +53,8 @@ export abstract class FreeViewport extends WorldViewport {
this.view = { this.view = {
x: (minX + maxX) / 2, x: (minX + maxX) / 2,
y: (minY + maxY) / 2, y: (minY + maxY) / 2,
zoom: clamp( zoom: zoomStepBelow(
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height), Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height) * this.ratio(),
MIN_ZOOM,
MAX_ZOOM,
), ),
}; };
this.redraw(); this.redraw();
@@ -68,8 +76,8 @@ export abstract class FreeViewport extends WorldViewport {
if (this.w > 0 && this.h > 0 && (w !== this.w || h !== this.h)) { if (this.w > 0 && this.h > 0 && (w !== this.w || h !== this.h)) {
const before = this.screenOrigin; const before = this.screenOrigin;
const after = this.getGlobalPosition(); const after = this.getGlobalPosition();
this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.view.zoom; this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.worldScale();
this.view.y += (after.y + h / 2 - (before.y + this.h / 2)) / this.view.zoom; this.view.y += (after.y + h / 2 - (before.y + this.h / 2)) / this.worldScale();
} }
super.resize(w, h); super.resize(w, h);
this.screenOrigin = this.getGlobalPosition(); this.screenOrigin = this.getGlobalPosition();
@@ -81,7 +89,8 @@ export abstract class FreeViewport extends WorldViewport {
} }
restoreView(view: unknown): void { restoreView(view: unknown): void {
this.view = { ...(view as FreeViewport['view']) }; const saved = view as FreeViewport['view'];
this.view = { ...saved, zoom: nearestZoomStep(saved.zoom) };
this.redraw(); this.redraw();
} }
@@ -90,7 +99,17 @@ export abstract class FreeViewport extends WorldViewport {
} }
protected override currentView(): View { protected override currentView(): View {
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h); return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h, this.ratio());
}
/** CSS pixels per world unit, for pointer distances. */
protected worldScale(): number {
return this.view.zoom / this.ratio();
}
/** Device pixels per CSS pixel: 1.25 under Windows scaling at 125%. */
private ratio(): number {
return ui().app.renderer.resolution;
} }
protected startPan(e: FederatedPointerEvent): void { protected startPan(e: FederatedPointerEvent): void {
@@ -101,9 +120,9 @@ export abstract class FreeViewport extends WorldViewport {
/** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */ /** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */
protected panTo(e: FederatedPointerEvent): boolean { protected panTo(e: FederatedPointerEvent): boolean {
if (!this.panning) return false; if (!this.panning) return false;
const { zoom } = this.view; const scale = this.worldScale();
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / zoom; this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / scale;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / zoom; this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / scale;
this.moveCamera(); this.moveCamera();
return true; return true;
} }
@@ -112,15 +131,21 @@ export abstract class FreeViewport extends WorldViewport {
this.panning = null; this.panning = null;
} }
/** Zooms about the pointer, so the world point under it stays under it. */ /** Zooms a step about the pointer, so the world point under it stays under it. */
private zoomAt(e: FederatedWheelEvent): void { private zoomAt(e: FederatedWheelEvent): void {
e.preventDefault();
if (Math.sign(e.deltaY) !== Math.sign(this.wheel)) this.wheel = 0;
this.wheel += e.deltaY;
if (Math.abs(this.wheel) < WHEEL_STEP) return;
const zoom = stepFreeZoom(this.view.zoom, this.wheel < 0 ? 1 : -1);
this.wheel = 0;
if (zoom === this.view.zoom) return;
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const before = screenToWorld(this.currentView(), local.x, local.y); const before = screenToWorld(this.currentView(), local.x, local.y);
this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM); this.view.zoom = zoom;
const after = screenToWorld(this.currentView(), local.x, local.y); const after = screenToWorld(this.currentView(), local.x, local.y);
this.view.x += before.x - after.x; this.view.x += before.x - after.x;
this.view.y += before.y - after.y; this.view.y += before.y - after.y;
this.redraw(); this.redraw();
e.preventDefault();
} }
} }
+3 -3
View File
@@ -249,8 +249,8 @@ export class SceneViewport extends FreeViewport {
return; return;
if (this.dragging) { if (this.dragging) {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; const dx = (local.x - this.dragging.pointer.x) / this.worldScale();
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom; const dy = (local.y - this.dragging.pointer.y) / this.worldScale();
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return; if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
this.dragging.moved = true; this.dragging.moved = true;
this.state.heldObjectIds = new Set(this.dragging.items.map((item) => item.objectId)); this.state.heldObjectIds = new Set(this.dragging.items.map((item) => item.objectId));
@@ -666,7 +666,7 @@ export class SceneViewport extends FreeViewport {
private syncGrid(): void { private syncGrid(): void {
this.grid.clear(); this.grid.clear();
const step = GRID_STEP * this.view.zoom; const step = GRID_STEP * this.worldScale();
if (step < MIN_GRID_STEP) return; if (step < MIN_GRID_STEP) return;
const origin = worldToScreen(this.currentView(), 0, 0); const origin = worldToScreen(this.currentView(), 0, 0);
drawGrid(this.grid, { drawGrid(this.grid, {
+34 -3
View File
@@ -12,9 +12,40 @@ export interface View {
rect: { x: number; y: number; w: number; h: number }; rect: { x: number; y: number; w: number; h: number };
} }
/** Free-panning view: the whole viewport is the rectangle. */ /**
export function freeView(x: number, y: number, zoom: number, w: number, h: number): View { * Free-panning view: the whole viewport is the rectangle. `zoom` counts device pixels per world
return { x, y, scale: zoom, rect: { x: 0, y: 0, w, h } }; * unit and `ratio` device pixels per CSS pixel, so zoom 1 is one screen pixel per world pixel even
* under Windows scaling.
*/
export function freeView(x: number, y: number, zoom: number, w: number, h: number, ratio = 1): View {
return { x, y, scale: zoom / ratio, rect: { x: 0, y: 0, w, h } };
}
/**
* The zoom steps of the scene view, in device pixels per world pixel. Each gives every texel the
* same number of screen pixels, so pixel art never shows one pixel wider than its neighbour.
*/
export const FREE_ZOOM_STEPS = [0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 16, 24] as const;
/** The largest step at or below `zoom`, never below the smallest (fit rounds down to it). */
export function zoomStepBelow(zoom: number): number {
return [...FREE_ZOOM_STEPS].reverse().find((step) => step <= zoom + 1e-9) ?? FREE_ZOOM_STEPS[0];
}
/** The step nearest to `zoom` on a log scale, for a zoom saved before the steps or on another screen. */
export function nearestZoomStep(zoom: number): number {
const distance = (step: number) => Math.abs(Math.log(step / Math.max(zoom, 1e-9)));
return FREE_ZOOM_STEPS.reduce((best, step) => (distance(step) < distance(best) ? step : best));
}
/** The next step up (`direction` 1) or down (-1) from `zoom`; stays at the ends. */
export function stepFreeZoom(zoom: number, direction: 1 | -1): number {
const at = FREE_ZOOM_STEPS.indexOf(nearestZoomStep(zoom) as (typeof FREE_ZOOM_STEPS)[number]);
return FREE_ZOOM_STEPS[clampIndex(at + direction)]!;
}
function clampIndex(index: number): number {
return Math.max(0, Math.min(FREE_ZOOM_STEPS.length - 1, index));
} }
/** How the game view sizes the camera frame: a fixed factor, the largest step that fits, or fitted. */ /** How the game view sizes the camera frame: a fixed factor, the largest step that fits, or fitted. */
+36
View File
@@ -3,13 +3,17 @@ import { describe, expect, it } from 'vitest';
import { import {
cameraFactor, cameraFactor,
cameraView, cameraView,
FREE_ZOOM_STEPS,
freeView, freeView,
GAME_SCALE_STEPS, GAME_SCALE_STEPS,
gameScaleFactor, gameScaleFactor,
isInside, isInside,
nearestZoomStep,
screenToWorld, screenToWorld,
stepFreeZoom,
worldBounds, worldBounds,
worldToScreen, worldToScreen,
zoomStepBelow,
} from '../../src/viewport/view'; } from '../../src/viewport/view';
describe('freeView', () => { describe('freeView', () => {
@@ -37,6 +41,38 @@ describe('freeView', () => {
}); });
}); });
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', () => { describe('cameraView', () => {
const camera = { x: 10, y: 20, props: { width: 640, height: 360 } }; const camera = { x: 10, y: 20, props: { width: 640, height: 360 } };