diff --git a/tests/unit/camera-rig-inspector.integration.test.tsx b/tests/unit/camera-rig-inspector.integration.test.tsx new file mode 100644 index 00000000..b8b73d3e --- /dev/null +++ b/tests/unit/camera-rig-inspector.integration.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { MockViewportHost, viewportHostInstances } = vi.hoisted(() => { + const viewportHostInstances: Array<{ + mount: ReturnType; + dispose: ReturnType; + updateWorld: ReturnType; + updateSimulation: ReturnType; + updateAssets: ReturnType; + updateDocument: ReturnType; + setPanelId: ReturnType; + setRenderEnabled: ReturnType; + setViewMode: ReturnType; + setDisplayMode: ReturnType; + setGridVisible: ReturnType; + setCameraState: ReturnType; + setBrushSelectionChangeHandler: ReturnType; + setCameraStateChangeHandler: ReturnType; + setCreationPreviewChangeHandler: ReturnType; + setCreationCommitHandler: ReturnType; + setTransformSessionChangeHandler: ReturnType; + setTransformCommitHandler: ReturnType; + setTransformCancelHandler: ReturnType; + setWhiteboxHoverLabelChangeHandler: ReturnType; + setWhiteboxSelectionMode: ReturnType; + setWhiteboxSnapSettings: ReturnType; + setToolMode: ReturnType; + setCreationPreview: ReturnType; + setTransformSession: ReturnType; + focusSelection: ReturnType; + }> = []; + + class MockViewportHost { + mount = vi.fn(); + dispose = vi.fn(); + updateWorld = vi.fn(); + updateSimulation = vi.fn(); + updateAssets = vi.fn(); + updateDocument = vi.fn(); + setPanelId = vi.fn(); + setRenderEnabled = vi.fn(); + setViewMode = vi.fn(); + setDisplayMode = vi.fn(); + setGridVisible = vi.fn(); + setCameraState = vi.fn(); + setBrushSelectionChangeHandler = vi.fn(); + setCameraStateChangeHandler = vi.fn(); + setCreationPreviewChangeHandler = vi.fn(); + setCreationCommitHandler = vi.fn(); + setTransformSessionChangeHandler = vi.fn(); + setTransformCommitHandler = vi.fn(); + setTransformCancelHandler = vi.fn(); + setWhiteboxHoverLabelChangeHandler = vi.fn(); + setWhiteboxSelectionMode = vi.fn(); + setWhiteboxSnapSettings = vi.fn(); + setToolMode = vi.fn(); + setCreationPreview = vi.fn(); + setTransformSession = vi.fn(); + focusSelection = vi.fn(); + + constructor() { + viewportHostInstances.push(this); + } + } + + return { + MockViewportHost, + viewportHostInstances + }; +}); + +vi.mock("../../src/viewport-three/viewport-host", () => ({ + ViewportHost: MockViewportHost +})); + +vi.mock("../../src/assets/project-asset-storage", () => ({ + getBrowserProjectAssetStorageAccess: vi.fn(async () => ({ + storage: null, + diagnostic: null + })) +})); + +import { App } from "../../src/app/App"; +import { createEditorStore } from "../../src/app/editor-store"; + +describe("Camera Rig inspector", () => { + beforeEach(() => { + viewportHostInstances.length = 0; + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation( + () => ({}) as never + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates a camera rig from the Add menu and persists fixed-rig inspector edits", async () => { + const store = createEditorStore(); + + render(); + + await waitFor(() => { + expect(viewportHostInstances.length).toBeGreaterThan(0); + }); + + fireEvent.click(screen.getByRole("button", { name: "Add" })); + fireEvent.click(await screen.findByTestId("add-menu-camera-rig")); + + await waitFor(() => { + expect( + Object.values(store.getState().document.entities).filter( + (entity) => entity.kind === "cameraRig" + ) + ).toHaveLength(1); + }); + + const createdCameraRig = Object.values(store.getState().document.entities).find( + (entity) => entity.kind === "cameraRig" + ); + + if (createdCameraRig === undefined || createdCameraRig.kind !== "cameraRig") { + throw new Error("Expected the created camera rig to exist."); + } + + await waitFor(() => { + expect(store.getState().selection).toEqual({ + kind: "entities", + ids: [createdCameraRig.id] + }); + }); + + expect( + screen.getByTestId(`outliner-entity-${createdCameraRig.id}`) + ).toBeInTheDocument(); + expect(screen.getByTestId("camera-rig-target-kind")).toHaveValue("player"); + + fireEvent.change(screen.getByTestId("camera-rig-priority"), { + target: { value: "9" } + }); + fireEvent.blur(screen.getByTestId("camera-rig-priority")); + fireEvent.click(screen.getByTestId("camera-rig-default-active")); + fireEvent.change(screen.getByTestId("camera-rig-target-kind"), { + target: { value: "worldPoint" } + }); + fireEvent.change(screen.getByTestId("camera-rig-target-world-x"), { + target: { value: "3.5" } + }); + fireEvent.blur(screen.getByTestId("camera-rig-target-world-x")); + fireEvent.change(screen.getByTestId("camera-rig-target-world-y"), { + target: { value: "1.75" } + }); + fireEvent.blur(screen.getByTestId("camera-rig-target-world-y")); + fireEvent.change(screen.getByTestId("camera-rig-target-world-z"), { + target: { value: "-6" } + }); + fireEvent.blur(screen.getByTestId("camera-rig-target-world-z")); + fireEvent.change(screen.getByTestId("camera-rig-transition-mode"), { + target: { value: "blend" } + }); + fireEvent.change(screen.getByTestId("camera-rig-transition-duration"), { + target: { value: "1.2" } + }); + fireEvent.blur(screen.getByTestId("camera-rig-transition-duration")); + fireEvent.change(screen.getByTestId("camera-rig-look-around-yaw-limit"), { + target: { value: "18" } + }); + fireEvent.blur(screen.getByTestId("camera-rig-look-around-yaw-limit")); + + await waitFor(() => { + expect(store.getState().document.entities[createdCameraRig.id]).toMatchObject({ + kind: "cameraRig", + priority: 9, + defaultActive: false, + target: { + kind: "worldPoint", + point: { + x: 3.5, + y: 1.75, + z: -6 + } + }, + transitionMode: "blend", + transitionDurationSeconds: 1.2, + lookAround: { + yawLimitDegrees: 18 + } + }); + }); + }); +}); diff --git a/tests/unit/runtime-host.test.ts b/tests/unit/runtime-host.test.ts index 1379ef68..bd759eb4 100644 --- a/tests/unit/runtime-host.test.ts +++ b/tests/unit/runtime-host.test.ts @@ -31,8 +31,12 @@ import { createBoxBrush } from "../../src/document/brushes"; import { createScenePath } from "../../src/document/paths"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { + createCameraRigEntity, + createCameraRigPlayerTargetRef, + createCameraRigWorldPointTargetRef, createNpcEntity, createPointLightEntity, + createPlayerStartEntity, createSoundEmitterEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; @@ -56,7 +60,7 @@ import { type RuntimeSceneLoadState } from "../../src/runtime-three/runtime-host"; import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build"; -import { AnimationClip, BoxGeometry, type AnimationMixer } from "three"; +import { AnimationClip, BoxGeometry, PerspectiveCamera, Vector3, type AnimationMixer } from "three"; import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-collider-fixtures"; function createDeferred() { @@ -78,6 +82,13 @@ function createDeferred() { }; } +function resolveYawPitchRadians(direction: Vector3) { + return { + yawRadians: Math.atan2(direction.x, direction.z), + pitchRadians: Math.asin(Math.max(-1, Math.min(1, direction.y))) + }; +} + describe("RuntimeHost", () => { afterEach(() => { vi.restoreAllMocks(); @@ -138,6 +149,220 @@ describe("RuntimeHost", () => { expect(collisionWorld.dispose).toHaveBeenCalledTimes(1); }); + it("resolves fixed camera rigs by priority, supports explicit overrides, and blends transitions", () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ + dispose: vi.fn(), + resolveThirdPersonCameraCollision: vi.fn( + (_pivot, desiredCameraPosition) => desiredCameraPosition + ) + } as unknown as RapierCollisionWorld); + + const defaultRig = createCameraRigEntity({ + id: "entity-camera-rig-default", + position: { + x: 0, + y: 5, + z: 10 + }, + priority: 10, + defaultActive: true, + target: createCameraRigWorldPointTargetRef({ + x: 0, + y: 1, + z: 0 + }), + transitionMode: "cut" + }); + const overrideRig = createCameraRigEntity({ + id: "entity-camera-rig-override", + position: { + x: 10, + y: 4, + z: -6 + }, + priority: 0, + defaultActive: false, + target: createCameraRigWorldPointTargetRef({ + x: 2, + y: 2, + z: -1 + }), + transitionMode: "blend", + transitionDurationSeconds: 0.5 + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Camera Rig Priority Scene" }), + entities: { + [defaultRig.id]: defaultRig, + [overrideRig.id]: overrideRig + } + }); + const host = new RuntimeHost({ + enableRendering: false + }); + host.loadScene(runtimeScene); + + const hostInternals = host as unknown as { + sceneReady: boolean; + camera: PerspectiveCamera; + activeRuntimeCameraRig: { entityId: string } | null; + applyActiveCameraRig(dt: number): { entityId: string } | null; + }; + + hostInternals.sceneReady = true; + + expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(defaultRig.id); + expect(hostInternals.camera.position).toMatchObject(defaultRig.position); + + host.setActiveCameraRigOverride(overrideRig.id); + + expect(hostInternals.applyActiveCameraRig(0.25)?.entityId).toBe( + overrideRig.id + ); + expect(hostInternals.activeRuntimeCameraRig?.entityId).toBe(overrideRig.id); + expect(hostInternals.camera.position.x).toBeCloseTo(5, 4); + expect(hostInternals.camera.position.y).toBeCloseTo(4.5, 4); + expect(hostInternals.camera.position.z).toBeCloseTo(2, 4); + + hostInternals.applyActiveCameraRig(0.25); + + expect(hostInternals.camera.position).toMatchObject(overrideRig.position); + + host.dispose(); + }); + + it("locks a fixed camera rig to its target and clamps authored look-around input", () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({ + dispose: vi.fn(), + resolveThirdPersonCameraCollision: vi.fn( + (_pivot, desiredCameraPosition) => desiredCameraPosition + ) + } as unknown as RapierCollisionWorld); + + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-camera-rig", + position: { + x: 1, + y: 0, + z: -2 + } + }); + const cameraRig = createCameraRigEntity({ + id: "entity-camera-rig-lookaround", + position: { + x: -4, + y: 3, + z: -8 + }, + target: createCameraRigPlayerTargetRef(), + targetOffset: { + x: 0, + y: 1.6, + z: 0 + }, + lookAround: { + enabled: true, + yawLimitDegrees: 10, + pitchLimitDegrees: 5, + recenterSpeed: 12 + } + }); + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Camera Rig Look Scene" }), + entities: { + [playerStart.id]: playerStart, + [cameraRig.id]: cameraRig + } + }); + const host = new RuntimeHost({ + enableRendering: false + }); + host.loadScene(runtimeScene); + + const hostInternals = host as unknown as { + sceneReady: boolean; + camera: PerspectiveCamera; + applyActiveCameraRig(dt: number): { entityId: string } | null; + handleRuntimePointerDown(event: { + button: number; + clientX: number; + clientY: number; + preventDefault(): void; + stopImmediatePropagation(): void; + }): void; + handleRuntimePointerMove(event: { + clientX: number; + clientY: number; + preventDefault(): void; + stopImmediatePropagation(): void; + }): void; + handleRuntimePointerUp(event: { + stopImmediatePropagation(): void; + }): void; + }; + + hostInternals.sceneReady = true; + + expect(hostInternals.applyActiveCameraRig(0)?.entityId).toBe(cameraRig.id); + + const expectedBaseDirection = new Vector3( + playerStart.position.x - cameraRig.position.x, + playerStart.position.y + 1.6 - cameraRig.position.y, + playerStart.position.z - cameraRig.position.z + ).normalize(); + const baseDirection = hostInternals.camera.getWorldDirection(new Vector3()); + + expect(baseDirection.angleTo(expectedBaseDirection)).toBeLessThan(1e-4); + + hostInternals.handleRuntimePointerDown({ + button: 0, + clientX: 0, + clientY: 0, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn() + }); + hostInternals.handleRuntimePointerMove({ + clientX: -10000, + clientY: 10000, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn() + }); + hostInternals.applyActiveCameraRig(0); + + const lookedDirection = hostInternals.camera.getWorldDirection(new Vector3()); + const baseAngles = resolveYawPitchRadians(expectedBaseDirection); + const lookedAngles = resolveYawPitchRadians(lookedDirection); + + expect( + ((lookedAngles.yawRadians - baseAngles.yawRadians) * 180) / Math.PI + ).toBeCloseTo(10, 1); + expect( + ((lookedAngles.pitchRadians - baseAngles.pitchRadians) * 180) / Math.PI + ).toBeCloseTo(-5, 1); + + hostInternals.handleRuntimePointerUp({ + stopImmediatePropagation: vi.fn() + }); + hostInternals.applyActiveCameraRig(0.5); + + const recenteredAngles = resolveYawPitchRadians( + hostInternals.camera.getWorldDirection(new Vector3()) + ); + + expect( + Math.abs(recenteredAngles.yawRadians - baseAngles.yawRadians) + ).toBeLessThan(Math.abs(lookedAngles.yawRadians - baseAngles.yawRadians)); + expect( + Math.abs(recenteredAngles.pitchRadians - baseAngles.pitchRadians) + ).toBeLessThan( + Math.abs(lookedAngles.pitchRadians - baseAngles.pitchRadians) + ); + + host.dispose(); + }); + it("applies typed light control effects through the runtime dispatcher", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(RapierCollisionWorld, "create").mockResolvedValue({