Update scene document migration and validation for model assets

This commit is contained in:
2026-03-31 17:31:48 +02:00
parent 082f1ffc3a
commit efb465f2ec
4 changed files with 462 additions and 7 deletions

View File

@@ -1,4 +1,17 @@
import { createStarterMaterialRegistry, type MaterialDef, type MaterialPattern } from "../materials/starter-material-library";
import {
createModelInstance,
normalizeModelInstanceName,
type ModelInstance
} from "../assets/model-instances";
import {
isProjectAssetKind,
type AudioAssetMetadata,
type ImageAssetMetadata,
type ModelAssetMetadata,
type ProjectAssetBoundingBox,
type ProjectAssetRecord
} from "../assets/project-assets";
import {
createInteractableEntity,
createPlayerStartEntity,
@@ -29,6 +42,7 @@ import {
FACE_MATERIALS_SCENE_DOCUMENT_VERSION,
FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION,
FOUNDATION_SCENE_DOCUMENT_VERSION,
MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION,
RUNNER_V1_SCENE_DOCUMENT_VERSION,
SCENE_DOCUMENT_VERSION,
TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION,
@@ -135,6 +149,203 @@ function expectEmptyCollection(value: unknown, label: string): Record<string, ne
return {};
}
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;
}
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`),
scale: readVec3(value.scale, `${label}.scale`)
});
}
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 readVec2(value: unknown, label: string) {
if (!isRecord(value)) {
throw new Error(`${label} must be an object.`);
@@ -756,11 +967,29 @@ export function migrateSceneDocument(source: unknown): SceneDocument {
};
}
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"),
entities: readEntities(source.entities),
interactionLinks: readInteractionLinks(source.interactionLinks)
};
}
if (source.version !== SCENE_DOCUMENT_VERSION) {
throw new Error(`Unsupported scene document version: ${String(source.version)}.`);
}
const materials = readMaterialRegistry(source.materials, "materials");
const assets = readAssets(source.assets);
return {
version: SCENE_DOCUMENT_VERSION,
@@ -768,9 +997,9 @@ export function migrateSceneDocument(source: unknown): SceneDocument {
world: readWorldSettings(source.world),
materials,
textures: expectEmptyCollection(source.textures, "textures"),
assets: expectEmptyCollection(source.assets, "assets"),
assets,
brushes: readBrushes(source.brushes, materials, false),
modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"),
modelInstances: readModelInstances(source.modelInstances, assets),
entities: readEntities(source.entities),
interactionLinks: readInteractionLinks(source.interactionLinks)
};