2026-03-31 02:34:13 +02:00
|
|
|
import { createStarterMaterialRegistry, type MaterialDef, type MaterialPattern } from "../materials/starter-material-library";
|
2026-03-31 17:31:48 +02:00
|
|
|
import {
|
2026-04-04 07:51:38 +02:00
|
|
|
createModelInstanceCollisionSettings,
|
2026-03-31 17:31:48 +02:00
|
|
|
createModelInstance,
|
2026-04-04 07:51:38 +02:00
|
|
|
isModelInstanceCollisionMode,
|
2026-03-31 17:31:48 +02:00
|
|
|
normalizeModelInstanceName,
|
2026-04-04 07:51:38 +02:00
|
|
|
type ModelInstanceCollisionSettings,
|
2026-03-31 17:31:48 +02:00
|
|
|
type ModelInstance
|
|
|
|
|
} from "../assets/model-instances";
|
|
|
|
|
import {
|
|
|
|
|
isProjectAssetKind,
|
|
|
|
|
type AudioAssetMetadata,
|
|
|
|
|
type ImageAssetMetadata,
|
|
|
|
|
type ModelAssetMetadata,
|
|
|
|
|
type ProjectAssetBoundingBox,
|
|
|
|
|
type ProjectAssetRecord
|
|
|
|
|
} from "../assets/project-assets";
|
2026-03-31 05:50:59 +02:00
|
|
|
import {
|
2026-04-04 15:51:56 +02:00
|
|
|
createPlayerStartColliderSettings,
|
2026-03-31 05:50:59 +02:00
|
|
|
createInteractableEntity,
|
2026-04-03 01:02:55 +02:00
|
|
|
normalizeEntityName,
|
2026-03-31 19:57:57 +02:00
|
|
|
createPointLightEntity,
|
2026-03-31 05:50:59 +02:00
|
|
|
createPlayerStartEntity,
|
|
|
|
|
createSoundEmitterEntity,
|
2026-03-31 19:57:57 +02:00
|
|
|
createSpotLightEntity,
|
2026-03-31 05:50:59 +02:00
|
|
|
createTeleportTargetEntity,
|
|
|
|
|
createTriggerVolumeEntity,
|
2026-04-04 15:51:56 +02:00
|
|
|
isPlayerStartColliderMode,
|
2026-03-31 05:50:59 +02:00
|
|
|
type EntityInstance
|
|
|
|
|
} from "../entities/entity-instances";
|
2026-03-31 06:15:45 +02:00
|
|
|
import {
|
2026-04-01 00:09:56 +02:00
|
|
|
createPlayAnimationInteractionLink,
|
2026-04-02 19:36:43 +02:00
|
|
|
createPlaySoundInteractionLink,
|
2026-04-01 00:09:56 +02:00
|
|
|
createStopAnimationInteractionLink,
|
2026-04-02 19:36:43 +02:00
|
|
|
createStopSoundInteractionLink,
|
2026-03-31 06:15:45 +02:00
|
|
|
createTeleportPlayerInteractionLink,
|
|
|
|
|
createToggleVisibilityInteractionLink,
|
|
|
|
|
isInteractionTriggerKind,
|
|
|
|
|
type InteractionLink
|
|
|
|
|
} from "../interactions/interaction-links";
|
2026-03-31 02:03:01 +02:00
|
|
|
import {
|
2026-03-31 02:34:13 +02:00
|
|
|
createBoxBrush,
|
|
|
|
|
createDefaultFaceUvState,
|
|
|
|
|
isBoxFaceId,
|
|
|
|
|
isFaceUvRotationQuarterTurns,
|
2026-03-31 04:22:46 +02:00
|
|
|
normalizeBrushName,
|
2026-03-31 02:34:13 +02:00
|
|
|
type BoxBrushFaces,
|
|
|
|
|
type BrushFace,
|
|
|
|
|
type FaceUvState
|
|
|
|
|
} from "./brushes";
|
|
|
|
|
import {
|
|
|
|
|
BOX_BRUSH_SCENE_DOCUMENT_VERSION,
|
2026-04-02 19:36:43 +02:00
|
|
|
ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION,
|
2026-04-03 01:02:55 +02:00
|
|
|
ENTITY_NAMES_SCENE_DOCUMENT_VERSION,
|
2026-03-31 06:45:28 +02:00
|
|
|
ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
2026-03-31 03:00:38 +02:00
|
|
|
FACE_MATERIALS_SCENE_DOCUMENT_VERSION,
|
2026-03-31 05:09:52 +02:00
|
|
|
FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION,
|
2026-03-31 02:03:01 +02:00
|
|
|
FOUNDATION_SCENE_DOCUMENT_VERSION,
|
2026-04-04 07:51:38 +02:00
|
|
|
IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION,
|
2026-03-31 19:57:57 +02:00
|
|
|
LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION,
|
2026-03-31 17:31:48 +02:00
|
|
|
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
|
2026-04-04 15:51:56 +02:00
|
|
|
PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION,
|
2026-03-31 04:22:46 +02:00
|
|
|
RUNNER_V1_SCENE_DOCUMENT_VERSION,
|
2026-04-02 20:49:31 +02:00
|
|
|
SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION,
|
2026-03-31 02:03:01 +02:00
|
|
|
SCENE_DOCUMENT_VERSION,
|
2026-03-31 06:45:28 +02:00
|
|
|
TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION,
|
2026-03-31 05:50:59 +02:00
|
|
|
WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION,
|
2026-03-31 05:09:52 +02:00
|
|
|
type SceneDocument
|
2026-03-31 02:03:01 +02:00
|
|
|
} from "./scene-document";
|
2026-04-02 20:49:31 +02:00
|
|
|
import {
|
|
|
|
|
createDefaultAdvancedRenderingSettings,
|
|
|
|
|
isAdvancedRenderingShadowMapSize,
|
|
|
|
|
isAdvancedRenderingShadowType,
|
|
|
|
|
isAdvancedRenderingToneMappingMode,
|
|
|
|
|
isWorldBackgroundMode,
|
|
|
|
|
type AdvancedRenderingSettings,
|
|
|
|
|
type WorldBackgroundSettings,
|
|
|
|
|
type WorldSettings
|
|
|
|
|
} from "./world-settings";
|
2026-03-31 01:29:35 +02:00
|
|
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:09:52 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:50:59 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 01:29:35 +02:00
|
|
|
function expectString(value: unknown, label: string): string {
|
|
|
|
|
if (typeof value !== "string") {
|
|
|
|
|
throw new Error(`${label} must be a string.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 01:29:35 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 01:30:33 +02:00
|
|
|
function expectLiteralString<T extends string>(value: unknown, expectedValue: T, label: string): T {
|
|
|
|
|
if (value !== expectedValue) {
|
|
|
|
|
throw new Error(`${label} must be ${expectedValue}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return expectedValue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:49:31 +02:00
|
|
|
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 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:57:29 +02:00
|
|
|
function readOptionalAllowedValue<T>(value: unknown, label: string, fallback: T, guard: (candidate: unknown) => candidate is T): T {
|
2026-04-02 20:49:31 +02:00
|
|
|
if (value === undefined) {
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:57:29 +02:00
|
|
|
if (!guard(value)) {
|
2026-04-02 20:49:31 +02:00
|
|
|
throw new Error(`${label} must be a supported value.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:57:29 +02:00
|
|
|
return value;
|
2026-04-02 20:49:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shadows = value.shadows as Record<string, unknown> | undefined;
|
|
|
|
|
const ambientOcclusion = value.ambientOcclusion as Record<string, unknown> | undefined;
|
|
|
|
|
const bloom = value.bloom as Record<string, unknown> | undefined;
|
|
|
|
|
const toneMapping = value.toneMapping as Record<string, unknown> | undefined;
|
|
|
|
|
const depthOfField = value.depthOfField as Record<string, unknown> | 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
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
function expectOptionalString(value: unknown, label: string): string | undefined {
|
|
|
|
|
if (value === undefined) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return expectString(value, label);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:22:46 +02:00
|
|
|
function readOptionalBrushName(value: unknown, label: string): string | undefined {
|
|
|
|
|
return normalizeBrushName(expectOptionalString(value, label));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 01:03:04 +02:00
|
|
|
function readOptionalEntityName(value: unknown, label: string): string | undefined {
|
|
|
|
|
return normalizeEntityName(expectOptionalString(value, label));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 01:29:35 +02:00
|
|
|
function expectEmptyCollection(value: unknown, label: string): Record<string, never> {
|
|
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be a record.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Object.keys(value).length > 0) {
|
2026-03-31 02:34:13 +02:00
|
|
|
throw new Error(`${label} must be empty in the current schema.`);
|
2026-03-31 01:29:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:31:48 +02:00
|
|
|
function readProjectAssetBoundingBox(value: unknown, label: string): ProjectAssetBoundingBox {
|
|
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be an object.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const min = readVec3(value.min, `${label}.min`);
|
|
|
|
|
const max = readVec3(value.max, `${label}.max`);
|
|
|
|
|
const size = readVec3(value.size, `${label}.size`);
|
|
|
|
|
|
|
|
|
|
if (size.x < 0 || size.y < 0 || size.z < 0) {
|
|
|
|
|
throw new Error(`${label}.size values must remain zero or greater.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
min,
|
|
|
|
|
max,
|
|
|
|
|
size
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 07:51:38 +02:00
|
|
|
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)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:52:02 +02:00
|
|
|
function readPlayerStartColliderSettings(value: unknown, label: string) {
|
|
|
|
|
if (value === undefined) {
|
|
|
|
|
return createPlayerStartColliderSettings();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be an object.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 16:01:28 +02:00
|
|
|
const mode = readOptionalAllowedValue(
|
|
|
|
|
value.mode,
|
|
|
|
|
`${label}.mode`,
|
|
|
|
|
"capsule",
|
|
|
|
|
(candidate): candidate is "capsule" | "box" | "none" => typeof candidate === "string" && isPlayerStartColliderMode(candidate)
|
|
|
|
|
);
|
2026-04-04 15:52:02 +02:00
|
|
|
|
|
|
|
|
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`)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:31:48 +02:00
|
|
|
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`)),
|
|
|
|
|
position: readVec3(value.position, `${label}.position`),
|
|
|
|
|
rotationDegrees: readVec3(value.rotationDegrees, `${label}.rotationDegrees`),
|
2026-04-01 00:10:14 +02:00
|
|
|
scale: readVec3(value.scale, `${label}.scale`),
|
2026-04-04 07:51:38 +02:00
|
|
|
collision: readModelInstanceCollisionSettings(value.collision, `${label}.collision`),
|
2026-04-01 00:10:14 +02:00
|
|
|
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`)
|
2026-03-31 17:31:48 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
function readVec2(value: unknown, label: string) {
|
|
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be an object.`);
|
2026-03-31 02:03:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
return {
|
|
|
|
|
x: expectFiniteNumber(value.x, `${label}.x`),
|
|
|
|
|
y: expectFiniteNumber(value.y, `${label}.y`)
|
|
|
|
|
};
|
2026-03-31 02:03:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
function readVec3(value: unknown, label: string) {
|
2026-03-31 02:03:01 +02:00
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be an object.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
return {
|
|
|
|
|
x: expectFiniteNumber(value.x, `${label}.x`),
|
|
|
|
|
y: expectFiniteNumber(value.y, `${label}.y`),
|
|
|
|
|
z: expectFiniteNumber(value.z, `${label}.z`)
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-03-31 02:03:01 +02:00
|
|
|
|
2026-03-31 05:09:52 +02:00
|
|
|
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.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
function expectMaterialPattern(value: unknown, label: string): MaterialPattern {
|
|
|
|
|
if (value !== "grid" && value !== "checker" && value !== "stripes" && value !== "diamond") {
|
|
|
|
|
throw new Error(`${label} must be a supported starter material pattern.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readMaterialRegistry(value: unknown, label: string): SceneDocument["materials"] {
|
|
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be a record.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const materials: SceneDocument["materials"] = {};
|
|
|
|
|
|
|
|
|
|
for (const [materialId, materialValue] of Object.entries(value)) {
|
|
|
|
|
if (!isRecord(materialValue)) {
|
|
|
|
|
throw new Error(`${label}.${materialId} must be an object.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const material: MaterialDef = {
|
|
|
|
|
id: expectString(materialValue.id, `${label}.${materialId}.id`),
|
|
|
|
|
name: expectString(materialValue.name, `${label}.${materialId}.name`),
|
|
|
|
|
baseColorHex: expectHexColor(materialValue.baseColorHex, `${label}.${materialId}.baseColorHex`),
|
|
|
|
|
accentColorHex: expectHexColor(materialValue.accentColorHex, `${label}.${materialId}.accentColorHex`),
|
|
|
|
|
pattern: expectMaterialPattern(materialValue.pattern, `${label}.${materialId}.pattern`),
|
|
|
|
|
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.`);
|
2026-03-31 02:03:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-31 02:34:13 +02:00
|
|
|
offset: readVec2(value.offset, `${label}.offset`),
|
|
|
|
|
scale,
|
|
|
|
|
rotationQuarterTurns,
|
|
|
|
|
flipU: expectBoolean(value.flipU, `${label}.flipU`),
|
|
|
|
|
flipV: expectBoolean(value.flipV, `${label}.flipV`)
|
2026-03-31 02:03:01 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
function readBrushFace(
|
|
|
|
|
value: unknown,
|
|
|
|
|
label: string,
|
|
|
|
|
materials: SceneDocument["materials"],
|
|
|
|
|
allowMissingUvState: boolean
|
|
|
|
|
): BrushFace {
|
2026-03-31 02:03:01 +02:00
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be an object.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
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}.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:03:01 +02:00
|
|
|
return {
|
2026-03-31 02:34:13 +02:00
|
|
|
materialId: materialId ?? null,
|
|
|
|
|
uv:
|
|
|
|
|
value.uv === undefined && allowMissingUvState
|
|
|
|
|
? createDefaultFaceUvState()
|
|
|
|
|
: readFaceUvState(value.uv, `${label}.uv`)
|
2026-03-31 02:03:01 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
function readBoxBrushFaces(
|
|
|
|
|
value: unknown,
|
|
|
|
|
label: string,
|
|
|
|
|
materials: SceneDocument["materials"],
|
|
|
|
|
allowMissingUvState: boolean
|
|
|
|
|
): BoxBrushFaces {
|
2026-03-31 02:03:01 +02:00
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be an object.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const extraFaceKeys = Object.keys(value).filter((faceId) => !isBoxFaceId(faceId));
|
|
|
|
|
|
|
|
|
|
if (extraFaceKeys.length > 0) {
|
|
|
|
|
throw new Error(`${label} contains unsupported face ids: ${extraFaceKeys.join(", ")}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-03-31 02:34:13 +02:00
|
|
|
posX: readBrushFace(value.posX, `${label}.posX`, materials, allowMissingUvState),
|
|
|
|
|
negX: readBrushFace(value.negX, `${label}.negX`, materials, allowMissingUvState),
|
|
|
|
|
posY: readBrushFace(value.posY, `${label}.posY`, materials, allowMissingUvState),
|
|
|
|
|
negY: readBrushFace(value.negY, `${label}.negY`, materials, allowMissingUvState),
|
|
|
|
|
posZ: readBrushFace(value.posZ, `${label}.posZ`, materials, allowMissingUvState),
|
|
|
|
|
negZ: readBrushFace(value.negZ, `${label}.negZ`, materials, allowMissingUvState)
|
2026-03-31 02:03:01 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
function readBrushes(
|
|
|
|
|
value: unknown,
|
|
|
|
|
materials: SceneDocument["materials"],
|
|
|
|
|
allowMissingUvState: boolean
|
|
|
|
|
): SceneDocument["brushes"] {
|
2026-03-31 02:03:01 +02:00
|
|
|
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.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (brushValue.kind !== "box") {
|
|
|
|
|
throw new Error(`brushes.${brushId}.kind must be box.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
brushes[brushId] = createBoxBrush({
|
|
|
|
|
id: expectString(brushValue.id, `brushes.${brushId}.id`),
|
2026-03-31 04:22:46 +02:00
|
|
|
name: readOptionalBrushName(brushValue.name, `brushes.${brushId}.name`),
|
2026-03-31 02:03:01 +02:00
|
|
|
center,
|
|
|
|
|
size,
|
2026-03-31 02:34:13 +02:00
|
|
|
faces: readBoxBrushFaces(brushValue.faces, `brushes.${brushId}.faces`, materials, allowMissingUvState),
|
2026-03-31 02:03:01 +02:00
|
|
|
layerId: expectOptionalString(brushValue.layerId, `brushes.${brushId}.layerId`),
|
|
|
|
|
groupId: expectOptionalString(brushValue.groupId, `brushes.${brushId}.groupId`)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return brushes;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 01:29:35 +02:00
|
|
|
function readWorldSettings(value: 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.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:09:52 +02:00
|
|
|
const direction = readVec3(sunLight.direction, "world.sunLight.direction");
|
|
|
|
|
assertNonZeroVec3(direction, "world.sunLight.direction");
|
2026-03-31 01:29:35 +02:00
|
|
|
|
2026-03-31 05:09:52 +02:00
|
|
|
const backgroundMode = expectString(background.mode, "world.background.mode");
|
|
|
|
|
let resolvedBackground: WorldBackgroundSettings;
|
|
|
|
|
|
|
|
|
|
if (!isWorldBackgroundMode(backgroundMode)) {
|
|
|
|
|
throw new Error("world.background.mode must be a supported background mode.");
|
2026-03-31 01:29:35 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:09:52 +02:00
|
|
|
if (backgroundMode === "solid") {
|
|
|
|
|
resolvedBackground = {
|
|
|
|
|
mode: "solid",
|
2026-03-31 01:29:35 +02:00
|
|
|
colorHex: expectHexColor(background.colorHex, "world.background.colorHex")
|
2026-03-31 05:09:52 +02:00
|
|
|
};
|
2026-03-31 19:57:57 +02:00
|
|
|
} else if (backgroundMode === "verticalGradient") {
|
2026-03-31 05:09:52 +02:00
|
|
|
resolvedBackground = {
|
|
|
|
|
mode: "verticalGradient",
|
|
|
|
|
topColorHex: expectHexColor(background.topColorHex, "world.background.topColorHex"),
|
|
|
|
|
bottomColorHex: expectHexColor(background.bottomColorHex, "world.background.bottomColorHex")
|
|
|
|
|
};
|
2026-03-31 19:57:57 +02:00
|
|
|
} else {
|
|
|
|
|
resolvedBackground = {
|
|
|
|
|
mode: "image",
|
2026-03-31 23:14:05 +02:00
|
|
|
assetId: expectString(background.assetId, "world.background.assetId"),
|
|
|
|
|
// Default to 0.5 for documents saved before environmentIntensity was added
|
|
|
|
|
environmentIntensity: typeof background.environmentIntensity === "number" && isFinite(background.environmentIntensity) && background.environmentIntensity >= 0
|
|
|
|
|
? background.environmentIntensity
|
|
|
|
|
: 0.5
|
2026-03-31 19:57:57 +02:00
|
|
|
};
|
2026-03-31 05:09:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
background: resolvedBackground,
|
2026-03-31 01:29:35 +02:00
|
|
|
ambientLight: {
|
|
|
|
|
colorHex: expectHexColor(ambientLight.colorHex, "world.ambientLight.colorHex"),
|
2026-03-31 05:09:52 +02:00
|
|
|
intensity: expectNonNegativeFiniteNumber(ambientLight.intensity, "world.ambientLight.intensity")
|
2026-03-31 01:29:35 +02:00
|
|
|
},
|
|
|
|
|
sunLight: {
|
|
|
|
|
colorHex: expectHexColor(sunLight.colorHex, "world.sunLight.colorHex"),
|
2026-03-31 05:09:52 +02:00
|
|
|
intensity: expectNonNegativeFiniteNumber(sunLight.intensity, "world.sunLight.intensity"),
|
|
|
|
|
direction
|
2026-04-02 20:49:31 +02:00
|
|
|
},
|
|
|
|
|
advancedRendering: readAdvancedRenderingSettings(value.advancedRendering)
|
2026-03-31 01:29:35 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 19:57:57 +02:00
|
|
|
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`),
|
2026-04-03 01:03:06 +02:00
|
|
|
name: readOptionalEntityName(value.name, `${label}.name`),
|
2026-03-31 19:57:57 +02:00
|
|
|
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`),
|
2026-04-03 01:03:06 +02:00
|
|
|
name: readOptionalEntityName(value.name, `${label}.name`),
|
2026-03-31 19:57:57 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:00:38 +02:00
|
|
|
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`),
|
2026-04-03 01:03:06 +02:00
|
|
|
name: readOptionalEntityName(value.name, `${label}.name`),
|
2026-03-31 03:00:38 +02:00
|
|
|
position: readVec3(value.position, `${label}.position`),
|
2026-04-04 15:52:02 +02:00
|
|
|
yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`),
|
|
|
|
|
collider: readPlayerStartColliderSettings(value.collider, `${label}.collider`)
|
2026-03-31 03:00:38 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (entity.kind !== kind) {
|
|
|
|
|
throw new Error(`${label}.kind must be playerStart.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return entity;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:51:07 +02:00
|
|
|
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`),
|
2026-04-03 01:03:06 +02:00
|
|
|
name: readOptionalEntityName(value.name, `${label}.name`),
|
2026-03-31 05:51:07 +02:00
|
|
|
position: readVec3(value.position, `${label}.position`),
|
2026-04-02 19:36:48 +02:00
|
|
|
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`),
|
2026-04-03 01:03:06 +02:00
|
|
|
name: readOptionalEntityName(value.name, `${label}.name`),
|
2026-04-02 19:36:48 +02:00
|
|
|
position: readVec3(value.position, `${label}.position`),
|
|
|
|
|
refDistance: radius,
|
|
|
|
|
maxDistance: radius,
|
|
|
|
|
volume: expectNonNegativeFiniteNumber(value.gain, `${label}.gain`),
|
2026-03-31 05:51:07 +02:00
|
|
|
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`),
|
2026-04-03 01:03:06 +02:00
|
|
|
name: readOptionalEntityName(value.name, `${label}.name`),
|
2026-03-31 05:51:07 +02:00
|
|
|
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`),
|
2026-04-03 01:03:06 +02:00
|
|
|
name: readOptionalEntityName(value.name, `${label}.name`),
|
2026-03-31 05:51:07 +02:00
|
|
|
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 entity = createInteractableEntity({
|
|
|
|
|
id: expectString(value.id, `${label}.id`),
|
2026-04-03 01:03:06 +02:00
|
|
|
name: readOptionalEntityName(value.name, `${label}.name`),
|
2026-03-31 05:51:07 +02:00
|
|
|
position: readVec3(value.position, `${label}.position`),
|
|
|
|
|
radius: expectPositiveFiniteNumber(value.radius, `${label}.radius`),
|
|
|
|
|
prompt: expectString(value.prompt, `${label}.prompt`),
|
|
|
|
|
enabled: expectBoolean(value.enabled, `${label}.enabled`)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (entity.kind !== kind) {
|
|
|
|
|
throw new Error(`${label}.kind must be interactable.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return entity;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:37:02 +02:00
|
|
|
function readEntityInstance(value: unknown, label: string, options: { legacySoundEmitter: boolean }): EntityInstance {
|
2026-03-31 05:51:07 +02:00
|
|
|
if (!isRecord(value)) {
|
|
|
|
|
throw new Error(`${label} must be an object.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (value.kind) {
|
2026-03-31 19:57:57 +02:00
|
|
|
case "pointLight":
|
|
|
|
|
return readPointLightEntity(value, label);
|
|
|
|
|
case "spotLight":
|
|
|
|
|
return readSpotLightEntity(value, label);
|
2026-03-31 05:51:07 +02:00
|
|
|
case "playerStart":
|
|
|
|
|
return readPlayerStartEntity(value, label);
|
|
|
|
|
case "soundEmitter":
|
2026-04-02 19:37:02 +02:00
|
|
|
return options.legacySoundEmitter ? readLegacySoundEmitterEntity(value, label) : readSoundEmitterEntity(value, label);
|
2026-03-31 05:51:07 +02:00
|
|
|
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.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:37:02 +02:00
|
|
|
function readEntities(value: unknown, options: { legacySoundEmitter: boolean }): SceneDocument["entities"] {
|
2026-03-31 03:00:38 +02:00
|
|
|
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.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:37:02 +02:00
|
|
|
const entity = readEntityInstance(entityValue, `entities.${entityId}`, options);
|
2026-03-31 03:00:38 +02:00
|
|
|
|
|
|
|
|
if (entity.id !== entityId) {
|
|
|
|
|
throw new Error(`entities.${entityId}.id must match the registry key.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entities[entityId] = entity;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return entities;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:16:05 +02:00
|
|
|
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;
|
2026-04-01 00:10:24 +02:00
|
|
|
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,
|
2026-04-01 04:05:33 +02:00
|
|
|
clipName,
|
|
|
|
|
loop: value.loop === undefined ? undefined : expectBoolean(value.loop, `${label}.loop`)
|
2026-04-01 00:10:24 +02:00
|
|
|
}).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;
|
|
|
|
|
}
|
2026-04-02 19:37:02 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-31 06:16:05 +02:00
|
|
|
default:
|
|
|
|
|
throw new Error(`${label}.type must be a supported interaction action.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
});
|
2026-04-01 00:10:28 +02:00
|
|
|
case "playAnimation":
|
|
|
|
|
return createPlayAnimationInteractionLink({
|
|
|
|
|
id: expectString(value.id, `${label}.id`),
|
|
|
|
|
sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`),
|
|
|
|
|
trigger,
|
|
|
|
|
targetModelInstanceId: action.targetModelInstanceId,
|
2026-04-01 04:06:12 +02:00
|
|
|
clipName: action.clipName,
|
|
|
|
|
loop: action.loop
|
2026-04-01 00:10:28 +02:00
|
|
|
});
|
|
|
|
|
case "stopAnimation":
|
|
|
|
|
return createStopAnimationInteractionLink({
|
|
|
|
|
id: expectString(value.id, `${label}.id`),
|
|
|
|
|
sourceEntityId: expectString(value.sourceEntityId, `${label}.sourceEntityId`),
|
|
|
|
|
trigger,
|
|
|
|
|
targetModelInstanceId: action.targetModelInstanceId
|
|
|
|
|
});
|
2026-04-02 19:37:02 +02:00
|
|
|
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
|
|
|
|
|
});
|
2026-03-31 06:16:05 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 01:29:35 +02:00
|
|
|
export function migrateSceneDocument(source: unknown): SceneDocument {
|
|
|
|
|
if (!isRecord(source)) {
|
|
|
|
|
throw new Error("Scene document must be a JSON object.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:03:01 +02:00
|
|
|
if (source.version === FOUNDATION_SCENE_DOCUMENT_VERSION) {
|
2026-03-31 02:40:21 +02:00
|
|
|
expectEmptyCollection(source.materials, "materials");
|
|
|
|
|
expectEmptyCollection(source.brushes, "brushes");
|
|
|
|
|
|
2026-03-31 02:03:01 +02:00
|
|
|
return {
|
|
|
|
|
version: SCENE_DOCUMENT_VERSION,
|
|
|
|
|
name: expectString(source.name, "name"),
|
|
|
|
|
world: readWorldSettings(source.world),
|
2026-03-31 02:34:13 +02:00
|
|
|
materials: createStarterMaterialRegistry(),
|
2026-03-31 02:03:01 +02:00
|
|
|
textures: expectEmptyCollection(source.textures, "textures"),
|
|
|
|
|
assets: expectEmptyCollection(source.assets, "assets"),
|
|
|
|
|
brushes: {},
|
|
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
|
|
|
|
entities: expectEmptyCollection(source.entities, "entities"),
|
|
|
|
|
interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
if (source.version === BOX_BRUSH_SCENE_DOCUMENT_VERSION) {
|
2026-03-31 02:40:21 +02:00
|
|
|
expectEmptyCollection(source.materials, "materials");
|
2026-03-31 02:34:13 +02:00
|
|
|
const materials = createStarterMaterialRegistry();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
|
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
|
|
|
|
entities: expectEmptyCollection(source.entities, "entities"),
|
|
|
|
|
interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:00:38 +02:00
|
|
|
if (source.version === FACE_MATERIALS_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
|
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
|
|
|
|
entities: expectEmptyCollection(source.entities, "entities"),
|
|
|
|
|
interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:22:46 +02:00
|
|
|
if (source.version === RUNNER_V1_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
2026-03-31 05:09:52 +02:00
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-03-31 05:09:52 +02:00
|
|
|
interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (source.version === FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
2026-03-31 05:51:17 +02:00
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-03-31 05:51:17 +02:00
|
|
|
interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (source.version === WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
2026-03-31 04:22:46 +02:00
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-03-31 04:22:46 +02:00
|
|
|
interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:16:05 +02:00
|
|
|
if (source.version === ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
|
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-03-31 06:16:05 +02:00
|
|
|
interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks")
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:45:28 +02:00
|
|
|
if (source.version === TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
|
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-03-31 06:45:28 +02:00
|
|
|
interactionLinks: readInteractionLinks(source.interactionLinks)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:31:48 +02:00
|
|
|
if (source.version === MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
|
|
|
|
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-03-31 17:31:48 +02:00
|
|
|
interactionLinks: readInteractionLinks(source.interactionLinks)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 19:57:57 +02:00
|
|
|
if (source.version === LOCAL_LIGHTS_AND_SKYBOX_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
2026-03-31 19:58:13 +02:00
|
|
|
const assets = readAssets(source.assets);
|
2026-03-31 19:57:57 +02:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
version: SCENE_DOCUMENT_VERSION,
|
|
|
|
|
name: expectString(source.name, "name"),
|
|
|
|
|
world: readWorldSettings(source.world),
|
|
|
|
|
materials,
|
|
|
|
|
textures: expectEmptyCollection(source.textures, "textures"),
|
2026-04-01 00:10:40 +02:00
|
|
|
assets,
|
|
|
|
|
brushes: readBrushes(source.brushes, materials, false),
|
|
|
|
|
modelInstances: readModelInstances(source.modelInstances, assets),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-04-02 19:37:12 +02:00
|
|
|
interactionLinks: readInteractionLinks(source.interactionLinks)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (source.version === ANIMATION_PLAYBACK_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
const assets = readAssets(source.assets);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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),
|
|
|
|
|
modelInstances: readModelInstances(source.modelInstances, assets),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: true }),
|
2026-04-01 00:10:40 +02:00
|
|
|
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");
|
|
|
|
|
const assets = readAssets(source.assets);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
version: SCENE_DOCUMENT_VERSION,
|
|
|
|
|
name: expectString(source.name, "name"),
|
|
|
|
|
world: readWorldSettings(source.world),
|
|
|
|
|
materials,
|
|
|
|
|
textures: expectEmptyCollection(source.textures, "textures"),
|
2026-03-31 19:58:13 +02:00
|
|
|
assets,
|
2026-03-31 19:57:57 +02:00
|
|
|
brushes: readBrushes(source.brushes, materials, false),
|
2026-04-02 20:50:43 +02:00
|
|
|
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");
|
|
|
|
|
const assets = readAssets(source.assets);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
version: SCENE_DOCUMENT_VERSION,
|
2026-04-04 07:51:38 +02:00
|
|
|
name: expectString(source.name, "name"),
|
|
|
|
|
world: readWorldSettings(source.world),
|
|
|
|
|
materials,
|
|
|
|
|
textures: expectEmptyCollection(source.textures, "textures"),
|
|
|
|
|
assets,
|
|
|
|
|
brushes: readBrushes(source.brushes, materials, false),
|
|
|
|
|
modelInstances: readModelInstances(source.modelInstances, assets),
|
|
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
|
|
|
|
interactionLinks: readInteractionLinks(source.interactionLinks)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:52:07 +02:00
|
|
|
// v16 -> v17: Player Start entities gained authored collider settings.
|
|
|
|
|
if (source.version === IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
const assets = readAssets(source.assets);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
version: PLAYER_START_COLLIDER_SETTINGS_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),
|
|
|
|
|
modelInstances: readModelInstances(source.modelInstances, assets),
|
|
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
|
|
|
|
interactionLinks: readInteractionLinks(source.interactionLinks)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 07:51:38 +02:00
|
|
|
// v15 -> v16: model instances gained authored collider settings.
|
|
|
|
|
if (source.version === ENTITY_NAMES_SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
|
|
|
|
const assets = readAssets(source.assets);
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-04 16:01:28 +02:00
|
|
|
version: SCENE_DOCUMENT_VERSION,
|
2026-04-02 20:50:43 +02:00
|
|
|
name: expectString(source.name, "name"),
|
|
|
|
|
world: readWorldSettings(source.world),
|
|
|
|
|
materials,
|
|
|
|
|
textures: expectEmptyCollection(source.textures, "textures"),
|
|
|
|
|
assets,
|
|
|
|
|
brushes: readBrushes(source.brushes, materials, false),
|
2026-04-03 01:03:18 +02:00
|
|
|
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");
|
|
|
|
|
const assets = readAssets(source.assets);
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-04 07:57:32 +02:00
|
|
|
version: SCENE_DOCUMENT_VERSION,
|
2026-04-03 01:03:18 +02:00
|
|
|
name: expectString(source.name, "name"),
|
|
|
|
|
world: readWorldSettings(source.world),
|
|
|
|
|
materials,
|
|
|
|
|
textures: expectEmptyCollection(source.textures, "textures"),
|
|
|
|
|
assets,
|
|
|
|
|
brushes: readBrushes(source.brushes, materials, false),
|
2026-03-31 19:58:13 +02:00
|
|
|
modelInstances: readModelInstances(source.modelInstances, assets),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-03-31 19:57:57 +02:00
|
|
|
interactionLinks: readInteractionLinks(source.interactionLinks)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 01:29:35 +02:00
|
|
|
if (source.version !== SCENE_DOCUMENT_VERSION) {
|
|
|
|
|
throw new Error(`Unsupported scene document version: ${String(source.version)}.`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 02:34:13 +02:00
|
|
|
const materials = readMaterialRegistry(source.materials, "materials");
|
2026-03-31 17:31:48 +02:00
|
|
|
const assets = readAssets(source.assets);
|
2026-03-31 02:34:13 +02:00
|
|
|
|
2026-03-31 01:29:35 +02:00
|
|
|
return {
|
|
|
|
|
version: SCENE_DOCUMENT_VERSION,
|
|
|
|
|
name: expectString(source.name, "name"),
|
|
|
|
|
world: readWorldSettings(source.world),
|
2026-03-31 02:34:13 +02:00
|
|
|
materials,
|
2026-03-31 01:29:35 +02:00
|
|
|
textures: expectEmptyCollection(source.textures, "textures"),
|
2026-03-31 17:31:48 +02:00
|
|
|
assets,
|
2026-03-31 02:34:13 +02:00
|
|
|
brushes: readBrushes(source.brushes, materials, false),
|
2026-03-31 17:31:48 +02:00
|
|
|
modelInstances: readModelInstances(source.modelInstances, assets),
|
2026-04-02 19:45:52 +02:00
|
|
|
entities: readEntities(source.entities, { legacySoundEmitter: false }),
|
2026-03-31 06:16:05 +02:00
|
|
|
interactionLinks: readInteractionLinks(source.interactionLinks)
|
2026-03-31 01:29:35 +02:00
|
|
|
};
|
|
|
|
|
}
|