From 5399ef17e59e7ae88d87c4891f0a4d42bc350428 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 10:40:54 +0200 Subject: [PATCH] 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 --- src/editor/viewport/FreeViewport.ts | 63 +++++++++++++++++++--------- src/editor/viewport/SceneViewport.ts | 6 +-- src/viewport/view.ts | 37 ++++++++++++++-- tests/viewport/view.test.ts | 36 ++++++++++++++++ 4 files changed, 117 insertions(+), 25 deletions(-) diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 88b49742..3a9edde1 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -2,25 +2,35 @@ import type { FederatedPointerEvent, FederatedWheelEvent } from 'pixi.js'; import { footprintExtent } from '../../core/properties/bounds'; import { boundsOf } from '../framing'; import type { EditorState } from '../state'; -import { clamp, zoomFactor } from '../../ui'; -import { freeView, screenToWorld, type View } from '../../viewport/view'; +import { ui } from '../../ui'; +import { + freeView, + nearestZoomStep, + screenToWorld, + stepFreeZoom, + type View, + zoomStepBelow, +} from '../../viewport/view'; import { WorldViewport } from './WorldViewport'; -const MIN_ZOOM = 0.1; -const MAX_ZOOM = 8; +/** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */ +const WHEEL_STEP = 50; /** Breathing room left around the content by `fit()`, in screen pixels. */ const FIT_MARGIN = 40; /** * 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. + * The zoom only takes whole steps in device pixels (`FREE_ZOOM_STEPS`), so pixel art stays even. */ 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 }; 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. */ 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) { super(state); @@ -43,10 +53,8 @@ export abstract class FreeViewport extends WorldViewport { this.view = { x: (minX + maxX) / 2, y: (minY + maxY) / 2, - zoom: clamp( - Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height), - MIN_ZOOM, - MAX_ZOOM, + zoom: zoomStepBelow( + Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height) * this.ratio(), ), }; 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)) { const before = this.screenOrigin; const after = this.getGlobalPosition(); - this.view.x += (after.x + w / 2 - (before.x + this.w / 2)) / this.view.zoom; - this.view.y += (after.y + h / 2 - (before.y + this.h / 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.worldScale(); } super.resize(w, h); this.screenOrigin = this.getGlobalPosition(); @@ -81,7 +89,8 @@ export abstract class FreeViewport extends WorldViewport { } 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(); } @@ -90,7 +99,17 @@ export abstract class FreeViewport extends WorldViewport { } 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 { @@ -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. */ protected panTo(e: FederatedPointerEvent): boolean { if (!this.panning) return false; - const { zoom } = this.view; - this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / zoom; - this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / zoom; + const scale = this.worldScale(); + 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) / scale; this.moveCamera(); return true; } @@ -112,15 +131,21 @@ export abstract class FreeViewport extends WorldViewport { 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 { + 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 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); this.view.x += before.x - after.x; this.view.y += before.y - after.y; this.redraw(); - e.preventDefault(); } } diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 67f0dfeb..87a7ebe8 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -249,8 +249,8 @@ export class SceneViewport extends FreeViewport { return; if (this.dragging) { const local = this.toLocal(e.global); - const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; - const dy = (local.y - this.dragging.pointer.y) / this.view.zoom; + const dx = (local.x - this.dragging.pointer.x) / this.worldScale(); + const dy = (local.y - this.dragging.pointer.y) / this.worldScale(); if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return; this.dragging.moved = true; this.state.heldObjectIds = new Set(this.dragging.items.map((item) => item.objectId)); @@ -666,7 +666,7 @@ export class SceneViewport extends FreeViewport { private syncGrid(): void { this.grid.clear(); - const step = GRID_STEP * this.view.zoom; + const step = GRID_STEP * this.worldScale(); if (step < MIN_GRID_STEP) return; const origin = worldToScreen(this.currentView(), 0, 0); drawGrid(this.grid, { diff --git a/src/viewport/view.ts b/src/viewport/view.ts index a1d6e9db..330bfbf7 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -12,9 +12,40 @@ export interface View { 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 { - return { x, y, scale: zoom, rect: { x: 0, y: 0, w, h } }; +/** + * Free-panning view: the whole viewport is the rectangle. `zoom` counts device pixels per world + * 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. */ diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index e687ce0b..a6eec78c 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -3,13 +3,17 @@ 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', () => { @@ -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', () => { const camera = { x: 10, y: 20, props: { width: 640, height: 360 } };