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

@@ -2,7 +2,7 @@ import { createOpaqueId } from "../core/ids";
import { cloneEditorSelection, type EditorSelection } from "../core/selection";
import type { ToolMode } from "../core/tool-mode";
import { cloneModelInstance, getModelInstanceKindLabel, type ModelInstance } from "../assets/model-instances";
import { cloneProjectAssetRecord, getProjectAssetKindLabel } from "../assets/project-assets";
import { getProjectAssetKindLabel } from "../assets/project-assets";
import type { EditorCommand } from "./command";
@@ -95,4 +95,3 @@ export function createUpsertModelInstanceCommand(options: UpsertModelInstanceCom
}
};
}

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)
};

View File

@@ -1,3 +1,13 @@
import {
type AudioAssetMetadata,
type ImageAssetMetadata,
type ModelAssetMetadata,
type ModelAssetRecord,
type ProjectAssetBoundingBox,
type ProjectAssetRecord,
type ModelInstance,
getProjectAssetKindLabel
} from "../assets/project-assets";
import {
type InteractableEntity,
type PlayerStartEntity,
@@ -158,6 +168,196 @@ function validateWorldSettings(world: WorldSettings, diagnostics: SceneDiagnosti
}
}
function validateProjectAssetBoundingBox(
boundingBox: ProjectAssetBoundingBox | null,
path: string,
diagnostics: SceneDiagnostic[]
) {
if (boundingBox === null) {
return;
}
if (!isFiniteVec3(boundingBox.min)) {
diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-min", "Model asset bounding boxes must have finite minimum coordinates.", `${path}.min`));
}
if (!isFiniteVec3(boundingBox.max)) {
diagnostics.push(createDiagnostic("error", "invalid-asset-bounding-box-max", "Model asset bounding boxes must have finite maximum coordinates.", `${path}.max`));
}
if (!isFiniteVec3(boundingBox.size) || boundingBox.size.x < 0 || boundingBox.size.y < 0 || boundingBox.size.z < 0) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-asset-bounding-box-size",
"Model asset bounding boxes must have finite, zero-or-greater size values.",
`${path}.size`
)
);
}
}
function validateModelAssetMetadata(metadata: ModelAssetMetadata, path: string, diagnostics: SceneDiagnostic[]) {
if (metadata.format !== "glb" && metadata.format !== "gltf") {
diagnostics.push(createDiagnostic("error", "invalid-model-asset-format", "Model asset format must be glb or gltf.", `${path}.format`));
}
if (metadata.sceneName !== null && metadata.sceneName.trim().length === 0) {
diagnostics.push(createDiagnostic("error", "invalid-model-asset-scene-name", "Model asset scene names must be non-empty when authored.", `${path}.sceneName`));
}
if (!isNonNegativeFiniteNumber(metadata.nodeCount)) {
diagnostics.push(createDiagnostic("error", "invalid-model-asset-node-count", "Model asset node counts must be finite and zero or greater.", `${path}.nodeCount`));
}
if (!isNonNegativeFiniteNumber(metadata.meshCount)) {
diagnostics.push(createDiagnostic("error", "invalid-model-asset-mesh-count", "Model asset mesh counts must be finite and zero or greater.", `${path}.meshCount`));
}
if (!Array.isArray(metadata.materialNames) || metadata.materialNames.some((name) => typeof name !== "string")) {
diagnostics.push(createDiagnostic("error", "invalid-model-asset-material-names", "Model asset material names must be string arrays.", `${path}.materialNames`));
}
if (!Array.isArray(metadata.textureNames) || metadata.textureNames.some((name) => typeof name !== "string")) {
diagnostics.push(createDiagnostic("error", "invalid-model-asset-texture-names", "Model asset texture names must be string arrays.", `${path}.textureNames`));
}
if (!Array.isArray(metadata.animationNames) || metadata.animationNames.some((name) => typeof name !== "string")) {
diagnostics.push(createDiagnostic("error", "invalid-model-asset-animation-names", "Model asset animation names must be string arrays.", `${path}.animationNames`));
}
validateProjectAssetBoundingBox(metadata.boundingBox, `${path}.boundingBox`, diagnostics);
if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) {
diagnostics.push(createDiagnostic("error", "invalid-model-asset-warnings", "Model asset warnings must be string arrays.", `${path}.warnings`));
}
}
function validateImageAssetMetadata(metadata: ImageAssetMetadata, path: string, diagnostics: SceneDiagnostic[]) {
if (!isPositiveFiniteNumber(metadata.width)) {
diagnostics.push(createDiagnostic("error", "invalid-image-asset-width", "Image asset width must be finite and greater than zero.", `${path}.width`));
}
if (!isPositiveFiniteNumber(metadata.height)) {
diagnostics.push(createDiagnostic("error", "invalid-image-asset-height", "Image asset height must be finite and greater than zero.", `${path}.height`));
}
if (!isBoolean(metadata.hasAlpha)) {
diagnostics.push(createDiagnostic("error", "invalid-image-asset-alpha", "Image asset alpha flags must be booleans.", `${path}.hasAlpha`));
}
if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) {
diagnostics.push(createDiagnostic("error", "invalid-image-asset-warnings", "Image asset warnings must be string arrays.", `${path}.warnings`));
}
}
function validateAudioAssetMetadata(metadata: AudioAssetMetadata, path: string, diagnostics: SceneDiagnostic[]) {
if (metadata.durationSeconds !== null && !isNonNegativeFiniteNumber(metadata.durationSeconds)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-audio-asset-duration",
"Audio asset durations must be finite and zero or greater when authored.",
`${path}.durationSeconds`
)
);
}
if (metadata.channelCount !== null && !isPositiveFiniteNumber(metadata.channelCount)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-audio-asset-channel-count",
"Audio asset channel counts must be finite and greater than zero when authored.",
`${path}.channelCount`
)
);
}
if (metadata.sampleRateHz !== null && !isPositiveFiniteNumber(metadata.sampleRateHz)) {
diagnostics.push(
createDiagnostic(
"error",
"invalid-audio-asset-sample-rate",
"Audio asset sample rates must be finite and greater than zero when authored.",
`${path}.sampleRateHz`
)
);
}
if (!Array.isArray(metadata.warnings) || metadata.warnings.some((warning) => typeof warning !== "string")) {
diagnostics.push(createDiagnostic("error", "invalid-audio-asset-warnings", "Audio asset warnings must be string arrays.", `${path}.warnings`));
}
}
function validateProjectAsset(asset: ProjectAssetRecord, path: string, diagnostics: SceneDiagnostic[]) {
if (asset.sourceName.trim().length === 0) {
diagnostics.push(createDiagnostic("error", "invalid-asset-source-name", "Asset source names must be non-empty strings.", `${path}.sourceName`));
}
if (asset.mimeType.trim().length === 0) {
diagnostics.push(createDiagnostic("error", "invalid-asset-mime-type", "Asset mime types must be non-empty strings.", `${path}.mimeType`));
}
if (asset.storageKey.trim().length === 0) {
diagnostics.push(createDiagnostic("error", "invalid-asset-storage-key", "Asset storage keys must be non-empty strings.", `${path}.storageKey`));
}
if (!isPositiveFiniteNumber(asset.byteLength)) {
diagnostics.push(createDiagnostic("error", "invalid-asset-byte-length", "Asset byte lengths must be finite and greater than zero.", `${path}.byteLength`));
}
switch (asset.kind) {
case "model":
validateModelAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics);
break;
case "image":
validateImageAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics);
break;
case "audio":
validateAudioAssetMetadata(asset.metadata, `${path}.metadata`, diagnostics);
break;
}
}
function validateModelInstance(modelInstance: ModelInstance, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[]) {
if (modelInstance.name !== undefined && modelInstance.name.trim().length === 0) {
diagnostics.push(createDiagnostic("error", "invalid-model-instance-name", "Model instance names must be non-empty when authored.", `${path}.name`));
}
if (!isFiniteVec3(modelInstance.position)) {
diagnostics.push(createDiagnostic("error", "invalid-model-instance-position", "Model instance positions must remain finite on every axis.", `${path}.position`));
}
if (!isFiniteVec3(modelInstance.rotationDegrees)) {
diagnostics.push(createDiagnostic("error", "invalid-model-instance-rotation", "Model instance rotations must remain finite on every axis.", `${path}.rotationDegrees`));
}
if (!hasPositiveFiniteVec3(modelInstance.scale)) {
diagnostics.push(createDiagnostic("error", "invalid-model-instance-scale", "Model instance scales must remain finite and positive on every axis.", `${path}.scale`));
}
const asset = document.assets[modelInstance.assetId];
if (asset === undefined) {
diagnostics.push(
createDiagnostic("error", "missing-model-instance-asset", `Model instance asset ${modelInstance.assetId} does not exist.`, `${path}.assetId`)
);
return;
}
if (asset.kind !== "model") {
diagnostics.push(
createDiagnostic(
"error",
"invalid-model-instance-asset-kind",
"Model instances may only reference model assets.",
`${path}.assetId`
)
);
}
}
function validatePlayerStartEntity(entity: PlayerStartEntity, path: string, diagnostics: SceneDiagnostic[]) {
if (!isFiniteVec3(entity.position)) {
diagnostics.push(
@@ -440,6 +640,17 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal
registerAuthoredId(material.id, path, seenIds, diagnostics);
}
for (const [assetKey, asset] of Object.entries(document.assets)) {
const path = `assets.${assetKey}`;
if (asset.id !== assetKey) {
diagnostics.push(createDiagnostic("error", "asset-id-mismatch", "Asset ids must match their registry key.", `${path}.id`));
}
registerAuthoredId(asset.id, path, seenIds, diagnostics);
validateProjectAsset(asset, path, diagnostics);
}
for (const [brushKey, brush] of Object.entries(document.brushes)) {
const path = `brushes.${brushKey}`;
@@ -475,6 +686,19 @@ export function validateSceneDocument(document: SceneDocument): SceneDocumentVal
}
}
for (const [modelInstanceKey, modelInstance] of Object.entries(document.modelInstances)) {
const path = `modelInstances.${modelInstanceKey}`;
if (modelInstance.id !== modelInstanceKey) {
diagnostics.push(
createDiagnostic("error", "model-instance-id-mismatch", "Model instance ids must match their registry key.", `${path}.id`)
);
}
registerAuthoredId(modelInstance.id, path, seenIds, diagnostics);
validateModelInstance(modelInstance, path, document, diagnostics);
}
for (const [entityKey, entity] of Object.entries(document.entities)) {
const path = `entities.${entityKey}`;

View File

@@ -1,10 +1,13 @@
import type { Brush } from "./brushes";
import type { ModelInstance } from "../assets/model-instances";
import type { ProjectAssetRecord } from "../assets/project-assets";
import type { EntityInstance } from "../entities/entity-instances";
import type { InteractionLink } from "../interactions/interaction-links";
import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library";
import { createDefaultWorldSettings, type WorldSettings } from "./world-settings";
export const SCENE_DOCUMENT_VERSION = 9 as const;
export const SCENE_DOCUMENT_VERSION = 10 as const;
export const MODEL_ASSET_PIPELINE_SCENE_DOCUMENT_VERSION = 9 as const;
export const FOUNDATION_SCENE_DOCUMENT_VERSION = 1 as const;
export const BOX_BRUSH_SCENE_DOCUMENT_VERSION = 2 as const;
export const FACE_MATERIALS_SCENE_DOCUMENT_VERSION = 3 as const;
@@ -20,9 +23,9 @@ export interface SceneDocument {
world: WorldSettings;
materials: Record<string, MaterialDef>;
textures: Record<string, never>;
assets: Record<string, never>;
assets: Record<string, ProjectAssetRecord>;
brushes: Record<string, Brush>;
modelInstances: Record<string, never>;
modelInstances: Record<string, ModelInstance>;
entities: Record<string, EntityInstance>;
interactionLinks: Record<string, InteractionLink>;
}