diff --git a/src/commands/upsert-model-instance-command.ts b/src/commands/upsert-model-instance-command.ts index 3d2b0c64..9a865a9e 100644 --- a/src/commands/upsert-model-instance-command.ts +++ b/src/commands/upsert-model-instance-command.ts @@ -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 } }; } - diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts index c131b227..09a669be 100644 --- a/src/document/migrate-scene-document.ts +++ b/src/document/migrate-scene-document.ts @@ -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 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}`; diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index 4b19032c..0d187703 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -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; textures: Record; - assets: Record; + assets: Record; brushes: Record; - modelInstances: Record; + modelInstances: Record; entities: Record; interactionLinks: Record; }