@flighthq/camera-controls 0.2.1-next.444.a313478

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ import type { Camera3D, FlyCameraController, FlyCameraControllerOptions } from '@flighthq/types';
2
+ export declare function createFlyCameraController(options?: Readonly<FlyCameraControllerOptions>): FlyCameraController;
3
+ export declare function lookFlyCameraController(controller: FlyCameraController, deltaYaw: number, deltaPitch: number): void;
4
+ export declare function moveFlyCameraController(controller: FlyCameraController, forward: number, right: number, up: number): void;
5
+ export declare function updateFlyCameraController(controller: FlyCameraController, camera: Camera3D, deltaTime: number): void;
6
+ //# sourceMappingURL=flyCameraController.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flyCameraController.d.ts","sourceRoot":"","sources":["../src/flyCameraController.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAKjG,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,0BAA0B,CAAC,GAAG,mBAAmB,CAc7G;AAKD,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,mBAAmB,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAGnH;AAMD,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,mBAAmB,EAC/B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,GACT,IAAI,CAON;AAMD,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,mBAAmB,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAoBpH"}
@@ -0,0 +1,70 @@
1
+ import { setCamera3DViewMatrix4FromLookAt } from '@flighthq/camera';
2
+ import { createVector3 } from '@flighthq/geometry';
3
+ import { clamp, damp } from '@flighthq/math';
4
+ // Allocates a fly / first-person controller. `position` defaults to the origin, `yaw`/`pitch` to 0
5
+ // (looking down -Z), `pitch` limits to just inside ±90° (no gimbal flip), and `smoothTime` to 0 (the
6
+ // look angles snap to their goal each update). Current and goal angles start equal.
7
+ export function createFlyCameraController(options) {
8
+ const yaw = options?.yaw ?? 0;
9
+ const pitch = options?.pitch ?? 0;
10
+ const position = options?.position;
11
+ return {
12
+ goalPitch: pitch,
13
+ goalYaw: yaw,
14
+ maxPitch: options?.maxPitch ?? DEFAULT_MAX_PITCH,
15
+ minPitch: options?.minPitch ?? DEFAULT_MIN_PITCH,
16
+ pitch,
17
+ position: createVector3(position?.x ?? 0, position?.y ?? 0, position?.z ?? 0),
18
+ smoothTime: options?.smoothTime ?? 0,
19
+ yaw,
20
+ };
21
+ }
22
+ // Moves the goal look angles by the given radian deltas: `deltaYaw` turns horizontally (unbounded),
23
+ // `deltaPitch` looks up/down (clamped to [minPitch, maxPitch]). The app maps a mouse-look delta to
24
+ // these; `updateFlyCameraController` eases the current angles toward the goal.
25
+ export function lookFlyCameraController(controller, deltaYaw, deltaPitch) {
26
+ controller.goalYaw += deltaYaw;
27
+ controller.goalPitch = clamp(controller.goalPitch + deltaPitch, controller.minPitch, controller.maxPitch);
28
+ }
29
+ // Translates `position` along the current heading (immediate, not eased): `forward`/`right` in the
30
+ // horizontal plane at the current yaw (so movement stays level regardless of pitch), `up` along
31
+ // world-up. The app maps WASD/thrust to these. Reads position into locals before writing so aliasing
32
+ // is safe.
33
+ export function moveFlyCameraController(controller, forward, right, up) {
34
+ const sinYaw = Math.sin(controller.yaw);
35
+ const cosYaw = Math.cos(controller.yaw);
36
+ const position = controller.position;
37
+ position.x += sinYaw * forward + cosYaw * right;
38
+ position.y += up;
39
+ position.z += -cosYaw * forward + sinYaw * right;
40
+ }
41
+ // Advances the controller one step and writes the resulting view into `camera` (in place). Eases the
42
+ // current yaw/pitch toward their clamped goals with `@flighthq/math` `damp` (frame-rate independent;
43
+ // `smoothTime` <= 0 or `deltaTime` <= 0 snaps), builds the forward direction from the angles, and
44
+ // calls look-at from `position` toward `position + forward` with a fixed world-up.
45
+ export function updateFlyCameraController(controller, camera, deltaTime) {
46
+ const goalPitch = clamp(controller.goalPitch, controller.minPitch, controller.maxPitch);
47
+ if (controller.smoothTime > 0 && deltaTime > 0) {
48
+ const lambda = 1 / controller.smoothTime;
49
+ controller.yaw = damp(controller.yaw, controller.goalYaw, lambda, deltaTime);
50
+ controller.pitch = damp(controller.pitch, goalPitch, lambda, deltaTime);
51
+ }
52
+ else {
53
+ controller.yaw = controller.goalYaw;
54
+ controller.pitch = goalPitch;
55
+ }
56
+ const cosPitch = Math.cos(controller.pitch);
57
+ const sinPitch = Math.sin(controller.pitch);
58
+ const cosYaw = Math.cos(controller.yaw);
59
+ const sinYaw = Math.sin(controller.yaw);
60
+ const position = controller.position;
61
+ scratchTarget.x = position.x + sinYaw * cosPitch;
62
+ scratchTarget.y = position.y + sinPitch;
63
+ scratchTarget.z = position.z - cosYaw * cosPitch;
64
+ setCamera3DViewMatrix4FromLookAt(camera, position, scratchTarget, WORLD_UP);
65
+ }
66
+ const DEFAULT_MAX_PITCH = Math.PI / 2 - 0.01;
67
+ const DEFAULT_MIN_PITCH = -Math.PI / 2 + 0.01;
68
+ const WORLD_UP = createVector3(0, 1, 0);
69
+ const scratchTarget = createVector3();
70
+ //# sourceMappingURL=flyCameraController.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flyCameraController.js","sourceRoot":"","sources":["../src/flyCameraController.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ import type { Camera2D, Camera2DFollowOptions } from '@flighthq/types';
2
+ export declare function updateCamera2DFollow(camera: Camera2D, targetX: number, targetY: number, deltaTime: number, options?: Readonly<Camera2DFollowOptions>): void;
3
+ //# sourceMappingURL=follow.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../src/follow.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAevE,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,QAAQ,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,QAAQ,CAAC,qBAAqB,CAAC,GACxC,IAAI,CAkDN"}
package/dist/follow.js ADDED
@@ -0,0 +1,71 @@
1
+ import { getCamera2DVisibleBounds } from '@flighthq/camera';
2
+ import { createRectangle } from '@flighthq/geometry';
3
+ import { clamp, damp } from '@flighthq/math';
4
+ // Moves the camera toward a follow target for one step, mutating `camera` in place.
5
+ //
6
+ // Three composed stages: (1) a deadzone box (half-extents around the camera center) yields a goal
7
+ // position — inside the box the goal is the current position (no motion); once the target crosses an
8
+ // edge the goal moves the minimum needed to keep the target on that edge. (2) The camera is smoothed
9
+ // toward the goal with `@flighthq/math`'s `damp` using `smoothTime` as the time constant, so motion
10
+ // is frame-rate independent; `smoothTime` <= 0 (or `deltaTime` <= 0) snaps to the goal. (3) If
11
+ // `worldBounds` is given, the camera is clamped so the visible world rectangle stays inside the
12
+ // level, centering on any axis where the level is smaller than the view.
13
+ //
14
+ // Camera3D inputs are read into locals before any write, so passing the same camera as target source is
15
+ // safe. The deadzone box is axis-aligned in world space (it aligns with the view only when
16
+ // `rotation` is 0).
17
+ export function updateCamera2DFollow(camera, targetX, targetY, deltaTime, options) {
18
+ const camX = camera.x;
19
+ const camY = camera.y;
20
+ const deadHalfW = options?.deadzoneHalfWidth ?? 0;
21
+ const deadHalfH = options?.deadzoneHalfHeight ?? 0;
22
+ const smoothTime = options?.smoothTime ?? 0;
23
+ const worldBounds = options?.worldBounds;
24
+ const dx = targetX - camX;
25
+ let goalX = camX;
26
+ if (dx > deadHalfW)
27
+ goalX = targetX - deadHalfW;
28
+ else if (dx < -deadHalfW)
29
+ goalX = targetX + deadHalfW;
30
+ const dy = targetY - camY;
31
+ let goalY = camY;
32
+ if (dy > deadHalfH)
33
+ goalY = targetY - deadHalfH;
34
+ else if (dy < -deadHalfH)
35
+ goalY = targetY + deadHalfH;
36
+ let nextX;
37
+ let nextY;
38
+ if (smoothTime > 0 && deltaTime > 0) {
39
+ const lambda = 1 / smoothTime;
40
+ nextX = damp(camX, goalX, lambda, deltaTime);
41
+ nextY = damp(camY, goalY, lambda, deltaTime);
42
+ }
43
+ else {
44
+ nextX = goalX;
45
+ nextY = goalY;
46
+ }
47
+ if (worldBounds) {
48
+ // The visible-bounds size is independent of camera position, so it is safe to read before the
49
+ // camera moves; the rectangle is centered on the camera, so clamping the center by its half-
50
+ // extents keeps the visible rect inside the level.
51
+ getCamera2DVisibleBounds(camera, scratchBounds);
52
+ const halfVisW = scratchBounds.width * 0.5;
53
+ const halfVisH = scratchBounds.height * 0.5;
54
+ if (worldBounds.width <= scratchBounds.width) {
55
+ nextX = worldBounds.x + worldBounds.width * 0.5;
56
+ }
57
+ else {
58
+ nextX = clamp(nextX, worldBounds.x + halfVisW, worldBounds.x + worldBounds.width - halfVisW);
59
+ }
60
+ if (worldBounds.height <= scratchBounds.height) {
61
+ nextY = worldBounds.y + worldBounds.height * 0.5;
62
+ }
63
+ else {
64
+ nextY = clamp(nextY, worldBounds.y + halfVisH, worldBounds.y + worldBounds.height - halfVisH);
65
+ }
66
+ }
67
+ camera.x = nextX;
68
+ camera.y = nextY;
69
+ }
70
+ const scratchBounds = createRectangle();
71
+ //# sourceMappingURL=follow.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"follow.js","sourceRoot":"","sources":["../src/follow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export * from './flyCameraController';
2
+ export * from './follow';
3
+ export * from './orbitCameraController';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,UAAU,CAAC;AACzB,cAAc,yBAAyB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './flyCameraController';
2
+ export * from './follow';
3
+ export * from './orbitCameraController';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,UAAU,CAAC;AACzB,cAAc,yBAAyB,CAAC"}
@@ -0,0 +1,7 @@
1
+ import type { Camera3D, OrbitCameraController, OrbitCameraControllerOptions } from '@flighthq/types';
2
+ export declare function createOrbitCameraController(options?: Readonly<OrbitCameraControllerOptions>): OrbitCameraController;
3
+ export declare function dollyCameraController(controller: OrbitCameraController, deltaDistance: number): void;
4
+ export declare function orbitCameraController(controller: OrbitCameraController, deltaAzimuth: number, deltaPolar: number): void;
5
+ export declare function panCameraController(controller: OrbitCameraController, deltaRight: number, deltaUp: number): void;
6
+ export declare function updateOrbitCameraController(controller: OrbitCameraController, camera: Camera3D, deltaTime: number): void;
7
+ //# sourceMappingURL=orbitCameraController.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orbitCameraController.d.ts","sourceRoot":"","sources":["../src/orbitCameraController.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,qBAAqB,EAAE,4BAA4B,EAAE,MAAM,iBAAiB,CAAC;AAMrG,wBAAgB,2BAA2B,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,4BAA4B,CAAC,GAAG,qBAAqB,CAmBnH;AAID,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,GAAG,IAAI,CAMpG;AAKD,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,qBAAqB,EACjC,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,MAAM,GACjB,IAAI,CAGN;AAMD,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAOhH;AAOD,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,qBAAqB,EACjC,MAAM,EAAE,QAAQ,EAChB,SAAS,EAAE,MAAM,GAChB,IAAI,CAuBN"}
@@ -0,0 +1,86 @@
1
+ import { setCamera3DViewMatrix4FromLookAt } from '@flighthq/camera';
2
+ import { createVector3 } from '@flighthq/geometry';
3
+ import { clamp, damp } from '@flighthq/math';
4
+ // Allocates an orbit controller. `target` defaults to the origin, `distance` to 10, angles to 0;
5
+ // `polar` limits default to just inside ±90° so the look-at up vector never degenerates, `distance`
6
+ // to (0.01, +∞), and `smoothTime` to 0 (no damping — the camera snaps to the goal each update). The
7
+ // current and goal coordinates start equal, so a controller with no verb calls holds a fixed view.
8
+ export function createOrbitCameraController(options) {
9
+ const azimuth = options?.azimuth ?? 0;
10
+ const polar = options?.polar ?? 0;
11
+ const distance = options?.distance ?? 10;
12
+ const target = options?.target;
13
+ return {
14
+ azimuth,
15
+ distance,
16
+ goalAzimuth: azimuth,
17
+ goalDistance: distance,
18
+ goalPolar: polar,
19
+ maxDistance: options?.maxDistance ?? Number.POSITIVE_INFINITY,
20
+ maxPolar: options?.maxPolar ?? DEFAULT_MAX_POLAR,
21
+ minDistance: options?.minDistance ?? DEFAULT_MIN_DISTANCE,
22
+ minPolar: options?.minPolar ?? DEFAULT_MIN_POLAR,
23
+ polar,
24
+ smoothTime: options?.smoothTime ?? 0,
25
+ target: createVector3(target?.x ?? 0, target?.y ?? 0, target?.z ?? 0),
26
+ };
27
+ }
28
+ // Moves the goal distance (dolly / zoom) by `deltaDistance`, clamped to [minDistance, maxDistance].
29
+ // Negative moves the eye toward the target.
30
+ export function dollyCameraController(controller, deltaDistance) {
31
+ controller.goalDistance = clamp(controller.goalDistance + deltaDistance, controller.minDistance, controller.maxDistance);
32
+ }
33
+ // Moves the goal orbit angles by the given radian deltas: `deltaAzimuth` rotates horizontally
34
+ // (unbounded), `deltaPolar` vertically (clamped to [minPolar, maxPolar]). The app maps a pointer drag
35
+ // to these; `updateOrbitCameraController` eases the current angles toward the goal.
36
+ export function orbitCameraController(controller, deltaAzimuth, deltaPolar) {
37
+ controller.goalAzimuth += deltaAzimuth;
38
+ controller.goalPolar = clamp(controller.goalPolar + deltaPolar, controller.minPolar, controller.maxPolar);
39
+ }
40
+ // Slides the orbit `target` in the view plane: `deltaRight` along the camera's horizontal right axis
41
+ // (at the current goal azimuth), `deltaUp` along world-up. Because the eye is derived from the target
42
+ // each update, panning the target pans the whole view. Reads the target into locals before writing so
43
+ // aliasing is safe.
44
+ export function panCameraController(controller, deltaRight, deltaUp) {
45
+ const cosAzimuth = Math.cos(controller.goalAzimuth);
46
+ const sinAzimuth = Math.sin(controller.goalAzimuth);
47
+ const target = controller.target;
48
+ target.x += cosAzimuth * deltaRight;
49
+ target.y += deltaUp;
50
+ target.z += -sinAzimuth * deltaRight;
51
+ }
52
+ // Advances the controller one step and writes the resulting view into `camera` (in place). Eases the
53
+ // current azimuth/polar/distance toward their clamped goals with `@flighthq/math` `damp`
54
+ // (frame-rate independent; `smoothTime` <= 0 or `deltaTime` <= 0 snaps), places the eye on the sphere
55
+ // around `target`, and calls look-at with a fixed world-up. The polar clamp keeps the eye off the
56
+ // poles so the up vector stays valid.
57
+ export function updateOrbitCameraController(controller, camera, deltaTime) {
58
+ const goalPolar = clamp(controller.goalPolar, controller.minPolar, controller.maxPolar);
59
+ const goalDistance = clamp(controller.goalDistance, controller.minDistance, controller.maxDistance);
60
+ if (controller.smoothTime > 0 && deltaTime > 0) {
61
+ const lambda = 1 / controller.smoothTime;
62
+ controller.azimuth = damp(controller.azimuth, controller.goalAzimuth, lambda, deltaTime);
63
+ controller.polar = damp(controller.polar, goalPolar, lambda, deltaTime);
64
+ controller.distance = damp(controller.distance, goalDistance, lambda, deltaTime);
65
+ }
66
+ else {
67
+ controller.azimuth = controller.goalAzimuth;
68
+ controller.polar = goalPolar;
69
+ controller.distance = goalDistance;
70
+ }
71
+ const cosPolar = Math.cos(controller.polar);
72
+ const sinPolar = Math.sin(controller.polar);
73
+ const cosAzimuth = Math.cos(controller.azimuth);
74
+ const sinAzimuth = Math.sin(controller.azimuth);
75
+ const target = controller.target;
76
+ scratchEye.x = target.x + controller.distance * sinAzimuth * cosPolar;
77
+ scratchEye.y = target.y + controller.distance * sinPolar;
78
+ scratchEye.z = target.z + controller.distance * cosAzimuth * cosPolar;
79
+ setCamera3DViewMatrix4FromLookAt(camera, scratchEye, target, WORLD_UP);
80
+ }
81
+ const DEFAULT_MAX_POLAR = Math.PI / 2 - 0.01;
82
+ const DEFAULT_MIN_DISTANCE = 0.01;
83
+ const DEFAULT_MIN_POLAR = -Math.PI / 2 + 0.01;
84
+ const WORLD_UP = createVector3(0, 1, 0);
85
+ const scratchEye = createVector3();
86
+ //# sourceMappingURL=orbitCameraController.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"orbitCameraController.js","sourceRoot":"","sources":["../src/orbitCameraController.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@flighthq/camera-controls",
3
+ "version": "0.2.1-next.444.a313478",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/flighthq/flight.git",
7
+ "directory": "packages/camera-controls"
8
+ },
9
+ "type": "module",
10
+ "main": "dist/index.js",
11
+ "types": "dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src/**/*.test.ts",
21
+ "!dist/**/*.test.js",
22
+ "!dist/**/*.test.d.ts",
23
+ "!dist/**/*.test.js.map",
24
+ "!dist/**/*.test.d.ts.map"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -b",
28
+ "clean": "tsc -b --clean",
29
+ "test": "vitest run --config vitest.config.ts",
30
+ "test:watch": "vitest --watch --config vitest.config.ts",
31
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
32
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
33
+ },
34
+ "dependencies": {
35
+ "@flighthq/camera": "0.2.1-next.444.a313478",
36
+ "@flighthq/geometry": "0.2.1-next.444.a313478",
37
+ "@flighthq/math": "0.2.1-next.444.a313478",
38
+ "@flighthq/types": "0.2.1-next.444.a313478"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^5.3.0"
42
+ },
43
+ "description": "Subjective camera controllers: 2D follow and 3D orbit / fly over the pure camera",
44
+ "sideEffects": false
45
+ }
@@ -0,0 +1,71 @@
1
+ import { createCamera3D, createPerspectiveProjection, setCamera3DViewMatrix4FromLookAt } from '@flighthq/camera';
2
+ import { createVector3 } from '@flighthq/geometry';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ createFlyCameraController,
7
+ lookFlyCameraController,
8
+ moveFlyCameraController,
9
+ updateFlyCameraController,
10
+ } from './flyCameraController';
11
+
12
+ function testCamera() {
13
+ return createCamera3D({ far: 100, near: 0.1, projection: createPerspectiveProjection({ aspect: 1, fovY: 1 }) });
14
+ }
15
+
16
+ describe('createFlyCameraController', () => {
17
+ it('applies documented defaults with current equal to goal', () => {
18
+ const c = createFlyCameraController();
19
+ expect(c.yaw).toBe(0);
20
+ expect(c.pitch).toBe(0);
21
+ expect(c.goalYaw).toBe(c.yaw);
22
+ expect(c.maxPitch).toBeLessThan(Math.PI / 2);
23
+ expect(c.minPitch).toBeGreaterThan(-Math.PI / 2);
24
+ });
25
+
26
+ it('seeds from options', () => {
27
+ const c = createFlyCameraController({ yaw: 0.5, pitch: 0.25, position: createVector3(1, 2, 3) });
28
+ expect(c.yaw).toBe(0.5);
29
+ expect(c.position.z).toBe(3);
30
+ });
31
+ });
32
+
33
+ describe('lookFlyCameraController', () => {
34
+ it('adds to the goal angles and clamps pitch', () => {
35
+ const c = createFlyCameraController({ minPitch: -1, maxPitch: 1 });
36
+ lookFlyCameraController(c, 0.5, 2);
37
+ expect(c.goalYaw).toBe(0.5);
38
+ expect(c.goalPitch).toBe(1); // clamped
39
+ });
40
+ });
41
+
42
+ describe('moveFlyCameraController', () => {
43
+ it('translates along the horizontal heading at yaw 0', () => {
44
+ const c = createFlyCameraController(); // yaw 0 => forward (0,0,-1), right (1,0,0)
45
+ moveFlyCameraController(c, 2, 3, 1);
46
+ expect(c.position.x).toBeCloseTo(3); // right
47
+ expect(c.position.y).toBeCloseTo(1); // up
48
+ expect(c.position.z).toBeCloseTo(-2); // forward is -Z
49
+ });
50
+ });
51
+
52
+ describe('updateFlyCameraController', () => {
53
+ it('snaps to the goal when smoothTime is 0 and writes the look-at view', () => {
54
+ const c = createFlyCameraController({ position: createVector3(0, 0, 5) }); // yaw/pitch 0 => looks toward -Z
55
+ const camera = testCamera();
56
+ updateFlyCameraController(c, camera, 0.016);
57
+ expect(c.yaw).toBe(c.goalYaw);
58
+
59
+ const expected = testCamera();
60
+ setCamera3DViewMatrix4FromLookAt(expected, createVector3(0, 0, 5), createVector3(0, 0, 4), createVector3(0, 1, 0));
61
+ for (let i = 0; i < 16; i++) expect(camera.view.m[i]).toBeCloseTo(expected.view.m[i]);
62
+ });
63
+
64
+ it('eases toward the goal when smoothTime is positive', () => {
65
+ const c = createFlyCameraController({ smoothTime: 0.5 });
66
+ lookFlyCameraController(c, 1, 0);
67
+ updateFlyCameraController(c, testCamera(), 0.016);
68
+ expect(c.yaw).toBeGreaterThan(0);
69
+ expect(c.yaw).toBeLessThan(1);
70
+ });
71
+ });
@@ -0,0 +1,55 @@
1
+ import { createCamera2D } from '@flighthq/camera';
2
+ import { getCamera2DVisibleBounds } from '@flighthq/camera';
3
+ import { createRectangle } from '@flighthq/geometry';
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { updateCamera2DFollow } from './follow';
7
+
8
+ describe('updateCamera2DFollow', () => {
9
+ it('does not move when the target is inside the deadzone', () => {
10
+ const camera = createCamera2D(800, 600);
11
+ updateCamera2DFollow(camera, 50, 30, 0.016, { deadzoneHalfWidth: 100, deadzoneHalfHeight: 100 });
12
+ expect(camera.x).toBe(0);
13
+ expect(camera.y).toBe(0);
14
+ });
15
+
16
+ it('moves partway toward the target with smoothing over one step', () => {
17
+ const camera = createCamera2D(800, 600);
18
+ updateCamera2DFollow(camera, 100, 0, 0.016, { smoothTime: 0.1 });
19
+ expect(camera.x).toBeGreaterThan(0);
20
+ expect(camera.x).toBeLessThan(100);
21
+ expect(camera.y).toBeCloseTo(0, 9);
22
+ });
23
+
24
+ it('snaps the target onto the deadzone edge when smoothTime is 0', () => {
25
+ const camera = createCamera2D(800, 600);
26
+ updateCamera2DFollow(camera, 300, 0, 0.016, { deadzoneHalfWidth: 100, smoothTime: 0 });
27
+ // Goal moves the minimum so the target sits exactly on the deadzone edge (300 - 100).
28
+ expect(camera.x).toBe(200);
29
+ expect(camera.y).toBe(0);
30
+ });
31
+
32
+ it('clamps the camera so the visible bounds stay inside world bounds', () => {
33
+ const camera = createCamera2D(800, 600);
34
+ const worldBounds = createRectangle(0, 0, 2000, 2000);
35
+ updateCamera2DFollow(camera, 1900, 1000, 0.016, { smoothTime: 0, worldBounds });
36
+ // Half view is 400x300, so the center clamps to [400, 1600] x [300, 1700].
37
+ expect(camera.x).toBe(1600);
38
+ expect(camera.y).toBe(1000);
39
+ const visible = createRectangle();
40
+ getCamera2DVisibleBounds(camera, visible);
41
+ expect(visible.x).toBeGreaterThanOrEqual(worldBounds.x - 1e-9);
42
+ expect(visible.y).toBeGreaterThanOrEqual(worldBounds.y - 1e-9);
43
+ expect(visible.x + visible.width).toBeLessThanOrEqual(worldBounds.x + worldBounds.width + 1e-9);
44
+ expect(visible.y + visible.height).toBeLessThanOrEqual(worldBounds.y + worldBounds.height + 1e-9);
45
+ });
46
+
47
+ it('centers the camera on an axis where the world is smaller than the view', () => {
48
+ const camera = createCamera2D(800, 600);
49
+ // World is only 400 wide (< 800 view), so x centers on the world midpoint.
50
+ const worldBounds = createRectangle(0, 0, 400, 2000);
51
+ updateCamera2DFollow(camera, 1900, 1000, 0.016, { smoothTime: 0, worldBounds });
52
+ expect(camera.x).toBe(200);
53
+ expect(camera.y).toBe(1000);
54
+ });
55
+ });
@@ -0,0 +1,87 @@
1
+ import { createCamera3D, createPerspectiveProjection, setCamera3DViewMatrix4FromLookAt } from '@flighthq/camera';
2
+ import { createMatrix4, createVector3 } from '@flighthq/geometry';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ createOrbitCameraController,
7
+ dollyCameraController,
8
+ orbitCameraController,
9
+ panCameraController,
10
+ updateOrbitCameraController,
11
+ } from './orbitCameraController';
12
+
13
+ function testCamera() {
14
+ return createCamera3D({ far: 100, near: 0.1, projection: createPerspectiveProjection({ aspect: 1, fovY: 1 }) });
15
+ }
16
+
17
+ describe('createOrbitCameraController', () => {
18
+ it('applies documented defaults with current equal to goal', () => {
19
+ const c = createOrbitCameraController();
20
+ expect(c.distance).toBe(10);
21
+ expect(c.azimuth).toBe(0);
22
+ expect(c.goalDistance).toBe(c.distance);
23
+ expect(c.goalAzimuth).toBe(c.azimuth);
24
+ expect(c.maxPolar).toBeLessThan(Math.PI / 2);
25
+ expect(c.minPolar).toBeGreaterThan(-Math.PI / 2);
26
+ });
27
+
28
+ it('seeds from options', () => {
29
+ const c = createOrbitCameraController({ azimuth: 1, polar: 0.5, distance: 4, target: createVector3(1, 2, 3) });
30
+ expect(c.distance).toBe(4);
31
+ expect(c.polar).toBe(0.5);
32
+ expect(c.target.x).toBe(1);
33
+ expect(c.target.z).toBe(3);
34
+ });
35
+ });
36
+
37
+ describe('dollyCameraController', () => {
38
+ it('moves the goal distance, clamped to the range', () => {
39
+ const c = createOrbitCameraController({ distance: 5, minDistance: 2, maxDistance: 8 });
40
+ dollyCameraController(c, 2);
41
+ expect(c.goalDistance).toBe(7);
42
+ dollyCameraController(c, 100);
43
+ expect(c.goalDistance).toBe(8);
44
+ dollyCameraController(c, -100);
45
+ expect(c.goalDistance).toBe(2);
46
+ });
47
+ });
48
+
49
+ describe('orbitCameraController', () => {
50
+ it('adds to the goal angles and clamps polar', () => {
51
+ const c = createOrbitCameraController({ minPolar: -1, maxPolar: 1 });
52
+ orbitCameraController(c, 0.5, 2);
53
+ expect(c.goalAzimuth).toBe(0.5);
54
+ expect(c.goalPolar).toBe(1); // clamped
55
+ });
56
+ });
57
+
58
+ describe('panCameraController', () => {
59
+ it('slides the target along right (at azimuth 0) and world up', () => {
60
+ const c = createOrbitCameraController();
61
+ panCameraController(c, 3, 2);
62
+ expect(c.target.x).toBeCloseTo(3); // right = (cos0, 0, -sin0) = (1,0,0)
63
+ expect(c.target.y).toBeCloseTo(2);
64
+ expect(c.target.z).toBeCloseTo(0);
65
+ });
66
+ });
67
+
68
+ describe('updateOrbitCameraController', () => {
69
+ it('snaps to the goal when smoothTime is 0 and writes the look-at view', () => {
70
+ const c = createOrbitCameraController({ distance: 10 }); // azimuth 0, polar 0 => eye (0,0,10)
71
+ const camera = testCamera();
72
+ updateOrbitCameraController(c, camera, 0.016);
73
+ expect(c.azimuth).toBe(c.goalAzimuth);
74
+
75
+ const expected = testCamera();
76
+ setCamera3DViewMatrix4FromLookAt(expected, createVector3(0, 0, 10), createVector3(0, 0, 0), createVector3(0, 1, 0));
77
+ for (let i = 0; i < 16; i++) expect(camera.view.m[i]).toBeCloseTo(expected.view.m[i]);
78
+ });
79
+
80
+ it('eases toward the goal when smoothTime is positive', () => {
81
+ const c = createOrbitCameraController({ smoothTime: 0.5 });
82
+ orbitCameraController(c, 1, 0);
83
+ updateOrbitCameraController(c, testCamera(), 0.016);
84
+ expect(c.azimuth).toBeGreaterThan(0);
85
+ expect(c.azimuth).toBeLessThan(1); // has not reached the goal in one small step
86
+ });
87
+ });