From 021a6dcb5649992f4248bdad317258ca17240ca9 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 25 Sep 2026 10:30:41 +0200 Subject: [PATCH] Zoom the tile tab in the same steps as the scene view The tile tab zoomed freely, so the pixels of a tile could end up unevenly wide at odd factors. Scene view, container tab and animation preview already snap to FREE_ZOOM_STEPS. The tile tab now does the same, so every pixel editor zooms alike. - The wheel steps through FREE_ZOOM_STEPS, counted in device pixels. The board position is rounded to whole device pixels. - New wheelSteps() in src/viewport/view.ts turns wheel events into steps: one notch is one step, a trackpad gathers 50 px per step. FreeViewport uses it too, so the rule exists only once. - Fit takes the largest step at which the whole board fits. Refitting on a new tab or board size stays as before. - panAndZoom takes an optional zoom rule instead of limits. Graph and node board keep zooming freely. - A saved view from before the steps snaps to the nearest step. - The cap parameter of frameBoxes from 4027afc has no user anymore. It is removed together with its unit test. Fit rounds down, and below 100 percent the steps are far apart. In the small e2e window the 48 px board lands at 25 instead of 41 percent, the 16 px board at 100 instead of 124 percent. Tests: - check.sh: 161 files, 2370 tests, exit 0 (-1 cap test, +1 wheelSteps test) - e2e 1-5 and 39: all passed, 169 checks. Three checks in 39 now expect steps: Fit on a step with the next one too large, one notch goes 0.25 to 0.5, 16 px tiles refit to at least 100 percent. - Counter check: a free Fit factor fails exactly those three checks (zoom 0.413 and 1.2396). Co-Authored-By: Claude Opus 5.5 --- src/editor/framing.ts | 9 ++---- src/editor/graph/graphView.ts | 16 ++++++++-- src/editor/tile/TileCanvas.ts | 49 ++++++++++++++++++++++------- src/editor/viewport/FreeViewport.ts | 14 +++------ src/viewport/view.ts | 19 +++++++++++ tests/editor/graph-framing.test.ts | 7 ----- tests/viewport/view.test.ts | 12 +++++++ tools/e2e/39-tile.mjs | 31 +++++++++++------- 8 files changed, 110 insertions(+), 47 deletions(-) diff --git a/src/editor/framing.ts b/src/editor/framing.ts index 273ab674..2f3493dd 100644 --- a/src/editor/framing.ts +++ b/src/editor/framing.ts @@ -148,24 +148,21 @@ export function boundsOf(boxes: readonly Box[], seed?: Bounds): Bounds | null { /** * The view that shows every box at once, centred, with `margin` screen pixels to spare. - * Never zooms past `cap`, 1 by default: a two-node graph blown up to fill the screen helps nobody. - * The Tile tab lifts it, since its board is measured in image pixels of a tile – 16 px of them - * at 100 % is a stamp in the corner. The scene view does not share the cap at all, which is why - * only the bounds sum is common. + * Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. The scene view + * deliberately does not share that cap, which is why only the bounds sum is common. */ export function frameBoxes( boxes: readonly Box[], view: { w: number; h: number }, margin: number, limits: { min: number; max: number }, - cap = 1, ): Framing { const bounds = boundsOf(boxes); if (!bounds) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 }; const { minX, minY, maxX, maxY } = bounds; const width = Math.max(maxX - minX, 1); const height = Math.max(maxY - minY, 1); - const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, cap); + const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1); const zoom = Math.min(Math.max(fitted, limits.min), limits.max); return { zoom, diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts index 73419f4c..eba202fa 100644 --- a/src/editor/graph/graphView.ts +++ b/src/editor/graph/graphView.ts @@ -41,6 +41,13 @@ export function zoomedAbout( return { pan: { x: at.x - graphX * zoom, y: at.y - graphY * zoom }, zoom }; } +/** + * How the wheel zooms: smoothly within limits, or by a rule of the surface's own – the Tile tab + * takes the scene view's steps. A rule returns null while the wheel has not made a step yet. + */ +export type WheelZoom = + { min: number; max: number } | ((view: GraphView, at: Point, deltaY: number) => GraphView | null); + /** * Drag with any button to pan, wheel to zoom about the pointer – all a board does with the pointer * when its content takes none. `set` receives each new view and which gesture made it. @@ -49,7 +56,7 @@ export function panAndZoom( surface: Container, get: () => GraphView, set: (view: GraphView, gesture: 'pan' | 'zoom') => void, - limits: { min: number; max: number } = ZOOM_LIMITS, + zoom: WheelZoom = ZOOM_LIMITS, ): void { let panning: { start: Point; pan: Point } | null = null; surface.on('pointerdown', (e: FederatedPointerEvent) => { @@ -63,8 +70,13 @@ export function panAndZoom( surface.on('pointerup', () => (panning = null)); surface.on('pointerupoutside', () => (panning = null)); surface.on('wheel', (e: FederatedWheelEvent) => { - set(zoomedAbout(get(), surface.toLocal(e.global), zoomFactor(e.deltaY), limits), 'zoom'); e.preventDefault(); + const at = surface.toLocal(e.global); + const view = + typeof zoom === 'function' + ? zoom(get(), at, e.deltaY) + : zoomedAbout(get(), at, zoomFactor(e.deltaY), zoom); + if (view) set(view, 'zoom'); }); } diff --git a/src/editor/tile/TileCanvas.ts b/src/editor/tile/TileCanvas.ts index 590f55ab..e5e0061e 100644 --- a/src/editor/tile/TileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -19,10 +19,11 @@ import { tilePlayback, } from '../../core/model/tile'; import { findAssetOf } from '../../core/model/Project'; -import { type Size, Theme, textStyle, Widget } from '../../ui'; +import { type Size, Theme, textStyle, ui, Widget } from '../../ui'; import { textureLibrary } from '../../viewport/TextureLibrary'; -import { type Box, frameBoxes } from '../framing'; -import { type GraphView, panAndZoom } from '../graph/graphView'; +import { nearestZoomStep, stepFreeZoom, wheelSteps, zoomStepBelow } from '../../viewport/view'; +import type { Point } from '../framing'; +import { type GraphView, panAndZoom, zoomedAbout } from '../graph/graphView'; import type { EditorState } from '../state'; /** Tiles per row in the combination grid; 47 of them then read as six rows of eight. */ @@ -38,8 +39,6 @@ const MIN_CAPTION_SIZE = 6; const CAPTION_MARGIN = 4; const SECTION_GAP = 1.2; const FIT_MARGIN = 16; -/** Wider than the graph's: a 48 px tile is small, and its quarters are what one wants to look at. */ -const ZOOM_LIMITS = { min: 0.1, max: 6 }; /** One tile of the board: where it sits and which quarters it is drawn from. */ interface BoardTile { @@ -64,7 +63,8 @@ interface Caption { * The Tile tab's picture (#118): every tile the block can produce, captioned with the * neighbours that ask for it, and below or beside them a sample map from the same quarters – the * one place that shows whether the edges really continue across tile borders. A drag pans, the - * wheel zooms, nothing is edited here. + * wheel zooms in the scene view's steps (`FREE_ZOOM_STEPS`, device pixels), so every texel of a + * tile gets as many screen pixels as its neighbour; nothing is edited here. */ export class TileCanvas extends Widget { private readonly bg = new Graphics(); @@ -107,6 +107,7 @@ export class TileCanvas extends Widget { /** The view size and the board a fit was made for. */ private fittedFor = { w: 0, h: 0, board: { w: 0, h: 0 } }; private readonly stopPictures: () => void; + private readonly wheel = wheelSteps(); constructor(private readonly state: EditorState) { super(); @@ -126,7 +127,7 @@ export class TileCanvas extends Widget { this.followsFit = false; this.applyView(); }, - ZOOM_LIMITS, + (view, at, deltaY) => this.zoomedStep(view, at, deltaY), ); state.on('project', () => this.redraw()); @@ -174,7 +175,9 @@ export class TileCanvas extends Widget { restoreView(saved: unknown): void { const { view, needsFit } = saved as { view: GraphView; needsFit: boolean }; if (needsFit) return; - this.view = { pan: { ...view.pan }, zoom: view.zoom }; + // A view saved before the steps, or on a screen with another scaling, lands on the nearest one. + const zoom = nearestZoomStep(view.zoom * this.ratio()) / this.ratio(); + this.view = { pan: { ...view.pan }, zoom }; this.needsFit = false; this.followsFit = false; this.redraw(); @@ -204,7 +207,9 @@ export class TileCanvas extends Widget { tile: number; problem: string; beside: boolean; + /** CSS pixels per image pixel; times `ratio` it is one of the scene view's steps. */ zoom: number; + ratio: number; pan: { x: number; y: number }; rect: { x: number; y: number; w: number; h: number }; /** Grid and sample map together, in image pixels of the tile. */ @@ -223,6 +228,7 @@ export class TileCanvas extends Widget { problem: info?.problem ?? '', beside: this.beside, zoom: this.view.zoom, + ratio: this.ratio(), pan: { ...this.view.pan }, rect: { x: origin.x, y: origin.y, w: this.w, h: this.h }, size: { ...this.board }, @@ -310,9 +316,11 @@ export class TileCanvas extends Widget { const regrown = this.board.w !== this.fittedFor.board.w || this.board.h !== this.fittedFor.board.h; if (this.followsFit && regrown) this.needsFit = true; if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) { - const box: Box = { id: 'board', x: 0, y: 0, w: this.board.w, h: this.board.h }; - const framing = frameBoxes([box], { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS, ZOOM_LIMITS.max); - this.view = { pan: { x: framing.panX, y: framing.panY }, zoom: framing.zoom }; + // The largest step the board fits at, as in the scene view – no longer a free factor. + const { w, h } = this.board; + const fitted = Math.min((this.w - FIT_MARGIN * 2) / w, (this.h - FIT_MARGIN * 2) / h); + const zoom = zoomStepBelow(fitted * this.ratio()) / this.ratio(); + this.view = { pan: { x: (this.w - w * zoom) / 2, y: (this.h - h * zoom) / 2 }, zoom }; this.needsFit = false; this.fittedFor = { w: this.w, h: this.h, board: { ...this.board } }; } @@ -498,8 +506,25 @@ export class TileCanvas extends Widget { this.hint.position.set(Theme.padding * 2, Theme.padding * 2); } + /** One step about the pointer once the wheel has travelled far enough; null until then. */ + private zoomedStep(view: GraphView, at: Point, deltaY: number): GraphView | null { + const direction = this.wheel(deltaY); + if (direction === 0) return null; + const zoom = stepFreeZoom(view.zoom * this.ratio(), direction) / this.ratio(); + if (zoom === view.zoom) return null; + return zoomedAbout(view, at, zoom / view.zoom, { min: zoom, max: zoom }); + } + + /** Device pixels per CSS pixel: the steps count device pixels, as in the scene view. */ + private ratio(): number { + return ui().app.renderer.resolution; + } + + /** The board starts on a whole device pixel, so a step draws every texel alike. */ private applyView(): void { - this.content.position.set(Math.round(this.view.pan.x), Math.round(this.view.pan.y)); + const ratio = this.ratio(); + const snap = (v: number) => Math.round(v * ratio) / ratio; + this.content.position.set(snap(this.view.pan.x), snap(this.view.pan.y)); this.content.scale.set(this.view.zoom); this.placeCaptions(); } diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 3a9edde1..9ed919ca 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -9,12 +9,11 @@ import { screenToWorld, stepFreeZoom, type View, + wheelSteps, zoomStepBelow, } from '../../viewport/view'; import { WorldViewport } from './WorldViewport'; -/** 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; @@ -29,8 +28,7 @@ export abstract class FreeViewport extends WorldViewport { 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; + private readonly wheel = wheelSteps(); protected constructor(state: EditorState) { super(state); @@ -134,11 +132,9 @@ export abstract class FreeViewport extends WorldViewport { /** 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; + const direction = this.wheel(e.deltaY); + if (direction === 0) return; + const zoom = stepFreeZoom(this.view.zoom, direction); if (zoom === this.view.zoom) return; const local = this.toLocal(e.global); const before = screenToWorld(this.currentView(), local.x, local.y); diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 77b3f72e..4a7f3ac9 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -49,6 +49,25 @@ function clampIndex(index: number): number { return Math.max(0, Math.min(FREE_ZOOM_STEPS.length - 1, index)); } +/** Wheel travel for one zoom step: a mouse notch (100) is one step, a trackpad steps every 50 px. */ +export const WHEEL_STEP = 50; + +/** + * Gathers wheel travel into whole zoom steps: 1 up, -1 down, 0 while it is not a step yet. Travel + * left over is dropped when the direction turns, so a trackpad does not step back on its own. + */ +export function wheelSteps(): (deltaY: number) => 1 | -1 | 0 { + let travel = 0; + return (deltaY) => { + if (Math.sign(deltaY) !== Math.sign(travel)) travel = 0; + travel += deltaY; + if (Math.abs(travel) < WHEEL_STEP) return 0; + const direction = travel < 0 ? 1 : -1; + travel = 0; + return direction; + }; +} + export { GAME_SCALE_STEPS, type GameScale }; /** diff --git a/tests/editor/graph-framing.test.ts b/tests/editor/graph-framing.test.ts index e4813bcd..eec73c25 100644 --- a/tests/editor/graph-framing.test.ts +++ b/tests/editor/graph-framing.test.ts @@ -141,13 +141,6 @@ describe('fit', () => { expect(f.zoom).toBe(1); }); - it('fills the view up to a raised cap – the Tile tab, whose board is a few tiles of 16 px', () => { - const board: Box[] = [{ id: 'board', x: 0, y: 0, w: 345, h: 186 }]; - const f = frameBoxes(board, { w: 1000, h: 600 }, 16, { min: 0.1, max: 6 }, 6); - expect(f.zoom).toBeCloseTo((1000 - 32) / 345); - expect(frameBoxes(board, { w: 10000, h: 6000 }, 16, { min: 0.1, max: 6 }, 6).zoom).toBe(6); - }); - it('never zooms out past the limit, however far the nodes are apart', () => { const far: Box[] = [ { id: 'a', x: 0, y: 0, w: 100, h: 60 }, diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts index a6eec78c..74346316 100644 --- a/tests/viewport/view.test.ts +++ b/tests/viewport/view.test.ts @@ -11,6 +11,7 @@ import { nearestZoomStep, screenToWorld, stepFreeZoom, + wheelSteps, worldBounds, worldToScreen, zoomStepBelow, @@ -71,6 +72,17 @@ describe('free view zoom steps under Windows scaling', () => { expect(nearestZoomStep(1.37)).toBe(1); expect(nearestZoomStep(0.4)).toBe(0.5); }); + + it('a mouse notch is a step, a trackpad gathers travel, and turning drops what was left', () => { + const wheel = wheelSteps(); + expect(wheel(-100)).toBe(1); + expect(wheel(100)).toBe(-1); + expect([wheel(-20), wheel(-20), wheel(-20)]).toEqual([0, 0, 1]); + expect(wheel(-40)).toBe(0); + // The 40 up still pending are dropped: 40 down alone are no step yet, 60 are. + expect(wheel(40)).toBe(0); + expect(wheel(20)).toBe(-1); + }); }); describe('cameraView', () => { diff --git a/tools/e2e/39-tile.mjs b/tools/e2e/39-tile.mjs index 094cdec7..30999e64 100644 --- a/tools/e2e/39-tile.mjs +++ b/tools/e2e/39-tile.mjs @@ -208,14 +208,19 @@ export async function tile(t) { await settle(); board = await json('__gm.tile.board()'); const fitted = { zoom: board.zoom, pan: board.pan }; - // Fit used to stop at 100 %, which left a board measured in 16 px tiles a stamp in the corner: - // framed, it reaches the margin on one side (or the zoom limit). - const fills = (b) => - b.zoom === 6 || - Math.abs(Math.max((b.size.w * b.zoom) / (b.rect.w - 32), (b.size.h * b.zoom) / (b.rect.h - 32)) - 1) < - 0.01; + // Fit used to stop at 100 %, which left a board measured in 16 px tiles a stamp in the corner. + // It takes the scene view's steps (device pixels, `FREE_ZOOM_STEPS`): the largest one the board + // fits at, so the next one up would run past the margin. + const STEPS = [0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 16, 24]; + const fills = (b) => { + const at = STEPS.findIndex((step) => Math.abs(step - b.zoom * b.ratio) < 1e-9); + const fits = (step) => + (b.size.w * step) / b.ratio <= b.rect.w - 32 + 1e-6 && + (b.size.h * step) / b.ratio <= b.rect.h - 32 + 1e-6; + return at >= 0 && fits(STEPS[at]) && (at === STEPS.length - 1 || !fits(STEPS[at + 1])); + }; check( - 'Fit fills the view with the board', + 'Fit fills the view with the board, at a step of the scene view', fills(board), JSON.stringify({ zoom: board.zoom, size: board.size, rect: board.rect }), ); @@ -230,7 +235,11 @@ export async function tile(t) { await wheel(centre.x, centre.y, -300); await settle(); board = await json('__gm.tile.board()'); - check('the wheel zooms it', board.zoom > fitted.zoom, JSON.stringify({ fitted, now: board.zoom })); + check( + 'the wheel zooms it one step up', + STEPS.indexOf(board.zoom * board.ratio) === STEPS.indexOf(fitted.zoom * board.ratio) + 1, + JSON.stringify({ fitted, now: board.zoom }), + ); // The captions are text in screen space at one size for all: before, each was shrunk to its // tile on its own inside the zoomed board, so `N` came out large and soft and `NESW-ne,se,sw,nw` // tiny. Two lines for a notched tile, none of them running into another. @@ -408,10 +417,10 @@ export async function tile(t) { JSON.stringify(board), ); // A third of the tile size, and the view untouched since the last Fit: the board frames itself - // again, past 100 %, instead of shrinking to a third where it stood. + // again, at 100 % or more, instead of shrinking to a third where it stood. check( - 'smaller tiles are framed again, past 100 %', - board.zoom > 1 && fills(board), + 'smaller tiles are framed again, at 100 % or more', + board.zoom * board.ratio >= 1 && fills(board), JSON.stringify({ zoom: board.zoom, size: board.size, rect: board.rect }), ); const wholeTile = (tx, ty) => [