import { createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library"; import type { Vec3 } from "../core/vector"; import { createModelInstanceCollisionSettings, createModelInstance, isModelInstanceCollisionMode, normalizeModelInstanceName, DEFAULT_MODEL_INSTANCE_ENABLED, DEFAULT_MODEL_INSTANCE_VISIBLE, type ModelInstanceCollisionSettings, type ModelInstance } from "../assets/model-instances"; import { isProjectAssetKind, type AudioAssetMetadata, type ImageAssetMetadata, type ModelAssetMetadata, type ProjectAssetBoundingBox, type ProjectAssetRecord } from "../assets/project-assets"; import { createCameraRigActorTargetRef, createCameraRigEntity, createCameraRigEntityTargetRef, createCameraRigLookAroundSettings, createCameraRigPlayerTargetRef, createCameraRigWorldPointTargetRef, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS, createNpcAlwaysPresence, createNpcEntity, createNpcColliderSettings, createNpcTimeWindowPresence, createPlayerStartColliderSettings, createPlayerStartInputBindings, createPlayerStartMovementTemplate, createInteractableEntity, isCameraRigTargetKind, isCameraRigTransitionMode, isNpcPresenceMode, normalizeEntityName, createPointLightEntity, createPlayerStartEntity, createSceneEntryEntity, createSoundEmitterEntity, createSpotLightEntity, createTeleportTargetEntity, createTriggerVolumeEntity, DEFAULT_ENTITY_ENABLED, DEFAULT_ENTITY_VISIBLE, isPlayerStartColliderMode, isPlayerStartGamepadActionBinding, isPlayerStartGamepadCameraLookBinding, isPlayerStartGamepadBinding, isPlayerStartKeyboardBindingCode, isPlayerStartMovementTemplateKind, isPlayerStartNavigationMode, type EntityInstance, type NpcPresence, type PlayerStartGamepadActionBinding, type PlayerStartGamepadBinding, type PlayerStartGamepadCameraLookBinding, type PlayerStartMovementTemplateKind } from "../entities/entity-instances"; import { createActivateCameraRigOverrideControlEffect, createFollowActorPathControlEffect, createActorControlTargetRef, createActiveSceneControlTargetRef, createCameraRigControlTargetRef, createClearCameraRigOverrideControlEffect, createEntityControlTargetRef, createInteractionControlTargetRef, createLightControlTargetRef, createModelInstanceControlTargetRef, createPlayActorAnimationControlEffect, createPlayModelAnimationControlEffect, createPlaySoundControlEffect, createProjectGlobalControlTargetRef, createSetAmbientLightColorControlEffect, createSetAmbientLightIntensityControlEffect, createSetActorPresenceControlEffect, createSetInteractionEnabledControlEffect, createSetLightEnabledControlEffect, createSetLightColorControlEffect, createSetLightIntensityControlEffect, createSetModelInstanceVisibleControlEffect, createSetSoundVolumeControlEffect, createSetSunLightColorControlEffect, createSetSunLightIntensityControlEffect, createSoundEmitterControlTargetRef, createStopModelAnimationControlEffect, createStopSoundControlEffect, isActorPathProgressMode, isControlEntityTargetKind, isControlInteractionTargetKind, type ControlEffect, type ControlTargetRef } from "../controls/control-surface"; import { createControlInteractionLink, createPlayAnimationInteractionLink, createRunSequenceInteractionLink, createPlaySoundInteractionLink, createStopAnimationInteractionLink, createStopSoundInteractionLink, createTeleportPlayerInteractionLink, createToggleVisibilityInteractionLink, isInteractionTriggerKind, type InteractionLink } from "../interactions/interaction-links"; import { BOX_VERTEX_IDS, WEDGE_FACE_IDS, WEDGE_VERTEX_IDS, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT, createBoxBrush, createConeBrush, createDefaultRadialPrismBrushGeometry, createDefaultConeBrushGeometry, createDefaultBoxBrushGeometry, createDefaultBoxBrushFogSettings, createDefaultBoxBrushLightSettings, createDefaultBoxBrushWaterSettings, createDefaultTorusBrushGeometry, createDefaultWedgeBrushGeometry, createDefaultFaceUvState, createRadialPrismBrush, createTorusBrush, createWedgeBrush, DEFAULT_BOX_BRUSH_ENABLED, DEFAULT_BOX_BRUSH_ROTATION_DEGREES, DEFAULT_BOX_BRUSH_VISIBLE, DEFAULT_WEDGE_BRUSH_ROTATION_DEGREES, getConeFaceIds, getConeVertexIds, getRadialPrismFaceIds, getRadialPrismVertexIds, getTorusFaceIds, getTorusVertexIds, isBoxBrushVolumeMode, isBoxBrushLightFalloffMode, isFaceUvRotationQuarterTurns, normalizeConeSideCount, normalizeRadialPrismSideCount, normalizeTorusMajorSegmentCount, normalizeTorusTubeSegmentCount, normalizeBrushName, type BoxBrushVolumeSettings, type BoxBrushFaces, type BrushFace, type FaceUvState, type WhiteboxFaceId, type WhiteboxVertexId } from "./brushes"; import { BOX_BRUSH_SCENE_DOCUMENT_VERSION, ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION, AUTHORED_TERRAIN_COLLISION_SCENE_DOCUMENT_VERSION, AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION, AUTHORED_TERRAIN_PAINT_SCENE_DOCUMENT_VERSION, AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION, CAMERA_RIG_ENTITY_SCENE_DOCUMENT_VERSION, CAMERA_RIG_RAIL_SCENE_DOCUMENT_VERSION, CELESTIAL_ORBIT_SETTINGS_SCENE_DOCUMENT_VERSION, CONTROL_SURFACE_FOUNDATION_SCENE_DOCUMENT_VERSION, DEFAULT_PROJECT_NAME, DEFAULT_PROJECT_SCENE_ID, SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION, ENTITY_NAMES_SCENE_DOCUMENT_VERSION, ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION, FACE_MATERIALS_SCENE_DOCUMENT_VERSION, FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION, FOUNDATION_SCENE_DOCUMENT_VERSION, IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION, LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION, MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION, MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION, PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION, PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION, PROJECT_TIME_SYSTEM_SCENE_DOCUMENT_VERSION, PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION, PROJECT_TIME_NIGHT_BACKGROUND_SCENE_DOCUMENT_VERSION, PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION, PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION, PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION, PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION, PLAYER_START_PAUSE_BINDINGS_SCENE_DOCUMENT_VERSION, PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION, PATH_FOUNDATION_SCENE_DOCUMENT_VERSION, PROJECT_SCHEDULER_FOUNDATION_SCENE_DOCUMENT_VERSION, SCHEDULER_ACTOR_ROUTINE_EFFECTS_SCENE_DOCUMENT_VERSION, EXPANDED_CONTROL_SURFACE_SCENE_DOCUMENT_VERSION, FOLLOW_ACTOR_PATH_SMOOTH_SCENE_DOCUMENT_VERSION, NPC_DIALOGUE_REFERENCE_SCENE_DOCUMENT_VERSION, PROJECT_DIALOGUE_LIBRARY_SCENE_DOCUMENT_VERSION, PROJECT_SEQUENCE_CLIPS_SCENE_DOCUMENT_VERSION, PROJECT_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION, PROJECT_SEQUENCE_LIBRARY_SCENE_DOCUMENT_VERSION, PROJECT_SEQUENCE_TIMING_SCENE_DOCUMENT_VERSION, PROJECT_SEQUENCE_UNIFIED_VISIBILITY_SCENE_DOCUMENT_VERSION, RUNNER_V1_SCENE_DOCUMENT_VERSION, SCENE_TRANSITION_ENTITIES_SCENE_DOCUMENT_VERSION, SCENE_TRANSITION_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION, SCHEDULER_CONTROL_EFFECTS_SCENE_DOCUMENT_VERSION, SHADER_SKY_SCENE_DOCUMENT_VERSION, SHADER_SKY_HORIZON_HEIGHT_SCENE_DOCUMENT_VERSION, SHADER_SKY_STAR_HORIZON_FADE_SCENE_DOCUMENT_VERSION, SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION, STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION, PROJECT_NAME_SCENE_DOCUMENT_VERSION, NPC_COLLIDER_SCENE_DOCUMENT_VERSION, NPC_ENTITY_FOUNDATION_SCENE_DOCUMENT_VERSION, NPC_PRESENCE_SCENE_DOCUMENT_VERSION, WHITEBOX_BOX_LIGHT_VOLUME_SCENE_DOCUMENT_VERSION, CELESTIAL_BODY_OVERLAY_SCENE_DOCUMENT_VERSION, SCENE_DOCUMENT_VERSION, STATIC_SIMPLE_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION, TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION, RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION, WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION, DAWN_DUSK_BACKGROUND_IMAGE_SCENE_DOCUMENT_VERSION, WHITEBOX_PRIMITIVES_SCENE_DOCUMENT_VERSION, WHITEBOX_BEVEL_SCENE_DOCUMENT_VERSION, WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION, WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION, WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION, WORLD_TIME_ENVIRONMENT_SCENE_DOCUMENT_VERSION, WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION, cloneSceneEditorPreferences, createDefaultSceneEditorPreferences, createDefaultSceneLoadingScreenSettings, createProjectDocumentFromSceneDocument, type ProjectDocument, type SceneEditorPanelPreferences, type SceneEditorPreferences, type ProjectScene, type SceneLoadingScreenSettings, type SceneDocument } from "./scene-document"; import { createEmptyProjectDialogueLibrary, createProjectDialogue, createProjectDialogueLine, type ProjectDialogue, type ProjectDialogueLibrary } from "../dialogues/project-dialogues"; import { createEmptyProjectSequenceLibrary, createProjectSequence, type ProjectSequenceLibrary } from "../sequencer/project-sequences"; import { type SequenceClip } from "../sequencer/project-sequence-steps"; import { createScenePath, createScenePathPoint, type ScenePath, type ScenePathPoint } from "./paths"; import { createTerrain, normalizeTerrainCellSize, normalizeTerrainName, normalizeTerrainSampleCount, type Terrain } from "./terrains"; import { createDefaultProjectTimeSettings, normalizeProjectStartDayNumber, normalizeTimeOfDayHours, type ProjectTimeSettings } from "./project-time-settings"; import { createEmptyProjectScheduler, createProjectScheduleEveryDaySelection, createProjectScheduleRoutine, type ProjectScheduler } from "../scheduler/project-scheduler"; import { cloneWorldBackgroundSettings, createDefaultWorldCelestialOrbitAuthoringSettings, createDefaultWorldTimeOfDaySettings, createDefaultWorldTimePhaseProfile, createDefaultWorldShaderSkySettings, DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY, isAdvancedRenderingWaterReflectionMode, createDefaultAdvancedRenderingSettings, isBoxVolumeRenderPath, isAdvancedRenderingShadowMapSize, isAdvancedRenderingShadowType, isAdvancedRenderingToneMappingMode, isWorldBackgroundMode, isWorldShaderSkyPresetId, type WorldCelestialOrbitAuthoringSettings, type WorldShaderSkySettings, type AdvancedRenderingSettings, type WorldBackgroundSettings, type WorldTimeOfDaySettings, type WorldTimePhaseProfile, type WorldSettings } from "./world-settings"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function expectFiniteNumber(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) { throw new Error(`${label} must be a finite number.`); } return value; } function expectNonNegativeFiniteNumber(value: unknown, label: string): number { const numberValue = expectFiniteNumber(value, label); if (numberValue < 0) { throw new Error(`${label} must be zero or greater.`); } return numberValue; } function expectPositiveFiniteNumber(value: unknown, label: string): number { const numberValue = expectFiniteNumber(value, label); if (numberValue <= 0) { throw new Error(`${label} must be greater than zero.`); } return numberValue; } function expectString(value: unknown, label: string): string { if (typeof value !== "string") { throw new Error(`${label} must be a string.`); } return value; } function readOptionalSceneLoadingText( value: unknown, label: string ): string | null { if (value === undefined || value === null) { return null; } const normalizedValue = expectString(value, label).trim(); return normalizedValue.length === 0 ? null : normalizedValue; } function readOptionalDialogueResourceId( value: unknown, label: string ): string | null { if (value === undefined || value === null) { return null; } const dialogueId = expectString(value, label).trim(); return dialogueId.length === 0 ? null : dialogueId; } function readNpcDialogues( value: unknown, label: string, _legacyProjectDialogues: ProjectDialogueLibrary ): ProjectDialogue[] { if (value === undefined) { return []; } if (!Array.isArray(value)) { throw new Error(`${label} must be an array.`); } return value.map((dialogueValue, dialogueIndex) => { if (!isRecord(dialogueValue)) { throw new Error(`${label}.${dialogueIndex} must be an object.`); } const linesValue = dialogueValue.lines; if (!Array.isArray(linesValue)) { throw new Error(`${label}.${dialogueIndex}.lines must be an array.`); } return createProjectDialogue({ id: expectString(dialogueValue.id, `${label}.${dialogueIndex}.id`), title: expectString( dialogueValue.title, `${label}.${dialogueIndex}.title` ), lines: linesValue.map((lineValue, lineIndex) => { if (!isRecord(lineValue)) { throw new Error( `${label}.${dialogueIndex}.lines.${lineIndex} must be an object.` ); } return createProjectDialogueLine({ id: expectString( lineValue.id, `${label}.${dialogueIndex}.lines.${lineIndex}.id` ), text: expectString( lineValue.text, `${label}.${dialogueIndex}.lines.${lineIndex}.text` ) }); }) }); }); } function readSceneLoadingScreen( value: unknown, label: string, options: { allowMissing: boolean } ): SceneLoadingScreenSettings { if (value === undefined && options.allowMissing) { return createDefaultSceneLoadingScreenSettings(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return { colorHex: expectString(value.colorHex, `${label}.colorHex`), headline: readOptionalSceneLoadingText(value.headline, `${label}.headline`), description: readOptionalSceneLoadingText( value.description, `${label}.description` ) }; } function readSceneEditorPanelPreferences( value: unknown, label: string ): SceneEditorPanelPreferences { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const viewMode = expectString(value.viewMode, `${label}.viewMode`); const displayMode = expectString(value.displayMode, `${label}.displayMode`); if ( viewMode !== "perspective" && viewMode !== "top" && viewMode !== "front" && viewMode !== "side" ) { throw new Error( `${label}.viewMode must be a supported viewport view mode.` ); } if ( displayMode !== "normal" && displayMode !== "authoring" && displayMode !== "wireframe" ) { throw new Error( `${label}.displayMode must be a supported viewport display mode.` ); } return { viewMode, displayMode }; } function readSceneEditorPreferences( value: unknown, label: string, options: { allowMissing: boolean } ): SceneEditorPreferences { if (value === undefined && options.allowMissing) { return createDefaultSceneEditorPreferences(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const whiteboxSelectionMode = expectString( value.whiteboxSelectionMode, `${label}.whiteboxSelectionMode` ); const viewportLayoutMode = expectString( value.viewportLayoutMode, `${label}.viewportLayoutMode` ); const activeViewportPanelId = expectString( value.activeViewportPanelId, `${label}.activeViewportPanelId` ); if ( whiteboxSelectionMode !== "object" && whiteboxSelectionMode !== "face" && whiteboxSelectionMode !== "edge" && whiteboxSelectionMode !== "vertex" ) { throw new Error( `${label}.whiteboxSelectionMode must be a supported whitebox selection mode.` ); } if (viewportLayoutMode !== "single" && viewportLayoutMode !== "quad") { throw new Error( `${label}.viewportLayoutMode must be a supported viewport layout mode.` ); } if ( activeViewportPanelId !== "topLeft" && activeViewportPanelId !== "topRight" && activeViewportPanelId !== "bottomLeft" && activeViewportPanelId !== "bottomRight" ) { throw new Error( `${label}.activeViewportPanelId must be a supported viewport panel id.` ); } const defaultPreferences = createDefaultSceneEditorPreferences(); return cloneSceneEditorPreferences({ whiteboxSelectionMode, whiteboxSnapEnabled: expectBoolean( value.whiteboxSnapEnabled, `${label}.whiteboxSnapEnabled` ), whiteboxSnapStep: expectPositiveFiniteNumber( value.whiteboxSnapStep, `${label}.whiteboxSnapStep` ), viewportGridVisible: expectBoolean( value.viewportGridVisible, `${label}.viewportGridVisible` ), viewportLayoutMode, activeViewportPanelId, viewportQuadSplit: isRecord(value.viewportQuadSplit) ? { x: expectFiniteNumber( value.viewportQuadSplit.x, `${label}.viewportQuadSplit.x` ), y: expectFiniteNumber( value.viewportQuadSplit.y, `${label}.viewportQuadSplit.y` ) } : { ...defaultPreferences.viewportQuadSplit }, viewportPanels: isRecord(value.viewportPanels) ? { topLeft: readSceneEditorPanelPreferences( value.viewportPanels.topLeft, `${label}.viewportPanels.topLeft` ), topRight: readSceneEditorPanelPreferences( value.viewportPanels.topRight, `${label}.viewportPanels.topRight` ), bottomLeft: readSceneEditorPanelPreferences( value.viewportPanels.bottomLeft, `${label}.viewportPanels.bottomLeft` ), bottomRight: readSceneEditorPanelPreferences( value.viewportPanels.bottomRight, `${label}.viewportPanels.bottomRight` ) } : defaultPreferences.viewportPanels }); } function expectBoolean(value: unknown, label: string): boolean { if (typeof value !== "boolean") { throw new Error(`${label} must be a boolean.`); } return value; } function expectStringArray(value: unknown, label: string): string[] { if ( !Array.isArray(value) || value.some((entry) => typeof entry !== "string") ) { throw new Error(`${label} must be a string array.`); } return [...value]; } function expectHexColor(value: unknown, label: string): string { const normalizedValue = expectString(value, label); if (!/^#[0-9a-f]{6}$/i.test(normalizedValue)) { throw new Error(`${label} must use #RRGGBB format.`); } return normalizedValue; } function expectLiteralString( value: unknown, expectedValue: T, label: string ): T { if (value !== expectedValue) { throw new Error(`${label} must be ${expectedValue}.`); } return expectedValue; } function readOptionalBoolean( value: unknown, label: string, fallback: boolean ): boolean { if (value === undefined) { return fallback; } return expectBoolean(value, label); } function readOptionalFiniteNumber( value: unknown, label: string, fallback: number ): number { if (value === undefined) { return fallback; } return expectFiniteNumber(value, label); } function readOptionalNonNegativeFiniteNumber( value: unknown, label: string, fallback: number ): number { if (value === undefined) { return fallback; } return expectNonNegativeFiniteNumber(value, label); } function readOptionalPositiveFiniteNumber( value: unknown, label: string, fallback: number ): number { if (value === undefined) { return fallback; } return expectPositiveFiniteNumber(value, label); } function readOptionalPositiveInteger( value: unknown, label: string, fallback: number ): number { if (value === undefined) { return fallback; } const integerValue = expectFiniteNumber(value, label); if (!Number.isInteger(integerValue) || integerValue <= 0) { throw new Error(`${label} must be a positive integer.`); } return integerValue; } 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 { if (value === undefined) { return fallback; } if (!guard(value)) { throw new Error(`${label} must be a supported value.`); } return value; } function readAdvancedRenderingSettings( value: unknown ): AdvancedRenderingSettings { const defaults = createDefaultAdvancedRenderingSettings(); if (value === undefined) { return defaults; } if (!isRecord(value)) { throw new Error("world.advancedRendering must be an object."); } if (value.shadows !== undefined && !isRecord(value.shadows)) { 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.bloom !== undefined && !isRecord(value.bloom)) { throw new Error("world.advancedRendering.bloom must be an object."); } if (value.toneMapping !== undefined && !isRecord(value.toneMapping)) { throw new Error("world.advancedRendering.toneMapping must be an object."); } if (value.depthOfField !== undefined && !isRecord(value.depthOfField)) { throw new Error("world.advancedRendering.depthOfField must be an object."); } if (value.whiteboxBevel !== undefined && !isRecord(value.whiteboxBevel)) { throw new Error("world.advancedRendering.whiteboxBevel must be an object."); } const shadows = value.shadows 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 whiteboxBevel = value.whiteboxBevel as | Record | undefined; const shadowsMapSize = readOptionalAllowedValue( shadows?.mapSize, "world.advancedRendering.shadows.mapSize", defaults.shadows.mapSize, isAdvancedRenderingShadowMapSize ); const shadowsType = readOptionalAllowedValue( shadows?.type, "world.advancedRendering.shadows.type", defaults.shadows.type, isAdvancedRenderingShadowType ); const toneMappingMode = readOptionalAllowedValue( toneMapping?.mode, "world.advancedRendering.toneMapping.mode", 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 waterReflectionMode = readOptionalAllowedValue( value.waterReflectionMode, "world.advancedRendering.waterReflectionMode", defaults.waterReflectionMode, isAdvancedRenderingWaterReflectionMode ); return { enabled: readOptionalBoolean( value.enabled, "world.advancedRendering.enabled", defaults.enabled ), shadows: { 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 ) }, ambientOcclusion: { enabled: readOptionalBoolean( ambientOcclusion?.enabled, "world.advancedRendering.ambientOcclusion.enabled", defaults.ambientOcclusion.enabled ), intensity: readOptionalNonNegativeFiniteNumber( ambientOcclusion?.intensity, "world.advancedRendering.ambientOcclusion.intensity", defaults.ambientOcclusion.intensity ), radius: readOptionalNonNegativeFiniteNumber( ambientOcclusion?.radius, "world.advancedRendering.ambientOcclusion.radius", defaults.ambientOcclusion.radius ), samples: readOptionalPositiveInteger( ambientOcclusion?.samples, "world.advancedRendering.ambientOcclusion.samples", defaults.ambientOcclusion.samples ) }, bloom: { enabled: readOptionalBoolean( bloom?.enabled, "world.advancedRendering.bloom.enabled", defaults.bloom.enabled ), intensity: readOptionalNonNegativeFiniteNumber( bloom?.intensity, "world.advancedRendering.bloom.intensity", defaults.bloom.intensity ), threshold: readOptionalNonNegativeFiniteNumber( bloom?.threshold, "world.advancedRendering.bloom.threshold", defaults.bloom.threshold ), 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 ) }, depthOfField: { enabled: readOptionalBoolean( depthOfField?.enabled, "world.advancedRendering.depthOfField.enabled", defaults.depthOfField.enabled ), focusDistance: readOptionalNonNegativeFiniteNumber( depthOfField?.focusDistance, "world.advancedRendering.depthOfField.focusDistance", defaults.depthOfField.focusDistance ), focalLength: readOptionalNonNegativeFiniteNumber( depthOfField?.focalLength, "world.advancedRendering.depthOfField.focalLength", defaults.depthOfField.focalLength ), bokehScale: readOptionalNonNegativeFiniteNumber( depthOfField?.bokehScale, "world.advancedRendering.depthOfField.bokehScale", defaults.depthOfField.bokehScale ) }, whiteboxBevel: { enabled: readOptionalBoolean( whiteboxBevel?.enabled, "world.advancedRendering.whiteboxBevel.enabled", defaults.whiteboxBevel.enabled ), edgeWidth: readOptionalNonNegativeFiniteNumber( whiteboxBevel?.edgeWidth, "world.advancedRendering.whiteboxBevel.edgeWidth", defaults.whiteboxBevel.edgeWidth ), normalStrength: readOptionalNonNegativeFiniteNumber( whiteboxBevel?.normalStrength, "world.advancedRendering.whiteboxBevel.normalStrength", defaults.whiteboxBevel.normalStrength ) }, fogPath, waterPath, waterReflectionMode }; } function readProjectTimeSettings( value: unknown, label: string, options: { allowMissing: boolean } ): ProjectTimeSettings { if (value === undefined && options.allowMissing) { return createDefaultProjectTimeSettings(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const defaults = createDefaultProjectTimeSettings(); return { startDayNumber: normalizeProjectStartDayNumber( readOptionalPositiveFiniteNumber( value.startDayNumber, `${label}.startDayNumber`, defaults.startDayNumber ) ), startTimeOfDayHours: normalizeTimeOfDayHours( readOptionalFiniteNumber( value.startTimeOfDayHours, `${label}.startTimeOfDayHours`, defaults.startTimeOfDayHours ) ), dayLengthMinutes: readOptionalPositiveFiniteNumber( value.dayLengthMinutes, `${label}.dayLengthMinutes`, defaults.dayLengthMinutes ), sunriseTimeOfDayHours: normalizeTimeOfDayHours( readOptionalFiniteNumber( value.sunriseTimeOfDayHours, `${label}.sunriseTimeOfDayHours`, defaults.sunriseTimeOfDayHours ) ), sunsetTimeOfDayHours: normalizeTimeOfDayHours( readOptionalFiniteNumber( value.sunsetTimeOfDayHours, `${label}.sunsetTimeOfDayHours`, defaults.sunsetTimeOfDayHours ) ), dawnDurationHours: readOptionalPositiveFiniteNumber( value.dawnDurationHours, `${label}.dawnDurationHours`, defaults.dawnDurationHours ), duskDurationHours: readOptionalPositiveFiniteNumber( value.duskDurationHours, `${label}.duskDurationHours`, defaults.duskDurationHours ) }; } function expectOptionalString( value: unknown, label: string ): string | undefined { if (value === undefined || value === null) { return undefined; } return expectString(value, label); } function readProjectAssetBoundingBox( value: unknown, label: string ): ProjectAssetBoundingBox { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return { min: readVec3(value.min, `${label}.min`), max: readVec3(value.max, `${label}.max`), size: readVec3(value.size, `${label}.size`) }; } function readOptionalBrushName( value: unknown, label: string ): string | undefined { return normalizeBrushName(expectOptionalString(value, label)); } function readOptionalEntityName( value: unknown, label: string ): string | undefined { return normalizeEntityName(expectOptionalString(value, label)); } function readBoxBrushVolumeSettings( value: unknown, label: string ): BoxBrushVolumeSettings { if (value === undefined) { return { mode: "none" }; } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const mode = readOptionalAllowedValue( value.mode, `${label}.mode`, "none", isBoxBrushVolumeMode ); if (mode === "none") { return { mode: "none" }; } if (mode === "water") { const defaults = createDefaultBoxBrushWaterSettings(); if (value.water !== undefined && !isRecord(value.water)) { throw new Error(`${label}.water must be an object.`); } const water = (value.water ?? {}) as Record; return { mode: "water", water: { 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 ), foamContactLimit: readOptionalPositiveIntegerWithMax( water.foamContactLimit, `${label}.water.foamContactLimit`, defaults.foamContactLimit, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT ), surfaceDisplacementEnabled: readOptionalBoolean( water.surfaceDisplacementEnabled, `${label}.water.surfaceDisplacementEnabled`, defaults.surfaceDisplacementEnabled ) } }; } if (mode === "fog") { const defaults = createDefaultBoxBrushFogSettings(); if (value.fog !== undefined && !isRecord(value.fog)) { throw new Error(`${label}.fog must be an object.`); } const fog = (value.fog ?? {}) as Record; 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 ) } }; } const defaults = createDefaultBoxBrushLightSettings(); if (value.light !== undefined && !isRecord(value.light)) { throw new Error(`${label}.light must be an object.`); } const light = (value.light ?? {}) as Record; return { mode: "light", light: { colorHex: light.colorHex === undefined ? defaults.colorHex : expectHexColor(light.colorHex, `${label}.light.colorHex`), intensity: readOptionalNonNegativeFiniteNumber( light.intensity, `${label}.light.intensity`, defaults.intensity ), padding: readOptionalNonNegativeFiniteNumber( light.padding, `${label}.light.padding`, defaults.padding ), falloff: readOptionalAllowedValue( light.falloff, `${label}.light.falloff`, defaults.falloff, isBoxBrushLightFalloffMode ) } }; } function expectEmptyCollection>( value: unknown, label: string ): T { if (value === undefined) { return {} as T; } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } if (Object.keys(value).length > 0) { throw new Error(`${label} must be empty.`); } return value as T; } function readModelAssetMetadata( value: unknown, label: string ): ModelAssetMetadata { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const format = expectString(value.format, `${label}.format`); if (format !== "glb" && format !== "gltf") { throw new Error(`${label}.format must be glb or gltf.`); } const sceneName = value.sceneName === null ? null : (expectOptionalString(value.sceneName, `${label}.sceneName`) ?? null); return { kind: "model", format, sceneName, nodeCount: expectNonNegativeFiniteNumber( value.nodeCount, `${label}.nodeCount` ), meshCount: expectNonNegativeFiniteNumber( value.meshCount, `${label}.meshCount` ), materialNames: expectStringArray( value.materialNames, `${label}.materialNames` ), textureNames: expectStringArray( value.textureNames, `${label}.textureNames` ), animationNames: expectStringArray( value.animationNames, `${label}.animationNames` ), boundingBox: value.boundingBox === null ? null : readProjectAssetBoundingBox( value.boundingBox, `${label}.boundingBox` ), warnings: expectStringArray(value.warnings, `${label}.warnings`) }; } function readImageAssetMetadata( value: unknown, label: string ): ImageAssetMetadata { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return { kind: "image", width: expectPositiveFiniteNumber(value.width, `${label}.width`), height: expectPositiveFiniteNumber(value.height, `${label}.height`), hasAlpha: expectBoolean(value.hasAlpha, `${label}.hasAlpha`), warnings: expectStringArray(value.warnings, `${label}.warnings`) }; } function readAudioAssetMetadata( value: unknown, label: string ): AudioAssetMetadata { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return { kind: "audio", durationSeconds: value.durationSeconds === null ? null : expectNonNegativeFiniteNumber( value.durationSeconds, `${label}.durationSeconds` ), channelCount: value.channelCount === null ? null : expectPositiveFiniteNumber( value.channelCount, `${label}.channelCount` ), sampleRateHz: value.sampleRateHz === null ? null : expectPositiveFiniteNumber( value.sampleRateHz, `${label}.sampleRateHz` ), warnings: expectStringArray(value.warnings, `${label}.warnings`) }; } function readProjectAsset(value: unknown, label: string): ProjectAssetRecord { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = value.kind; if (!isProjectAssetKind(kind)) { throw new Error(`${label}.kind must be model, image, or audio.`); } const id = expectString(value.id, `${label}.id`); const sourceName = expectString(value.sourceName, `${label}.sourceName`); const mimeType = expectString(value.mimeType, `${label}.mimeType`); const storageKey = expectString(value.storageKey, `${label}.storageKey`); const byteLength = expectPositiveFiniteNumber( value.byteLength, `${label}.byteLength` ); switch (kind) { case "model": return { id, kind, sourceName, mimeType, storageKey, byteLength, metadata: readModelAssetMetadata(value.metadata, `${label}.metadata`) }; case "image": return { id, kind, sourceName, mimeType, storageKey, byteLength, metadata: readImageAssetMetadata(value.metadata, `${label}.metadata`) }; case "audio": return { id, kind, sourceName, mimeType, storageKey, byteLength, metadata: readAudioAssetMetadata(value.metadata, `${label}.metadata`) }; } } function readAssets(value: unknown): SceneDocument["assets"] { if (!isRecord(value)) { throw new Error("assets must be a record."); } const assets: SceneDocument["assets"] = {}; for (const [assetId, assetValue] of Object.entries(value)) { const asset = readProjectAsset(assetValue, `assets.${assetId}`); if (asset.id !== assetId) { throw new Error(`assets.${assetId}.id must match the registry key.`); } assets[assetId] = asset; } return assets; } function readModelInstanceCollisionSettings( value: unknown, label: string ): ModelInstanceCollisionSettings { if (value === undefined) { return createModelInstanceCollisionSettings(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const mode = readOptionalAllowedValue( value.mode, `${label}.mode`, "none", isModelInstanceCollisionMode ); return createModelInstanceCollisionSettings({ mode, visible: readOptionalBoolean(value.visible, `${label}.visible`, false) }); } function readPlayerStartColliderSettings(value: unknown, label: string) { if (value === undefined) { return createPlayerStartColliderSettings(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const mode = readOptionalAllowedValue( value.mode, `${label}.mode`, "capsule", (candidate): candidate is "capsule" | "box" | "none" => typeof candidate === "string" && isPlayerStartColliderMode(candidate) ); return createPlayerStartColliderSettings({ mode, eyeHeight: value.eyeHeight === undefined ? undefined : expectPositiveFiniteNumber(value.eyeHeight, `${label}.eyeHeight`), 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`) }); } function readNpcColliderSettings(value: unknown, label: string) { if (value === undefined) { return createNpcColliderSettings(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const mode = readOptionalAllowedValue( value.mode, `${label}.mode`, "capsule", (candidate): candidate is "capsule" | "box" | "none" => typeof candidate === "string" && isPlayerStartColliderMode(candidate) ); return createNpcColliderSettings({ mode, eyeHeight: value.eyeHeight === undefined ? undefined : expectPositiveFiniteNumber(value.eyeHeight, `${label}.eyeHeight`), 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`) }); } function readPlayerStartNavigationMode(value: unknown, label: string) { return readOptionalAllowedValue( value, label, "firstPerson", (candidate): candidate is "firstPerson" | "thirdPerson" => typeof candidate === "string" && isPlayerStartNavigationMode(candidate) ); } function readCameraRigTargetRef(value: unknown, label: string) { if (value === undefined) { return createCameraRigPlayerTargetRef(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectString(value.kind, `${label}.kind`); if (!isCameraRigTargetKind(kind)) { throw new Error( `${label}.kind must be player, actor, entity, or worldPoint.` ); } switch (kind) { case "player": return createCameraRigPlayerTargetRef(); case "actor": return createCameraRigActorTargetRef( expectString(value.actorId, `${label}.actorId`) ); case "entity": return createCameraRigEntityTargetRef( expectString(value.entityId, `${label}.entityId`) ); case "worldPoint": return createCameraRigWorldPointTargetRef( readVec3(value.point, `${label}.point`) ); } } function readCameraRigLookAroundSettings(value: unknown, label: string) { if (value === undefined) { return createCameraRigLookAroundSettings(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return createCameraRigLookAroundSettings({ enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, true), yawLimitDegrees: value.yawLimitDegrees === undefined ? undefined : expectNonNegativeFiniteNumber( value.yawLimitDegrees, `${label}.yawLimitDegrees` ), pitchLimitDegrees: value.pitchLimitDegrees === undefined ? undefined : expectNonNegativeFiniteNumber( value.pitchLimitDegrees, `${label}.pitchLimitDegrees` ), recenterSpeed: value.recenterSpeed === undefined ? undefined : expectNonNegativeFiniteNumber( value.recenterSpeed, `${label}.recenterSpeed` ) }); } function readPlayerStartKeyboardBindingCode( value: unknown, label: string, fallback: (typeof DEFAULT_PLAYER_START_KEYBOARD_BINDINGS)[keyof typeof DEFAULT_PLAYER_START_KEYBOARD_BINDINGS] ) { return readOptionalAllowedValue( value, label, fallback, (candidate): candidate is typeof fallback => typeof candidate === "string" && isPlayerStartKeyboardBindingCode(candidate) ); } function readPlayerStartGamepadBinding( value: unknown, label: string, fallback: PlayerStartGamepadBinding ): PlayerStartGamepadBinding { return readOptionalAllowedValue( value, label, fallback, (candidate): candidate is typeof fallback => typeof candidate === "string" && isPlayerStartGamepadBinding(candidate) ); } function readPlayerStartGamepadActionBinding( value: unknown, label: string, fallback: PlayerStartGamepadActionBinding ): PlayerStartGamepadActionBinding { return readOptionalAllowedValue( value, label, fallback, (candidate): candidate is typeof fallback => typeof candidate === "string" && isPlayerStartGamepadActionBinding(candidate) ); } function readPlayerStartGamepadCameraLookBinding( value: unknown, label: string, fallback: PlayerStartGamepadCameraLookBinding ): PlayerStartGamepadCameraLookBinding { return readOptionalAllowedValue( value, label, fallback, (candidate): candidate is typeof fallback => typeof candidate === "string" && isPlayerStartGamepadCameraLookBinding(candidate) ); } function readPlayerStartInputBindings(value: unknown, label: string) { if (value === undefined) { return createPlayerStartInputBindings(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const keyboard = value.keyboard; const gamepad = value.gamepad; if (keyboard !== undefined && !isRecord(keyboard)) { throw new Error(`${label}.keyboard must be an object.`); } if (gamepad !== undefined && !isRecord(gamepad)) { throw new Error(`${label}.gamepad must be an object.`); } return createPlayerStartInputBindings({ keyboard: { moveForward: readPlayerStartKeyboardBindingCode( keyboard?.moveForward, `${label}.keyboard.moveForward`, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.moveForward ), moveBackward: readPlayerStartKeyboardBindingCode( keyboard?.moveBackward, `${label}.keyboard.moveBackward`, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.moveBackward ), moveLeft: readPlayerStartKeyboardBindingCode( keyboard?.moveLeft, `${label}.keyboard.moveLeft`, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.moveLeft ), moveRight: readPlayerStartKeyboardBindingCode( keyboard?.moveRight, `${label}.keyboard.moveRight`, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.moveRight ), jump: readPlayerStartKeyboardBindingCode( keyboard?.jump, `${label}.keyboard.jump`, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.jump ), sprint: readPlayerStartKeyboardBindingCode( keyboard?.sprint, `${label}.keyboard.sprint`, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.sprint ), crouch: readPlayerStartKeyboardBindingCode( keyboard?.crouch, `${label}.keyboard.crouch`, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.crouch ), pauseTime: readPlayerStartKeyboardBindingCode( keyboard?.pauseTime, `${label}.keyboard.pauseTime`, DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.pauseTime ) }, gamepad: { moveForward: readPlayerStartGamepadBinding( gamepad?.moveForward, `${label}.gamepad.moveForward`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.moveForward ), moveBackward: readPlayerStartGamepadBinding( gamepad?.moveBackward, `${label}.gamepad.moveBackward`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.moveBackward ), moveLeft: readPlayerStartGamepadBinding( gamepad?.moveLeft, `${label}.gamepad.moveLeft`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.moveLeft ), moveRight: readPlayerStartGamepadBinding( gamepad?.moveRight, `${label}.gamepad.moveRight`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.moveRight ), jump: readPlayerStartGamepadActionBinding( gamepad?.jump, `${label}.gamepad.jump`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.jump ), sprint: readPlayerStartGamepadActionBinding( gamepad?.sprint, `${label}.gamepad.sprint`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.sprint ), crouch: readPlayerStartGamepadActionBinding( gamepad?.crouch, `${label}.gamepad.crouch`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.crouch ), pauseTime: readPlayerStartGamepadActionBinding( gamepad?.pauseTime, `${label}.gamepad.pauseTime`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.pauseTime ), cameraLook: readPlayerStartGamepadCameraLookBinding( gamepad?.cameraLook, `${label}.gamepad.cameraLook`, DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.cameraLook ) } }); } function readPlayerStartMovementTemplate(value: unknown, label: string) { if (value === undefined) { return createPlayerStartMovementTemplate(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = readOptionalAllowedValue( value.kind, `${label}.kind`, "default", (candidate): candidate is PlayerStartMovementTemplateKind => typeof candidate === "string" && isPlayerStartMovementTemplateKind(candidate) ); const preset = createPlayerStartMovementTemplate({ kind }); const capabilities = value.capabilities; const jump = value.jump; const sprint = value.sprint; const crouch = value.crouch; if (capabilities !== undefined && !isRecord(capabilities)) { throw new Error(`${label}.capabilities must be an object.`); } if (jump !== undefined && !isRecord(jump)) { throw new Error(`${label}.jump must be an object.`); } if (sprint !== undefined && !isRecord(sprint)) { throw new Error(`${label}.sprint must be an object.`); } if (crouch !== undefined && !isRecord(crouch)) { throw new Error(`${label}.crouch must be an object.`); } return createPlayerStartMovementTemplate({ kind, moveSpeed: value.moveSpeed === undefined ? undefined : expectPositiveFiniteNumber(value.moveSpeed, `${label}.moveSpeed`), maxSpeed: value.maxSpeed === undefined ? preset.maxSpeed : expectNonNegativeFiniteNumber(value.maxSpeed, `${label}.maxSpeed`), maxStepHeight: value.maxStepHeight === undefined ? preset.maxStepHeight : expectNonNegativeFiniteNumber( value.maxStepHeight, `${label}.maxStepHeight` ), capabilities: { jump: readOptionalBoolean( capabilities?.jump, `${label}.capabilities.jump`, preset.capabilities.jump ), sprint: readOptionalBoolean( capabilities?.sprint, `${label}.capabilities.sprint`, preset.capabilities.sprint ), crouch: readOptionalBoolean( capabilities?.crouch, `${label}.capabilities.crouch`, preset.capabilities.crouch ) }, jump: { speed: jump?.speed === undefined ? preset.jump.speed : expectPositiveFiniteNumber(jump.speed, `${label}.jump.speed`), bufferMs: jump?.bufferMs === undefined ? preset.jump.bufferMs : expectNonNegativeFiniteNumber( jump.bufferMs, `${label}.jump.bufferMs` ), coyoteTimeMs: jump?.coyoteTimeMs === undefined ? preset.jump.coyoteTimeMs : expectNonNegativeFiniteNumber( jump.coyoteTimeMs, `${label}.jump.coyoteTimeMs` ), variableHeight: readOptionalBoolean( jump?.variableHeight, `${label}.jump.variableHeight`, preset.jump.variableHeight ), moveWhileJumping: readOptionalBoolean( jump?.moveWhileJumping, `${label}.jump.moveWhileJumping`, preset.jump.moveWhileJumping ), moveWhileFalling: readOptionalBoolean( jump?.moveWhileFalling, `${label}.jump.moveWhileFalling`, preset.jump.moveWhileFalling ), directionOnly: readOptionalBoolean( jump?.directionOnly, `${label}.jump.directionOnly`, preset.jump.directionOnly ), maxHoldMs: jump?.maxHoldMs === undefined ? preset.jump.maxHoldMs : expectPositiveFiniteNumber( jump.maxHoldMs, `${label}.jump.maxHoldMs` ), bunnyHop: readOptionalBoolean( jump?.bunnyHop, `${label}.jump.bunnyHop`, preset.jump.bunnyHop ), bunnyHopBoost: jump?.bunnyHopBoost === undefined ? preset.jump.bunnyHopBoost : expectNonNegativeFiniteNumber( jump.bunnyHopBoost, `${label}.jump.bunnyHopBoost` ) }, sprint: { speedMultiplier: sprint?.speedMultiplier === undefined ? preset.sprint.speedMultiplier : expectPositiveFiniteNumber( sprint.speedMultiplier, `${label}.sprint.speedMultiplier` ) }, crouch: { speedMultiplier: crouch?.speedMultiplier === undefined ? preset.crouch.speedMultiplier : expectPositiveFiniteNumber( crouch.speedMultiplier, `${label}.crouch.speedMultiplier` ) } }); } function readModelInstance( value: unknown, label: string, assets: SceneDocument["assets"] ): ModelInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const assetId = expectString(value.assetId, `${label}.assetId`); const asset = assets[assetId]; if (asset === undefined) { throw new Error(`${label}.assetId references missing asset ${assetId}.`); } if (asset.kind !== "model") { throw new Error(`${label}.assetId must reference a model asset.`); } return createModelInstance({ id: expectString(value.id, `${label}.id`), assetId, name: normalizeModelInstanceName( expectOptionalString(value.name, `${label}.name`) ), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_MODEL_INSTANCE_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_MODEL_INSTANCE_ENABLED ), position: readVec3(value.position, `${label}.position`), rotationDegrees: readVec3( value.rotationDegrees, `${label}.rotationDegrees` ), scale: readVec3(value.scale, `${label}.scale`), collision: readModelInstanceCollisionSettings( value.collision, `${label}.collision` ), 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`) }); } function readModelInstances( value: unknown, assets: SceneDocument["assets"] ): SceneDocument["modelInstances"] { if (!isRecord(value)) { throw new Error("modelInstances must be a record."); } const modelInstances: SceneDocument["modelInstances"] = {}; for (const [modelInstanceId, modelInstanceValue] of Object.entries(value)) { const modelInstance = readModelInstance( modelInstanceValue, `modelInstances.${modelInstanceId}`, assets ); if (modelInstance.id !== modelInstanceId) { throw new Error( `modelInstances.${modelInstanceId}.id must match the registry key.` ); } modelInstances[modelInstanceId] = modelInstance; } return modelInstances; } function readTerrain(value: unknown, label: string): Terrain { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const sampleCountX = normalizeTerrainSampleCount( expectFiniteNumber(value.sampleCountX, `${label}.sampleCountX`), `${label}.sampleCountX` ); const sampleCountZ = normalizeTerrainSampleCount( expectFiniteNumber(value.sampleCountZ, `${label}.sampleCountZ`), `${label}.sampleCountZ` ); const cellSize = normalizeTerrainCellSize( expectFiniteNumber(value.cellSize, `${label}.cellSize`) ); if (!Array.isArray(value.heights)) { throw new Error(`${label}.heights must be an array.`); } const heights = value.heights.map((heightValue, index) => expectFiniteNumber(heightValue, `${label}.heights.${index}`) ); const layers = value.layers === undefined ? undefined : (() => { if ( !Array.isArray(value.layers) || value.layers.some((layer) => !isRecord(layer)) ) { throw new Error( `${label}.layers must be an array of layer objects.` ); } return value.layers.map((layerValue, layerIndex) => ({ materialId: layerValue.materialId === undefined || layerValue.materialId === null ? null : expectString( layerValue.materialId, `${label}.layers.${layerIndex}.materialId` ) })); })(); const paintWeights = value.paintWeights === undefined ? undefined : (() => { if (!Array.isArray(value.paintWeights)) { throw new Error(`${label}.paintWeights must be an array.`); } return value.paintWeights.map((paintWeight, index) => expectFiniteNumber(paintWeight, `${label}.paintWeights.${index}`) ); })(); return createTerrain({ id: expectString(value.id, `${label}.id`), name: normalizeTerrainName( expectOptionalString(value.name, `${label}.name`) ), visible: expectBoolean(value.visible, `${label}.visible`), enabled: expectBoolean(value.enabled, `${label}.enabled`), collisionEnabled: value.collisionEnabled === undefined ? undefined : expectBoolean(value.collisionEnabled, `${label}.collisionEnabled`), position: readVec3(value.position, `${label}.position`), sampleCountX, sampleCountZ, cellSize, heights, layers, paintWeights }); } function readTerrains(value: unknown): SceneDocument["terrains"] { if (value === undefined) { return {}; } if (!isRecord(value)) { throw new Error("terrains must be a record."); } const terrains: SceneDocument["terrains"] = {}; for (const [terrainId, terrainValue] of Object.entries(value)) { const terrain = readTerrain(terrainValue, `terrains.${terrainId}`); if (terrain.id !== terrainId) { throw new Error(`terrains.${terrainId}.id must match the registry key.`); } terrains[terrainId] = terrain; } return terrains; } function readVec2(value: unknown, label: string) { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return { x: expectFiniteNumber(value.x, `${label}.x`), y: expectFiniteNumber(value.y, `${label}.y`) }; } function readVec3(value: unknown, label: string) { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return { x: expectFiniteNumber(value.x, `${label}.x`), y: expectFiniteNumber(value.y, `${label}.y`), z: expectFiniteNumber(value.z, `${label}.z`) }; } function readOptionalVec3( value: unknown, label: string, fallback: { x: number; y: number; z: number } ) { if (value === undefined) { return { x: fallback.x, y: fallback.y, z: fallback.z }; } return readVec3(value, label); } 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 readMaterialRegistry( value: unknown, label: string, options: { allowLegacyStarterPatterns: boolean } ): SceneDocument["materials"] { if (!isRecord(value)) { throw new Error(`${label} must be a record.`); } const materials: SceneDocument["materials"] = {}; const starterRegistry = createStarterMaterialRegistry(); for (const [materialId, materialValue] of Object.entries(value)) { if (!isRecord(materialValue)) { throw new Error(`${label}.${materialId} must be an object.`); } if ( options.allowLegacyStarterPatterns && materialValue.pattern !== undefined ) { const legacyMaterialId = expectString( materialValue.id, `${label}.${materialId}.id` ); if (legacyMaterialId !== materialId) { throw new Error( `${label}.${materialId}.id must match the registry key.` ); } if (starterRegistry[materialId] === undefined) { throw new Error( `${label}.${materialId} is not a supported legacy starter material id.` ); } materials[materialId] = starterRegistry[materialId]; continue; } const workflow = expectString( materialValue.workflow, `${label}.${materialId}.workflow` ); if ( workflow !== "roughness-only" && workflow !== "metallic-roughness" && workflow !== "specular-roughness" ) { throw new Error( `${label}.${materialId}.workflow must be a supported material workflow.` ); } const previewImageName = expectString( materialValue.previewImageName, `${label}.${materialId}.previewImageName` ); if ( previewImageName !== "preview.webp" && previewImageName !== "preview_sphere.webp" ) { throw new Error( `${label}.${materialId}.previewImageName must be preview.webp or preview_sphere.webp.` ); } const sizeCmValue = materialValue.sizeCm; if (!isRecord(sizeCmValue)) { throw new Error(`${label}.${materialId}.sizeCm must be an object.`); } const material: MaterialDef = { id: expectString(materialValue.id, `${label}.${materialId}.id`), name: expectString(materialValue.name, `${label}.${materialId}.name`), assetFolder: expectString( materialValue.assetFolder, `${label}.${materialId}.assetFolder` ), workflow, previewImageName, sizeCm: { width: expectPositiveFiniteNumber( sizeCmValue.width, `${label}.${materialId}.sizeCm.width` ), height: expectPositiveFiniteNumber( sizeCmValue.height, `${label}.${materialId}.sizeCm.height` ) }, swatchColorHex: expectHexColor( materialValue.swatchColorHex, `${label}.${materialId}.swatchColorHex` ), tags: expectStringArray(materialValue.tags, `${label}.${materialId}.tags`) }; if (material.id !== materialId) { throw new Error(`${label}.${materialId}.id must match the registry key.`); } materials[materialId] = material; } return materials; } function readFaceUvState(value: unknown, label: string): FaceUvState { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const rotationQuarterTurns = expectFiniteNumber( value.rotationQuarterTurns, `${label}.rotationQuarterTurns` ); if (!isFaceUvRotationQuarterTurns(rotationQuarterTurns)) { throw new Error(`${label}.rotationQuarterTurns must be 0, 1, 2, or 3.`); } const scale = readVec2(value.scale, `${label}.scale`); if (scale.x <= 0 || scale.y <= 0) { throw new Error(`${label}.scale values must remain positive.`); } return { offset: readVec2(value.offset, `${label}.offset`), scale, rotationQuarterTurns, flipU: expectBoolean(value.flipU, `${label}.flipU`), flipV: expectBoolean(value.flipV, `${label}.flipV`) }; } function readBrushFace( value: unknown, label: string, materials: SceneDocument["materials"], allowMissingUvState: boolean ): BrushFace { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const materialId = value.materialId; 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}.` ); } return { materialId: materialId ?? null, uv: value.uv === undefined && allowMissingUvState ? createDefaultFaceUvState() : readFaceUvState(value.uv, `${label}.uv`) }; } function readBoxBrushFaces( value: unknown, label: string, materials: SceneDocument["materials"], allowMissingUvState: boolean ): BoxBrushFaces { return readBrushFaces(value, label, materials, allowMissingUvState, [ "posX", "negX", "posY", "negY", "posZ", "negZ" ]) as BoxBrushFaces; } function readBrushFaces( value: unknown, label: string, materials: SceneDocument["materials"], allowMissingUvState: boolean, faceIds: readonly WhiteboxFaceId[] ) { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const supportedFaceIds = new Set(faceIds); const extraFaceKeys = Object.keys(value).filter( (faceId) => !supportedFaceIds.has(faceId) ); if (extraFaceKeys.length > 0) { throw new Error( `${label} contains unsupported face ids: ${extraFaceKeys.join(", ")}.` ); } return Object.fromEntries( faceIds.map((faceId) => [ faceId, readBrushFace( value[faceId], `${label}.${faceId}`, materials, allowMissingUvState ) ]) ); } function readBoxBrushGeometry( value: unknown, label: string, size: { x: number; y: number; z: number } ) { return readBrushGeometry( value, label, createDefaultBoxBrushGeometry(size), BOX_VERTEX_IDS ); } function readBrushGeometry }>( value: unknown, label: string, fallbackGeometry: T, vertexIds: readonly WhiteboxVertexId[] ): T { if (value === undefined) { return fallbackGeometry; } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } if (!isRecord(value.vertices)) { throw new Error(`${label}.vertices must be an object.`); } const vertices = value.vertices; const extraVertexKeys = Object.keys(vertices).filter( (vertexId) => !vertexIds.includes(vertexId as WhiteboxVertexId) ); if (extraVertexKeys.length > 0) { throw new Error( `${label}.vertices contains unsupported vertex ids: ${extraVertexKeys.join(", ")}.` ); } return { vertices: Object.fromEntries( vertexIds.map((vertexId) => [ vertexId, readVec3(vertices[vertexId], `${label}.vertices.${vertexId}`) ]) ) as T["vertices"] } as T; } function readBrushes( value: unknown, materials: SceneDocument["materials"], allowMissingUvState: boolean ): SceneDocument["brushes"] { if (!isRecord(value)) { throw new Error("brushes must be a record."); } const brushes: SceneDocument["brushes"] = {}; for (const [brushId, brushValue] of Object.entries(value)) { if (!isRecord(brushValue)) { throw new Error(`brushes.${brushId} must be an object.`); } const kind = brushValue.kind; const center = readVec3(brushValue.center, `brushes.${brushId}.center`); const size = readVec3(brushValue.size, `brushes.${brushId}.size`); if (size.x <= 0 || size.y <= 0 || size.z <= 0) { throw new Error(`brushes.${brushId}.size values must be positive.`); } const sharedBrushFields = { id: expectString(brushValue.id, `brushes.${brushId}.id`), name: readOptionalBrushName(brushValue.name, `brushes.${brushId}.name`), visible: readOptionalBoolean( brushValue.visible, `brushes.${brushId}.visible`, DEFAULT_BOX_BRUSH_VISIBLE ), enabled: readOptionalBoolean( brushValue.enabled, `brushes.${brushId}.enabled`, DEFAULT_BOX_BRUSH_ENABLED ), center, rotationDegrees: readOptionalVec3( brushValue.rotationDegrees, `brushes.${brushId}.rotationDegrees`, DEFAULT_BOX_BRUSH_ROTATION_DEGREES ), size, volume: readBoxBrushVolumeSettings( brushValue.volume, `brushes.${brushId}.volume` ), layerId: expectOptionalString( brushValue.layerId, `brushes.${brushId}.layerId` ), groupId: expectOptionalString( brushValue.groupId, `brushes.${brushId}.groupId` ) }; if (kind === "box") { brushes[brushId] = createBoxBrush({ ...sharedBrushFields, geometry: readBoxBrushGeometry( brushValue.geometry, `brushes.${brushId}.geometry`, size ), faces: readBoxBrushFaces( brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState ) }); continue; } if (kind === "wedge") { brushes[brushId] = createWedgeBrush({ ...sharedBrushFields, rotationDegrees: brushValue.rotationDegrees === undefined ? DEFAULT_WEDGE_BRUSH_ROTATION_DEGREES : sharedBrushFields.rotationDegrees, geometry: readBrushGeometry( brushValue.geometry, `brushes.${brushId}.geometry`, createDefaultWedgeBrushGeometry(size), WEDGE_VERTEX_IDS ), faces: readBrushFaces( brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState, WEDGE_FACE_IDS ) as ReturnType["faces"] }); continue; } if (kind === "radialPrism") { const sideCount = normalizeRadialPrismSideCount( expectFiniteNumber(brushValue.sideCount, `brushes.${brushId}.sideCount`) ); brushes[brushId] = createRadialPrismBrush({ ...sharedBrushFields, sideCount, geometry: readBrushGeometry( brushValue.geometry, `brushes.${brushId}.geometry`, createDefaultRadialPrismBrushGeometry(size, sideCount), getRadialPrismVertexIds(sideCount) ), faces: readBrushFaces( brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState, getRadialPrismFaceIds(sideCount) ) as ReturnType["faces"] }); continue; } if (kind === "cone") { const sideCount = normalizeConeSideCount( expectFiniteNumber(brushValue.sideCount, `brushes.${brushId}.sideCount`) ); brushes[brushId] = createConeBrush({ ...sharedBrushFields, sideCount, geometry: readBrushGeometry( brushValue.geometry, `brushes.${brushId}.geometry`, createDefaultConeBrushGeometry(size, sideCount), getConeVertexIds(sideCount) ), faces: readBrushFaces( brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState, getConeFaceIds(sideCount) ) as ReturnType["faces"] }); continue; } if (kind === "torus") { const majorSegmentCount = normalizeTorusMajorSegmentCount( expectFiniteNumber( brushValue.majorSegmentCount, `brushes.${brushId}.majorSegmentCount` ) ); const tubeSegmentCount = normalizeTorusTubeSegmentCount( expectFiniteNumber( brushValue.tubeSegmentCount, `brushes.${brushId}.tubeSegmentCount` ) ); brushes[brushId] = createTorusBrush({ ...sharedBrushFields, majorSegmentCount, tubeSegmentCount, geometry: readBrushGeometry( brushValue.geometry, `brushes.${brushId}.geometry`, createDefaultTorusBrushGeometry( size, majorSegmentCount, tubeSegmentCount ), getTorusVertexIds(majorSegmentCount, tubeSegmentCount) ), faces: readBrushFaces( brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState, getTorusFaceIds(majorSegmentCount, tubeSegmentCount) ) as ReturnType["faces"] }); continue; } throw new Error( `brushes.${brushId}.kind must be box, wedge, radialPrism, cone, or torus.` ); } return brushes; } function readWorldBackgroundSettings( value: unknown, label: string, options: { allowMissing?: boolean; allowShader?: boolean; defaultValue?: WorldBackgroundSettings; } = {} ): WorldBackgroundSettings { if (value === undefined) { if (options.allowMissing && options.defaultValue !== undefined) { return cloneWorldBackgroundSettings(options.defaultValue); } throw new Error(`${label} must be an object.`); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const backgroundMode = expectString(value.mode, `${label}.mode`); if (!isWorldBackgroundMode(backgroundMode)) { throw new Error(`${label}.mode must be a supported background mode.`); } if (backgroundMode === "shader" && options.allowShader === false) { throw new Error(`${label}.mode must not use a shader background here.`); } if (backgroundMode === "solid") { return { mode: "solid", colorHex: expectHexColor(value.colorHex, `${label}.colorHex`) }; } if (backgroundMode === "verticalGradient") { return { mode: "verticalGradient", topColorHex: expectHexColor(value.topColorHex, `${label}.topColorHex`), bottomColorHex: expectHexColor( value.bottomColorHex, `${label}.bottomColorHex` ) }; } if (backgroundMode === "shader") { return { mode: "shader" }; } return { mode: "image", assetId: expectString(value.assetId, `${label}.assetId`), environmentIntensity: typeof value.environmentIntensity === "number" && isFinite(value.environmentIntensity) && value.environmentIntensity >= 0 ? value.environmentIntensity : 0.5 }; } function readWorldShaderSkySettings( value: unknown, label: string, dayBackground: WorldBackgroundSettings ): WorldShaderSkySettings { const defaults = createDefaultWorldShaderSkySettings(dayBackground); if (value === undefined) { return defaults; } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const celestial = value.celestial === undefined ? {} : isRecord(value.celestial) ? value.celestial : (() => { throw new Error(`${label}.celestial must be an object.`); })(); const stars = value.stars === undefined ? {} : isRecord(value.stars) ? value.stars : (() => { throw new Error(`${label}.stars must be an object.`); })(); const clouds = value.clouds === undefined ? {} : isRecord(value.clouds) ? value.clouds : (() => { throw new Error(`${label}.clouds must be an object.`); })(); const presetId = value.presetId ?? defaults.presetId; if (!isWorldShaderSkyPresetId(presetId)) { throw new Error(`${label}.presetId must be a supported shader sky preset.`); } return { presetId, dayTopColorHex: expectHexColor( value.dayTopColorHex ?? defaults.dayTopColorHex, `${label}.dayTopColorHex` ), dayBottomColorHex: expectHexColor( value.dayBottomColorHex ?? defaults.dayBottomColorHex, `${label}.dayBottomColorHex` ), horizonHeight: readOptionalFiniteNumber( value.horizonHeight, `${label}.horizonHeight`, defaults.horizonHeight ), celestial: { sunDiscSizeDegrees: readOptionalPositiveFiniteNumber( celestial.sunDiscSizeDegrees, `${label}.celestial.sunDiscSizeDegrees`, defaults.celestial.sunDiscSizeDegrees ), moonDiscSizeDegrees: readOptionalPositiveFiniteNumber( celestial.moonDiscSizeDegrees, `${label}.celestial.moonDiscSizeDegrees`, defaults.celestial.moonDiscSizeDegrees ) }, stars: { density: readOptionalNonNegativeFiniteNumber( stars.density, `${label}.stars.density`, defaults.stars.density ), brightness: readOptionalNonNegativeFiniteNumber( stars.brightness, `${label}.stars.brightness`, defaults.stars.brightness ), horizonFadeOffset: readOptionalFiniteNumber( stars.horizonFadeOffset, `${label}.stars.horizonFadeOffset`, defaults.stars.horizonFadeOffset ) }, clouds: { coverage: readOptionalNonNegativeFiniteNumber( clouds.coverage, `${label}.clouds.coverage`, defaults.clouds.coverage ), density: readOptionalNonNegativeFiniteNumber( clouds.density, `${label}.clouds.density`, defaults.clouds.density ), softness: readOptionalNonNegativeFiniteNumber( clouds.softness, `${label}.clouds.softness`, defaults.clouds.softness ), scale: readOptionalPositiveFiniteNumber( clouds.scale, `${label}.clouds.scale`, defaults.clouds.scale ), height: readOptionalFiniteNumber( clouds.height, `${label}.clouds.height`, defaults.clouds.height ), heightVariation: readOptionalNonNegativeFiniteNumber( clouds.heightVariation, `${label}.clouds.heightVariation`, defaults.clouds.heightVariation ), tintHex: expectHexColor( clouds.tintHex ?? defaults.clouds.tintHex, `${label}.clouds.tintHex` ), opacity: readOptionalNonNegativeFiniteNumber( clouds.opacity, `${label}.clouds.opacity`, defaults.clouds.opacity ), opacityRandomness: readOptionalNonNegativeFiniteNumber( clouds.opacityRandomness, `${label}.clouds.opacityRandomness`, defaults.clouds.opacityRandomness ), driftSpeed: readOptionalNonNegativeFiniteNumber( clouds.driftSpeed, `${label}.clouds.driftSpeed`, defaults.clouds.driftSpeed ), driftDirectionDegrees: readOptionalFiniteNumber( clouds.driftDirectionDegrees, `${label}.clouds.driftDirectionDegrees`, defaults.clouds.driftDirectionDegrees ) } }; } function readWorldCelestialOrbitAuthoringSettings( value: unknown, label: string, sunDirection: Vec3 ): WorldCelestialOrbitAuthoringSettings { const defaults = createDefaultWorldCelestialOrbitAuthoringSettings(sunDirection); if (value === undefined) { return defaults; } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const sun = value.sun === undefined ? {} : isRecord(value.sun) ? value.sun : (() => { throw new Error(`${label}.sun must be an object.`); })(); const moon = value.moon === undefined ? {} : isRecord(value.moon) ? value.moon : (() => { throw new Error(`${label}.moon must be an object.`); })(); return { sun: { azimuthDegrees: readOptionalFiniteNumber( sun.azimuthDegrees, `${label}.sun.azimuthDegrees`, defaults.sun.azimuthDegrees ), peakAltitudeDegrees: readOptionalFiniteNumber( sun.peakAltitudeDegrees, `${label}.sun.peakAltitudeDegrees`, defaults.sun.peakAltitudeDegrees ) }, moon: { azimuthDegrees: readOptionalFiniteNumber( moon.azimuthDegrees, `${label}.moon.azimuthDegrees`, defaults.moon.azimuthDegrees ), peakAltitudeDegrees: readOptionalFiniteNumber( moon.peakAltitudeDegrees, `${label}.moon.peakAltitudeDegrees`, defaults.moon.peakAltitudeDegrees ) } }; } function readWorldTimePhaseProfile( value: unknown, label: string, phase: "dawn" | "dusk" | "night" ): WorldTimePhaseProfile { const defaults = createDefaultWorldTimePhaseProfile(phase); if (value === undefined) { return defaults; } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const skyTopColorHex = expectHexColor( value.skyTopColorHex ?? defaults.skyTopColorHex, `${label}.skyTopColorHex` ); const skyBottomColorHex = expectHexColor( value.skyBottomColorHex ?? defaults.skyBottomColorHex, `${label}.skyBottomColorHex` ); const fallbackBackground: WorldBackgroundSettings = { mode: "verticalGradient", topColorHex: skyTopColorHex, bottomColorHex: skyBottomColorHex }; return { background: readWorldBackgroundSettings( value.background, `${label}.background`, { allowMissing: true, allowShader: false, defaultValue: fallbackBackground } ), skyTopColorHex, skyBottomColorHex, ambientColorHex: expectHexColor( value.ambientColorHex ?? defaults.ambientColorHex, `${label}.ambientColorHex` ), ambientIntensityFactor: readOptionalNonNegativeFiniteNumber( value.ambientIntensityFactor, `${label}.ambientIntensityFactor`, defaults.ambientIntensityFactor ), lightColorHex: expectHexColor( value.lightColorHex ?? defaults.lightColorHex, `${label}.lightColorHex` ), lightIntensityFactor: readOptionalNonNegativeFiniteNumber( value.lightIntensityFactor, `${label}.lightIntensityFactor`, defaults.lightIntensityFactor ) }; } function readLegacyWorldTimeOfDaySettings( value: unknown, label: string ): WorldTimeOfDaySettings { const defaults = createDefaultWorldTimeOfDaySettings(); if (!isRecord(value)) { return defaults; } const dawn = readWorldTimePhaseProfile(value.dawn, `${label}.dawn`, "dawn"); const dusk = readWorldTimePhaseProfile(value.dusk, `${label}.dusk`, "dusk"); const nightProfile = readWorldTimePhaseProfile( value.night, `${label}.night`, "night" ); let nightBackground: WorldBackgroundSettings = { mode: "verticalGradient", topColorHex: nightProfile.skyTopColorHex, bottomColorHex: nightProfile.skyBottomColorHex }; if (isRecord(value.nightBackground)) { const legacyNightBackground = value.nightBackground; const assetId = typeof legacyNightBackground.assetId === "string" ? legacyNightBackground.assetId.trim() : ""; if (assetId.length > 0) { nightBackground = { mode: "image", assetId, environmentIntensity: typeof legacyNightBackground.environmentIntensity === "number" && isFinite(legacyNightBackground.environmentIntensity) && legacyNightBackground.environmentIntensity >= 0 ? legacyNightBackground.environmentIntensity : DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY }; } } return { dawn: { ...dawn, background: cloneWorldBackgroundSettings(dawn.background) }, dusk: { ...dusk, background: cloneWorldBackgroundSettings(dusk.background) }, night: { background: nightBackground, ambientColorHex: nightProfile.ambientColorHex, ambientIntensityFactor: nightProfile.ambientIntensityFactor, lightColorHex: nightProfile.lightColorHex, lightIntensityFactor: nightProfile.lightIntensityFactor } }; } function readWorldTimeOfDaySettings( value: unknown, label: string, options: { legacyProjectTimeValue?: unknown } = {} ): WorldTimeOfDaySettings { const defaults = createDefaultWorldTimeOfDaySettings(); if (value === undefined) { if (options.legacyProjectTimeValue !== undefined) { return readLegacyWorldTimeOfDaySettings( options.legacyProjectTimeValue, "time" ); } return defaults; } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const nightDefaults = defaults.night; const nightValue = value.night; return { dawn: readWorldTimePhaseProfile(value.dawn, `${label}.dawn`, "dawn"), dusk: readWorldTimePhaseProfile(value.dusk, `${label}.dusk`, "dusk"), night: { background: readWorldBackgroundSettings( isRecord(nightValue) ? nightValue.background : undefined, `${label}.night.background`, { allowMissing: true, allowShader: false, defaultValue: nightDefaults.background } ), ambientColorHex: expectHexColor( isRecord(nightValue) ? (nightValue.ambientColorHex ?? nightDefaults.ambientColorHex) : nightDefaults.ambientColorHex, `${label}.night.ambientColorHex` ), ambientIntensityFactor: readOptionalNonNegativeFiniteNumber( isRecord(nightValue) ? nightValue.ambientIntensityFactor : undefined, `${label}.night.ambientIntensityFactor`, nightDefaults.ambientIntensityFactor ), lightColorHex: expectHexColor( isRecord(nightValue) ? (nightValue.lightColorHex ?? nightDefaults.lightColorHex) : nightDefaults.lightColorHex, `${label}.night.lightColorHex` ), lightIntensityFactor: readOptionalNonNegativeFiniteNumber( isRecord(nightValue) ? nightValue.lightIntensityFactor : undefined, `${label}.night.lightIntensityFactor`, nightDefaults.lightIntensityFactor ) } }; } function readWorldSettings( value: unknown, options: { legacyProjectTimeValue?: unknown } = {} ): WorldSettings { if (!isRecord(value)) { throw new Error("world must be an object."); } const background = value.background; const ambientLight = value.ambientLight; const sunLight = value.sunLight; if (!isRecord(background)) { throw new Error("world.background must be an object."); } if (!isRecord(ambientLight)) { throw new Error("world.ambientLight must be an object."); } if (!isRecord(sunLight)) { throw new Error("world.sunLight must be an object."); } const direction = readVec3(sunLight.direction, "world.sunLight.direction"); assertNonZeroVec3(direction, "world.sunLight.direction"); const resolvedBackground = readWorldBackgroundSettings( background, "world.background" ); return { projectTimeLightingEnabled: readOptionalBoolean( value.projectTimeLightingEnabled, "world.projectTimeLightingEnabled", true ), showCelestialBodies: readOptionalBoolean( value.showCelestialBodies, "world.showCelestialBodies", false ), background: resolvedBackground, shaderSky: readWorldShaderSkySettings( value.shaderSky, "world.shaderSky", resolvedBackground ), celestialOrbits: readWorldCelestialOrbitAuthoringSettings( value.celestialOrbits, "world.celestialOrbits", direction ), ambientLight: { 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" ), direction }, timeOfDay: readWorldTimeOfDaySettings(value.timeOfDay, "world.timeOfDay", { legacyProjectTimeValue: options.legacyProjectTimeValue }), advancedRendering: readAdvancedRenderingSettings(value.advancedRendering) }; } function readPointLightEntity(value: unknown, label: string): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "pointLight", `${label}.kind`); const entity = createPointLightEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), colorHex: expectHexColor(value.colorHex, `${label}.colorHex`), intensity: expectNonNegativeFiniteNumber( value.intensity, `${label}.intensity` ), distance: expectPositiveFiniteNumber(value.distance, `${label}.distance`) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be pointLight.`); } return entity; } function readSpotLightEntity(value: unknown, label: string): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "spotLight", `${label}.kind`); const entity = createSpotLightEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), direction: readVec3(value.direction, `${label}.direction`), colorHex: expectHexColor(value.colorHex, `${label}.colorHex`), intensity: expectNonNegativeFiniteNumber( value.intensity, `${label}.intensity` ), distance: expectPositiveFiniteNumber(value.distance, `${label}.distance`), angleDegrees: expectFiniteNumber( value.angleDegrees, `${label}.angleDegrees` ) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be spotLight.`); } return entity; } function readPlayerStartEntity(value: unknown, label: string): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "playerStart", `${label}.kind`); const entity = createPlayerStartEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`), navigationMode: readPlayerStartNavigationMode( value.navigationMode, `${label}.navigationMode` ), movementTemplate: readPlayerStartMovementTemplate( value.movementTemplate, `${label}.movementTemplate` ), inputBindings: readPlayerStartInputBindings( value.inputBindings, `${label}.inputBindings` ), collider: readPlayerStartColliderSettings( value.collider, `${label}.collider` ) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be playerStart.`); } return entity; } function readCameraRigEntity(value: unknown, label: string): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "cameraRig", `${label}.kind`); const rigType = readOptionalAllowedValue( value.rigType, `${label}.rigType`, "fixed", (candidate): candidate is "fixed" | "rail" => candidate === "fixed" || candidate === "rail" ); const transitionMode = readOptionalAllowedValue( value.transitionMode, `${label}.transitionMode`, "blend", (candidate): candidate is "cut" | "blend" => typeof candidate === "string" && isCameraRigTransitionMode(candidate) ); const entity = createCameraRigEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), ...(rigType === "fixed" ? { position: readVec3(value.position, `${label}.position`), rigType: "fixed" as const } : { rigType: "rail" as const, pathId: expectString(value.pathId, `${label}.pathId`) }), priority: value.priority === undefined ? undefined : expectNonNegativeFiniteNumber(value.priority, `${label}.priority`), defaultActive: value.defaultActive === undefined ? undefined : expectBoolean(value.defaultActive, `${label}.defaultActive`), target: readCameraRigTargetRef(value.target, `${label}.target`), targetOffset: value.targetOffset === undefined ? undefined : readVec3(value.targetOffset, `${label}.targetOffset`), transitionMode, transitionDurationSeconds: value.transitionDurationSeconds === undefined ? undefined : expectNonNegativeFiniteNumber( value.transitionDurationSeconds, `${label}.transitionDurationSeconds` ), lookAround: readCameraRigLookAroundSettings( value.lookAround, `${label}.lookAround` ) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be cameraRig.`); } return entity; } function readSoundEmitterEntity(value: unknown, label: string): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "soundEmitter", `${label}.kind`); const entity = createSoundEmitterEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), audioAssetId: value.audioAssetId === undefined || value.audioAssetId === null ? undefined : expectString(value.audioAssetId, `${label}.audioAssetId`), volume: expectNonNegativeFiniteNumber(value.volume, `${label}.volume`), refDistance: expectPositiveFiniteNumber( value.refDistance, `${label}.refDistance` ), maxDistance: expectPositiveFiniteNumber( value.maxDistance, `${label}.maxDistance` ), autoplay: expectBoolean(value.autoplay, `${label}.autoplay`), loop: expectBoolean(value.loop, `${label}.loop`) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be soundEmitter.`); } return entity; } function readLegacySoundEmitterEntity( value: unknown, label: string ): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "soundEmitter", `${label}.kind`); const radius = expectPositiveFiniteNumber(value.radius, `${label}.radius`); const entity = createSoundEmitterEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), refDistance: radius, maxDistance: radius, volume: expectNonNegativeFiniteNumber(value.gain, `${label}.gain`), autoplay: expectBoolean(value.autoplay, `${label}.autoplay`), loop: expectBoolean(value.loop, `${label}.loop`) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be soundEmitter.`); } return entity; } 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 size = readVec3(value.size, `${label}.size`); if (size.x <= 0 || size.y <= 0 || size.z <= 0) { throw new Error(`${label}.size values must be positive.`); } const entity = createTriggerVolumeEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), size, triggerOnEnter: expectBoolean( value.triggerOnEnter, `${label}.triggerOnEnter` ), triggerOnExit: expectBoolean(value.triggerOnExit, `${label}.triggerOnExit`) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be triggerVolume.`); } return entity; } 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 entity = createTeleportTargetEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be teleportTarget.`); } return entity; } function readInteractableEntity(value: unknown, label: string): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "interactable", `${label}.kind`); const interactionEnabled = value.interactionEnabled === undefined ? expectBoolean(value.enabled, `${label}.enabled`) : expectBoolean(value.interactionEnabled, `${label}.interactionEnabled`); const entity = createInteractableEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: value.interactionEnabled === undefined ? DEFAULT_ENTITY_ENABLED : readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), radius: expectPositiveFiniteNumber(value.radius, `${label}.radius`), prompt: expectString(value.prompt, `${label}.prompt`), interactionEnabled }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be interactable.`); } return entity; } function readSceneEntryEntity(value: unknown, label: string): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "sceneEntry", `${label}.kind`); const entity = createSceneEntryEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be sceneEntry.`); } return entity; } function readNpcEntity( value: unknown, label: string, legacyProjectDialogues: ProjectDialogueLibrary ): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectLiteralString(value.kind, "npc", `${label}.kind`); const dialogueId = readOptionalDialogueResourceId( value.dialogueId, `${label}.dialogueId` ); const dialogues = value.dialogues === undefined ? dialogueId === null ? [] : (() => { const legacyDialogue = legacyProjectDialogues.dialogues[dialogueId] ?? null; return legacyDialogue === null ? [] : [legacyDialogue]; })() : readNpcDialogues( value.dialogues, `${label}.dialogues`, legacyProjectDialogues ); const entity = createNpcEntity({ id: expectString(value.id, `${label}.id`), name: readOptionalEntityName(value.name, `${label}.name`), visible: readOptionalBoolean( value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE ), enabled: readOptionalBoolean( value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED ), position: readVec3(value.position, `${label}.position`), actorId: expectString(value.actorId, `${label}.actorId`), presence: readNpcPresence(value.presence, `${label}.presence`), yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`), modelAssetId: value.modelAssetId === undefined || value.modelAssetId === null ? undefined : expectString(value.modelAssetId, `${label}.modelAssetId`), dialogues, defaultDialogueId: value.defaultDialogueId === undefined ? dialogueId : readOptionalDialogueResourceId( value.defaultDialogueId, `${label}.defaultDialogueId` ), collider: readNpcColliderSettings(value.collider, `${label}.collider`) }); if (entity.kind !== kind) { throw new Error(`${label}.kind must be npc.`); } return entity; } function readNpcPresence(value: unknown, label: string): NpcPresence { if (value === undefined) { return createNpcAlwaysPresence(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const mode = expectString(value.mode, `${label}.mode`); if (!isNpcPresenceMode(mode)) { throw new Error(`${label}.mode must be always or timeWindow.`); } switch (mode) { case "always": return createNpcAlwaysPresence(); case "timeWindow": return createNpcTimeWindowPresence({ startHour: expectFiniteNumber(value.startHour, `${label}.startHour`), endHour: expectFiniteNumber(value.endHour, `${label}.endHour`) }); } } function readEntityInstance( value: unknown, label: string, options: { legacySoundEmitter: boolean; legacyProjectDialogues?: ProjectDialogueLibrary; } ): EntityInstance { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } switch (value.kind) { case "pointLight": return readPointLightEntity(value, label); case "spotLight": return readSpotLightEntity(value, label); case "playerStart": return readPlayerStartEntity(value, label); case "cameraRig": return readCameraRigEntity(value, label); case "sceneEntry": return readSceneEntryEntity(value, label); case "npc": return readNpcEntity( value, label, options.legacyProjectDialogues ?? createEmptyProjectDialogueLibrary() ); case "soundEmitter": return options.legacySoundEmitter ? readLegacySoundEmitterEntity(value, label) : readSoundEmitterEntity(value, label); case "triggerVolume": return readTriggerVolumeEntity(value, label); case "teleportTarget": return readTeleportTargetEntity(value, label); case "interactable": return readInteractableEntity(value, label); default: throw new Error(`${label}.kind must be a supported entity type.`); } } function readEntities( value: unknown, options: { legacySoundEmitter: boolean; legacyProjectDialogues?: ProjectDialogueLibrary; } ): SceneDocument["entities"] { if (!isRecord(value)) { throw new Error("entities must be a record."); } const entities: SceneDocument["entities"] = {}; for (const [entityId, entityValue] of Object.entries(value)) { if (!isRecord(entityValue)) { throw new Error(`entities.${entityId} must be an object.`); } const entity = readEntityInstance( entityValue, `entities.${entityId}`, options ); if (entity.id !== entityId) { throw new Error(`entities.${entityId}.id must match the registry key.`); } entities[entityId] = entity; } return entities; } function readControlTargetRef(value: unknown, label: string): ControlTargetRef { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const kind = expectString(value.kind, `${label}.kind`); switch (kind) { case "actor": return createActorControlTargetRef( expectString(value.actorId, `${label}.actorId`) ); case "entity": { const entityKind = expectString(value.entityKind, `${label}.entityKind`); if (!isControlEntityTargetKind(entityKind)) { throw new Error( `${label}.entityKind must be a supported control entity kind.` ); } return createEntityControlTargetRef( entityKind, expectString(value.entityId, `${label}.entityId`) ); } case "interaction": { const interactionKind = expectString( value.interactionKind, `${label}.interactionKind` ); if (!isControlInteractionTargetKind(interactionKind)) { throw new Error( `${label}.interactionKind must be a supported control interaction kind.` ); } return createInteractionControlTargetRef( interactionKind, expectString(value.entityId, `${label}.entityId`) ); } case "scene": expectLiteralString(value.scope, "activeScene", `${label}.scope`); return createActiveSceneControlTargetRef(); case "global": expectLiteralString(value.scope, "project", `${label}.scope`); return createProjectGlobalControlTargetRef(); case "modelInstance": return createModelInstanceControlTargetRef( expectString(value.modelInstanceId, `${label}.modelInstanceId`) ); default: throw new Error(`${label}.kind must be a supported control target kind.`); } } function readControlEffect(value: unknown, label: string): ControlEffect { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const type = expectString(value.type, `${label}.type`); switch (type) { case "activateCameraRigOverride": return createActivateCameraRigOverrideControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType }); case "clearCameraRigOverride": return createClearCameraRigOverrideControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType }); case "setActorPresence": return createSetActorPresenceControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, active: expectBoolean(value.active, `${label}.active`) }); case "playActorAnimation": return createPlayActorAnimationControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, clipName: expectString(value.clipName, `${label}.clipName`), loop: value.loop === undefined ? undefined : expectBoolean(value.loop, `${label}.loop`) }); case "followActorPath": { const progressMode = expectString( value.progressMode, `${label}.progressMode` ); if (!isActorPathProgressMode(progressMode)) { throw new Error( `${label}.progressMode must be a supported actor path progress mode.` ); } return createFollowActorPathControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, pathId: expectString(value.pathId, `${label}.pathId`), speed: expectPositiveFiniteNumber(value.speed, `${label}.speed`), loop: expectBoolean(value.loop, `${label}.loop`), smoothPath: value.smoothPath === undefined ? true : expectBoolean(value.smoothPath, `${label}.smoothPath`), progressMode }); } case "playModelAnimation": return createPlayModelAnimationControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, clipName: expectString(value.clipName, `${label}.clipName`), loop: value.loop === undefined ? undefined : expectBoolean(value.loop, `${label}.loop`) }); case "stopModelAnimation": return createStopModelAnimationControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType }); case "setModelInstanceVisible": return createSetModelInstanceVisibleControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, visible: expectBoolean(value.visible, `${label}.visible`) }); case "playSound": return createPlaySoundControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType }); case "stopSound": return createStopSoundControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType }); case "setSoundVolume": return createSetSoundVolumeControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, volume: expectNonNegativeFiniteNumber(value.volume, `${label}.volume`) }); case "setInteractionEnabled": return createSetInteractionEnabledControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, enabled: expectBoolean(value.enabled, `${label}.enabled`) }); case "setLightEnabled": return createSetLightEnabledControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, enabled: expectBoolean(value.enabled, `${label}.enabled`) }); case "setLightIntensity": return createSetLightIntensityControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, intensity: expectNonNegativeFiniteNumber( value.intensity, `${label}.intensity` ) }); case "setLightColor": return createSetLightColorControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, colorHex: expectString(value.colorHex, `${label}.colorHex`) }); case "setAmbientLightIntensity": return createSetAmbientLightIntensityControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, intensity: expectNonNegativeFiniteNumber( value.intensity, `${label}.intensity` ) }); case "setAmbientLightColor": return createSetAmbientLightColorControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, colorHex: expectString(value.colorHex, `${label}.colorHex`) }); case "setSunLightIntensity": return createSetSunLightIntensityControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, intensity: expectNonNegativeFiniteNumber( value.intensity, `${label}.intensity` ) }); case "setSunLightColor": return createSetSunLightColorControlEffect({ target: readControlTargetRef( value.target, `${label}.target` ) as ReturnType, colorHex: expectString(value.colorHex, `${label}.colorHex`) }); default: throw new Error(`${label}.type must be a supported control effect.`); } } function readInteractionAction( value: unknown, label: string ): InteractionLink["action"] { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } switch (value.type) { case "teleportPlayer": return createTeleportPlayerInteractionLink({ sourceEntityId: "interaction-source-placeholder", targetEntityId: expectString( value.targetEntityId, `${label}.targetEntityId` ) }).action; case "toggleVisibility": return createToggleVisibilityInteractionLink({ sourceEntityId: "interaction-source-placeholder", 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` ); if (targetModelInstanceId.trim().length === 0) { throw new Error(`${label}.targetModelInstanceId must be non-empty.`); } const clipName = expectString(value.clipName, `${label}.clipName`); if (clipName.trim().length === 0) { throw new Error(`${label}.clipName must be non-empty.`); } return createPlayAnimationInteractionLink({ sourceEntityId: "interaction-source-placeholder", targetModelInstanceId, clipName, loop: value.loop === undefined ? undefined : expectBoolean(value.loop, `${label}.loop`) }).action; } case "stopAnimation": { const targetModelInstanceId = expectString( value.targetModelInstanceId, `${label}.targetModelInstanceId` ); if (targetModelInstanceId.trim().length === 0) { throw new Error(`${label}.targetModelInstanceId must be non-empty.`); } return createStopAnimationInteractionLink({ sourceEntityId: "interaction-source-placeholder", targetModelInstanceId }).action; } case "playSound": { const targetSoundEmitterId = expectString( value.targetSoundEmitterId, `${label}.targetSoundEmitterId` ); if (targetSoundEmitterId.trim().length === 0) { throw new Error(`${label}.targetSoundEmitterId must be non-empty.`); } return createPlaySoundInteractionLink({ sourceEntityId: "interaction-source-placeholder", targetSoundEmitterId }).action; } case "stopSound": { const targetSoundEmitterId = expectString( value.targetSoundEmitterId, `${label}.targetSoundEmitterId` ); if (targetSoundEmitterId.trim().length === 0) { throw new Error(`${label}.targetSoundEmitterId must be non-empty.`); } return createStopSoundInteractionLink({ sourceEntityId: "interaction-source-placeholder", targetSoundEmitterId }).action; } case "runSequence": { const sequenceId = expectString(value.sequenceId, `${label}.sequenceId`); if (sequenceId.trim().length === 0) { throw new Error(`${label}.sequenceId must be non-empty.`); } return createRunSequenceInteractionLink({ sourceEntityId: "interaction-source-placeholder", sequenceId }).action; } case "control": return createControlInteractionLink({ sourceEntityId: "interaction-source-placeholder", effect: readControlEffect(value.effect, `${label}.effect`) }).action; default: throw new Error(`${label}.type must be a supported interaction action.`); } } function readProjectScheduler( value: unknown, label: string, options: { allowMissing: boolean } ): ProjectScheduler { if (value === undefined && options.allowMissing) { return createEmptyProjectScheduler(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const routinesValue = value.routines; if (!isRecord(routinesValue)) { throw new Error(`${label}.routines must be an object.`); } const routines = Object.fromEntries( Object.entries(routinesValue).map(([routineId, routineValue]) => { if (!isRecord(routineValue)) { throw new Error(`${label}.routines.${routineId} must be an object.`); } const target = readControlTargetRef( routineValue.target, `${label}.routines.${routineId}.target` ); const effects = Array.isArray(routineValue.effects) ? routineValue.effects.map((effectValue, effectIndex) => readControlEffect( effectValue, `${label}.routines.${routineId}.effects.${effectIndex}` ) ) : routineValue.effect === undefined ? undefined : [ readControlEffect( routineValue.effect, `${label}.routines.${routineId}.effect` ) ]; return [ routineId, createProjectScheduleRoutine({ id: expectString( routineValue.id, `${label}.routines.${routineId}.id` ), title: expectString( routineValue.title, `${label}.routines.${routineId}.title` ), enabled: readOptionalBoolean( routineValue.enabled, `${label}.routines.${routineId}.enabled`, true ), target: target as ReturnType, sequenceId: routineValue.sequenceId === undefined || routineValue.sequenceId === null ? null : expectString( routineValue.sequenceId, `${label}.routines.${routineId}.sequenceId` ), days: routineValue.days === undefined ? createProjectScheduleEveryDaySelection() : (() => { if (!isRecord(routineValue.days)) { throw new Error( `${label}.routines.${routineId}.days must be an object.` ); } const mode = expectString( routineValue.days.mode, `${label}.routines.${routineId}.days.mode` ); if (mode === "everyDay") { return createProjectScheduleEveryDaySelection(); } if (mode !== "selectedDays") { throw new Error( `${label}.routines.${routineId}.days.mode must be everyDay or selectedDays.` ); } return { mode: "selectedDays" as const, days: expectStringArray( routineValue.days.days, `${label}.routines.${routineId}.days.days` ).map((day) => { switch (day) { case "monday": case "tuesday": case "wednesday": case "thursday": case "friday": case "saturday": case "sunday": return day; default: throw new Error( `${label}.routines.${routineId}.days.days must only contain supported weekdays.` ); } }) }; })(), startHour: expectFiniteNumber( routineValue.startHour, `${label}.routines.${routineId}.startHour` ), endHour: expectFiniteNumber( routineValue.endHour, `${label}.routines.${routineId}.endHour` ), priority: readOptionalFiniteNumber( routineValue.priority, `${label}.routines.${routineId}.priority`, 0 ), effects }) ]; }) ); return { routines }; } function readProjectDialogueLibrary( value: unknown, label: string, options: { allowMissing: boolean } ): ProjectDialogueLibrary { if (value === undefined) { if (options.allowMissing) { return createEmptyProjectDialogueLibrary(); } return createEmptyProjectDialogueLibrary(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } if (!isRecord(value.dialogues)) { throw new Error(`${label}.dialogues must be an object.`); } const dialogues: ProjectDialogueLibrary["dialogues"] = {}; for (const [dialogueKey, dialogueValue] of Object.entries(value.dialogues)) { if (!isRecord(dialogueValue)) { throw new Error(`${label}.dialogues.${dialogueKey} must be an object.`); } const linesValue = dialogueValue.lines; if (!Array.isArray(linesValue)) { throw new Error( `${label}.dialogues.${dialogueKey}.lines must be an array.` ); } dialogues[dialogueKey] = createProjectDialogue({ id: expectString( dialogueValue.id, `${label}.dialogues.${dialogueKey}.id` ), title: expectString( dialogueValue.title, `${label}.dialogues.${dialogueKey}.title` ), lines: linesValue.map((lineValue, lineIndex) => { if (!isRecord(lineValue)) { throw new Error( `${label}.dialogues.${dialogueKey}.lines.${lineIndex} must be an object.` ); } return createProjectDialogueLine({ id: expectString( lineValue.id, `${label}.dialogues.${dialogueKey}.lines.${lineIndex}.id` ), text: expectString( lineValue.text, `${label}.dialogues.${dialogueKey}.lines.${lineIndex}.text` ) }); }) }); } return { dialogues }; } function readProjectSequenceEffect( value: unknown, label: string ): SequenceClip { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const type = expectString(value.type, `${label}.type`); const stepClass = expectString(value.stepClass, `${label}.stepClass`); if (stepClass !== "held" && stepClass !== "impulse") { throw new Error(`${label}.stepClass must be held or impulse.`); } switch (type) { case "controlEffect": return { stepClass, type: "controlEffect", effect: readControlEffect(value.effect, `${label}.effect`) }; case "makeNpcTalk": if (stepClass !== "impulse") { throw new Error( `${label}.makeNpcTalk effects must use the impulse class.` ); } return { stepClass: "impulse", type: "makeNpcTalk", npcEntityId: expectString(value.npcEntityId, `${label}.npcEntityId`), dialogueId: readOptionalDialogueResourceId( value.dialogueId, `${label}.dialogueId` ) }; case "teleportPlayer": if (stepClass !== "impulse") { throw new Error( `${label}.teleportPlayer effects must use the impulse class.` ); } return { stepClass: "impulse", type: "teleportPlayer", targetEntityId: expectString( value.targetEntityId, `${label}.targetEntityId` ) }; case "startSceneTransition": if (stepClass !== "impulse") { throw new Error( `${label}.startSceneTransition effects must use the impulse class.` ); } return { stepClass: "impulse", type: "startSceneTransition", targetSceneId: expectString( value.targetSceneId, `${label}.targetSceneId` ), targetEntryEntityId: expectString( value.targetEntryEntityId, `${label}.targetEntryEntityId` ) }; case "toggleVisibility": case "setVisibility": { const isLegacyToggle = value.type === "toggleVisibility"; if (stepClass !== "impulse") { throw new Error( `${label}.${String(value.type)} effects must use the impulse class.` ); } if (isLegacyToggle) { const visible = value.visible === undefined ? undefined : expectBoolean(value.visible, `${label}.visible`); return { stepClass: "impulse", type: "setVisibility", target: { kind: "brush", brushId: expectString(value.targetBrushId, `${label}.targetBrushId`) }, mode: visible === undefined ? "toggle" : visible ? "show" : "hide" }; } if (!isRecord(value.target)) { throw new Error(`${label}.target must be an object.`); } const targetKind = expectString( value.target.kind, `${label}.target.kind` ); if (targetKind !== "brush" && targetKind !== "modelInstance") { throw new Error(`${label}.target.kind must be brush or modelInstance.`); } const mode = expectString(value.mode, `${label}.mode`); if (mode !== "toggle" && mode !== "show" && mode !== "hide") { throw new Error(`${label}.mode must be toggle, show, or hide.`); } return { stepClass: "impulse", type: "setVisibility", target: targetKind === "brush" ? { kind: "brush", brushId: expectString( value.target.brushId, `${label}.target.brushId` ) } : { kind: "modelInstance", modelInstanceId: expectString( value.target.modelInstanceId, `${label}.target.modelInstanceId` ) }, mode }; } default: throw new Error(`${label}.type must be a supported sequence effect.`); } } function readProjectSequenceLibrary( value: unknown, label: string, options: { allowMissing: boolean } ): ProjectSequenceLibrary { if (value === undefined) { if (options.allowMissing) { return createEmptyProjectSequenceLibrary(); } return createEmptyProjectSequenceLibrary(); } if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } if (!isRecord(value.sequences)) { throw new Error(`${label}.sequences must be an object.`); } const sequences: ProjectSequenceLibrary["sequences"] = {}; for (const [sequenceKey, sequenceValue] of Object.entries(value.sequences)) { if (!isRecord(sequenceValue)) { throw new Error(`${label}.sequences.${sequenceKey} must be an object.`); } const effectsValue = Array.isArray(sequenceValue.effects) ? sequenceValue.effects : Array.isArray(sequenceValue.clips) ? sequenceValue.clips : sequenceValue.steps; if (!Array.isArray(effectsValue)) { throw new Error( `${label}.sequences.${sequenceKey}.effects must be an array.` ); } sequences[sequenceKey] = createProjectSequence({ id: expectString( sequenceValue.id, `${label}.sequences.${sequenceKey}.id` ), title: expectString( sequenceValue.title, `${label}.sequences.${sequenceKey}.title` ), effects: effectsValue.map((effectValue, effectIndex) => readProjectSequenceEffect( effectValue, `${label}.sequences.${sequenceKey}.effects.${effectIndex}` ) ) }); } return { sequences }; } function migrateLegacySceneNpcPresenceToScheduler( document: SceneDocument ): SceneDocument { let nextScheduler = createEmptyProjectScheduler(); let nextEntities = document.entities; let migrated = false; for (const [entityId, entity] of Object.entries(document.entities)) { if (entity.kind !== "npc" || entity.presence.mode !== "timeWindow") { continue; } if (!migrated) { nextScheduler = readProjectScheduler(document.scheduler, "scheduler", { allowMissing: true }); nextEntities = { ...document.entities }; migrated = true; } nextScheduler.routines[`schedule-routine-${entityId}`] = createProjectScheduleRoutine({ id: `schedule-routine-${entityId}`, title: entity.name?.trim() || entity.actorId, target: createActorControlTargetRef(entity.actorId), days: createProjectScheduleEveryDaySelection(), startHour: entity.presence.startHour, endHour: entity.presence.endHour, priority: 0, effect: createSetActorPresenceControlEffect({ target: createActorControlTargetRef(entity.actorId), active: true }) }); nextEntities[entityId] = createNpcEntity({ ...entity, presence: createNpcAlwaysPresence() }); } if (!migrated) { return { ...document, scheduler: readProjectScheduler(document.scheduler, "scheduler", { allowMissing: true }) }; } return { ...document, scheduler: nextScheduler, entities: nextEntities }; } function migrateLegacyProjectNpcPresenceToScheduler( document: ProjectDocument ): ProjectDocument { let nextScheduler = readProjectScheduler(document.scheduler, "scheduler", { allowMissing: true }); let nextScenes = document.scenes; let migrated = false; for (const [sceneId, scene] of Object.entries(document.scenes)) { let nextEntities = scene.entities; let sceneChanged = false; for (const [entityId, entity] of Object.entries(scene.entities)) { if (entity.kind !== "npc" || entity.presence.mode !== "timeWindow") { continue; } if (!migrated) { nextScenes = { ...document.scenes }; migrated = true; } if (!sceneChanged) { nextEntities = { ...scene.entities }; sceneChanged = true; } nextScheduler.routines[`schedule-routine-${sceneId}-${entityId}`] = createProjectScheduleRoutine({ id: `schedule-routine-${sceneId}-${entityId}`, title: entity.name?.trim() || entity.actorId, target: createActorControlTargetRef(entity.actorId), days: createProjectScheduleEveryDaySelection(), startHour: entity.presence.startHour, endHour: entity.presence.endHour, priority: 0, effect: createSetActorPresenceControlEffect({ target: createActorControlTargetRef(entity.actorId), active: true }) }); nextEntities[entityId] = createNpcEntity({ ...entity, presence: createNpcAlwaysPresence() }); } if (sceneChanged) { nextScenes[sceneId] = { ...scene, entities: nextEntities }; } } return { ...document, scheduler: nextScheduler, scenes: nextScenes }; } function readInteractionLink(value: unknown, label: string): InteractionLink { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } const trigger = expectString(value.trigger, `${label}.trigger`); if (!isInteractionTriggerKind(trigger)) { throw new Error( `${label}.trigger must be a supported interaction trigger.` ); } const action = readInteractionAction(value.action, `${label}.action`); switch (action.type) { case "teleportPlayer": return createTeleportPlayerInteractionLink({ id: expectString(value.id, `${label}.id`), 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` ), trigger, targetBrushId: action.targetBrushId, visible: action.visible }); case "playAnimation": return createPlayAnimationInteractionLink({ id: expectString(value.id, `${label}.id`), sourceEntityId: expectString( value.sourceEntityId, `${label}.sourceEntityId` ), trigger, targetModelInstanceId: action.targetModelInstanceId, clipName: action.clipName, loop: action.loop }); case "stopAnimation": return createStopAnimationInteractionLink({ id: expectString(value.id, `${label}.id`), 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` ), trigger, targetSoundEmitterId: action.targetSoundEmitterId }); case "stopSound": return createStopSoundInteractionLink({ id: expectString(value.id, `${label}.id`), sourceEntityId: expectString( value.sourceEntityId, `${label}.sourceEntityId` ), trigger, targetSoundEmitterId: action.targetSoundEmitterId }); case "runSequence": return createRunSequenceInteractionLink({ id: expectString(value.id, `${label}.id`), sourceEntityId: expectString( value.sourceEntityId, `${label}.sourceEntityId` ), trigger, sequenceId: action.sequenceId }); case "control": return createControlInteractionLink({ id: expectString(value.id, `${label}.id`), sourceEntityId: expectString( value.sourceEntityId, `${label}.sourceEntityId` ), trigger, effect: action.effect }); } } function readInteractionLinks( value: unknown ): SceneDocument["interactionLinks"] { if (!isRecord(value)) { throw new Error("interactionLinks must be a record."); } const interactionLinks: SceneDocument["interactionLinks"] = {}; for (const [linkId, linkValue] of Object.entries(value)) { const interactionLink = readInteractionLink( linkValue, `interactionLinks.${linkId}` ); if (interactionLink.id !== linkId) { throw new Error( `interactionLinks.${linkId}.id must match the registry key.` ); } interactionLinks[linkId] = interactionLink; } return interactionLinks; } function readScenePathPointValue( value: unknown, label: string ): ScenePathPoint { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return createScenePathPoint({ id: expectString(value.id, `${label}.id`), position: readVec3(value.position, `${label}.position`) }); } function readScenePathValue(value: unknown, label: string): ScenePath { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } if (!Array.isArray(value.points)) { throw new Error(`${label}.points must be an array.`); } return createScenePath({ id: expectString(value.id, `${label}.id`), name: value.name === undefined ? undefined : expectString(value.name, `${label}.name`), visible: expectBoolean(value.visible, `${label}.visible`), enabled: expectBoolean(value.enabled, `${label}.enabled`), loop: expectBoolean(value.loop, `${label}.loop`), points: value.points.map((pointValue, index) => readScenePathPointValue(pointValue, `${label}.points.${index}`) ) }); } function readScenePaths(value: unknown): SceneDocument["paths"] { if (value === undefined) { return {}; } if (!isRecord(value)) { throw new Error("paths must be a record."); } const paths: SceneDocument["paths"] = {}; for (const [pathId, pathValue] of Object.entries(value)) { const path = readScenePathValue(pathValue, `paths.${pathId}`); if (path.id !== pathId) { throw new Error(`paths.${pathId}.id must match the registry key.`); } paths[pathId] = path; } return paths; } export function migrateSceneDocument(source: unknown): SceneDocument { if (!isRecord(source)) { throw new Error("Scene document must be a JSON object."); } if (source.version === FOUNDATION_SCENE_DOCUMENT_VERSION) { expectEmptyCollection(source.materials, "materials"); expectEmptyCollection(source.brushes, "brushes"); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials: createStarterMaterialRegistry(), textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: {}, terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: expectEmptyCollection(source.entities, "entities"), interactionLinks: expectEmptyCollection( source.interactionLinks, "interactionLinks" ) }; } if (source.version === BOX_BRUSH_SCENE_DOCUMENT_VERSION) { expectEmptyCollection(source.materials, "materials"); const materials = createStarterMaterialRegistry(); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, true), terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: expectEmptyCollection(source.entities, "entities"), interactionLinks: expectEmptyCollection( source.interactionLinks, "interactionLinks" ) }; } if (source.version === FACE_MATERIALS_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: expectEmptyCollection(source.entities, "entities"), interactionLinks: expectEmptyCollection( source.interactionLinks, "interactionLinks" ) }; } if (source.version === RUNNER_V1_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: expectEmptyCollection( source.interactionLinks, "interactionLinks" ) }; } if (source.version === FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: expectEmptyCollection( source.interactionLinks, "interactionLinks" ) }; } if (source.version === WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: expectEmptyCollection( source.interactionLinks, "interactionLinks" ) }; } if (source.version === ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: expectEmptyCollection( source.interactionLinks, "interactionLinks" ) }; } if ( source.version === TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION ) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } if (source.version === MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: expectEmptyCollection( source.modelInstances, "modelInstances" ), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } if (source.version === LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } if (source.version === ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: true }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } // v11 → v12: animation fields added to model instances and interaction links // readModelInstance now reads animationClipName/animationAutoplay as optional (defaulting to undefined) // so no special handling is needed beyond routing through the same readers if (source.version === 11) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } if (source.version === SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } // v16 -> v18: Player Start collider settings landed before whitebox box rotation. if (source.version === IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } // v17 -> v18: box-based whitebox solids gained authored object rotation. if ( source.version === PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION ) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } // v15 -> v16: model instances gained authored collider settings. if (source.version === ENTITY_NAMES_SCENE_DOCUMENT_VERSION) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } // v14 -> v15: entities gained an optional authored name field. if (source.version === 14) { const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); return { time: createDefaultProjectTimeSettings(), scheduler: createEmptyProjectScheduler(), sequences: createEmptyProjectSequenceLibrary(), version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), world: readWorldSettings(source.world), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: {}, paths: {}, modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false }), interactionLinks: readInteractionLinks(source.interactionLinks) }; } if ( source.version !== SCENE_DOCUMENT_VERSION && source.version !== PATH_FOUNDATION_SCENE_DOCUMENT_VERSION && source.version !== NPC_PRESENCE_SCENE_DOCUMENT_VERSION && source.version !== NPC_ENTITY_FOUNDATION_SCENE_DOCUMENT_VERSION && source.version !== WORLD_TIME_ENVIRONMENT_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_TIME_NIGHT_BACKGROUND_SCENE_DOCUMENT_VERSION && source.version !== 33 && source.version !== AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION && source.version !== PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION && source.version !== PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION && source.version !== PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_NAME_SCENE_DOCUMENT_VERSION && source.version !== STATIC_SIMPLE_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION && source.version !== SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION && source.version !== PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION && source.version !== PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION && source.version !== PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION && source.version !== SCENE_TRANSITION_ENTITIES_SCENE_DOCUMENT_VERSION && source.version !== RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION && source.version !== MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION && source.version !== WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_BEVEL_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_PRIMITIVES_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION && source.version !== NPC_COLLIDER_SCENE_DOCUMENT_VERSION && source.version !== CONTROL_SURFACE_FOUNDATION_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_SCHEDULER_FOUNDATION_SCENE_DOCUMENT_VERSION && source.version !== EXPANDED_CONTROL_SURFACE_SCENE_DOCUMENT_VERSION && source.version !== SCHEDULER_CONTROL_EFFECTS_SCENE_DOCUMENT_VERSION && source.version !== SCHEDULER_ACTOR_ROUTINE_EFFECTS_SCENE_DOCUMENT_VERSION && source.version !== NPC_DIALOGUE_REFERENCE_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_DIALOGUE_LIBRARY_SCENE_DOCUMENT_VERSION && source.version !== PLAYER_START_PAUSE_BINDINGS_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_SEQUENCE_LIBRARY_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_SEQUENCE_CLIPS_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_SEQUENCE_TIMING_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION && source.version !== PROJECT_SEQUENCE_UNIFIED_VISIBILITY_SCENE_DOCUMENT_VERSION && source.version !== SCENE_TRANSITION_SEQUENCE_EFFECTS_SCENE_DOCUMENT_VERSION && source.version !== AUTHORED_TERRAIN_FOUNDATION_SCENE_DOCUMENT_VERSION && source.version !== AUTHORED_TERRAIN_PAINT_SCENE_DOCUMENT_VERSION && source.version !== AUTHORED_TERRAIN_COLLISION_SCENE_DOCUMENT_VERSION && source.version !== DAWN_DUSK_BACKGROUND_IMAGE_SCENE_DOCUMENT_VERSION && source.version !== WHITEBOX_BOX_LIGHT_VOLUME_SCENE_DOCUMENT_VERSION && source.version !== CELESTIAL_BODY_OVERLAY_SCENE_DOCUMENT_VERSION && source.version !== SHADER_SKY_SCENE_DOCUMENT_VERSION && source.version !== SHADER_SKY_HORIZON_HEIGHT_SCENE_DOCUMENT_VERSION && source.version !== SHADER_SKY_STAR_HORIZON_FADE_SCENE_DOCUMENT_VERSION && source.version !== CAMERA_RIG_ENTITY_SCENE_DOCUMENT_VERSION && source.version !== CAMERA_RIG_RAIL_SCENE_DOCUMENT_VERSION && source.version !== CELESTIAL_ORBIT_SETTINGS_SCENE_DOCUMENT_VERSION && source.version !== SCENE_DOCUMENT_VERSION && source.version !== FOLLOW_ACTOR_PATH_SMOOTH_SCENE_DOCUMENT_VERSION ) { throw new Error( `Unsupported scene document version: ${String(source.version)}.` ); } const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); const legacyDialogues = readProjectDialogueLibrary( source.dialogues, "dialogues", { allowMissing: true } ); const migratedDocument: SceneDocument = { version: SCENE_DOCUMENT_VERSION, name: expectString(source.name, "name"), time: readProjectTimeSettings(source.time, "time", { allowMissing: source.version < PROJECT_TIME_SYSTEM_SCENE_DOCUMENT_VERSION }), scheduler: readProjectScheduler(source.scheduler, "scheduler", { allowMissing: source.version < PROJECT_SCHEDULER_FOUNDATION_SCENE_DOCUMENT_VERSION }), sequences: readProjectSequenceLibrary(source.sequences, "sequences", { allowMissing: source.version < PROJECT_SEQUENCE_LIBRARY_SCENE_DOCUMENT_VERSION }), world: readWorldSettings(source.world, { legacyProjectTimeValue: source.version < SCENE_DOCUMENT_VERSION ? source.time : undefined }), materials, textures: expectEmptyCollection(source.textures, "textures"), assets, brushes: readBrushes(source.brushes, materials, false), terrains: readTerrains(source.terrains), paths: readScenePaths(source.paths), modelInstances: readModelInstances(source.modelInstances, assets), entities: readEntities(source.entities, { legacySoundEmitter: false, legacyProjectDialogues: legacyDialogues }), interactionLinks: readInteractionLinks(source.interactionLinks) }; return source.version < PROJECT_SCHEDULER_FOUNDATION_SCENE_DOCUMENT_VERSION ? migrateLegacySceneNpcPresenceToScheduler(migratedDocument) : migratedDocument; } function readProjectScene( value: unknown, label: string, materials: Record, assets: Record, options: { allowMissingLoadingScreen: boolean; allowMissingEditorPreferences: boolean; legacyProjectTimeValue?: unknown; legacyProjectDialogues?: ProjectDialogueLibrary; } ): ProjectScene { if (!isRecord(value)) { throw new Error(`${label} must be an object.`); } return { id: expectString(value.id, `${label}.id`), name: expectString(value.name, `${label}.name`), loadingScreen: readSceneLoadingScreen( value.loadingScreen, `${label}.loadingScreen`, { allowMissing: options.allowMissingLoadingScreen } ), editorPreferences: readSceneEditorPreferences( value.editorPreferences, `${label}.editorPreferences`, { allowMissing: options.allowMissingEditorPreferences } ), world: readWorldSettings(value.world, { legacyProjectTimeValue: options.legacyProjectTimeValue }), brushes: readBrushes(value.brushes, materials, false), terrains: readTerrains(value.terrains), paths: readScenePaths(value.paths), modelInstances: readModelInstances(value.modelInstances, assets), entities: readEntities(value.entities, { legacySoundEmitter: false, legacyProjectDialogues: options.legacyProjectDialogues }), interactionLinks: readInteractionLinks(value.interactionLinks) }; } function isProjectDocumentVersion(value: unknown): value is number { return ( typeof value === "number" && Number.isInteger(value) && value >= MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION && value <= SCENE_DOCUMENT_VERSION ); } function readProjectName( value: unknown, label: string, options: { allowMissing: boolean } ): string { if (value === undefined && options.allowMissing) { return DEFAULT_PROJECT_NAME; } return expectString(value, label); } export function migrateProjectDocument(source: unknown): ProjectDocument { if (!isRecord(source)) { throw new Error("Project document must be a JSON object."); } if (isProjectDocumentVersion(source.version)) { if (!isRecord(source.scenes)) { throw new Error("scenes must be an object."); } const materials = readMaterialRegistry(source.materials, "materials", { allowLegacyStarterPatterns: source.version < STARTER_PBR_MATERIAL_LIBRARY_SCENE_DOCUMENT_VERSION }); const assets = readAssets(source.assets); const scenes: Record = {}; const allowMissingLoadingScreen = source.version === MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION; const allowMissingProjectName = source.version < PROJECT_NAME_SCENE_DOCUMENT_VERSION; const allowMissingEditorPreferences = source.version < SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION; const allowMissingTimeSettings = source.version < PROJECT_TIME_SYSTEM_SCENE_DOCUMENT_VERSION; const legacyDialogues = readProjectDialogueLibrary( source.dialogues, "dialogues", { allowMissing: true } ); for (const [sceneKey, sceneValue] of Object.entries(source.scenes)) { scenes[sceneKey] = readProjectScene( sceneValue, `scenes.${sceneKey}`, materials, assets, { allowMissingLoadingScreen, allowMissingEditorPreferences, legacyProjectTimeValue: source.version < SCENE_DOCUMENT_VERSION ? source.time : undefined, legacyProjectDialogues: legacyDialogues } ); } const migratedDocument: ProjectDocument = { version: SCENE_DOCUMENT_VERSION, name: readProjectName(source.name, "name", { allowMissing: allowMissingProjectName }), time: readProjectTimeSettings(source.time, "time", { allowMissing: allowMissingTimeSettings }), scheduler: readProjectScheduler(source.scheduler, "scheduler", { allowMissing: source.version < PROJECT_SCHEDULER_FOUNDATION_SCENE_DOCUMENT_VERSION }), sequences: readProjectSequenceLibrary(source.sequences, "sequences", { allowMissing: source.version < PROJECT_SEQUENCE_LIBRARY_SCENE_DOCUMENT_VERSION }), activeSceneId: expectString(source.activeSceneId, "activeSceneId"), scenes, materials, textures: expectEmptyCollection(source.textures, "textures"), assets }; return source.version < PROJECT_SCHEDULER_FOUNDATION_SCENE_DOCUMENT_VERSION ? migrateLegacyProjectNpcPresenceToScheduler(migratedDocument) : migratedDocument; } return createProjectDocumentFromSceneDocument( migrateSceneDocument(source), DEFAULT_PROJECT_SCENE_ID ); }