From e205cea50cae3a5d2cd532a2ff362267cad558c4 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sat, 11 Apr 2026 04:19:50 +0200 Subject: [PATCH] auto-git: [change] src/app/app.css [change] src/commands/set-scene-loading-screen-command.ts [change] src/document/migrate-scene-document.ts [change] src/document/scene-document-validation.ts [change] src/document/scene-document.ts [change] src/runner-web/RunnerCanvas.tsx [change] src/runtime-three/first-person-navigation-controller.ts [change] src/runtime-three/navigation-controller.ts [change] src/runtime-three/orbit-visitor-navigation-controller.ts [change] src/runtime-three/runtime-host.ts [change] tests/domain/editor-store.test.ts [change] tests/serialization/local-draft-storage.test.ts [change] tests/serialization/project-document-json.test.ts [change] tests/serialization/project-package.test.ts [change] tests/unit/runner-canvas.test.tsx [change] tests/unit/runtime-host.test.ts --- src/app/app.css | 35 +- .../set-scene-loading-screen-command.ts | 4 +- src/document/migrate-scene-document.ts | 859 ++++++++++++---- src/document/scene-document-validation.ts | 935 ++++++++++++++---- src/document/scene-document.ts | 32 +- src/runner-web/RunnerCanvas.tsx | 72 +- .../first-person-navigation-controller.ts | 107 +- src/runtime-three/navigation-controller.ts | 17 +- .../orbit-visitor-navigation-controller.ts | 30 +- src/runtime-three/runtime-host.ts | 468 +++++++-- tests/domain/editor-store.test.ts | 20 +- .../serialization/local-draft-storage.test.ts | 36 +- .../project-document-json.test.ts | 6 +- tests/serialization/project-package.test.ts | 102 +- tests/unit/runner-canvas.test.tsx | 68 +- tests/unit/runtime-host.test.ts | 4 +- 16 files changed, 2232 insertions(+), 563 deletions(-) diff --git a/src/app/app.css b/src/app/app.css index 0c7510a6..6ec0528a 100644 --- a/src/app/app.css +++ b/src/app/app.css @@ -32,7 +32,11 @@ body { min-height: 100vh; overflow: hidden; background: - radial-gradient(circle at top, rgba(80, 96, 120, 0.35) 0%, rgba(23, 28, 37, 0) 42%), + radial-gradient( + circle at top, + rgba(80, 96, 120, 0.35) 0%, + rgba(23, 28, 37, 0) 42% + ), linear-gradient(180deg, #1b2029 0%, #101318 100%); color: var(--color-text); } @@ -203,7 +207,10 @@ button:disabled { .workspace { display: grid; - grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(280px, 320px); + grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax( + 280px, + 320px + ); gap: 12px; min-height: 0; overflow: hidden; @@ -296,7 +303,11 @@ button:disabled { .stat-card { padding: 12px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.04) 0%, + rgba(255, 255, 255, 0.02) 100% + ); border: 1px solid var(--color-border); border-radius: 14px; } @@ -988,7 +999,11 @@ button:disabled { flex: 1 1 auto; min-height: 0; background: - radial-gradient(circle at top, rgba(130, 154, 188, 0.28) 0%, rgba(130, 154, 188, 0) 38%), + radial-gradient( + circle at top, + rgba(130, 154, 188, 0.28) 0%, + rgba(130, 154, 188, 0) 38% + ), linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%); } @@ -1145,7 +1160,11 @@ button:disabled { gap: 8px; padding: 18px; color: #f3e8da; - background: linear-gradient(180deg, rgba(8, 10, 14, 0.12) 0%, rgba(8, 10, 14, 0.58) 100%); + background: linear-gradient( + 180deg, + rgba(8, 10, 14, 0.12) 0%, + rgba(8, 10, 14, 0.58) 100% + ); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 18px; pointer-events: auto; @@ -1263,7 +1282,11 @@ button:disabled { z-index: 2; pointer-events: none; background: - radial-gradient(circle at 50% 18%, rgba(146, 223, 255, 0.2), transparent 42%), + radial-gradient( + circle at 50% 18%, + rgba(146, 223, 255, 0.2), + transparent 42% + ), linear-gradient(180deg, rgba(38, 113, 153, 0.16), rgba(8, 40, 63, 0.42)); backdrop-filter: blur(1.5px) saturate(1.08); mix-blend-mode: screen; diff --git a/src/commands/set-scene-loading-screen-command.ts b/src/commands/set-scene-loading-screen-command.ts index 1bdb942e..1d006b25 100644 --- a/src/commands/set-scene-loading-screen-command.ts +++ b/src/commands/set-scene-loading-screen-command.ts @@ -70,7 +70,9 @@ export function createSetSceneLoadingScreenCommand( ...currentProjectDocument.scenes, [options.sceneId]: { ...currentScene, - loadingScreen: cloneSceneLoadingScreenSettings(previousLoadingScreen) + loadingScreen: cloneSceneLoadingScreenSettings( + previousLoadingScreen + ) } } }); diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts index 8c7ca540..594d5715 100644 --- a/src/document/migrate-scene-document.ts +++ b/src/document/migrate-scene-document.ts @@ -1,4 +1,8 @@ -import { createStarterMaterialRegistry, type MaterialDef, type MaterialPattern } from "../materials/starter-material-library"; +import { + createStarterMaterialRegistry, + type MaterialDef, + type MaterialPattern +} from "../materials/starter-material-library"; import { createModelInstanceCollisionSettings, createModelInstance, @@ -140,7 +144,10 @@ function expectString(value: unknown, label: string): string { return value; } -function readOptionalSceneLoadingText(value: unknown, label: string): string | null { +function readOptionalSceneLoadingText( + value: unknown, + label: string +): string | null { if (value === undefined || value === null) { return null; } @@ -181,7 +188,10 @@ function expectBoolean(value: unknown, label: string): boolean { } function expectStringArray(value: unknown, label: string): string[] { - if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string") + ) { throw new Error(`${label} must be a string array.`); } @@ -198,7 +208,11 @@ function expectHexColor(value: unknown, label: string): string { return normalizedValue; } -function expectLiteralString(value: unknown, expectedValue: T, label: string): T { +function expectLiteralString( + value: unknown, + expectedValue: T, + label: string +): T { if (value !== expectedValue) { throw new Error(`${label} must be ${expectedValue}.`); } @@ -206,7 +220,11 @@ function expectLiteralString(value: unknown, expectedValue: T, return expectedValue; } -function readOptionalBoolean(value: unknown, label: string, fallback: boolean): boolean { +function readOptionalBoolean( + value: unknown, + label: string, + fallback: boolean +): boolean { if (value === undefined) { return fallback; } @@ -214,7 +232,11 @@ function readOptionalBoolean(value: unknown, label: string, fallback: boolean): return expectBoolean(value, label); } -function readOptionalFiniteNumber(value: unknown, label: string, fallback: number): number { +function readOptionalFiniteNumber( + value: unknown, + label: string, + fallback: number +): number { if (value === undefined) { return fallback; } @@ -222,7 +244,11 @@ function readOptionalFiniteNumber(value: unknown, label: string, fallback: numbe return expectFiniteNumber(value, label); } -function readOptionalNonNegativeFiniteNumber(value: unknown, label: string, fallback: number): number { +function readOptionalNonNegativeFiniteNumber( + value: unknown, + label: string, + fallback: number +): number { if (value === undefined) { return fallback; } @@ -230,7 +256,11 @@ function readOptionalNonNegativeFiniteNumber(value: unknown, label: string, fall return expectNonNegativeFiniteNumber(value, label); } -function readOptionalPositiveInteger(value: unknown, label: string, fallback: number): number { +function readOptionalPositiveInteger( + value: unknown, + label: string, + fallback: number +): number { if (value === undefined) { return fallback; } @@ -244,11 +274,21 @@ function readOptionalPositiveInteger(value: unknown, label: string, fallback: nu return integerValue; } -function readOptionalPositiveIntegerWithMax(value: unknown, label: string, fallback: number, max: number): number { +function readOptionalPositiveIntegerWithMax( + value: unknown, + label: string, + fallback: number, + max: number +): number { return Math.min(readOptionalPositiveInteger(value, label, fallback), max); } -function readOptionalAllowedValue(value: unknown, label: string, fallback: T, guard: (candidate: unknown) => candidate is T): T { +function readOptionalAllowedValue( + value: unknown, + label: string, + fallback: T, + guard: (candidate: unknown) => candidate is T +): T { if (value === undefined) { return fallback; } @@ -260,7 +300,9 @@ function readOptionalAllowedValue(value: unknown, label: string, fallback: T, return value; } -function readAdvancedRenderingSettings(value: unknown): AdvancedRenderingSettings { +function readAdvancedRenderingSettings( + value: unknown +): AdvancedRenderingSettings { const defaults = createDefaultAdvancedRenderingSettings(); if (value === undefined) { @@ -275,8 +317,13 @@ function readAdvancedRenderingSettings(value: unknown): AdvancedRenderingSetting throw new Error("world.advancedRendering.shadows must be an object."); } - if (value.ambientOcclusion !== undefined && !isRecord(value.ambientOcclusion)) { - throw new Error("world.advancedRendering.ambientOcclusion must be an object."); + if ( + value.ambientOcclusion !== undefined && + !isRecord(value.ambientOcclusion) + ) { + throw new Error( + "world.advancedRendering.ambientOcclusion must be an object." + ); } if (value.bloom !== undefined && !isRecord(value.bloom)) { @@ -292,10 +339,14 @@ function readAdvancedRenderingSettings(value: unknown): AdvancedRenderingSetting } const shadows = value.shadows as Record | undefined; - const ambientOcclusion = value.ambientOcclusion as Record | undefined; + const ambientOcclusion = value.ambientOcclusion as + | Record + | undefined; const bloom = value.bloom as Record | undefined; const toneMapping = value.toneMapping as Record | undefined; - const depthOfField = value.depthOfField as Record | undefined; + const depthOfField = value.depthOfField as + | Record + | undefined; const shadowsMapSize = readOptionalAllowedValue( shadows?.mapSize, @@ -315,8 +366,18 @@ function readAdvancedRenderingSettings(value: unknown): AdvancedRenderingSetting defaults.toneMapping.mode, isAdvancedRenderingToneMappingMode ); - const fogPath = readOptionalAllowedValue(value.fogPath, "world.advancedRendering.fogPath", defaults.fogPath, isBoxVolumeRenderPath); - const waterPath = readOptionalAllowedValue(value.waterPath, "world.advancedRendering.waterPath", defaults.waterPath, isBoxVolumeRenderPath); + const fogPath = readOptionalAllowedValue( + value.fogPath, + "world.advancedRendering.fogPath", + defaults.fogPath, + isBoxVolumeRenderPath + ); + const waterPath = readOptionalAllowedValue( + value.waterPath, + "world.advancedRendering.waterPath", + defaults.waterPath, + isBoxVolumeRenderPath + ); const waterReflectionMode = readOptionalAllowedValue( value.waterReflectionMode, "world.advancedRendering.waterReflectionMode", @@ -325,12 +386,24 @@ function readAdvancedRenderingSettings(value: unknown): AdvancedRenderingSetting ); return { - enabled: readOptionalBoolean(value.enabled, "world.advancedRendering.enabled", defaults.enabled), + enabled: readOptionalBoolean( + value.enabled, + "world.advancedRendering.enabled", + defaults.enabled + ), shadows: { - enabled: readOptionalBoolean(shadows?.enabled, "world.advancedRendering.shadows.enabled", defaults.shadows.enabled), + enabled: readOptionalBoolean( + shadows?.enabled, + "world.advancedRendering.shadows.enabled", + defaults.shadows.enabled + ), mapSize: shadowsMapSize, type: shadowsType, - bias: readOptionalFiniteNumber(shadows?.bias, "world.advancedRendering.shadows.bias", defaults.shadows.bias) + bias: readOptionalFiniteNumber( + shadows?.bias, + "world.advancedRendering.shadows.bias", + defaults.shadows.bias + ) }, ambientOcclusion: { enabled: readOptionalBoolean( @@ -355,7 +428,11 @@ function readAdvancedRenderingSettings(value: unknown): AdvancedRenderingSetting ) }, bloom: { - enabled: readOptionalBoolean(bloom?.enabled, "world.advancedRendering.bloom.enabled", defaults.bloom.enabled), + enabled: readOptionalBoolean( + bloom?.enabled, + "world.advancedRendering.bloom.enabled", + defaults.bloom.enabled + ), intensity: readOptionalNonNegativeFiniteNumber( bloom?.intensity, "world.advancedRendering.bloom.intensity", @@ -366,11 +443,19 @@ function readAdvancedRenderingSettings(value: unknown): AdvancedRenderingSetting "world.advancedRendering.bloom.threshold", defaults.bloom.threshold ), - radius: readOptionalNonNegativeFiniteNumber(bloom?.radius, "world.advancedRendering.bloom.radius", defaults.bloom.radius) + radius: readOptionalNonNegativeFiniteNumber( + bloom?.radius, + "world.advancedRendering.bloom.radius", + defaults.bloom.radius + ) }, toneMapping: { mode: toneMappingMode, - exposure: readOptionalFiniteNumber(toneMapping?.exposure, "world.advancedRendering.toneMapping.exposure", defaults.toneMapping.exposure) + exposure: readOptionalFiniteNumber( + toneMapping?.exposure, + "world.advancedRendering.toneMapping.exposure", + defaults.toneMapping.exposure + ) }, depthOfField: { enabled: readOptionalBoolean( @@ -400,7 +485,10 @@ function readAdvancedRenderingSettings(value: unknown): AdvancedRenderingSetting }; } -function readBoxBrushVolumeSettings(value: unknown, label: string): BoxBrushVolumeSettings { +function readBoxBrushVolumeSettings( + value: unknown, + label: string +): BoxBrushVolumeSettings { if (value === undefined) { return { mode: "none" @@ -411,7 +499,12 @@ function readBoxBrushVolumeSettings(value: unknown, label: string): BoxBrushVolu throw new Error(`${label} must be an object.`); } - const mode = readOptionalAllowedValue(value.mode, `${label}.mode`, "none", isBoxBrushVolumeMode); + const mode = readOptionalAllowedValue( + value.mode, + `${label}.mode`, + "none", + isBoxBrushVolumeMode + ); if (mode === "none") { return { @@ -431,13 +524,20 @@ function readBoxBrushVolumeSettings(value: unknown, label: string): BoxBrushVolu return { mode: "water", water: { - colorHex: water.colorHex === undefined ? defaults.colorHex : expectHexColor(water.colorHex, `${label}.water.colorHex`), + colorHex: + water.colorHex === undefined + ? defaults.colorHex + : expectHexColor(water.colorHex, `${label}.water.colorHex`), surfaceOpacity: readOptionalNonNegativeFiniteNumber( water.surfaceOpacity, `${label}.water.surfaceOpacity`, defaults.surfaceOpacity ), - waveStrength: readOptionalNonNegativeFiniteNumber(water.waveStrength, `${label}.water.waveStrength`, defaults.waveStrength), + waveStrength: readOptionalNonNegativeFiniteNumber( + water.waveStrength, + `${label}.water.waveStrength`, + defaults.waveStrength + ), foamContactLimit: readOptionalPositiveIntegerWithMax( water.foamContactLimit, `${label}.water.foamContactLimit`, @@ -464,14 +564,28 @@ function readBoxBrushVolumeSettings(value: unknown, label: string): BoxBrushVolu return { mode: "fog", fog: { - colorHex: fog.colorHex === undefined ? defaults.colorHex : expectHexColor(fog.colorHex, `${label}.fog.colorHex`), - density: readOptionalNonNegativeFiniteNumber(fog.density, `${label}.fog.density`, defaults.density), - padding: readOptionalNonNegativeFiniteNumber(fog.padding, `${label}.fog.padding`, defaults.padding) + colorHex: + fog.colorHex === undefined + ? defaults.colorHex + : expectHexColor(fog.colorHex, `${label}.fog.colorHex`), + density: readOptionalNonNegativeFiniteNumber( + fog.density, + `${label}.fog.density`, + defaults.density + ), + padding: readOptionalNonNegativeFiniteNumber( + fog.padding, + `${label}.fog.padding`, + defaults.padding + ) } }; } -function expectOptionalString(value: unknown, label: string): string | undefined { +function expectOptionalString( + value: unknown, + label: string +): string | undefined { if (value === undefined) { return undefined; } @@ -479,15 +593,24 @@ function expectOptionalString(value: unknown, label: string): string | undefined return expectString(value, label); } -function readOptionalBrushName(value: unknown, label: string): string | undefined { +function readOptionalBrushName( + value: unknown, + label: string +): string | undefined { return normalizeBrushName(expectOptionalString(value, label)); } -function readOptionalEntityName(value: unknown, label: string): string | undefined { +function readOptionalEntityName( + value: unknown, + label: string +): string | undefined { return normalizeEntityName(expectOptionalString(value, label)); } -function expectEmptyCollection(value: unknown, label: string): Record { +function expectEmptyCollection( + value: unknown, + label: string +): Record { if (!isRecord(value)) { throw new Error(`${label} must be a record.`); } @@ -499,7 +622,10 @@ function expectEmptyCollection(value: unknown, label: string): Record typeof candidate === "string" && isPlayerStartColliderMode(candidate) + (candidate): candidate is "capsule" | "box" | "none" => + typeof candidate === "string" && isPlayerStartColliderMode(candidate) ); return createPlayerStartColliderSettings({ mode, - eyeHeight: value.eyeHeight === undefined ? undefined : expectPositiveFiniteNumber(value.eyeHeight, `${label}.eyeHeight`), + eyeHeight: + value.eyeHeight === undefined + ? undefined + : expectPositiveFiniteNumber(value.eyeHeight, `${label}.eyeHeight`), capsuleRadius: - value.capsuleRadius === undefined ? undefined : expectPositiveFiniteNumber(value.capsuleRadius, `${label}.capsuleRadius`), + value.capsuleRadius === undefined + ? undefined + : expectPositiveFiniteNumber( + value.capsuleRadius, + `${label}.capsuleRadius` + ), capsuleHeight: - value.capsuleHeight === undefined ? undefined : expectPositiveFiniteNumber(value.capsuleHeight, `${label}.capsuleHeight`), - boxSize: value.boxSize === undefined ? undefined : readVec3(value.boxSize, `${label}.boxSize`) + value.capsuleHeight === undefined + ? undefined + : expectPositiveFiniteNumber( + value.capsuleHeight, + `${label}.capsuleHeight` + ), + boxSize: + value.boxSize === undefined + ? undefined + : readVec3(value.boxSize, `${label}.boxSize`) }); } -function readModelInstance(value: unknown, label: string, assets: SceneDocument["assets"]): ModelInstance { +function readModelInstance( + value: unknown, + label: string, + assets: SceneDocument["assets"] +): ModelInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } @@ -713,24 +917,39 @@ function readModelInstance(value: unknown, label: string, assets: SceneDocument[ return createModelInstance({ id: expectString(value.id, `${label}.id`), assetId, - name: normalizeModelInstanceName(expectOptionalString(value.name, `${label}.name`)), + name: normalizeModelInstanceName( + expectOptionalString(value.name, `${label}.name`) + ), position: readVec3(value.position, `${label}.position`), - rotationDegrees: readVec3(value.rotationDegrees, `${label}.rotationDegrees`), + rotationDegrees: readVec3( + value.rotationDegrees, + `${label}.rotationDegrees` + ), scale: readVec3(value.scale, `${label}.scale`), - collision: readModelInstanceCollisionSettings(value.collision, `${label}.collision`), + collision: readModelInstanceCollisionSettings( + value.collision, + `${label}.collision` + ), animationClipName: (() => { - const raw = expectOptionalString(value.animationClipName, `${label}.animationClipName`); + const raw = expectOptionalString( + value.animationClipName, + `${label}.animationClipName` + ); if (raw === undefined) return undefined; const trimmed = raw.trim(); return trimmed.length === 0 ? undefined : trimmed; })(), - animationAutoplay: value.animationAutoplay === undefined - ? undefined - : expectBoolean(value.animationAutoplay, `${label}.animationAutoplay`) + animationAutoplay: + value.animationAutoplay === undefined + ? undefined + : expectBoolean(value.animationAutoplay, `${label}.animationAutoplay`) }); } -function readModelInstances(value: unknown, assets: SceneDocument["assets"]): SceneDocument["modelInstances"] { +function readModelInstances( + value: unknown, + assets: SceneDocument["assets"] +): SceneDocument["modelInstances"] { if (!isRecord(value)) { throw new Error("modelInstances must be a record."); } @@ -738,10 +957,16 @@ function readModelInstances(value: unknown, assets: SceneDocument["assets"]): Sc const modelInstances: SceneDocument["modelInstances"] = {}; for (const [modelInstanceId, modelInstanceValue] of Object.entries(value)) { - const modelInstance = readModelInstance(modelInstanceValue, `modelInstances.${modelInstanceId}`, assets); + const modelInstance = readModelInstance( + modelInstanceValue, + `modelInstances.${modelInstanceId}`, + assets + ); if (modelInstance.id !== modelInstanceId) { - throw new Error(`modelInstances.${modelInstanceId}.id must match the registry key.`); + throw new Error( + `modelInstances.${modelInstanceId}.id must match the registry key.` + ); } modelInstances[modelInstanceId] = modelInstance; @@ -773,7 +998,11 @@ function readVec3(value: unknown, label: string) { }; } -function readOptionalVec3(value: unknown, label: string, fallback: { x: number; y: number; z: number }) { +function readOptionalVec3( + value: unknown, + label: string, + fallback: { x: number; y: number; z: number } +) { if (value === undefined) { return { x: fallback.x, @@ -785,21 +1014,32 @@ function readOptionalVec3(value: unknown, label: string, fallback: { x: number; return readVec3(value, label); } -function assertNonZeroVec3(vector: { x: number; y: number; z: number }, label: string) { +function assertNonZeroVec3( + vector: { x: number; y: number; z: number }, + label: string +) { if (vector.x === 0 && vector.y === 0 && vector.z === 0) { throw new Error(`${label} must not be the zero vector.`); } } function expectMaterialPattern(value: unknown, label: string): MaterialPattern { - if (value !== "grid" && value !== "checker" && value !== "stripes" && value !== "diamond") { + if ( + value !== "grid" && + value !== "checker" && + value !== "stripes" && + value !== "diamond" + ) { throw new Error(`${label} must be a supported starter material pattern.`); } return value; } -function readMaterialRegistry(value: unknown, label: string): SceneDocument["materials"] { +function readMaterialRegistry( + value: unknown, + label: string +): SceneDocument["materials"] { if (!isRecord(value)) { throw new Error(`${label} must be a record.`); } @@ -814,9 +1054,18 @@ function readMaterialRegistry(value: unknown, label: string): SceneDocument["mat const material: MaterialDef = { id: expectString(materialValue.id, `${label}.${materialId}.id`), name: expectString(materialValue.name, `${label}.${materialId}.name`), - baseColorHex: expectHexColor(materialValue.baseColorHex, `${label}.${materialId}.baseColorHex`), - accentColorHex: expectHexColor(materialValue.accentColorHex, `${label}.${materialId}.accentColorHex`), - pattern: expectMaterialPattern(materialValue.pattern, `${label}.${materialId}.pattern`), + baseColorHex: expectHexColor( + materialValue.baseColorHex, + `${label}.${materialId}.baseColorHex` + ), + accentColorHex: expectHexColor( + materialValue.accentColorHex, + `${label}.${materialId}.accentColorHex` + ), + pattern: expectMaterialPattern( + materialValue.pattern, + `${label}.${materialId}.pattern` + ), tags: expectStringArray(materialValue.tags, `${label}.${materialId}.tags`) }; @@ -835,7 +1084,10 @@ function readFaceUvState(value: unknown, label: string): FaceUvState { throw new Error(`${label} must be an object.`); } - const rotationQuarterTurns = expectFiniteNumber(value.rotationQuarterTurns, `${label}.rotationQuarterTurns`); + const rotationQuarterTurns = expectFiniteNumber( + value.rotationQuarterTurns, + `${label}.rotationQuarterTurns` + ); if (!isFaceUvRotationQuarterTurns(rotationQuarterTurns)) { throw new Error(`${label}.rotationQuarterTurns must be 0, 1, 2, or 3.`); @@ -868,12 +1120,22 @@ function readBrushFace( const materialId = value.materialId; - if (materialId !== null && materialId !== undefined && typeof materialId !== "string") { + if ( + materialId !== null && + materialId !== undefined && + typeof materialId !== "string" + ) { throw new Error(`${label}.materialId must be a string or null.`); } - if (materialId !== null && materialId !== undefined && materials[materialId] === undefined) { - throw new Error(`${label}.materialId references missing material ${materialId}.`); + if ( + materialId !== null && + materialId !== undefined && + materials[materialId] === undefined + ) { + throw new Error( + `${label}.materialId references missing material ${materialId}.` + ); } return { @@ -895,23 +1157,61 @@ function readBoxBrushFaces( throw new Error(`${label} must be an object.`); } - const extraFaceKeys = Object.keys(value).filter((faceId) => !isBoxFaceId(faceId)); + const extraFaceKeys = Object.keys(value).filter( + (faceId) => !isBoxFaceId(faceId) + ); if (extraFaceKeys.length > 0) { - throw new Error(`${label} contains unsupported face ids: ${extraFaceKeys.join(", ")}.`); + throw new Error( + `${label} contains unsupported face ids: ${extraFaceKeys.join(", ")}.` + ); } return { - posX: readBrushFace(value.posX, `${label}.posX`, materials, allowMissingUvState), - negX: readBrushFace(value.negX, `${label}.negX`, materials, allowMissingUvState), - posY: readBrushFace(value.posY, `${label}.posY`, materials, allowMissingUvState), - negY: readBrushFace(value.negY, `${label}.negY`, materials, allowMissingUvState), - posZ: readBrushFace(value.posZ, `${label}.posZ`, materials, allowMissingUvState), - negZ: readBrushFace(value.negZ, `${label}.negZ`, materials, allowMissingUvState) + posX: readBrushFace( + value.posX, + `${label}.posX`, + materials, + allowMissingUvState + ), + negX: readBrushFace( + value.negX, + `${label}.negX`, + materials, + allowMissingUvState + ), + posY: readBrushFace( + value.posY, + `${label}.posY`, + materials, + allowMissingUvState + ), + negY: readBrushFace( + value.negY, + `${label}.negY`, + materials, + allowMissingUvState + ), + posZ: readBrushFace( + value.posZ, + `${label}.posZ`, + materials, + allowMissingUvState + ), + negZ: readBrushFace( + value.negZ, + `${label}.negZ`, + materials, + allowMissingUvState + ) }; } -function readBoxBrushGeometry(value: unknown, label: string, size: { x: number; y: number; z: number }) { +function readBoxBrushGeometry( + value: unknown, + label: string, + size: { x: number; y: number; z: number } +) { if (value === undefined) { return createDefaultBoxBrushGeometry(size); } @@ -925,23 +1225,50 @@ function readBoxBrushGeometry(value: unknown, label: string, size: { x: number; } const extraVertexKeys = Object.keys(value.vertices).filter( - (vertexId) => !BOX_VERTEX_IDS.includes(vertexId as (typeof BOX_VERTEX_IDS)[number]) + (vertexId) => + !BOX_VERTEX_IDS.includes(vertexId as (typeof BOX_VERTEX_IDS)[number]) ); if (extraVertexKeys.length > 0) { - throw new Error(`${label}.vertices contains unsupported vertex ids: ${extraVertexKeys.join(", ")}.`); + throw new Error( + `${label}.vertices contains unsupported vertex ids: ${extraVertexKeys.join(", ")}.` + ); } return { vertices: { - negX_negY_negZ: readVec3(value.vertices.negX_negY_negZ, `${label}.vertices.negX_negY_negZ`), - posX_negY_negZ: readVec3(value.vertices.posX_negY_negZ, `${label}.vertices.posX_negY_negZ`), - negX_posY_negZ: readVec3(value.vertices.negX_posY_negZ, `${label}.vertices.negX_posY_negZ`), - posX_posY_negZ: readVec3(value.vertices.posX_posY_negZ, `${label}.vertices.posX_posY_negZ`), - negX_negY_posZ: readVec3(value.vertices.negX_negY_posZ, `${label}.vertices.negX_negY_posZ`), - posX_negY_posZ: readVec3(value.vertices.posX_negY_posZ, `${label}.vertices.posX_negY_posZ`), - negX_posY_posZ: readVec3(value.vertices.negX_posY_posZ, `${label}.vertices.negX_posY_posZ`), - posX_posY_posZ: readVec3(value.vertices.posX_posY_posZ, `${label}.vertices.posX_posY_posZ`) + negX_negY_negZ: readVec3( + value.vertices.negX_negY_negZ, + `${label}.vertices.negX_negY_negZ` + ), + posX_negY_negZ: readVec3( + value.vertices.posX_negY_negZ, + `${label}.vertices.posX_negY_negZ` + ), + negX_posY_negZ: readVec3( + value.vertices.negX_posY_negZ, + `${label}.vertices.negX_posY_negZ` + ), + posX_posY_negZ: readVec3( + value.vertices.posX_posY_negZ, + `${label}.vertices.posX_posY_negZ` + ), + negX_negY_posZ: readVec3( + value.vertices.negX_negY_posZ, + `${label}.vertices.negX_negY_posZ` + ), + posX_negY_posZ: readVec3( + value.vertices.posX_negY_posZ, + `${label}.vertices.posX_negY_posZ` + ), + negX_posY_posZ: readVec3( + value.vertices.negX_posY_posZ, + `${label}.vertices.negX_posY_posZ` + ), + posX_posY_posZ: readVec3( + value.vertices.posX_posY_posZ, + `${label}.vertices.posX_posY_posZ` + ) } }; } @@ -983,11 +1310,29 @@ function readBrushes( DEFAULT_BOX_BRUSH_ROTATION_DEGREES ), size, - geometry: readBoxBrushGeometry(brushValue.geometry, `brushes.${brushId}.geometry`, size), - faces: readBoxBrushFaces(brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState), - volume: readBoxBrushVolumeSettings(brushValue.volume, `brushes.${brushId}.volume`), - layerId: expectOptionalString(brushValue.layerId, `brushes.${brushId}.layerId`), - groupId: expectOptionalString(brushValue.groupId, `brushes.${brushId}.groupId`) + geometry: readBoxBrushGeometry( + brushValue.geometry, + `brushes.${brushId}.geometry`, + size + ), + faces: readBoxBrushFaces( + brushValue.faces, + `brushes.${brushId}.faces`, + materials, + allowMissingUvState + ), + volume: readBoxBrushVolumeSettings( + brushValue.volume, + `brushes.${brushId}.volume` + ), + layerId: expectOptionalString( + brushValue.layerId, + `brushes.${brushId}.layerId` + ), + groupId: expectOptionalString( + brushValue.groupId, + `brushes.${brushId}.groupId` + ) }); } @@ -1022,7 +1367,9 @@ function readWorldSettings(value: unknown): WorldSettings { let resolvedBackground: WorldBackgroundSettings; if (!isWorldBackgroundMode(backgroundMode)) { - throw new Error("world.background.mode must be a supported background mode."); + throw new Error( + "world.background.mode must be a supported background mode." + ); } if (backgroundMode === "solid") { @@ -1033,29 +1380,47 @@ function readWorldSettings(value: unknown): WorldSettings { } else if (backgroundMode === "verticalGradient") { resolvedBackground = { mode: "verticalGradient", - topColorHex: expectHexColor(background.topColorHex, "world.background.topColorHex"), - bottomColorHex: expectHexColor(background.bottomColorHex, "world.background.bottomColorHex") + topColorHex: expectHexColor( + background.topColorHex, + "world.background.topColorHex" + ), + bottomColorHex: expectHexColor( + background.bottomColorHex, + "world.background.bottomColorHex" + ) }; } else { resolvedBackground = { mode: "image", assetId: expectString(background.assetId, "world.background.assetId"), // Default to 0.5 for documents saved before environmentIntensity was added - environmentIntensity: typeof background.environmentIntensity === "number" && isFinite(background.environmentIntensity) && background.environmentIntensity >= 0 - ? background.environmentIntensity - : 0.5 + environmentIntensity: + typeof background.environmentIntensity === "number" && + isFinite(background.environmentIntensity) && + background.environmentIntensity >= 0 + ? background.environmentIntensity + : 0.5 }; } return { background: resolvedBackground, ambientLight: { - colorHex: expectHexColor(ambientLight.colorHex, "world.ambientLight.colorHex"), - intensity: expectNonNegativeFiniteNumber(ambientLight.intensity, "world.ambientLight.intensity") + colorHex: expectHexColor( + ambientLight.colorHex, + "world.ambientLight.colorHex" + ), + intensity: expectNonNegativeFiniteNumber( + ambientLight.intensity, + "world.ambientLight.intensity" + ) }, sunLight: { colorHex: expectHexColor(sunLight.colorHex, "world.sunLight.colorHex"), - intensity: expectNonNegativeFiniteNumber(sunLight.intensity, "world.sunLight.intensity"), + intensity: expectNonNegativeFiniteNumber( + sunLight.intensity, + "world.sunLight.intensity" + ), direction }, advancedRendering: readAdvancedRenderingSettings(value.advancedRendering) @@ -1073,7 +1438,10 @@ function readPointLightEntity(value: unknown, label: string): EntityInstance { name: readOptionalEntityName(value.name, `${label}.name`), position: readVec3(value.position, `${label}.position`), colorHex: expectHexColor(value.colorHex, `${label}.colorHex`), - intensity: expectNonNegativeFiniteNumber(value.intensity, `${label}.intensity`), + intensity: expectNonNegativeFiniteNumber( + value.intensity, + `${label}.intensity` + ), distance: expectPositiveFiniteNumber(value.distance, `${label}.distance`) }); @@ -1096,9 +1464,15 @@ function readSpotLightEntity(value: unknown, label: string): EntityInstance { position: readVec3(value.position, `${label}.position`), direction: readVec3(value.direction, `${label}.direction`), colorHex: expectHexColor(value.colorHex, `${label}.colorHex`), - intensity: expectNonNegativeFiniteNumber(value.intensity, `${label}.intensity`), + intensity: expectNonNegativeFiniteNumber( + value.intensity, + `${label}.intensity` + ), distance: expectPositiveFiniteNumber(value.distance, `${label}.distance`), - angleDegrees: expectFiniteNumber(value.angleDegrees, `${label}.angleDegrees`) + angleDegrees: expectFiniteNumber( + value.angleDegrees, + `${label}.angleDegrees` + ) }); if (entity.kind !== kind) { @@ -1119,7 +1493,10 @@ function readPlayerStartEntity(value: unknown, label: string): EntityInstance { name: readOptionalEntityName(value.name, `${label}.name`), position: readVec3(value.position, `${label}.position`), yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`), - collider: readPlayerStartColliderSettings(value.collider, `${label}.collider`) + collider: readPlayerStartColliderSettings( + value.collider, + `${label}.collider` + ) }); if (entity.kind !== kind) { @@ -1144,8 +1521,14 @@ function readSoundEmitterEntity(value: unknown, label: string): EntityInstance { ? undefined : expectString(value.audioAssetId, `${label}.audioAssetId`), volume: expectNonNegativeFiniteNumber(value.volume, `${label}.volume`), - refDistance: expectPositiveFiniteNumber(value.refDistance, `${label}.refDistance`), - maxDistance: expectPositiveFiniteNumber(value.maxDistance, `${label}.maxDistance`), + refDistance: expectPositiveFiniteNumber( + value.refDistance, + `${label}.refDistance` + ), + maxDistance: expectPositiveFiniteNumber( + value.maxDistance, + `${label}.maxDistance` + ), autoplay: expectBoolean(value.autoplay, `${label}.autoplay`), loop: expectBoolean(value.loop, `${label}.loop`) }); @@ -1157,7 +1540,10 @@ function readSoundEmitterEntity(value: unknown, label: string): EntityInstance { return entity; } -function readLegacySoundEmitterEntity(value: unknown, label: string): EntityInstance { +function readLegacySoundEmitterEntity( + value: unknown, + label: string +): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } @@ -1182,12 +1568,19 @@ function readLegacySoundEmitterEntity(value: unknown, label: string): EntityInst return entity; } -function readTriggerVolumeEntity(value: unknown, label: string): EntityInstance { +function readTriggerVolumeEntity( + value: unknown, + label: string +): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } - const kind = expectLiteralString(value.kind, "triggerVolume", `${label}.kind`); + const kind = expectLiteralString( + value.kind, + "triggerVolume", + `${label}.kind` + ); const size = readVec3(value.size, `${label}.size`); if (size.x <= 0 || size.y <= 0 || size.z <= 0) { @@ -1199,7 +1592,10 @@ function readTriggerVolumeEntity(value: unknown, label: string): EntityInstance name: readOptionalEntityName(value.name, `${label}.name`), position: readVec3(value.position, `${label}.position`), size, - triggerOnEnter: expectBoolean(value.triggerOnEnter, `${label}.triggerOnEnter`), + triggerOnEnter: expectBoolean( + value.triggerOnEnter, + `${label}.triggerOnEnter` + ), triggerOnExit: expectBoolean(value.triggerOnExit, `${label}.triggerOnExit`) }); @@ -1210,12 +1606,19 @@ function readTriggerVolumeEntity(value: unknown, label: string): EntityInstance return entity; } -function readTeleportTargetEntity(value: unknown, label: string): EntityInstance { +function readTeleportTargetEntity( + value: unknown, + label: string +): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } - const kind = expectLiteralString(value.kind, "teleportTarget", `${label}.kind`); + const kind = expectLiteralString( + value.kind, + "teleportTarget", + `${label}.kind` + ); const entity = createTeleportTargetEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), @@ -1252,7 +1655,11 @@ function readInteractableEntity(value: unknown, label: string): EntityInstance { return entity; } -function readEntityInstance(value: unknown, label: string, options: { legacySoundEmitter: boolean }): EntityInstance { +function readEntityInstance( + value: unknown, + label: string, + options: { legacySoundEmitter: boolean } +): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } @@ -1265,7 +1672,9 @@ function readEntityInstance(value: unknown, label: string, options: { legacySoun case "playerStart": return readPlayerStartEntity(value, label); case "soundEmitter": - return options.legacySoundEmitter ? readLegacySoundEmitterEntity(value, label) : readSoundEmitterEntity(value, label); + return options.legacySoundEmitter + ? readLegacySoundEmitterEntity(value, label) + : readSoundEmitterEntity(value, label); case "triggerVolume": return readTriggerVolumeEntity(value, label); case "teleportTarget": @@ -1277,7 +1686,10 @@ function readEntityInstance(value: unknown, label: string, options: { legacySoun } } -function readEntities(value: unknown, options: { legacySoundEmitter: boolean }): SceneDocument["entities"] { +function readEntities( + value: unknown, + options: { legacySoundEmitter: boolean } +): SceneDocument["entities"] { if (!isRecord(value)) { throw new Error("entities must be a record."); } @@ -1289,7 +1701,11 @@ function readEntities(value: unknown, options: { legacySoundEmitter: boolean }): throw new Error(`entities.${entityId} must be an object.`); } - const entity = readEntityInstance(entityValue, `entities.${entityId}`, options); + const entity = readEntityInstance( + entityValue, + `entities.${entityId}`, + options + ); if (entity.id !== entityId) { throw new Error(`entities.${entityId}.id must match the registry key.`); @@ -1301,7 +1717,10 @@ function readEntities(value: unknown, options: { legacySoundEmitter: boolean }): return entities; } -function readInteractionAction(value: unknown, label: string): InteractionLink["action"] { +function readInteractionAction( + value: unknown, + label: string +): InteractionLink["action"] { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } @@ -1310,19 +1729,28 @@ function readInteractionAction(value: unknown, label: string): InteractionLink[" case "teleportPlayer": return createTeleportPlayerInteractionLink({ sourceEntityId: "interaction-source-placeholder", - targetEntityId: expectString(value.targetEntityId, `${label}.targetEntityId`) + targetEntityId: expectString( + value.targetEntityId, + `${label}.targetEntityId` + ) }).action; case "toggleVisibility": return createToggleVisibilityInteractionLink({ sourceEntityId: "interaction-source-placeholder", - targetBrushId: expectString(value.targetBrushId, `${label}.targetBrushId`), + targetBrushId: expectString( + value.targetBrushId, + `${label}.targetBrushId` + ), visible: value.visible === undefined ? undefined : expectBoolean(value.visible, `${label}.visible`) }).action; case "playAnimation": { - const targetModelInstanceId = expectString(value.targetModelInstanceId, `${label}.targetModelInstanceId`); + const targetModelInstanceId = expectString( + value.targetModelInstanceId, + `${label}.targetModelInstanceId` + ); if (targetModelInstanceId.trim().length === 0) { throw new Error(`${label}.targetModelInstanceId must be non-empty.`); } @@ -1334,11 +1762,17 @@ function readInteractionAction(value: unknown, label: string): InteractionLink[" sourceEntityId: "interaction-source-placeholder", targetModelInstanceId, clipName, - loop: value.loop === undefined ? undefined : expectBoolean(value.loop, `${label}.loop`) + loop: + value.loop === undefined + ? undefined + : expectBoolean(value.loop, `${label}.loop`) }).action; } case "stopAnimation": { - const targetModelInstanceId = expectString(value.targetModelInstanceId, `${label}.targetModelInstanceId`); + const targetModelInstanceId = expectString( + value.targetModelInstanceId, + `${label}.targetModelInstanceId` + ); if (targetModelInstanceId.trim().length === 0) { throw new Error(`${label}.targetModelInstanceId must be non-empty.`); } @@ -1348,7 +1782,10 @@ function readInteractionAction(value: unknown, label: string): InteractionLink[" }).action; } case "playSound": { - const targetSoundEmitterId = expectString(value.targetSoundEmitterId, `${label}.targetSoundEmitterId`); + const targetSoundEmitterId = expectString( + value.targetSoundEmitterId, + `${label}.targetSoundEmitterId` + ); if (targetSoundEmitterId.trim().length === 0) { throw new Error(`${label}.targetSoundEmitterId must be non-empty.`); } @@ -1358,7 +1795,10 @@ function readInteractionAction(value: unknown, label: string): InteractionLink[" }).action; } case "stopSound": { - const targetSoundEmitterId = expectString(value.targetSoundEmitterId, `${label}.targetSoundEmitterId`); + const targetSoundEmitterId = expectString( + value.targetSoundEmitterId, + `${label}.targetSoundEmitterId` + ); if (targetSoundEmitterId.trim().length === 0) { throw new Error(`${label}.targetSoundEmitterId must be non-empty.`); } @@ -1380,7 +1820,9 @@ function readInteractionLink(value: unknown, label: string): InteractionLink { const trigger = expectString(value.trigger, `${label}.trigger`); if (!isInteractionTriggerKind(trigger)) { - throw new Error(`${label}.trigger must be a supported interaction trigger.`); + throw new Error( + `${label}.trigger must be a supported interaction trigger.` + ); } const action = readInteractionAction(value.action, `${label}.action`); @@ -1389,14 +1831,20 @@ function readInteractionLink(value: unknown, label: string): InteractionLink { case "teleportPlayer": return createTeleportPlayerInteractionLink({ id: expectString(value.id, `${label}.id`), - sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + sourceEntityId: expectString( + value.sourceEntityId, + `${label}.sourceEntityId` + ), trigger, targetEntityId: action.targetEntityId }); case "toggleVisibility": return createToggleVisibilityInteractionLink({ id: expectString(value.id, `${label}.id`), - sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + sourceEntityId: expectString( + value.sourceEntityId, + `${label}.sourceEntityId` + ), trigger, targetBrushId: action.targetBrushId, visible: action.visible @@ -1404,7 +1852,10 @@ function readInteractionLink(value: unknown, label: string): InteractionLink { case "playAnimation": return createPlayAnimationInteractionLink({ id: expectString(value.id, `${label}.id`), - sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + sourceEntityId: expectString( + value.sourceEntityId, + `${label}.sourceEntityId` + ), trigger, targetModelInstanceId: action.targetModelInstanceId, clipName: action.clipName, @@ -1413,28 +1864,39 @@ function readInteractionLink(value: unknown, label: string): InteractionLink { case "stopAnimation": return createStopAnimationInteractionLink({ id: expectString(value.id, `${label}.id`), - sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + sourceEntityId: expectString( + value.sourceEntityId, + `${label}.sourceEntityId` + ), trigger, targetModelInstanceId: action.targetModelInstanceId }); case "playSound": return createPlaySoundInteractionLink({ id: expectString(value.id, `${label}.id`), - sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + sourceEntityId: expectString( + value.sourceEntityId, + `${label}.sourceEntityId` + ), trigger, targetSoundEmitterId: action.targetSoundEmitterId }); case "stopSound": return createStopSoundInteractionLink({ id: expectString(value.id, `${label}.id`), - sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`), + sourceEntityId: expectString( + value.sourceEntityId, + `${label}.sourceEntityId` + ), trigger, targetSoundEmitterId: action.targetSoundEmitterId }); } } -function readInteractionLinks(value: unknown): SceneDocument["interactionLinks"] { +function readInteractionLinks( + value: unknown +): SceneDocument["interactionLinks"] { if (!isRecord(value)) { throw new Error("interactionLinks must be a record."); } @@ -1442,10 +1904,15 @@ function readInteractionLinks(value: unknown): SceneDocument["interactionLinks"] const interactionLinks: SceneDocument["interactionLinks"] = {}; for (const [linkId, linkValue] of Object.entries(value)) { - const interactionLink = readInteractionLink(linkValue, `interactionLinks.${linkId}`); + const interactionLink = readInteractionLink( + linkValue, + `interactionLinks.${linkId}` + ); if (interactionLink.id !== linkId) { - throw new Error(`interactionLinks.${linkId}.id must match the registry key.`); + throw new Error( + `interactionLinks.${linkId}.id must match the registry key.` + ); } interactionLinks[linkId] = interactionLink; @@ -1471,9 +1938,15 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: {}, - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: expectEmptyCollection(source.entities, "entities"), - interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + interactionLinks: expectEmptyCollection( + source.interactionLinks, + "interactionLinks" + ) }; } @@ -1489,9 +1962,15 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, true), - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: expectEmptyCollection(source.entities, "entities"), - interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + interactionLinks: expectEmptyCollection( + source.interactionLinks, + "interactionLinks" + ) }; } @@ -1506,9 +1985,15 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: expectEmptyCollection(source.entities, "entities"), - interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + interactionLinks: expectEmptyCollection( + source.interactionLinks, + "interactionLinks" + ) }; } @@ -1523,9 +2008,15 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: readEntities(source.entities, { legacySoundEmitter: false }), - interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + interactionLinks: expectEmptyCollection( + source.interactionLinks, + "interactionLinks" + ) }; } @@ -1540,9 +2031,15 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: readEntities(source.entities, { legacySoundEmitter: false }), - interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + interactionLinks: expectEmptyCollection( + source.interactionLinks, + "interactionLinks" + ) }; } @@ -1557,9 +2054,15 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: readEntities(source.entities, { legacySoundEmitter: false }), - interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + interactionLinks: expectEmptyCollection( + source.interactionLinks, + "interactionLinks" + ) }; } @@ -1574,13 +2077,21 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: readEntities(source.entities, { legacySoundEmitter: false }), - interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + interactionLinks: expectEmptyCollection( + source.interactionLinks, + "interactionLinks" + ) }; } - if (source.version === TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION) { + if ( + source.version === TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION + ) { const materials = readMaterialRegistry(source.materials, "materials"); return { @@ -1591,7 +2102,10 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; @@ -1608,7 +2122,10 @@ export function migrateSceneDocument(source: unknown): SceneDocument { textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), - modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + modelInstances: expectEmptyCollection( + source.modelInstances, + "modelInstances" + ), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; @@ -1709,7 +2226,9 @@ export function migrateSceneDocument(source: unknown): SceneDocument { } // v17 -> v18: box-based whitebox solids gained authored object rotation. - if (source.version === PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION) { + if ( + source.version === PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION + ) { const materials = readMaterialRegistry(source.materials, "materials"); const assets = readAssets(source.assets); @@ -1773,7 +2292,9 @@ export function migrateSceneDocument(source: unknown): SceneDocument { source.version !== WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION ) { - throw new Error(`Unsupported scene document version: ${String(source.version)}.`); + throw new Error( + `Unsupported scene document version: ${String(source.version)}.` + ); } const materials = readMaterialRegistry(source.materials, "materials"); @@ -1807,9 +2328,13 @@ function readProjectScene( return { id: expectString(value.id, `${label}.id`), name: expectString(value.name, `${label}.name`), - loadingScreen: readSceneLoadingScreen(value.loadingScreen, `${label}.loadingScreen`, { - allowMissing: options.allowMissingLoadingScreen - }), + loadingScreen: readSceneLoadingScreen( + value.loadingScreen, + `${label}.loadingScreen`, + { + allowMissing: options.allowMissingLoadingScreen + } + ), world: readWorldSettings(value.world), brushes: readBrushes(value.brushes, materials, false), modelInstances: readModelInstances(value.modelInstances, assets), diff --git a/src/document/scene-document-validation.ts b/src/document/scene-document-validation.ts index 45ed7f89..019d594e 100644 --- a/src/document/scene-document-validation.ts +++ b/src/document/scene-document-validation.ts @@ -78,11 +78,23 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } -function isFiniteVec3(vector: { x: unknown; y: unknown; z: unknown }): vector is { x: number; y: number; z: number } { - return isFiniteNumber(vector.x) && isFiniteNumber(vector.y) && isFiniteNumber(vector.z); +function isFiniteVec3(vector: { + x: unknown; + y: unknown; + z: unknown; +}): vector is { x: number; y: number; z: number } { + return ( + isFiniteNumber(vector.x) && + isFiniteNumber(vector.y) && + isFiniteNumber(vector.z) + ); } -function hasPositiveFiniteVec3(vector: { x: unknown; y: unknown; z: unknown }): vector is { x: number; y: number; z: number } { +function hasPositiveFiniteVec3(vector: { + x: unknown; + y: unknown; + z: unknown; +}): vector is { x: number; y: number; z: number } { return isFiniteVec3(vector) && vector.x > 0 && vector.y > 0 && vector.z > 0; } @@ -98,7 +110,10 @@ function isPositiveInteger(value: unknown): value is number { return isFiniteNumber(value) && Number.isInteger(value) && value > 0; } -function isPositiveIntegerInRange(value: unknown, max: number): value is number { +function isPositiveIntegerInRange( + value: unknown, + max: number +): value is number { return isPositiveInteger(value) && value <= max; } @@ -106,11 +121,19 @@ function isBoolean(value: unknown): value is boolean { return typeof value === "boolean"; } -function hasNonZeroVectorLength(vector: { x: number; y: number; z: number }): boolean { +function hasNonZeroVectorLength(vector: { + x: number; + y: number; + z: number; +}): boolean { return vector.x !== 0 || vector.y !== 0 || vector.z !== 0; } -function validateWorldSettings(world: WorldSettings, document: SceneDocument, diagnostics: SceneDiagnostic[]) { +function validateWorldSettings( + world: WorldSettings, + document: SceneDocument, + diagnostics: SceneDiagnostic[] +) { if (world.background.mode === "solid") { if (!isHexColorString(world.background.colorHex)) { diagnostics.push( @@ -145,7 +168,10 @@ function validateWorldSettings(world: WorldSettings, document: SceneDocument, di ); } } else { - if (typeof world.background.assetId !== "string" || world.background.assetId.trim().length === 0) { + if ( + typeof world.background.assetId !== "string" || + world.background.assetId.trim().length === 0 + ) { diagnostics.push( createDiagnostic( "error", @@ -214,7 +240,12 @@ function validateWorldSettings(world: WorldSettings, document: SceneDocument, di if (!isHexColorString(world.sunLight.colorHex)) { diagnostics.push( - createDiagnostic("error", "invalid-world-sun-color", "World sun color must use a #RRGGBB color.", "world.sunLight.colorHex") + createDiagnostic( + "error", + "invalid-world-sun-color", + "World sun color must use a #RRGGBB color.", + "world.sunLight.colorHex" + ) ); } @@ -229,7 +260,10 @@ function validateWorldSettings(world: WorldSettings, document: SceneDocument, di ); } - if (!isFiniteVec3(world.sunLight.direction) || !hasNonZeroVectorLength(world.sunLight.direction)) { + if ( + !isFiniteVec3(world.sunLight.direction) || + !hasNonZeroVectorLength(world.sunLight.direction) + ) { diagnostics.push( createDiagnostic( "error", @@ -308,7 +342,9 @@ function validateWorldSettings(world: WorldSettings, document: SceneDocument, di ); } - if (!isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.intensity)) { + if ( + !isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.intensity) + ) { diagnostics.push( createDiagnostic( "error", @@ -418,7 +454,9 @@ function validateWorldSettings(world: WorldSettings, document: SceneDocument, di ); } - if (!isNonNegativeFiniteNumber(advancedRendering.depthOfField.focusDistance)) { + if ( + !isNonNegativeFiniteNumber(advancedRendering.depthOfField.focusDistance) + ) { diagnostics.push( createDiagnostic( "error", @@ -473,7 +511,11 @@ function validateWorldSettings(world: WorldSettings, document: SceneDocument, di ); } - if (!isAdvancedRenderingWaterReflectionMode(advancedRendering.waterReflectionMode)) { + if ( + !isAdvancedRenderingWaterReflectionMode( + advancedRendering.waterReflectionMode + ) + ) { diagnostics.push( createDiagnostic( "error", @@ -485,47 +527,132 @@ function validateWorldSettings(world: WorldSettings, document: SceneDocument, di } } -function validatePointLightEntity(entity: PointLightEntity, path: string, diagnostics: SceneDiagnostic[]) { +function validatePointLightEntity( + entity: PointLightEntity, + path: string, + diagnostics: SceneDiagnostic[] +) { if (!isFiniteVec3(entity.position)) { - diagnostics.push(createDiagnostic("error", "invalid-point-light-position", "Point Light position must remain finite on every axis.", `${path}.position`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-point-light-position", + "Point Light position must remain finite on every axis.", + `${path}.position` + ) + ); } if (!isHexColorString(entity.colorHex)) { - diagnostics.push(createDiagnostic("error", "invalid-point-light-color", "Point Light color must use a #RRGGBB color.", `${path}.colorHex`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-point-light-color", + "Point Light color must use a #RRGGBB color.", + `${path}.colorHex` + ) + ); } if (!isNonNegativeFiniteNumber(entity.intensity)) { - diagnostics.push(createDiagnostic("error", "invalid-point-light-intensity", "Point Light intensity must remain finite and zero or greater.", `${path}.intensity`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-point-light-intensity", + "Point Light intensity must remain finite and zero or greater.", + `${path}.intensity` + ) + ); } if (!isPositiveFiniteNumber(entity.distance)) { - diagnostics.push(createDiagnostic("error", "invalid-point-light-distance", "Point Light distance must remain finite and greater than zero.", `${path}.distance`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-point-light-distance", + "Point Light distance must remain finite and greater than zero.", + `${path}.distance` + ) + ); } } -function validateSpotLightEntity(entity: SpotLightEntity, path: string, diagnostics: SceneDiagnostic[]) { +function validateSpotLightEntity( + entity: SpotLightEntity, + path: string, + diagnostics: SceneDiagnostic[] +) { if (!isFiniteVec3(entity.position)) { - diagnostics.push(createDiagnostic("error", "invalid-spot-light-position", "Spot Light position must remain finite on every axis.", `${path}.position`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-spot-light-position", + "Spot Light position must remain finite on every axis.", + `${path}.position` + ) + ); } - if (!isFiniteVec3(entity.direction) || !hasNonZeroVectorLength(entity.direction)) { - diagnostics.push(createDiagnostic("error", "invalid-spot-light-direction", "Spot Light direction must remain finite and must not be the zero vector.", `${path}.direction`)); + if ( + !isFiniteVec3(entity.direction) || + !hasNonZeroVectorLength(entity.direction) + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-spot-light-direction", + "Spot Light direction must remain finite and must not be the zero vector.", + `${path}.direction` + ) + ); } if (!isHexColorString(entity.colorHex)) { - diagnostics.push(createDiagnostic("error", "invalid-spot-light-color", "Spot Light color must use a #RRGGBB color.", `${path}.colorHex`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-spot-light-color", + "Spot Light color must use a #RRGGBB color.", + `${path}.colorHex` + ) + ); } if (!isNonNegativeFiniteNumber(entity.intensity)) { - diagnostics.push(createDiagnostic("error", "invalid-spot-light-intensity", "Spot Light intensity must remain finite and zero or greater.", `${path}.intensity`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-spot-light-intensity", + "Spot Light intensity must remain finite and zero or greater.", + `${path}.intensity` + ) + ); } if (!isPositiveFiniteNumber(entity.distance)) { - diagnostics.push(createDiagnostic("error", "invalid-spot-light-distance", "Spot Light distance must remain finite and greater than zero.", `${path}.distance`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-spot-light-distance", + "Spot Light distance must remain finite and greater than zero.", + `${path}.distance` + ) + ); } - if (!isFiniteNumber(entity.angleDegrees) || entity.angleDegrees <= 0 || entity.angleDegrees >= 180) { - diagnostics.push(createDiagnostic("error", "invalid-spot-light-angle", "Spot Light angle must remain a finite degree value between 0 and 180.", `${path}.angleDegrees`)); + if ( + !isFiniteNumber(entity.angleDegrees) || + entity.angleDegrees <= 0 || + entity.angleDegrees >= 180 + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-spot-light-angle", + "Spot Light angle must remain a finite degree value between 0 and 180.", + `${path}.angleDegrees` + ) + ); } } @@ -539,14 +666,33 @@ function validateProjectAssetBoundingBox( } if (!isFiniteVec3(boundingBox.min)) { - diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-min", "Model asset bounding boxes must have finite minimum coordinates.", `${path}.min`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-asset-bounding-box-min", + "Model asset bounding boxes must have finite minimum coordinates.", + `${path}.min` + ) + ); } if (!isFiniteVec3(boundingBox.max)) { - diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-max", "Model asset bounding boxes must have finite maximum coordinates.", `${path}.max`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-asset-bounding-box-max", + "Model asset bounding boxes must have finite maximum coordinates.", + `${path}.max` + ) + ); } - if (!isFiniteVec3(boundingBox.size) || boundingBox.size.x < 0 || boundingBox.size.y < 0 || boundingBox.size.z < 0) { + if ( + !isFiniteVec3(boundingBox.size) || + boundingBox.size.x < 0 || + boundingBox.size.y < 0 || + boundingBox.size.z < 0 + ) { diagnostics.push( createDiagnostic( "error", @@ -558,62 +704,180 @@ function validateProjectAssetBoundingBox( } } -function validateModelAssetMetadata(metadata: ModelAssetMetadata, path: string, diagnostics: SceneDiagnostic[]) { +function validateModelAssetMetadata( + metadata: ModelAssetMetadata, + path: string, + diagnostics: SceneDiagnostic[] +) { if (metadata.format !== "glb" && metadata.format !== "gltf") { - diagnostics.push(createDiagnostic("error", "invalid-model-asset-format", "Model asset format must be glb or gltf.", `${path}.format`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-asset-format", + "Model asset format must be glb or gltf.", + `${path}.format` + ) + ); } if (metadata.sceneName !== null && metadata.sceneName.trim().length === 0) { - diagnostics.push(createDiagnostic("error", "invalid-model-asset-scene-name", "Model asset scene names must be non-empty when authored.", `${path}.sceneName`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-asset-scene-name", + "Model asset scene names must be non-empty when authored.", + `${path}.sceneName` + ) + ); } if (!isNonNegativeFiniteNumber(metadata.nodeCount)) { - diagnostics.push(createDiagnostic("error", "invalid-model-asset-node-count", "Model asset node counts must be finite and zero or greater.", `${path}.nodeCount`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-asset-node-count", + "Model asset node counts must be finite and zero or greater.", + `${path}.nodeCount` + ) + ); } if (!isNonNegativeFiniteNumber(metadata.meshCount)) { - diagnostics.push(createDiagnostic("error", "invalid-model-asset-mesh-count", "Model asset mesh counts must be finite and zero or greater.", `${path}.meshCount`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-asset-mesh-count", + "Model asset mesh counts must be finite and zero or greater.", + `${path}.meshCount` + ) + ); } - if (!Array.isArray(metadata.materialNames) || metadata.materialNames.some((name) => typeof name !== "string")) { - diagnostics.push(createDiagnostic("error", "invalid-model-asset-material-names", "Model asset material names must be string arrays.", `${path}.materialNames`)); + if ( + !Array.isArray(metadata.materialNames) || + metadata.materialNames.some((name) => typeof name !== "string") + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-asset-material-names", + "Model asset material names must be string arrays.", + `${path}.materialNames` + ) + ); } - if (!Array.isArray(metadata.textureNames) || metadata.textureNames.some((name) => typeof name !== "string")) { - diagnostics.push(createDiagnostic("error", "invalid-model-asset-texture-names", "Model asset texture names must be string arrays.", `${path}.textureNames`)); + if ( + !Array.isArray(metadata.textureNames) || + metadata.textureNames.some((name) => typeof name !== "string") + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-asset-texture-names", + "Model asset texture names must be string arrays.", + `${path}.textureNames` + ) + ); } - if (!Array.isArray(metadata.animationNames) || metadata.animationNames.some((name) => typeof name !== "string")) { - diagnostics.push(createDiagnostic("error", "invalid-model-asset-animation-names", "Model asset animation names must be string arrays.", `${path}.animationNames`)); + if ( + !Array.isArray(metadata.animationNames) || + metadata.animationNames.some((name) => typeof name !== "string") + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-asset-animation-names", + "Model asset animation names must be string arrays.", + `${path}.animationNames` + ) + ); } - validateProjectAssetBoundingBox(metadata.boundingBox, `${path}.boundingBox`, diagnostics); + validateProjectAssetBoundingBox( + metadata.boundingBox, + `${path}.boundingBox`, + diagnostics + ); - if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) { - diagnostics.push(createDiagnostic("error", "invalid-model-asset-warnings", "Model asset warnings must be string arrays.", `${path}.warnings`)); + if ( + !Array.isArray(metadata.warnings) || + metadata.warnings.some((warning) => typeof warning !== "string") + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-asset-warnings", + "Model asset warnings must be string arrays.", + `${path}.warnings` + ) + ); } } -function validateImageAssetMetadata(metadata: ImageAssetMetadata, path: string, diagnostics: SceneDiagnostic[]) { +function validateImageAssetMetadata( + metadata: ImageAssetMetadata, + path: string, + diagnostics: SceneDiagnostic[] +) { if (!isPositiveFiniteNumber(metadata.width)) { - diagnostics.push(createDiagnostic("error", "invalid-image-asset-width", "Image asset width must be finite and greater than zero.", `${path}.width`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-image-asset-width", + "Image asset width must be finite and greater than zero.", + `${path}.width` + ) + ); } if (!isPositiveFiniteNumber(metadata.height)) { - diagnostics.push(createDiagnostic("error", "invalid-image-asset-height", "Image asset height must be finite and greater than zero.", `${path}.height`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-image-asset-height", + "Image asset height must be finite and greater than zero.", + `${path}.height` + ) + ); } if (!isBoolean(metadata.hasAlpha)) { - diagnostics.push(createDiagnostic("error", "invalid-image-asset-alpha", "Image asset alpha flags must be booleans.", `${path}.hasAlpha`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-image-asset-alpha", + "Image asset alpha flags must be booleans.", + `${path}.hasAlpha` + ) + ); } - if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) { - diagnostics.push(createDiagnostic("error", "invalid-image-asset-warnings", "Image asset warnings must be string arrays.", `${path}.warnings`)); + if ( + !Array.isArray(metadata.warnings) || + metadata.warnings.some((warning) => typeof warning !== "string") + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-image-asset-warnings", + "Image asset warnings must be string arrays.", + `${path}.warnings` + ) + ); } } -function validateAudioAssetMetadata(metadata: AudioAssetMetadata, path: string, diagnostics: SceneDiagnostic[]) { - if (metadata.durationSeconds !== null && !isNonNegativeFiniteNumber(metadata.durationSeconds)) { +function validateAudioAssetMetadata( + metadata: AudioAssetMetadata, + path: string, + diagnostics: SceneDiagnostic[] +) { + if ( + metadata.durationSeconds !== null && + !isNonNegativeFiniteNumber(metadata.durationSeconds) + ) { diagnostics.push( createDiagnostic( "error", @@ -624,7 +888,10 @@ function validateAudioAssetMetadata(metadata: AudioAssetMetadata, path: string, ); } - if (metadata.channelCount !== null && !isPositiveFiniteNumber(metadata.channelCount)) { + if ( + metadata.channelCount !== null && + !isPositiveFiniteNumber(metadata.channelCount) + ) { diagnostics.push( createDiagnostic( "error", @@ -635,7 +902,10 @@ function validateAudioAssetMetadata(metadata: AudioAssetMetadata, path: string, ); } - if (metadata.sampleRateHz !== null && !isPositiveFiniteNumber(metadata.sampleRateHz)) { + if ( + metadata.sampleRateHz !== null && + !isPositiveFiniteNumber(metadata.sampleRateHz) + ) { diagnostics.push( createDiagnostic( "error", @@ -646,56 +916,146 @@ function validateAudioAssetMetadata(metadata: AudioAssetMetadata, path: string, ); } - if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) { - diagnostics.push(createDiagnostic("error", "invalid-audio-asset-warnings", "Audio asset warnings must be string arrays.", `${path}.warnings`)); + if ( + !Array.isArray(metadata.warnings) || + metadata.warnings.some((warning) => typeof warning !== "string") + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-audio-asset-warnings", + "Audio asset warnings must be string arrays.", + `${path}.warnings` + ) + ); } } -function validateProjectAsset(asset: ProjectAssetRecord, path: string, diagnostics: SceneDiagnostic[]) { +function validateProjectAsset( + asset: ProjectAssetRecord, + path: string, + diagnostics: SceneDiagnostic[] +) { if (asset.sourceName.trim().length === 0) { - diagnostics.push(createDiagnostic("error", "invalid-asset-source-name", "Asset source names must be non-empty strings.", `${path}.sourceName`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-asset-source-name", + "Asset source names must be non-empty strings.", + `${path}.sourceName` + ) + ); } if (asset.mimeType.trim().length === 0) { - diagnostics.push(createDiagnostic("error", "invalid-asset-mime-type", "Asset mime types must be non-empty strings.", `${path}.mimeType`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-asset-mime-type", + "Asset mime types must be non-empty strings.", + `${path}.mimeType` + ) + ); } if (asset.storageKey.trim().length === 0) { - diagnostics.push(createDiagnostic("error", "invalid-asset-storage-key", "Asset storage keys must be non-empty strings.", `${path}.storageKey`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-asset-storage-key", + "Asset storage keys must be non-empty strings.", + `${path}.storageKey` + ) + ); } if (!isPositiveFiniteNumber(asset.byteLength)) { - diagnostics.push(createDiagnostic("error", "invalid-asset-byte-length", "Asset byte lengths must be finite and greater than zero.", `${path}.byteLength`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-asset-byte-length", + "Asset byte lengths must be finite and greater than zero.", + `${path}.byteLength` + ) + ); } switch (asset.kind) { case "model": - validateModelAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics); + validateModelAssetMetadata( + asset.metadata, + `${path}.metadata`, + diagnostics + ); break; case "image": - validateImageAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics); + validateImageAssetMetadata( + asset.metadata, + `${path}.metadata`, + diagnostics + ); break; case "audio": - validateAudioAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics); + validateAudioAssetMetadata( + asset.metadata, + `${path}.metadata`, + diagnostics + ); break; } } -function validateModelInstance(modelInstance: ModelInstance, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[]) { - if (modelInstance.name !== undefined && modelInstance.name.trim().length === 0) { - diagnostics.push(createDiagnostic("error", "invalid-model-instance-name", "Model instance names must be non-empty when authored.", `${path}.name`)); +function validateModelInstance( + modelInstance: ModelInstance, + path: string, + document: SceneDocument, + diagnostics: SceneDiagnostic[] +) { + if ( + modelInstance.name !== undefined && + modelInstance.name.trim().length === 0 + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-instance-name", + "Model instance names must be non-empty when authored.", + `${path}.name` + ) + ); } if (!isFiniteVec3(modelInstance.position)) { - diagnostics.push(createDiagnostic("error", "invalid-model-instance-position", "Model instance positions must remain finite on every axis.", `${path}.position`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-instance-position", + "Model instance positions must remain finite on every axis.", + `${path}.position` + ) + ); } if (!isFiniteVec3(modelInstance.rotationDegrees)) { - diagnostics.push(createDiagnostic("error", "invalid-model-instance-rotation", "Model instance rotations must remain finite on every axis.", `${path}.rotationDegrees`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-instance-rotation", + "Model instance rotations must remain finite on every axis.", + `${path}.rotationDegrees` + ) + ); } if (!hasPositiveFiniteVec3(modelInstance.scale)) { - diagnostics.push(createDiagnostic("error", "invalid-model-instance-scale", "Model instance scales must remain finite and positive on every axis.", `${path}.scale`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-model-instance-scale", + "Model instance scales must remain finite and positive on every axis.", + `${path}.scale` + ) + ); } if (!isModelInstanceCollisionMode(modelInstance.collision.mode)) { @@ -724,7 +1084,12 @@ function validateModelInstance(modelInstance: ModelInstance, path: string, docum if (asset === undefined) { diagnostics.push( - createDiagnostic("error", "missing-model-instance-asset", `Model instance asset ${modelInstance.assetId} does not exist.`, `${path}.assetId`) + createDiagnostic( + "error", + "missing-model-instance-asset", + `Model instance asset ${modelInstance.assetId} does not exist.`, + `${path}.assetId` + ) ); return; } @@ -741,21 +1106,48 @@ function validateModelInstance(modelInstance: ModelInstance, path: string, docum } } -function validateEntityName(name: string | undefined, path: string, diagnostics: SceneDiagnostic[]) { +function validateEntityName( + name: string | undefined, + path: string, + diagnostics: SceneDiagnostic[] +) { if (name !== undefined && name.trim().length === 0) { - diagnostics.push(createDiagnostic("error", "invalid-entity-name", "Entity names must be non-empty when authored.", `${path}.name`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-entity-name", + "Entity names must be non-empty when authored.", + `${path}.name` + ) + ); } } -function validatePlayerStartEntity(entity: PlayerStartEntity, path: string, diagnostics: SceneDiagnostic[]) { +function validatePlayerStartEntity( + entity: PlayerStartEntity, + path: string, + diagnostics: SceneDiagnostic[] +) { if (!isFiniteVec3(entity.position)) { diagnostics.push( - createDiagnostic("error", "invalid-player-start-position", "Player Start position must remain finite on every axis.", `${path}.position`) + createDiagnostic( + "error", + "invalid-player-start-position", + "Player Start position must remain finite on every axis.", + `${path}.position` + ) ); } if (!isFiniteNumber(entity.yawDegrees)) { - diagnostics.push(createDiagnostic("error", "invalid-player-start-yaw", "Player Start yaw must remain a finite number.", `${path}.yawDegrees`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-player-start-yaw", + "Player Start yaw must remain a finite number.", + `${path}.yawDegrees` + ) + ); } if (!isPlayerStartColliderMode(entity.collider.mode)) { @@ -769,7 +1161,10 @@ function validatePlayerStartEntity(entity: PlayerStartEntity, path: string, diag ); } - if (!isFiniteNumber(entity.collider.eyeHeight) || entity.collider.eyeHeight <= 0) { + if ( + !isFiniteNumber(entity.collider.eyeHeight) || + entity.collider.eyeHeight <= 0 + ) { diagnostics.push( createDiagnostic( "error", @@ -780,7 +1175,10 @@ function validatePlayerStartEntity(entity: PlayerStartEntity, path: string, diag ); } - if (!isFiniteNumber(entity.collider.capsuleRadius) || entity.collider.capsuleRadius <= 0) { + if ( + !isFiniteNumber(entity.collider.capsuleRadius) || + entity.collider.capsuleRadius <= 0 + ) { diagnostics.push( createDiagnostic( "error", @@ -791,7 +1189,10 @@ function validatePlayerStartEntity(entity: PlayerStartEntity, path: string, diag ); } - if (!isFiniteNumber(entity.collider.capsuleHeight) || entity.collider.capsuleHeight <= 0) { + if ( + !isFiniteNumber(entity.collider.capsuleHeight) || + entity.collider.capsuleHeight <= 0 + ) { diagnostics.push( createDiagnostic( "error", @@ -893,16 +1294,31 @@ function validateSoundEmitterAudioAsset( return asset; } -function validateSoundEmitterEntity(entity: SoundEmitterEntity, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[]) { +function validateSoundEmitterEntity( + entity: SoundEmitterEntity, + path: string, + document: SceneDocument, + diagnostics: SceneDiagnostic[] +) { if (!isFiniteVec3(entity.position)) { diagnostics.push( - createDiagnostic("error", "invalid-sound-emitter-position", "Sound Emitter position must remain finite on every axis.", `${path}.position`) + createDiagnostic( + "error", + "invalid-sound-emitter-position", + "Sound Emitter position must remain finite on every axis.", + `${path}.position` + ) ); } if (!isNonNegativeFiniteNumber(entity.volume)) { diagnostics.push( - createDiagnostic("error", "invalid-sound-emitter-volume", "Sound Emitter volume must remain finite and zero or greater.", `${path}.volume`) + createDiagnostic( + "error", + "invalid-sound-emitter-volume", + "Sound Emitter volume must remain finite and zero or greater.", + `${path}.volume` + ) ); } @@ -928,7 +1344,11 @@ function validateSoundEmitterEntity(entity: SoundEmitterEntity, path: string, do ); } - if (isPositiveFiniteNumber(entity.refDistance) && isPositiveFiniteNumber(entity.maxDistance) && entity.maxDistance < entity.refDistance) { + if ( + isPositiveFiniteNumber(entity.refDistance) && + isPositiveFiniteNumber(entity.maxDistance) && + entity.maxDistance < entity.refDistance + ) { diagnostics.push( createDiagnostic( "error", @@ -941,21 +1361,48 @@ function validateSoundEmitterEntity(entity: SoundEmitterEntity, path: string, do if (!isBoolean(entity.autoplay)) { diagnostics.push( - createDiagnostic("error", "invalid-sound-emitter-autoplay", "Sound Emitter autoplay must remain a boolean.", `${path}.autoplay`) + createDiagnostic( + "error", + "invalid-sound-emitter-autoplay", + "Sound Emitter autoplay must remain a boolean.", + `${path}.autoplay` + ) ); } if (!isBoolean(entity.loop)) { - diagnostics.push(createDiagnostic("error", "invalid-sound-emitter-loop", "Sound Emitter loop must remain a boolean.", `${path}.loop`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-sound-emitter-loop", + "Sound Emitter loop must remain a boolean.", + `${path}.loop` + ) + ); } - validateSoundEmitterAudioAsset(entity, path, document, diagnostics, entity.autoplay ? "error" : "warning"); + validateSoundEmitterAudioAsset( + entity, + path, + document, + diagnostics, + entity.autoplay ? "error" : "warning" + ); } -function validateTriggerVolumeEntity(entity: TriggerVolumeEntity, path: string, diagnostics: SceneDiagnostic[]) { +function validateTriggerVolumeEntity( + entity: TriggerVolumeEntity, + path: string, + diagnostics: SceneDiagnostic[] +) { if (!isFiniteVec3(entity.position)) { diagnostics.push( - createDiagnostic("error", "invalid-trigger-volume-position", "Trigger Volume position must remain finite on every axis.", `${path}.position`) + createDiagnostic( + "error", + "invalid-trigger-volume-position", + "Trigger Volume position must remain finite on every axis.", + `${path}.position` + ) ); } @@ -993,45 +1440,90 @@ function validateTriggerVolumeEntity(entity: TriggerVolumeEntity, path: string, } } -function validateTeleportTargetEntity(entity: TeleportTargetEntity, path: string, diagnostics: SceneDiagnostic[]) { +function validateTeleportTargetEntity( + entity: TeleportTargetEntity, + path: string, + diagnostics: SceneDiagnostic[] +) { if (!isFiniteVec3(entity.position)) { diagnostics.push( - createDiagnostic("error", "invalid-teleport-target-position", "Teleport Target position must remain finite on every axis.", `${path}.position`) + createDiagnostic( + "error", + "invalid-teleport-target-position", + "Teleport Target position must remain finite on every axis.", + `${path}.position` + ) ); } if (!isFiniteNumber(entity.yawDegrees)) { diagnostics.push( - createDiagnostic("error", "invalid-teleport-target-yaw", "Teleport Target yaw must remain a finite number.", `${path}.yawDegrees`) + createDiagnostic( + "error", + "invalid-teleport-target-yaw", + "Teleport Target yaw must remain a finite number.", + `${path}.yawDegrees` + ) ); } } -function validateInteractableEntity(entity: InteractableEntity, path: string, diagnostics: SceneDiagnostic[]) { +function validateInteractableEntity( + entity: InteractableEntity, + path: string, + diagnostics: SceneDiagnostic[] +) { if (!isFiniteVec3(entity.position)) { diagnostics.push( - createDiagnostic("error", "invalid-interactable-position", "Interactable position must remain finite on every axis.", `${path}.position`) + createDiagnostic( + "error", + "invalid-interactable-position", + "Interactable position must remain finite on every axis.", + `${path}.position` + ) ); } if (!isPositiveFiniteNumber(entity.radius)) { diagnostics.push( - createDiagnostic("error", "invalid-interactable-radius", "Interactable radius must remain finite and greater than zero.", `${path}.radius`) + createDiagnostic( + "error", + "invalid-interactable-radius", + "Interactable radius must remain finite and greater than zero.", + `${path}.radius` + ) ); } if (typeof entity.prompt !== "string" || entity.prompt.trim().length === 0) { diagnostics.push( - createDiagnostic("error", "invalid-interactable-prompt", "Interactable prompt must remain a non-empty string.", `${path}.prompt`) + createDiagnostic( + "error", + "invalid-interactable-prompt", + "Interactable prompt must remain a non-empty string.", + `${path}.prompt` + ) ); } if (!isBoolean(entity.enabled)) { - diagnostics.push(createDiagnostic("error", "invalid-interactable-enabled", "Interactable enabled must remain a boolean.", `${path}.enabled`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-interactable-enabled", + "Interactable enabled must remain a boolean.", + `${path}.enabled` + ) + ); } } -function validateInteractionLink(link: InteractionLink, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[]) { +function validateInteractionLink( + link: InteractionLink, + path: string, + document: SceneDocument, + diagnostics: SceneDiagnostic[] +) { const sourceEntity = document.entities[link.sourceEntityId]; if (sourceEntity === undefined) { @@ -1046,7 +1538,10 @@ function validateInteractionLink(link: InteractionLink, path: string, document: return; } - if (sourceEntity.kind !== "triggerVolume" && sourceEntity.kind !== "interactable") { + if ( + sourceEntity.kind !== "triggerVolume" && + sourceEntity.kind !== "interactable" + ) { diagnostics.push( createDiagnostic( "error", @@ -1121,7 +1616,10 @@ function validateInteractionLink(link: InteractionLink, path: string, document: ); } - if (link.action.visible !== undefined && typeof link.action.visible !== "boolean") { + if ( + link.action.visible !== undefined && + typeof link.action.visible !== "boolean" + ) { diagnostics.push( createDiagnostic( "error", @@ -1132,56 +1630,58 @@ function validateInteractionLink(link: InteractionLink, path: string, document: ); } break; - case "playAnimation": - { - const targetModelInstance = document.modelInstances[link.action.targetModelInstanceId]; + case "playAnimation": { + const targetModelInstance = + document.modelInstances[link.action.targetModelInstanceId]; - if (targetModelInstance === undefined) { - diagnostics.push( - createDiagnostic( - "error", - "missing-play-animation-target-instance", - `Play animation target model instance ${link.action.targetModelInstanceId} does not exist.`, - `${path}.action.targetModelInstanceId` - ) - ); - return; - } - - if (link.action.clipName.trim().length === 0) { - diagnostics.push( - createDiagnostic( - "error", - "invalid-play-animation-clip-name", - "Play animation clip name must be non-empty.", - `${path}.action.clipName` - ) - ); - return; - } - - const targetAsset = document.assets[targetModelInstance.assetId]; - - if (targetAsset === undefined || targetAsset.kind !== "model") { - return; - } - - if (!targetAsset.metadata.animationNames.includes(link.action.clipName)) { - diagnostics.push( - createDiagnostic( - "error", - "missing-play-animation-clip", - `Play animation clip ${link.action.clipName} does not exist on model asset ${targetAsset.id}.`, - `${path}.action.clipName` - ) - ); - } - - break; + if (targetModelInstance === undefined) { + diagnostics.push( + createDiagnostic( + "error", + "missing-play-animation-target-instance", + `Play animation target model instance ${link.action.targetModelInstanceId} does not exist.`, + `${path}.action.targetModelInstanceId` + ) + ); + return; } + + if (link.action.clipName.trim().length === 0) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-play-animation-clip-name", + "Play animation clip name must be non-empty.", + `${path}.action.clipName` + ) + ); + return; + } + + const targetAsset = document.assets[targetModelInstance.assetId]; + + if (targetAsset === undefined || targetAsset.kind !== "model") { + return; + } + + if (!targetAsset.metadata.animationNames.includes(link.action.clipName)) { + diagnostics.push( + createDiagnostic( + "error", + "missing-play-animation-clip", + `Play animation clip ${link.action.clipName} does not exist on model asset ${targetAsset.id}.`, + `${path}.action.clipName` + ) + ); + } + + break; + } case "stopAnimation": // Validate that the target model instance exists in the document - if (document.modelInstances[link.action.targetModelInstanceId] === undefined) { + if ( + document.modelInstances[link.action.targetModelInstanceId] === undefined + ) { diagnostics.push( createDiagnostic( "error", @@ -1246,12 +1746,22 @@ function validateInteractionLink(link: InteractionLink, path: string, document: } } -function registerAuthoredId(id: string, path: string, seenIds: Map, diagnostics: SceneDiagnostic[]) { +function registerAuthoredId( + id: string, + path: string, + seenIds: Map, + diagnostics: SceneDiagnostic[] +) { const previousPath = seenIds.get(id); if (previousPath !== undefined) { diagnostics.push( - createDiagnostic("error", "duplicate-authored-id", `Duplicate authored id ${id} is already used at ${previousPath}.`, path) + createDiagnostic( + "error", + "duplicate-authored-id", + `Duplicate authored id ${id} is already used at ${previousPath}.`, + path + ) ); return; } @@ -1259,7 +1769,10 @@ function registerAuthoredId(id: string, path: string, seenIds: Map diagnostic.path === undefined || @@ -1360,22 +1875,31 @@ function validateProjectSceneLoadingScreen( } export function formatSceneDiagnostic(diagnostic: SceneDiagnostic): string { - return diagnostic.path === undefined ? diagnostic.message : `${diagnostic.path}: ${diagnostic.message}`; + return diagnostic.path === undefined + ? diagnostic.message + : `${diagnostic.path}: ${diagnostic.message}`; } -export function formatSceneDiagnosticSummary(diagnostics: SceneDiagnostic[], limit = 3): string { +export function formatSceneDiagnosticSummary( + diagnostics: SceneDiagnostic[], + limit = 3 +): string { if (diagnostics.length === 0) { return "No diagnostics."; } const visibleDiagnostics = diagnostics.slice(0, Math.max(1, limit)); - const summary = visibleDiagnostics.map((diagnostic) => formatSceneDiagnostic(diagnostic)).join("; "); + const summary = visibleDiagnostics + .map((diagnostic) => formatSceneDiagnostic(diagnostic)) + .join("; "); const remainingCount = diagnostics.length - visibleDiagnostics.length; return remainingCount > 0 ? `${summary}; +${remainingCount} more` : summary; } -export function validateSceneDocument(document: SceneDocument): SceneDocumentValidationResult { +export function validateSceneDocument( + document: SceneDocument +): SceneDocumentValidationResult { const diagnostics: SceneDiagnostic[] = []; const seenIds = new Map(); @@ -1386,7 +1910,12 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal if (material.id !== materialKey) { diagnostics.push( - createDiagnostic("error", "material-id-mismatch", "Material ids must match their registry key.", `${path}.id`) + createDiagnostic( + "error", + "material-id-mismatch", + "Material ids must match their registry key.", + `${path}.id` + ) ); } @@ -1397,7 +1926,14 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal const path = `assets.${assetKey}`; if (asset.id !== assetKey) { - diagnostics.push(createDiagnostic("error", "asset-id-mismatch", "Asset ids must match their registry key.", `${path}.id`)); + diagnostics.push( + createDiagnostic( + "error", + "asset-id-mismatch", + "Asset ids must match their registry key.", + `${path}.id` + ) + ); } registerAuthoredId(asset.id, path, seenIds, diagnostics); @@ -1408,18 +1944,37 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal const path = `brushes.${brushKey}`; if (brush.id !== brushKey) { - diagnostics.push(createDiagnostic("error", "brush-id-mismatch", "Brush ids must match their registry key.", `${path}.id`)); + diagnostics.push( + createDiagnostic( + "error", + "brush-id-mismatch", + "Brush ids must match their registry key.", + `${path}.id` + ) + ); } registerAuthoredId(brush.id, path, seenIds, diagnostics); if (brush.name !== undefined && brush.name.trim().length === 0) { - diagnostics.push(createDiagnostic("error", "invalid-box-name", "Box brush names must be non-empty when authored.", `${path}.name`)); + diagnostics.push( + createDiagnostic( + "error", + "invalid-box-name", + "Box brush names must be non-empty when authored.", + `${path}.name` + ) + ); } if (!isFiniteVec3(brush.center)) { diagnostics.push( - createDiagnostic("error", "invalid-box-center", "Box brush centers must remain finite on every axis.", `${path}.center`) + createDiagnostic( + "error", + "invalid-box-center", + "Box brush centers must remain finite on every axis.", + `${path}.center` + ) ); } @@ -1436,7 +1991,12 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal if (!isFiniteVec3(brush.size) || !hasPositiveBoxSize(brush.size)) { diagnostics.push( - createDiagnostic("error", "invalid-box-size", "Box brush sizes must remain finite and positive on every axis.", `${path}.size`) + createDiagnostic( + "error", + "invalid-box-size", + "Box brush sizes must remain finite and positive on every axis.", + `${path}.size` + ) ); } @@ -1495,7 +2055,10 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal ) ); } else { - if (typeof water.colorHex !== "string" || !isHexColorString(water.colorHex)) { + if ( + typeof water.colorHex !== "string" || + !isHexColorString(water.colorHex) + ) { diagnostics.push( createDiagnostic( "error", @@ -1528,7 +2091,12 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal ); } - if (!isPositiveIntegerInRange(water.foamContactLimit, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT)) { + if ( + !isPositiveIntegerInRange( + water.foamContactLimit, + MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT + ) + ) { diagnostics.push( createDiagnostic( "error", @@ -1565,7 +2133,10 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal ) ); } else { - if (typeof fog.colorHex !== "string" || !isHexColorString(fog.colorHex)) { + if ( + typeof fog.colorHex !== "string" || + !isHexColorString(fog.colorHex) + ) { diagnostics.push( createDiagnostic( "error", @@ -1601,12 +2172,19 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal } } - for (const [modelInstanceKey, modelInstance] of Object.entries(document.modelInstances)) { + for (const [modelInstanceKey, modelInstance] of Object.entries( + document.modelInstances + )) { const path = `modelInstances.${modelInstanceKey}`; if (modelInstance.id !== modelInstanceKey) { diagnostics.push( - createDiagnostic("error", "model-instance-id-mismatch", "Model instance ids must match their registry key.", `${path}.id`) + createDiagnostic( + "error", + "model-instance-id-mismatch", + "Model instance ids must match their registry key.", + `${path}.id` + ) ); } @@ -1618,7 +2196,14 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal const path = `entities.${entityKey}`; if (entity.id !== entityKey) { - diagnostics.push(createDiagnostic("error", "entity-id-mismatch", "Entity ids must match their registry key.", `${path}.id`)); + diagnostics.push( + createDiagnostic( + "error", + "entity-id-mismatch", + "Entity ids must match their registry key.", + `${path}.id` + ) + ); } registerAuthoredId(entity.id, path, seenIds, diagnostics); @@ -1663,7 +2248,14 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal const path = `interactionLinks.${linkKey}`; if (link.id !== linkKey) { - diagnostics.push(createDiagnostic("error", "interaction-link-id-mismatch", "Interaction link ids must match their registry key.", `${path}.id`)); + diagnostics.push( + createDiagnostic( + "error", + "interaction-link-id-mismatch", + "Interaction link ids must match their registry key.", + `${path}.id` + ) + ); } registerAuthoredId(link.id, path, seenIds, diagnostics); @@ -1673,7 +2265,9 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal return { diagnostics, errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), - warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning") + warnings: diagnostics.filter( + (diagnostic) => diagnostic.severity === "warning" + ) }; } @@ -1681,7 +2275,9 @@ export function assertSceneDocumentIsValid(document: SceneDocument) { const validation = validateSceneDocument(document); if (validation.errors.length > 0) { - throw new Error(`Scene document has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}`); + throw new Error( + `Scene document has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}` + ); } } @@ -1741,10 +2337,7 @@ export function validateProjectDocument( validateProjectSceneLoadingScreen(scene, scenePath, diagnostics); - const sceneDocument = createSceneDocumentFromProject( - document, - sceneKey - ); + const sceneDocument = createSceneDocumentFromProject(document, sceneKey); for (const diagnostic of filterProjectSceneDiagnostics( validateSceneDocument(sceneDocument).diagnostics diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index b448a239..8419fe96 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -4,8 +4,15 @@ import type { ModelInstance } from "../assets/model-instances"; import type { ProjectAssetRecord } from "../assets/project-assets"; import type { EntityInstance } from "../entities/entity-instances"; import type { InteractionLink } from "../interactions/interaction-links"; -import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library"; -import { createDefaultWorldSettings, type WorldSettings } from "./world-settings"; +import { + cloneMaterialRegistry, + createStarterMaterialRegistry, + type MaterialDef +} from "../materials/starter-material-library"; +import { + createDefaultWorldSettings, + type WorldSettings +} from "./world-settings"; export const SCENE_DOCUMENT_VERSION = 23 as const; export const MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION = 22 as const; @@ -13,7 +20,8 @@ export const WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION = 21 as const; export const WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION = 20 as const; export const WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION = 19 as const; export const WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION = 18 as const; -export const PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION = 17 as const; +export const PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION = + 17 as const; export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16 as const; export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 as const; export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const; @@ -27,7 +35,8 @@ export const RUNNER_V1_SCENE_DOCUMENT_VERSION = 4 as const; export const FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION = 5 as const; export const WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 6 as const; export const ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION = 7 as const; -export const TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION = 8 as const; +export const TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION = + 8 as const; export const RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION = 23 as const; export const DEFAULT_PROJECT_SCENE_ID = "scene-main" as const; @@ -71,12 +80,16 @@ export interface SceneDocument { interactionLinks: Record; } -export function createEmptySceneDocument(overrides: Partial> = {}): SceneDocument { +export function createEmptySceneDocument( + overrides: Partial> = {} +): SceneDocument { return { version: SCENE_DOCUMENT_VERSION, name: overrides.name ?? "Untitled Scene", world: overrides.world ?? createDefaultWorldSettings(), - materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()), + materials: cloneMaterialRegistry( + overrides.materials ?? createStarterMaterialRegistry() + ), textures: {}, assets: {}, brushes: {}, @@ -87,7 +100,9 @@ export function createEmptySceneDocument(overrides: Partial> = {} + overrides: Partial< + Pick + > = {} ): ProjectScene { return { id: overrides.id ?? createOpaqueId("scene"), @@ -113,7 +128,8 @@ export function createEmptyProjectDocument( } = {} ): ProjectDocument { const initialScene = createEmptyProjectScene({ - id: overrides.sceneId ?? overrides.activeSceneId ?? DEFAULT_PROJECT_SCENE_ID, + id: + overrides.sceneId ?? overrides.activeSceneId ?? DEFAULT_PROJECT_SCENE_ID, name: overrides.sceneName, world: overrides.world }); diff --git a/src/runner-web/RunnerCanvas.tsx b/src/runner-web/RunnerCanvas.tsx index c3dec552..49ad23e7 100644 --- a/src/runner-web/RunnerCanvas.tsx +++ b/src/runner-web/RunnerCanvas.tsx @@ -6,9 +6,15 @@ import type { LoadedImageAsset } from "../assets/image-assets"; import type { ProjectAssetRecord } from "../assets/project-assets"; import type { SceneLoadingScreenSettings } from "../document/scene-document"; import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller"; -import { RuntimeHost, type RuntimeSceneLoadState } from "../runtime-three/runtime-host"; +import { + RuntimeHost, + type RuntimeSceneLoadState +} from "../runtime-three/runtime-host"; import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system"; -import type { RuntimeNavigationMode, RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build"; +import type { + RuntimeNavigationMode, + RuntimeSceneDefinition +} from "../runtime-three/runtime-scene-build"; import { createWorldBackgroundStyle } from "../shared-ui/world-background-style"; interface RunnerCanvasProps { @@ -45,10 +51,13 @@ export function RunnerCanvas({ status: "loading", message: null }); - const [interactionPrompt, setInteractionPrompt] = useState(null); - const [firstPersonTelemetry, setFirstPersonTelemetry] = useState(null); + const [interactionPrompt, setInteractionPrompt] = + useState(null); + const [firstPersonTelemetry, setFirstPersonTelemetry] = + useState(null); const overlayMessage = runnerMessage ?? sceneLoadState.message; - const overlayStatus = overlayMessage !== null ? "error" : sceneLoadState.status; + const overlayStatus = + overlayMessage !== null ? "error" : sceneLoadState.status; const runnerReady = overlayStatus === "ready"; useEffect(() => { @@ -86,7 +95,9 @@ export function RunnerCanvas({ }; } catch (error) { const message = - error instanceof Error ? error.message : "Runner initialization failed."; + error instanceof Error + ? error.message + : "Runner initialization failed."; const failureMessage = `Runner initialization failed: ${message}`; setRunnerMessage(failureMessage); setSceneLoadState({ @@ -98,10 +109,19 @@ export function RunnerCanvas({ onFirstPersonTelemetryChange(null); return; } - }, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange]); + }, [ + onFirstPersonTelemetryChange, + onInteractionPromptChange, + onRuntimeMessageChange + ]); useEffect(() => { - hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets); + hostRef.current?.updateAssets( + projectAssets, + loadedModelAssets, + loadedImageAssets, + loadedAudioAssets + ); }, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]); useEffect(() => { @@ -116,7 +136,12 @@ export function RunnerCanvas({ onFirstPersonTelemetryChange(null); onRuntimeMessageChange(null); hostRef.current?.loadScene(runtimeScene); - }, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange, runtimeScene]); + }, [ + onFirstPersonTelemetryChange, + onInteractionPromptChange, + onRuntimeMessageChange, + runtimeScene + ]); useEffect(() => { hostRef.current?.setNavigationMode(navigationMode); @@ -131,7 +156,10 @@ export function RunnerCanvas({ aria-busy={!runnerReady} style={createWorldBackgroundStyle( runtimeScene.world.background, - runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null + runtimeScene.world.background.mode === "image" + ? (loadedImageAssets[runtimeScene.world.background.assetId] + ?.sourceUrl ?? null) + : null )} >
+
Click
-
+
{interactionPrompt.prompt}
-
- {interactionPrompt.distance.toFixed(1)}m away · {interactionPrompt.range.toFixed(1)}m range +
+ {interactionPrompt.distance.toFixed(1)}m away ·{" "} + {interactionPrompt.range.toFixed(1)}m range
) : null} {runnerMessage === null ? null : (
-
Runner Unavailable
+
+ Runner Unavailable +
{runnerMessage}
)} diff --git a/src/runtime-three/first-person-navigation-controller.ts b/src/runtime-three/first-person-navigation-controller.ts index 93204bf9..5ea3e3c8 100644 --- a/src/runtime-three/first-person-navigation-controller.ts +++ b/src/runtime-three/first-person-navigation-controller.ts @@ -3,7 +3,11 @@ import { Euler, Vector3 } from "three"; import type { Vec3 } from "../core/vector"; import { getFirstPersonPlayerEyeHeight } from "./player-collision"; -import type { NavigationController, RuntimeControllerContext, RuntimeLocomotionState } from "./navigation-controller"; +import type { + NavigationController, + RuntimeControllerContext, + RuntimeLocomotionState +} from "./navigation-controller"; const LOOK_SENSITIVITY = 0.0022; const MOVE_SPEED = 4.5; @@ -11,7 +15,10 @@ const GRAVITY = 22; const MAX_PITCH_RADIANS = Math.PI * 0.48; function clampPitch(pitchRadians: number): number { - return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians)); + return Math.max( + -MAX_PITCH_RADIANS, + Math.min(MAX_PITCH_RADIANS, pitchRadians) + ); } function toEyePosition(feetPosition: Vec3, eyeHeight: number): Vec3 { @@ -67,7 +74,10 @@ export class FirstPersonNavigationController implements NavigationController { window.addEventListener("keyup", this.handleKeyUp); window.addEventListener("blur", this.handleBlur); document.addEventListener("mousemove", this.handleMouseMove); - document.addEventListener("pointerlockchange", this.handlePointerLockChange); + document.addEventListener( + "pointerlockchange", + this.handlePointerLockChange + ); document.addEventListener("pointerlockerror", this.handlePointerLockError); ctx.domElement.addEventListener("pointerdown", this.handlePointerDown); @@ -81,8 +91,14 @@ export class FirstPersonNavigationController implements NavigationController { window.removeEventListener("keyup", this.handleKeyUp); window.removeEventListener("blur", this.handleBlur); document.removeEventListener("mousemove", this.handleMouseMove); - document.removeEventListener("pointerlockchange", this.handlePointerLockChange); - document.removeEventListener("pointerlockerror", this.handlePointerLockError); + document.removeEventListener( + "pointerlockchange", + this.handlePointerLockChange + ); + document.removeEventListener( + "pointerlockerror", + this.handlePointerLockError + ); ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown); this.pressedKeys.clear(); @@ -120,9 +136,15 @@ export class FirstPersonNavigationController implements NavigationController { } const playerShape = this.context.getRuntimeScene().playerCollider; - const currentVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition); - const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0); - const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0); + const currentVolumeState = this.context.resolvePlayerVolumeState( + this.feetPosition + ); + const inputX = + (this.pressedKeys.has("KeyD") ? 1 : 0) - + (this.pressedKeys.has("KeyA") ? 1 : 0); + const inputZ = + (this.pressedKeys.has("KeyW") ? 1 : 0) - + (this.pressedKeys.has("KeyS") ? 1 : 0); const inputLength = Math.hypot(inputX, inputZ); let horizontalX = 0; @@ -133,11 +155,25 @@ export class FirstPersonNavigationController implements NavigationController { const normalizedInputZ = inputZ / inputLength; const moveDistance = MOVE_SPEED * dt; - this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians)); - this.rightVector.set(-Math.cos(this.yawRadians), 0, Math.sin(this.yawRadians)); + this.forwardVector.set( + Math.sin(this.yawRadians), + 0, + Math.cos(this.yawRadians) + ); + this.rightVector.set( + -Math.cos(this.yawRadians), + 0, + Math.sin(this.yawRadians) + ); - horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance; - horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance; + horizontalX = + (this.forwardVector.x * normalizedInputZ + + this.rightVector.x * normalizedInputX) * + moveDistance; + horizontalZ = + (this.forwardVector.z * normalizedInputZ + + this.rightVector.z * normalizedInputX) * + moveDistance; } if (playerShape.mode === "none") { @@ -152,7 +188,10 @@ export class FirstPersonNavigationController implements NavigationController { this.feetPosition, { x: horizontalX, - y: playerShape.mode === "none" || currentVolumeState.inWater ? 0 : this.verticalVelocity * dt, + y: + playerShape.mode === "none" || currentVolumeState.inWater + ? 0 + : this.verticalVelocity * dt, z: horizontalZ }, playerShape @@ -165,7 +204,9 @@ export class FirstPersonNavigationController implements NavigationController { } this.feetPosition = resolvedMotion.feetPosition; - const nextVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition); + const nextVolumeState = this.context.resolvePlayerVolumeState( + this.feetPosition + ); this.inWaterVolume = nextVolumeState.inWater; this.inFogVolume = nextVolumeState.inFog; this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded; @@ -210,7 +251,12 @@ export class FirstPersonNavigationController implements NavigationController { return; } - const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)); + const eyePosition = toEyePosition( + this.feetPosition, + getFirstPersonPlayerEyeHeight( + this.context.getRuntimeScene().playerCollider + ) + ); this.cameraRotation.x = this.pitchRadians; // Authoring yaw treats 0 degrees as facing +Z, while a three.js camera // looks down -Z by default. Offset by 180 degrees so runtime view matches @@ -218,7 +264,11 @@ export class FirstPersonNavigationController implements NavigationController { this.cameraRotation.y = this.yawRadians + Math.PI; this.cameraRotation.z = 0; - this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z); + this.context.camera.position.set( + eyePosition.x, + eyePosition.y, + eyePosition.z + ); this.context.camera.rotation.copy(this.cameraRotation); } @@ -227,8 +277,14 @@ export class FirstPersonNavigationController implements NavigationController { return; } - const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)); - const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition); + const eyePosition = toEyePosition( + this.feetPosition, + getFirstPersonPlayerEyeHeight( + this.context.getRuntimeScene().playerCollider + ) + ); + const cameraVolumeState = + this.context.resolvePlayerVolumeState(eyePosition); this.context.setFirstPersonTelemetry({ feetPosition: { @@ -250,7 +306,8 @@ export class FirstPersonNavigationController implements NavigationController { return; } - const pointerLocked = document.pointerLockElement === this.context.domElement; + const pointerLocked = + document.pointerLockElement === this.context.domElement; this.pointerLocked = pointerLocked; this.context.setRuntimeMessage( pointerLocked @@ -278,7 +335,9 @@ export class FirstPersonNavigationController implements NavigationController { } this.yawRadians -= event.movementX * LOOK_SENSITIVITY; - this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY); + this.pitchRadians = clampPitch( + this.pitchRadians - event.movementY * LOOK_SENSITIVITY + ); }; private handlePointerLockChange = () => { @@ -292,11 +351,15 @@ export class FirstPersonNavigationController implements NavigationController { }; private handlePointerDown = () => { - if (this.context === null || document.pointerLockElement === this.context.domElement) { + if ( + this.context === null || + document.pointerLockElement === this.context.domElement + ) { return; } - const pointerLockCapableElement = this.context.domElement as HTMLCanvasElement & { + const pointerLockCapableElement = this.context + .domElement as HTMLCanvasElement & { requestPointerLock(): void | Promise; }; const pointerLockResult = pointerLockCapableElement.requestPointerLock(); diff --git a/src/runtime-three/navigation-controller.ts b/src/runtime-three/navigation-controller.ts index c2ef0a5c..f2204b67 100644 --- a/src/runtime-three/navigation-controller.ts +++ b/src/runtime-three/navigation-controller.ts @@ -2,8 +2,15 @@ import type { PerspectiveCamera } from "three"; import type { Vec3 } from "../core/vector"; -import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision"; -import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build"; +import type { + FirstPersonPlayerShape, + ResolvedPlayerMotion +} from "./player-collision"; +import type { + RuntimeNavigationMode, + RuntimeSceneDefinition, + RuntimeSpawnPoint +} from "./runtime-scene-build"; export interface FirstPersonTelemetry { feetPosition: Vec3; @@ -28,7 +35,11 @@ export interface RuntimeControllerContext { camera: PerspectiveCamera; domElement: HTMLCanvasElement; getRuntimeScene(): RuntimeSceneDefinition; - resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion | null; + resolveFirstPersonMotion( + feetPosition: Vec3, + motion: Vec3, + shape: FirstPersonPlayerShape + ): ResolvedPlayerMotion | null; resolvePlayerVolumeState(feetPosition: Vec3): RuntimePlayerVolumeState; setRuntimeMessage(message: string | null): void; setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void; diff --git a/src/runtime-three/orbit-visitor-navigation-controller.ts b/src/runtime-three/orbit-visitor-navigation-controller.ts index d0350518..6cca9341 100644 --- a/src/runtime-three/orbit-visitor-navigation-controller.ts +++ b/src/runtime-three/orbit-visitor-navigation-controller.ts @@ -2,7 +2,10 @@ import { Vector3 } from "three"; import type { Vec3 } from "../core/vector"; -import type { NavigationController, RuntimeControllerContext } from "./navigation-controller"; +import type { + NavigationController, + RuntimeControllerContext +} from "./navigation-controller"; const MIN_DISTANCE = 2; const MAX_DISTANCE = 48; @@ -48,9 +51,16 @@ export class OrbitVisitorNavigationController implements NavigationController { if (!this.initializedFromScene) { const runtimeScene = ctx.getRuntimeScene(); - const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target; + const focusPoint = + runtimeScene.playerStart?.position ?? + runtimeScene.sceneBounds?.center ?? + this.target; const focusDistance = runtimeScene.sceneBounds - ? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1 + ? Math.max( + runtimeScene.sceneBounds.size.x, + runtimeScene.sceneBounds.size.y, + runtimeScene.sceneBounds.size.z + ) * 1.1 : 8; this.target = cloneVec3(focusPoint); @@ -59,12 +69,16 @@ export class OrbitVisitorNavigationController implements NavigationController { } ctx.domElement.addEventListener("pointerdown", this.handlePointerDown); - ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false }); + ctx.domElement.addEventListener("wheel", this.handleWheel, { + passive: false + }); ctx.domElement.addEventListener("contextmenu", this.handleContextMenu); window.addEventListener("pointermove", this.handlePointerMove); window.addEventListener("pointerup", this.handlePointerUp); - ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom."); + ctx.setRuntimeMessage( + "Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom." + ); ctx.setFirstPersonTelemetry(null); this.updateCameraTransform(); } @@ -117,7 +131,11 @@ export class OrbitVisitorNavigationController implements NavigationController { z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance }; - this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z); + this.context.camera.position.set( + cameraPosition.x, + cameraPosition.y, + cameraPosition.z + ); this.lookAtVector.set(this.target.x, this.target.y, this.target.z); this.context.camera.lookAt(this.lookAtVector); } diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index 83cc0d6d..d5178c26 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -26,13 +26,19 @@ import { } from "three"; import { EffectComposer } from "postprocessing"; -import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering"; +import { + createModelInstanceRenderGroup, + disposeModelInstance +} from "../assets/model-instance-rendering"; import type { LoadedModelAsset } from "../assets/gltf-model-import"; import type { LoadedImageAsset } from "../assets/image-assets"; import type { LoadedAudioAsset } from "../assets/audio-assets"; import type { ProjectAssetRecord } from "../assets/project-assets"; import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh"; -import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; +import { + createStarterMaterialSignature, + createStarterMaterialTexture +} from "../materials/starter-material-textures"; import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, @@ -57,9 +63,18 @@ import { } from "../document/world-settings"; import { FirstPersonNavigationController } from "./first-person-navigation-controller"; -import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext, RuntimePlayerVolumeState } from "./navigation-controller"; +import type { + FirstPersonTelemetry, + NavigationController, + RuntimeControllerContext, + RuntimePlayerVolumeState +} from "./navigation-controller"; import { RapierCollisionWorld } from "./rapier-collision-world"; -import { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system"; +import { + RuntimeInteractionSystem, + type RuntimeInteractionDispatcher, + type RuntimeInteractionPrompt +} from "./runtime-interaction-system"; import { RuntimeAudioSystem } from "./runtime-audio-system"; import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller"; import { resolveUnderwaterFogState } from "./underwater-fog"; @@ -115,19 +130,32 @@ export class RuntimeHost { private readonly localLightGroup = new Group(); private readonly brushGroup = new Group(); private readonly modelGroup = new Group(); - private readonly firstPersonController = new FirstPersonNavigationController(); - private readonly orbitVisitorController = new OrbitVisitorNavigationController(); + private readonly firstPersonController = + new FirstPersonNavigationController(); + private readonly orbitVisitorController = + new OrbitVisitorNavigationController(); private readonly interactionSystem = new RuntimeInteractionSystem(); - private readonly audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null); + private readonly audioSystem = new RuntimeAudioSystem( + this.scene, + this.camera, + null + ); private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03); private readonly waterReflectionCamera = new PerspectiveCamera(); - private readonly brushMeshes = new Map>(); + private readonly brushMeshes = new Map< + string, + Mesh + >(); private volumeTime = 0; private readonly volumeAnimatedUniforms: Array<{ value: number }> = []; - private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = []; + private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = + []; private readonly localLightObjects = new Map(); private readonly modelRenderObjects = new Map(); - private readonly materialTextureCache = new Map(); + private readonly materialTextureCache = new Map< + string, + CachedMaterialTexture + >(); private readonly animationMixers = new Map(); private readonly instanceAnimationClips = new Map(); private readonly controllerContext: RuntimeControllerContext; @@ -138,7 +166,8 @@ export class RuntimeHost { private desiredNavigationMode: RuntimeNavigationMode = "firstPerson"; private sceneReady = false; private currentWorld: RuntimeSceneDefinition["world"] | null = null; - private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null; + private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = + null; private advancedRenderingComposer: EffectComposer | null = null; private projectAssets: Record = {}; private loadedModelAssets: Record = {}; @@ -148,9 +177,14 @@ export class RuntimeHost { private previousFrameTime = 0; private container: HTMLElement | null = null; private activeController: NavigationController | null = null; - private runtimeMessageHandler: ((message: string | null) => void) | null = null; - private firstPersonTelemetryHandler: ((telemetry: FirstPersonTelemetry | null) => void) | null = null; - private interactionPromptHandler: ((prompt: RuntimeInteractionPrompt | null) => void) | null = null; + private runtimeMessageHandler: ((message: string | null) => void) | null = + null; + private firstPersonTelemetryHandler: + | ((telemetry: FirstPersonTelemetry | null) => void) + | null = null; + private interactionPromptHandler: + | ((prompt: RuntimeInteractionPrompt | null) => void) + | null = null; private sceneLoadStateHandler: | ((state: RuntimeSceneLoadState) => void) | null = null; @@ -169,8 +203,11 @@ export class RuntimeHost { this.scene.add(this.modelGroup); this.underwaterSceneFog.density = 0; this.scene.fog = this.underwaterSceneFog; - this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, alpha: true }) : null; - this.domElement = this.renderer?.domElement ?? document.createElement("canvas"); + this.renderer = enableRendering + ? new WebGLRenderer({ antialias: false, alpha: true }) + : null; + this.domElement = + this.renderer?.domElement ?? document.createElement("canvas"); if (this.renderer !== null) { this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); @@ -189,8 +226,14 @@ export class RuntimeHost { return this.runtimeScene; }, - resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null, - resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition), + resolveFirstPersonMotion: (feetPosition, motion, shape) => + this.collisionWorld?.resolveFirstPersonMotion( + feetPosition, + motion, + shape + ) ?? null, + resolvePlayerVolumeState: (feetPosition) => + this.resolvePlayerVolumeState(feetPosition), setRuntimeMessage: (message) => { if (message === this.currentRuntimeMessage) { return; @@ -206,7 +249,11 @@ export class RuntimeHost { }; } - private resolvePlayerVolumeState(feetPosition: { x: number; y: number; z: number }): RuntimePlayerVolumeState { + private resolvePlayerVolumeState(feetPosition: { + x: number; + y: number; + z: number; + }): RuntimePlayerVolumeState { if (this.runtimeScene === null) { return { inWater: false, @@ -214,8 +261,12 @@ export class RuntimeHost { }; } - const inWater = this.runtimeScene.volumes.water.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume)); - const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume)); + const inWater = this.runtimeScene.volumes.water.some((volume) => + this.isPointInsideOrientedVolume(feetPosition, volume) + ); + const inFog = this.runtimeScene.volumes.fog.some((volume) => + this.isPointInsideOrientedVolume(feetPosition, volume) + ); return { inWater, @@ -225,9 +276,17 @@ export class RuntimeHost { private isPointInsideOrientedVolume( point: { x: number; y: number; z: number }, - volume: { center: { x: number; y: number; z: number }; rotationDegrees: { x: number; y: number; z: number }; size: { x: number; y: number; z: number } } + volume: { + center: { x: number; y: number; z: number }; + rotationDegrees: { x: number; y: number; z: number }; + size: { x: number; y: number; z: number }; + } ): boolean { - this.volumeOffset.set(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z); + this.volumeOffset.set( + point.x - volume.center.x, + point.y - volume.center.y, + point.z - volume.center.z + ); this.volumeInverseRotation .setFromEuler( @@ -257,7 +316,10 @@ export class RuntimeHost { this.container = container; container.appendChild(this.domElement); this.domElement.addEventListener("click", this.handleRuntimeClick); - this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown); + this.domElement.addEventListener( + "pointerdown", + this.handleRuntimePointerDown + ); this.resize(); this.resizeObserver = new ResizeObserver(() => { @@ -338,11 +400,15 @@ export class RuntimeHost { this.audioSystem.setRuntimeMessageHandler(handler); } - setFirstPersonTelemetryHandler(handler: ((telemetry: FirstPersonTelemetry | null) => void) | null) { + setFirstPersonTelemetryHandler( + handler: ((telemetry: FirstPersonTelemetry | null) => void) | null + ) { this.firstPersonTelemetryHandler = handler; } - setInteractionPromptHandler(handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null) { + setInteractionPromptHandler( + handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null + ) { this.interactionPromptHandler = handler; } @@ -389,7 +455,10 @@ export class RuntimeHost { this.renderer?.forceContextLoss(); this.renderer?.dispose(); this.domElement.removeEventListener("click", this.handleRuntimeClick); - this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown); + this.domElement.removeEventListener( + "pointerdown", + this.handleRuntimePointerDown + ); if (this.container !== null && this.container.contains(this.domElement)) { this.container.removeChild(this.domElement); @@ -505,7 +574,8 @@ export class RuntimeHost { .multiplyScalar(18); if (world.background.mode === "image") { - const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null; + const texture = + this.loadedImageAssets[world.background.assetId]?.texture ?? null; this.scene.background = texture; this.scene.environment = texture; this.scene.environmentIntensity = world.background.environmentIntensity; @@ -516,7 +586,10 @@ export class RuntimeHost { } if (this.renderer !== null) { - configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering); + configureAdvancedRenderingRenderer( + this.renderer, + world.advancedRendering + ); this.syncAdvancedRenderingComposer(world.advancedRendering); } @@ -554,7 +627,10 @@ export class RuntimeHost { const shouldUseComposer = settings.enabled; const settingsChanged = this.currentAdvancedRenderingSettings === null || - !areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings); + !areAdvancedRenderingSettingsEqual( + this.currentAdvancedRenderingSettings, + settings + ); if (!shouldUseComposer) { if (this.advancedRenderingComposer !== null) { @@ -575,8 +651,14 @@ export class RuntimeHost { this.advancedRenderingComposer.dispose(); } - this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings); - this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings); + this.advancedRenderingComposer = createAdvancedRenderingComposer( + this.renderer, + this.scene, + this.camera, + settings + ); + this.currentAdvancedRenderingSettings = + cloneAdvancedRenderingSettings(settings); this.renderer.autoClear = false; } @@ -586,7 +668,8 @@ export class RuntimeHost { } const advancedRendering = this.currentWorld.advancedRendering; - const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled; + const shadowsEnabled = + advancedRendering.enabled && advancedRendering.shadows.enabled; applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering); @@ -621,11 +704,21 @@ export class RuntimeHost { this.applyShadowState(); } - private createPointLightRuntimeObjects(pointLight: RuntimeLocalLightCollection["pointLights"][number]): LocalLightRenderObjects { + private createPointLightRuntimeObjects( + pointLight: RuntimeLocalLightCollection["pointLights"][number] + ): LocalLightRenderObjects { const group = new Group(); - const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance); + const light = new PointLight( + pointLight.colorHex, + pointLight.intensity, + pointLight.distance + ); - group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z); + group.position.set( + pointLight.position.x, + pointLight.position.y, + pointLight.position.z + ); light.position.set(0, 0, 0); group.add(light); @@ -634,7 +727,9 @@ export class RuntimeHost { }; } - private createSpotLightRuntimeObjects(spotLight: RuntimeLocalLightCollection["spotLights"][number]): LocalLightRenderObjects { + private createSpotLightRuntimeObjects( + spotLight: RuntimeLocalLightCollection["spotLights"][number] + ): LocalLightRenderObjects { const group = new Group(); const light = new SpotLight( spotLight.colorHex, @@ -644,10 +739,21 @@ export class RuntimeHost { 0.18, 1 ); - const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize(); - const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction); + const direction = new Vector3( + spotLight.direction.x, + spotLight.direction.y, + spotLight.direction.z + ).normalize(); + const orientation = new Quaternion().setFromUnitVectors( + new Vector3(0, 1, 0), + direction + ); - group.position.set(spotLight.position.x, spotLight.position.y, spotLight.position.z); + group.position.set( + spotLight.position.x, + spotLight.position.y, + spotLight.position.z + ); group.quaternion.copy(orientation); light.position.set(0, 0, 0); light.target.position.set(0, 1, 0); @@ -662,26 +768,75 @@ export class RuntimeHost { private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) { this.clearBrushMeshes(); const volumeRenderPaths: ResolvedBoxVolumeRenderPaths = - this.currentWorld === null ? { fog: "performance", water: "performance" } : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering); + this.currentWorld === null + ? { fog: "performance", water: "performance" } + : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering); for (const brush of brushes) { const geometry = buildBoxBrushDerivedMeshData(brush).geometry; - const staticContactPatches = brush.volume.mode === "water" ? this.collectRuntimeStaticWaterContactPatches(brush) : []; + const staticContactPatches = + brush.volume.mode === "water" + ? this.collectRuntimeStaticWaterContactPatches(brush) + : []; const contactPatches = brush.volume.mode === "water" - ? this.mergeRuntimeWaterContactPatches(brush, staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush)) + ? this.mergeRuntimeWaterContactPatches( + brush, + staticContactPatches, + this.collectRuntimePlayerWaterContactPatches(brush) + ) : []; - const materials = - this.createFogMaterialSet(brush, volumeRenderPaths) ?? - [ - this.createFaceMaterial(brush, "posX", brush.faces.posX.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "negX", brush.faces.negX.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "posY", brush.faces.posY.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "negY", brush.faces.negY.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "posZ", brush.faces.posZ.material, volumeRenderPaths, contactPatches, staticContactPatches), - this.createFaceMaterial(brush, "negZ", brush.faces.negZ.material, volumeRenderPaths, contactPatches, staticContactPatches) - ]; + const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ?? [ + this.createFaceMaterial( + brush, + "posX", + brush.faces.posX.material, + volumeRenderPaths, + contactPatches, + staticContactPatches + ), + this.createFaceMaterial( + brush, + "negX", + brush.faces.negX.material, + volumeRenderPaths, + contactPatches, + staticContactPatches + ), + this.createFaceMaterial( + brush, + "posY", + brush.faces.posY.material, + volumeRenderPaths, + contactPatches, + staticContactPatches + ), + this.createFaceMaterial( + brush, + "negY", + brush.faces.negY.material, + volumeRenderPaths, + contactPatches, + staticContactPatches + ), + this.createFaceMaterial( + brush, + "posZ", + brush.faces.posZ.material, + volumeRenderPaths, + contactPatches, + staticContactPatches + ), + this.createFaceMaterial( + brush, + "negZ", + brush.faces.negZ.material, + volumeRenderPaths, + contactPatches, + staticContactPatches + ) + ]; const mesh = new Mesh(geometry, materials); mesh.position.set(brush.center.x, brush.center.y, brush.center.z); @@ -700,7 +855,10 @@ export class RuntimeHost { private createFogMaterialSet( brush: RuntimeBoxBrushInstance, - volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" } + volumeRenderPaths: { + fog: "performance" | "quality"; + water: "performance" | "quality"; + } ): Material[] | null { if (brush.volume.mode !== "fog") { return null; @@ -720,10 +878,16 @@ export class RuntimeHost { }); this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); - return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial.material); + return Array.from( + { length: BOX_FACE_MATERIAL_COUNT }, + () => fogMaterial.material + ); } - const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)); + const densityOpacity = Math.max( + 0.06, + Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08) + ); const fogMaterial = new MeshBasicMaterial({ color: brush.volume.fog.colorHex, transparent: true, @@ -734,9 +898,14 @@ export class RuntimeHost { return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial); } - private configureFogVolumeMesh(mesh: Mesh, materials: Material[]) { + private configureFogVolumeMesh( + mesh: Mesh, + materials: Material[] + ) { const fogMaterials = materials.filter( - (material): material is ShaderMaterial => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined + (material): material is ShaderMaterial => + material instanceof ShaderMaterial && + material.uniforms["localCameraPosition"] !== undefined ); if (fogMaterials.length === 0) { @@ -744,15 +913,21 @@ export class RuntimeHost { } mesh.onBeforeRender = (_renderer, _scene, camera) => { - const localCameraPosition = mesh.worldToLocal(this.fogLocalCameraPosition.copy(camera.position)); + const localCameraPosition = mesh.worldToLocal( + this.fogLocalCameraPosition.copy(camera.position) + ); for (const material of fogMaterials) { - (material.uniforms["localCameraPosition"] as { value: Vector3 }).value.copy(localCameraPosition); + ( + material.uniforms["localCameraPosition"] as { value: Vector3 } + ).value.copy(localCameraPosition); } }; } - private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["modelInstances"]) { + private rebuildModelInstances( + modelInstances: RuntimeSceneDefinition["modelInstances"] + ) { this.clearModelInstances(); for (const modelInstance of modelInstances) { @@ -782,10 +957,19 @@ export class RuntimeHost { if (loadedAsset?.animations && loadedAsset.animations.length > 0) { const mixer = new AnimationMixer(renderGroup); this.animationMixers.set(modelInstance.instanceId, mixer); - this.instanceAnimationClips.set(modelInstance.instanceId, loadedAsset.animations); + this.instanceAnimationClips.set( + modelInstance.instanceId, + loadedAsset.animations + ); - if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) { - const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName); + if ( + modelInstance.animationAutoplay === true && + modelInstance.animationClipName + ) { + const clip = AnimationClip.findByName( + loadedAsset.animations, + modelInstance.animationClipName + ); if (clip) { mixer.clipAction(clip).play(); } @@ -800,18 +984,28 @@ export class RuntimeHost { brush: RuntimeBoxBrushInstance, faceId: "posX" | "negX" | "posY" | "negY" | "posZ" | "negZ", material: RuntimeBoxBrushInstance["faces"]["posX"]["material"], - volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" }, + volumeRenderPaths: { + fog: "performance" | "quality"; + water: "performance" | "quality"; + }, contactPatches: ReturnType, staticContactPatches: ReturnType ): Material { if (brush.volume.mode === "water") { - const baseOpacity = Math.max(0.05, Math.min(1, brush.volume.water.surfaceOpacity)); + const baseOpacity = Math.max( + 0.05, + Math.min(1, brush.volume.water.surfaceOpacity) + ); const waterMaterial = createWaterMaterial({ colorHex: brush.volume.water.colorHex, surfaceOpacity: brush.volume.water.surfaceOpacity, waveStrength: brush.volume.water.waveStrength, - surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled, - opacity: faceId === "posY" ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5, + surfaceDisplacementEnabled: + brush.volume.water.surfaceDisplacementEnabled, + opacity: + faceId === "posY" + ? Math.min(1, baseOpacity + 0.18) + : baseOpacity * 0.5, quality: volumeRenderPaths.water === "quality", wireframe: false, isTopFace: faceId === "posY", @@ -831,17 +1025,26 @@ export class RuntimeHost { this.volumeAnimatedUniforms.push(waterMaterial.animationUniform); } - if (faceId === "posY" && waterMaterial.contactPatchesUniform !== null && waterMaterial.contactPatchAxesUniform !== null) { + if ( + faceId === "posY" && + waterMaterial.contactPatchesUniform !== null && + waterMaterial.contactPatchAxesUniform !== null + ) { this.runtimeWaterContactUniforms.push({ brush, uniform: waterMaterial.contactPatchesUniform, axisUniform: waterMaterial.contactPatchAxesUniform, - shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] }, + shapeUniform: waterMaterial.contactPatchShapesUniform ?? { + value: [] + }, staticContactPatches, reflectionTextureUniform: waterMaterial.reflectionTextureUniform, reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform, reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform, - reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null, + reflectionRenderTarget: + this.getWaterReflectionMode() !== "none" + ? this.createWaterReflectionRenderTarget() + : null, lastReflectionUpdateTime: Number.NEGATIVE_INFINITY }); } @@ -867,7 +1070,10 @@ export class RuntimeHost { return fogMaterial.material; } // Performance fallback: simple transparent material - const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)); + const densityOpacity = Math.max( + 0.06, + Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08) + ); return new MeshBasicMaterial({ color: brush.volume.fog.colorHex, transparent: true, @@ -895,7 +1101,10 @@ export class RuntimeHost { private updateUnderwaterSceneFog() { const fogState = this.activeController === this.firstPersonController - ? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry) + ? resolveUnderwaterFogState( + this.runtimeScene, + this.currentFirstPersonTelemetry + ) : null; if (fogState === null) { @@ -908,7 +1117,11 @@ export class RuntimeHost { } private getWaterReflectionMode() { - if (this.currentWorld === null || !this.currentWorld.advancedRendering.enabled || this.currentWorld.advancedRendering.waterPath !== "quality") { + if ( + this.currentWorld === null || + !this.currentWorld.advancedRendering.enabled || + this.currentWorld.advancedRendering.waterPath !== "quality" + ) { return "none" as const; } @@ -957,7 +1170,8 @@ export class RuntimeHost { } if (binding.reflectionRenderTarget === null) { - binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget(); + binding.reflectionRenderTarget = + this.createWaterReflectionRenderTarget(); } const canRenderReflection = updatePlanarReflectionCamera( @@ -972,12 +1186,19 @@ export class RuntimeHost { continue; } - if (binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS) { + if ( + binding.reflectionTextureUniform.value !== null && + now - binding.lastReflectionUpdateTime < + WATER_REFLECTION_UPDATE_INTERVAL_MS + ) { binding.reflectionEnabledUniform.value = 0.36; continue; } - const hiddenWaterMeshes: Array<{ mesh: Mesh; visible: boolean }> = []; + const hiddenWaterMeshes: Array<{ + mesh: Mesh; + visible: boolean; + }> = []; for (const runtimeBrush of this.runtimeScene.brushes) { if (runtimeBrush.volume.mode !== "water") { continue; @@ -1000,11 +1221,13 @@ export class RuntimeHost { const previousAutoClear = this.renderer.autoClear; const previousRenderTarget = this.renderer.getRenderTarget(); const previousFogDensity = this.underwaterSceneFog.density; - const previousReflectionStates = this.runtimeWaterContactUniforms.map((waterBinding) => ({ - binding: waterBinding, - enabled: waterBinding.reflectionEnabledUniform?.value ?? 0, - texture: waterBinding.reflectionTextureUniform?.value ?? null - })); + const previousReflectionStates = this.runtimeWaterContactUniforms.map( + (waterBinding) => ({ + binding: waterBinding, + enabled: waterBinding.reflectionEnabledUniform?.value ?? 0, + texture: waterBinding.reflectionTextureUniform?.value ?? null + }) + ); try { this.underwaterSceneFog.density = 0; @@ -1037,13 +1260,16 @@ export class RuntimeHost { } } - binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture; + binding.reflectionTextureUniform.value = + binding.reflectionRenderTarget.texture; binding.reflectionEnabledUniform.value = 0.36; binding.lastReflectionUpdateTime = now; } } - private getOrCreateTexture(material: NonNullable) { + private getOrCreateTexture( + material: NonNullable + ) { const signature = createStarterMaterialSignature(material); const cachedTexture = this.materialTextureCache.get(material.id); @@ -1092,7 +1318,10 @@ export class RuntimeHost { } private createPlayerWaterContactBounds() { - if (this.runtimeScene === null || this.currentFirstPersonTelemetry === null) { + if ( + this.runtimeScene === null || + this.currentFirstPersonTelemetry === null + ) { return null; } @@ -1131,16 +1360,27 @@ export class RuntimeHost { } } - private collectRuntimeStaticWaterContactPatches(brush: RuntimeBoxBrushInstance) { + private collectRuntimeStaticWaterContactPatches( + brush: RuntimeBoxBrushInstance + ) { const contactBounds: Parameters[1] = []; - const runtimeBrushesById = new Map((this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [runtimeBrush.id, runtimeBrush])); + const runtimeBrushesById = new Map( + (this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [ + runtimeBrush.id, + runtimeBrush + ]) + ); for (const collider of this.runtimeScene?.colliders ?? []) { if (collider.source === "brush") { const otherBrush = runtimeBrushesById.get(collider.brushId); - if (otherBrush === undefined || otherBrush.id === brush.id || otherBrush.volume.mode !== "none") { + if ( + otherBrush === undefined || + otherBrush.id === brush.id || + otherBrush.volume.mode !== "none" + ) { continue; } @@ -1189,7 +1429,9 @@ export class RuntimeHost { ); } - private collectRuntimePlayerWaterContactPatches(brush: RuntimeBoxBrushInstance) { + private collectRuntimePlayerWaterContactPatches( + brush: RuntimeBoxBrushInstance + ) { const playerBounds = this.createPlayerWaterContactBounds(); if (playerBounds === null) { @@ -1208,7 +1450,9 @@ export class RuntimeHost { } private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) { - return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0; + return brush.volume.mode === "water" + ? brush.volume.water.foamContactLimit + : 0; } private mergeRuntimeWaterContactPatches( @@ -1216,7 +1460,10 @@ export class RuntimeHost { staticContactPatches: ReturnType, dynamicContactPatches: ReturnType ) { - return [...dynamicContactPatches, ...staticContactPatches].slice(0, this.getRuntimeWaterFoamContactLimit(brush)); + return [...dynamicContactPatches, ...staticContactPatches].slice( + 0, + this.getRuntimeWaterFoamContactLimit(brush) + ); } private updateRuntimeWaterContactUniforms() { @@ -1226,9 +1473,12 @@ export class RuntimeHost { binding.staticContactPatches, this.collectRuntimePlayerWaterContactPatches(binding.brush) ); - binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches); - binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches); - binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches); + binding.uniform.value = + createWaterContactPatchUniformValue(mergedPatches); + binding.axisUniform.value = + createWaterContactPatchAxisUniformValue(mergedPatches); + binding.shapeUniform.value = + createWaterContactPatchShapeUniformValue(mergedPatches); } } @@ -1293,7 +1543,11 @@ export class RuntimeHost { this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null ) { - this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher()); + this.interactionSystem.updatePlayerPosition( + this.currentFirstPersonTelemetry.feetPosition, + this.runtimeScene, + this.createInteractionDispatcher() + ); this.camera.getWorldDirection(this.cameraForward); this.setInteractionPrompt( this.interactionSystem.resolveClickInteractionPrompt( @@ -1329,7 +1583,10 @@ export class RuntimeHost { this.firstPersonController.teleportTo(target.position, target.yawDegrees); } - private applyToggleBrushVisibilityAction(brushId: string, visible: boolean | undefined) { + private applyToggleBrushVisibilityAction( + brushId: string, + visible: boolean | undefined + ) { const mesh = this.brushMeshes.get(brushId); if (mesh === undefined) { @@ -1339,7 +1596,11 @@ export class RuntimeHost { mesh.visible = visible ?? !mesh.visible; } - private applyPlayAnimationAction(instanceId: string, clipName: string, loop: boolean | undefined) { + private applyPlayAnimationAction( + instanceId: string, + clipName: string, + loop: boolean | undefined + ) { const mixer = this.animationMixers.get(instanceId); const clips = this.instanceAnimationClips.get(instanceId); @@ -1351,7 +1612,9 @@ export class RuntimeHost { const clip = AnimationClip.findByName(clips, clipName); if (!clip) { - console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`); + console.warn( + `playAnimation: clip "${clipName}" not found on instance ${instanceId}` + ); return; } @@ -1399,7 +1662,8 @@ export class RuntimeHost { private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) { if ( - this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId && + this.currentInteractionPrompt?.sourceEntityId === + prompt?.sourceEntityId && this.currentInteractionPrompt?.prompt === prompt?.prompt && this.currentInteractionPrompt?.distance === prompt?.distance && this.currentInteractionPrompt?.range === prompt?.range @@ -1422,7 +1686,11 @@ export class RuntimeHost { } this.audioSystem.handleUserGesture(); - this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher()); + this.interactionSystem.dispatchClickInteraction( + this.currentInteractionPrompt.sourceEntityId, + this.runtimeScene, + this.createInteractionDispatcher() + ); }; private handleRuntimePointerDown = () => { diff --git a/tests/domain/editor-store.test.ts b/tests/domain/editor-store.test.ts index 0a409716..3180a7d3 100644 --- a/tests/domain/editor-store.test.ts +++ b/tests/domain/editor-store.test.ts @@ -84,7 +84,9 @@ describe("EditorStore", () => { const secondSceneId = store.getState().activeSceneId; expect(secondSceneId).not.toBe(firstSceneId); - expect(Object.keys(store.getState().projectDocument.scenes)).toHaveLength(2); + expect(Object.keys(store.getState().projectDocument.scenes)).toHaveLength( + 2 + ); expect(store.getState().document.name).toBe("Scene 2"); store.executeCommand(createCreateBoxBrushCommand()); @@ -269,9 +271,13 @@ describe("EditorStore", () => { expect(store.getState().whiteboxSelectionMode).toBe("object"); expect(store.getState().viewportLayoutMode).toBe("single"); expect(store.getState().activeViewportPanelId).toBe("topLeft"); - expect(store.getState().viewportPanels.topLeft.viewMode).toBe("perspective"); + expect(store.getState().viewportPanels.topLeft.viewMode).toBe( + "perspective" + ); expect(store.getState().viewportPanels.topRight.viewMode).toBe("top"); - expect(store.getState().viewportPanels.topRight.displayMode).toBe("authoring"); + expect(store.getState().viewportPanels.topRight.displayMode).toBe( + "authoring" + ); expect(store.getState().viewportQuadSplit).toEqual({ x: 0.5, y: 0.5 @@ -289,7 +295,9 @@ describe("EditorStore", () => { expect(store.getState().viewportLayoutMode).toBe("quad"); expect(store.getState().activeViewportPanelId).toBe("bottomRight"); expect(store.getState().viewportPanels.bottomRight.viewMode).toBe("front"); - expect(store.getState().viewportPanels.bottomRight.displayMode).toBe("normal"); + expect(store.getState().viewportPanels.bottomRight.displayMode).toBe( + "normal" + ); expect(store.getState().viewportQuadSplit).toEqual({ x: 0.38, y: 0.62 @@ -482,7 +490,9 @@ describe("EditorStore", () => { }) ); - expect(store.getState().viewportTransientState.transformSession).toMatchObject({ + expect( + store.getState().viewportTransientState.transformSession + ).toMatchObject({ kind: "active", source: "keyboard", sourcePanelId: "bottomRight", diff --git a/tests/serialization/local-draft-storage.test.ts b/tests/serialization/local-draft-storage.test.ts index e4fef2d4..5e36b941 100644 --- a/tests/serialization/local-draft-storage.test.ts +++ b/tests/serialization/local-draft-storage.test.ts @@ -75,11 +75,16 @@ describe("local draft storage", () => { expect(result.document.version).toBe(SCENE_DOCUMENT_VERSION); expect(result.document).toEqual(createEmptyProjectDocument()); expect(result.diagnostic).toContain("Stored autosave could not be loaded."); - expect(result.diagnostic).toContain("Starting with a fresh empty document."); + expect(result.diagnostic).toContain( + "Starting with a fresh empty document." + ); }); it("reports browser storage access failures without throwing", () => { - const originalDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage"); + const originalDescriptor = Object.getOwnPropertyDescriptor( + window, + "localStorage" + ); Object.defineProperty(window, "localStorage", { configurable: true, @@ -92,7 +97,9 @@ describe("local draft storage", () => { const result = getBrowserStorageAccess(); expect(result.storage).toBeNull(); - expect(result.diagnostic).toContain("Browser local storage is unavailable."); + expect(result.diagnostic).toContain( + "Browser local storage is unavailable." + ); expect(result.diagnostic).toContain("access denied"); } finally { if (originalDescriptor !== undefined) { @@ -161,9 +168,9 @@ describe("local draft storage", () => { return; } - expect( - result.document.scenes[result.document.activeSceneId]?.name - ).toBe("Viewport Draft"); + expect(result.document.scenes[result.document.activeSceneId]?.name).toBe( + "Viewport Draft" + ); expect(result.viewportLayoutState).toMatchObject({ layoutMode: "quad", activePanelId: "bottomRight", @@ -235,7 +242,10 @@ describe("local draft storage", () => { it("loads older raw scene-document drafts without requiring viewport layout state", () => { const storage = new MemoryStorage(); - storage.setItem(DEFAULT_SCENE_DRAFT_STORAGE_KEY, serializeSceneDocument(createEmptySceneDocument({ name: "Legacy Draft" }))); + storage.setItem( + DEFAULT_SCENE_DRAFT_STORAGE_KEY, + serializeSceneDocument(createEmptySceneDocument({ name: "Legacy Draft" })) + ); const result = loadSceneDocumentDraft(storage); @@ -245,9 +255,9 @@ describe("local draft storage", () => { return; } - expect( - result.document.scenes[result.document.activeSceneId]?.name - ).toBe("Legacy Draft"); + expect(result.document.scenes[result.document.activeSceneId]?.name).toBe( + "Legacy Draft" + ); expect(result.viewportLayoutState).toBeNull(); expect(result.message).toBe("Recovered latest autosave."); }); @@ -263,9 +273,9 @@ describe("local draft storage", () => { const result = loadOrCreateSceneDocument(storage); - expect( - result.document.scenes[result.document.activeSceneId]?.name - ).toBe("Recovered Scene"); + expect(result.document.scenes[result.document.activeSceneId]?.name).toBe( + "Recovered Scene" + ); expect(result.diagnostic).toBe("Recovered latest autosave."); }); diff --git a/tests/serialization/project-document-json.test.ts b/tests/serialization/project-document-json.test.ts index 28ca1f89..b54cad44 100644 --- a/tests/serialization/project-document-json.test.ts +++ b/tests/serialization/project-document-json.test.ts @@ -44,8 +44,10 @@ describe("project document JSON", () => { id: "scene-main", name: "Legacy Entry" }); - const { loadingScreen: _loadingScreen, ...legacySceneWithoutLoadingScreen } = - legacyScene; + const { + loadingScreen: _loadingScreen, + ...legacySceneWithoutLoadingScreen + } = legacyScene; void _loadingScreen; const migratedDocument = parseProjectDocumentJson( diff --git a/tests/serialization/project-package.test.ts b/tests/serialization/project-package.test.ts index c1e9cfaf..71ad6690 100644 --- a/tests/serialization/project-package.test.ts +++ b/tests/serialization/project-package.test.ts @@ -5,11 +5,21 @@ import { strToU8, unzipSync, Zip, ZipDeflate } from "fflate"; import { afterEach, describe, expect, it, vi } from "vitest"; import { loadAudioAssetFromStorage } from "../../src/assets/audio-assets"; -import { loadModelAssetFromStorage, importModelAssetFromFiles } from "../../src/assets/gltf-model-import"; +import { + loadModelAssetFromStorage, + importModelAssetFromFiles +} from "../../src/assets/gltf-model-import"; import { loadImageAssetFromStorage } from "../../src/assets/image-assets"; import { createModelInstance } from "../../src/assets/model-instances"; -import { createInMemoryProjectAssetStorage, type ProjectAssetStorage } from "../../src/assets/project-asset-storage"; -import { createProjectAssetStorageKey, type AudioAssetRecord, type ImageAssetRecord } from "../../src/assets/project-assets"; +import { + createInMemoryProjectAssetStorage, + type ProjectAssetStorage +} from "../../src/assets/project-asset-storage"; +import { + createProjectAssetStorageKey, + type AudioAssetRecord, + type ImageAssetRecord +} from "../../src/assets/project-assets"; import { createEmptyProjectScene, createEmptySceneDocument, @@ -22,11 +32,24 @@ import { } from "../../src/serialization/project-package"; import { serializeProjectDocument } from "../../src/serialization/scene-document-json"; -const tinyGlbFixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle.glb"); -const externalTriangleGltfPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/scene.gltf"); -const externalTriangleBinPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/triangle.bin"); +const tinyGlbFixturePath = path.resolve( + process.cwd(), + "fixtures/assets/tiny-triangle.glb" +); +const externalTriangleGltfPath = path.resolve( + process.cwd(), + "fixtures/assets/external-triangle/scene.gltf" +); +const externalTriangleBinPath = path.resolve( + process.cwd(), + "fixtures/assets/external-triangle/triangle.bin" +); -function createTestFile(bytes: Uint8Array | Buffer, name: string, type: string): File { +function createTestFile( + bytes: Uint8Array | Buffer, + name: string, + type: string +): File { const arrayBuffer = new ArrayBuffer(bytes.byteLength); new Uint8Array(arrayBuffer).set(bytes); @@ -72,7 +95,9 @@ function buildZipArchive(entries: Record): Uint8Array { zip.end(); - const archiveBytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)); + const archiveBytes = new Uint8Array( + chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) + ); let offset = 0; for (const chunk of chunks) { @@ -177,7 +202,11 @@ describe("project package serialization", () => { await storage.putAsset(imageAsset.storageKey, { files: { [imageAsset.sourceName]: { - bytes: cloneArrayBuffer(strToU8("")), + bytes: cloneArrayBuffer( + strToU8( + '' + ) + ), mimeType: imageAsset.mimeType } } @@ -191,7 +220,10 @@ describe("project package serialization", () => { } }); - const imageLoadListeners = new WeakMap void; error?: () => void }>(); + const imageLoadListeners = new WeakMap< + object, + { load?: () => void; error?: () => void } + >(); const mockImageWidth = 1024; const mockImageHeight = 512; @@ -247,18 +279,32 @@ describe("project package serialization", () => { const packageBytes = await saveProjectPackage(document, storage); const restoredStorage = createInMemoryProjectAssetStorage(); - const restoredDocument = await loadProjectPackage(packageBytes, restoredStorage); + const restoredDocument = await loadProjectPackage( + packageBytes, + restoredStorage + ); expect(restoredDocument).toEqual(document); - const restoredModel = await loadModelAssetFromStorage(restoredStorage, importedModel.asset); - const restoredImage = await loadImageAssetFromStorage(restoredStorage, imageAsset); - const restoredAudio = await loadAudioAssetFromStorage(restoredStorage, audioAsset); + const restoredModel = await loadModelAssetFromStorage( + restoredStorage, + importedModel.asset + ); + const restoredImage = await loadImageAssetFromStorage( + restoredStorage, + imageAsset + ); + const restoredAudio = await loadAudioAssetFromStorage( + restoredStorage, + audioAsset + ); expect(restoredModel.metadata.format).toBe("glb"); expect(restoredModel.template.children.length).toBeGreaterThan(0); expect(restoredImage.metadata.width).toBe(imageAsset.metadata.width); - expect(restoredAudio.metadata.durationSeconds).toBe(audioAsset.metadata.durationSeconds); + expect(restoredAudio.metadata.durationSeconds).toBe( + audioAsset.metadata.durationSeconds + ); }); it("preserves multi-file gltf asset bundles inside the packaged assets directory", async () => { @@ -290,7 +336,10 @@ describe("project package serialization", () => { await loadProjectPackage(packageBytes, restoredStorage); - const restoredModel = await loadModelAssetFromStorage(restoredStorage, importedModel.asset); + const restoredModel = await loadModelAssetFromStorage( + restoredStorage, + importedModel.asset + ); expect(restoredModel.metadata.format).toBe("gltf"); expect(restoredModel.template.children.length).toBeGreaterThan(0); @@ -320,7 +369,9 @@ describe("project package serialization", () => { } }); - await expect(saveProjectPackage(document, storage)).rejects.toThrow("Missing stored binary data for image asset missing.png."); + await expect(saveProjectPackage(document, storage)).rejects.toThrow( + "Missing stored binary data for image asset missing.png." + ); }); it("fails project load when scene.json is missing", async () => { @@ -328,7 +379,9 @@ describe("project package serialization", () => { "assets/readme.txt": strToU8("not a project") }); - await expect(loadProjectPackage(packageBytes, null)).rejects.toThrow("project package is missing scene.json"); + await expect(loadProjectPackage(packageBytes, null)).rejects.toThrow( + "project package is missing scene.json" + ); }); it("fails project load when a declared asset has no packaged files", async () => { @@ -357,7 +410,9 @@ describe("project package serialization", () => { [PROJECT_PACKAGE_SCENE_PATH]: strToU8(serializeProjectDocument(document)) }); - await expect(loadProjectPackage(packageBytes, createInMemoryProjectAssetStorage())).rejects.toThrow( + await expect( + loadProjectPackage(packageBytes, createInMemoryProjectAssetStorage()) + ).rejects.toThrow( "project package is missing bundled files for image asset missing.svg" ); }); @@ -366,8 +421,13 @@ describe("project package serialization", () => { const document = createProjectDocument( createEmptySceneDocument({ name: "Portable Scene Without Storage" }) ); - const packageBytes = await saveProjectPackage(document, createInMemoryProjectAssetStorage()); + const packageBytes = await saveProjectPackage( + document, + createInMemoryProjectAssetStorage() + ); - await expect(loadProjectPackage(packageBytes, null as ProjectAssetStorage | null)).resolves.toEqual(document); + await expect( + loadProjectPackage(packageBytes, null as ProjectAssetStorage | null) + ).resolves.toEqual(document); }); }); diff --git a/tests/unit/runner-canvas.test.tsx b/tests/unit/runner-canvas.test.tsx index 3bba6f01..36efaceb 100644 --- a/tests/unit/runner-canvas.test.tsx +++ b/tests/unit/runner-canvas.test.tsx @@ -60,7 +60,9 @@ describe("RunnerCanvas", () => { }); it("only shows the underwater overlay when the camera is submerged", async () => { - const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument()); + const runtimeScene = buildRuntimeSceneFromDocument( + createEmptySceneDocument() + ); const onTelemetryChange = vi.fn(); render( @@ -81,12 +83,20 @@ describe("RunnerCanvas", () => { await waitFor(() => { expect(runtimeHostInstances).toHaveLength(1); - expect(runtimeHostInstances[0]?.setFirstPersonTelemetryHandler).toHaveBeenCalledTimes(1); - expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1); + expect( + runtimeHostInstances[0]?.setFirstPersonTelemetryHandler + ).toHaveBeenCalledTimes(1); + expect( + runtimeHostInstances[0]?.setSceneLoadStateHandler + ).toHaveBeenCalledTimes(1); }); - const publishTelemetry = runtimeHostInstances[0]?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as ((telemetry: FirstPersonTelemetry | null) => void) | undefined; - const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as + const publishTelemetry = runtimeHostInstances[0] + ?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as + | ((telemetry: FirstPersonTelemetry | null) => void) + | undefined; + const publishSceneLoadState = runtimeHostInstances[0] + ?.setSceneLoadStateHandler.mock.calls[0]?.[0] as | ((state: RuntimeSceneLoadState) => void) | undefined; @@ -114,7 +124,9 @@ describe("RunnerCanvas", () => { }); }); - expect(screen.queryByLabelText("Built-in scene runner")?.className).not.toContain("runner-canvas--underwater"); + expect( + screen.queryByLabelText("Built-in scene runner")?.className + ).not.toContain("runner-canvas--underwater"); expect(document.querySelector(".runner-canvas__underwater")).toBeNull(); act(() => { @@ -131,12 +143,16 @@ describe("RunnerCanvas", () => { }); }); - expect(screen.getByLabelText("Built-in scene runner").className).toContain("runner-canvas--underwater"); + expect(screen.getByLabelText("Built-in scene runner").className).toContain( + "runner-canvas--underwater" + ); expect(document.querySelector(".runner-canvas__underwater")).not.toBeNull(); }); it("shows the loading overlay until the runtime host reports readiness", async () => { - const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument()); + const runtimeScene = buildRuntimeSceneFromDocument( + createEmptySceneDocument() + ); render( { await waitFor(() => { expect(runtimeHostInstances).toHaveLength(1); - expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1); + expect( + runtimeHostInstances[0]?.setSceneLoadStateHandler + ).toHaveBeenCalledTimes(1); }); - expect(screen.getByTestId("runner-loading-overlay").className).not.toContain( - "runner-canvas__loading-overlay--hidden" - ); + expect( + screen.getByTestId("runner-loading-overlay").className + ).not.toContain("runner-canvas__loading-overlay--hidden"); expect(screen.getByTestId("runner-loading-scene-name")).toHaveTextContent( "Dungeon Entry" ); expect(screen.getByTestId("runner-loading-headline")).toHaveTextContent( "Preparing encounter" ); - expect( - screen.getByTestId("runner-loading-description") - ).toHaveTextContent("Enemies and triggers are being wired up."); + expect(screen.getByTestId("runner-loading-description")).toHaveTextContent( + "Enemies and triggers are being wired up." + ); expect(document.querySelector(".runner-canvas__crosshair")).toBeNull(); expect(screen.getByTestId("runner-shell")).toHaveAttribute( "aria-busy", "true" ); - const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as + const publishSceneLoadState = runtimeHostInstances[0] + ?.setSceneLoadStateHandler.mock.calls[0]?.[0] as | ((state: RuntimeSceneLoadState) => void) | undefined; @@ -205,7 +224,9 @@ describe("RunnerCanvas", () => { }); it("keeps the overlay visible and shows load errors from the runtime host", async () => { - const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument()); + const runtimeScene = buildRuntimeSceneFromDocument( + createEmptySceneDocument() + ); render( { ); await waitFor(() => { - expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1); + expect( + runtimeHostInstances[0]?.setSceneLoadStateHandler + ).toHaveBeenCalledTimes(1); }); - const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as + const publishSceneLoadState = runtimeHostInstances[0] + ?.setSceneLoadStateHandler.mock.calls[0]?.[0] as | ((state: RuntimeSceneLoadState) => void) | undefined; @@ -238,9 +262,9 @@ describe("RunnerCanvas", () => { }); }); - expect(screen.getByTestId("runner-loading-overlay").className).not.toContain( - "runner-canvas__loading-overlay--hidden" - ); + expect( + screen.getByTestId("runner-loading-overlay").className + ).not.toContain("runner-canvas__loading-overlay--hidden"); expect(screen.getByTestId("runner-loading-error")).toHaveTextContent( "Runner scene failed to load: collision bootstrap exploded." ); diff --git a/tests/unit/runtime-host.test.ts b/tests/unit/runtime-host.test.ts index d800ff35..f9bcb0c1 100644 --- a/tests/unit/runtime-host.test.ts +++ b/tests/unit/runtime-host.test.ts @@ -34,7 +34,9 @@ describe("RuntimeHost", () => { }); it("delays controller activation until collision setup reports the scene as ready", async () => { - const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument()); + const runtimeScene = buildRuntimeSceneFromDocument( + createEmptySceneDocument() + ); vi.spyOn(console, "warn").mockImplementation(() => undefined); const collisionWorld = { dispose: vi.fn()