2026-03-31 17:31:48 +02:00
|
|
|
import {
|
|
|
|
|
type AudioAssetMetadata,
|
|
|
|
|
type ImageAssetMetadata,
|
|
|
|
|
type ModelAssetMetadata,
|
|
|
|
|
type ProjectAssetBoundingBox,
|
2026-03-31 17:32:26 +02:00
|
|
|
type ProjectAssetRecord
|
2026-03-31 17:31:48 +02:00
|
|
|
} from "../assets/project-assets";
|
2026-03-31 17:32:26 +02:00
|
|
|
import type { ModelInstance } from "../assets/model-instances";
|
2026-04-04 07:51:38 +02:00
|
|
|
import { isModelInstanceCollisionMode } from "../assets/model-instances";
|
2026-04-14 01:34:07 +02:00
|
|
|
import {
|
2026-04-23 02:37:02 +02:00
|
|
|
type CameraRigControlTargetRef,
|
2026-04-14 01:54:57 +02:00
|
|
|
type ActorControlTargetRef,
|
2026-04-14 13:42:31 +02:00
|
|
|
getControlEffectResolutionKey,
|
|
|
|
|
isActorPathProgressMode,
|
2026-04-14 01:34:07 +02:00
|
|
|
type ControlEffect,
|
2026-04-14 02:57:33 +02:00
|
|
|
type ControlTargetRef,
|
2026-04-14 01:34:07 +02:00
|
|
|
type InteractionControlTargetRef,
|
2026-04-14 02:38:57 +02:00
|
|
|
type LightControlTargetRef,
|
|
|
|
|
type ModelInstanceControlTargetRef,
|
|
|
|
|
type SceneControlTargetRef,
|
2026-04-14 02:57:33 +02:00
|
|
|
type SoundEmitterControlTargetRef,
|
|
|
|
|
getControlTargetRefKey
|
2026-04-14 01:34:07 +02:00
|
|
|
} from "../controls/control-surface";
|
2026-04-11 14:25:42 +02:00
|
|
|
import { WHITEBOX_SELECTION_MODES } from "../core/whitebox-selection-mode";
|
2026-03-31 05:50:40 +02:00
|
|
|
import {
|
2026-04-23 08:51:25 +02:00
|
|
|
isCameraRigRailPlacementMode,
|
2026-04-22 16:58:28 +02:00
|
|
|
isCameraRigTransitionMode,
|
2026-04-13 23:48:24 +02:00
|
|
|
isNpcPresenceMode,
|
2026-04-04 15:52:27 +02:00
|
|
|
isPlayerStartColliderMode,
|
2026-04-11 18:26:49 +02:00
|
|
|
isPlayerStartGamepadActionBinding,
|
2026-04-11 12:31:30 +02:00
|
|
|
isPlayerStartGamepadCameraLookBinding,
|
2026-04-11 12:13:59 +02:00
|
|
|
isPlayerStartGamepadBinding,
|
|
|
|
|
isPlayerStartKeyboardBindingCode,
|
2026-04-11 17:59:10 +02:00
|
|
|
isPlayerStartMovementTemplateKind,
|
2026-04-11 11:14:24 +02:00
|
|
|
isPlayerStartNavigationMode,
|
2026-04-13 17:10:54 +02:00
|
|
|
getNpcColliderHeight,
|
2026-04-04 15:52:27 +02:00
|
|
|
getPlayerStartColliderHeight,
|
2026-04-22 16:58:28 +02:00
|
|
|
type CameraRigEntity,
|
|
|
|
|
type CameraRigTargetRef,
|
2026-04-12 03:56:02 +02:00
|
|
|
type EntityInstance,
|
2026-03-31 05:50:40 +02:00
|
|
|
type InteractableEntity,
|
2026-04-13 17:10:54 +02:00
|
|
|
type CharacterColliderSettings,
|
2026-04-13 23:48:24 +02:00
|
|
|
type NpcPresence,
|
2026-04-13 16:17:50 +02:00
|
|
|
type NpcEntity,
|
2026-03-31 19:57:42 +02:00
|
|
|
type PointLightEntity,
|
2026-03-31 05:50:40 +02:00
|
|
|
type PlayerStartEntity,
|
2026-04-11 04:33:43 +02:00
|
|
|
type SceneEntryEntity,
|
2026-03-31 05:50:40 +02:00
|
|
|
type SoundEmitterEntity,
|
2026-03-31 19:57:42 +02:00
|
|
|
type SpotLightEntity,
|
2026-03-31 05:50:40 +02:00
|
|
|
type TeleportTargetEntity,
|
|
|
|
|
type TriggerVolumeEntity
|
|
|
|
|
} from "../entities/entity-instances";
|
2026-03-31 06:16:17 +02:00
|
|
|
import { type InteractionLink } from "../interactions/interaction-links";
|
2026-04-07 06:30:14 +02:00
|
|
|
import {
|
|
|
|
|
MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT,
|
|
|
|
|
hasPositiveBoxSize,
|
2026-04-22 13:57:35 +02:00
|
|
|
isBoxBrushLightFalloffMode,
|
2026-04-15 09:12:01 +02:00
|
|
|
isBoxBrushVolumeMode,
|
|
|
|
|
normalizeConeSideCount,
|
|
|
|
|
normalizeRadialPrismSideCount,
|
|
|
|
|
normalizeTorusMajorSegmentCount,
|
|
|
|
|
normalizeTorusTubeSegmentCount
|
2026-04-07 06:30:14 +02:00
|
|
|
} from "./brushes";
|
2026-04-22 15:30:37 +02:00
|
|
|
import {
|
|
|
|
|
getBrushFaceIds,
|
|
|
|
|
getBrushVertexIds
|
|
|
|
|
} from "../geometry/whitebox-topology";
|
2026-04-11 03:47:57 +02:00
|
|
|
import {
|
|
|
|
|
createSceneDocumentFromProject,
|
|
|
|
|
type ProjectDocument,
|
|
|
|
|
type SceneDocument
|
|
|
|
|
} from "./scene-document";
|
2026-04-12 04:31:52 +02:00
|
|
|
import {
|
|
|
|
|
HOURS_PER_DAY,
|
|
|
|
|
type ProjectTimeSettings
|
|
|
|
|
} from "./project-time-settings";
|
2026-04-14 01:40:15 +02:00
|
|
|
import { MIN_SCENE_PATH_POINT_COUNT, type ScenePath } from "./paths";
|
2026-04-20 02:36:38 +02:00
|
|
|
import {
|
|
|
|
|
MIN_TERRAIN_SAMPLE_COUNT,
|
|
|
|
|
TERRAIN_LAYER_COUNT,
|
|
|
|
|
type Terrain
|
|
|
|
|
} from "./terrains";
|
2026-04-02 20:50:03 +02:00
|
|
|
import {
|
2026-04-07 06:30:14 +02:00
|
|
|
isAdvancedRenderingWaterReflectionMode,
|
2026-04-02 20:50:03 +02:00
|
|
|
isAdvancedRenderingShadowMapSize,
|
|
|
|
|
isAdvancedRenderingShadowType,
|
2026-04-06 08:19:20 +02:00
|
|
|
isBoxVolumeRenderPath,
|
2026-04-02 20:50:03 +02:00
|
|
|
isAdvancedRenderingToneMappingMode,
|
|
|
|
|
isHexColorString,
|
2026-04-22 15:15:23 +02:00
|
|
|
isWorldShaderSkyPresetId,
|
2026-04-22 16:54:07 +02:00
|
|
|
type WorldCelestialOrbitAuthoringSettings,
|
2026-04-13 14:49:26 +02:00
|
|
|
type WorldBackgroundSettings,
|
2026-04-22 15:15:23 +02:00
|
|
|
type WorldShaderSkySettings,
|
2026-04-13 14:49:26 +02:00
|
|
|
type WorldTimePhaseProfile,
|
2026-04-02 20:50:03 +02:00
|
|
|
type WorldSettings
|
|
|
|
|
} from "./world-settings";
|
2026-04-14 01:54:57 +02:00
|
|
|
import {
|
|
|
|
|
createEmptyProjectScheduler,
|
|
|
|
|
isProjectScheduleWeekday,
|
|
|
|
|
type ProjectScheduler
|
|
|
|
|
} from "../scheduler/project-scheduler";
|
2026-04-14 19:56:59 +02:00
|
|
|
import type { ProjectDialogue } from "../dialogues/project-dialogues";
|
2026-04-14 23:34:41 +02:00
|
|
|
import type {
|
|
|
|
|
ProjectSequence,
|
|
|
|
|
ProjectSequenceLibrary
|
|
|
|
|
} from "../sequencer/project-sequences";
|
|
|
|
|
import {
|
|
|
|
|
getHeldSequenceControlEffects,
|
|
|
|
|
getProjectScheduleRoutineHeldSteps,
|
2026-04-15 01:10:27 +02:00
|
|
|
getProjectSequenceImpulseSteps
|
2026-04-14 23:34:41 +02:00
|
|
|
} from "../sequencer/project-sequence-steps";
|
2026-03-31 03:40:57 +02:00
|
|
|
|
|
|
|
|
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[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:41:06 +02:00
|
|
|
export function createDiagnostic(
|
2026-03-31 03:40:57 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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)
|
|
|
|
|
);
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function hasPositiveFiniteVec3(vector: {
|
|
|
|
|
x: unknown;
|
|
|
|
|
y: unknown;
|
|
|
|
|
z: unknown;
|
|
|
|
|
}): vector is { x: number; y: number; z: number } {
|
2026-03-31 05:50:40 +02:00
|
|
|
return isFiniteVec3(vector) && vector.x > 0 && vector.y > 0 && vector.z > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:10:09 +02:00
|
|
|
function isNonNegativeFiniteNumber(value: unknown): value is number {
|
|
|
|
|
return isFiniteNumber(value) && value >= 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:50:40 +02:00
|
|
|
function isPositiveFiniteNumber(value: unknown): value is number {
|
|
|
|
|
return isFiniteNumber(value) && value > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:15:23 +02:00
|
|
|
function isFiniteNumberInRange(
|
|
|
|
|
value: unknown,
|
|
|
|
|
min: number,
|
|
|
|
|
max: number
|
|
|
|
|
): value is number {
|
|
|
|
|
return isFiniteNumber(value) && value >= min && value <= max;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:50:09 +02:00
|
|
|
function isPositiveInteger(value: unknown): value is number {
|
|
|
|
|
return isFiniteNumber(value) && Number.isInteger(value) && value > 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function isPositiveIntegerInRange(
|
|
|
|
|
value: unknown,
|
|
|
|
|
max: number
|
|
|
|
|
): value is number {
|
2026-04-07 06:30:14 +02:00
|
|
|
return isPositiveInteger(value) && value <= max;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 05:50:40 +02:00
|
|
|
function isBoolean(value: unknown): value is boolean {
|
|
|
|
|
return typeof value === "boolean";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function hasNonZeroVectorLength(vector: {
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
z: number;
|
|
|
|
|
}): boolean {
|
2026-03-31 05:10:09 +02:00
|
|
|
return vector.x !== 0 || vector.y !== 0 || vector.z !== 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:49:55 +02:00
|
|
|
function validateWorldBackgroundSettings(
|
|
|
|
|
background: WorldBackgroundSettings,
|
|
|
|
|
document: SceneDocument | ProjectDocument,
|
|
|
|
|
diagnostics: SceneDiagnostic[],
|
|
|
|
|
path: string,
|
2026-04-22 12:28:45 +02:00
|
|
|
label: string,
|
2026-04-22 15:15:57 +02:00
|
|
|
options: { allowEmptyImageAssetId?: boolean; allowShader?: boolean } = {}
|
2026-04-13 14:49:55 +02:00
|
|
|
) {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:15:57 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:49:55 +02:00
|
|
|
if (
|
|
|
|
|
typeof background.assetId !== "string" ||
|
|
|
|
|
background.assetId.trim().length === 0
|
|
|
|
|
) {
|
2026-04-22 12:28:45 +02:00
|
|
|
if (!options.allowEmptyImageAssetId) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
`invalid-${label}-asset-id`,
|
|
|
|
|
`${label} must reference a non-empty image asset id.`,
|
|
|
|
|
`${path}.assetId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-13 14:49:55 +02:00
|
|
|
} 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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:15:57 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:59:11 +02:00
|
|
|
if (!isFiniteNumberInRange(settings.horizonHeight, -0.5, 0.5)) {
|
2026-04-22 15:49:22 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-world-shader-sky-horizon-height",
|
2026-04-22 15:59:11 +02:00
|
|
|
"World shader sky horizon height must stay between -0.5 and 0.5.",
|
2026-04-22 15:49:22 +02:00
|
|
|
`${path}.horizonHeight`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:15:57 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:22:35 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:15:57 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-25 01:31:20 +02:00
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-22 15:15:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:54:07 +02:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateWorldSettings(
|
|
|
|
|
world: WorldSettings,
|
|
|
|
|
document: SceneDocument,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 14:08:11 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 14:01:25 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:50:23 +02:00
|
|
|
validateWorldBackgroundSettings(
|
|
|
|
|
world.background,
|
|
|
|
|
document,
|
|
|
|
|
diagnostics,
|
|
|
|
|
"world.background",
|
|
|
|
|
"world-background"
|
|
|
|
|
);
|
2026-04-22 15:16:11 +02:00
|
|
|
validateWorldShaderSkySettings(
|
|
|
|
|
world.shaderSky,
|
|
|
|
|
diagnostics,
|
|
|
|
|
"world.shaderSky"
|
|
|
|
|
);
|
2026-04-22 16:54:07 +02:00
|
|
|
validateWorldCelestialOrbitSettings(
|
|
|
|
|
world.celestialOrbits,
|
|
|
|
|
diagnostics,
|
|
|
|
|
"world.celestialOrbits"
|
|
|
|
|
);
|
2026-03-31 05:10:09 +02:00
|
|
|
|
|
|
|
|
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(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-world-sun-color",
|
|
|
|
|
"World sun color must use a #RRGGBB color.",
|
|
|
|
|
"world.sunLight.colorHex"
|
|
|
|
|
)
|
2026-03-31 05:10:09 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isFiniteVec3(world.sunLight.direction) ||
|
|
|
|
|
!hasNonZeroVectorLength(world.sunLight.direction)
|
|
|
|
|
) {
|
2026-03-31 05:10:09 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-world-sun-direction",
|
|
|
|
|
"World sun direction must remain finite and must not be the zero vector.",
|
|
|
|
|
"world.sunLight.direction"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-02 20:50:27 +02:00
|
|
|
|
2026-04-13 14:50:23 +02:00
|
|
|
validateWorldTimePhaseProfile(
|
|
|
|
|
world.timeOfDay.dawn,
|
|
|
|
|
diagnostics,
|
2026-04-22 12:28:58 +02:00
|
|
|
document,
|
2026-04-13 14:50:23 +02:00
|
|
|
"world.timeOfDay.dawn",
|
|
|
|
|
"dawn"
|
|
|
|
|
);
|
|
|
|
|
validateWorldTimePhaseProfile(
|
|
|
|
|
world.timeOfDay.dusk,
|
|
|
|
|
diagnostics,
|
2026-04-22 12:28:58 +02:00
|
|
|
document,
|
2026-04-13 14:50:23 +02:00
|
|
|
"world.timeOfDay.dusk",
|
|
|
|
|
"dusk"
|
|
|
|
|
);
|
|
|
|
|
validateWorldBackgroundSettings(
|
|
|
|
|
world.timeOfDay.night.background,
|
|
|
|
|
document,
|
|
|
|
|
diagnostics,
|
|
|
|
|
"world.timeOfDay.night.background",
|
2026-04-22 15:16:11 +02:00
|
|
|
"night-background",
|
|
|
|
|
{
|
|
|
|
|
allowShader: false
|
|
|
|
|
}
|
2026-04-13 14:50:23 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isNonNegativeFiniteNumber(world.timeOfDay.night.ambientIntensityFactor)
|
|
|
|
|
) {
|
2026-04-13 14:50:23 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:50:27 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isNonNegativeFiniteNumber(advancedRendering.ambientOcclusion.intensity)
|
|
|
|
|
) {
|
2026-04-02 20:50:27 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isNonNegativeFiniteNumber(advancedRendering.depthOfField.focusDistance)
|
|
|
|
|
) {
|
2026-04-02 20:50:27 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-06 08:19:20 +02:00
|
|
|
|
2026-04-12 01:03:50 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 08:19:20 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-07 06:30:14 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isAdvancedRenderingWaterReflectionMode(
|
|
|
|
|
advancedRendering.waterReflectionMode
|
|
|
|
|
)
|
|
|
|
|
) {
|
2026-04-07 06:30:14 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-advanced-rendering-water-reflection-mode",
|
|
|
|
|
"Advanced rendering water reflection mode must be none, world, or all.",
|
|
|
|
|
"world.advancedRendering.waterReflectionMode"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-31 05:10:09 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:51:07 +02:00
|
|
|
function validateWorldTimePhaseProfile(
|
|
|
|
|
profile: WorldTimePhaseProfile,
|
2026-04-12 14:08:11 +02:00
|
|
|
diagnostics: SceneDiagnostic[],
|
2026-04-22 12:28:52 +02:00
|
|
|
document: SceneDocument | ProjectDocument,
|
2026-04-12 14:08:11 +02:00
|
|
|
path: string,
|
|
|
|
|
label: string
|
|
|
|
|
) {
|
2026-04-22 12:28:52 +02:00
|
|
|
validateWorldBackgroundSettings(
|
|
|
|
|
profile.background,
|
|
|
|
|
document,
|
|
|
|
|
diagnostics,
|
|
|
|
|
`${path}.background`,
|
2026-04-22 12:28:59 +02:00
|
|
|
`${label}-background`,
|
2026-04-22 12:28:52 +02:00
|
|
|
{
|
2026-04-22 15:16:03 +02:00
|
|
|
allowEmptyImageAssetId: true,
|
|
|
|
|
allowShader: false
|
2026-04-22 12:28:52 +02:00
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-12 14:08:11 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:31:52 +02:00
|
|
|
function validateProjectTimeSettings(
|
|
|
|
|
time: ProjectTimeSettings,
|
|
|
|
|
diagnostics: SceneDiagnostic[],
|
|
|
|
|
path = "time"
|
|
|
|
|
) {
|
2026-04-12 14:08:11 +02:00
|
|
|
if (!isPositiveInteger(time.startDayNumber)) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-project-time-start-day-number",
|
|
|
|
|
"Project start day must be a positive integer.",
|
|
|
|
|
`${path}.startDayNumber`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:31:52 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 14:08:11 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:31:52 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-12 14:08:11 +02:00
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-12 04:31:52 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validatePointLightEntity(
|
|
|
|
|
entity: PointLightEntity,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 03:34:18 +02:00
|
|
|
validateAuthoredEntityState(entity, path, diagnostics);
|
|
|
|
|
|
2026-03-31 19:57:42 +02:00
|
|
|
if (!isFiniteVec3(entity.position)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-point-light-position",
|
|
|
|
|
"Point Light position must remain finite on every axis.",
|
|
|
|
|
`${path}.position`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isHexColorString(entity.colorHex)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-point-light-color",
|
|
|
|
|
"Point Light color must use a #RRGGBB color.",
|
|
|
|
|
`${path}.colorHex`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isNonNegativeFiniteNumber(entity.intensity)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-point-light-intensity",
|
|
|
|
|
"Point Light intensity must remain finite and zero or greater.",
|
|
|
|
|
`${path}.intensity`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isPositiveFiniteNumber(entity.distance)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-point-light-distance",
|
|
|
|
|
"Point Light distance must remain finite and greater than zero.",
|
|
|
|
|
`${path}.distance`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateSpotLightEntity(
|
|
|
|
|
entity: SpotLightEntity,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 03:34:18 +02:00
|
|
|
validateAuthoredEntityState(entity, path, diagnostics);
|
|
|
|
|
|
2026-03-31 19:57:42 +02:00
|
|
|
if (!isFiniteVec3(entity.position)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-spot-light-position",
|
|
|
|
|
"Spot Light position must remain finite on every axis.",
|
|
|
|
|
`${path}.position`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isHexColorString(entity.colorHex)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-spot-light-color",
|
|
|
|
|
"Spot Light color must use a #RRGGBB color.",
|
|
|
|
|
`${path}.colorHex`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isNonNegativeFiniteNumber(entity.intensity)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-spot-light-intensity",
|
|
|
|
|
"Spot Light intensity must remain finite and zero or greater.",
|
|
|
|
|
`${path}.intensity`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isPositiveFiniteNumber(entity.distance)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-spot-light-distance",
|
|
|
|
|
"Spot Light distance must remain finite and greater than zero.",
|
|
|
|
|
`${path}.distance`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 19:57:42 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 16:58:28 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-04-22 18:06:23 +02:00
|
|
|
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;
|
2026-04-22 16:58:28 +02:00
|
|
|
|
2026-04-22 18:06:23 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-23 08:51:25 +02:00
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-22 16:58:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:31:48 +02:00
|
|
|
function validateProjectAssetBoundingBox(
|
|
|
|
|
boundingBox: ProjectAssetBoundingBox | null,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
|
|
|
|
if (boundingBox === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFiniteVec3(boundingBox.min)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-asset-bounding-box-min",
|
|
|
|
|
"Model asset bounding boxes must have finite minimum coordinates.",
|
|
|
|
|
`${path}.min`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFiniteVec3(boundingBox.max)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-asset-bounding-box-max",
|
|
|
|
|
"Model asset bounding boxes must have finite maximum coordinates.",
|
|
|
|
|
`${path}.max`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isFiniteVec3(boundingBox.size) ||
|
|
|
|
|
boundingBox.size.x < 0 ||
|
|
|
|
|
boundingBox.size.y < 0 ||
|
|
|
|
|
boundingBox.size.z < 0
|
|
|
|
|
) {
|
2026-03-31 17:31:48 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-asset-bounding-box-size",
|
|
|
|
|
"Model asset bounding boxes must have finite, zero-or-greater size values.",
|
|
|
|
|
`${path}.size`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateModelAssetMetadata(
|
|
|
|
|
metadata: ModelAssetMetadata,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-03-31 17:31:48 +02:00
|
|
|
if (metadata.format !== "glb" && metadata.format !== "gltf") {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-asset-format",
|
|
|
|
|
"Model asset format must be glb or gltf.",
|
|
|
|
|
`${path}.format`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (metadata.sceneName !== null && metadata.sceneName.trim().length === 0) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-asset-scene-name",
|
|
|
|
|
"Model asset scene names must be non-empty when authored.",
|
|
|
|
|
`${path}.sceneName`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isNonNegativeFiniteNumber(metadata.nodeCount)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-asset-node-count",
|
|
|
|
|
"Model asset node counts must be finite and zero or greater.",
|
|
|
|
|
`${path}.nodeCount`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isNonNegativeFiniteNumber(metadata.meshCount)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-asset-mesh-count",
|
|
|
|
|
"Model asset mesh counts must be finite and zero or greater.",
|
|
|
|
|
`${path}.meshCount`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
validateProjectAssetBoundingBox(
|
|
|
|
|
metadata.boundingBox,
|
|
|
|
|
`${path}.boundingBox`,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateImageAssetMetadata(
|
|
|
|
|
metadata: ImageAssetMetadata,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-03-31 17:31:48 +02:00
|
|
|
if (!isPositiveFiniteNumber(metadata.width)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-image-asset-width",
|
|
|
|
|
"Image asset width must be finite and greater than zero.",
|
|
|
|
|
`${path}.width`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isPositiveFiniteNumber(metadata.height)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-image-asset-height",
|
|
|
|
|
"Image asset height must be finite and greater than zero.",
|
|
|
|
|
`${path}.height`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isBoolean(metadata.hasAlpha)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-image-asset-alpha",
|
|
|
|
|
"Image asset alpha flags must be booleans.",
|
|
|
|
|
`${path}.hasAlpha`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateAudioAssetMetadata(
|
|
|
|
|
metadata: AudioAssetMetadata,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
|
|
|
|
if (
|
|
|
|
|
metadata.durationSeconds !== null &&
|
|
|
|
|
!isNonNegativeFiniteNumber(metadata.durationSeconds)
|
|
|
|
|
) {
|
2026-03-31 17:31:48 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-audio-asset-duration",
|
|
|
|
|
"Audio asset durations must be finite and zero or greater when authored.",
|
|
|
|
|
`${path}.durationSeconds`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
metadata.channelCount !== null &&
|
|
|
|
|
!isPositiveFiniteNumber(metadata.channelCount)
|
|
|
|
|
) {
|
2026-03-31 17:31:48 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-audio-asset-channel-count",
|
|
|
|
|
"Audio asset channel counts must be finite and greater than zero when authored.",
|
|
|
|
|
`${path}.channelCount`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
metadata.sampleRateHz !== null &&
|
|
|
|
|
!isPositiveFiniteNumber(metadata.sampleRateHz)
|
|
|
|
|
) {
|
2026-03-31 17:31:48 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-audio-asset-sample-rate",
|
|
|
|
|
"Audio asset sample rates must be finite and greater than zero when authored.",
|
|
|
|
|
`${path}.sampleRateHz`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateProjectAsset(
|
|
|
|
|
asset: ProjectAssetRecord,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-03-31 17:31:48 +02:00
|
|
|
if (asset.sourceName.trim().length === 0) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-asset-source-name",
|
|
|
|
|
"Asset source names must be non-empty strings.",
|
|
|
|
|
`${path}.sourceName`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (asset.mimeType.trim().length === 0) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-asset-mime-type",
|
|
|
|
|
"Asset mime types must be non-empty strings.",
|
|
|
|
|
`${path}.mimeType`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (asset.storageKey.trim().length === 0) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-asset-storage-key",
|
|
|
|
|
"Asset storage keys must be non-empty strings.",
|
|
|
|
|
`${path}.storageKey`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isPositiveFiniteNumber(asset.byteLength)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-asset-byte-length",
|
|
|
|
|
"Asset byte lengths must be finite and greater than zero.",
|
|
|
|
|
`${path}.byteLength`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (asset.kind) {
|
|
|
|
|
case "model":
|
2026-04-11 04:19:50 +02:00
|
|
|
validateModelAssetMetadata(
|
|
|
|
|
asset.metadata,
|
|
|
|
|
`${path}.metadata`,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
break;
|
|
|
|
|
case "image":
|
2026-04-11 04:19:50 +02:00
|
|
|
validateImageAssetMetadata(
|
|
|
|
|
asset.metadata,
|
|
|
|
|
`${path}.metadata`,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
break;
|
|
|
|
|
case "audio":
|
2026-04-11 04:19:50 +02:00
|
|
|
validateAudioAssetMetadata(
|
|
|
|
|
asset.metadata,
|
|
|
|
|
`${path}.metadata`,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFiniteVec3(modelInstance.position)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-instance-position",
|
|
|
|
|
"Model instance positions must remain finite on every axis.",
|
|
|
|
|
`${path}.position`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFiniteVec3(modelInstance.rotationDegrees)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-instance-rotation",
|
|
|
|
|
"Model instance rotations must remain finite on every axis.",
|
|
|
|
|
`${path}.rotationDegrees`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasPositiveFiniteVec3(modelInstance.scale)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-instance-scale",
|
|
|
|
|
"Model instance scales must remain finite and positive on every axis.",
|
|
|
|
|
`${path}.scale`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 07:51:38 +02:00
|
|
|
if (!isModelInstanceCollisionMode(modelInstance.collision.mode)) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-instance-collision-mode",
|
2026-04-11 16:30:55 +02:00
|
|
|
"Model instance collision mode must be one of none, terrain, static, static-simple, dynamic, or simple.",
|
2026-04-04 07:51:38 +02:00
|
|
|
`${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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 03:34:03 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:31:48 +02:00
|
|
|
const asset = document.assets[modelInstance.assetId];
|
|
|
|
|
|
|
|
|
|
if (asset === undefined) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"missing-model-instance-asset",
|
|
|
|
|
`Model instance asset ${modelInstance.assetId} does not exist.`,
|
|
|
|
|
`${path}.assetId`
|
|
|
|
|
)
|
2026-03-31 17:31:48 +02:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (asset.kind !== "model") {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-model-instance-asset-kind",
|
|
|
|
|
"Model instances may only reference model assets.",
|
|
|
|
|
`${path}.assetId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateEntityName(
|
|
|
|
|
name: string | undefined,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-03 01:05:13 +02:00
|
|
|
if (name !== undefined && name.trim().length === 0) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-entity-name",
|
|
|
|
|
"Entity names must be non-empty when authored.",
|
|
|
|
|
`${path}.name`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-04-03 01:05:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
function validateScenePath(
|
|
|
|
|
pathValue: ScenePath,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-13 21:21:40 +02:00
|
|
|
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<string>();
|
|
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 19:46:51 +02:00
|
|
|
function validateTerrain(
|
|
|
|
|
terrain: Terrain,
|
|
|
|
|
path: string,
|
2026-04-20 02:36:38 +02:00
|
|
|
document: SceneDocument,
|
2026-04-18 19:46:51 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 02:36:38 +02:00
|
|
|
if (!isBoolean(terrain.collisionEnabled)) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-terrain-collision-enabled",
|
|
|
|
|
"Terrain collisionEnabled must remain a boolean.",
|
|
|
|
|
`${path}.collisionEnabled`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 19:46:51 +02:00
|
|
|
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}`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-20 02:36:38 +02:00
|
|
|
|
|
|
|
|
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}`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-18 19:46:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 03:34:03 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validatePlayerStartEntity(
|
|
|
|
|
entity: PlayerStartEntity,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 03:34:03 +02:00
|
|
|
validateAuthoredEntityState(entity, path, diagnostics);
|
|
|
|
|
|
2026-03-31 03:40:57 +02:00
|
|
|
if (!isFiniteVec3(entity.position)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-player-start-position",
|
|
|
|
|
"Player Start position must remain finite on every axis.",
|
|
|
|
|
`${path}.position`
|
|
|
|
|
)
|
2026-03-31 03:40:57 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFiniteNumber(entity.yawDegrees)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-player-start-yaw",
|
|
|
|
|
"Player Start yaw must remain a finite number.",
|
|
|
|
|
`${path}.yawDegrees`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
2026-04-04 15:52:27 +02:00
|
|
|
|
2026-04-11 11:14:24 +02:00
|
|
|
if (!isPlayerStartNavigationMode(entity.navigationMode)) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-player-start-navigation-mode",
|
|
|
|
|
"Player Start navigation mode must be firstPerson or thirdPerson.",
|
|
|
|
|
`${path}.navigationMode`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 23:02:03 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 15:15:42 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 17:59:19 +02:00
|
|
|
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) ||
|
2026-04-11 17:59:33 +02:00
|
|
|
(entity.movementTemplate?.moveSpeed ?? 0) <= 0
|
2026-04-11 17:59:19 +02:00
|
|
|
) {
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 20:30:56 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 20:59:30 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 17:59:33 +02:00
|
|
|
if (typeof entity.movementTemplate?.capabilities?.jump !== "boolean") {
|
2026-04-11 17:59:19 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-player-start-jump-capability",
|
|
|
|
|
"Player Start movement template jump capability must be a boolean.",
|
|
|
|
|
`${path}.movementTemplate.capabilities.jump`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 17:59:33 +02:00
|
|
|
if (typeof entity.movementTemplate?.capabilities?.sprint !== "boolean") {
|
2026-04-11 17:59:19 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-player-start-sprint-capability",
|
|
|
|
|
"Player Start movement template sprint capability must be a boolean.",
|
|
|
|
|
`${path}.movementTemplate.capabilities.sprint`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 17:59:33 +02:00
|
|
|
if (typeof entity.movementTemplate?.capabilities?.crouch !== "boolean") {
|
2026-04-11 17:59:19 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-player-start-crouch-capability",
|
|
|
|
|
"Player Start movement template crouch capability must be a boolean.",
|
|
|
|
|
`${path}.movementTemplate.capabilities.crouch`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 20:17:57 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (!isNonNegativeFiniteNumber(entity.movementTemplate?.jump?.coyoteTimeMs)) {
|
2026-04-11 20:17:57 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 02:06:00 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 02:18:53 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 20:30:56 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 20:17:57 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartKeyboardBindingCode(
|
|
|
|
|
entity.inputBindings?.keyboard.moveForward
|
|
|
|
|
)
|
|
|
|
|
) {
|
2026-04-11 12:13:59 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartKeyboardBindingCode(
|
|
|
|
|
entity.inputBindings?.keyboard.moveBackward
|
|
|
|
|
)
|
|
|
|
|
) {
|
2026-04-11 12:13:59 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveLeft)
|
|
|
|
|
) {
|
2026-04-11 12:13:59 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveRight)
|
|
|
|
|
) {
|
2026-04-11 12:13:59 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 18:26:49 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.sprint)
|
|
|
|
|
) {
|
2026-04-11 18:26:49 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.crouch)
|
|
|
|
|
) {
|
2026-04-11 18:26:49 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 22:39:25 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 12:13:59 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartGamepadBinding(entity.inputBindings?.gamepad.moveBackward)
|
|
|
|
|
) {
|
2026-04-11 12:13:59 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 18:26:49 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.sprint)
|
|
|
|
|
) {
|
2026-04-11 18:26:49 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.crouch)
|
|
|
|
|
) {
|
2026-04-11 18:26:49 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 22:39:25 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 12:31:30 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 15:52:27 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isFiniteNumber(entity.collider.eyeHeight) ||
|
|
|
|
|
entity.collider.eyeHeight <= 0
|
|
|
|
|
) {
|
2026-04-04 15:52:27 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-player-start-eye-height",
|
|
|
|
|
"Player Start eye height must remain a finite number greater than zero.",
|
|
|
|
|
`${path}.collider.eyeHeight`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isFiniteNumber(entity.collider.capsuleRadius) ||
|
|
|
|
|
entity.collider.capsuleRadius <= 0
|
|
|
|
|
) {
|
2026-04-04 15:52:27 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-player-start-capsule-radius",
|
|
|
|
|
"Player Start capsule radius must remain a finite number greater than zero.",
|
|
|
|
|
`${path}.collider.capsuleRadius`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isFiniteNumber(entity.collider.capsuleHeight) ||
|
|
|
|
|
entity.collider.capsuleHeight <= 0
|
|
|
|
|
) {
|
2026-04-04 15:52:27 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:38:00 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateSoundEmitterEntity(
|
|
|
|
|
entity: SoundEmitterEntity,
|
|
|
|
|
path: string,
|
|
|
|
|
document: SceneDocument,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 03:34:03 +02:00
|
|
|
validateAuthoredEntityState(entity, path, diagnostics);
|
|
|
|
|
|
2026-03-31 05:50:40 +02:00
|
|
|
if (!isFiniteVec3(entity.position)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-sound-emitter-position",
|
|
|
|
|
"Sound Emitter position must remain finite on every axis.",
|
|
|
|
|
`${path}.position`
|
|
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 21:19:30 +02:00
|
|
|
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,
|
2026-04-13 17:10:54 +02:00
|
|
|
path,
|
2026-04-13 21:19:30 +02:00
|
|
|
document,
|
2026-04-13 17:10:54 +02:00
|
|
|
diagnostics,
|
2026-04-13 21:19:30 +02:00
|
|
|
entity.autoplay === true ? "error" : "warning"
|
2026-04-13 17:10:54 +02:00
|
|
|
);
|
2026-04-13 21:19:30 +02:00
|
|
|
}
|
2026-04-13 17:10:54 +02:00
|
|
|
|
|
|
|
|
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)) {
|
2026-03-31 05:50:40 +02:00
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-13 17:10:54 +02:00
|
|
|
`invalid-${options.codePrefix}-eye-height`,
|
|
|
|
|
`${options.label} eye height must remain finite and greater than zero.`,
|
|
|
|
|
`${path}.collider.eyeHeight`
|
2026-04-11 04:19:50 +02:00
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:10:54 +02:00
|
|
|
if (!isPositiveFiniteNumber(collider.capsuleRadius)) {
|
2026-03-31 05:50:40 +02:00
|
|
|
diagnostics.push(
|
2026-04-02 19:38:00 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-13 17:10:54 +02:00
|
|
|
`invalid-${options.codePrefix}-capsule-radius`,
|
|
|
|
|
`${options.label} capsule radius must remain finite and greater than zero.`,
|
|
|
|
|
`${path}.collider.capsuleRadius`
|
2026-04-02 19:38:00 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:10:54 +02:00
|
|
|
if (!isPositiveFiniteNumber(collider.capsuleHeight)) {
|
2026-04-02 19:38:00 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-13 17:10:54 +02:00
|
|
|
`invalid-${options.codePrefix}-capsule-height`,
|
|
|
|
|
`${options.label} capsule height must remain finite and greater than zero.`,
|
|
|
|
|
`${path}.collider.capsuleHeight`
|
2026-04-02 19:38:00 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
2026-04-13 17:10:54 +02:00
|
|
|
!isFiniteVec3(collider.boxSize) ||
|
|
|
|
|
collider.boxSize.x <= 0 ||
|
|
|
|
|
collider.boxSize.y <= 0 ||
|
|
|
|
|
collider.boxSize.z <= 0
|
2026-04-11 04:19:50 +02:00
|
|
|
) {
|
2026-04-02 19:38:00 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-13 17:10:54 +02:00
|
|
|
`invalid-${options.codePrefix}-box-size`,
|
|
|
|
|
`${options.label} box size must remain finite and positive on every axis.`,
|
|
|
|
|
`${path}.collider.boxSize`
|
2026-04-02 19:38:00 +02:00
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:10:54 +02:00
|
|
|
if (collider.capsuleHeight < collider.capsuleRadius * 2) {
|
2026-03-31 05:50:40 +02:00
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-13 17:10:54 +02:00
|
|
|
`invalid-${options.codePrefix}-capsule-proportions`,
|
|
|
|
|
`${options.label} capsule height must be at least twice the capsule radius.`,
|
|
|
|
|
`${path}.collider.capsuleHeight`
|
2026-04-11 04:19:50 +02:00
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:10:54 +02:00
|
|
|
const colliderHeight = options.getHeight(collider);
|
|
|
|
|
|
|
|
|
|
if (colliderHeight !== null && collider.eyeHeight > colliderHeight) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-13 17:10:54 +02:00
|
|
|
`invalid-${options.codePrefix}-eye-height`,
|
|
|
|
|
`${options.label} eye height must fit within the authored collider height.`,
|
|
|
|
|
`${path}.collider.eyeHeight`
|
2026-04-11 04:19:50 +02:00
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 05:50:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-13 21:19:30 +02:00
|
|
|
|
|
|
|
|
function validateTriggerVolumeEntity(
|
|
|
|
|
entity: TriggerVolumeEntity,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 03:34:03 +02:00
|
|
|
validateAuthoredEntityState(entity, path, diagnostics);
|
|
|
|
|
|
2026-03-31 05:50:40 +02:00
|
|
|
if (!isFiniteVec3(entity.position)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-trigger-volume-position",
|
|
|
|
|
"Trigger Volume position must remain finite on every axis.",
|
|
|
|
|
`${path}.position`
|
|
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateTeleportTargetEntity(
|
|
|
|
|
entity: TeleportTargetEntity,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 03:34:03 +02:00
|
|
|
validateAuthoredEntityState(entity, path, diagnostics);
|
|
|
|
|
|
2026-03-31 05:50:40 +02:00
|
|
|
if (!isFiniteVec3(entity.position)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-teleport-target-position",
|
|
|
|
|
"Teleport Target position must remain finite on every axis.",
|
|
|
|
|
`${path}.position`
|
|
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFiniteNumber(entity.yawDegrees)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-teleport-target-yaw",
|
|
|
|
|
"Teleport Target yaw must remain a finite number.",
|
|
|
|
|
`${path}.yawDegrees`
|
|
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateInteractableEntity(
|
|
|
|
|
entity: InteractableEntity,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 03:34:03 +02:00
|
|
|
validateAuthoredEntityState(entity, path, diagnostics);
|
|
|
|
|
|
2026-03-31 05:50:40 +02:00
|
|
|
if (!isFiniteVec3(entity.position)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-interactable-position",
|
|
|
|
|
"Interactable position must remain finite on every axis.",
|
|
|
|
|
`${path}.position`
|
|
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isPositiveFiniteNumber(entity.radius)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-interactable-radius",
|
|
|
|
|
"Interactable radius must remain finite and greater than zero.",
|
|
|
|
|
`${path}.radius`
|
|
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeof entity.prompt !== "string" || entity.prompt.trim().length === 0) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-interactable-prompt",
|
|
|
|
|
"Interactable prompt must remain a non-empty string.",
|
|
|
|
|
`${path}.prompt`
|
|
|
|
|
)
|
2026-03-31 05:50:40 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 03:34:03 +02:00
|
|
|
if (!isBoolean(entity.interactionEnabled)) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-12 03:34:03 +02:00
|
|
|
"invalid-interactable-interaction-enabled",
|
|
|
|
|
"Interactable interactionEnabled must remain a boolean.",
|
|
|
|
|
`${path}.interactionEnabled`
|
2026-04-11 04:19:50 +02:00
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 05:50:40 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:33:43 +02:00
|
|
|
function validateSceneEntryEntity(
|
|
|
|
|
entity: SceneEntryEntity,
|
|
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-12 03:34:03 +02:00
|
|
|
validateAuthoredEntityState(entity, path, diagnostics);
|
|
|
|
|
|
2026-04-11 04:33:43 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 16:17:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:48:24 +02:00
|
|
|
function validateNpcPresence(
|
2026-04-13 23:48:32 +02:00
|
|
|
presence: NpcPresence | undefined,
|
2026-04-13 23:48:24 +02:00
|
|
|
path: string,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-13 23:48:32 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:48:24 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 16:17:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
typeof entity.actorId !== "string" ||
|
|
|
|
|
entity.actorId.trim().length === 0
|
|
|
|
|
) {
|
2026-04-13 16:17:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-npc-actor-id",
|
|
|
|
|
"NPC actorId must remain a non-empty string.",
|
|
|
|
|
`${path}.actorId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:48:24 +02:00
|
|
|
validateNpcPresence(entity.presence, `${path}.presence`, diagnostics);
|
2026-04-13 16:17:50 +02:00
|
|
|
validateNpcModelAssetId(entity, path, document, diagnostics);
|
2026-04-15 09:19:50 +02:00
|
|
|
const seenNpcDialogueIds = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
for (const [dialogueIndex, dialogue] of entity.dialogues.entries()) {
|
|
|
|
|
const dialoguePath = `${path}.dialogues.${dialogueIndex}`;
|
2026-04-22 15:30:37 +02:00
|
|
|
registerAuthoredId(
|
|
|
|
|
dialogue.id,
|
|
|
|
|
dialoguePath,
|
|
|
|
|
seenNpcDialogueIds,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-15 09:19:50 +02:00
|
|
|
validateProjectDialogue(
|
|
|
|
|
dialogue,
|
|
|
|
|
dialoguePath,
|
|
|
|
|
seenNpcDialogueIds,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 20:32:05 +02:00
|
|
|
if (
|
2026-04-15 09:19:50 +02:00
|
|
|
entity.defaultDialogueId !== null &&
|
2026-04-22 15:30:37 +02:00
|
|
|
!entity.dialogues.some(
|
|
|
|
|
(dialogue) => dialogue.id === entity.defaultDialogueId
|
|
|
|
|
)
|
2026-04-14 20:32:05 +02:00
|
|
|
) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-15 09:19:50 +02:00
|
|
|
"missing-npc-default-dialogue",
|
|
|
|
|
`NPC default dialogue ${entity.defaultDialogueId} does not exist on this NPC.`,
|
|
|
|
|
`${path}.defaultDialogueId`
|
2026-04-14 20:32:05 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-13 17:10:54 +02:00
|
|
|
validateCharacterColliderSettings(entity.collider, path, diagnostics, {
|
|
|
|
|
codePrefix: "npc",
|
|
|
|
|
label: "NPC",
|
|
|
|
|
getHeight: getNpcColliderHeight
|
|
|
|
|
});
|
2026-04-13 16:17:50 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:34:13 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:37:02 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 01:37:00 +02:00
|
|
|
if (
|
|
|
|
|
targetEntity.kind !== target.entityKind ||
|
|
|
|
|
targetEntity.kind !== "cameraRig"
|
|
|
|
|
) {
|
2026-04-23 02:37:02 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:55:10 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:34:13 +02:00
|
|
|
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 ||
|
2026-04-15 02:07:03 +02:00
|
|
|
targetEntity.kind !== "interactable"
|
2026-04-14 01:34:13 +02:00
|
|
|
) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-control-interaction-target-kind",
|
2026-04-15 02:07:03 +02:00
|
|
|
"Interaction control effects must target an Interactable entity of the authored target kind.",
|
2026-04-14 01:34:13 +02:00
|
|
|
`${path}.interactionKind`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:39:03 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
if (
|
|
|
|
|
targetEntity.kind !== target.entityKind ||
|
|
|
|
|
targetEntity.kind !== "soundEmitter"
|
|
|
|
|
) {
|
2026-04-14 02:39:03 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:34:13 +02:00
|
|
|
function validateControlEffect(
|
|
|
|
|
effect: ControlEffect,
|
|
|
|
|
path: string,
|
|
|
|
|
document: SceneDocument,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
|
|
|
|
switch (effect.type) {
|
2026-04-14 01:55:14 +02:00
|
|
|
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;
|
2026-04-23 02:37:02 +02:00
|
|
|
case "activateCameraRigOverride":
|
|
|
|
|
case "clearCameraRigOverride":
|
|
|
|
|
validateCameraRigControlTarget(
|
|
|
|
|
effect.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
document,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
|
|
|
|
return;
|
2026-04-14 13:43:01 +02:00
|
|
|
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;
|
2026-04-14 01:34:13 +02:00
|
|
|
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":
|
2026-04-14 02:39:22 +02:00
|
|
|
validateModelInstanceControlTarget(
|
|
|
|
|
effect.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
document,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
case "setModelInstanceVisible":
|
|
|
|
|
validateModelInstanceControlTarget(
|
|
|
|
|
effect.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
document,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
|
|
|
|
if (!isBoolean(effect.visible)) {
|
2026-04-14 01:34:13 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-14 02:39:22 +02:00
|
|
|
"invalid-control-model-instance-visible",
|
|
|
|
|
"Model visibility control values must remain boolean.",
|
|
|
|
|
`${path}.visible`
|
2026-04-14 01:34:13 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
case "playSound":
|
2026-04-14 02:39:22 +02:00
|
|
|
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)) {
|
2026-04-14 01:34:13 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-14 02:39:22 +02:00
|
|
|
"invalid-control-sound-volume",
|
|
|
|
|
"Sound control volume must remain finite and zero or greater.",
|
|
|
|
|
`${path}.volume`
|
2026-04-14 01:34:13 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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;
|
2026-04-14 02:39:22 +02:00
|
|
|
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;
|
2026-04-14 01:34:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function validateInteractionLink(
|
|
|
|
|
link: InteractionLink,
|
|
|
|
|
path: string,
|
|
|
|
|
document: SceneDocument,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-03-31 06:16:17 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
sourceEntity.kind !== "triggerVolume" &&
|
2026-04-15 10:59:16 +02:00
|
|
|
sourceEntity.kind !== "interactable" &&
|
|
|
|
|
sourceEntity.kind !== "npc"
|
2026-04-11 04:19:50 +02:00
|
|
|
) {
|
2026-03-31 06:16:17 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-interaction-source-kind",
|
2026-04-15 10:59:16 +02:00
|
|
|
"Interaction links may only source from Trigger Volume, Interactable, or NPC entities in the current slice.",
|
2026-03-31 06:16:17 +02:00
|
|
|
`${path}.sourceEntityId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:45:43 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-15 10:59:16 +02:00
|
|
|
} 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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-31 06:16:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
link.action.visible !== undefined &&
|
|
|
|
|
typeof link.action.visible !== "boolean"
|
|
|
|
|
) {
|
2026-03-31 06:16:17 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-visibility-action-visible",
|
|
|
|
|
"Visibility actions must use a boolean visible value when authored.",
|
|
|
|
|
`${path}.action.visible`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
break;
|
2026-04-11 04:19:50 +02:00
|
|
|
case "playAnimation": {
|
|
|
|
|
const targetModelInstance =
|
|
|
|
|
document.modelInstances[link.action.targetModelInstanceId];
|
2026-04-02 19:19:15 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-02 19:19:15 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-02 19:19:15 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
const targetAsset = document.assets[targetModelInstance.assetId];
|
2026-04-02 19:19:15 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (targetAsset === undefined || targetAsset.kind !== "model") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-02 19:19:15 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-04-01 00:12:38 +02:00
|
|
|
}
|
2026-04-11 04:19:50 +02:00
|
|
|
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-04-01 00:12:38 +02:00
|
|
|
case "stopAnimation":
|
|
|
|
|
// Validate that the target model instance exists in the document
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
document.modelInstances[link.action.targetModelInstanceId] === undefined
|
|
|
|
|
) {
|
2026-04-01 00:12:38 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"missing-stop-animation-target-instance",
|
|
|
|
|
`Stop animation target model instance ${link.action.targetModelInstanceId} does not exist.`,
|
|
|
|
|
`${path}.action.targetModelInstanceId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
break;
|
2026-04-02 19:38:16 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-14 23:36:19 +02:00
|
|
|
case "runSequence": {
|
2026-04-22 15:30:37 +02:00
|
|
|
const sequence =
|
|
|
|
|
document.sequences.sequences[link.action.sequenceId] ?? null;
|
2026-04-14 23:36:19 +02:00
|
|
|
|
|
|
|
|
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",
|
2026-04-15 07:24:02 +02:00
|
|
|
"Interaction link sequences must include at least one start effect.",
|
2026-04-14 23:36:19 +02:00
|
|
|
`${path}.action.sequenceId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-04-14 01:34:07 +02:00
|
|
|
case "control":
|
|
|
|
|
validateControlEffect(
|
|
|
|
|
link.action.effect,
|
|
|
|
|
`${path}.action.effect`,
|
|
|
|
|
document,
|
|
|
|
|
diagnostics
|
2026-03-31 06:16:17 +02:00
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:55:55 +02:00
|
|
|
function validateProjectScheduler(
|
2026-04-14 01:55:32 +02:00
|
|
|
scheduler: ProjectScheduler,
|
2026-04-14 23:36:42 +02:00
|
|
|
sequences: ProjectSequenceLibrary,
|
2026-04-14 02:58:52 +02:00
|
|
|
context: ProjectSchedulerValidationContext,
|
2026-04-14 01:55:32 +02:00
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
|
|
|
|
for (const [routineKey, routine] of Object.entries(scheduler.routines)) {
|
|
|
|
|
const path = `scheduler.routines.${routineKey}`;
|
2026-04-14 23:36:42 +02:00
|
|
|
const resolvedHeldSteps = getProjectScheduleRoutineHeldSteps(
|
|
|
|
|
routine,
|
|
|
|
|
sequences
|
|
|
|
|
);
|
|
|
|
|
const resolvedEffects = getHeldSequenceControlEffects(resolvedHeldSteps);
|
2026-04-14 01:55:32 +02:00
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:58:52 +02:00
|
|
|
validateProjectSchedulerControlTarget(
|
|
|
|
|
routine.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
context,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 01:55:32 +02:00
|
|
|
|
2026-04-14 23:36:42 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:55:32 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
if (
|
|
|
|
|
!isFiniteNumber(routine.priority) ||
|
|
|
|
|
!Number.isInteger(routine.priority)
|
|
|
|
|
) {
|
2026-04-14 01:55:32 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-project-schedule-priority",
|
|
|
|
|
"Project schedule priorities must remain finite integers.",
|
|
|
|
|
`${path}.priority`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 02:07:44 +02:00
|
|
|
const resolvedImpulseEffects =
|
|
|
|
|
routine.sequenceId === null
|
|
|
|
|
? []
|
|
|
|
|
: getProjectSequenceImpulseSteps(
|
2026-04-22 15:30:37 +02:00
|
|
|
sequences.sequences[routine.sequenceId] ?? {
|
|
|
|
|
id: "",
|
|
|
|
|
title: "",
|
|
|
|
|
effects: []
|
|
|
|
|
}
|
2026-04-15 02:07:44 +02:00
|
|
|
);
|
|
|
|
|
|
2026-04-14 23:36:42 +02:00
|
|
|
if (resolvedEffects.length === 0) {
|
2026-04-15 02:07:44 +02:00
|
|
|
if (
|
|
|
|
|
routine.target.kind === "global" &&
|
|
|
|
|
resolvedImpulseEffects.length > 0
|
|
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 07:24:02 +02:00
|
|
|
if (routine.target.kind === "actor") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:43:11 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-project-schedule-routine-effects-empty",
|
2026-04-15 07:24:02 +02:00
|
|
|
"Project sequencer placements must resolve at least one timeline control effect unless they only use start effects.",
|
2026-04-14 23:36:42 +02:00
|
|
|
routine.sequenceId === null ? `${path}.effects` : `${path}.sequenceId`
|
2026-04-14 13:43:11 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 23:36:42 +02:00
|
|
|
if (routine.target.kind !== "actor" && resolvedEffects.length !== 1) {
|
2026-04-14 13:43:11 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-project-schedule-non-actor-effect-count",
|
2026-04-15 07:24:02 +02:00
|
|
|
"Non-actor sequencer placements must currently resolve exactly one timeline control effect.",
|
2026-04-14 23:36:42 +02:00
|
|
|
routine.sequenceId === null ? `${path}.effects` : `${path}.sequenceId`
|
2026-04-14 13:43:11 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const seenResolutionKeys = new Set<string>();
|
|
|
|
|
|
2026-04-14 23:36:42 +02:00
|
|
|
for (const [effectIndex, effect] of resolvedEffects.entries()) {
|
|
|
|
|
const effectPath =
|
|
|
|
|
routine.sequenceId === null
|
|
|
|
|
? `${path}.effects.${effectIndex}`
|
|
|
|
|
: `${path}.sequenceId`;
|
|
|
|
|
|
2026-04-14 13:43:11 +02:00
|
|
|
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.",
|
2026-04-14 23:36:42 +02:00
|
|
|
`${effectPath}.target`
|
2026-04-14 13:43:11 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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.",
|
2026-04-14 23:36:42 +02:00
|
|
|
effectPath
|
2026-04-14 13:43:11 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
seenResolutionKeys.add(resolutionKey);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 23:36:42 +02:00
|
|
|
if (routine.sequenceId === null) {
|
|
|
|
|
validateProjectSchedulerEffect(
|
|
|
|
|
effect,
|
|
|
|
|
effectPath,
|
|
|
|
|
context,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-14 13:43:11 +02:00
|
|
|
}
|
2026-04-14 02:58:52 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProjectSchedulerValidationContext {
|
|
|
|
|
actorIds: Set<string>;
|
2026-04-14 13:42:43 +02:00
|
|
|
actorUsagesById: Map<string, ProjectSchedulerActorValidationUsage[]>;
|
2026-04-15 01:44:50 +02:00
|
|
|
brushCounts: Map<string, number>;
|
2026-04-14 02:58:52 +02:00
|
|
|
entityCounts: Map<string, number>;
|
|
|
|
|
entityKindsById: Map<string, Set<EntityInstance["kind"]>>;
|
|
|
|
|
modelInstanceCounts: Map<string, number>;
|
|
|
|
|
modelAnimationNamesById: Map<string, string[]>;
|
|
|
|
|
soundEmitterHasAudioById: Map<string, boolean>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:42:43 +02:00
|
|
|
interface ProjectSchedulerActorValidationUsage {
|
|
|
|
|
sceneId: string;
|
|
|
|
|
entityId: string;
|
|
|
|
|
modelAssetId: string | null;
|
|
|
|
|
animationNames: string[];
|
|
|
|
|
pathIds: Set<string>;
|
|
|
|
|
enabledPathIds: Set<string>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:58:52 +02:00
|
|
|
function incrementSchedulerValidationCount(
|
|
|
|
|
counts: Map<string, number>,
|
|
|
|
|
id: string
|
|
|
|
|
) {
|
|
|
|
|
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addSchedulerValidationKind<TKey extends string, TValue extends string>(
|
|
|
|
|
collection: Map<TKey, Set<TValue>>,
|
|
|
|
|
key: TKey,
|
|
|
|
|
value: TValue
|
|
|
|
|
) {
|
|
|
|
|
const values = collection.get(key) ?? new Set<TValue>();
|
|
|
|
|
values.add(value);
|
|
|
|
|
collection.set(key, values);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createEmptyProjectSchedulerValidationContext(): ProjectSchedulerValidationContext {
|
|
|
|
|
return {
|
|
|
|
|
actorIds: new Set<string>(),
|
2026-04-14 13:42:43 +02:00
|
|
|
actorUsagesById: new Map<string, ProjectSchedulerActorValidationUsage[]>(),
|
2026-04-15 01:44:50 +02:00
|
|
|
brushCounts: new Map<string, number>(),
|
2026-04-14 02:58:52 +02:00
|
|
|
entityCounts: new Map<string, number>(),
|
|
|
|
|
entityKindsById: new Map<string, Set<EntityInstance["kind"]>>(),
|
|
|
|
|
modelInstanceCounts: new Map<string, number>(),
|
|
|
|
|
modelAnimationNamesById: new Map<string, string[]>(),
|
|
|
|
|
soundEmitterHasAudioById: new Map<string, boolean>()
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function recordProjectSchedulerSceneTargets(
|
|
|
|
|
context: ProjectSchedulerValidationContext,
|
2026-04-15 01:44:50 +02:00
|
|
|
scene: Pick<
|
|
|
|
|
SceneDocument,
|
|
|
|
|
"brushes" | "entities" | "modelInstances" | "assets" | "paths"
|
|
|
|
|
>,
|
2026-04-14 13:42:43 +02:00
|
|
|
sceneId: string
|
2026-04-14 02:58:52 +02:00
|
|
|
) {
|
2026-04-14 13:42:43 +02:00
|
|
|
const pathIds = new Set(Object.keys(scene.paths));
|
|
|
|
|
const enabledPathIds = new Set(
|
|
|
|
|
Object.values(scene.paths)
|
|
|
|
|
.filter((path) => path.enabled)
|
|
|
|
|
.map((path) => path.id)
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-15 01:44:50 +02:00
|
|
|
for (const brush of Object.values(scene.brushes)) {
|
|
|
|
|
incrementSchedulerValidationCount(context.brushCounts, brush.id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:58:52 +02:00
|
|
|
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);
|
2026-04-14 13:42:43 +02:00
|
|
|
const usages = context.actorUsagesById.get(entity.actorId) ?? [];
|
|
|
|
|
const targetAsset =
|
2026-04-22 15:30:37 +02:00
|
|
|
entity.modelAssetId === null
|
|
|
|
|
? undefined
|
|
|
|
|
: scene.assets[entity.modelAssetId];
|
2026-04-14 13:42:43 +02:00
|
|
|
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);
|
2026-04-14 02:58:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entity.kind === "soundEmitter") {
|
2026-04-22 15:30:37 +02:00
|
|
|
context.soundEmitterHasAudioById.set(
|
|
|
|
|
entity.id,
|
|
|
|
|
entity.audioAssetId !== null
|
|
|
|
|
);
|
2026-04-14 02:58:52 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-04-14 13:42:43 +02:00
|
|
|
recordProjectSchedulerSceneTargets(context, document, "activeScene");
|
2026-04-14 02:58:52 +02:00
|
|
|
return context;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createProjectSchedulerValidationContextFromProjectDocument(
|
|
|
|
|
document: ProjectDocument
|
|
|
|
|
): ProjectSchedulerValidationContext {
|
|
|
|
|
const context = createEmptyProjectSchedulerValidationContext();
|
|
|
|
|
|
|
|
|
|
for (const scene of Object.values(document.scenes)) {
|
2026-04-22 15:30:37 +02:00
|
|
|
recordProjectSchedulerSceneTargets(
|
|
|
|
|
context,
|
|
|
|
|
{
|
|
|
|
|
brushes: scene.brushes,
|
|
|
|
|
entities: scene.entities,
|
|
|
|
|
modelInstances: scene.modelInstances,
|
|
|
|
|
assets: document.assets,
|
|
|
|
|
paths: scene.paths
|
|
|
|
|
},
|
|
|
|
|
scene.id
|
|
|
|
|
);
|
2026-04-14 02:58:52 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:51:51 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
const usages = getProjectSchedulerActorValidationUsages(
|
|
|
|
|
context,
|
|
|
|
|
target.actorId
|
|
|
|
|
);
|
2026-04-14 13:51:51 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:58:52 +02:00
|
|
|
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"
|
2026-04-23 02:37:02 +02:00
|
|
|
: 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.`,
|
2026-04-14 02:58:52 +02:00
|
|
|
`${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"
|
2026-04-23 02:37:02 +02:00
|
|
|
: target.entityKind === "cameraRig"
|
|
|
|
|
? "invalid-control-camera-rig-target-kind"
|
|
|
|
|
: "invalid-control-light-target-kind",
|
2026-04-14 02:58:52 +02:00
|
|
|
target.entityKind === "soundEmitter"
|
|
|
|
|
? "Control sound effects must target a Sound Emitter entity."
|
2026-04-23 02:37:02 +02:00
|
|
|
: 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`
|
2026-04-14 02:58:52 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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[]
|
|
|
|
|
) {
|
2026-04-22 15:30:37 +02:00
|
|
|
const modelInstanceCount =
|
|
|
|
|
context.modelInstanceCounts.get(target.modelInstanceId) ?? 0;
|
2026-04-14 02:58:52 +02:00
|
|
|
|
|
|
|
|
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":
|
2026-04-22 15:30:37 +02:00
|
|
|
validateProjectSchedulerInteractionTarget(
|
|
|
|
|
target,
|
|
|
|
|
path,
|
|
|
|
|
context,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 02:58:52 +02:00
|
|
|
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;
|
2026-04-23 02:37:02 +02:00
|
|
|
case "activateCameraRigOverride":
|
|
|
|
|
case "clearCameraRigOverride":
|
|
|
|
|
validateProjectSchedulerEntityTarget(
|
|
|
|
|
effect.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
context,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
|
|
|
|
return;
|
2026-04-14 13:43:25 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 11:13:20 +02:00
|
|
|
if (!isBoolean(effect.smoothPath)) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-control-actor-path-smooth",
|
|
|
|
|
"Actor path smoothing must remain a boolean.",
|
|
|
|
|
`${path}.smoothPath`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 13:43:25 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-14 02:58:52 +02:00
|
|
|
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 =
|
2026-04-22 15:30:37 +02:00
|
|
|
context.modelAnimationNamesById.get(effect.target.modelInstanceId) ??
|
|
|
|
|
[];
|
2026-04-14 02:58:52 +02:00
|
|
|
|
|
|
|
|
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;
|
2026-04-14 01:55:55 +02:00
|
|
|
}
|
2026-04-14 02:58:52 +02:00
|
|
|
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":
|
2026-04-22 15:30:37 +02:00
|
|
|
validateProjectSchedulerSceneTarget(
|
|
|
|
|
effect.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 02:58:52 +02:00
|
|
|
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":
|
2026-04-22 15:30:37 +02:00
|
|
|
validateProjectSchedulerSceneTarget(
|
|
|
|
|
effect.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 02:58:52 +02:00
|
|
|
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":
|
2026-04-22 15:30:37 +02:00
|
|
|
validateProjectSchedulerSceneTarget(
|
|
|
|
|
effect.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 02:58:52 +02:00
|
|
|
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":
|
2026-04-22 15:30:37 +02:00
|
|
|
validateProjectSchedulerSceneTarget(
|
|
|
|
|
effect.target,
|
|
|
|
|
`${path}.target`,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 02:58:52 +02:00
|
|
|
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;
|
2026-04-14 01:55:32 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function registerAuthoredId(
|
|
|
|
|
id: string,
|
|
|
|
|
path: string,
|
|
|
|
|
seenIds: Map<string, string>,
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-03-31 03:40:57 +02:00
|
|
|
const previousPath = seenIds.get(id);
|
|
|
|
|
|
|
|
|
|
if (previousPath !== undefined) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"duplicate-authored-id",
|
|
|
|
|
`Duplicate authored id ${id} is already used at ${previousPath}.`,
|
|
|
|
|
path
|
|
|
|
|
)
|
2026-03-31 03:40:57 +02:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seenIds.set(id, path);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function prefixDiagnosticPath(
|
|
|
|
|
prefix: string,
|
|
|
|
|
path?: string
|
|
|
|
|
): string | undefined {
|
2026-04-11 03:47:57 +02:00
|
|
|
return path === undefined ? undefined : `${prefix}${path}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 19:56:45 +02:00
|
|
|
function validateProjectDialogue(
|
|
|
|
|
dialogue: ProjectDialogue,
|
|
|
|
|
path: string,
|
|
|
|
|
seenIds: Map<string, string>,
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 23:35:21 +02:00
|
|
|
function validateProjectSequence(
|
|
|
|
|
sequence: ProjectSequence,
|
|
|
|
|
path: string,
|
2026-04-15 10:04:13 +02:00
|
|
|
projectResources: Pick<ProjectDocument, "scenes"> & {
|
|
|
|
|
currentSceneEntities?: SceneDocument["entities"];
|
|
|
|
|
},
|
2026-04-14 23:35:51 +02:00
|
|
|
context: ProjectSchedulerValidationContext,
|
2026-04-14 23:35:21 +02:00
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
2026-04-15 03:53:23 +02:00
|
|
|
const projectScenes = projectResources.scenes ?? {};
|
2026-04-15 10:04:13 +02:00
|
|
|
const currentSceneEntities = projectResources.currentSceneEntities ?? {};
|
2026-04-15 03:53:23 +02:00
|
|
|
|
2026-04-14 23:35:21 +02:00
|
|
|
if (sequence.title.trim().length === 0) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-project-sequence-title",
|
|
|
|
|
"Project sequence titles must be non-empty.",
|
|
|
|
|
`${path}.title`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:10:27 +02:00
|
|
|
if (sequence.effects.length === 0) {
|
2026-04-14 23:35:21 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
2026-04-15 01:10:27 +02:00
|
|
|
"invalid-project-sequence-effects-empty",
|
|
|
|
|
"Project sequences must contain at least one effect.",
|
|
|
|
|
`${path}.effects`
|
2026-04-14 23:35:21 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:10:27 +02:00
|
|
|
for (const [effectIndex, effect] of sequence.effects.entries()) {
|
|
|
|
|
const effectPath = `${path}.effects.${effectIndex}`;
|
2026-04-15 00:47:17 +02:00
|
|
|
|
2026-04-15 01:10:27 +02:00
|
|
|
switch (effect.type) {
|
2026-04-14 23:35:21 +02:00
|
|
|
case "controlEffect":
|
2026-04-14 23:35:51 +02:00
|
|
|
validateProjectSchedulerEffect(
|
2026-04-15 01:10:27 +02:00
|
|
|
effect.effect,
|
|
|
|
|
`${effectPath}.effect`,
|
2026-04-14 23:35:51 +02:00
|
|
|
context,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 23:35:21 +02:00
|
|
|
break;
|
2026-04-15 09:19:50 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 10:04:13 +02:00
|
|
|
if (targetNpc === null) {
|
|
|
|
|
const candidate = currentSceneEntities[effect.npcEntityId];
|
|
|
|
|
|
|
|
|
|
if (candidate?.kind === "npc") {
|
|
|
|
|
targetNpc = candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 09:19:50 +02:00
|
|
|
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 &&
|
2026-04-22 15:30:37 +02:00
|
|
|
!targetNpc.dialogues.some(
|
|
|
|
|
(dialogue) => dialogue.id === effect.dialogueId
|
|
|
|
|
)
|
2026-04-15 09:19:50 +02:00
|
|
|
) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"missing-sequence-npc-dialogue",
|
|
|
|
|
`Dialogue ${effect.dialogueId} does not exist on NPC ${effect.npcEntityId}.`,
|
|
|
|
|
`${effectPath}.dialogueId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-04-14 23:35:21 +02:00
|
|
|
case "teleportPlayer":
|
2026-04-15 01:40:54 +02:00
|
|
|
break;
|
2026-04-15 02:07:22 +02:00
|
|
|
case "startSceneTransition": {
|
2026-04-15 03:53:23 +02:00
|
|
|
if (Object.keys(projectScenes).length === 0) {
|
2026-04-15 03:43:20 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 03:53:23 +02:00
|
|
|
const targetScene = projectScenes[effect.targetSceneId] ?? null;
|
2026-04-15 02:07:22 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-04-15 01:40:54 +02:00
|
|
|
case "setVisibility":
|
|
|
|
|
if (effect.target.kind === "brush") {
|
2026-04-15 01:44:50 +02:00
|
|
|
if ((context.brushCounts.get(effect.target.brushId) ?? 0) === 0) {
|
2026-04-15 01:40:54 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"missing-sequence-visibility-target-brush",
|
|
|
|
|
`Sequence visibility target brush ${effect.target.brushId} does not exist.`,
|
|
|
|
|
`${effectPath}.target.brushId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-22 15:30:37 +02:00
|
|
|
} else if (
|
|
|
|
|
(context.modelInstanceCounts.get(effect.target.modelInstanceId) ??
|
|
|
|
|
0) === 0
|
|
|
|
|
) {
|
2026-04-15 01:40:54 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"missing-sequence-visibility-target-model-instance",
|
|
|
|
|
`Sequence visibility target model instance ${effect.target.modelInstanceId} does not exist.`,
|
|
|
|
|
`${effectPath}.target.modelInstanceId`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-14 23:35:21 +02:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 03:47:57 +02:00
|
|
|
function validateProjectResources(
|
2026-04-15 03:55:02 +02:00
|
|
|
document: Pick<
|
|
|
|
|
ProjectDocument,
|
2026-04-15 09:53:55 +02:00
|
|
|
"materials" | "assets" | "sequences" | "scenes"
|
2026-04-15 03:55:02 +02:00
|
|
|
>,
|
2026-04-14 23:35:51 +02:00
|
|
|
context: ProjectSchedulerValidationContext,
|
2026-04-11 03:47:57 +02:00
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
) {
|
|
|
|
|
const seenIds = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-04-14 19:56:45 +02:00
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
for (const [sequenceKey, sequence] of Object.entries(
|
|
|
|
|
document.sequences.sequences
|
|
|
|
|
)) {
|
2026-04-14 23:35:21 +02:00
|
|
|
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);
|
2026-04-15 03:46:33 +02:00
|
|
|
validateProjectSequence(
|
|
|
|
|
sequence,
|
|
|
|
|
path,
|
2026-04-15 09:49:55 +02:00
|
|
|
{ scenes: document.scenes },
|
2026-04-15 03:46:33 +02:00
|
|
|
context,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 23:35:21 +02:00
|
|
|
}
|
2026-04-11 03:47:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
function filterProjectSceneDiagnostics(
|
|
|
|
|
diagnostics: SceneDiagnostic[]
|
|
|
|
|
): SceneDiagnostic[] {
|
2026-04-11 03:47:57 +02:00
|
|
|
return diagnostics.filter(
|
|
|
|
|
(diagnostic) =>
|
|
|
|
|
diagnostic.path === undefined ||
|
|
|
|
|
(!diagnostic.path.startsWith("materials.") &&
|
2026-04-12 04:31:52 +02:00
|
|
|
!diagnostic.path.startsWith("time.") &&
|
2026-04-14 19:56:45 +02:00
|
|
|
!diagnostic.path.startsWith("assets.") &&
|
2026-04-14 23:35:21 +02:00
|
|
|
!diagnostic.path.startsWith("dialogues.") &&
|
|
|
|
|
!diagnostic.path.startsWith("sequences."))
|
2026-04-11 03:47:57 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:14:26 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 14:25:42 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:57 +02:00
|
|
|
export function formatSceneDiagnostic(diagnostic: SceneDiagnostic): string {
|
2026-04-11 04:19:50 +02:00
|
|
|
return diagnostic.path === undefined
|
|
|
|
|
? diagnostic.message
|
|
|
|
|
: `${diagnostic.path}: ${diagnostic.message}`;
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
export function formatSceneDiagnosticSummary(
|
|
|
|
|
diagnostics: SceneDiagnostic[],
|
|
|
|
|
limit = 3
|
|
|
|
|
): string {
|
2026-03-31 03:40:57 +02:00
|
|
|
if (diagnostics.length === 0) {
|
|
|
|
|
return "No diagnostics.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const visibleDiagnostics = diagnostics.slice(0, Math.max(1, limit));
|
2026-04-11 04:19:50 +02:00
|
|
|
const summary = visibleDiagnostics
|
|
|
|
|
.map((diagnostic) => formatSceneDiagnostic(diagnostic))
|
|
|
|
|
.join("; ");
|
2026-03-31 03:40:57 +02:00
|
|
|
const remainingCount = diagnostics.length - visibleDiagnostics.length;
|
|
|
|
|
|
|
|
|
|
return remainingCount > 0 ? `${summary}; +${remainingCount} more` : summary;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
export function validateSceneDocument(
|
|
|
|
|
document: SceneDocument
|
|
|
|
|
): SceneDocumentValidationResult {
|
2026-03-31 03:40:57 +02:00
|
|
|
const diagnostics: SceneDiagnostic[] = [];
|
|
|
|
|
const seenIds = new Map<string, string>();
|
2026-04-14 23:36:57 +02:00
|
|
|
const projectSchedulerValidationContext =
|
|
|
|
|
createProjectSchedulerValidationContextFromSceneDocument(document);
|
2026-03-31 03:40:57 +02:00
|
|
|
|
2026-04-13 14:51:21 +02:00
|
|
|
validateProjectTimeSettings(document.time, diagnostics);
|
2026-03-31 19:57:50 +02:00
|
|
|
validateWorldSettings(document.world, document, diagnostics);
|
2026-03-31 05:10:09 +02:00
|
|
|
|
2026-03-31 03:40:57 +02:00
|
|
|
for (const [materialKey, material] of Object.entries(document.materials)) {
|
|
|
|
|
const path = `materials.${materialKey}`;
|
|
|
|
|
|
|
|
|
|
if (material.id !== materialKey) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"material-id-mismatch",
|
|
|
|
|
"Material ids must match their registry key.",
|
|
|
|
|
`${path}.id`
|
|
|
|
|
)
|
2026-03-31 03:40:57 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerAuthoredId(material.id, path, seenIds, diagnostics);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:31:48 +02:00
|
|
|
for (const [assetKey, asset] of Object.entries(document.assets)) {
|
|
|
|
|
const path = `assets.${assetKey}`;
|
|
|
|
|
|
|
|
|
|
if (asset.id !== assetKey) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"asset-id-mismatch",
|
|
|
|
|
"Asset ids must match their registry key.",
|
|
|
|
|
`${path}.id`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 17:31:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerAuthoredId(asset.id, path, seenIds, diagnostics);
|
|
|
|
|
validateProjectAsset(asset, path, diagnostics);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:30:37 +02:00
|
|
|
for (const [sequenceKey, sequence] of Object.entries(
|
|
|
|
|
document.sequences.sequences
|
|
|
|
|
)) {
|
2026-04-14 23:36:57 +02:00
|
|
|
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,
|
2026-04-15 10:04:13 +02:00
|
|
|
{ scenes: {}, currentSceneEntities: document.entities },
|
2026-04-14 23:36:57 +02:00
|
|
|
projectSchedulerValidationContext,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:57 +02:00
|
|
|
for (const [brushKey, brush] of Object.entries(document.brushes)) {
|
|
|
|
|
const path = `brushes.${brushKey}`;
|
|
|
|
|
|
|
|
|
|
if (brush.id !== brushKey) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"brush-id-mismatch",
|
|
|
|
|
"Brush ids must match their registry key.",
|
|
|
|
|
`${path}.id`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerAuthoredId(brush.id, path, seenIds, diagnostics);
|
|
|
|
|
|
2026-04-12 03:34:03 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 04:22:46 +02:00
|
|
|
if (brush.name !== undefined && brush.name.trim().length === 0) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-box-name",
|
|
|
|
|
"Box brush names must be non-empty when authored.",
|
|
|
|
|
`${path}.name`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 04:22:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-04 19:25:54 +02:00
|
|
|
if (!isFiniteVec3(brush.center)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-box-center",
|
|
|
|
|
"Box brush centers must remain finite on every axis.",
|
|
|
|
|
`${path}.center`
|
|
|
|
|
)
|
2026-04-04 19:25:54 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isFiniteVec3(brush.rotationDegrees)) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-box-rotation",
|
|
|
|
|
"Box brush rotations must remain finite on every axis.",
|
|
|
|
|
`${path}.rotationDegrees`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:40:57 +02:00
|
|
|
if (!isFiniteVec3(brush.size) || !hasPositiveBoxSize(brush.size)) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-box-size",
|
|
|
|
|
"Box brush sizes must remain finite and positive on every axis.",
|
|
|
|
|
`${path}.size`
|
|
|
|
|
)
|
2026-03-31 03:40:57 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 09:12:16 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:08:23 +02:00
|
|
|
for (const vertexId of getBrushVertexIds(brush)) {
|
2026-04-05 02:28:36 +02:00
|
|
|
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}`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:08:23 +02:00
|
|
|
for (const faceId of getBrushFaceIds(brush)) {
|
2026-03-31 03:40:57 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-06 08:19:20 +02:00
|
|
|
|
|
|
|
|
const volume = brush.volume as Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
if (!isBoxBrushVolumeMode(volume.mode)) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-box-volume-mode",
|
2026-04-22 13:57:35 +02:00
|
|
|
"Box volume mode must be none, water, fog, or light.",
|
2026-04-06 08:19:20 +02:00
|
|
|
`${path}.volume.mode`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 08:08:23 +02:00
|
|
|
if (brush.kind !== "box" && volume.mode !== "none") {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-non-box-volume-mode",
|
2026-04-22 13:57:35 +02:00
|
|
|
"Only whitebox boxes support water, fog, or light volume modes.",
|
2026-04-15 08:08:23 +02:00
|
|
|
`${path}.volume.mode`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 08:19:20 +02:00
|
|
|
if (volume.mode === "water") {
|
|
|
|
|
const water = volume.water as Record<string, unknown> | undefined;
|
|
|
|
|
|
|
|
|
|
if (water === undefined) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-box-water-settings",
|
|
|
|
|
"Water volumes must define water settings.",
|
|
|
|
|
`${path}.volume.water`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
} else {
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
typeof water.colorHex !== "string" ||
|
|
|
|
|
!isHexColorString(water.colorHex)
|
|
|
|
|
) {
|
2026-04-06 08:19:20 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-07 06:30:14 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
!isPositiveIntegerInRange(
|
|
|
|
|
water.foamContactLimit,
|
|
|
|
|
MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT
|
|
|
|
|
)
|
|
|
|
|
) {
|
2026-04-07 06:30:14 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-07 07:11:15 +02:00
|
|
|
|
|
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-06 08:19:20 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (volume.mode === "fog") {
|
|
|
|
|
const fog = volume.fog as Record<string, unknown> | undefined;
|
|
|
|
|
|
|
|
|
|
if (fog === undefined) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-box-fog-settings",
|
|
|
|
|
"Fog volumes must define fog settings.",
|
|
|
|
|
`${path}.volume.fog`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
} else {
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
typeof fog.colorHex !== "string" ||
|
|
|
|
|
!isHexColorString(fog.colorHex)
|
|
|
|
|
) {
|
2026-04-06 08:19:20 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-04-22 13:57:35 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (volume.mode === "light") {
|
|
|
|
|
const light = volume.light as Record<string, unknown> | 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`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-04-06 08:19:20 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
for (const [pathKey, pathValue] of Object.entries(document.paths)) {
|
2026-04-13 21:23:04 +02:00
|
|
|
const path = `paths.${pathKey}`;
|
2026-04-13 21:21:40 +02:00
|
|
|
|
2026-04-13 21:23:04 +02:00
|
|
|
if (pathValue.id !== pathKey) {
|
2026-04-13 21:21:40 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"path-id-mismatch",
|
|
|
|
|
"Path ids must match their registry key.",
|
|
|
|
|
`${path}.id`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 21:23:04 +02:00
|
|
|
registerAuthoredId(pathValue.id, path, seenIds, diagnostics);
|
|
|
|
|
validateScenePath(pathValue, path, diagnostics);
|
2026-04-13 21:21:40 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 19:47:04 +02:00
|
|
|
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);
|
2026-04-20 02:36:38 +02:00
|
|
|
validateTerrain(terrain, path, document, diagnostics);
|
2026-04-18 19:47:04 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
for (const [modelInstanceKey, modelInstance] of Object.entries(
|
|
|
|
|
document.modelInstances
|
|
|
|
|
)) {
|
2026-03-31 17:31:48 +02:00
|
|
|
const path = `modelInstances.${modelInstanceKey}`;
|
|
|
|
|
|
|
|
|
|
if (modelInstance.id !== modelInstanceKey) {
|
|
|
|
|
diagnostics.push(
|
2026-04-11 04:19:50 +02:00
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"model-instance-id-mismatch",
|
|
|
|
|
"Model instance ids must match their registry key.",
|
|
|
|
|
`${path}.id`
|
|
|
|
|
)
|
2026-03-31 17:31:48 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerAuthoredId(modelInstance.id, path, seenIds, diagnostics);
|
|
|
|
|
validateModelInstance(modelInstance, path, document, diagnostics);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 16:17:50 +02:00
|
|
|
const seenNpcActorIds = new Map<string, string>();
|
|
|
|
|
|
2026-03-31 03:40:57 +02:00
|
|
|
for (const [entityKey, entity] of Object.entries(document.entities)) {
|
|
|
|
|
const path = `entities.${entityKey}`;
|
|
|
|
|
|
|
|
|
|
if (entity.id !== entityKey) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"entity-id-mismatch",
|
|
|
|
|
"Entity ids must match their registry key.",
|
|
|
|
|
`${path}.id`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerAuthoredId(entity.id, path, seenIds, diagnostics);
|
2026-04-03 01:05:14 +02:00
|
|
|
validateEntityName(entity.name, path, diagnostics);
|
2026-03-31 03:40:57 +02:00
|
|
|
|
2026-03-31 05:50:40 +02:00
|
|
|
switch (entity.kind) {
|
2026-03-31 19:57:50 +02:00
|
|
|
case "pointLight":
|
|
|
|
|
validatePointLightEntity(entity, path, diagnostics);
|
|
|
|
|
break;
|
|
|
|
|
case "spotLight":
|
|
|
|
|
validateSpotLightEntity(entity, path, diagnostics);
|
|
|
|
|
break;
|
2026-04-22 16:58:34 +02:00
|
|
|
case "cameraRig":
|
|
|
|
|
validateCameraRigEntity(entity, path, document, diagnostics);
|
|
|
|
|
break;
|
2026-03-31 05:50:40 +02:00
|
|
|
case "playerStart":
|
|
|
|
|
validatePlayerStartEntity(entity, path, diagnostics);
|
|
|
|
|
break;
|
2026-04-11 04:33:43 +02:00
|
|
|
case "sceneEntry":
|
|
|
|
|
validateSceneEntryEntity(entity, path, diagnostics);
|
|
|
|
|
break;
|
2026-04-13 16:17:50 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-31 05:50:40 +02:00
|
|
|
case "soundEmitter":
|
2026-04-02 19:38:16 +02:00
|
|
|
validateSoundEmitterEntity(entity, path, document, diagnostics);
|
2026-03-31 05:50:40 +02:00
|
|
|
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;
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:16:17 +02:00
|
|
|
for (const [linkKey, link] of Object.entries(document.interactionLinks)) {
|
|
|
|
|
const path = `interactionLinks.${linkKey}`;
|
|
|
|
|
|
|
|
|
|
if (link.id !== linkKey) {
|
2026-04-11 04:19:50 +02:00
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"interaction-link-id-mismatch",
|
|
|
|
|
"Interaction link ids must match their registry key.",
|
|
|
|
|
`${path}.id`
|
|
|
|
|
)
|
|
|
|
|
);
|
2026-03-31 06:16:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
registerAuthoredId(link.id, path, seenIds, diagnostics);
|
|
|
|
|
validateInteractionLink(link, path, document, diagnostics);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:56:04 +02:00
|
|
|
validateProjectScheduler(
|
|
|
|
|
document.scheduler,
|
2026-04-14 23:36:57 +02:00
|
|
|
document.sequences,
|
|
|
|
|
projectSchedulerValidationContext,
|
2026-04-14 01:56:04 +02:00
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-14 01:55:43 +02:00
|
|
|
|
2026-03-31 03:40:57 +02:00
|
|
|
return {
|
|
|
|
|
diagnostics,
|
|
|
|
|
errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"),
|
2026-04-11 04:19:50 +02:00
|
|
|
warnings: diagnostics.filter(
|
|
|
|
|
(diagnostic) => diagnostic.severity === "warning"
|
|
|
|
|
)
|
2026-03-31 03:40:57 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function assertSceneDocumentIsValid(document: SceneDocument) {
|
|
|
|
|
const validation = validateSceneDocument(document);
|
|
|
|
|
|
|
|
|
|
if (validation.errors.length > 0) {
|
2026-04-11 04:19:50 +02:00
|
|
|
throw new Error(
|
|
|
|
|
`Scene document has ${validation.errors.length} validation error(s): ${formatSceneDiagnosticSummary(validation.errors)}`
|
|
|
|
|
);
|
2026-03-31 03:40:57 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-11 03:47:57 +02:00
|
|
|
|
|
|
|
|
export function validateProjectDocument(
|
|
|
|
|
document: ProjectDocument
|
|
|
|
|
): SceneDocumentValidationResult {
|
|
|
|
|
const diagnostics: SceneDiagnostic[] = [];
|
2026-04-14 23:36:57 +02:00
|
|
|
const projectSchedulerValidationContext =
|
|
|
|
|
createProjectSchedulerValidationContextFromProjectDocument(document);
|
2026-04-11 03:47:57 +02:00
|
|
|
|
2026-04-11 13:25:22 +02:00
|
|
|
if (document.name.trim().length === 0) {
|
|
|
|
|
diagnostics.push(
|
|
|
|
|
createDiagnostic(
|
|
|
|
|
"error",
|
|
|
|
|
"invalid-project-name",
|
|
|
|
|
"Project names must be non-empty.",
|
|
|
|
|
"name"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 03:47:57 +02:00
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:51:21 +02:00
|
|
|
validateProjectTimeSettings(document.time, diagnostics);
|
2026-04-14 23:36:57 +02:00
|
|
|
validateProjectResources(
|
|
|
|
|
document,
|
|
|
|
|
projectSchedulerValidationContext,
|
|
|
|
|
diagnostics
|
|
|
|
|
);
|
2026-04-11 03:47:57 +02:00
|
|
|
|
2026-04-14 01:56:04 +02:00
|
|
|
validateProjectScheduler(
|
2026-04-14 01:55:43 +02:00
|
|
|
document.scheduler,
|
2026-04-14 23:36:57 +02:00
|
|
|
document.sequences,
|
|
|
|
|
projectSchedulerValidationContext,
|
2026-04-14 01:55:43 +02:00
|
|
|
diagnostics
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-11 03:47:57 +02:00
|
|
|
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`
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:14:33 +02:00
|
|
|
validateProjectSceneLoadingScreen(scene, scenePath, diagnostics);
|
2026-04-11 14:25:42 +02:00
|
|
|
validateProjectSceneEditorPreferences(scene, scenePath, diagnostics);
|
2026-04-11 04:14:33 +02:00
|
|
|
|
2026-04-14 01:55:43 +02:00
|
|
|
const sceneDocument = {
|
|
|
|
|
...createSceneDocumentFromProject(document, sceneKey),
|
|
|
|
|
scheduler: createEmptyProjectScheduler()
|
|
|
|
|
};
|
2026-04-11 03:47:57 +02:00
|
|
|
|
|
|
|
|
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)}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|