import { type AudioAssetMetadata, type ImageAssetMetadata, type ModelAssetMetadata, type ProjectAssetBoundingBox, type ProjectAssetRecord } from "../assets/project-assets"; import type { ModelInstance } from "../assets/model-instances"; import { isModelInstanceCollisionMode } from "../assets/model-instances"; import { type CameraRigControlTargetRef, type ActorControlTargetRef, getControlEffectResolutionKey, isActorPathProgressMode, type ControlEffect, type ControlTargetRef, type InteractionControlTargetRef, type LightControlTargetRef, type ModelInstanceControlTargetRef, type SceneControlTargetRef, type SoundEmitterControlTargetRef, getControlTargetRefKey } from "../controls/control-surface"; import { WHITEBOX_SELECTION_MODES } from "../core/whitebox-selection-mode"; import { isCameraRigRailPlacementMode, isCameraRigTransitionMode, isNpcPresenceMode, isPlayerStartColliderMode, isPlayerStartGamepadActionBinding, isPlayerStartGamepadCameraLookBinding, isPlayerStartGamepadBinding, isPlayerStartKeyboardBindingCode, isPlayerStartMovementTemplateKind, isPlayerStartNavigationMode, getNpcColliderHeight, getPlayerStartColliderHeight, type CameraRigEntity, type CameraRigTargetRef, type EntityInstance, type InteractableEntity, type CharacterColliderSettings, type NpcPresence, type NpcEntity, type PointLightEntity, type PlayerStartEntity, type SceneEntryEntity, type SoundEmitterEntity, type SpotLightEntity, type TeleportTargetEntity, type TriggerVolumeEntity } from "../entities/entity-instances"; import { type InteractionLink } from "../interactions/interaction-links"; import { MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT, hasPositiveBoxSize, isBoxBrushLightFalloffMode, isBoxBrushVolumeMode, normalizeConeSideCount, normalizeRadialPrismSideCount, normalizeTorusMajorSegmentCount, normalizeTorusTubeSegmentCount } from "./brushes"; import { getBrushFaceIds, getBrushVertexIds } from "../geometry/whitebox-topology"; import { createSceneDocumentFromProject, type ProjectDocument, type SceneDocument } from "./scene-document"; import { HOURS_PER_DAY, type ProjectTimeSettings } from "./project-time-settings"; import { MIN_SCENE_PATH_POINT_COUNT, type ScenePath } from "./paths"; import { MIN_TERRAIN_SAMPLE_COUNT, TERRAIN_LAYER_COUNT, type Terrain } from "./terrains"; import { isAdvancedRenderingWaterReflectionMode, isAdvancedRenderingShadowMapSize, isAdvancedRenderingShadowType, isBoxVolumeRenderPath, isAdvancedRenderingToneMappingMode, isHexColorString, isWorldShaderSkyPresetId, type WorldCelestialOrbitAuthoringSettings, type WorldBackgroundSettings, type WorldShaderSkySettings, type WorldTimePhaseProfile, type WorldSettings } from "./world-settings"; import { createEmptyProjectScheduler, isProjectScheduleWeekday, type ProjectScheduler } from "../scheduler/project-scheduler"; import type { ProjectDialogue } from "../dialogues/project-dialogues"; import type { ProjectSequence, ProjectSequenceLibrary } from "../sequencer/project-sequences"; import { getHeldSequenceControlEffects, getProjectScheduleRoutineHeldSteps, getProjectSequenceImpulseSteps } from "../sequencer/project-sequence-steps"; export type SceneDiagnosticSeverity = "error" | "warning"; export type SceneDiagnosticScope = "document" | "build"; export interface SceneDiagnostic { code: string; severity: SceneDiagnosticSeverity; scope: SceneDiagnosticScope; message: string; path?: string; } export interface SceneDocumentValidationResult { diagnostics: SceneDiagnostic[]; errors: SceneDiagnostic[]; warnings: SceneDiagnostic[]; } export function createDiagnostic( severity: SceneDiagnosticSeverity, code: string, message: string, path?: string, scope: SceneDiagnosticScope = "document" ): SceneDiagnostic { return { code, severity, scope, message, path }; } function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } function isFiniteVec3(vector: { x: unknown; y: unknown; z: unknown; }): vector is { x: number; y: number; z: number } { return ( isFiniteNumber(vector.x) && isFiniteNumber(vector.y) && isFiniteNumber(vector.z) ); } function hasPositiveFiniteVec3(vector: { x: unknown; y: unknown; z: unknown; }): vector is { x: number; y: number; z: number } { return isFiniteVec3(vector) && vector.x > 0 && vector.y > 0 && vector.z > 0; } function isNonNegativeFiniteNumber(value: unknown): value is number { return isFiniteNumber(value) && value >= 0; } function isPositiveFiniteNumber(value: unknown): value is number { return isFiniteNumber(value) && value > 0; } function isFiniteNumberInRange( value: unknown, min: number, max: number ): value is number { return isFiniteNumber(value) && value >= min && value <= max; } function isPositiveInteger(value: unknown): value is number { return isFiniteNumber(value) && Number.isInteger(value) && value > 0; } function isPositiveIntegerInRange( value: unknown, max: number ): value is number { return isPositiveInteger(value) && value <= max; } function isBoolean(value: unknown): value is boolean { return typeof value === "boolean"; } function hasNonZeroVectorLength(vector: { x: number; y: number; z: number; }): boolean { return vector.x !== 0 || vector.y !== 0 || vector.z !== 0; } function validateWorldBackgroundSettings( background: WorldBackgroundSettings, document: SceneDocument | ProjectDocument, diagnostics: SceneDiagnostic[], path: string, label: string, options: { allowEmptyImageAssetId?: boolean; allowShader?: boolean } = {} ) { if (background.mode === "solid") { if (!isHexColorString(background.colorHex)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-color`, `${label} must use a #RRGGBB color.`, `${path}.colorHex` ) ); } return; } if (background.mode === "verticalGradient") { if (!isHexColorString(background.topColorHex)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-top-color`, `${label} top color must use a #RRGGBB color.`, `${path}.topColorHex` ) ); } if (!isHexColorString(background.bottomColorHex)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-bottom-color`, `${label} bottom color must use a #RRGGBB color.`, `${path}.bottomColorHex` ) ); } return; } if (background.mode === "shader") { if (options.allowShader === false) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-mode`, `${label} must not use shader mode here.`, `${path}.mode` ) ); } return; } if ( typeof background.assetId !== "string" || background.assetId.trim().length === 0 ) { if (!options.allowEmptyImageAssetId) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-asset-id`, `${label} must reference a non-empty image asset id.`, `${path}.assetId` ) ); } } else { const backgroundAsset = document.assets[background.assetId]; if (backgroundAsset === undefined) { diagnostics.push( createDiagnostic( "error", `missing-${label}-asset`, `${label} asset ${background.assetId} does not exist.`, `${path}.assetId` ) ); } else if (backgroundAsset.kind !== "image") { diagnostics.push( createDiagnostic( "error", `invalid-${label}-asset-kind`, `${label} must reference an image asset.`, `${path}.assetId` ) ); } } if (!isNonNegativeFiniteNumber(background.environmentIntensity)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-environment-intensity`, `${label} environment intensity must be a non-negative finite number.`, `${path}.environmentIntensity` ) ); } } function validateWorldShaderSkySettings( settings: WorldShaderSkySettings, diagnostics: SceneDiagnostic[], path: string ) { if (!isWorldShaderSkyPresetId(settings.presetId)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-preset", "World shader sky preset must be a supported built-in preset.", `${path}.presetId` ) ); } if (!isHexColorString(settings.dayTopColorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-day-top-color", "World shader sky day top color must use a #RRGGBB color.", `${path}.dayTopColorHex` ) ); } if (!isHexColorString(settings.dayBottomColorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-day-bottom-color", "World shader sky day bottom color must use a #RRGGBB color.", `${path}.dayBottomColorHex` ) ); } if (!isFiniteNumberInRange(settings.horizonHeight, -0.5, 0.5)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-horizon-height", "World shader sky horizon height must stay between -0.5 and 0.5.", `${path}.horizonHeight` ) ); } if (!isPositiveFiniteNumber(settings.celestial.sunDiscSizeDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-sun-disc-size", "World shader sky sun disc size must be a positive finite number.", `${path}.celestial.sunDiscSizeDegrees` ) ); } if (!isPositiveFiniteNumber(settings.celestial.moonDiscSizeDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-moon-disc-size", "World shader sky moon disc size must be a positive finite number.", `${path}.celestial.moonDiscSizeDegrees` ) ); } if (!isNonNegativeFiniteNumber(settings.stars.density)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-star-density", "World shader sky star density must be a non-negative finite number.", `${path}.stars.density` ) ); } if (!isNonNegativeFiniteNumber(settings.stars.brightness)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-star-brightness", "World shader sky star brightness must be a non-negative finite number.", `${path}.stars.brightness` ) ); } if (!isFiniteNumberInRange(settings.stars.horizonFadeOffset, -0.5, 0.5)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-star-horizon-fade-offset", "World shader sky star horizon offset must stay between -0.5 and 0.5.", `${path}.stars.horizonFadeOffset` ) ); } if (!isFiniteNumberInRange(settings.clouds.coverage, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-coverage", "World shader sky cloud coverage must stay between 0 and 1.", `${path}.clouds.coverage` ) ); } if (!isNonNegativeFiniteNumber(settings.clouds.density)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-density", "World shader sky cloud density must be a non-negative finite number.", `${path}.clouds.density` ) ); } if (!isFiniteNumberInRange(settings.clouds.softness, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-softness", "World shader sky cloud softness must stay between 0 and 1.", `${path}.clouds.softness` ) ); } if (!isPositiveFiniteNumber(settings.clouds.scale)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-scale", "World shader sky cloud scale must be a positive finite number.", `${path}.clouds.scale` ) ); } if (!isFiniteNumberInRange(settings.clouds.height, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-height", "World shader sky cloud height must stay between 0 and 1.", `${path}.clouds.height` ) ); } if (!isFiniteNumberInRange(settings.clouds.heightVariation, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-height-variation", "World shader sky cloud height variation must stay between 0 and 1.", `${path}.clouds.heightVariation` ) ); } if (!isHexColorString(settings.clouds.tintHex)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-tint", "World shader sky cloud tint must use a #RRGGBB color.", `${path}.clouds.tintHex` ) ); } if (!isFiniteNumberInRange(settings.clouds.opacity, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-opacity", "World shader sky cloud opacity must stay between 0 and 1.", `${path}.clouds.opacity` ) ); } if (!isFiniteNumberInRange(settings.clouds.opacityRandomness, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-opacity-randomness", "World shader sky cloud opacity randomness must stay between 0 and 1.", `${path}.clouds.opacityRandomness` ) ); } if (!isNonNegativeFiniteNumber(settings.clouds.driftSpeed)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-drift-speed", "World shader sky cloud drift speed must be a non-negative finite number.", `${path}.clouds.driftSpeed` ) ); } if (!isFiniteNumber(settings.clouds.driftDirectionDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-cloud-drift-direction", "World shader sky cloud drift direction must be a finite number.", `${path}.clouds.driftDirectionDegrees` ) ); } if (typeof settings.aurora.enabled !== "boolean") { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-aurora-enabled", "World shader sky aurora toggle must be true or false.", `${path}.aurora.enabled` ) ); } if (!isNonNegativeFiniteNumber(settings.aurora.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-aurora-intensity", "World shader sky aurora intensity must be a non-negative finite number.", `${path}.aurora.intensity` ) ); } if (!isFiniteNumberInRange(settings.aurora.height, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-aurora-height", "World shader sky aurora height must stay between 0 and 1.", `${path}.aurora.height` ) ); } if (!isFiniteNumberInRange(settings.aurora.thickness, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-aurora-thickness", "World shader sky aurora thickness must stay between 0 and 1.", `${path}.aurora.thickness` ) ); } if (!isNonNegativeFiniteNumber(settings.aurora.speed)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-aurora-speed", "World shader sky aurora speed must be a non-negative finite number.", `${path}.aurora.speed` ) ); } if (!isHexColorString(settings.aurora.primaryColorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-aurora-primary-color", "World shader sky aurora primary color must use a #RRGGBB color.", `${path}.aurora.primaryColorHex` ) ); } if (!isHexColorString(settings.aurora.secondaryColorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-world-shader-sky-aurora-secondary-color", "World shader sky aurora secondary color must use a #RRGGBB color.", `${path}.aurora.secondaryColorHex` ) ); } } function validateWorldCelestialOrbitSettings( settings: WorldCelestialOrbitAuthoringSettings, diagnostics: SceneDiagnostic[], path: string ) { const validateOrbit = ( orbit: WorldCelestialOrbitAuthoringSettings["sun"], orbitPath: string, label: "sun" | "moon" ) => { if (!isFiniteNumberInRange(orbit.azimuthDegrees, 0, 360)) { diagnostics.push( createDiagnostic( "error", `invalid-world-${label}-orbit-azimuth`, `World ${label} orbit azimuth must stay between 0 and 360 degrees.`, `${orbitPath}.azimuthDegrees` ) ); } if (!isFiniteNumberInRange(orbit.peakAltitudeDegrees, 0.1, 89.9)) { diagnostics.push( createDiagnostic( "error", `invalid-world-${label}-orbit-peak-altitude`, `World ${label} orbit peak altitude must stay between 0.1 and 89.9 degrees.`, `${orbitPath}.peakAltitudeDegrees` ) ); } }; validateOrbit(settings.sun, `${path}.sun`, "sun"); validateOrbit(settings.moon, `${path}.moon`, "moon"); } function validateWorldSettings( world: WorldSettings, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { if (!isBoolean(world.projectTimeLightingEnabled)) { diagnostics.push( createDiagnostic( "error", "invalid-world-project-time-lighting-enabled", "Scene world project-time lighting toggle must be true or false.", "world.projectTimeLightingEnabled" ) ); } if (!isBoolean(world.showCelestialBodies)) { diagnostics.push( createDiagnostic( "error", "invalid-world-show-celestial-bodies", "Scene world celestial body toggle must be true or false.", "world.showCelestialBodies" ) ); } validateWorldBackgroundSettings( world.background, document, diagnostics, "world.background", "world-background" ); validateWorldShaderSkySettings( world.shaderSky, diagnostics, "world.shaderSky" ); validateWorldCelestialOrbitSettings( world.celestialOrbits, diagnostics, "world.celestialOrbits" ); if (!isHexColorString(world.ambientLight.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-world-ambient-color", "World ambient light must use a #RRGGBB color.", "world.ambientLight.colorHex" ) ); } if (!isNonNegativeFiniteNumber(world.ambientLight.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-world-ambient-intensity", "World ambient light intensity must remain finite and zero or greater.", "world.ambientLight.intensity" ) ); } if (!isHexColorString(world.sunLight.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-world-sun-color", "World sun color must use a #RRGGBB color.", "world.sunLight.colorHex" ) ); } if (!isNonNegativeFiniteNumber(world.sunLight.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-world-sun-intensity", "World sun intensity must remain finite and zero or greater.", "world.sunLight.intensity" ) ); } if ( !isFiniteVec3(world.sunLight.direction) || !hasNonZeroVectorLength(world.sunLight.direction) ) { diagnostics.push( createDiagnostic( "error", "invalid-world-sun-direction", "World sun direction must remain finite and must not be the zero vector.", "world.sunLight.direction" ) ); } validateWorldTimePhaseProfile( world.timeOfDay.dawn, diagnostics, document, "world.timeOfDay.dawn", "dawn" ); validateWorldTimePhaseProfile( world.timeOfDay.dusk, diagnostics, document, "world.timeOfDay.dusk", "dusk" ); validateWorldBackgroundSettings( world.timeOfDay.night.background, document, diagnostics, "world.timeOfDay.night.background", "night-background", { allowShader: false } ); if (!isHexColorString(world.timeOfDay.night.ambientColorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-night-ambient-color", "night ambient color must use a #RRGGBB color.", "world.timeOfDay.night.ambientColorHex" ) ); } if ( !isNonNegativeFiniteNumber(world.timeOfDay.night.ambientIntensityFactor) ) { diagnostics.push( createDiagnostic( "error", "invalid-night-ambient-intensity-factor", "night ambient intensity factor must be a non-negative finite number.", "world.timeOfDay.night.ambientIntensityFactor" ) ); } if (!isHexColorString(world.timeOfDay.night.lightColorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-night-light-color", "night light color must use a #RRGGBB color.", "world.timeOfDay.night.lightColorHex" ) ); } if (!isNonNegativeFiniteNumber(world.timeOfDay.night.lightIntensityFactor)) { diagnostics.push( createDiagnostic( "error", "invalid-night-light-intensity-factor", "night light intensity factor must be a non-negative finite number.", "world.timeOfDay.night.lightIntensityFactor" ) ); } const advancedRendering = world.advancedRendering; if (!isBoolean(advancedRendering.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-enabled", "Advanced rendering enabled must be a boolean.", "world.advancedRendering.enabled" ) ); } if (!isBoolean(advancedRendering.shadows.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-shadows-enabled", "Advanced rendering shadow enabled must be a boolean.", "world.advancedRendering.shadows.enabled" ) ); } if (!isAdvancedRenderingShadowMapSize(advancedRendering.shadows.mapSize)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-shadow-map-size", "Advanced rendering shadow map size must be one of 512, 1024, 2048, or 4096.", "world.advancedRendering.shadows.mapSize" ) ); } if (!isAdvancedRenderingShadowType(advancedRendering.shadows.type)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-shadow-type", "Advanced rendering shadow type must be basic, pcf, or pcfSoft.", "world.advancedRendering.shadows.type" ) ); } if (!isFiniteNumber(advancedRendering.shadows.bias)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-shadow-bias", "Advanced rendering shadow bias must be a finite number.", "world.advancedRendering.shadows.bias" ) ); } if (!isBoolean(advancedRendering.ambientOcclusion.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-ao-enabled", "Advanced rendering ambient occlusion enabled must be a boolean.", "world.advancedRendering.ambientOcclusion.enabled" ) ); } if ( !isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.intensity) ) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-ao-intensity", "Advanced rendering ambient occlusion intensity must be a non-negative finite number.", "world.advancedRendering.ambientOcclusion.intensity" ) ); } if (!isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.radius)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-ao-radius", "Advanced rendering ambient occlusion radius must be a non-negative finite number.", "world.advancedRendering.ambientOcclusion.radius" ) ); } if (!isPositiveInteger(advancedRendering.ambientOcclusion.samples)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-ao-samples", "Advanced rendering ambient occlusion samples must be a positive integer.", "world.advancedRendering.ambientOcclusion.samples" ) ); } if (!isBoolean(advancedRendering.bloom.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-bloom-enabled", "Advanced rendering bloom enabled must be a boolean.", "world.advancedRendering.bloom.enabled" ) ); } if (!isNonNegativeFiniteNumber(advancedRendering.bloom.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-bloom-intensity", "Advanced rendering bloom intensity must be a non-negative finite number.", "world.advancedRendering.bloom.intensity" ) ); } if (!isNonNegativeFiniteNumber(advancedRendering.bloom.threshold)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-bloom-threshold", "Advanced rendering bloom threshold must be a non-negative finite number.", "world.advancedRendering.bloom.threshold" ) ); } if (!isNonNegativeFiniteNumber(advancedRendering.bloom.radius)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-bloom-radius", "Advanced rendering bloom radius must be a non-negative finite number.", "world.advancedRendering.bloom.radius" ) ); } if (!isAdvancedRenderingToneMappingMode(advancedRendering.toneMapping.mode)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-tone-mapping-mode", "Advanced rendering tone mapping mode must be none, linear, reinhard, cineon, or acesFilmic.", "world.advancedRendering.toneMapping.mode" ) ); } if (!isPositiveFiniteNumber(advancedRendering.toneMapping.exposure)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-tone-mapping-exposure", "Advanced rendering tone mapping exposure must be a positive finite number.", "world.advancedRendering.toneMapping.exposure" ) ); } if (!isBoolean(advancedRendering.depthOfField.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-dof-enabled", "Advanced rendering depth of field enabled must be a boolean.", "world.advancedRendering.depthOfField.enabled" ) ); } if ( !isNonNegativeFiniteNumber(advancedRendering.depthOfField.focusDistance) ) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-dof-focus-distance", "Advanced rendering depth of field focus distance must be a non-negative finite number.", "world.advancedRendering.depthOfField.focusDistance" ) ); } if (!isPositiveFiniteNumber(advancedRendering.depthOfField.focalLength)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-dof-focal-length", "Advanced rendering depth of field focal length must be a positive finite number.", "world.advancedRendering.depthOfField.focalLength" ) ); } if (!isPositiveFiniteNumber(advancedRendering.depthOfField.bokehScale)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-dof-bokeh-scale", "Advanced rendering depth of field bokeh scale must be a positive finite number.", "world.advancedRendering.depthOfField.bokehScale" ) ); } if (!isBoolean(advancedRendering.whiteboxBevel.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-whitebox-bevel-enabled", "Advanced rendering whitebox bevel enabled must be a boolean.", "world.advancedRendering.whiteboxBevel.enabled" ) ); } if (!isNonNegativeFiniteNumber(advancedRendering.whiteboxBevel.edgeWidth)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-whitebox-bevel-edge-width", "Advanced rendering whitebox bevel edge width must be a non-negative finite number.", "world.advancedRendering.whiteboxBevel.edgeWidth" ) ); } if ( !isNonNegativeFiniteNumber(advancedRendering.whiteboxBevel.normalStrength) ) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-whitebox-bevel-normal-strength", "Advanced rendering whitebox bevel normal strength must be a non-negative finite number.", "world.advancedRendering.whiteboxBevel.normalStrength" ) ); } if (!isBoxVolumeRenderPath(advancedRendering.fogPath)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-fog-path", "Advanced rendering fog path must be performance or quality.", "world.advancedRendering.fogPath" ) ); } if (!isBoxVolumeRenderPath(advancedRendering.waterPath)) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-water-path", "Advanced rendering water path must be performance or quality.", "world.advancedRendering.waterPath" ) ); } if ( !isAdvancedRenderingWaterReflectionMode( advancedRendering.waterReflectionMode ) ) { diagnostics.push( createDiagnostic( "error", "invalid-advanced-rendering-water-reflection-mode", "Advanced rendering water reflection mode must be none, world, or all.", "world.advancedRendering.waterReflectionMode" ) ); } } function validateWorldTimePhaseProfile( profile: WorldTimePhaseProfile, diagnostics: SceneDiagnostic[], document: SceneDocument | ProjectDocument, path: string, label: string ) { validateWorldBackgroundSettings( profile.background, document, diagnostics, `${path}.background`, `${label}-background`, { allowEmptyImageAssetId: true, allowShader: false } ); if (!isHexColorString(profile.skyTopColorHex)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-sky-top-color`, `${label} sky top color must use a #RRGGBB color.`, `${path}.skyTopColorHex` ) ); } if (!isHexColorString(profile.skyBottomColorHex)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-sky-bottom-color`, `${label} sky bottom color must use a #RRGGBB color.`, `${path}.skyBottomColorHex` ) ); } if (!isHexColorString(profile.ambientColorHex)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-ambient-color`, `${label} ambient color must use a #RRGGBB color.`, `${path}.ambientColorHex` ) ); } if (!isNonNegativeFiniteNumber(profile.ambientIntensityFactor)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-ambient-intensity-factor`, `${label} ambient intensity factor must be a non-negative finite number.`, `${path}.ambientIntensityFactor` ) ); } if (!isHexColorString(profile.lightColorHex)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-light-color`, `${label} light color must use a #RRGGBB color.`, `${path}.lightColorHex` ) ); } if (!isNonNegativeFiniteNumber(profile.lightIntensityFactor)) { diagnostics.push( createDiagnostic( "error", `invalid-${label}-light-intensity-factor`, `${label} light intensity factor must be a non-negative finite number.`, `${path}.lightIntensityFactor` ) ); } } function validateProjectTimeSettings( time: ProjectTimeSettings, diagnostics: SceneDiagnostic[], path = "time" ) { if (!isPositiveInteger(time.startDayNumber)) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-start-day-number", "Project start day must be a positive integer.", `${path}.startDayNumber` ) ); } if (!isFiniteNumber(time.startTimeOfDayHours)) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-start-hours", "Project time start-of-day must be a finite hour value.", `${path}.startTimeOfDayHours` ) ); } else if ( time.startTimeOfDayHours < 0 || time.startTimeOfDayHours >= HOURS_PER_DAY ) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-start-range", "Project time start-of-day must stay within the 0..24 hour range.", `${path}.startTimeOfDayHours` ) ); } if (!isFiniteNumber(time.sunriseTimeOfDayHours)) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-sunrise-hours", "Project sunrise must be a finite hour value.", `${path}.sunriseTimeOfDayHours` ) ); } else if ( time.sunriseTimeOfDayHours < 0 || time.sunriseTimeOfDayHours >= HOURS_PER_DAY ) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-sunrise-range", "Project sunrise must stay within the 0..24 hour range.", `${path}.sunriseTimeOfDayHours` ) ); } if (!isFiniteNumber(time.sunsetTimeOfDayHours)) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-sunset-hours", "Project sunset must be a finite hour value.", `${path}.sunsetTimeOfDayHours` ) ); } else if ( time.sunsetTimeOfDayHours < 0 || time.sunsetTimeOfDayHours >= HOURS_PER_DAY ) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-sunset-range", "Project sunset must stay within the 0..24 hour range.", `${path}.sunsetTimeOfDayHours` ) ); } if ( isFiniteNumber(time.sunriseTimeOfDayHours) && isFiniteNumber(time.sunsetTimeOfDayHours) && time.sunriseTimeOfDayHours >= time.sunsetTimeOfDayHours ) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-sun-window", "Project sunrise must be earlier than project sunset.", `${path}.sunriseTimeOfDayHours` ) ); } if (!isPositiveFiniteNumber(time.dayLengthMinutes)) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-day-length", "Project time day length must be a positive finite number of real minutes.", `${path}.dayLengthMinutes` ) ); } if ( !isPositiveFiniteNumber(time.dawnDurationHours) || time.dawnDurationHours >= HOURS_PER_DAY ) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-dawn-duration", "Project dawn duration must be a positive finite number shorter than one day.", `${path}.dawnDurationHours` ) ); } if ( !isPositiveFiniteNumber(time.duskDurationHours) || time.duskDurationHours >= HOURS_PER_DAY ) { diagnostics.push( createDiagnostic( "error", "invalid-project-time-dusk-duration", "Project dusk duration must be a positive finite number shorter than one day.", `${path}.duskDurationHours` ) ); } } function validatePointLightEntity( entity: PointLightEntity, path: string, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-point-light-position", "Point Light position must remain finite on every axis.", `${path}.position` ) ); } if (!isHexColorString(entity.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-point-light-color", "Point Light color must use a #RRGGBB color.", `${path}.colorHex` ) ); } if (!isNonNegativeFiniteNumber(entity.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-point-light-intensity", "Point Light intensity must remain finite and zero or greater.", `${path}.intensity` ) ); } if (!isPositiveFiniteNumber(entity.distance)) { diagnostics.push( createDiagnostic( "error", "invalid-point-light-distance", "Point Light distance must remain finite and greater than zero.", `${path}.distance` ) ); } } function validateSpotLightEntity( entity: SpotLightEntity, path: string, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-spot-light-position", "Spot Light position must remain finite on every axis.", `${path}.position` ) ); } if ( !isFiniteVec3(entity.direction) || !hasNonZeroVectorLength(entity.direction) ) { diagnostics.push( createDiagnostic( "error", "invalid-spot-light-direction", "Spot Light direction must remain finite and must not be the zero vector.", `${path}.direction` ) ); } if (!isHexColorString(entity.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-spot-light-color", "Spot Light color must use a #RRGGBB color.", `${path}.colorHex` ) ); } if (!isNonNegativeFiniteNumber(entity.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-spot-light-intensity", "Spot Light intensity must remain finite and zero or greater.", `${path}.intensity` ) ); } if (!isPositiveFiniteNumber(entity.distance)) { diagnostics.push( createDiagnostic( "error", "invalid-spot-light-distance", "Spot Light distance must remain finite and greater than zero.", `${path}.distance` ) ); } if ( !isFiniteNumber(entity.angleDegrees) || entity.angleDegrees <= 0 || entity.angleDegrees >= 180 ) { diagnostics.push( createDiagnostic( "error", "invalid-spot-light-angle", "Spot Light angle must remain a finite degree value between 0 and 180.", `${path}.angleDegrees` ) ); } } function validateCameraRigTargetRef( target: CameraRigTargetRef, entity: CameraRigEntity, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { switch (target.kind) { case "player": return; case "actor": { if (target.actorId.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-target-actor-id", "Camera Rig actor targets must reference a non-empty actor id.", `${path}.actorId` ) ); return; } const matchingActor = Object.values(document.entities).some( (candidate) => candidate.kind === "npc" && candidate.actorId === target.actorId ); if (!matchingActor) { diagnostics.push( createDiagnostic( "error", "missing-camera-rig-target-actor", `Camera Rig actor target ${target.actorId} does not exist in this scene.`, `${path}.actorId` ) ); } return; } case "entity": { if (target.entityId.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-target-entity-id", "Camera Rig entity targets must reference a non-empty entity id.", `${path}.entityId` ) ); return; } const targetEntity = document.entities[target.entityId]; if (targetEntity === undefined) { diagnostics.push( createDiagnostic( "error", "missing-camera-rig-target-entity", `Camera Rig entity target ${target.entityId} does not exist in this scene.`, `${path}.entityId` ) ); return; } if (targetEntity.id === entity.id) { diagnostics.push( createDiagnostic( "error", "camera-rig-self-target", "Camera Rigs must not target themselves.", `${path}.entityId` ) ); } if (targetEntity.kind === "cameraRig") { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-target-entity-kind", "Camera Rigs must not target another Camera Rig entity.", `${path}.entityId` ) ); } return; } case "worldPoint": if (!isFiniteVec3(target.point)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-target-world-point", "Camera Rig world-point targets must remain finite on every axis.", `${path}.point` ) ); } return; } } function validateCameraRigEntity( entity: CameraRigEntity, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (entity.rigType === "fixed") { if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-position", "Camera Rig position must remain finite on every axis.", `${path}.position` ) ); } } else { if (entity.pathId.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-path-id", "Rail Camera Rig path ids must be non-empty.", `${path}.pathId` ) ); } else { const authoredPath = document.paths[entity.pathId] ?? null; if (authoredPath === null) { diagnostics.push( createDiagnostic( "error", "missing-camera-rig-path", `Rail Camera Rig path ${entity.pathId} does not exist in the scene.`, `${path}.pathId` ) ); } else if (!authoredPath.enabled) { diagnostics.push( createDiagnostic( "error", "disabled-camera-rig-path", "Rail Camera Rigs require an enabled authored path.", `${path}.pathId` ) ); } } if (!isCameraRigRailPlacementMode(entity.railPlacementMode)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-rail-placement-mode", "Rail Camera Rig placement mode must be nearestToTarget or mapTargetBetweenPoints.", `${path}.railPlacementMode` ) ); } else if (entity.railPlacementMode === "mapTargetBetweenPoints") { if (!isFiniteVec3(entity.trackStartPoint)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-track-start-point", "Mapped Rail Camera Rig track start points must remain finite on every axis.", `${path}.trackStartPoint` ) ); } if (!isFiniteVec3(entity.trackEndPoint)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-track-end-point", "Mapped Rail Camera Rig track end points must remain finite on every axis.", `${path}.trackEndPoint` ) ); } if (!isFiniteNumberInRange(entity.railStartProgress, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-rail-start-progress", "Mapped Rail Camera Rig start progress must remain between 0 and 1.", `${path}.railStartProgress` ) ); } if (!isFiniteNumberInRange(entity.railEndProgress, 0, 1)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-rail-end-progress", "Mapped Rail Camera Rig end progress must remain between 0 and 1.", `${path}.railEndProgress` ) ); } } } if (!isNonNegativeFiniteNumber(entity.priority)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-priority", "Camera Rig priority must remain finite and zero or greater.", `${path}.priority` ) ); } if (!isBoolean(entity.defaultActive)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-default-active", "Camera Rig defaultActive must remain a boolean.", `${path}.defaultActive` ) ); } validateCameraRigTargetRef( entity.target, entity, `${path}.target`, document, diagnostics ); if (!isFiniteVec3(entity.targetOffset)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-target-offset", "Camera Rig targetOffset must remain finite on every axis.", `${path}.targetOffset` ) ); } if (!isCameraRigTransitionMode(entity.transitionMode)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-transition-mode", "Camera Rig transitionMode must be cut or blend.", `${path}.transitionMode` ) ); } if (!isNonNegativeFiniteNumber(entity.transitionDurationSeconds)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-transition-duration", "Camera Rig transition duration must remain finite and zero or greater.", `${path}.transitionDurationSeconds` ) ); } if (!isBoolean(entity.lookAround.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-look-around-enabled", "Camera Rig look-around enabled must remain a boolean.", `${path}.lookAround.enabled` ) ); } if (!isNonNegativeFiniteNumber(entity.lookAround.yawLimitDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-look-around-yaw-limit", "Camera Rig look-around yaw limit must remain finite and zero or greater.", `${path}.lookAround.yawLimitDegrees` ) ); } if (!isNonNegativeFiniteNumber(entity.lookAround.pitchLimitDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-look-around-pitch-limit", "Camera Rig look-around pitch limit must remain finite and zero or greater.", `${path}.lookAround.pitchLimitDegrees` ) ); } if (!isNonNegativeFiniteNumber(entity.lookAround.recenterSpeed)) { diagnostics.push( createDiagnostic( "error", "invalid-camera-rig-look-around-recenter-speed", "Camera Rig look-around recenter speed must remain finite and zero or greater.", `${path}.lookAround.recenterSpeed` ) ); } } 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` ) ); } if (!isModelInstanceCollisionMode(modelInstance.collision.mode)) { diagnostics.push( createDiagnostic( "error", "invalid-model-instance-collision-mode", "Model instance collision mode must be one of none, terrain, static, static-simple, dynamic, or simple.", `${path}.collision.mode` ) ); } if (!isBoolean(modelInstance.collision.visible)) { diagnostics.push( createDiagnostic( "error", "invalid-model-instance-collision-visibility", "Model instance collision visibility must be a boolean.", `${path}.collision.visible` ) ); } if (!isBoolean(modelInstance.visible)) { diagnostics.push( createDiagnostic( "error", "invalid-model-instance-visible", "Model instance visible must be a boolean.", `${path}.visible` ) ); } if (!isBoolean(modelInstance.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-model-instance-enabled", "Model instance enabled must be a boolean.", `${path}.enabled` ) ); } 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 validateEntityName( name: string | undefined, path: string, diagnostics: SceneDiagnostic[] ) { if (name !== undefined && name.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-entity-name", "Entity names must be non-empty when authored.", `${path}.name` ) ); } } function validateScenePath( pathValue: ScenePath, path: string, diagnostics: SceneDiagnostic[] ) { if (!isBoolean(pathValue.visible)) { diagnostics.push( createDiagnostic( "error", "invalid-path-visible", "Path visible must remain a boolean.", `${path}.visible` ) ); } if (!isBoolean(pathValue.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-path-enabled", "Path enabled must remain a boolean.", `${path}.enabled` ) ); } if (pathValue.name !== undefined && pathValue.name.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-path-name", "Path names must be non-empty when authored.", `${path}.name` ) ); } if (!isBoolean(pathValue.loop)) { diagnostics.push( createDiagnostic( "error", "invalid-path-loop", "Path loop must remain a boolean.", `${path}.loop` ) ); } if (pathValue.points.length < MIN_SCENE_PATH_POINT_COUNT) { diagnostics.push( createDiagnostic( "error", "invalid-path-point-count", `Paths must define at least ${MIN_SCENE_PATH_POINT_COUNT} points.`, `${path}.points` ) ); } const seenPointIds = new Set(); for (const [pointIndex, point] of pathValue.points.entries()) { const pointPath = `${path}.points.${pointIndex}`; if (point.id.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-path-point-id", "Path point ids must be non-empty strings.", `${pointPath}.id` ) ); } else if (seenPointIds.has(point.id)) { diagnostics.push( createDiagnostic( "error", "duplicate-path-point-id", `Path point id ${point.id} is already used within this path.`, `${pointPath}.id` ) ); } else { seenPointIds.add(point.id); } if (!isFiniteVec3(point.position)) { diagnostics.push( createDiagnostic( "error", "invalid-path-point-position", "Path point positions must remain finite on every axis.", `${pointPath}.position` ) ); } } } function validateTerrain( terrain: Terrain, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { if (!isBoolean(terrain.visible)) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-visible", "Terrain visible must remain a boolean.", `${path}.visible` ) ); } if (!isBoolean(terrain.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-enabled", "Terrain enabled must remain a boolean.", `${path}.enabled` ) ); } if (terrain.name !== undefined && terrain.name.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-name", "Terrain names must be non-empty when authored.", `${path}.name` ) ); } if (!isFiniteVec3(terrain.position)) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-position", "Terrain positions must remain finite on every axis.", `${path}.position` ) ); } if (!isBoolean(terrain.collisionEnabled)) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-collision-enabled", "Terrain collisionEnabled must remain a boolean.", `${path}.collisionEnabled` ) ); } if ( !isPositiveInteger(terrain.sampleCountX) || terrain.sampleCountX < MIN_TERRAIN_SAMPLE_COUNT ) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-sample-count-x", `Terrain sampleCountX must be an integer greater than or equal to ${MIN_TERRAIN_SAMPLE_COUNT}.`, `${path}.sampleCountX` ) ); } if ( !isPositiveInteger(terrain.sampleCountZ) || terrain.sampleCountZ < MIN_TERRAIN_SAMPLE_COUNT ) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-sample-count-z", `Terrain sampleCountZ must be an integer greater than or equal to ${MIN_TERRAIN_SAMPLE_COUNT}.`, `${path}.sampleCountZ` ) ); } if (!isPositiveFiniteNumber(terrain.cellSize)) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-cell-size", "Terrain cellSize must be a positive finite number.", `${path}.cellSize` ) ); } if (terrain.heights.length !== terrain.sampleCountX * terrain.sampleCountZ) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-height-count", "Terrain heights must contain exactly one sample per grid point.", `${path}.heights` ) ); } for (let index = 0; index < terrain.heights.length; index += 1) { if (isFiniteNumber(terrain.heights[index])) { continue; } diagnostics.push( createDiagnostic( "error", "invalid-terrain-height", "Terrain heights must remain finite.", `${path}.heights.${index}` ) ); } if (terrain.layers.length !== TERRAIN_LAYER_COUNT) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-layer-count", `Terrain layers must contain exactly ${TERRAIN_LAYER_COUNT} authored layer slots.`, `${path}.layers` ) ); } for (let index = 0; index < terrain.layers.length; index += 1) { const layer = terrain.layers[index]; if (layer.materialId === null) { continue; } if (document.materials[layer.materialId] === undefined) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-layer-material", `Terrain layer material reference ${layer.materialId} does not exist in the document material registry.`, `${path}.layers.${index}.materialId` ) ); } } const expectedPaintWeightCount = terrain.sampleCountX * terrain.sampleCountZ * (TERRAIN_LAYER_COUNT - 1); if (terrain.paintWeights.length !== expectedPaintWeightCount) { diagnostics.push( createDiagnostic( "error", "invalid-terrain-paint-weight-count", `Terrain paint weights must contain exactly ${expectedPaintWeightCount} values.`, `${path}.paintWeights` ) ); } for (let index = 0; index < terrain.paintWeights.length; index += 1) { const paintWeight = terrain.paintWeights[index]; if (isFiniteNumber(paintWeight) && paintWeight >= 0 && paintWeight <= 1) { continue; } diagnostics.push( createDiagnostic( "error", "invalid-terrain-paint-weight", "Terrain paint weights must remain finite values between 0 and 1.", `${path}.paintWeights.${index}` ) ); } } function validateAuthoredEntityState( entity: EntityInstance, path: string, diagnostics: SceneDiagnostic[] ) { if (!isBoolean(entity.visible)) { diagnostics.push( createDiagnostic( "error", "invalid-entity-visible", "Entity visible must remain a boolean.", `${path}.visible` ) ); } if (!isBoolean(entity.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-entity-enabled", "Entity enabled must remain a boolean.", `${path}.enabled` ) ); } } function validatePlayerStartEntity( entity: PlayerStartEntity, path: string, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-position", "Player Start position must remain finite on every axis.", `${path}.position` ) ); } if (!isFiniteNumber(entity.yawDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-yaw", "Player Start yaw must remain a finite number.", `${path}.yawDegrees` ) ); } if (!isPlayerStartNavigationMode(entity.navigationMode)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-navigation-mode", "Player Start navigation mode must be firstPerson or thirdPerson.", `${path}.navigationMode` ) ); } if ( !isFiniteNumber(entity.interactionReachMeters) || entity.interactionReachMeters <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-interaction-reach", "Player Start interaction reach must remain a finite number greater than zero.", `${path}.interactionReachMeters` ) ); } if ( !isFiniteNumber(entity.interactionAngleDegrees) || entity.interactionAngleDegrees <= 0 || entity.interactionAngleDegrees >= 180 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-interaction-angle", "Player Start interaction angle must remain a finite number greater than zero and less than 180.", `${path}.interactionAngleDegrees` ) ); } if (!isPlayerStartMovementTemplateKind(entity.movementTemplate?.kind)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-movement-template-kind", "Player Start movement template must be a supported typed template.", `${path}.movementTemplate.kind` ) ); } if ( !isFiniteNumber(entity.movementTemplate?.moveSpeed) || (entity.movementTemplate?.moveSpeed ?? 0) <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-movement-speed", "Player Start movement template move speed must remain a finite number greater than zero.", `${path}.movementTemplate.moveSpeed` ) ); } if (!isNonNegativeFiniteNumber(entity.movementTemplate?.maxSpeed)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-max-speed", "Player Start max speed must remain a finite number zero or greater.", `${path}.movementTemplate.maxSpeed` ) ); } if (!isNonNegativeFiniteNumber(entity.movementTemplate?.maxStepHeight)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-max-step-height", "Player Start max step height must remain a finite number zero or greater.", `${path}.movementTemplate.maxStepHeight` ) ); } if (typeof entity.movementTemplate?.capabilities?.jump !== "boolean") { diagnostics.push( createDiagnostic( "error", "invalid-player-start-jump-capability", "Player Start movement template jump capability must be a boolean.", `${path}.movementTemplate.capabilities.jump` ) ); } if (typeof entity.movementTemplate?.capabilities?.sprint !== "boolean") { diagnostics.push( createDiagnostic( "error", "invalid-player-start-sprint-capability", "Player Start movement template sprint capability must be a boolean.", `${path}.movementTemplate.capabilities.sprint` ) ); } if (typeof entity.movementTemplate?.capabilities?.crouch !== "boolean") { diagnostics.push( createDiagnostic( "error", "invalid-player-start-crouch-capability", "Player Start movement template crouch capability must be a boolean.", `${path}.movementTemplate.capabilities.crouch` ) ); } if ( !isFiniteNumber(entity.movementTemplate?.jump?.speed) || (entity.movementTemplate?.jump?.speed ?? 0) <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-jump-speed", "Player Start jump speed must remain a finite number greater than zero.", `${path}.movementTemplate.jump.speed` ) ); } if (!isNonNegativeFiniteNumber(entity.movementTemplate?.jump?.bufferMs)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-jump-buffer-ms", "Player Start jump buffer must remain a finite number zero or greater.", `${path}.movementTemplate.jump.bufferMs` ) ); } if (!isNonNegativeFiniteNumber(entity.movementTemplate?.jump?.coyoteTimeMs)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-coyote-time-ms", "Player Start coyote time must remain a finite number zero or greater.", `${path}.movementTemplate.jump.coyoteTimeMs` ) ); } if (!isBoolean(entity.movementTemplate?.jump?.variableHeight)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-variable-jump-height", "Player Start variable jump height setting must be a boolean.", `${path}.movementTemplate.jump.variableHeight` ) ); } if ( !isFiniteNumber(entity.movementTemplate?.jump?.maxHoldMs) || (entity.movementTemplate?.jump?.maxHoldMs ?? 0) <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-variable-jump-max-hold-ms", "Player Start variable jump max hold must remain a finite number greater than zero.", `${path}.movementTemplate.jump.maxHoldMs` ) ); } if (!isBoolean(entity.movementTemplate?.jump?.moveWhileJumping)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-while-jumping", "Player Start move while jumping setting must be a boolean.", `${path}.movementTemplate.jump.moveWhileJumping` ) ); } if (!isBoolean(entity.movementTemplate?.jump?.moveWhileFalling)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-while-falling", "Player Start move while falling setting must be a boolean.", `${path}.movementTemplate.jump.moveWhileFalling` ) ); } if (!isBoolean(entity.movementTemplate?.jump?.directionOnly)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-air-direction-only", "Player Start air direction only setting must be a boolean.", `${path}.movementTemplate.jump.directionOnly` ) ); } if (!isBoolean(entity.movementTemplate?.jump?.bunnyHop)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-bunny-hop", "Player Start bunny hop setting must be a boolean.", `${path}.movementTemplate.jump.bunnyHop` ) ); } if ( !isNonNegativeFiniteNumber(entity.movementTemplate?.jump?.bunnyHopBoost) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-bunny-hop-boost", "Player Start bunny hop boost must remain a finite number zero or greater.", `${path}.movementTemplate.jump.bunnyHopBoost` ) ); } if ( !isFiniteNumber(entity.movementTemplate?.sprint?.speedMultiplier) || (entity.movementTemplate?.sprint?.speedMultiplier ?? 0) <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-sprint-speed-multiplier", "Player Start sprint speed multiplier must remain a finite number greater than zero.", `${path}.movementTemplate.sprint.speedMultiplier` ) ); } if ( !isFiniteNumber(entity.movementTemplate?.crouch?.speedMultiplier) || (entity.movementTemplate?.crouch?.speedMultiplier ?? 0) <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-crouch-speed-multiplier", "Player Start crouch speed multiplier must remain a finite number greater than zero.", `${path}.movementTemplate.crouch.speedMultiplier` ) ); } if ( !isPlayerStartKeyboardBindingCode( entity.inputBindings?.keyboard.moveForward ) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-forward-keyboard-binding", "Player Start move-forward keyboard binding must be a supported key code.", `${path}.inputBindings.keyboard.moveForward` ) ); } if ( !isPlayerStartKeyboardBindingCode( entity.inputBindings?.keyboard.moveBackward ) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-backward-keyboard-binding", "Player Start move-backward keyboard binding must be a supported key code.", `${path}.inputBindings.keyboard.moveBackward` ) ); } if ( !isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveLeft) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-left-keyboard-binding", "Player Start move-left keyboard binding must be a supported key code.", `${path}.inputBindings.keyboard.moveLeft` ) ); } if ( !isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveRight) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-right-keyboard-binding", "Player Start move-right keyboard binding must be a supported key code.", `${path}.inputBindings.keyboard.moveRight` ) ); } if (!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.jump)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-jump-keyboard-binding", "Player Start jump keyboard binding must be a supported key code.", `${path}.inputBindings.keyboard.jump` ) ); } if ( !isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.sprint) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-sprint-keyboard-binding", "Player Start sprint keyboard binding must be a supported key code.", `${path}.inputBindings.keyboard.sprint` ) ); } if ( !isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.crouch) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-crouch-keyboard-binding", "Player Start crouch keyboard binding must be a supported key code.", `${path}.inputBindings.keyboard.crouch` ) ); } if ( !isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.interact) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-interact-keyboard-binding", "Player Start interact keyboard binding must be a supported key or mouse code.", `${path}.inputBindings.keyboard.interact` ) ); } if ( !isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.pauseTime) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-pause-keyboard-binding", "Player Start pause keyboard binding must be a supported key code.", `${path}.inputBindings.keyboard.pauseTime` ) ); } if (!isPlayerStartGamepadBinding(entity.inputBindings?.gamepad.moveForward)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-forward-gamepad-binding", "Player Start move-forward gamepad binding must be a supported standard-gamepad input.", `${path}.inputBindings.gamepad.moveForward` ) ); } if ( !isPlayerStartGamepadBinding(entity.inputBindings?.gamepad.moveBackward) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-backward-gamepad-binding", "Player Start move-backward gamepad binding must be a supported standard-gamepad input.", `${path}.inputBindings.gamepad.moveBackward` ) ); } if (!isPlayerStartGamepadBinding(entity.inputBindings?.gamepad.moveLeft)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-left-gamepad-binding", "Player Start move-left gamepad binding must be a supported standard-gamepad input.", `${path}.inputBindings.gamepad.moveLeft` ) ); } if (!isPlayerStartGamepadBinding(entity.inputBindings?.gamepad.moveRight)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-move-right-gamepad-binding", "Player Start move-right gamepad binding must be a supported standard-gamepad input.", `${path}.inputBindings.gamepad.moveRight` ) ); } if (!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.jump)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-jump-gamepad-binding", "Player Start jump gamepad binding must be a supported standard-gamepad action input.", `${path}.inputBindings.gamepad.jump` ) ); } if ( !isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.sprint) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-sprint-gamepad-binding", "Player Start sprint gamepad binding must be a supported standard-gamepad action input.", `${path}.inputBindings.gamepad.sprint` ) ); } if ( !isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.crouch) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-crouch-gamepad-binding", "Player Start crouch gamepad binding must be a supported standard-gamepad action input.", `${path}.inputBindings.gamepad.crouch` ) ); } if ( !isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.interact) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-interact-gamepad-binding", "Player Start interact gamepad binding must be a supported standard-gamepad action input.", `${path}.inputBindings.gamepad.interact` ) ); } if ( !isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.pauseTime) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-pause-gamepad-binding", "Player Start pause gamepad binding must be a supported standard-gamepad action input.", `${path}.inputBindings.gamepad.pauseTime` ) ); } if ( !isPlayerStartGamepadCameraLookBinding( entity.inputBindings?.gamepad.cameraLook ) ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-camera-look-gamepad-binding", "Player Start camera-look gamepad binding must be a supported standard-gamepad camera input.", `${path}.inputBindings.gamepad.cameraLook` ) ); } if (!isPlayerStartColliderMode(entity.collider.mode)) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-collider-mode", "Player Start collider mode must be capsule, box, or none.", `${path}.collider.mode` ) ); } if ( !isFiniteNumber(entity.collider.eyeHeight) || entity.collider.eyeHeight <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-eye-height", "Player Start eye height must remain a finite number greater than zero.", `${path}.collider.eyeHeight` ) ); } if ( !isFiniteNumber(entity.collider.capsuleRadius) || entity.collider.capsuleRadius <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-capsule-radius", "Player Start capsule radius must remain a finite number greater than zero.", `${path}.collider.capsuleRadius` ) ); } if ( !isFiniteNumber(entity.collider.capsuleHeight) || entity.collider.capsuleHeight <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-capsule-height", "Player Start capsule height must remain a finite number greater than zero.", `${path}.collider.capsuleHeight` ) ); } if ( !isFiniteVec3(entity.collider.boxSize) || entity.collider.boxSize.x <= 0 || entity.collider.boxSize.y <= 0 || entity.collider.boxSize.z <= 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-box-size", "Player Start box size must remain finite and positive on every axis.", `${path}.collider.boxSize` ) ); } if (entity.collider.capsuleHeight < entity.collider.capsuleRadius * 2) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-capsule-proportions", "Player Start capsule height must be at least twice the capsule radius.", `${path}.collider.capsuleHeight` ) ); } const colliderHeight = getPlayerStartColliderHeight(entity.collider); if (colliderHeight !== null && entity.collider.eyeHeight > colliderHeight) { diagnostics.push( createDiagnostic( "error", "invalid-player-start-eye-height", "Player Start eye height must fit within the authored collider height.", `${path}.collider.eyeHeight` ) ); } } function validateSoundEmitterAudioAsset( entity: SoundEmitterEntity, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[], missingSeverity: SceneDiagnosticSeverity ): ProjectAssetRecord | null { if (entity.audioAssetId === null) { diagnostics.push( createDiagnostic( missingSeverity, "missing-sound-emitter-audio-asset", entity.autoplay ? "Sound Emitter autoplay requires an assigned audio asset." : "Sound Emitter has no audio asset assigned yet.", `${path}.audioAssetId` ) ); return null; } const asset = document.assets[entity.audioAssetId]; if (asset === undefined) { diagnostics.push( createDiagnostic( "error", "missing-sound-emitter-audio-asset", `Sound Emitter audio asset ${entity.audioAssetId} does not exist.`, `${path}.audioAssetId` ) ); return null; } if (asset.kind !== "audio") { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-audio-asset-kind", "Sound Emitter audioAssetId must reference an audio asset.", `${path}.audioAssetId` ) ); return null; } return asset; } function validateSoundEmitterEntity( entity: SoundEmitterEntity, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-position", "Sound Emitter position must remain finite on every axis.", `${path}.position` ) ); } if (!isNonNegativeFiniteNumber(entity.volume)) { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-volume", "Sound Emitter volume must remain a finite number zero or greater.", `${path}.volume` ) ); } if (!isPositiveFiniteNumber(entity.refDistance)) { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-ref-distance", "Sound Emitter ref distance must remain a finite number greater than zero.", `${path}.refDistance` ) ); } if (!isPositiveFiniteNumber(entity.maxDistance)) { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-max-distance", "Sound Emitter max distance must remain a finite number greater than zero.", `${path}.maxDistance` ) ); } if ( isPositiveFiniteNumber(entity.refDistance) && isPositiveFiniteNumber(entity.maxDistance) && entity.maxDistance < entity.refDistance ) { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-distance-range", "Sound Emitter max distance must be greater than or equal to ref distance.", `${path}.maxDistance` ) ); } if (!isBoolean(entity.autoplay)) { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-autoplay-flag", "Sound Emitter autoplay must remain a boolean.", `${path}.autoplay` ) ); } if (!isBoolean(entity.loop)) { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-loop-flag", "Sound Emitter loop must remain a boolean.", `${path}.loop` ) ); } validateSoundEmitterAudioAsset( entity, path, document, diagnostics, entity.autoplay === true ? "error" : "warning" ); } function validateCharacterColliderSettings( collider: CharacterColliderSettings, path: string, diagnostics: SceneDiagnostic[], options: { codePrefix: string; label: string; getHeight: (collider: CharacterColliderSettings) => number | null; } ) { if (!isPlayerStartColliderMode(collider.mode)) { diagnostics.push( createDiagnostic( "error", `invalid-${options.codePrefix}-collider-mode`, `${options.label} collider mode must be capsule, box, or none.`, `${path}.collider.mode` ) ); } if (!isPositiveFiniteNumber(collider.eyeHeight)) { diagnostics.push( createDiagnostic( "error", `invalid-${options.codePrefix}-eye-height`, `${options.label} eye height must remain finite and greater than zero.`, `${path}.collider.eyeHeight` ) ); } if (!isPositiveFiniteNumber(collider.capsuleRadius)) { diagnostics.push( createDiagnostic( "error", `invalid-${options.codePrefix}-capsule-radius`, `${options.label} capsule radius must remain finite and greater than zero.`, `${path}.collider.capsuleRadius` ) ); } if (!isPositiveFiniteNumber(collider.capsuleHeight)) { diagnostics.push( createDiagnostic( "error", `invalid-${options.codePrefix}-capsule-height`, `${options.label} capsule height must remain finite and greater than zero.`, `${path}.collider.capsuleHeight` ) ); } if ( !isFiniteVec3(collider.boxSize) || collider.boxSize.x <= 0 || collider.boxSize.y <= 0 || collider.boxSize.z <= 0 ) { diagnostics.push( createDiagnostic( "error", `invalid-${options.codePrefix}-box-size`, `${options.label} box size must remain finite and positive on every axis.`, `${path}.collider.boxSize` ) ); } if (collider.capsuleHeight < collider.capsuleRadius * 2) { diagnostics.push( createDiagnostic( "error", `invalid-${options.codePrefix}-capsule-proportions`, `${options.label} capsule height must be at least twice the capsule radius.`, `${path}.collider.capsuleHeight` ) ); } const colliderHeight = options.getHeight(collider); if (colliderHeight !== null && collider.eyeHeight > colliderHeight) { diagnostics.push( createDiagnostic( "error", `invalid-${options.codePrefix}-eye-height`, `${options.label} eye height must fit within the authored collider height.`, `${path}.collider.eyeHeight` ) ); } } function validateTriggerVolumeEntity( entity: TriggerVolumeEntity, path: string, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-trigger-volume-position", "Trigger Volume position must remain finite on every axis.", `${path}.position` ) ); } if (!hasPositiveFiniteVec3(entity.size)) { diagnostics.push( createDiagnostic( "error", "invalid-trigger-volume-size", "Trigger Volume size must remain finite and positive on every axis.", `${path}.size` ) ); } if (!isBoolean(entity.triggerOnEnter)) { diagnostics.push( createDiagnostic( "error", "invalid-trigger-volume-enter-flag", "Trigger Volume triggerOnEnter must remain a boolean.", `${path}.triggerOnEnter` ) ); } if (!isBoolean(entity.triggerOnExit)) { diagnostics.push( createDiagnostic( "error", "invalid-trigger-volume-exit-flag", "Trigger Volume triggerOnExit must remain a boolean.", `${path}.triggerOnExit` ) ); } } function validateTeleportTargetEntity( entity: TeleportTargetEntity, path: string, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-teleport-target-position", "Teleport Target position must remain finite on every axis.", `${path}.position` ) ); } if (!isFiniteNumber(entity.yawDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-teleport-target-yaw", "Teleport Target yaw must remain a finite number.", `${path}.yawDegrees` ) ); } } function validateInteractableEntity( entity: InteractableEntity, path: string, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-interactable-position", "Interactable position must remain finite on every axis.", `${path}.position` ) ); } if (!isPositiveFiniteNumber(entity.radius)) { diagnostics.push( createDiagnostic( "error", "invalid-interactable-radius", "Interactable radius must remain finite and greater than zero.", `${path}.radius` ) ); } if (typeof entity.prompt !== "string" || entity.prompt.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-interactable-prompt", "Interactable prompt must remain a non-empty string.", `${path}.prompt` ) ); } if (!isBoolean(entity.interactionEnabled)) { diagnostics.push( createDiagnostic( "error", "invalid-interactable-interaction-enabled", "Interactable interactionEnabled must remain a boolean.", `${path}.interactionEnabled` ) ); } } function validateSceneEntryEntity( entity: SceneEntryEntity, path: string, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-scene-entry-position", "Scene Entry position must remain finite on every axis.", `${path}.position` ) ); } if (!isFiniteNumber(entity.yawDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-scene-entry-yaw", "Scene Entry yaw must remain a finite number.", `${path}.yawDegrees` ) ); } } function validateNpcModelAssetId( entity: NpcEntity, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { if (entity.modelAssetId === null) { return; } if ( typeof entity.modelAssetId !== "string" || entity.modelAssetId.trim().length === 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-npc-model-asset-id", "NPC modelAssetId must be null or reference a non-empty model asset id.", `${path}.modelAssetId` ) ); return; } const asset = document.assets[entity.modelAssetId]; if (asset === undefined) { diagnostics.push( createDiagnostic( "error", "missing-npc-model-asset", `NPC model asset ${entity.modelAssetId} does not exist.`, `${path}.modelAssetId` ) ); return; } if (asset.kind !== "model") { diagnostics.push( createDiagnostic( "error", "invalid-npc-model-asset-kind", "NPC modelAssetId must reference a model asset.", `${path}.modelAssetId` ) ); } } function validateNpcPresence( presence: NpcPresence | undefined, path: string, diagnostics: SceneDiagnostic[] ) { if ( presence === undefined || typeof presence !== "object" || presence === null || !("mode" in presence) ) { diagnostics.push( createDiagnostic( "error", "missing-npc-presence", "NPC presence must remain explicitly authored.", path ) ); return; } if (!isNpcPresenceMode(presence.mode)) { diagnostics.push( createDiagnostic( "error", "invalid-npc-presence-mode", "NPC presence mode must be always or timeWindow.", `${path}.mode` ) ); return; } if (presence.mode !== "timeWindow") { return; } if (!isFiniteNumber(presence.startHour)) { diagnostics.push( createDiagnostic( "error", "invalid-npc-presence-start-hour", "NPC presence window start hour must be a finite number.", `${path}.startHour` ) ); } else if (presence.startHour < 0 || presence.startHour >= HOURS_PER_DAY) { diagnostics.push( createDiagnostic( "error", "invalid-npc-presence-start-range", "NPC presence window start hour must stay within the 0..24 hour range.", `${path}.startHour` ) ); } if (!isFiniteNumber(presence.endHour)) { diagnostics.push( createDiagnostic( "error", "invalid-npc-presence-end-hour", "NPC presence window end hour must be a finite number.", `${path}.endHour` ) ); } else if (presence.endHour < 0 || presence.endHour >= HOURS_PER_DAY) { diagnostics.push( createDiagnostic( "error", "invalid-npc-presence-end-range", "NPC presence window end hour must stay within the 0..24 hour range.", `${path}.endHour` ) ); } if ( isFiniteNumber(presence.startHour) && isFiniteNumber(presence.endHour) && presence.startHour === presence.endHour ) { diagnostics.push( createDiagnostic( "error", "invalid-npc-presence-zero-window", "NPC presence time windows must span at least part of the day.", `${path}.startHour` ) ); } } function validateNpcEntity( entity: NpcEntity, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { validateAuthoredEntityState(entity, path, diagnostics); if (!isFiniteVec3(entity.position)) { diagnostics.push( createDiagnostic( "error", "invalid-npc-position", "NPC position must remain finite on every axis.", `${path}.position` ) ); } if (!isFiniteNumber(entity.yawDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-npc-yaw", "NPC yaw must remain a finite number.", `${path}.yawDegrees` ) ); } if ( typeof entity.actorId !== "string" || entity.actorId.trim().length === 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-npc-actor-id", "NPC actorId must remain a non-empty string.", `${path}.actorId` ) ); } validateNpcPresence(entity.presence, `${path}.presence`, diagnostics); validateNpcModelAssetId(entity, path, document, diagnostics); const seenNpcDialogueIds = new Map(); for (const [dialogueIndex, dialogue] of entity.dialogues.entries()) { const dialoguePath = `${path}.dialogues.${dialogueIndex}`; registerAuthoredId( dialogue.id, dialoguePath, seenNpcDialogueIds, diagnostics ); validateProjectDialogue( dialogue, dialoguePath, seenNpcDialogueIds, diagnostics ); } if ( entity.defaultDialogueId !== null && !entity.dialogues.some( (dialogue) => dialogue.id === entity.defaultDialogueId ) ) { diagnostics.push( createDiagnostic( "error", "missing-npc-default-dialogue", `NPC default dialogue ${entity.defaultDialogueId} does not exist on this NPC.`, `${path}.defaultDialogueId` ) ); } validateCharacterColliderSettings(entity.collider, path, diagnostics, { codePrefix: "npc", label: "NPC", getHeight: getNpcColliderHeight }); } function validateLightControlTarget( target: LightControlTargetRef, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { const targetEntity = document.entities[target.entityId]; if (targetEntity === undefined) { diagnostics.push( createDiagnostic( "error", "missing-control-light-entity", `Light control target entity ${target.entityId} does not exist.`, `${path}.entityId` ) ); return; } if ( targetEntity.kind !== target.entityKind || (targetEntity.kind !== "pointLight" && targetEntity.kind !== "spotLight") ) { diagnostics.push( createDiagnostic( "error", "invalid-control-light-target-kind", "Light control effects must target a Point Light or Spot Light entity of the authored target kind.", `${path}.entityKind` ) ); } } function validateCameraRigControlTarget( target: CameraRigControlTargetRef, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { const targetEntity = document.entities[target.entityId]; if (targetEntity === undefined) { diagnostics.push( createDiagnostic( "error", "missing-control-camera-rig-entity", `Camera control target entity ${target.entityId} does not exist.`, `${path}.entityId` ) ); return; } if ( targetEntity.kind !== target.entityKind || targetEntity.kind !== "cameraRig" ) { diagnostics.push( createDiagnostic( "error", "invalid-control-camera-rig-target-kind", "Camera control effects must target a Camera Rig entity of the authored target kind.", `${path}.entityKind` ) ); } } function validateActorControlTarget( target: ActorControlTargetRef, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { const matchingActor = Object.values(document.entities).find( (entity) => entity.kind === "npc" && entity.actorId === target.actorId ); if (matchingActor !== undefined) { return; } diagnostics.push( createDiagnostic( "error", "missing-control-actor-target", `Actor control target ${target.actorId} does not exist in this document.`, `${path}.actorId` ) ); } function validateInteractionControlTarget( target: InteractionControlTargetRef, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { const targetEntity = document.entities[target.entityId]; if (targetEntity === undefined) { diagnostics.push( createDiagnostic( "error", "missing-control-interaction-entity", `Interaction control target entity ${target.entityId} does not exist.`, `${path}.entityId` ) ); return; } if ( targetEntity.kind !== target.interactionKind || targetEntity.kind !== "interactable" ) { diagnostics.push( createDiagnostic( "error", "invalid-control-interaction-target-kind", "Interaction control effects must target an Interactable entity of the authored target kind.", `${path}.interactionKind` ) ); } } function validateModelInstanceControlTarget( target: ModelInstanceControlTargetRef, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { if (document.modelInstances[target.modelInstanceId] !== undefined) { return; } diagnostics.push( createDiagnostic( "error", "missing-control-model-instance-target", `Control model instance target ${target.modelInstanceId} does not exist.`, `${path}.modelInstanceId` ) ); } function validateSoundEmitterControlTarget( target: SoundEmitterControlTargetRef, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[], options: { requireAudioAsset: boolean } ) { const targetEntity = document.entities[target.entityId]; if (targetEntity === undefined) { diagnostics.push( createDiagnostic( "error", "missing-control-sound-emitter-entity", `Control sound emitter entity ${target.entityId} does not exist.`, `${path}.entityId` ) ); return; } if ( targetEntity.kind !== target.entityKind || targetEntity.kind !== "soundEmitter" ) { diagnostics.push( createDiagnostic( "error", "invalid-control-sound-emitter-kind", "Control sound effects must target a Sound Emitter entity.", `${path}.entityKind` ) ); return; } if (options.requireAudioAsset && targetEntity.audioAssetId === null) { diagnostics.push( createDiagnostic( "error", "missing-control-sound-emitter-audio-asset", "Control sound playback effects require a Sound Emitter that references an audio asset.", `${path}.entityId` ) ); } } function validateSceneControlTarget( target: SceneControlTargetRef, path: string, diagnostics: SceneDiagnostic[] ) { if (target.kind === "scene" && target.scope === "activeScene") { return; } diagnostics.push( createDiagnostic( "error", "invalid-control-scene-target", "Scene control effects must target the active scene scope.", `${path}.scope` ) ); } function validateControlEffect( effect: ControlEffect, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { switch (effect.type) { case "setActorPresence": validateActorControlTarget( effect.target, `${path}.target`, document, diagnostics ); if (!isBoolean(effect.active)) { diagnostics.push( createDiagnostic( "error", "invalid-control-actor-active", "Actor presence control values must remain boolean.", `${path}.active` ) ); } return; case "activateCameraRigOverride": case "clearCameraRigOverride": validateCameraRigControlTarget( effect.target, `${path}.target`, document, diagnostics ); return; case "playActorAnimation": case "followActorPath": diagnostics.push( createDiagnostic( "error", "unsupported-scene-control-actor-routine-effect", "Actor animation and actor path routine effects are scheduler-owned in this slice and are not supported on scene interaction control actions.", `${path}.type` ) ); return; case "playModelAnimation": { const targetModelInstance = document.modelInstances[effect.target.modelInstanceId]; if (targetModelInstance === undefined) { diagnostics.push( createDiagnostic( "error", "missing-control-play-animation-target-instance", `Control play animation target model instance ${effect.target.modelInstanceId} does not exist.`, `${path}.target.modelInstanceId` ) ); return; } if (effect.clipName.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-control-play-animation-clip-name", "Control play animation clip name must be non-empty.", `${path}.clipName` ) ); return; } const targetAsset = document.assets[targetModelInstance.assetId]; if (targetAsset === undefined || targetAsset.kind !== "model") { return; } if (!targetAsset.metadata.animationNames.includes(effect.clipName)) { diagnostics.push( createDiagnostic( "error", "missing-control-play-animation-clip", `Control play animation clip ${effect.clipName} does not exist on model asset ${targetAsset.id}.`, `${path}.clipName` ) ); } return; } case "stopModelAnimation": validateModelInstanceControlTarget( effect.target, `${path}.target`, document, diagnostics ); return; case "setModelInstanceVisible": validateModelInstanceControlTarget( effect.target, `${path}.target`, document, diagnostics ); if (!isBoolean(effect.visible)) { diagnostics.push( createDiagnostic( "error", "invalid-control-model-instance-visible", "Model visibility control values must remain boolean.", `${path}.visible` ) ); } return; case "playSound": case "stopSound": validateSoundEmitterControlTarget( effect.target, `${path}.target`, document, diagnostics, { requireAudioAsset: true } ); return; case "setSoundVolume": validateSoundEmitterControlTarget( effect.target, `${path}.target`, document, diagnostics, { requireAudioAsset: false } ); if (!isNonNegativeFiniteNumber(effect.volume)) { diagnostics.push( createDiagnostic( "error", "invalid-control-sound-volume", "Sound control volume must remain finite and zero or greater.", `${path}.volume` ) ); } return; case "setInteractionEnabled": validateInteractionControlTarget( effect.target, `${path}.target`, document, diagnostics ); if (!isBoolean(effect.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-control-interaction-enabled", "Interaction control enabled must remain a boolean.", `${path}.enabled` ) ); } return; case "setLightEnabled": validateLightControlTarget( effect.target, `${path}.target`, document, diagnostics ); if (!isBoolean(effect.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-control-light-enabled", "Light control enabled must remain a boolean.", `${path}.enabled` ) ); } return; case "setLightIntensity": validateLightControlTarget( effect.target, `${path}.target`, document, diagnostics ); if (!isNonNegativeFiniteNumber(effect.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-control-light-intensity", "Light control intensity must remain finite and zero or greater.", `${path}.intensity` ) ); } return; case "setLightColor": validateLightControlTarget( effect.target, `${path}.target`, document, diagnostics ); if (!isHexColorString(effect.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-control-light-color", "Light control color must remain a valid hex color string.", `${path}.colorHex` ) ); } return; case "setAmbientLightIntensity": validateSceneControlTarget(effect.target, `${path}.target`, diagnostics); if (!isNonNegativeFiniteNumber(effect.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-control-ambient-light-intensity", "Ambient light control intensity must remain finite and zero or greater.", `${path}.intensity` ) ); } return; case "setAmbientLightColor": validateSceneControlTarget(effect.target, `${path}.target`, diagnostics); if (!isHexColorString(effect.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-control-ambient-light-color", "Ambient light control color must remain a valid hex color string.", `${path}.colorHex` ) ); } return; case "setSunLightIntensity": validateSceneControlTarget(effect.target, `${path}.target`, diagnostics); if (!isNonNegativeFiniteNumber(effect.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-control-sun-light-intensity", "Sun light control intensity must remain finite and zero or greater.", `${path}.intensity` ) ); } return; case "setSunLightColor": validateSceneControlTarget(effect.target, `${path}.target`, diagnostics); if (!isHexColorString(effect.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-control-sun-light-color", "Sun light control color must remain a valid hex color string.", `${path}.colorHex` ) ); } return; } } function validateInteractionLink( link: InteractionLink, path: string, document: SceneDocument, diagnostics: SceneDiagnostic[] ) { const sourceEntity = document.entities[link.sourceEntityId]; if (sourceEntity === undefined) { diagnostics.push( createDiagnostic( "error", "missing-interaction-source-entity", `Interaction source entity ${link.sourceEntityId} does not exist.`, `${path}.sourceEntityId` ) ); return; } if ( sourceEntity.kind !== "triggerVolume" && sourceEntity.kind !== "interactable" && sourceEntity.kind !== "npc" ) { diagnostics.push( createDiagnostic( "error", "invalid-interaction-source-kind", "Interaction links may only source from Trigger Volume, Interactable, or NPC entities in the current slice.", `${path}.sourceEntityId` ) ); } if (sourceEntity.kind === "triggerVolume") { if (link.trigger !== "enter" && link.trigger !== "exit") { diagnostics.push( createDiagnostic( "error", "unsupported-interaction-trigger", "Trigger Volume links may only use enter or exit triggers.", `${path}.trigger` ) ); } } else if (sourceEntity.kind === "interactable") { if (link.trigger !== "click") { diagnostics.push( createDiagnostic( "error", "unsupported-interaction-trigger", "Interactable links may only use the click trigger.", `${path}.trigger` ) ); } } else if (sourceEntity.kind === "npc") { if (link.trigger !== "click") { diagnostics.push( createDiagnostic( "error", "unsupported-interaction-trigger", "NPC links may only use the click trigger.", `${path}.trigger` ) ); } } switch (link.action.type) { case "teleportPlayer": { const targetEntity = document.entities[link.action.targetEntityId]; if (targetEntity === undefined) { diagnostics.push( createDiagnostic( "error", "missing-teleport-target-entity", `Teleport target entity ${link.action.targetEntityId} does not exist.`, `${path}.action.targetEntityId` ) ); return; } if (targetEntity.kind !== "teleportTarget") { diagnostics.push( createDiagnostic( "error", "invalid-teleport-target-kind", "Teleport player actions must target a Teleport Target entity.", `${path}.action.targetEntityId` ) ); } break; } case "toggleVisibility": if (document.brushes[link.action.targetBrushId] === undefined) { diagnostics.push( createDiagnostic( "error", "missing-visibility-target-brush", `Visibility target brush ${link.action.targetBrushId} does not exist.`, `${path}.action.targetBrushId` ) ); } if ( link.action.visible !== undefined && typeof link.action.visible !== "boolean" ) { diagnostics.push( createDiagnostic( "error", "invalid-visibility-action-visible", "Visibility actions must use a boolean visible value when authored.", `${path}.action.visible` ) ); } break; case "playAnimation": { const targetModelInstance = document.modelInstances[link.action.targetModelInstanceId]; if (targetModelInstance === undefined) { diagnostics.push( createDiagnostic( "error", "missing-play-animation-target-instance", `Play animation target model instance ${link.action.targetModelInstanceId} does not exist.`, `${path}.action.targetModelInstanceId` ) ); return; } if (link.action.clipName.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-play-animation-clip-name", "Play animation clip name must be non-empty.", `${path}.action.clipName` ) ); return; } const targetAsset = document.assets[targetModelInstance.assetId]; if (targetAsset === undefined || targetAsset.kind !== "model") { return; } if (!targetAsset.metadata.animationNames.includes(link.action.clipName)) { diagnostics.push( createDiagnostic( "error", "missing-play-animation-clip", `Play animation clip ${link.action.clipName} does not exist on model asset ${targetAsset.id}.`, `${path}.action.clipName` ) ); } break; } case "stopAnimation": // Validate that the target model instance exists in the document if ( document.modelInstances[link.action.targetModelInstanceId] === undefined ) { diagnostics.push( createDiagnostic( "error", "missing-stop-animation-target-instance", `Stop animation target model instance ${link.action.targetModelInstanceId} does not exist.`, `${path}.action.targetModelInstanceId` ) ); } break; case "playSound": case "stopSound": { const targetEntity = document.entities[link.action.targetSoundEmitterId]; if (targetEntity === undefined) { diagnostics.push( createDiagnostic( "error", "missing-sound-emitter-entity", `Sound emitter entity ${link.action.targetSoundEmitterId} does not exist.`, `${path}.action.targetSoundEmitterId` ) ); break; } if (targetEntity.kind !== "soundEmitter") { diagnostics.push( createDiagnostic( "error", "invalid-sound-emitter-kind", "Sound playback actions must target a Sound Emitter entity.", `${path}.action.targetSoundEmitterId` ) ); break; } if (targetEntity.audioAssetId === null) { diagnostics.push( createDiagnostic( "error", "missing-sound-emitter-audio-asset", "Sound playback actions require a Sound Emitter that references an audio asset.", `${path}.action.targetSoundEmitterId` ) ); } break; } case "runSequence": { const sequence = document.sequences.sequences[link.action.sequenceId] ?? null; if (sequence === null) { diagnostics.push( createDiagnostic( "error", "missing-sequence-resource", `Sequence ${link.action.sequenceId} does not exist in the project sequence library.`, `${path}.action.sequenceId` ) ); break; } if (getProjectSequenceImpulseSteps(sequence).length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-link-sequence-no-impulse-steps", "Interaction link sequences must include at least one start effect.", `${path}.action.sequenceId` ) ); } break; } case "control": validateControlEffect( link.action.effect, `${path}.action.effect`, document, diagnostics ); break; } } function validateProjectScheduler( scheduler: ProjectScheduler, sequences: ProjectSequenceLibrary, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { for (const [routineKey, routine] of Object.entries(scheduler.routines)) { const path = `scheduler.routines.${routineKey}`; const resolvedHeldSteps = getProjectScheduleRoutineHeldSteps( routine, sequences ); const resolvedEffects = getHeldSequenceControlEffects(resolvedHeldSteps); if (routine.id !== routineKey) { diagnostics.push( createDiagnostic( "error", "project-schedule-routine-id-mismatch", "Project schedule routine ids must match their registry key.", `${path}.id` ) ); } if (routine.title.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-routine-title", "Project schedule routine titles must be non-empty.", `${path}.title` ) ); } if (!isBoolean(routine.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-routine-enabled", "Project schedule routines must remain explicitly enabled or disabled.", `${path}.enabled` ) ); } validateProjectSchedulerControlTarget( routine.target, `${path}.target`, context, diagnostics ); if ( routine.sequenceId !== null && sequences.sequences[routine.sequenceId] === undefined ) { diagnostics.push( createDiagnostic( "error", "missing-routine-sequence-resource", `Sequence ${routine.sequenceId} does not exist in the project sequence library.`, `${path}.sequenceId` ) ); } switch (routine.days.mode) { case "everyDay": break; case "selectedDays": if (routine.days.days.length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-routine-days-empty", "Selected-day routines must include at least one weekday.", `${path}.days.days` ) ); } for (const [dayIndex, day] of routine.days.days.entries()) { if (!isProjectScheduleWeekday(day)) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-routine-day", "Project schedule days must use supported weekday ids.", `${path}.days.days.${dayIndex}` ) ); } } break; default: diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-routine-days-mode", "Project schedule day selection must use everyDay or selectedDays.", `${path}.days.mode` ) ); break; } if (!isFiniteNumber(routine.startHour)) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-start-hour", "Project schedule start hours must be finite numbers.", `${path}.startHour` ) ); } else if (routine.startHour < 0 || routine.startHour >= HOURS_PER_DAY) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-start-range", "Project schedule start hours must stay within the 0..24 hour range.", `${path}.startHour` ) ); } if (!isFiniteNumber(routine.endHour)) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-end-hour", "Project schedule end hours must be finite numbers.", `${path}.endHour` ) ); } else if (routine.endHour < 0 || routine.endHour >= HOURS_PER_DAY) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-end-range", "Project schedule end hours must stay within the 0..24 hour range.", `${path}.endHour` ) ); } if ( isFiniteNumber(routine.startHour) && isFiniteNumber(routine.endHour) && routine.startHour === routine.endHour ) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-zero-window", "Project schedule routines must span at least part of the day.", `${path}.startHour` ) ); } if ( !isFiniteNumber(routine.priority) || !Number.isInteger(routine.priority) ) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-priority", "Project schedule priorities must remain finite integers.", `${path}.priority` ) ); } const resolvedImpulseEffects = routine.sequenceId === null ? [] : getProjectSequenceImpulseSteps( sequences.sequences[routine.sequenceId] ?? { id: "", title: "", effects: [] } ); if (resolvedEffects.length === 0) { if ( routine.target.kind === "global" && resolvedImpulseEffects.length > 0 ) { continue; } if (routine.target.kind === "actor") { continue; } diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-routine-effects-empty", "Project sequencer placements must resolve at least one timeline control effect unless they only use start effects.", routine.sequenceId === null ? `${path}.effects` : `${path}.sequenceId` ) ); continue; } if (routine.target.kind !== "actor" && resolvedEffects.length !== 1) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-non-actor-effect-count", "Non-actor sequencer placements must currently resolve exactly one timeline control effect.", routine.sequenceId === null ? `${path}.effects` : `${path}.sequenceId` ) ); } const seenResolutionKeys = new Set(); for (const [effectIndex, effect] of resolvedEffects.entries()) { const effectPath = routine.sequenceId === null ? `${path}.effects.${effectIndex}` : `${path}.sequenceId`; if ( getControlTargetRefKey(effect.target) !== getControlTargetRefKey(routine.target) ) { diagnostics.push( createDiagnostic( "error", "project-schedule-effect-target-mismatch", "Project schedule effect targets must match the authored routine target.", `${effectPath}.target` ) ); } const resolutionKey = getControlEffectResolutionKey(effect); if (seenResolutionKeys.has(resolutionKey)) { diagnostics.push( createDiagnostic( "error", "invalid-project-schedule-duplicate-effect", "Project schedule routines cannot author duplicate effects for the same resolved control slot.", effectPath ) ); } else { seenResolutionKeys.add(resolutionKey); } if (routine.sequenceId === null) { validateProjectSchedulerEffect( effect, effectPath, context, diagnostics ); } } } } interface ProjectSchedulerValidationContext { actorIds: Set; actorUsagesById: Map; brushCounts: Map; entityCounts: Map; entityKindsById: Map>; modelInstanceCounts: Map; modelAnimationNamesById: Map; soundEmitterHasAudioById: Map; } interface ProjectSchedulerActorValidationUsage { sceneId: string; entityId: string; modelAssetId: string | null; animationNames: string[]; pathIds: Set; enabledPathIds: Set; } function incrementSchedulerValidationCount( counts: Map, id: string ) { counts.set(id, (counts.get(id) ?? 0) + 1); } function addSchedulerValidationKind( collection: Map>, key: TKey, value: TValue ) { const values = collection.get(key) ?? new Set(); values.add(value); collection.set(key, values); } function createEmptyProjectSchedulerValidationContext(): ProjectSchedulerValidationContext { return { actorIds: new Set(), actorUsagesById: new Map(), brushCounts: new Map(), entityCounts: new Map(), entityKindsById: new Map>(), modelInstanceCounts: new Map(), modelAnimationNamesById: new Map(), soundEmitterHasAudioById: new Map() }; } function recordProjectSchedulerSceneTargets( context: ProjectSchedulerValidationContext, scene: Pick< SceneDocument, "brushes" | "entities" | "modelInstances" | "assets" | "paths" >, sceneId: string ) { const pathIds = new Set(Object.keys(scene.paths)); const enabledPathIds = new Set( Object.values(scene.paths) .filter((path) => path.enabled) .map((path) => path.id) ); for (const brush of Object.values(scene.brushes)) { incrementSchedulerValidationCount(context.brushCounts, brush.id); } for (const entity of Object.values(scene.entities)) { incrementSchedulerValidationCount(context.entityCounts, entity.id); addSchedulerValidationKind(context.entityKindsById, entity.id, entity.kind); if (entity.kind === "npc") { context.actorIds.add(entity.actorId); const usages = context.actorUsagesById.get(entity.actorId) ?? []; const targetAsset = entity.modelAssetId === null ? undefined : scene.assets[entity.modelAssetId]; usages.push({ sceneId, entityId: entity.id, modelAssetId: entity.modelAssetId, animationNames: targetAsset !== undefined && targetAsset.kind === "model" ? [...targetAsset.metadata.animationNames] : [], pathIds: new Set(pathIds), enabledPathIds: new Set(enabledPathIds) }); context.actorUsagesById.set(entity.actorId, usages); } if (entity.kind === "soundEmitter") { context.soundEmitterHasAudioById.set( entity.id, entity.audioAssetId !== null ); } } for (const modelInstance of Object.values(scene.modelInstances)) { incrementSchedulerValidationCount( context.modelInstanceCounts, modelInstance.id ); const targetAsset = scene.assets[modelInstance.assetId]; const animationNames = targetAsset !== undefined && targetAsset.kind === "model" ? [...targetAsset.metadata.animationNames] : []; context.modelAnimationNamesById.set(modelInstance.id, animationNames); } } function createProjectSchedulerValidationContextFromSceneDocument( document: SceneDocument ): ProjectSchedulerValidationContext { const context = createEmptyProjectSchedulerValidationContext(); recordProjectSchedulerSceneTargets(context, document, "activeScene"); return context; } function createProjectSchedulerValidationContextFromProjectDocument( document: ProjectDocument ): ProjectSchedulerValidationContext { const context = createEmptyProjectSchedulerValidationContext(); for (const scene of Object.values(document.scenes)) { recordProjectSchedulerSceneTargets( context, { brushes: scene.brushes, entities: scene.entities, modelInstances: scene.modelInstances, assets: document.assets, paths: scene.paths }, scene.id ); } return context; } function validateProjectSchedulerActorTarget( target: ActorControlTargetRef, path: string, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { if (context.actorIds.has(target.actorId)) { return; } diagnostics.push( createDiagnostic( "error", "missing-control-actor-target", `Actor control target ${target.actorId} does not exist in this document.`, `${path}.actorId` ) ); } function getProjectSchedulerActorValidationUsages( context: ProjectSchedulerValidationContext, actorId: string ): ProjectSchedulerActorValidationUsage[] { return context.actorUsagesById.get(actorId) ?? []; } function validateProjectSchedulerSingleActorUsage( target: ActorControlTargetRef, path: string, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[], capabilityLabel: string ): ProjectSchedulerActorValidationUsage | null { validateProjectSchedulerActorTarget(target, path, context, diagnostics); const usages = getProjectSchedulerActorValidationUsages( context, target.actorId ); if (usages.length === 1) { return usages[0] ?? null; } if (usages.length > 1) { diagnostics.push( createDiagnostic( "error", "ambiguous-project-schedule-actor-usage", `${capabilityLabel} currently requires an actor with exactly one NPC usage in the project.`, `${path}.actorId` ) ); } return null; } function validateProjectSchedulerEntityTarget( target: ControlTargetRef & { kind: "entity" }, path: string, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { const entityCount = context.entityCounts.get(target.entityId) ?? 0; if (entityCount === 0) { diagnostics.push( createDiagnostic( "error", target.entityKind === "soundEmitter" ? "missing-control-sound-emitter-entity" : target.entityKind === "cameraRig" ? "missing-control-camera-rig-entity" : "missing-control-light-entity", `${ target.entityKind === "soundEmitter" ? "Control sound emitter" : target.entityKind === "cameraRig" ? "Camera control" : "Light control" } target entity ${target.entityId} does not exist.`, `${path}.entityId` ) ); return; } if (entityCount > 1) { diagnostics.push( createDiagnostic( "error", "ambiguous-project-schedule-entity-target", `Project schedule target ${target.entityId} exists in multiple scenes and must be unique to be scheduler-addressable.`, `${path}.entityId` ) ); return; } const entityKinds = context.entityKindsById.get(target.entityId) ?? new Set(); if (!entityKinds.has(target.entityKind)) { diagnostics.push( createDiagnostic( "error", target.entityKind === "soundEmitter" ? "invalid-control-sound-emitter-kind" : target.entityKind === "cameraRig" ? "invalid-control-camera-rig-target-kind" : "invalid-control-light-target-kind", target.entityKind === "soundEmitter" ? "Control sound effects must target a Sound Emitter entity." : target.entityKind === "cameraRig" ? "Camera control effects must target a Camera Rig entity of the authored target kind." : "Light control effects must target a Point Light or Spot Light entity of the authored target kind.", `${path}.entityKind` ) ); } } function validateProjectSchedulerInteractionTarget( target: InteractionControlTargetRef, path: string, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { const entityCount = context.entityCounts.get(target.entityId) ?? 0; if (entityCount === 0) { diagnostics.push( createDiagnostic( "error", "missing-control-interaction-entity", `Interaction control target entity ${target.entityId} does not exist.`, `${path}.entityId` ) ); return; } if (entityCount > 1) { diagnostics.push( createDiagnostic( "error", "ambiguous-project-schedule-interaction-target", `Project schedule interaction target ${target.entityId} exists in multiple scenes and must be unique to be scheduler-addressable.`, `${path}.entityId` ) ); return; } const entityKinds = context.entityKindsById.get(target.entityId) ?? new Set(); if (!entityKinds.has(target.interactionKind)) { diagnostics.push( createDiagnostic( "error", "invalid-control-interaction-target-kind", "Interaction control effects must target an Interactable or Scene Exit entity of the authored target kind.", `${path}.interactionKind` ) ); } } function validateProjectSchedulerModelTarget( target: ModelInstanceControlTargetRef, path: string, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { const modelInstanceCount = context.modelInstanceCounts.get(target.modelInstanceId) ?? 0; if (modelInstanceCount === 0) { diagnostics.push( createDiagnostic( "error", "missing-control-model-instance-target", `Control model instance target ${target.modelInstanceId} does not exist.`, `${path}.modelInstanceId` ) ); return; } if (modelInstanceCount > 1) { diagnostics.push( createDiagnostic( "error", "ambiguous-project-schedule-model-instance-target", `Project schedule model instance target ${target.modelInstanceId} exists in multiple scenes and must be unique to be scheduler-addressable.`, `${path}.modelInstanceId` ) ); } } function validateProjectSchedulerSceneTarget( target: SceneControlTargetRef, path: string, diagnostics: SceneDiagnostic[] ) { if (target.scope === "activeScene") { return; } diagnostics.push( createDiagnostic( "error", "invalid-control-scene-target", "Scene control effects must target the active scene scope.", `${path}.scope` ) ); } function validateProjectSchedulerControlTarget( target: ControlTargetRef, path: string, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { switch (target.kind) { case "actor": validateProjectSchedulerActorTarget(target, path, context, diagnostics); return; case "entity": validateProjectSchedulerEntityTarget(target, path, context, diagnostics); return; case "interaction": validateProjectSchedulerInteractionTarget( target, path, context, diagnostics ); return; case "scene": validateProjectSchedulerSceneTarget(target, path, diagnostics); return; case "modelInstance": validateProjectSchedulerModelTarget(target, path, context, diagnostics); return; case "global": return; } } function validateProjectSchedulerSoundEmitterTarget( target: SoundEmitterControlTargetRef, path: string, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[], options: { requireAudioAsset: boolean } ) { validateProjectSchedulerEntityTarget(target, path, context, diagnostics); if (!options.requireAudioAsset) { return; } const entityCount = context.entityCounts.get(target.entityId) ?? 0; if (entityCount !== 1) { return; } if (context.soundEmitterHasAudioById.get(target.entityId) === false) { diagnostics.push( createDiagnostic( "error", "missing-control-sound-emitter-audio-asset", "Control sound playback effects require a Sound Emitter that references an audio asset.", `${path}.entityId` ) ); } } function validateProjectSchedulerEffect( effect: ControlEffect, path: string, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { switch (effect.type) { case "setActorPresence": validateProjectSchedulerActorTarget( effect.target, `${path}.target`, context, diagnostics ); if (!isBoolean(effect.active)) { diagnostics.push( createDiagnostic( "error", "invalid-control-actor-active", "Actor presence control values must remain boolean.", `${path}.active` ) ); } return; case "activateCameraRigOverride": case "clearCameraRigOverride": validateProjectSchedulerEntityTarget( effect.target, `${path}.target`, context, diagnostics ); return; case "playActorAnimation": { const usage = validateProjectSchedulerSingleActorUsage( effect.target, `${path}.target`, context, diagnostics, "Actor animation scheduling" ); if (effect.clipName.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-control-actor-animation-clip-name", "Actor animation clip names must be non-empty.", `${path}.clipName` ) ); return; } if (usage === null) { return; } if (!usage.animationNames.includes(effect.clipName)) { diagnostics.push( createDiagnostic( "error", "missing-control-actor-animation-clip", `Actor animation clip ${effect.clipName} does not exist on the uniquely bound NPC model asset.`, `${path}.clipName` ) ); } return; } case "followActorPath": { const usage = validateProjectSchedulerSingleActorUsage( effect.target, `${path}.target`, context, diagnostics, "Actor path scheduling" ); if (effect.pathId.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-control-actor-path-id", "Actor path ids must be non-empty.", `${path}.pathId` ) ); } if (!isPositiveFiniteNumber(effect.speed)) { diagnostics.push( createDiagnostic( "error", "invalid-control-actor-path-speed", "Actor path speed must remain finite and greater than zero.", `${path}.speed` ) ); } if (!isBoolean(effect.loop)) { diagnostics.push( createDiagnostic( "error", "invalid-control-actor-path-loop", "Actor path loop must remain a boolean.", `${path}.loop` ) ); } if (!isBoolean(effect.smoothPath)) { diagnostics.push( createDiagnostic( "error", "invalid-control-actor-path-smooth", "Actor path smoothing must remain a boolean.", `${path}.smoothPath` ) ); } if (!isActorPathProgressMode(effect.progressMode)) { diagnostics.push( createDiagnostic( "error", "invalid-control-actor-path-progress-mode", "Actor path progress mode must use a supported typed value.", `${path}.progressMode` ) ); } if (usage === null) { return; } if (!usage.pathIds.has(effect.pathId)) { diagnostics.push( createDiagnostic( "error", "missing-control-actor-path", `Actor path ${effect.pathId} does not exist in the uniquely bound NPC scene.`, `${path}.pathId` ) ); return; } if (!usage.enabledPathIds.has(effect.pathId)) { diagnostics.push( createDiagnostic( "error", "disabled-control-actor-path", "Actor follow-path routines require an enabled authored path.", `${path}.pathId` ) ); } return; } case "playModelAnimation": { validateProjectSchedulerModelTarget( effect.target, `${path}.target`, context, diagnostics ); if (effect.clipName.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-control-play-animation-clip-name", "Control play animation clip name must be non-empty.", `${path}.clipName` ) ); return; } const targetCount = context.modelInstanceCounts.get(effect.target.modelInstanceId) ?? 0; if (targetCount !== 1) { return; } const animationNames = context.modelAnimationNamesById.get(effect.target.modelInstanceId) ?? []; if ( animationNames.length > 0 && !animationNames.includes(effect.clipName) ) { diagnostics.push( createDiagnostic( "error", "missing-control-play-animation-clip", `Control play animation clip ${effect.clipName} does not exist on the target model asset.`, `${path}.clipName` ) ); } return; } case "stopModelAnimation": validateProjectSchedulerModelTarget( effect.target, `${path}.target`, context, diagnostics ); return; case "setModelInstanceVisible": validateProjectSchedulerModelTarget( effect.target, `${path}.target`, context, diagnostics ); if (!isBoolean(effect.visible)) { diagnostics.push( createDiagnostic( "error", "invalid-control-model-instance-visible", "Model visibility control values must remain boolean.", `${path}.visible` ) ); } return; case "playSound": case "stopSound": validateProjectSchedulerSoundEmitterTarget( effect.target, `${path}.target`, context, diagnostics, { requireAudioAsset: true } ); return; case "setSoundVolume": validateProjectSchedulerSoundEmitterTarget( effect.target, `${path}.target`, context, diagnostics, { requireAudioAsset: false } ); if (!isNonNegativeFiniteNumber(effect.volume)) { diagnostics.push( createDiagnostic( "error", "invalid-control-sound-volume", "Sound control volume must remain finite and zero or greater.", `${path}.volume` ) ); } return; case "setInteractionEnabled": validateProjectSchedulerInteractionTarget( effect.target, `${path}.target`, context, diagnostics ); if (!isBoolean(effect.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-control-interaction-enabled", "Interaction control enabled values must remain boolean.", `${path}.enabled` ) ); } return; case "setLightEnabled": validateProjectSchedulerEntityTarget( effect.target, `${path}.target`, context, diagnostics ); if (!isBoolean(effect.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-control-light-enabled", "Light control enabled values must remain boolean.", `${path}.enabled` ) ); } return; case "setLightIntensity": validateProjectSchedulerEntityTarget( effect.target, `${path}.target`, context, diagnostics ); if (!isNonNegativeFiniteNumber(effect.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-control-light-intensity", "Light control intensity must remain finite and zero or greater.", `${path}.intensity` ) ); } return; case "setLightColor": validateProjectSchedulerEntityTarget( effect.target, `${path}.target`, context, diagnostics ); if (!isHexColorString(effect.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-control-light-color", "Light control color must remain a valid hex color string.", `${path}.colorHex` ) ); } return; case "setAmbientLightIntensity": validateProjectSchedulerSceneTarget( effect.target, `${path}.target`, diagnostics ); if (!isNonNegativeFiniteNumber(effect.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-control-ambient-light-intensity", "Ambient light control intensity must remain finite and zero or greater.", `${path}.intensity` ) ); } return; case "setAmbientLightColor": validateProjectSchedulerSceneTarget( effect.target, `${path}.target`, diagnostics ); if (!isHexColorString(effect.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-control-ambient-light-color", "Ambient light control color must remain a valid hex color string.", `${path}.colorHex` ) ); } return; case "setSunLightIntensity": validateProjectSchedulerSceneTarget( effect.target, `${path}.target`, diagnostics ); if (!isNonNegativeFiniteNumber(effect.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-control-sun-light-intensity", "Sun light control intensity must remain finite and zero or greater.", `${path}.intensity` ) ); } return; case "setSunLightColor": validateProjectSchedulerSceneTarget( effect.target, `${path}.target`, diagnostics ); if (!isHexColorString(effect.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-control-sun-light-color", "Sun light control color must remain a valid hex color string.", `${path}.colorHex` ) ); } return; } } function registerAuthoredId( id: string, path: string, seenIds: Map, diagnostics: SceneDiagnostic[] ) { const previousPath = seenIds.get(id); if (previousPath !== undefined) { diagnostics.push( createDiagnostic( "error", "duplicate-authored-id", `Duplicate authored id ${id} is already used at ${previousPath}.`, path ) ); return; } seenIds.set(id, path); } function prefixDiagnosticPath( prefix: string, path?: string ): string | undefined { return path === undefined ? undefined : `${prefix}${path}`; } function validateProjectDialogue( dialogue: ProjectDialogue, path: string, seenIds: Map, diagnostics: SceneDiagnostic[] ) { if (dialogue.id.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-dialogue-id", "Dialogue ids must be non-empty.", `${path}.id` ) ); } if (dialogue.title.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-dialogue-title", "Dialogue titles must be non-empty.", `${path}.title` ) ); } if (dialogue.lines.length === 0) { diagnostics.push( createDiagnostic( "error", "missing-dialogue-lines", "Dialogues must contain at least one line.", `${path}.lines` ) ); return; } for (const [lineIndex, line] of dialogue.lines.entries()) { const linePath = `${path}.lines.${lineIndex}`; registerAuthoredId(line.id, linePath, seenIds, diagnostics); if (line.text.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-dialogue-line-text", "Dialogue line text must be non-empty.", `${linePath}.text` ) ); } } } function validateProjectSequence( sequence: ProjectSequence, path: string, projectResources: Pick & { currentSceneEntities?: SceneDocument["entities"]; }, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { const projectScenes = projectResources.scenes ?? {}; const currentSceneEntities = projectResources.currentSceneEntities ?? {}; if (sequence.title.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-project-sequence-title", "Project sequence titles must be non-empty.", `${path}.title` ) ); } if (sequence.effects.length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-project-sequence-effects-empty", "Project sequences must contain at least one effect.", `${path}.effects` ) ); return; } for (const [effectIndex, effect] of sequence.effects.entries()) { const effectPath = `${path}.effects.${effectIndex}`; switch (effect.type) { case "controlEffect": validateProjectSchedulerEffect( effect.effect, `${effectPath}.effect`, context, diagnostics ); break; case "makeNpcTalk": { let targetNpc: NpcEntity | null = null; for (const scene of Object.values(projectScenes)) { const candidate = scene.entities[effect.npcEntityId]; if (candidate?.kind === "npc") { targetNpc = candidate; break; } } if (targetNpc === null) { const candidate = currentSceneEntities[effect.npcEntityId]; if (candidate?.kind === "npc") { targetNpc = candidate; } } if (targetNpc === null) { diagnostics.push( createDiagnostic( "error", "missing-sequence-npc-dialogue-target", `NPC ${effect.npcEntityId} does not exist in this project.`, `${effectPath}.npcEntityId` ) ); break; } if ( effect.dialogueId !== null && !targetNpc.dialogues.some( (dialogue) => dialogue.id === effect.dialogueId ) ) { diagnostics.push( createDiagnostic( "error", "missing-sequence-npc-dialogue", `Dialogue ${effect.dialogueId} does not exist on NPC ${effect.npcEntityId}.`, `${effectPath}.dialogueId` ) ); } break; } case "teleportPlayer": break; case "startSceneTransition": { if (Object.keys(projectScenes).length === 0) { break; } const targetScene = projectScenes[effect.targetSceneId] ?? null; if (targetScene === null) { diagnostics.push( createDiagnostic( "error", "missing-sequence-transition-target-scene", `Scene transition target scene ${effect.targetSceneId} does not exist in this project.`, `${effectPath}.targetSceneId` ) ); break; } const targetEntry = targetScene.entities[effect.targetEntryEntityId] ?? null; if (targetEntry === null) { diagnostics.push( createDiagnostic( "error", "missing-sequence-transition-target-entry", `Scene transition target entry ${effect.targetEntryEntityId} does not exist in scene ${targetScene.name}.`, `${effectPath}.targetEntryEntityId` ) ); break; } if (targetEntry.kind !== "sceneEntry") { diagnostics.push( createDiagnostic( "error", "invalid-sequence-transition-target-entry-kind", `Scene transition target ${effect.targetEntryEntityId} in scene ${targetScene.name} is not a Scene Entry.`, `${effectPath}.targetEntryEntityId` ) ); } break; } case "setVisibility": if (effect.target.kind === "brush") { if ((context.brushCounts.get(effect.target.brushId) ?? 0) === 0) { diagnostics.push( createDiagnostic( "error", "missing-sequence-visibility-target-brush", `Sequence visibility target brush ${effect.target.brushId} does not exist.`, `${effectPath}.target.brushId` ) ); } } else if ( (context.modelInstanceCounts.get(effect.target.modelInstanceId) ?? 0) === 0 ) { diagnostics.push( createDiagnostic( "error", "missing-sequence-visibility-target-model-instance", `Sequence visibility target model instance ${effect.target.modelInstanceId} does not exist.`, `${effectPath}.target.modelInstanceId` ) ); } break; } } } function validateProjectResources( document: Pick< ProjectDocument, "materials" | "assets" | "sequences" | "scenes" >, context: ProjectSchedulerValidationContext, diagnostics: SceneDiagnostic[] ) { const seenIds = new Map(); for (const [materialKey, material] of Object.entries(document.materials)) { const path = `materials.${materialKey}`; if (material.id !== materialKey) { diagnostics.push( createDiagnostic( "error", "material-id-mismatch", "Material ids must match their registry key.", `${path}.id` ) ); } 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 [sequenceKey, sequence] of Object.entries( document.sequences.sequences )) { const path = `sequences.sequences.${sequenceKey}`; if (sequence.id !== sequenceKey) { diagnostics.push( createDiagnostic( "error", "sequence-id-mismatch", "Sequence ids must match their registry key.", `${path}.id` ) ); } registerAuthoredId(sequence.id, path, seenIds, diagnostics); validateProjectSequence( sequence, path, { scenes: document.scenes }, context, diagnostics ); } } function filterProjectSceneDiagnostics( diagnostics: SceneDiagnostic[] ): SceneDiagnostic[] { return diagnostics.filter( (diagnostic) => diagnostic.path === undefined || (!diagnostic.path.startsWith("materials.") && !diagnostic.path.startsWith("time.") && !diagnostic.path.startsWith("assets.") && !diagnostic.path.startsWith("dialogues.") && !diagnostic.path.startsWith("sequences.")) ); } function validateProjectSceneLoadingScreen( scene: ProjectDocument["scenes"][string], scenePath: string, diagnostics: SceneDiagnostic[] ) { if (!isHexColorString(scene.loadingScreen.colorHex)) { diagnostics.push( createDiagnostic( "error", "invalid-scene-loading-color", "Scene loading overlays must use #RRGGBB colors.", `${scenePath}.loadingScreen.colorHex` ) ); } if ( scene.loadingScreen.headline !== null && scene.loadingScreen.headline.trim().length === 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-scene-loading-headline", "Scene loading overlay headlines must be non-empty when authored.", `${scenePath}.loadingScreen.headline` ) ); } if ( scene.loadingScreen.description !== null && scene.loadingScreen.description.trim().length === 0 ) { diagnostics.push( createDiagnostic( "error", "invalid-scene-loading-description", "Scene loading overlay descriptions must be non-empty when authored.", `${scenePath}.loadingScreen.description` ) ); } } function validateProjectSceneEditorPreferences( scene: ProjectDocument["scenes"][string], scenePath: string, diagnostics: SceneDiagnostic[] ) { const preferences = scene.editorPreferences; if (!WHITEBOX_SELECTION_MODES.includes(preferences.whiteboxSelectionMode)) { diagnostics.push( createDiagnostic( "error", "invalid-scene-editor-selection-mode", "Scene editor selection mode must be one of object, face, edge, or vertex.", `${scenePath}.editorPreferences.whiteboxSelectionMode` ) ); } if ( preferences.viewportLayoutMode !== "single" && preferences.viewportLayoutMode !== "quad" ) { diagnostics.push( createDiagnostic( "error", "invalid-scene-editor-layout-mode", "Scene editor viewport layout must be single or quad.", `${scenePath}.editorPreferences.viewportLayoutMode` ) ); } if ( preferences.activeViewportPanelId !== "topLeft" && preferences.activeViewportPanelId !== "topRight" && preferences.activeViewportPanelId !== "bottomLeft" && preferences.activeViewportPanelId !== "bottomRight" ) { diagnostics.push( createDiagnostic( "error", "invalid-scene-editor-active-panel", "Scene editor active viewport panel must reference a supported panel id.", `${scenePath}.editorPreferences.activeViewportPanelId` ) ); } if (!isPositiveFiniteNumber(preferences.whiteboxSnapStep)) { diagnostics.push( createDiagnostic( "error", "invalid-scene-editor-snap-step", "Scene editor snap step must be greater than zero.", `${scenePath}.editorPreferences.whiteboxSnapStep` ) ); } if (!isFiniteNumber(preferences.viewportQuadSplit.x)) { diagnostics.push( createDiagnostic( "error", "invalid-scene-editor-quad-split-x", "Scene editor quad split X must be finite.", `${scenePath}.editorPreferences.viewportQuadSplit.x` ) ); } if (!isFiniteNumber(preferences.viewportQuadSplit.y)) { diagnostics.push( createDiagnostic( "error", "invalid-scene-editor-quad-split-y", "Scene editor quad split Y must be finite.", `${scenePath}.editorPreferences.viewportQuadSplit.y` ) ); } for (const [panelId, panelPreferences] of Object.entries( preferences.viewportPanels )) { if ( panelPreferences.viewMode !== "perspective" && panelPreferences.viewMode !== "top" && panelPreferences.viewMode !== "front" && panelPreferences.viewMode !== "side" ) { diagnostics.push( createDiagnostic( "error", "invalid-scene-editor-panel-view-mode", "Scene editor panel view mode must be perspective, top, front, or side.", `${scenePath}.editorPreferences.viewportPanels.${panelId}.viewMode` ) ); } if ( panelPreferences.displayMode !== "normal" && panelPreferences.displayMode !== "authoring" && panelPreferences.displayMode !== "wireframe" ) { diagnostics.push( createDiagnostic( "error", "invalid-scene-editor-panel-display-mode", "Scene editor panel display mode must be normal, authoring, or wireframe.", `${scenePath}.editorPreferences.viewportPanels.${panelId}.displayMode` ) ); } } } export function formatSceneDiagnostic(diagnostic: SceneDiagnostic): string { return diagnostic.path === undefined ? diagnostic.message : `${diagnostic.path}: ${diagnostic.message}`; } export function formatSceneDiagnosticSummary( diagnostics: SceneDiagnostic[], limit = 3 ): string { if (diagnostics.length === 0) { return "No diagnostics."; } const visibleDiagnostics = diagnostics.slice(0, Math.max(1, limit)); const summary = visibleDiagnostics .map((diagnostic) => formatSceneDiagnostic(diagnostic)) .join("; "); const remainingCount = diagnostics.length - visibleDiagnostics.length; return remainingCount > 0 ? `${summary}; +${remainingCount} more` : summary; } export function validateSceneDocument( document: SceneDocument ): SceneDocumentValidationResult { const diagnostics: SceneDiagnostic[] = []; const seenIds = new Map(); const projectSchedulerValidationContext = createProjectSchedulerValidationContextFromSceneDocument(document); validateProjectTimeSettings(document.time, diagnostics); validateWorldSettings(document.world, document, diagnostics); for (const [materialKey, material] of Object.entries(document.materials)) { const path = `materials.${materialKey}`; if (material.id !== materialKey) { diagnostics.push( createDiagnostic( "error", "material-id-mismatch", "Material ids must match their registry key.", `${path}.id` ) ); } 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 [sequenceKey, sequence] of Object.entries( document.sequences.sequences )) { const path = `sequences.sequences.${sequenceKey}`; if (sequence.id !== sequenceKey) { diagnostics.push( createDiagnostic( "error", "sequence-id-mismatch", "Sequence ids must match their registry key.", `${path}.id` ) ); } registerAuthoredId(sequence.id, path, seenIds, diagnostics); validateProjectSequence( sequence, path, { scenes: {}, currentSceneEntities: document.entities }, projectSchedulerValidationContext, diagnostics ); } for (const [brushKey, brush] of Object.entries(document.brushes)) { const path = `brushes.${brushKey}`; if (brush.id !== brushKey) { diagnostics.push( createDiagnostic( "error", "brush-id-mismatch", "Brush ids must match their registry key.", `${path}.id` ) ); } registerAuthoredId(brush.id, path, seenIds, diagnostics); if (!isBoolean(brush.visible)) { diagnostics.push( createDiagnostic( "error", "invalid-box-visible", "Box brush visible must remain a boolean.", `${path}.visible` ) ); } if (!isBoolean(brush.enabled)) { diagnostics.push( createDiagnostic( "error", "invalid-box-enabled", "Box brush enabled must remain a boolean.", `${path}.enabled` ) ); } if (brush.name !== undefined && brush.name.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-box-name", "Box brush names must be non-empty when authored.", `${path}.name` ) ); } if (!isFiniteVec3(brush.center)) { diagnostics.push( createDiagnostic( "error", "invalid-box-center", "Box brush centers must remain finite on every axis.", `${path}.center` ) ); } if (!isFiniteVec3(brush.rotationDegrees)) { diagnostics.push( createDiagnostic( "error", "invalid-box-rotation", "Box brush rotations must remain finite on every axis.", `${path}.rotationDegrees` ) ); } if (!isFiniteVec3(brush.size) || !hasPositiveBoxSize(brush.size)) { diagnostics.push( createDiagnostic( "error", "invalid-box-size", "Box brush sizes must remain finite and positive on every axis.", `${path}.size` ) ); } if (brush.kind === "radialPrism") { try { normalizeRadialPrismSideCount(brush.sideCount); } catch (error) { diagnostics.push( createDiagnostic( "error", "invalid-radial-prism-side-count", error instanceof Error ? error.message : "Radial prism side count must be valid.", `${path}.sideCount` ) ); } } if (brush.kind === "cone") { try { normalizeConeSideCount(brush.sideCount); } catch (error) { diagnostics.push( createDiagnostic( "error", "invalid-cone-side-count", error instanceof Error ? error.message : "Cone side count must be valid.", `${path}.sideCount` ) ); } } if (brush.kind === "torus") { try { normalizeTorusMajorSegmentCount(brush.majorSegmentCount); } catch (error) { diagnostics.push( createDiagnostic( "error", "invalid-torus-major-segment-count", error instanceof Error ? error.message : "Torus major segment count must be valid.", `${path}.majorSegmentCount` ) ); } try { normalizeTorusTubeSegmentCount(brush.tubeSegmentCount); } catch (error) { diagnostics.push( createDiagnostic( "error", "invalid-torus-tube-segment-count", error instanceof Error ? error.message : "Torus tube segment count must be valid.", `${path}.tubeSegmentCount` ) ); } } for (const vertexId of getBrushVertexIds(brush)) { if (!isFiniteVec3(brush.geometry.vertices[vertexId])) { diagnostics.push( createDiagnostic( "error", "invalid-box-geometry-vertex", "Box brush geometry vertices must remain finite on every axis.", `${path}.geometry.vertices.${vertexId}` ) ); } } for (const faceId of getBrushFaceIds(brush)) { const materialId = brush.faces[faceId].materialId; if (materialId !== null && document.materials[materialId] === undefined) { diagnostics.push( createDiagnostic( "error", "missing-material-ref", `Face material reference ${materialId} does not exist in the document material registry.`, `${path}.faces.${faceId}.materialId` ) ); } } const volume = brush.volume as Record; if (!isBoxBrushVolumeMode(volume.mode)) { diagnostics.push( createDiagnostic( "error", "invalid-box-volume-mode", "Box volume mode must be none, water, fog, or light.", `${path}.volume.mode` ) ); continue; } if (brush.kind !== "box" && volume.mode !== "none") { diagnostics.push( createDiagnostic( "error", "invalid-non-box-volume-mode", "Only whitebox boxes support water, fog, or light volume modes.", `${path}.volume.mode` ) ); continue; } if (volume.mode === "water") { const water = volume.water as Record | undefined; if (water === undefined) { diagnostics.push( createDiagnostic( "error", "invalid-box-water-settings", "Water volumes must define water settings.", `${path}.volume.water` ) ); } else { if ( typeof water.colorHex !== "string" || !isHexColorString(water.colorHex) ) { diagnostics.push( createDiagnostic( "error", "invalid-box-water-color", "Water volume color must use #RRGGBB format.", `${path}.volume.water.colorHex` ) ); } if (!isNonNegativeFiniteNumber(water.surfaceOpacity)) { diagnostics.push( createDiagnostic( "error", "invalid-box-water-surface-opacity", "Water surface opacity must be a non-negative finite number.", `${path}.volume.water.surfaceOpacity` ) ); } if (!isNonNegativeFiniteNumber(water.waveStrength)) { diagnostics.push( createDiagnostic( "error", "invalid-box-water-wave-strength", "Water wave strength must be a non-negative finite number.", `${path}.volume.water.waveStrength` ) ); } if ( !isPositiveIntegerInRange( water.foamContactLimit, MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT ) ) { diagnostics.push( createDiagnostic( "error", "invalid-box-water-foam-contact-limit", `Water foam contact limit must be a positive integer between 1 and ${MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT}.`, `${path}.volume.water.foamContactLimit` ) ); } if (typeof water.surfaceDisplacementEnabled !== "boolean") { diagnostics.push( createDiagnostic( "error", "invalid-box-water-surface-displacement-enabled", "Water surface displacement must be enabled or disabled explicitly.", `${path}.volume.water.surfaceDisplacementEnabled` ) ); } } } if (volume.mode === "fog") { const fog = volume.fog as Record | undefined; if (fog === undefined) { diagnostics.push( createDiagnostic( "error", "invalid-box-fog-settings", "Fog volumes must define fog settings.", `${path}.volume.fog` ) ); } else { if ( typeof fog.colorHex !== "string" || !isHexColorString(fog.colorHex) ) { diagnostics.push( createDiagnostic( "error", "invalid-box-fog-color", "Fog volume color must use #RRGGBB format.", `${path}.volume.fog.colorHex` ) ); } if (!isNonNegativeFiniteNumber(fog.density)) { diagnostics.push( createDiagnostic( "error", "invalid-box-fog-density", "Fog volume density must be a non-negative finite number.", `${path}.volume.fog.density` ) ); } if (!isNonNegativeFiniteNumber(fog.padding)) { diagnostics.push( createDiagnostic( "error", "invalid-box-fog-padding", "Fog volume padding must be a non-negative finite number.", `${path}.volume.fog.padding` ) ); } } } if (volume.mode === "light") { const light = volume.light as Record | undefined; if (light === undefined) { diagnostics.push( createDiagnostic( "error", "invalid-box-light-settings", "Light volumes must define light settings.", `${path}.volume.light` ) ); } else { if ( typeof light.colorHex !== "string" || !isHexColorString(light.colorHex) ) { diagnostics.push( createDiagnostic( "error", "invalid-box-light-color", "Light volume color must use #RRGGBB format.", `${path}.volume.light.colorHex` ) ); } if (!isNonNegativeFiniteNumber(light.intensity)) { diagnostics.push( createDiagnostic( "error", "invalid-box-light-intensity", "Light volume intensity must be a non-negative finite number.", `${path}.volume.light.intensity` ) ); } if (!isNonNegativeFiniteNumber(light.padding)) { diagnostics.push( createDiagnostic( "error", "invalid-box-light-padding", "Light volume padding must be a non-negative finite number.", `${path}.volume.light.padding` ) ); } if (!isBoxBrushLightFalloffMode(light.falloff)) { diagnostics.push( createDiagnostic( "error", "invalid-box-light-falloff", "Light volume falloff must be linear or smoothstep.", `${path}.volume.light.falloff` ) ); } } } } for (const [pathKey, pathValue] of Object.entries(document.paths)) { const path = `paths.${pathKey}`; if (pathValue.id !== pathKey) { diagnostics.push( createDiagnostic( "error", "path-id-mismatch", "Path ids must match their registry key.", `${path}.id` ) ); } registerAuthoredId(pathValue.id, path, seenIds, diagnostics); validateScenePath(pathValue, path, diagnostics); } for (const [terrainKey, terrain] of Object.entries(document.terrains)) { const path = `terrains.${terrainKey}`; if (terrain.id !== terrainKey) { diagnostics.push( createDiagnostic( "error", "terrain-id-mismatch", "Terrain ids must match their registry key.", `${path}.id` ) ); } registerAuthoredId(terrain.id, path, seenIds, diagnostics); validateTerrain(terrain, path, document, diagnostics); } 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); } const seenNpcActorIds = new Map(); for (const [entityKey, entity] of Object.entries(document.entities)) { const path = `entities.${entityKey}`; if (entity.id !== entityKey) { diagnostics.push( createDiagnostic( "error", "entity-id-mismatch", "Entity ids must match their registry key.", `${path}.id` ) ); } registerAuthoredId(entity.id, path, seenIds, diagnostics); validateEntityName(entity.name, path, diagnostics); switch (entity.kind) { case "pointLight": validatePointLightEntity(entity, path, diagnostics); break; case "spotLight": validateSpotLightEntity(entity, path, diagnostics); break; case "cameraRig": validateCameraRigEntity(entity, path, document, diagnostics); break; case "playerStart": validatePlayerStartEntity(entity, path, diagnostics); break; case "sceneEntry": validateSceneEntryEntity(entity, path, diagnostics); break; case "npc": { validateNpcEntity(entity, path, document, diagnostics); const normalizedActorId = typeof entity.actorId === "string" ? entity.actorId.trim() : ""; if (normalizedActorId.length > 0) { const previousPath = seenNpcActorIds.get(normalizedActorId); if (previousPath !== undefined) { diagnostics.push( createDiagnostic( "error", "duplicate-npc-actor-id", `NPC actorId ${normalizedActorId} is already used by ${previousPath}.`, `${path}.actorId` ) ); } else { seenNpcActorIds.set(normalizedActorId, path); } } break; } case "soundEmitter": validateSoundEmitterEntity(entity, path, document, diagnostics); break; case "triggerVolume": validateTriggerVolumeEntity(entity, path, diagnostics); break; case "teleportTarget": validateTeleportTargetEntity(entity, path, diagnostics); break; case "interactable": validateInteractableEntity(entity, path, diagnostics); break; default: diagnostics.push( createDiagnostic( "error", "unsupported-entity-kind", `Unsupported entity kind ${(entity as { kind: string }).kind}.`, `${path}.kind` ) ); break; } } for (const [linkKey, link] of Object.entries(document.interactionLinks)) { const path = `interactionLinks.${linkKey}`; if (link.id !== linkKey) { diagnostics.push( createDiagnostic( "error", "interaction-link-id-mismatch", "Interaction link ids must match their registry key.", `${path}.id` ) ); } registerAuthoredId(link.id, path, seenIds, diagnostics); validateInteractionLink(link, path, document, diagnostics); } validateProjectScheduler( document.scheduler, document.sequences, projectSchedulerValidationContext, diagnostics ); return { diagnostics, errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), warnings: diagnostics.filter( (diagnostic) => diagnostic.severity === "warning" ) }; } export function assertSceneDocumentIsValid(document: SceneDocument) { const validation = validateSceneDocument(document); if (validation.errors.length > 0) { throw new Error( `Scene document has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}` ); } } export function validateProjectDocument( document: ProjectDocument ): SceneDocumentValidationResult { const diagnostics: SceneDiagnostic[] = []; const projectSchedulerValidationContext = createProjectSchedulerValidationContextFromProjectDocument(document); if (document.name.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-project-name", "Project names must be non-empty.", "name" ) ); } if (Object.keys(document.scenes).length === 0) { diagnostics.push( createDiagnostic( "error", "missing-project-scenes", "Project documents must contain at least one scene.", "scenes" ) ); } if (document.scenes[document.activeSceneId] === undefined) { diagnostics.push( createDiagnostic( "error", "missing-active-scene", `Project active scene ${document.activeSceneId} does not exist.`, "activeSceneId" ) ); } validateProjectTimeSettings(document.time, diagnostics); validateProjectResources( document, projectSchedulerValidationContext, diagnostics ); validateProjectScheduler( document.scheduler, document.sequences, projectSchedulerValidationContext, diagnostics ); for (const [sceneKey, scene] of Object.entries(document.scenes)) { const scenePath = `scenes.${sceneKey}`; if (scene.id !== sceneKey) { diagnostics.push( createDiagnostic( "error", "scene-id-mismatch", "Scene ids must match their registry key.", `${scenePath}.id` ) ); } if (scene.name.trim().length === 0) { diagnostics.push( createDiagnostic( "error", "invalid-scene-name", "Scene names must be non-empty.", `${scenePath}.name` ) ); } validateProjectSceneLoadingScreen(scene, scenePath, diagnostics); validateProjectSceneEditorPreferences(scene, scenePath, diagnostics); const sceneDocument = { ...createSceneDocumentFromProject(document, sceneKey), scheduler: createEmptyProjectScheduler() }; for (const diagnostic of filterProjectSceneDiagnostics( validateSceneDocument(sceneDocument).diagnostics )) { diagnostics.push({ ...diagnostic, path: prefixDiagnosticPath(`${scenePath}.`, diagnostic.path) }); } } return { diagnostics, errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"), warnings: diagnostics.filter( (diagnostic) => diagnostic.severity === "warning" ) }; } export function assertProjectDocumentIsValid(document: ProjectDocument) { const validation = validateProjectDocument(document); if (validation.errors.length > 0) { throw new Error( `Project document has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}` ); } }