From 3c95666e856b81171a2831c5588289dae058bbc8 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 27 Apr 2026 17:22:25 +0200 Subject: [PATCH] Add ProjectDocument support for scene building and validation --- src/runtime-three/runtime-scene-build.ts | 6 +- src/runtime-three/runtime-scene-validation.ts | 12 +++- ...first-person-navigation-controller.test.ts | 37 ++++++++++++ tests/unit/runtime-host.test.ts | 58 +++++++++++++++++++ ...third-person-navigation-controller.test.ts | 37 ++++++++++++ 5 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/runtime-three/runtime-scene-build.ts b/src/runtime-three/runtime-scene-build.ts index 9b606b60..18f3c498 100644 --- a/src/runtime-three/runtime-scene-build.ts +++ b/src/runtime-three/runtime-scene-build.ts @@ -48,7 +48,7 @@ import { type WhiteboxFaceId, type FaceUvState } from "../document/brushes"; -import type { SceneDocument } from "../document/scene-document"; +import type { ProjectDocument, SceneDocument } from "../document/scene-document"; import { cloneProjectTimeSettings, type ProjectTimeSettings @@ -531,6 +531,7 @@ export interface BuildRuntimeSceneOptions { loadedModelAssets?: Record; runtimeClock?: RuntimeClockState; sceneEntryId?: string | null; + projectDocument?: ProjectDocument; } export function resolveRuntimeNavigationMode( @@ -1837,7 +1838,8 @@ export function buildRuntimeSceneFromDocument( assertRuntimeSceneBuildable(document, { navigationMode, - loadedModelAssets: options.loadedModelAssets + loadedModelAssets: options.loadedModelAssets, + projectDocument: options.projectDocument }); const enabledBrushes = Object.values(document.brushes).filter( diff --git a/src/runtime-three/runtime-scene-validation.ts b/src/runtime-three/runtime-scene-validation.ts index e17c937d..2eed859c 100644 --- a/src/runtime-three/runtime-scene-validation.ts +++ b/src/runtime-three/runtime-scene-validation.ts @@ -1,8 +1,10 @@ import type { LoadedModelAsset } from "../assets/gltf-model-import"; import { getModelInstances } from "../assets/model-instances"; import type { Brush } from "../document/brushes"; -import type { SceneDocument } from "../document/scene-document"; +import type { ProjectDocument, SceneDocument } from "../document/scene-document"; import { + assertProjectSchedulingResourcesAreValid, + assertSceneDocumentLocalBuildContentIsValid, assertSceneDocumentIsValid, createDiagnostic, formatSceneDiagnosticSummary, @@ -21,6 +23,7 @@ export interface RuntimeSceneBuildValidationResult { interface ValidateRuntimeSceneBuildOptions { navigationMode: "firstPerson" | "thirdPerson"; loadedModelAssets?: Record; + projectDocument?: ProjectDocument; } function validateBrushGeometry(brush: Brush, path: string, diagnostics: SceneDiagnostic[]) { @@ -100,7 +103,12 @@ export function validateRuntimeSceneBuild( } export function assertRuntimeSceneBuildable(document: SceneDocument, options: ValidateRuntimeSceneBuildOptions) { - assertSceneDocumentIsValid(document); + if (options.projectDocument === undefined) { + assertSceneDocumentIsValid(document); + } else { + assertProjectSchedulingResourcesAreValid(options.projectDocument); + assertSceneDocumentLocalBuildContentIsValid(document); + } const validation = validateRuntimeSceneBuild(document, options); diff --git a/tests/unit/first-person-navigation-controller.test.ts b/tests/unit/first-person-navigation-controller.test.ts index 2737111a..8ff67184 100644 --- a/tests/unit/first-person-navigation-controller.test.ts +++ b/tests/unit/first-person-navigation-controller.test.ts @@ -162,6 +162,43 @@ describe("FirstPersonNavigationController", () => { expect(exitPointerLockSpy).toHaveBeenCalledTimes(1); }); + it("applies authored horizontal mouse inversion while pointer-locked", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-invert-first-person", + invertMouseCameraHorizontal: true + }); + const { context, domElement } = createRuntimeControllerContext(playerStart); + const controller = new FirstPersonNavigationController(); + const mouseMoveEvent = new MouseEvent("mousemove"); + + Object.defineProperty(mouseMoveEvent, "movementX", { + configurable: true, + value: 20 + }); + Object.defineProperty(mouseMoveEvent, "movementY", { + configurable: true, + value: 0 + }); + Object.defineProperty(document, "pointerLockElement", { + configurable: true, + get: () => domElement + }); + + controller.activate(context); + document.dispatchEvent(mouseMoveEvent); + controller.update(0); + + const telemetry = + context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0]; + + expect(telemetry?.pointerLocked).toBe(true); + expect(telemetry?.yawDegrees).toBeGreaterThan(0); + + controller.deactivate(context, { + releasePointerLock: false + }); + }); + it("uses authored gamepad bindings instead of the hardcoded stick mapping", () => { const playerStart = createPlayerStartEntity({ id: "entity-player-start-custom-gamepad", diff --git a/tests/unit/runtime-host.test.ts b/tests/unit/runtime-host.test.ts index ad273bd1..1a2a343b 100644 --- a/tests/unit/runtime-host.test.ts +++ b/tests/unit/runtime-host.test.ts @@ -3773,6 +3773,64 @@ describe("RuntimeHost", () => { host.dispose(); }); + it("preserves pointer lock when switching between first- and third-person controllers", () => { + const host = new RuntimeHost({ + enableRendering: false + }); + const runtimeScene = buildRuntimeSceneFromDocument( + createEmptySceneDocument(), + { + navigationMode: "firstPerson" + } + ); + const hostInternals = host as unknown as { + activeController: { + id: "firstPerson" | "thirdPerson"; + deactivate: ReturnType; + } | null; + controllerContext: unknown; + desiredNavigationMode: "firstPerson" | "thirdPerson"; + runtimeScene: ReturnType | null; + sceneReady: boolean; + thirdPersonController: { + id: "thirdPerson"; + activate: ReturnType; + }; + activateDesiredNavigationController(): void; + }; + const deactivate = vi.fn(); + const activate = vi.fn(); + const domElement = ( + host as unknown as { + domElement: HTMLCanvasElement; + } + ).domElement; + + hostInternals.runtimeScene = runtimeScene; + hostInternals.sceneReady = true; + hostInternals.activeController = { + id: "firstPerson", + deactivate + }; + hostInternals.desiredNavigationMode = "thirdPerson"; + hostInternals.thirdPersonController = { + id: "thirdPerson", + activate + }; + Object.defineProperty(document, "pointerLockElement", { + configurable: true, + get: () => domElement + }); + + hostInternals.activateDesiredNavigationController(); + + expect(deactivate).toHaveBeenCalledWith(hostInternals.controllerContext, { + releasePointerLock: false + }); + expect(activate).toHaveBeenCalledWith(hostInternals.controllerContext); + host.dispose(); + }); + it("switches an active target once from directional screen-space look input", () => { const host = new RuntimeHost({ enableRendering: false diff --git a/tests/unit/third-person-navigation-controller.test.ts b/tests/unit/third-person-navigation-controller.test.ts index 31a5733b..0352e9be 100644 --- a/tests/unit/third-person-navigation-controller.test.ts +++ b/tests/unit/third-person-navigation-controller.test.ts @@ -171,6 +171,43 @@ describe("ThirdPersonNavigationController", () => { controller.deactivate(context); }); + it("captures pointer-locked third-person mouse look and honors horizontal inversion", () => { + const playerStart = createPlayerStartEntity({ + id: "entity-player-start-invert-third-person", + invertMouseCameraHorizontal: true + }); + const { context, domElement } = createRuntimeControllerContext(playerStart); + const controller = new ThirdPersonNavigationController(); + const mouseMoveEvent = new MouseEvent("mousemove"); + + Object.defineProperty(mouseMoveEvent, "movementX", { + configurable: true, + value: 24 + }); + Object.defineProperty(mouseMoveEvent, "movementY", { + configurable: true, + value: 0 + }); + Object.defineProperty(document, "pointerLockElement", { + configurable: true, + get: () => domElement + }); + + controller.activate(context); + document.dispatchEvent(mouseMoveEvent); + controller.update(0); + + const telemetry = + context.setPlayerControllerTelemetry.mock.calls.at(-1)?.[0]; + + expect(telemetry?.pointerLocked).toBe(true); + expect(context.camera.position.x).toBeLessThan(0); + + controller.deactivate(context, { + releasePointerLock: false + }); + }); + it("smooths the third-person camera back out when collision clears", () => { const { context } = createRuntimeControllerContext(); const controller = new ThirdPersonNavigationController();