From 11202784c14bc508ace3af472d3c5a1d267e4609 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:43:11 +0200 Subject: [PATCH 1/2] fix(rendering): map pointer coordinates over the unzoomed design space in screenToWorld --- .../coordinates-and-views/split-views.ts | 5 +- site/src/content/api/application.json | 2 +- site/src/content/api/pointer.json | 4 +- site/src/content/api/view.json | 10 +- .../content/guide/input/mouse-and-pointer.mdx | 4 +- .../guide/runtime/coordinates-and-views.mdx | 4 +- src/core/Application.ts | 6 +- src/input/InteractionSystem.ts | 8 +- src/input/Pointer.ts | 2 +- src/rendering/RenderingContext.ts | 9 ++ src/rendering/View.ts | 79 ++++++----- test/core/application-lifecycle.test.ts | 35 ++++- test/input/interaction.test.ts | 49 +++++++ test/rendering/rendering-context.test.ts | 19 +++ test/rendering/view.test.ts | 128 ++++++++++++++++++ 15 files changed, 311 insertions(+), 53 deletions(-) diff --git a/examples/guides/coordinates-and-views/split-views.ts b/examples/guides/coordinates-and-views/split-views.ts index c6c60c000..04bda5b42 100644 --- a/examples/guides/coordinates-and-views/split-views.ts +++ b/examples/guides/coordinates-and-views/split-views.ts @@ -13,7 +13,8 @@ export const drawSplitWorld = (context: RenderingContext, world: RenderNode, lef // #endregion guide:split-views // #region guide:pointer-world -import type { PointLike } from '@codexo/exojs'; +import type { Application, PointLike } from '@codexo/exojs'; -export const pointerInWorld = (view: View, pointer: PointLike): PointLike => view.screenToWorld(pointer.x, pointer.y); +export const pointerInWorld = (app: Application, view: View, pointer: PointLike): PointLike => + view.screenToWorld(pointer.x, pointer.y, app.width, app.height); // #endregion guide:pointer-world diff --git a/site/src/content/api/application.json b/site/src/content/api/application.json index e41d8d566..68844f9ed 100644 --- a/site/src/content/api/application.json +++ b/site/src/content/api/application.json @@ -356,7 +356,7 @@ } ], "returnType": "PointLike", - "description": "Convert a logical/design-space pixel coordinate - the space of Pointer.x/Pointer.y and node positions, e.g. 0..app.width - to a world position using the active camera. At the default centered camera this is the identity; with a panned/zoomed/rotated camera it undoes the transform. Equivalent to app.rendering.view.screenToWorld(x, y)." + "description": "Convert a logical/design-space pixel coordinate - the space of Pointer.x/Pointer.y and node positions, e.g. 0..app.width - to a world position using the active camera. At the default centered camera this is the identity; with a panned/zoomed/rotated camera it undoes the transform, and it honours a camera whose viewport covers only part of the canvas. Equivalent to app.rendering.view.screenToWorld(x, y, app.width, app.height)." }, { "name": "setCursor", diff --git a/site/src/content/api/pointer.json b/site/src/content/api/pointer.json index 85e4eff49..1de9167bb 100644 --- a/site/src/content/api/pointer.json +++ b/site/src/content/api/pointer.json @@ -1,6 +1,6 @@ { "title": "Pointer", - "description": "Unified mouse / touch / pen pointer. Wraps a single browser `PointerEvent.pointerId` and writes its state (position, buttons, pressure, tilt, etc.) into the engine's shared channels buffer so it can be polled by Input bindings or read directly by interaction-aware scene nodes. Coordinates are stored in the application's logical space - `app.width` / `app.height` units (`0..app.width` × `0..app.height`), matching node positions, the 2-argument View.screenToWorld, and the active camera. The CSS-pixel event coordinates are mapped through the canvas's display rect, so the value is independent of Application.pixelRatio, of the render resolution, and of however the canvas is scaled for display: a click at its right edge always reads `app.width`, whichever sizing policy is in play. A point outside the canvas maps outside `0..app.width` accordingly. The channel writes are normalized to 0..1 (position, size, twist, tilt) for backend-agnostic sampling. Pointers are owned by the InputSystem, which assigns them a slot index in 0..15 (see maxPointers) and exposes their per-slot channel offsets via the Pointer class namespace constants.", + "description": "Unified mouse / touch / pen pointer. Wraps a single browser `PointerEvent.pointerId` and writes its state (position, buttons, pressure, tilt, etc.) into the engine's shared channels buffer so it can be polled by Input bindings or read directly by interaction-aware scene nodes. Coordinates are stored in the application's logical space - `app.width` / `app.height` units (`0..app.width` × `0..app.height`), matching node positions and Application.screenToWorld. The CSS-pixel event coordinates are mapped through the canvas's display rect, so the value is independent of Application.pixelRatio, of the render resolution, and of however the canvas is scaled for display: a click at its right edge always reads `app.width`, whichever sizing policy is in play. A point outside the canvas maps outside `0..app.width` accordingly. The channel writes are normalized to 0..1 (position, size, twist, tilt) for backend-agnostic sampling. Pointers are owned by the InputSystem, which assigns them a slot index in 0..15 (see maxPointers) and exposes their per-slot channel offsets via the Pointer class namespace constants.", "symbol": "Pointer", "kind": "class", "subsystem": "input", @@ -20,7 +20,7 @@ "members": [], "paragraphs": [ "Unified mouse / touch / pen pointer. Wraps a single browser `PointerEvent.pointerId` and writes its state (position, buttons, pressure, tilt, etc.) into the engine's shared channels buffer so it can be polled by Input bindings or read directly by interaction-aware scene nodes.", - "Coordinates are stored in the application's logical space - `app.width` / `app.height` units (`0..app.width` × `0..app.height`), matching node positions, the 2-argument View.screenToWorld, and the active camera. The CSS-pixel event coordinates are mapped through the canvas's display rect, so the value is independent of Application.pixelRatio, of the render resolution, and of however the canvas is scaled for display: a click at its right edge always reads `app.width`, whichever sizing policy is in play. A point outside the canvas maps outside `0..app.width` accordingly. The channel writes are normalized to 0..1 (position, size, twist, tilt) for backend-agnostic sampling.", + "Coordinates are stored in the application's logical space - `app.width` / `app.height` units (`0..app.width` × `0..app.height`), matching node positions and Application.screenToWorld. The CSS-pixel event coordinates are mapped through the canvas's display rect, so the value is independent of Application.pixelRatio, of the render resolution, and of however the canvas is scaled for display: a click at its right edge always reads `app.width`, whichever sizing policy is in play. A point outside the canvas maps outside `0..app.width` accordingly. The channel writes are normalized to 0..1 (position, size, twist, tilt) for backend-agnostic sampling.", "Pointers are owned by the InputSystem, which assigns them a slot index in 0..15 (see maxPointers) and exposes their per-slot channel offsets via the Pointer class namespace constants." ], "importLine": "import { Pointer } from '@codexo/exojs'", diff --git a/site/src/content/api/view.json b/site/src/content/api/view.json index 30098c8ff..832ef4d52 100644 --- a/site/src/content/api/view.json +++ b/site/src/content/api/view.json @@ -800,7 +800,7 @@ } ], "returnType": "PointLike", - "description": "Convert a logical/design-space pixel coordinate to a world-space position. The 2-argument form treats (x, y) as coordinates in the view's own design space (0..view.width × 0..view.height) - the same space as Pointer.x/Pointer.y and node positions - and applies only the inverse camera transform. The viewport rectangle is intentionally ignored, because design-space input already lives inside the camera's projection - the mapping from the surface to logical pixels happened upstream. At the default centered camera this is the identity." + "description": "Convert a logical/design-space pixel coordinate to a world-space position. The 2-argument form treats (x, y) as coordinates in the view's design space: the unzoomed size given to the constructor, resize or reset. Zooming, or writing size directly, changes how much world the view shows but not this space, so a full-surface view sized to the canvas keeps accepting Pointer.x/Pointer.y unchanged at every zoom level. The viewport rectangle is ignored; for a view that covers only part of the surface (split screen) or whose design size differs from the canvas, use the 4-argument form with the surface size. At the default centered camera this is the identity." }, { "name": "screenToWorld", @@ -910,7 +910,7 @@ } ], "returnType": "PointLike", - "description": "Convert a raw canvas backing-store pixel coordinate to a world-space position. Accounts for the view's viewport rectangle, so it works correctly in multi-view / split-screen and letterboxed setups." + "description": "Convert a coordinate on the render surface to a world-space position. Accounts for the view's viewport rectangle, so it works correctly in multi-view / split-screen and letterboxed setups. The coordinate and the surface size only need to share a unit: pass app.width/app.height for pointer coordinates, or app.canvas.width/app.canvas.height for backing-store pixels." }, { "name": "setBounds", @@ -1499,7 +1499,7 @@ } ], "returnType": "PointLike", - "description": "Convert a world-space position to logical/design-space pixel coordinates (0..view.width × 0..view.height). Inverse of the 2-argument screenToWorld; the viewport is ignored. At the default centered camera this is the identity." + "description": "Convert a world-space position to design-space pixel coordinates. Inverse of the 2-argument screenToWorld, with the same design space; the viewport is ignored. At the default centered camera this is the identity." }, { "name": "worldToScreen", @@ -1609,7 +1609,7 @@ } ], "returnType": "PointLike", - "description": "Convert a world-space position to raw canvas backing-store pixel coordinates. Accounts for the view's viewport rectangle." + "description": "Convert a world-space position to a coordinate on the render surface. Inverse of the 4-argument screenToWorld; accounts for the view's viewport rectangle, and the result is in the unit of the surface size." }, { "name": "zoom", @@ -1656,7 +1656,7 @@ } ], "returnType": "this", - "description": "" + "description": "Multiply the visible area by factor relative to the current zoom. A factor of 0.5 halves the visible area, like doubling zoomLevel." }, { "name": "zoomIn", diff --git a/site/src/content/guide/input/mouse-and-pointer.mdx b/site/src/content/guide/input/mouse-and-pointer.mdx index 786d0bac2..61402df76 100644 --- a/site/src/content/guide/input/mouse-and-pointer.mdx +++ b/site/src/content/guide/input/mouse-and-pointer.mdx @@ -130,9 +130,9 @@ const world = view.screenToWorld(pointer.x, pointer.y); // world.x, world.y are now in scene world space ``` -The inverse is `worldToScreen(worldX, worldY)`, and the two round-trip. If the default centered camera is active (no pan/zoom), design space already equals world space, so `screenToWorld` is the identity and no mapping is needed. +The inverse is `worldToScreen(worldX, worldY)`, and the two round-trip. The two-argument form maps over the view's design space — the size it was created or resized with — so zooming changes how much world is visible but not which pointer coordinates are accepted. If the default centered camera is active (no pan/zoom), design space already equals world space, so `screenToWorld` is the identity and no mapping is needed. `app.screenToWorld(x, y)` maps through the active camera. -For raw canvas backing-store pixels — e.g. an off-screen render target at a different pixel ratio — the four-argument form `screenToWorld(x, y, canvasWidth, canvasHeight)` additionally accounts for the viewport rectangle and physical pixel size. +For a view that covers only part of the canvas (split screen, minimap) or whose size differs from the canvas, use the four-argument form `screenToWorld(x, y, app.width, app.height)`: it accounts for the viewport rectangle. Backing-store pixels work the same way with `app.canvas.width` and `app.canvas.height`. ## Interactive sprites diff --git a/site/src/content/guide/runtime/coordinates-and-views.mdx b/site/src/content/guide/runtime/coordinates-and-views.mdx index 3f1222f85..e0b2fb2ba 100644 --- a/site/src/content/guide/runtime/coordinates-and-views.mdx +++ b/site/src/content/guide/runtime/coordinates-and-views.mdx @@ -35,11 +35,11 @@ For smoothing, keep a separate world-space camera target and move it toward the ## Convert input using the same view -Pointer coordinates arrive in logical canvas units. Convert them through the view that rendered the world object: +Pointer coordinates arrive in logical canvas units. Convert them through the view that rendered the world object, over the logical canvas size so the view's viewport is taken into account: -In split-screen, first choose the viewport under the pointer, then use that viewport's view. Converting with the other player's camera can yield a plausible point in the wrong part of the world. If hit testing needs a child's local coordinates, apply the appropriate world-to-local conversion afterward. +For a camera that covers the whole canvas, the two-argument `view.screenToWorld(x, y)` gives the same result at every zoom level; `app.screenToWorld(x, y)` does this for the active camera. In split-screen, first choose the viewport under the pointer, then use that viewport's view with the canvas size as above. Converting with the other player's camera can yield a plausible point in the wrong part of the world. If hit testing needs a child's local coordinates, apply the appropriate world-to-local conversion afterward. Do not multiply pointer coordinates by DPR to repair a camera mismatch. The input system already supplies logical canvas coordinates; backing pixels are a rendering detail. diff --git a/src/core/Application.ts b/src/core/Application.ts index 31f855ccb..1e8828cfb 100644 --- a/src/core/Application.ts +++ b/src/core/Application.ts @@ -1122,10 +1122,12 @@ export class Application = {}> { * {@link Pointer.x}/{@link Pointer.y} and node positions, e.g. `0..app.width` * - to a world position using the active camera. At the default centered * camera this is the identity; with a panned/zoomed/rotated camera it undoes - * the transform. Equivalent to `app.rendering.view.screenToWorld(x, y)`. + * the transform, and it honours a camera whose viewport covers only part of + * the canvas. Equivalent to + * `app.rendering.view.screenToWorld(x, y, app.width, app.height)`. */ public screenToWorld(x: number, y: number): PointLike { - return this._rendering.view.screenToWorld(x, y); + return this._rendering.view.screenToWorld(x, y, this.width, this.height); } /** diff --git a/src/input/InteractionSystem.ts b/src/input/InteractionSystem.ts index 8eab448bc..f9ebd7cfb 100644 --- a/src/input/InteractionSystem.ts +++ b/src/input/InteractionSystem.ts @@ -1633,7 +1633,7 @@ export class InteractionSystem { const uiRoot = this._app.scenes.currentScene?._peekUI() ?? null; if (uiRoot !== null) { - const ui = this._app.rendering.screenView.screenToWorld(x, y); + const ui = this._designToLayerSpace(x, y, true); const uiHit = this._hitTestNode(uiRoot, ui.x, ui.y); if (uiHit !== null) { @@ -1641,7 +1641,7 @@ export class InteractionSystem { } } - const world = this._app.rendering.view.screenToWorld(x, y); + const world = this._designToLayerSpace(x, y, false); return { node: this._hitTest(world.x, world.y), x: world.x, y: world.y }; } @@ -1665,7 +1665,9 @@ export class InteractionSystem { private _designToLayerSpace(x: number, y: number, ui: boolean): PointLike { const view = ui ? this._app.rendering.screenView : this._app.rendering.view; - return view.screenToWorld(x, y); + // Map over the logical surface rather than the view's own design space: + // the active camera may cover only part of the canvas (split screen). + return view.screenToWorld(x, y, this._app.width, this._app.height); } /** Whether `node` lives inside the active scene's UI layer. */ diff --git a/src/input/Pointer.ts b/src/input/Pointer.ts index 6d1072721..d8441f605 100644 --- a/src/input/Pointer.ts +++ b/src/input/Pointer.ts @@ -115,7 +115,7 @@ export enum PointerState { * * Coordinates are stored in the application's logical space - `app.width` / * `app.height` units (`0..app.width` × `0..app.height`), matching node - * positions, the 2-argument {@link View.screenToWorld}, and the active camera. + * positions and {@link Application.screenToWorld}. * The CSS-pixel event coordinates are mapped through the canvas's display rect, * so the value is independent of {@link Application.pixelRatio}, of the render * resolution, and of however the canvas is scaled for display: a click at its diff --git a/src/rendering/RenderingContext.ts b/src/rendering/RenderingContext.ts index 661297669..556a7ab1e 100644 --- a/src/rendering/RenderingContext.ts +++ b/src/rendering/RenderingContext.ts @@ -247,7 +247,16 @@ export class RenderingContext implements DrawContext { * Preserves the camera's center and zoom; only the visible area size changes. */ public resize(width: number, height: number): void { + const zoom = this._view.zoomLevel; + + // `View.resize` resets zoom along with the design space; reapply it so a + // zoomed camera keeps its apparent scale across canvas resizes. this._view.resize(width, height); + + if (zoom !== 1) { + this._view.setZoom(zoom); + } + this._screenView.resize(width, height); this._screenView.setCenter(width / 2, height / 2); } diff --git a/src/rendering/View.ts b/src/rendering/View.ts index ab200cd8e..a33664759 100644 --- a/src/rendering/View.ts +++ b/src/rendering/View.ts @@ -278,10 +278,12 @@ export class View { return this; } + /** + * Multiply the visible area by `factor` relative to the current zoom. A + * factor of `0.5` halves the visible area, like doubling {@link zoomLevel}. + */ public zoom(factor: number): this { - this.resize(this._size.width * factor, this._size.height * factor); - - return this; + return this.setZoom(this._zoomLevel / factor); } /** @@ -492,27 +494,32 @@ export class View { /** * Convert a logical/design-space pixel coordinate to a world-space position. * - * The 2-argument form treats `(x, y)` as coordinates in the view's own - * design space (`0..view.width` × `0..view.height`) - the same space as - * {@link Pointer.x}/{@link Pointer.y} and node positions - and applies only - * the inverse camera transform. The viewport rectangle is intentionally - * ignored, because design-space input already lives inside the camera's - * projection - the mapping from the surface to logical pixels happened - * upstream. At the default centered camera this is the identity. + * The 2-argument form treats `(x, y)` as coordinates in the view's design + * space: the unzoomed size given to the constructor, {@link resize} or + * {@link reset}. Zooming, or writing {@link size} directly, changes how much + * world the view shows but not this space, so a full-surface view sized to + * the canvas keeps accepting {@link Pointer.x}/{@link Pointer.y} unchanged + * at every zoom level. The viewport rectangle is ignored; for a view that + * covers only part of the surface (split screen) or whose design size + * differs from the canvas, use the 4-argument form with the surface size. + * At the default centered camera this is the identity. * - * @param x - X coordinate in design/logical pixels (`0..view.width`). - * @param y - Y coordinate in design/logical pixels (`0..view.height`). + * @param x - X coordinate in design pixels. + * @param y - Y coordinate in design pixels. */ public screenToWorld(x: number, y: number): PointLike; /** - * Convert a raw canvas backing-store pixel coordinate to a world-space - * position. Accounts for the view's viewport rectangle, so it works - * correctly in multi-view / split-screen and letterboxed setups. + * Convert a coordinate on the render surface to a world-space position. + * Accounts for the view's viewport rectangle, so it works correctly in + * multi-view / split-screen and letterboxed setups. The coordinate and the + * surface size only need to share a unit: pass `app.width`/`app.height` for + * pointer coordinates, or `app.canvas.width`/`app.canvas.height` for + * backing-store pixels. * - * @param screenX - X pixel relative to the canvas top-left corner. - * @param screenY - Y pixel relative to the canvas top-left corner. - * @param canvasWidth - Canvas backing-store width in pixels (`app.canvas.width`). - * @param canvasHeight - Canvas backing-store height in pixels (`app.canvas.height`). + * @param screenX - X coordinate relative to the surface top-left corner. + * @param screenY - Y coordinate relative to the surface top-left corner. + * @param canvasWidth - Surface width in the unit of `screenX`. + * @param canvasHeight - Surface height in the unit of `screenY`. */ public screenToWorld(screenX: number, screenY: number, canvasWidth: number, canvasHeight: number): PointLike; public screenToWorld(screenX: number, screenY: number, canvasWidth?: number, canvasHeight?: number): PointLike { @@ -520,8 +527,8 @@ export class View { let clipY: number; if (canvasWidth === undefined || canvasHeight === undefined) { - const w = this.width || 1; - const h = this.height || 1; + const w = this._designWidth(); + const h = this._designHeight(); clipX = (screenX / w) * 2 - 1; clipY = 1 - (screenY / h) * 2; @@ -555,23 +562,23 @@ export class View { } /** - * Convert a world-space position to logical/design-space pixel coordinates - * (`0..view.width` × `0..view.height`). Inverse of the 2-argument - * {@link screenToWorld}; the viewport is ignored. At the default centered - * camera this is the identity. + * Convert a world-space position to design-space pixel coordinates. Inverse + * of the 2-argument {@link screenToWorld}, with the same design space; the + * viewport is ignored. At the default centered camera this is the identity. * * @param worldX - World X coordinate. * @param worldY - World Y coordinate. */ public worldToScreen(worldX: number, worldY: number): PointLike; /** - * Convert a world-space position to raw canvas backing-store pixel - * coordinates. Accounts for the view's viewport rectangle. + * Convert a world-space position to a coordinate on the render surface. + * Inverse of the 4-argument {@link screenToWorld}; accounts for the view's + * viewport rectangle, and the result is in the unit of the surface size. * * @param worldX - World X coordinate. * @param worldY - World Y coordinate. - * @param canvasWidth - Canvas backing-store width in pixels (`app.canvas.width`). - * @param canvasHeight - Canvas backing-store height in pixels (`app.canvas.height`). + * @param canvasWidth - Surface width, e.g. `app.width` or `app.canvas.width`. + * @param canvasHeight - Surface height, e.g. `app.height` or `app.canvas.height`. */ public worldToScreen(worldX: number, worldY: number, canvasWidth: number, canvasHeight: number): PointLike; public worldToScreen(worldX: number, worldY: number, canvasWidth?: number, canvasHeight?: number): PointLike { @@ -581,8 +588,8 @@ export class View { if (canvasWidth === undefined || canvasHeight === undefined) { return { - x: ((clipX + 1) / 2) * this.width, - y: ((1 - clipY) / 2) * this.height, + x: ((clipX + 1) / 2) * this._designWidth(), + y: ((1 - clipY) / 2) * this._designHeight(), }; } @@ -679,6 +686,16 @@ export class View { this._flags.destroy(); } + // A view constructed with a zero size and sized later only through `size` + // has no design space of its own; its visible area is the closest stand-in. + private _designWidth(): number { + return this._zoomBaseWidth || this.width || 1; + } + + private _designHeight(): number { + return this._zoomBaseHeight || this.height || 1; + } + private _setDirty(): void { this._flags.addMask(ViewFlags.TransformInverse | ViewFlags.BoundingBox); this._updateId++; diff --git a/test/core/application-lifecycle.test.ts b/test/core/application-lifecycle.test.ts index 798b73258..c7642fd86 100644 --- a/test/core/application-lifecycle.test.ts +++ b/test/core/application-lifecycle.test.ts @@ -1256,7 +1256,7 @@ describe('Application lifecycle / getters / sizing', () => { // ------------------------------------------------------------------------- describe('screenToWorld()', () => { - test('delegates to rendering.view.screenToWorld with the same args and return value', async () => { + test('delegates to rendering.view.screenToWorld over the logical surface', async () => { const { Application } = await loadHarness(); const app = new Application({ backend: { type: 'webgl2' } }); const fakePoint = { x: 42, y: 24 }; @@ -1264,9 +1264,40 @@ describe('Application lifecycle / getters / sizing', () => { const result = app.screenToWorld(10, 20); - expect(spy).toHaveBeenCalledWith(10, 20); + expect(spy).toHaveBeenCalledWith(10, 20, app.width, app.height); expect(result).toBe(fakePoint); }); + + test('maps a design point through a zoomed active camera', async () => { + const { Application } = await loadHarness(); + const { View } = await import('#rendering/View'); + const app = new Application({ backend: { type: 'webgl2' } }); + const view = new View(300, 200, app.width, app.height).setZoom(2); + + app.rendering.view = view; + + const centre = app.screenToWorld(app.width / 2, app.height / 2); + const quarter = app.screenToWorld(app.width / 4, app.height / 4); + + expect(centre.x).toBeCloseTo(view.center.x, 2); + expect(centre.y).toBeCloseTo(view.center.y, 2); + expect(quarter.x).toBeCloseTo(view.center.x - app.width / 8, 2); + expect(quarter.y).toBeCloseTo(view.center.y - app.height / 8, 2); + }); + + test('maps a design point through a split-screen active camera', async () => { + const { Application } = await loadHarness(); + const { View } = await import('#rendering/View'); + const app = new Application({ backend: { type: 'webgl2' } }); + const view = new View(1000, 0, app.width / 2, app.height).setViewport(0.5, 0, 0.5, 1); + + app.rendering.view = view; + + const centre = app.screenToWorld(app.width * 0.75, app.height / 2); + + expect(centre.x).toBeCloseTo(1000, 2); + expect(centre.y).toBeCloseTo(0, 2); + }); }); // ------------------------------------------------------------------------- diff --git a/test/input/interaction.test.ts b/test/input/interaction.test.ts index 677a5ffb4..122982143 100644 --- a/test/input/interaction.test.ts +++ b/test/input/interaction.test.ts @@ -14,6 +14,7 @@ import { BrowserPlatform } from '#platform/BrowserPlatform'; import { Container } from '#rendering/Container'; import { Drawable } from '#rendering/Drawable'; import type { Geometry } from '#rendering/geometry/Geometry'; +import { View } from '#rendering/View'; import { frameDelta } from '../support/frame-delta'; @@ -316,6 +317,54 @@ describe('InteractionSystem — hit-test basics', () => { // 2. Z-order - top child wins // --------------------------------------------------------------------------- +describe('InteractionSystem — active camera mapping', () => { + const hitsAt = ( + app: Application, + scene: Scene, + signals: MockSignals, + bounds: [number, number, number, number], + pointer: { x: number; y: number }, + ): boolean => { + const im = new InteractionSystem(app); + im.attachRoot(scene.root); + const sprite = new TestSprite().setBounds(...bounds); + + sprite.interactive = true; + scene.addChild(sprite); + + const handler = vi.fn(); + + sprite.onPointerDown.add(handler); + dispatchPointer(signals.onPointerDown, pointer); + flushInteractions(im); + + im.destroy(); + sprite.destroy(); + + return handler.mock.calls.length === 1; + }; + + test('hits a world node under the pointer through a zoomed camera', () => { + const { app, scene, signals } = createApp(); + const view = new View(400, 300, 800, 600).setZoom(2); + + (app.rendering as { view: View }).view = view; + + // Design (200, 150) shows world (300, 225) at zoom 2. + expect(hitsAt(app, scene, signals, [290, 215, 20, 20], { x: 200, y: 150 })).toBe(true); + }); + + test('hits a world node through a split-screen active camera viewport', () => { + const { app, scene, signals } = createApp(); + const view = new View(1000, 0, 400, 600).setViewport(0.5, 0, 0.5, 1).setZoom(2); + + (app.rendering as { view: View }).view = view; + + // The centre of the right half shows the camera centre (1000, 0). + expect(hitsAt(app, scene, signals, [990, -10, 20, 20], { x: 600, y: 300 })).toBe(true); + }); +}); + describe('InteractionSystem — z-order', () => { test('top child (added last) receives hit over bottom child at same position', () => { const { app, scene, signals } = createApp(); diff --git a/test/rendering/rendering-context.test.ts b/test/rendering/rendering-context.test.ts index 5f0881253..b86600b12 100644 --- a/test/rendering/rendering-context.test.ts +++ b/test/rendering/rendering-context.test.ts @@ -183,6 +183,25 @@ describe('RenderingContext', () => { expect(drawEvents[1]).toBe(green); }); + test('resize keeps the camera zoom and maps the new design space', () => { + const { backend } = createMockBackend(); + const context = new RenderingContext(backend); + const view = new View(500, 400, 320, 200).setZoom(2); + + context.view = view; + context.resize(640, 400); + + expect(view.zoomLevel).toBe(2); + expect(view.width).toBeCloseTo(320); + expect(view.height).toBeCloseTo(200); + expect(view.center.x).toBe(500); + + const corner = view.screenToWorld(0, 0); + + expect(corner.x).toBeCloseTo(340, 2); + expect(corner.y).toBeCloseTo(300, 2); + }); + test('stats exposes backend stats', () => { const { backend } = createMockBackend(); const context = new RenderingContext(backend); diff --git a/test/rendering/view.test.ts b/test/rendering/view.test.ts index dc16de7a3..96a674abc 100644 --- a/test/rendering/view.test.ts +++ b/test/rendering/view.test.ts @@ -254,6 +254,134 @@ describe('View — coordinate conversion', () => { }); }); +describe('View — coordinate conversion under zoom', () => { + // Expected values are derived from the camera geometry, never from the + // inverse function: a zoom of `z` shows `referenceSize / z` world units + // centred on the camera, stretched across the whole design space. + + const expectPoint = (actual: { x: number; y: number }, x: number, y: number): void => { + expect(actual.x).toBeCloseTo(x, 2); + expect(actual.y).toBeCloseTo(y, 2); + }; + + test('2-arg screenToWorld maps the design space across the zoomed visible area', () => { + const view = new View(400, 300, 800, 600); + + view.setZoom(2); + + expectPoint(view.screenToWorld(400, 300), 400, 300); + expectPoint(view.screenToWorld(200, 150), 300, 225); + expectPoint(view.screenToWorld(0, 0), 200, 150); + expectPoint(view.screenToWorld(800, 600), 600, 450); + }); + + test('2-arg screenToWorld zooms out below 1', () => { + const view = new View(400, 300, 800, 600); + + view.setZoom(0.5); + + expectPoint(view.screenToWorld(400, 300), 400, 300); + expectPoint(view.screenToWorld(200, 150), 0, 0); + expectPoint(view.screenToWorld(0, 0), -400, -300); + }); + + test('2-arg screenToWorld combines zoom with a panned, non-square camera', () => { + const view = new View(1000, 500, 1280, 720); + + view.setZoom(4); + + // Visible area 320×180 centred on (1000, 500). + expectPoint(view.screenToWorld(0, 0), 840, 410); + expectPoint(view.screenToWorld(1280, 720), 1160, 590); + expectPoint(view.screenToWorld(640, 360), 1000, 500); + }); + + test('2-arg screenToWorld scales distances by 1 / zoom under rotation', () => { + const view = new View(50, -20, 1280, 720); + + view.setZoom(4); + view.setRotation(90); + + const world = view.screenToWorld(640 + 100, 360); + + expect(Math.hypot(world.x - 50, world.y + 20)).toBeCloseTo(25, 2); + }); + + test('2-arg worldToScreen places world points where the projection renders them', () => { + const view = new View(300, 200, 800, 600); + + view.setZoom(2.5); + view.setRotation(30); + + const m = view.getTransform(); + + for (const [x, y] of [ + [300, 200], + [250, 180], + [410, 260], + ]) { + // The backend maps clip space onto the full 800×600 design surface. + const clipX = m.a * x + m.b * y + m.x; + const clipY = m.c * x + m.d * y + m.y; + const pixelX = ((clipX + 1) / 2) * 800; + const pixelY = ((1 - clipY) / 2) * 600; + + expectPoint(view.worldToScreen(x, y), pixelX, pixelY); + expectPoint(view.screenToWorld(pixelX, pixelY), x, y); + } + }); + + test('zoom() keeps the design space and tracks the zoom level', () => { + const view = new View(400, 300, 800, 600); + + view.zoom(0.5); + + expect(view.width).toBeCloseTo(400); + expect(view.zoomLevel).toBeCloseTo(2); + expectPoint(view.screenToWorld(200, 150), 300, 225); + }); + + test('writing the size directly narrows the visible area without moving the design space', () => { + const view = new View(400, 300, 800, 600); + + view.size.set(400, 300); + + expectPoint(view.screenToWorld(200, 150), 300, 225); + expectPoint(view.worldToScreen(300, 225), 200, 150); + }); + + test('resize() replaces the design space and resets zoom', () => { + const view = new View(400, 300, 800, 600); + + view.setZoom(2); + view.resize(1600, 1200); + + expect(view.zoomLevel).toBe(1); + expectPoint(view.screenToWorld(0, 0), -400, -300); + expectPoint(view.screenToWorld(1600, 1200), 1200, 900); + }); + + test('4-arg screenToWorld agrees with the 2-arg form for a full-surface view', () => { + const view = new View(400, 300, 800, 600); + + view.setZoom(2); + + expectPoint(view.screenToWorld(200, 150, 800, 600), 300, 225); + expectPoint(view.screenToWorld(400, 300, 1600, 1200), 300, 225); + }); + + test('4-arg screenToWorld resolves a pointer inside a split-screen viewport', () => { + const right = new View(1000, 0, 400, 600).setViewport(0.5, 0, 0.5, 1); + + right.setZoom(2); + + // The right half of an 800×600 surface shows 200×300 world units around (1000, 0). + expectPoint(right.screenToWorld(600, 300, 800, 600), 1000, 0); + expectPoint(right.screenToWorld(400, 0, 800, 600), 900, -150); + expectPoint(right.worldToScreen(900, -150, 800, 600), 400, 0); + }); +}); + describe('View.destroy', () => { test('reports destroyed only after destroy has run', () => { const view = new View(0, 0, 100, 100); From 36e5d1e1ab28393c3324a944bca81aba1a32b145 Mon Sep 17 00:00:00 2001 From: Exoridus <1218727+Exoridus@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:11:02 +0200 Subject: [PATCH 2/2] fix(rendering): keep zoom() relative to the current size and guard a zero-extent viewport --- src/rendering/View.ts | 9 ++++- .../view-rotated-cull-bounds.test.ts | 15 +++++---- test/rendering/view-rotation-pivot.test.ts | 4 +-- test/rendering/view.test.ts | 33 +++++++++++++++++++ 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/rendering/View.ts b/src/rendering/View.ts index a33664759..5cef4dcaa 100644 --- a/src/rendering/View.ts +++ b/src/rendering/View.ts @@ -283,7 +283,10 @@ export class View { * factor of `0.5` halves the visible area, like doubling {@link zoomLevel}. */ public zoom(factor: number): this { - return this.setZoom(this._zoomLevel / factor); + this._zoomLevel = Math.max(0.0001, this._zoomLevel / factor); + this._size.set(this._size.width * factor, this._size.height * factor); + + return this; } /** @@ -536,6 +539,10 @@ export class View { const vw = this._viewport.width * canvasWidth; const vh = this._viewport.height * canvasHeight; + if (vw === 0 || vh === 0) { + return { x: 0, y: 0 }; + } + clipX = ((screenX - this._viewport.x * canvasWidth) / vw) * 2 - 1; clipY = 1 - ((screenY - this._viewport.y * canvasHeight) / vh) * 2; } diff --git a/test/rendering/view-rotated-cull-bounds.test.ts b/test/rendering/view-rotated-cull-bounds.test.ts index d6d579e49..0e3fa02e3 100644 --- a/test/rendering/view-rotated-cull-bounds.test.ts +++ b/test/rendering/view-rotated-cull-bounds.test.ts @@ -2,16 +2,17 @@ import { View } from '#rendering/View'; /** * The world-space AABB of the four viewport corners, mapped through the view's - * own `screenToWorld`. This is by definition the area the camera renders, so - * the cull bounds must cover exactly it - whatever rotation, zoom or centre is - * in play, and independent of how the projection composes them. + * own `screenToWorld` over a unit surface. This is by definition the area the + * camera renders, so the cull bounds must cover exactly it - whatever rotation, + * zoom or centre is in play, and independent of how the projection composes + * them. */ const visibleAreaFromCorners = (view: View): { left: number; top: number; right: number; bottom: number } => { const corners = [ - view.screenToWorld(0, 0), - view.screenToWorld(view.width, 0), - view.screenToWorld(view.width, view.height), - view.screenToWorld(0, view.height), + view.screenToWorld(0, 0, 1, 1), + view.screenToWorld(1, 0, 1, 1), + view.screenToWorld(1, 1, 1, 1), + view.screenToWorld(0, 1, 1, 1), ]; const xs = corners.map(corner => corner.x); const ys = corners.map(corner => corner.y); diff --git a/test/rendering/view-rotation-pivot.test.ts b/test/rendering/view-rotation-pivot.test.ts index d3c184f21..596bb6a64 100644 --- a/test/rendering/view-rotation-pivot.test.ts +++ b/test/rendering/view-rotation-pivot.test.ts @@ -62,8 +62,8 @@ describe('View rotation pivots around the camera center', () => { view.setRotation(37); view.setZoom(2.5); - // Zoom shrinks the design space, so the middle of the viewport moves with it. - const world = view.screenToWorld(view.width / 2, view.height / 2); + // Zoom changes the visible area, not the design space the pointer lives in. + const world = view.screenToWorld(WIDTH / 2, HEIGHT / 2); expect(world.x).toBeCloseTo(-250, 6); expect(world.y).toBeCloseTo(480, 6); diff --git a/test/rendering/view.test.ts b/test/rendering/view.test.ts index 96a674abc..f1b1841c4 100644 --- a/test/rendering/view.test.ts +++ b/test/rendering/view.test.ts @@ -350,6 +350,39 @@ describe('View — coordinate conversion under zoom', () => { expectPoint(view.worldToScreen(300, 225), 200, 150); }); + test('zoom() scales a directly written size and keeps the design space', () => { + const view = new View(400, 300, 800, 600); + + view.size.set(400, 300); + view.zoom(0.5); + + expect(view.width).toBeCloseTo(200); + expectPoint(view.screenToWorld(0, 0), 300, 225); + }); + + test('View.from maps the design space of its size at the requested zoom', () => { + const view = View.from({ center: { x: 400, y: 300 }, size: { width: 800, height: 600 }, zoom: 2 }); + + expectPoint(view.screenToWorld(200, 150), 300, 225); + }); + + test('a view constructed without a size maps over the size written later', () => { + const view = new View(400, 300, 0, 0); + + view.size.set(800, 600); + + expectPoint(view.screenToWorld(0, 0), 0, 0); + expectPoint(view.worldToScreen(800, 600), 800, 600); + }); + + test('4-arg screenToWorld returns a finite point for a zero-extent viewport', () => { + const view = new View(400, 300, 800, 600).setViewport(0, 0, 0, 0); + const world = view.screenToWorld(10, 10, 800, 600); + + expect(Number.isFinite(world.x)).toBe(true); + expect(Number.isFinite(world.y)).toBe(true); + }); + test('resize() replaces the design space and resets zoom', () => { const view = new View(400, 300, 800, 600);