import * as THREE from 'three'; export type SimulatorVector3Tuple = [number, number, number]; export type SimulatorQuaternionTuple = [number, number, number, number]; export type SimulatorPhysicsMode = false | 'fixed' | 'dynamic'; export interface SimulatorLocationDefinition { /** Short agent-facing description of the location's intended use. */ description: string; /** World-space coordinates accepted directly by simulator controls. */ position: SimulatorVector3Tuple; } export type SimulatorLocations = Record; export interface SimulatorObjectDefinition { id?: string; assetPath?: string; object?: THREE.Object3D; position?: SimulatorVector3Tuple; quaternion?: SimulatorQuaternionTuple; scale?: SimulatorVector3Tuple; visible?: boolean; detectObject?: boolean; label?: string; data?: unknown; physics?: SimulatorPhysicsMode; } export interface SimulatorSceneManifest { name?: string; scenePath?: string; videoPath?: string; scenePlanesPath?: string; navMeshPath?: string; position?: SimulatorVector3Tuple; quaternion?: SimulatorQuaternionTuple; scale?: SimulatorVector3Tuple; locations?: SimulatorLocations; objects?: SimulatorObjectDefinition[]; } export interface ResolvedSimulatorSceneManifest extends Omit< SimulatorSceneManifest, 'scenePath' | 'videoPath' | 'scenePlanesPath' | 'navMeshPath' | 'objects' > { scenePath?: string; videoPath?: string; scenePlanesPath?: string; navMeshPath?: string; objects: SimulatorObjectDefinition[]; manifestUrl: string; } const MANIFEST_KEYS = new Set([ 'name', 'scenePath', 'videoPath', 'scenePlanesPath', 'navMeshPath', 'position', 'quaternion', 'scale', 'locations', 'objects', ]); const LOCATION_KEYS = new Set(['description', 'position']); const OBJECT_KEYS = new Set([ 'id', 'assetPath', 'position', 'quaternion', 'scale', 'visible', 'detectObject', 'label', 'data', 'physics', ]); function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function assertKnownKeys( value: Record, keys: Set, location: string ) { for (const key of Object.keys(value)) { if (!keys.has(key)) { throw new Error(`${location}: unknown field '${key}'.`); } } } function parseString(value: unknown, location: string): string | undefined { if (value === undefined) return undefined; if (typeof value !== 'string' || value.length === 0) { throw new Error(`${location}: expected a non-empty string.`); } return value; } function parseTuple( value: unknown, length: number, location: string ): T | undefined { if (value === undefined) return undefined; if ( !Array.isArray(value) || value.length !== length || value.some((item) => typeof item !== 'number' || !Number.isFinite(item)) ) { throw new Error( `${location}: expected an array of ${length} finite numbers.` ); } return value as T; } function parseBoolean(value: unknown, location: string) { if (value === undefined) return undefined; if (typeof value !== 'boolean') { throw new Error(`${location}: expected a boolean.`); } return value; } function parsePhysics(value: unknown, location: string): SimulatorPhysicsMode { if (value === undefined || value === false) return false; if (value !== 'fixed' && value !== 'dynamic') { throw new Error(`${location}: expected false, 'fixed', or 'dynamic'.`); } return value; } function parseLocations(value: unknown): SimulatorLocations | undefined { if (value === undefined) return undefined; if (!isRecord(value)) { throw new Error('manifest.locations: expected an object.'); } const locations: SimulatorLocations = {}; for (const [name, definition] of Object.entries(value)) { const location = `manifest.locations.${name}`; if (!name) { throw new Error('manifest.locations: expected non-empty names.'); } if (!isRecord(definition)) { throw new Error(`${location}: expected an object.`); } assertKnownKeys(definition, LOCATION_KEYS, location); const description = parseString( definition.description, `${location}.description` ); const position = parseTuple( definition.position, 3, `${location}.position` ); if (!description || !position) { throw new Error(`${location}: description and position are required.`); } locations[name] = {description, position}; } return locations; } function parseObject( value: unknown, index: number, seenIds: Set ): SimulatorObjectDefinition { const location = `objects[${index}]`; if (!isRecord(value)) { throw new Error(`${location}: expected an object.`); } assertKnownKeys(value, OBJECT_KEYS, location); const id = parseString(value.id, `${location}.id`); if (id && seenIds.has(id)) { throw new Error(`${location}: duplicate id '${id}'.`); } if (id) seenIds.add(id); const assetPath = parseString(value.assetPath, `${location}.assetPath`); if (!assetPath) { throw new Error(`${location}: assetPath is required.`); } const detectObject = parseBoolean( value.detectObject, `${location}.detectObject` ); const label = parseString(value.label, `${location}.label`); if (detectObject && !label) { throw new Error(`${location}: detectObject requires label.`); } const quaternion = parseTuple( value.quaternion, 4, `${location}.quaternion` ); if (quaternion && quaternion.every((component) => component === 0)) { throw new Error(`${location}.quaternion: expected a non-zero quaternion.`); } const scale = parseTuple( value.scale, 3, `${location}.scale` ); if (scale?.some((component) => component === 0)) { throw new Error(`${location}.scale: components must be non-zero.`); } return { id, assetPath, position: parseTuple( value.position, 3, `${location}.position` ), quaternion, scale, visible: parseBoolean(value.visible, `${location}.visible`), detectObject, label, data: value.data, physics: parsePhysics(value.physics, `${location}.physics`), }; } function resolveOptionalUrl(path: string | undefined, baseUrl: string) { return path ? new URL(path, baseUrl).href : undefined; } export function parseSimulatorSceneManifest( value: unknown, manifestUrl: string ): ResolvedSimulatorSceneManifest { if (!isRecord(value)) { throw new Error( `Invalid simulator manifest at ${manifestUrl}: expected an object.` ); } try { assertKnownKeys(value, MANIFEST_KEYS, 'manifest'); const scenePath = parseString(value.scenePath, 'manifest.scenePath'); const videoPath = parseString(value.videoPath, 'manifest.videoPath'); if (scenePath && videoPath) { throw new Error( 'manifest: scenePath and videoPath are mutually exclusive.' ); } const objectValues = value.objects; if (objectValues !== undefined && !Array.isArray(objectValues)) { throw new Error('manifest.objects: expected an array.'); } const seenIds = new Set(); const objects = (objectValues ?? []).map((object, index) => parseObject(object, index, seenIds) ); const quaternion = parseTuple( value.quaternion, 4, 'manifest.quaternion' ); if (quaternion && quaternion.every((component) => component === 0)) { throw new Error('manifest.quaternion: expected a non-zero quaternion.'); } const scale = parseTuple( value.scale, 3, 'manifest.scale' ); if (scale?.some((component) => component === 0)) { throw new Error('manifest.scale: components must be non-zero.'); } return { name: parseString(value.name, 'manifest.name'), scenePath: resolveOptionalUrl(scenePath, manifestUrl), videoPath: resolveOptionalUrl(videoPath, manifestUrl), scenePlanesPath: resolveOptionalUrl( parseString(value.scenePlanesPath, 'manifest.scenePlanesPath'), manifestUrl ), navMeshPath: resolveOptionalUrl( parseString(value.navMeshPath, 'manifest.navMeshPath'), manifestUrl ), position: parseTuple( value.position, 3, 'manifest.position' ), quaternion, scale, locations: parseLocations(value.locations), objects: objects.map((object) => ({ ...object, assetPath: resolveOptionalUrl(object.assetPath, manifestUrl), })), manifestUrl, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Invalid simulator manifest at ${manifestUrl}: ${message}`); } } export async function loadSimulatorSceneManifest( manifestPath: string, baseUrl = document.baseURI ) { const manifestUrl = new URL(manifestPath, baseUrl).href; // Manifests are small, mutable environment descriptors. Always refresh them // while allowing the larger assets they reference to use normal HTTP caching. const response = await fetch(manifestUrl, {cache: 'no-store'}); if (!response.ok) { throw new Error( `Failed to load simulator manifest at ${manifestUrl}: ${response.status} ${response.statusText}` ); } return parseSimulatorSceneManifest(await response.json(), manifestUrl); }