From 14313e60a11317d8c9bce408eeca2f3d5bcd09d0 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sun, 21 Dec 2025 11:05:18 -0600 Subject: [PATCH] feat: camera operations --- TODO.txt | 30 +- packages/core/package.json | 21 +- packages/core/src/camera.ts | 164 ++++++++ packages/core/tests/camera.test.ts | 606 +++++++++++++++++++++++++++++ pnpm-lock.yaml | 10 + 5 files changed, 809 insertions(+), 22 deletions(-) create mode 100644 packages/core/src/camera.ts create mode 100644 packages/core/tests/camera.test.ts diff --git a/TODO.txt b/TODO.txt index 6728032..59b25b2 100644 --- a/TODO.txt +++ b/TODO.txt @@ -40,7 +40,6 @@ Goal: a monorepo that can run a blank canvas in web + desktop. - `pnpm dev:desktop` launches a Tauri window that shows the same canvas. - `pnpm test` runs at least 1 passing core test. - ============================================================================== 2. Milestone B: Math + coordinate systems *wb-B* ============================================================================== @@ -60,33 +59,29 @@ Core primitives (/packages/core/src/math.ts): - multiply(a, b) - transformPoint(m, p) -Camera (/packages/core/src/camera): -[ ] Define Camera { x, y, zoom } (world origin + scale) -[ ] Implement worldToScreen(camera, p) -[ ] Implement screenToWorld(camera, p) -[ ] Implement cameraPan(camera, deltaScreen) -> camera' -[ ] Implement cameraZoomAt(camera, factor, anchorScreenPoint) -> camera' +Camera (/packages/core/src/camera.ts): +[x] Define Camera { x, y, zoom } (world origin + scale) +[x] Implement worldToScreen(camera, p) +[x] Implement screenToWorld(camera, p) +[x] Implement cameraPan(camera, deltaScreen) -> camera' +[x] Implement cameraZoomAt(camera, factor, anchorScreenPoint) -> camera' -Tests (/packages/core/test): -[ ] worldToScreen(screenToWorld(p)) round-trip within epsilon -[ ] zoomAt keeps anchor point stable (screen position unchanged) -[ ] pan moves world under cursor as expected +Tests (/packages/core/tests/camera.test.ts): +[x] worldToScreen(screenToWorld(p)) round-trip within epsilon +[x] zoomAt keeps anchor point stable (screen position unchanged) +[x] pan moves world under cursor as expected (DoD): - All math/camera functions are unit-tested and pass. - ============================================================================== 3. Milestone C: Document model (records) *wb-C* ============================================================================== Goal: define the minimal data model that can represent a drawing. -IDs (/packages/core/src/id): -[ ] Implement createId(prefix) -> string (stable, unique enough for offline) -[ ] Add test: ids are unique across 10k calls - -Records (/packages/core/src/model): +Records & ID (/packages/core/src/model): +[ ] Implement createId(prefix) -> uuid (v4) [ ] Define PageRecord { id, name, shapeIds: string[] } [ ] Define ShapeRecord base: - id, type, pageId @@ -114,7 +109,6 @@ Validation: (DoD): - You can serialize a doc with a page + 1 shape to JSON and validate it. - ============================================================================== 4. Milestone D: Store + selectors (reactive core) *wb-D* ============================================================================== diff --git a/packages/core/package.json b/packages/core/package.json index 8ecd345..efc4679 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,13 +6,23 @@ "author": "Author Name ", "license": "MIT", "homepage": "https://github.com/author/library#readme", - "repository": { "type": "git", "url": "git+https://github.com/author/library.git" }, - "bugs": { "url": "https://github.com/author/library/issues" }, - "exports": { ".": "./dist/index.mjs", "./package.json": "./package.json" }, + "repository": { + "type": "git", + "url": "git+https://github.com/author/library.git" + }, + "bugs": { + "url": "https://github.com/author/library/issues" + }, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, "main": "./dist/index.mjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsdown", "dev": "tsdown --watch", @@ -26,5 +36,8 @@ "tsdown": "^0.18.1", "typescript": "^5.9.3", "vitest": "^4.0.16" + }, + "dependencies": { + "uuid": "^13.0.0" } } diff --git a/packages/core/src/camera.ts b/packages/core/src/camera.ts new file mode 100644 index 0000000..d06ffac --- /dev/null +++ b/packages/core/src/camera.ts @@ -0,0 +1,164 @@ +import { Vec2 } from "./math"; + +/** + * Camera represents the viewport into the infinite canvas + * - x, y: world coordinates at the center of the screen + * - zoom: scale factor (1 = 100%, 2 = 200% zoomed in, 0.5 = 50% zoomed out) + */ +export type Camera = { x: number; y: number; zoom: number }; + +/** + * Viewport dimensions in screen pixels + */ +export type Viewport = { width: number; height: number }; + +export const Camera = { + /** + * Create a new camera with default values + */ + create(x = 0, y = 0, zoom = 1): Camera { + return { x, y, zoom }; + }, + + /** + * Transform a point from world coordinates to screen coordinates + * + * Algorithm: + * 1. Translate point relative to camera position + * 2. Scale by zoom factor + * 3. Translate to screen center + * + * @param camera - The camera + * @param worldPoint - Point in world coordinates + * @param viewport - Screen viewport dimensions + * @returns Point in screen coordinates (pixels) + */ + worldToScreen(camera: Camera, worldPoint: Vec2, viewport: Viewport): Vec2 { + const offsetX = worldPoint.x - camera.x; + const offsetY = worldPoint.y - camera.y; + return { x: offsetX * camera.zoom + viewport.width / 2, y: offsetY * camera.zoom + viewport.height / 2 }; + }, + + /** + * Transform a point from screen coordinates to world coordinates + * + * This is the inverse of worldToScreen + * + * @param camera - The camera + * @param screenPoint - Point in screen coordinates (pixels) + * @param viewport - Screen viewport dimensions + * @returns Point in world coordinates + */ + screenToWorld(camera: Camera, screenPoint: Vec2, viewport: Viewport): Vec2 { + const offsetX = screenPoint.x - viewport.width / 2; + const offsetY = screenPoint.y - viewport.height / 2; + return { x: offsetX / camera.zoom + camera.x, y: offsetY / camera.zoom + camera.y }; + }, + + /** + * Pan the camera by a delta in screen space + * + * When the user drags the canvas, they move it in screen pixels. + * We need to convert that to world space movement. + * + * @param camera - The current camera + * @param deltaScreen - Movement delta in screen pixels + * @returns New camera with updated position + */ + pan(camera: Camera, deltaScreen: Vec2): Camera { + const worldDeltaX = -deltaScreen.x / camera.zoom; + const worldDeltaY = -deltaScreen.y / camera.zoom; + return { x: camera.x + worldDeltaX, y: camera.y + worldDeltaY, zoom: camera.zoom }; + }, + + /** + * Zoom the camera at a specific screen anchor point + * + * The anchor point should remain at the same screen position after zoom. + * This creates the "zoom to cursor" behavior. + * + * Algorithm: + * 1. Convert anchor from screen to world coordinates (at current zoom) + * 2. Apply zoom factor + * 3. Convert anchor back to screen coordinates (at new zoom) + * 4. Adjust camera position so anchor stays at same screen position + * + * @param camera - The current camera + * @param factor - Zoom multiplier (e.g., 1.1 = zoom in 10%, 0.9 = zoom out 10%) + * @param anchorScreen - The screen point to zoom towards + * @param viewport - Screen viewport dimensions + * @returns New camera with updated zoom and position + */ + zoomAt(camera: Camera, factor: number, anchorScreen: Vec2, viewport: Viewport): Camera { + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); + const newZoom = camera.zoom * factor; + + const offsetX = anchorScreen.x - viewport.width / 2; + const offsetY = anchorScreen.y - viewport.height / 2; + + return { x: anchorWorld.x - offsetX / newZoom, y: anchorWorld.y - offsetY / newZoom, zoom: newZoom }; + }, + + /** + * Clamp camera zoom to reasonable bounds + * + * @param camera - The camera to clamp + * @param minZoom - Minimum zoom level (default: 0.1 = 10%) + * @param maxZoom - Maximum zoom level (default: 10 = 1000%) + * @returns Camera with clamped zoom + */ + clampZoom(camera: Camera, minZoom = 0.1, maxZoom = 10): Camera { + const clampedZoom = Math.max(minZoom, Math.min(maxZoom, camera.zoom)); + + if (clampedZoom === camera.zoom) { + return camera; + } + + return { x: camera.x, y: camera.y, zoom: clampedZoom }; + }, + + /** + * Reset camera to default position and zoom + * + * @returns Camera at origin with 100% zoom + */ + reset(): Camera { + return Camera.create(0, 0, 1); + }, + + /** + * Clone a camera + * + * @param camera - Camera to clone + * @returns New camera with same values + */ + clone(camera: Camera): Camera { + return { x: camera.x, y: camera.y, zoom: camera.zoom }; + }, + + /** + * Check if two cameras are approximately equal + * + * @param a - First camera + * @param b - Second camera + * @param epsilon - Tolerance for comparison + * @returns True if cameras are equal within epsilon + */ + equals(a: Camera, b: Camera, epsilon = 1e-10): boolean { + return (Math.abs(a.x - b.x) <= epsilon && Math.abs(a.y - b.y) <= epsilon && Math.abs(a.zoom - b.zoom) <= epsilon); + }, + + /** + * Get the world-space bounds visible in the viewport + * + * @param camera - The camera + * @param viewport - Screen viewport dimensions + * @returns Bounding box in world coordinates + */ + getViewportBounds(camera: Camera, viewport: Viewport) { + const topLeft = Camera.screenToWorld(camera, { x: 0, y: 0 }, viewport); + const bottomRight = Camera.screenToWorld(camera, { x: viewport.width, y: viewport.height }, viewport); + + return { min: topLeft, max: bottomRight, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y }; + }, +}; diff --git a/packages/core/tests/camera.test.ts b/packages/core/tests/camera.test.ts new file mode 100644 index 0000000..d804201 --- /dev/null +++ b/packages/core/tests/camera.test.ts @@ -0,0 +1,606 @@ +import { describe, expect, it } from "vitest"; +import { Camera, Viewport } from "../src/camera"; + +const viewport: Viewport = { width: 800, height: 600 }; + +describe("Camera", () => { + describe("create", () => { + it("should create camera with default values", () => { + const camera = Camera.create(); + expect(camera).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + + it("should create camera with custom values", () => { + const camera = Camera.create(100, 200, 2); + expect(camera).toEqual({ x: 100, y: 200, zoom: 2 }); + }); + + it.each([ + { description: "negative position", x: -100, y: -200, zoom: 1 }, + { description: "fractional zoom", x: 0, y: 0, zoom: 0.5 }, + { description: "large zoom", x: 0, y: 0, zoom: 10 }, + { description: "very small zoom", x: 0, y: 0, zoom: 0.01 }, + ])("should handle $description", ({ x, y, zoom }) => { + const camera = Camera.create(x, y, zoom); + expect(camera).toEqual({ x, y, zoom }); + }); + }); + + describe("clone", () => { + it("should create a copy of the camera", () => { + const camera = Camera.create(10, 20, 1.5); + const cloned = Camera.clone(camera); + expect(cloned).toEqual(camera); + expect(cloned).not.toBe(camera); + }); + }); + + describe("equals", () => { + it.each([ + { + description: "identical cameras", + a: Camera.create(10, 20, 1.5), + b: Camera.create(10, 20, 1.5), + expected: true, + }, + { description: "different position", a: Camera.create(10, 20, 1), b: Camera.create(11, 20, 1), expected: false }, + { description: "different zoom", a: Camera.create(10, 20, 1), b: Camera.create(10, 20, 1.1), expected: false }, + ])("should compare $description", ({ a, b, expected }) => { + expect(Camera.equals(a, b)).toBe(expected); + }); + + it("should use epsilon for floating point comparison", () => { + const a = Camera.create(10, 20, 1); + const b = Camera.create(10 + 5e-11, 20 + 5e-11, 1 + 5e-11); + expect(Camera.equals(a, b)).toBe(true); + }); + + it("should allow custom epsilon", () => { + const a = Camera.create(10, 20, 1); + const b = Camera.create(10.005, 20.005, 1.005); + expect(Camera.equals(a, b, 0.01)).toBe(true); + expect(Camera.equals(a, b, 0.001)).toBe(false); + }); + }); + + describe("reset", () => { + it("should reset camera to origin with 100% zoom", () => { + expect(Camera.reset()).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + }); + + describe("worldToScreen", () => { + it.each([{ + description: "camera at origin, point at origin", + camera: Camera.create(0, 0, 1), + worldPoint: { x: 0, y: 0 }, + expected: { x: 400, y: 300 }, + }, { + description: "camera at origin, point to the right", + camera: Camera.create(0, 0, 1), + worldPoint: { x: 100, y: 0 }, + expected: { x: 500, y: 300 }, + }, { + description: "camera at origin, point below", + camera: Camera.create(0, 0, 1), + worldPoint: { x: 0, y: 100 }, + expected: { x: 400, y: 400 }, + }, { + description: "camera offset, point at camera position", + camera: Camera.create(100, 200, 1), + worldPoint: { x: 100, y: 200 }, + expected: { x: 400, y: 300 }, + }, { + description: "zoomed in 2x, point at origin", + camera: Camera.create(0, 0, 2), + worldPoint: { x: 100, y: 0 }, + expected: { x: 600, y: 300 }, + }, { + description: "zoomed out 0.5x, point at origin", + camera: Camera.create(0, 0, 0.5), + worldPoint: { x: 100, y: 0 }, + expected: { x: 450, y: 300 }, + }, { + description: "negative world coordinates", + camera: Camera.create(0, 0, 1), + worldPoint: { x: -100, y: -50 }, + expected: { x: 300, y: 250 }, + }, { + description: "camera and point both negative", + camera: Camera.create(-100, -100, 1), + worldPoint: { x: -50, y: -50 }, + expected: { x: 450, y: 350 }, + }])("should handle $description", ({ camera, worldPoint, expected }) => { + const result = Camera.worldToScreen(camera, worldPoint, viewport); + expect(result.x).toBeCloseTo(expected.x); + expect(result.y).toBeCloseTo(expected.y); + }); + }); + + describe("screenToWorld", () => { + it.each([{ + description: "center of screen", + camera: Camera.create(0, 0, 1), + screenPoint: { x: 400, y: 300 }, + expected: { x: 0, y: 0 }, + }, { + description: "top-left corner", + camera: Camera.create(0, 0, 1), + screenPoint: { x: 0, y: 0 }, + expected: { x: -400, y: -300 }, + }, { + description: "bottom-right corner", + camera: Camera.create(0, 0, 1), + screenPoint: { x: 800, y: 600 }, + expected: { x: 400, y: 300 }, + }, { + description: "with camera offset", + camera: Camera.create(100, 200, 1), + screenPoint: { x: 400, y: 300 }, + expected: { x: 100, y: 200 }, + }, { + description: "with 2x zoom", + camera: Camera.create(0, 0, 2), + screenPoint: { x: 600, y: 300 }, + expected: { x: 100, y: 0 }, + }, { + description: "with 0.5x zoom", + camera: Camera.create(0, 0, 0.5), + screenPoint: { x: 450, y: 300 }, + expected: { x: 100, y: 0 }, + }])("should handle $description", ({ camera, screenPoint, expected }) => { + const result = Camera.screenToWorld(camera, screenPoint, viewport); + expect(result.x).toBeCloseTo(expected.x); + expect(result.y).toBeCloseTo(expected.y); + }); + }); + + describe("worldToScreen <-> screenToWorld round-trip", () => { + const testCases = [ + { description: "origin camera, origin point", camera: Camera.create(0, 0, 1), worldPoint: { x: 0, y: 0 } }, + { + description: "origin camera, arbitrary point", + camera: Camera.create(0, 0, 1), + worldPoint: { x: 123.456, y: -789.012 }, + }, + { + description: "offset camera, arbitrary point", + camera: Camera.create(100, 200, 1), + worldPoint: { x: 50, y: -30 }, + }, + { description: "zoomed in camera", camera: Camera.create(0, 0, 2.5), worldPoint: { x: 100, y: 200 } }, + { description: "zoomed out camera", camera: Camera.create(0, 0, 0.3), worldPoint: { x: 1000, y: 2000 } }, + { description: "complex camera state", camera: Camera.create(-500, 300, 1.7), worldPoint: { x: -123, y: 456 } }, + { + description: "very large coordinates", + camera: Camera.create(10_000, -10_000, 1), + worldPoint: { x: 9999, y: -9999 }, + }, + { description: "very small zoom", camera: Camera.create(0, 0, 0.01), worldPoint: { x: 50_000, y: 30_000 } }, + ]; + + it.each(testCases)("should round-trip for $description", ({ camera, worldPoint }) => { + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); + + expect(backToWorld.x).toBeCloseTo(worldPoint.x, 10); + expect(backToWorld.y).toBeCloseTo(worldPoint.y, 10); + }); + + it("should round-trip in reverse direction", () => { + const camera = Camera.create(100, 200, 1.5); + const screenPoint = { x: 250, y: 450 }; + + const worldPoint = Camera.screenToWorld(camera, screenPoint, viewport); + const backToScreen = Camera.worldToScreen(camera, worldPoint, viewport); + + expect(backToScreen.x).toBeCloseTo(screenPoint.x, 10); + expect(backToScreen.y).toBeCloseTo(screenPoint.y, 10); + }); + }); + + describe("pan", () => { + it.each([{ + description: "pan right (positive screen delta)", + camera: Camera.create(0, 0, 1), + deltaScreen: { x: 100, y: 0 }, + expectedPosition: { x: -100, y: 0 }, + }, { + description: "pan left (negative screen delta)", + camera: Camera.create(0, 0, 1), + deltaScreen: { x: -100, y: 0 }, + expectedPosition: { x: 100, y: 0 }, + }, { + description: "pan down", + camera: Camera.create(0, 0, 1), + deltaScreen: { x: 0, y: 50 }, + expectedPosition: { x: 0, y: -50 }, + }, { + description: "pan up", + camera: Camera.create(0, 0, 1), + deltaScreen: { x: 0, y: -50 }, + expectedPosition: { x: 0, y: 50 }, + }, { + description: "diagonal pan", + camera: Camera.create(0, 0, 1), + deltaScreen: { x: 100, y: 50 }, + expectedPosition: { x: -100, y: -50 }, + }, { + description: "pan from non-origin", + camera: Camera.create(200, 300, 1), + deltaScreen: { x: 50, y: 25 }, + expectedPosition: { x: 150, y: 275 }, + }])("should handle $description", ({ camera, deltaScreen, expectedPosition }) => { + const newCamera = Camera.pan(camera, deltaScreen); + expect(newCamera.x).toBeCloseTo(expectedPosition.x); + expect(newCamera.y).toBeCloseTo(expectedPosition.y); + expect(newCamera.zoom).toBe(camera.zoom); + }); + + it("should scale pan by zoom level", () => { + const testCases = [{ + description: "zoomed in 2x (smaller world movement)", + camera: Camera.create(0, 0, 2), + deltaScreen: { x: 100, y: 0 }, + expectedPosition: { x: -50, y: 0 }, + }, { + description: "zoomed out 0.5x (larger world movement)", + camera: Camera.create(0, 0, 0.5), + deltaScreen: { x: 100, y: 0 }, + expectedPosition: { x: -200, y: 0 }, + }, { + description: "zoomed in 4x", + camera: Camera.create(0, 0, 4), + deltaScreen: { x: 80, y: 40 }, + expectedPosition: { x: -20, y: -10 }, + }]; + + for (const { camera, deltaScreen, expectedPosition } of testCases) { + const newCamera = Camera.pan(camera, deltaScreen); + expect(newCamera.x).toBeCloseTo(expectedPosition.x); + expect(newCamera.y).toBeCloseTo(expectedPosition.y); + } + }); + + it("should verify pan moves world under cursor correctly", () => { + const camera = Camera.create(0, 0, 1); + const cursorScreen = { x: 300, y: 200 }; + const worldBefore = Camera.screenToWorld(camera, cursorScreen, viewport); + + const deltaScreen = { x: 50, y: 30 }; + const newCamera = Camera.pan(camera, deltaScreen); + + const worldAfter = Camera.screenToWorld(newCamera, cursorScreen, viewport); + + expect(worldAfter.x - worldBefore.x).toBeCloseTo(-50); + expect(worldAfter.y - worldBefore.y).toBeCloseTo(-30); + }); + }); + + describe("zoomAt", () => { + it.each([{ + description: "zoom in 2x at center", + camera: Camera.create(0, 0, 1), + factor: 2, + anchorScreen: { x: 400, y: 300 }, + expectedZoom: 2, + }, { + description: "zoom out 0.5x at center", + camera: Camera.create(0, 0, 1), + factor: 0.5, + anchorScreen: { x: 400, y: 300 }, + expectedZoom: 0.5, + }, { + description: "zoom in 1.5x at center", + camera: Camera.create(0, 0, 1), + factor: 1.5, + anchorScreen: { x: 400, y: 300 }, + expectedZoom: 1.5, + }, { + description: "compound zoom", + camera: Camera.create(0, 0, 2), + factor: 1.5, + anchorScreen: { x: 400, y: 300 }, + expectedZoom: 3, + }])("should update zoom for $description", ({ camera, factor, anchorScreen, expectedZoom }) => { + const newCamera = Camera.zoomAt(camera, factor, anchorScreen, viewport); + expect(newCamera.zoom).toBeCloseTo(expectedZoom); + }); + + it("should keep anchor point stable when zooming at center", () => { + const camera = Camera.create(0, 0, 1); + const anchorScreen = { x: 400, y: 300 }; + + const newCamera = Camera.zoomAt(camera, 2, anchorScreen, viewport); + + const anchorWorldBefore = Camera.screenToWorld(camera, anchorScreen, viewport); + const anchorWorldAfter = Camera.screenToWorld(newCamera, anchorScreen, viewport); + + expect(anchorWorldBefore.x).toBeCloseTo(anchorWorldAfter.x); + expect(anchorWorldBefore.y).toBeCloseTo(anchorWorldAfter.y); + + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorldBefore, viewport); + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y); + }); + + it("should keep anchor point stable when zooming at corner", () => { + const camera = Camera.create(0, 0, 1); + const anchorScreen = { x: 100, y: 150 }; + const anchorWorldBefore = Camera.screenToWorld(camera, anchorScreen, viewport); + const newCamera = Camera.zoomAt(camera, 2, anchorScreen, viewport); + + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorldBefore, viewport); + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y); + }); + + it("should keep anchor point stable when zooming at bottom-right", () => { + const camera = Camera.create(0, 0, 1); + const anchorScreen = { x: 700, y: 550 }; + + const anchorWorldBefore = Camera.screenToWorld(camera, anchorScreen, viewport); + const newCamera = Camera.zoomAt(camera, 0.5, anchorScreen, viewport); + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorldBefore, viewport); + + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); + }); + + it("should keep anchor stable with offset camera", () => { + const camera = Camera.create(100, 200, 1.5); + const anchorScreen = { x: 250, y: 400 }; + + const anchorWorldBefore = Camera.screenToWorld(camera, anchorScreen, viewport); + const newCamera = Camera.zoomAt(camera, 2, anchorScreen, viewport); + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorldBefore, viewport); + + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); + }); + + it("should handle multiple zoom operations correctly", () => { + let camera = Camera.create(0, 0, 1); + const anchorScreen = { x: 300, y: 250 }; + + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); + + camera = Camera.zoomAt(camera, 2, anchorScreen, viewport); + let anchorScreenAfter = Camera.worldToScreen(camera, anchorWorld, viewport); + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); + + camera = Camera.zoomAt(camera, 1.5, anchorScreen, viewport); + anchorScreenAfter = Camera.worldToScreen(camera, anchorWorld, viewport); + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); + + expect(camera.zoom).toBeCloseTo(3, 5); + }); + + it("should handle zoom out correctly", () => { + const camera = Camera.create(100, 100, 2); + const anchorScreen = { x: 500, y: 400 }; + + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); + const newCamera = Camera.zoomAt(camera, 0.5, anchorScreen, viewport); + const anchorScreenAfter = Camera.worldToScreen(newCamera, anchorWorld, viewport); + + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); + expect(newCamera.zoom).toBeCloseTo(1, 5); + }); + }); + + describe("clampZoom", () => { + it.each([ + { description: "within bounds", camera: Camera.create(0, 0, 1), minZoom: 0.1, maxZoom: 10, expectedZoom: 1 }, + { description: "below minimum", camera: Camera.create(0, 0, 0.05), minZoom: 0.1, maxZoom: 10, expectedZoom: 0.1 }, + { description: "above maximum", camera: Camera.create(0, 0, 15), minZoom: 0.1, maxZoom: 10, expectedZoom: 10 }, + { description: "at minimum", camera: Camera.create(0, 0, 0.1), minZoom: 0.1, maxZoom: 10, expectedZoom: 0.1 }, + { description: "at maximum", camera: Camera.create(0, 0, 10), minZoom: 0.1, maxZoom: 10, expectedZoom: 10 }, + ])("should clamp $description", ({ camera, minZoom, maxZoom, expectedZoom }) => { + const clamped = Camera.clampZoom(camera, minZoom, maxZoom); + expect(clamped.zoom).toBe(expectedZoom); + expect(clamped.x).toBe(camera.x); + expect(clamped.y).toBe(camera.y); + }); + + it("should return same camera if no clamping needed", () => { + const camera = Camera.create(10, 20, 1); + const clamped = Camera.clampZoom(camera, 0.1, 10); + expect(clamped).toBe(camera); + }); + + it("should use default min/max when not specified", () => { + expect(Camera.clampZoom(Camera.create(0, 0, 0.05)).zoom).toBe(0.1); + expect(Camera.clampZoom(Camera.create(0, 0, 15)).zoom).toBe(10); + }); + }); + + describe("getViewportBounds", () => { + it("should return correct bounds for camera at origin", () => { + const camera = Camera.create(0, 0, 1); + const bounds = Camera.getViewportBounds(camera, viewport); + + expect(bounds.min.x).toBeCloseTo(-400); + expect(bounds.min.y).toBeCloseTo(-300); + expect(bounds.max.x).toBeCloseTo(400); + expect(bounds.max.y).toBeCloseTo(300); + expect(bounds.width).toBeCloseTo(800); + expect(bounds.height).toBeCloseTo(600); + }); + + it("should return correct bounds for offset camera", () => { + const camera = Camera.create(100, 200, 1); + const bounds = Camera.getViewportBounds(camera, viewport); + + expect(bounds.min.x).toBeCloseTo(-300); + expect(bounds.min.y).toBeCloseTo(-100); + expect(bounds.max.x).toBeCloseTo(500); + expect(bounds.max.y).toBeCloseTo(500); + }); + + it("should return correct bounds for zoomed camera", () => { + const camera = Camera.create(0, 0, 2); + const bounds = Camera.getViewportBounds(camera, viewport); + + expect(bounds.width).toBeCloseTo(400); + expect(bounds.height).toBeCloseTo(300); + expect(bounds.min.x).toBeCloseTo(-200); + expect(bounds.max.x).toBeCloseTo(200); + }); + + it("should return correct bounds for zoomed out camera", () => { + const camera = Camera.create(0, 0, 0.5); + const bounds = Camera.getViewportBounds(camera, viewport); + expect(bounds.width).toBeCloseTo(1600); + expect(bounds.height).toBeCloseTo(1200); + }); + }); + + describe("edge cases", () => { + it("should handle very large world coordinates", () => { + const camera = Camera.create(1_000_000, 2_000_000, 1); + const worldPoint = { x: 999_999, y: 1_999_999 }; + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); + + expect(backToWorld.x).toBeCloseTo(worldPoint.x, 5); + expect(backToWorld.y).toBeCloseTo(worldPoint.y, 5); + }); + + it("should handle very small zoom levels", () => { + const camera = Camera.create(0, 0, 0.001); + const worldPoint = { x: 100_000, y: 50_000 }; + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); + + expect(backToWorld.x).toBeCloseTo(worldPoint.x, 0); + expect(backToWorld.y).toBeCloseTo(worldPoint.y, 0); + }); + + it("should handle very large zoom levels", () => { + const camera = Camera.create(0, 0, 100); + const worldPoint = { x: 1, y: 0.5 }; + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); + + expect(backToWorld.x).toBeCloseTo(worldPoint.x, 5); + expect(backToWorld.y).toBeCloseTo(worldPoint.y, 5); + }); + + it("should handle fractional pixel coordinates", () => { + const camera = Camera.create(0, 0, 1); + const screenPoint = { x: 123.456, y: 789.012 }; + const worldPoint = Camera.screenToWorld(camera, screenPoint, viewport); + const backToScreen = Camera.worldToScreen(camera, worldPoint, viewport); + + expect(backToScreen.x).toBeCloseTo(screenPoint.x); + expect(backToScreen.y).toBeCloseTo(screenPoint.y); + }); + + it("should handle negative zoom gracefully in transforms", () => { + const camera = Camera.create(0, 0, -1); + const worldPoint = { x: 100, y: 50 }; + const screenPoint = Camera.worldToScreen(camera, worldPoint, viewport); + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); + + expect(backToWorld.x).toBeCloseTo(worldPoint.x); + expect(backToWorld.y).toBeCloseTo(worldPoint.y); + }); + + it("should handle different viewport sizes", () => { + const smallViewport: Viewport = { width: 320, height: 240 }; + const largeViewport: Viewport = { width: 1920, height: 1080 }; + const camera = Camera.create(0, 0, 1); + const worldPoint = { x: 100, y: 50 }; + + const screenSmall = Camera.worldToScreen(camera, worldPoint, smallViewport); + const backSmall = Camera.screenToWorld(camera, screenSmall, smallViewport); + expect(backSmall.x).toBeCloseTo(worldPoint.x); + expect(backSmall.y).toBeCloseTo(worldPoint.y); + + const screenLarge = Camera.worldToScreen(camera, worldPoint, largeViewport); + const backLarge = Camera.screenToWorld(camera, screenLarge, largeViewport); + expect(backLarge.x).toBeCloseTo(worldPoint.x); + expect(backLarge.y).toBeCloseTo(worldPoint.y); + }); + + it("should handle zero-sized viewport dimensions gracefully", () => { + const zeroViewport: Viewport = { width: 0, height: 0 }; + const camera = Camera.create(0, 0, 1); + const worldPoint = { x: 100, y: 50 }; + const screenPoint = Camera.worldToScreen(camera, worldPoint, zeroViewport); + expect(screenPoint).toBeDefined(); + }); + }); + + describe("integration scenarios", () => { + it("should handle pan then zoom correctly", () => { + let camera = Camera.create(0, 0, 1); + const anchorScreen = { x: 300, y: 250 }; + + camera = Camera.pan(camera, { x: 100, y: 50 }); + + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); + + camera = Camera.zoomAt(camera, 2, anchorScreen, viewport); + + const anchorScreenAfter = Camera.worldToScreen(camera, anchorWorld, viewport); + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 5); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 5); + }); + + it("should handle zoom then pan correctly", () => { + let camera = Camera.create(0, 0, 1); + const testWorldPoint = { x: 100, y: 200 }; + + camera = Camera.zoomAt(camera, 2, { x: 400, y: 300 }, viewport); + + const screenBefore = Camera.worldToScreen(camera, testWorldPoint, viewport); + + camera = Camera.pan(camera, { x: 50, y: 30 }); + + const screenAfter = Camera.worldToScreen(camera, testWorldPoint, viewport); + expect(screenAfter.x - screenBefore.x).toBeCloseTo(50, 5); + expect(screenAfter.y - screenBefore.y).toBeCloseTo(30, 5); + }); + + it("should handle complex manipulation sequence", () => { + let camera = Camera.create(0, 0, 1); + const trackPoint = { x: 500, y: 300 }; + + camera = Camera.pan(camera, { x: 50, y: 25 }); + camera = Camera.zoomAt(camera, 1.5, { x: 400, y: 300 }, viewport); + camera = Camera.pan(camera, { x: -30, y: 40 }); + camera = Camera.zoomAt(camera, 0.8, { x: 600, y: 200 }, viewport); + + const screenPoint = Camera.worldToScreen(camera, trackPoint, viewport); + const backToWorld = Camera.screenToWorld(camera, screenPoint, viewport); + + expect(backToWorld.x).toBeCloseTo(trackPoint.x, 5); + expect(backToWorld.y).toBeCloseTo(trackPoint.y, 5); + }); + + it("should maintain consistency after many zoom operations", () => { + let camera = Camera.create(0, 0, 1); + const anchorScreen = { x: 400, y: 300 }; + const anchorWorld = Camera.screenToWorld(camera, anchorScreen, viewport); + + for (let i = 0; i < 10; i++) { + camera = Camera.zoomAt(camera, 1.1, anchorScreen, viewport); + } + + for (let i = 0; i < 10; i++) { + camera = Camera.zoomAt(camera, 1 / 1.1, anchorScreen, viewport); + } + + expect(camera.zoom).toBeCloseTo(1, 5); + + const anchorScreenAfter = Camera.worldToScreen(camera, anchorWorld, viewport); + expect(anchorScreenAfter.x).toBeCloseTo(anchorScreen.x, 3); + expect(anchorScreenAfter.y).toBeCloseTo(anchorScreen.y, 3); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff39b91..e3dcf14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,10 @@ importers: version: 8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) packages/core: + dependencies: + uuid: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@types/node': specifier: ^25.0.3 @@ -1357,6 +1361,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2637,6 +2645,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@13.0.0: {} + vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(yaml@2.8.2): dependencies: esbuild: 0.27.2 -- 2.51.2