/** * The scene file: everything the browser knows about a scene, as one JSON * document, so a scene built here can be rendered somewhere else. * * The file carries the scene description (objects and body), every parameter * value, the microphone, the air grid size, and, only when they have been * edited by hand, the two .m sources. Generated sources are deliberately * *not* stored: the generator is deterministic, so omitting them keeps the * file small, diffable, and honest about what the user actually authored. * The same file loads back into the browser (Load scene) and into the * headless renderer (scripts/render.ts), and both go through the same * StudioSession, so a scene renders identically wherever it runs. * * Parsing validates shape and types with named errors rather than letting a * malformed file surface as a compile failure three layers down. */ import { OBJECT_TEMPLATES } from './objects.ts'; import { BODY_TEMPLATES } from './bodies.ts'; import type { BodyType, ObjectType, Params, SceneSpec } from './types.ts'; export interface SceneFile { /** Format marker and version. */ soundbox: 1; spec: SceneSpec; /** Model parameter values, by suffixed key (f0_s1, ...). */ params: Params; /** Scene parameter values: placement and body, by key. */ sceneParams: Params; mic: { x: number; y: number; z: number }; /** Air grid points along x. */ nx: number; /** Hand-edited .m sources, present only where the user edited them. */ sources?: { model?: string; scene?: string }; } export function makeSceneFile(init: { spec: SceneSpec; params: Params; sceneParams: Params; mic: { x: number; y: number; z: number }; nx: number; modelSource?: string | null; sceneSource?: string | null; }): SceneFile { const file: SceneFile = { soundbox: 1, spec: init.spec, params: init.params, sceneParams: init.sceneParams, mic: { ...init.mic }, nx: init.nx, }; const sources: { model?: string; scene?: string } = {}; if (init.modelSource) sources.model = init.modelSource; if (init.sceneSource) sources.scene = init.sceneSource; if (sources.model || sources.scene) file.sources = sources; return file; } export const serializeSceneFile = (file: SceneFile): string => `${JSON.stringify(file, null, 2)}\n`; class SceneFileError extends Error { constructor(message: string) { super(`not a valid soundbox scene file: ${message}`); this.name = 'SceneFileError'; } } const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); function numberMap(v: unknown, what: string): Params { if (v === undefined) return {}; if (!isRecord(v)) throw new SceneFileError(`${what} must be an object`); const out: Params = {}; for (const [k, val] of Object.entries(v)) { if (typeof val !== 'number' || !Number.isFinite(val)) { throw new SceneFileError(`${what}.${k} must be a finite number`); } out[k] = val; } return out; } export function parseSceneFile(text: string): SceneFile { let raw: unknown; try { raw = JSON.parse(text); } catch (e) { throw new SceneFileError(`not JSON (${e instanceof Error ? e.message : e})`); } if (!isRecord(raw)) throw new SceneFileError('the top level must be an object'); if (raw.soundbox !== 1) { throw new SceneFileError(`missing or unsupported format marker (want "soundbox": 1)`); } const specRaw = raw.spec; if (!isRecord(specRaw)) throw new SceneFileError('missing "spec"'); const objectsRaw = specRaw.objects; if (!Array.isArray(objectsRaw) || objectsRaw.length === 0) { throw new SceneFileError('"spec.objects" must be a non-empty array'); } const seen = new Set(); const objects = objectsRaw.map((o, i) => { if (!isRecord(o)) throw new SceneFileError(`object ${i} must be an object`); const { id, type } = o; if (typeof id !== 'string' || !/^[A-Za-z][A-Za-z0-9]*$/.test(id)) { throw new SceneFileError(`object ${i} needs an alphanumeric "id"`); } if (seen.has(id)) throw new SceneFileError(`duplicate object id '${id}'`); seen.add(id); if (typeof type !== 'string' || !(type in OBJECT_TEMPLATES)) { throw new SceneFileError( `object '${id}' has unknown type '${String(type)}' ` + `(one of: ${Object.keys(OBJECT_TEMPLATES).join(', ')})`, ); } const size = o.size; if (!isRecord(size) || typeof size.L1 !== 'number' || !(size.L1 > 0)) { throw new SceneFileError(`object '${id}' needs a positive "size.L1"`); } if (type === 'membrane' && size.L2 !== undefined && !(typeof size.L2 === 'number' && size.L2 > 0)) { throw new SceneFileError(`object '${id}': "size.L2" must be a positive number`); } return { id, type: type as ObjectType, size: { L1: size.L1, ...(typeof size.L2 === 'number' ? { L2: size.L2 } : {}) }, params: numberMap(o.params, `object '${id}' params`), place: numberMap(o.place, `object '${id}' place`), }; }); const bodyRaw = specRaw.body; if (!isRecord(bodyRaw)) throw new SceneFileError('missing "spec.body"'); if (typeof bodyRaw.type !== 'string' || !(bodyRaw.type in BODY_TEMPLATES)) { throw new SceneFileError( `unknown body type '${String(bodyRaw.type)}' ` + `(one of: ${Object.keys(BODY_TEMPLATES).join(', ')})`, ); } const spec: SceneSpec = { objects, body: { type: bodyRaw.type as BodyType, params: numberMap(bodyRaw.params, 'body params'), }, }; const micRaw = raw.mic; if ( !isRecord(micRaw) || typeof micRaw.x !== 'number' || typeof micRaw.y !== 'number' || typeof micRaw.z !== 'number' ) { throw new SceneFileError('"mic" must be {x, y, z} in metres'); } const nx = raw.nx; if (typeof nx !== 'number' || !Number.isInteger(nx) || nx < 16 || nx % 2 !== 0) { throw new SceneFileError('"nx" must be an even integer of at least 16'); } let sources: SceneFile['sources']; if (raw.sources !== undefined) { if (!isRecord(raw.sources)) throw new SceneFileError('"sources" must be an object'); sources = {}; for (const key of ['model', 'scene'] as const) { const v = raw.sources[key]; if (v === undefined) continue; if (typeof v !== 'string') throw new SceneFileError(`"sources.${key}" must be a string`); sources[key] = v; } } return { soundbox: 1, spec, params: numberMap(raw.params, 'params'), sceneParams: numberMap(raw.sceneParams, 'sceneParams'), mic: { x: micRaw.x, y: micRaw.y, z: micRaw.z }, nx, ...(sources && (sources.model || sources.scene) ? { sources } : {}), }; }