Add Format Specification section 21 (Scenario Model 0.1) and the runtime that realizes it: the scenario director, seven trigger classes, timelines with repeats and branches, admission and concurrency control, nested ownership with bounded cleanup, and the production GC5 resource counters. Exhibit E provides a reproducible forty-minute long-scenario reference and scenario-challenge.xzbt covers the PRD 131 cases. A standalone acceptance/soak page is built by tools/build-scenario-acceptance.mjs. 252 automated checks pass. The two-hour real-duration development soak required by the GC6 schedule remains pending. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M7dgfQ12mpM4JjSMv3inLA
11198 lines
531 KiB
HTML
11198 lines
531 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; media-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||
<title>XZBT 0.1</title>
|
||
<style>:root {
|
||
color-scheme: dark;
|
||
--ink: #f3f3ef;
|
||
--muted: #9ba09b;
|
||
--surface: #151817;
|
||
--surface-2: #1d211f;
|
||
--line: #323834;
|
||
--accent: #b8ff5a;
|
||
--accent-dark: #20330d;
|
||
--danger: #ff7b72;
|
||
--warning: #ffca5c;
|
||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: #0b0d0c; }
|
||
button, input { font: inherit; }
|
||
button { border: 1px solid var(--line); border-radius: 0.55rem; padding: 0.66rem 0.9rem; color: var(--ink); background: var(--surface-2); cursor: pointer; }
|
||
button:hover:not(:disabled), button:focus-visible { border-color: var(--accent); outline: none; }
|
||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||
select, input { width: 100%; accent-color: var(--accent); color: var(--ink); background: #0f1210; border: 1px solid var(--line); border-radius: 0.45rem; padding: 0.5rem; }
|
||
.primary { color: #10130e; border-color: var(--accent); background: var(--accent); font-weight: 700; }
|
||
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 3vw, 2.5rem); border-bottom: 1px solid var(--line); }
|
||
.brand { display: flex; align-items: baseline; gap: 0.8rem; }
|
||
.brand h1 { margin: 0; letter-spacing: 0.22em; font-size: 1.15rem; }
|
||
.brand span, .status-cluster { color: var(--muted); font-size: 0.8rem; }
|
||
.status-cluster { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem 1rem; }
|
||
.storage-warning { color: var(--warning); }
|
||
.storage-ready { color: var(--accent); }
|
||
main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-height: calc(100vh - 65px); }
|
||
.sidebar { padding: 1.25rem; border-right: 1px solid var(--line); background: #101311; }
|
||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
||
.section-heading h2 { margin: 0; font-size: 0.95rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||
.drop-zone { padding: 1rem; margin-bottom: 1rem; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 0.75rem; }
|
||
.drop-zone.is-dragging { color: var(--accent); border-color: var(--accent); background: var(--accent-dark); }
|
||
.exhibit-list { display: grid; gap: 0.75rem; }
|
||
.exhibit-card { padding: 1rem; border: 1px solid var(--line); border-radius: 0.8rem; background: var(--surface); }
|
||
.exhibit-card.is-active { border-color: var(--accent); box-shadow: inset 3px 0 var(--accent); }
|
||
.exhibit-card h3, .exhibit-card p { margin: 0 0 0.55rem; }
|
||
.exhibit-card .description { color: #c7cbc7; font-size: 0.9rem; line-height: 1.45; }
|
||
.exhibit-card .meta { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.72rem; }
|
||
.workspace { display: grid; grid-template-rows: minmax(300px, 1fr) minmax(220px, 38vh); min-width: 0; }
|
||
.stage { display: grid; place-items: center; padding: clamp(1.5rem, 5vw, 5rem); background: radial-gradient(circle at 50% 45%, #1a211c, #0b0d0c 62%); }
|
||
.stage-card { width: min(660px, 100%); padding: clamp(1.5rem, 4vw, 3rem); border: 1px solid var(--line); border-radius: 1rem; background: rgba(16, 19, 17, 0.88); }
|
||
.eyebrow { color: var(--accent); letter-spacing: 0.12em; text-transform: uppercase; font-size: 0.75rem; }
|
||
.stage-card h2 { margin: 0.35rem 0 0.7rem; font-size: clamp(1.7rem, 4vw, 3rem); }
|
||
.stage-card p { color: var(--muted); line-height: 1.55; }
|
||
.runtime-readout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin: 1.5rem 0; }
|
||
.runtime-readout div { padding: 0.8rem; border: 1px solid var(--line); border-radius: 0.55rem; overflow: hidden; }
|
||
.runtime-readout dt { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; }
|
||
.runtime-readout dd { margin: 0.35rem 0 0; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
||
.grammar-panel { margin: 1rem 0; padding-top: 0.8rem; border-top: 1px solid var(--line); }
|
||
.grammar-panel h3 { margin: 0 0 0.7rem; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||
.parameter-control { display: grid; grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 1.5fr) auto; gap: 0.65rem; align-items: center; margin: 0.55rem 0; }
|
||
.parameter-control label { color: #d9ddd9; font-size: 0.82rem; }
|
||
.compact { padding: 0.45rem 0.6rem; font-size: 0.75rem; }
|
||
.override-badge { margin-left: 0.5rem; padding: 0.12rem 0.35rem; color: var(--accent); background: var(--accent-dark); border-radius: 999px; font-size: 0.62rem; text-transform: uppercase; }
|
||
.resolved-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.35rem 1rem; padding: 0; list-style: none; }
|
||
.resolved-values li { display: flex; justify-content: space-between; gap: 1rem; min-width: 0; font-size: 0.76rem; }
|
||
.resolved-values code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
|
||
.resolved-values output, #condition-result { color: var(--accent); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||
.diagnostics-panel { min-height: 0; padding: 1rem 1.25rem; border-top: 1px solid var(--line); background: var(--surface); overflow: auto; }
|
||
.count { display: inline-grid; place-items: center; min-width: 1.6rem; min-height: 1.6rem; border-radius: 999px; color: var(--accent); background: var(--accent-dark); }
|
||
.diagnostic-list { display: grid; gap: 0.55rem; padding: 0; margin: 0; list-style: none; }
|
||
.diagnostic { display: grid; grid-template-columns: 4.5rem minmax(9rem, auto) 1fr; gap: 0.65rem; align-items: baseline; padding: 0.7rem; border-left: 3px solid var(--line); background: #101311; font-size: 0.82rem; }
|
||
.diagnostic.error { border-color: var(--danger); }
|
||
.diagnostic.warning { border-color: var(--warning); }
|
||
.diagnostic.info { border-color: var(--accent); }
|
||
.diagnostic .severity, .diagnostic small { color: var(--muted); font-size: 0.68rem; }
|
||
.diagnostic small { grid-column: 3; }
|
||
.empty { color: var(--muted); text-align: center; }
|
||
[hidden] { display: none !important; }
|
||
@media (max-width: 760px) {
|
||
main { grid-template-columns: 1fr; }
|
||
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
|
||
.workspace { grid-template-rows: auto auto; }
|
||
.diagnostic { grid-template-columns: 1fr; }
|
||
.diagnostic small { grid-column: 1; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } }
|
||
|
||
.audio-controls {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.6rem;
|
||
align-items: center;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.audio-controls label {
|
||
margin-left: auto;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.audio-buses {
|
||
display: grid;
|
||
gap: 0.4rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.sound-list {
|
||
display: grid;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.sound-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
padding: 0.35rem 0.55rem;
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
border-radius: 0.4rem;
|
||
}
|
||
|
||
.stage-canvas {
|
||
display: block;
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
margin: 0 0 1rem;
|
||
border-radius: 6px;
|
||
background: #000;
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
}</style>
|
||
</head>
|
||
<body>
|
||
<header class="app-header">
|
||
<div class="brand"><h1>XZBT</h1><span id="runtime-version"></span></div>
|
||
<div class="status-cluster" role="status" aria-live="polite">
|
||
<span id="app-status">Starting…</span><span id="storage-status">Checking storage…</span>
|
||
</div>
|
||
</header>
|
||
<main>
|
||
<aside class="sidebar" aria-labelledby="library-title">
|
||
<div class="section-heading"><h2 id="library-title">Exhibit library</h2><button id="import-button" class="primary" type="button">Import files</button></div>
|
||
<input id="import-files" type="file" accept=".xzbt,application/json" multiple hidden>
|
||
<div id="drop-zone" class="drop-zone">Drop .xzbt files here</div>
|
||
<p id="empty-state" class="empty">No exhibits loaded.</p>
|
||
<div id="library-list" class="exhibit-list"></div>
|
||
</aside>
|
||
<section class="workspace">
|
||
<section class="stage" aria-live="polite">
|
||
<div id="stage-empty" class="stage-card"><span class="eyebrow">Runtime ready</span><h2>Select an exhibit</h2><p>Import declarative XZBT 0.1 documents, then activate one from the library. Exhibits run without adding subject-specific code to this runtime.</p></div>
|
||
<div id="stage-active" class="stage-card" hidden>
|
||
<span class="eyebrow">Active exhibit</span><h2 id="active-name"></h2>
|
||
<canvas id="stage-canvas" class="stage-canvas" aria-label="Exhibit visual output"></canvas>
|
||
<dl class="runtime-readout"><div><dt>Exhibit ID</dt><dd id="active-id"></dd></div><div><dt>Resolved seed</dt><dd id="active-seed"></dd></div><div style="grid-column:1/-1"><dt>Deterministic visual-stream preview</dt><dd id="active-sequence"></dd></div></dl>
|
||
<p>Declarative sound, visuals, cadence, and scenarios share one logical clock. Parameter edits remain stored while a temporary scenario override is active.</p>
|
||
<section class="grammar-panel" aria-labelledby="configuration-title"><h3 id="configuration-title">Parameters</h3><div id="configuration"></div></section>
|
||
<section class="grammar-panel" aria-labelledby="audio-title">
|
||
<h3 id="audio-title">Audio</h3>
|
||
<p id="audio-state" class="empty">Audio is locked until you start it.</p>
|
||
<div class="audio-controls">
|
||
<button id="audio-unlock" type="button">Start audio</button>
|
||
<button id="audio-stop" type="button" disabled>Stop all sounds</button>
|
||
<label for="master-volume">Master volume</label>
|
||
<input id="master-volume" type="range" min="0" max="1" step="0.01" value="0.8">
|
||
</div>
|
||
<div id="audio-buses" class="audio-buses"></div>
|
||
<div id="sound-list" class="sound-list"></div>
|
||
</section>
|
||
<section class="grammar-panel" aria-labelledby="values-title"><h3 id="values-title">Resolved values</h3><ul id="resolved-values" class="resolved-values"></ul><p>Generated condition: <output id="condition-result">n/a</output></p></section>
|
||
<section class="grammar-panel" aria-labelledby="scenarios-title"><h3 id="scenarios-title">Scenarios</h3><div id="scenario-list"></div></section>
|
||
<button id="pause-button" type="button">Pause</button>
|
||
<button id="deactivate-button" type="button">Deactivate</button>
|
||
</div>
|
||
</section>
|
||
<section class="diagnostics-panel" aria-labelledby="diagnostics-title">
|
||
<div class="section-heading"><h2 id="diagnostics-title">Diagnostics <span id="diagnostic-count" class="count">0</span></h2><button id="clear-diagnostics" type="button">Clear</button></div>
|
||
<p id="diagnostics-empty" class="empty">No diagnostics yet.</p><ol id="diagnostic-list" class="diagnostic-list"></ol>
|
||
</section>
|
||
</section>
|
||
</main>
|
||
<script>'use strict';
|
||
(() => {
|
||
|
||
/* src/runtime/constants.js */
|
||
const XZBT_FORMAT_VERSION = '0.1';
|
||
const XZBT_RUNTIME_VERSION = '0.1.0-phase6';
|
||
const UINT32_RANGE = 0x1_0000_0000;
|
||
const RNG_DOMAINS = Object.freeze([
|
||
'cadence',
|
||
'scenario',
|
||
'visual',
|
||
'sound',
|
||
'manual-sample',
|
||
'sample'
|
||
]);
|
||
|
||
const DATABASE = Object.freeze({
|
||
name: 'xzbt-runtime-0.1',
|
||
version: 1,
|
||
exhibits: 'exhibits',
|
||
preferences: 'preferences'
|
||
});
|
||
|
||
const ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
||
const ALLOWED_TOP_LEVEL_FIELDS = Object.freeze([
|
||
'xzbt', 'meta', 'runtime', 'parameters', 'ui', 'state', 'signals',
|
||
'components', 'visuals', 'audio', 'sounds', 'cadence', 'modulators',
|
||
'bindings', 'events', 'scenarios'
|
||
]);
|
||
const ALLOWED_META_FIELDS = Object.freeze([
|
||
'id', 'name', 'version', 'author', 'description', 'license', 'tags'
|
||
]);
|
||
|
||
/* src/runtime/diagnostics.js */
|
||
class Diagnostics {
|
||
constructor({ limit = 250, onChange = () => {} } = {}) {
|
||
this.limit = limit;
|
||
this.onChange = onChange;
|
||
this.entries = [];
|
||
this.sequence = 0;
|
||
}
|
||
|
||
add(severity, code, message, context = {}) {
|
||
const entry = Object.freeze({
|
||
sequence: ++this.sequence,
|
||
timestamp: new Date().toISOString(),
|
||
severity,
|
||
code,
|
||
exhibitId: context.exhibitId ?? null,
|
||
section: context.section ?? null,
|
||
objectId: context.objectId ?? null,
|
||
property: context.property ?? null,
|
||
message
|
||
});
|
||
this.entries.push(entry);
|
||
if (this.entries.length > this.limit) this.entries.shift();
|
||
this.onChange(this.list());
|
||
return entry;
|
||
}
|
||
|
||
info(code, message, context) { return this.add('info', code, message, context); }
|
||
warn(code, message, context) { return this.add('warning', code, message, context); }
|
||
error(code, message, context) { return this.add('error', code, message, context); }
|
||
list() { return [...this.entries]; }
|
||
clear() { this.entries.length = 0; this.onChange([]); }
|
||
}
|
||
|
||
/* src/runtime/rng.js */
|
||
function rotateLeft(value, count) {
|
||
return ((value << count) | (value >>> (32 - count))) >>> 0;
|
||
}
|
||
|
||
function multiply32(left, right) {
|
||
return Math.imul(left, right) >>> 0;
|
||
}
|
||
|
||
function fnv1a32(text) {
|
||
let hash = 0x811c9dc5;
|
||
for (const byte of new TextEncoder().encode(text)) {
|
||
hash ^= byte;
|
||
hash = multiply32(hash, 0x01000193);
|
||
}
|
||
return hash >>> 0;
|
||
}
|
||
|
||
function splitMix32(seed) {
|
||
let state = seed >>> 0;
|
||
return () => {
|
||
state = (state + 0x9e3779b9) >>> 0;
|
||
let value = state;
|
||
value = multiply32(value ^ (value >>> 16), 0x21f0aaad);
|
||
value = multiply32(value ^ (value >>> 15), 0x735a2d97);
|
||
return (value ^ (value >>> 15)) >>> 0;
|
||
};
|
||
}
|
||
|
||
function deriveStreamState(rootSeed, domain, stableInstanceKey) {
|
||
validateRootSeed(rootSeed);
|
||
if (!RNG_DOMAINS.includes(domain)) throw new RangeError(`Unknown XZBT random domain: ${domain}.`);
|
||
if (typeof stableInstanceKey !== 'string' || stableInstanceKey.length === 0) {
|
||
throw new TypeError('A non-empty stable instance key is required.');
|
||
}
|
||
const hash = fnv1a32(`xzbt-0.1\0${rootSeed}\0${domain}\0${stableInstanceKey}`);
|
||
const expand = splitMix32(hash);
|
||
const state = [expand(), expand(), expand(), expand()];
|
||
if (state.every((word) => word === 0)) state[3] = 1;
|
||
return state;
|
||
}
|
||
|
||
class RandomStream {
|
||
constructor(state) {
|
||
if (!Array.isArray(state) || state.length !== 4 || state.some((word) => !Number.isInteger(word))) {
|
||
throw new TypeError('xoshiro128** state must contain four integer words.');
|
||
}
|
||
this.state = state.map((word) => word >>> 0);
|
||
if (this.state.every((word) => word === 0)) throw new RangeError('xoshiro128** state cannot be all zero.');
|
||
}
|
||
|
||
nextUint32() {
|
||
const state = this.state;
|
||
const result = multiply32(rotateLeft(multiply32(state[1], 5), 7), 9);
|
||
const temporary = (state[1] << 9) >>> 0;
|
||
state[2] ^= state[0];
|
||
state[3] ^= state[1];
|
||
state[1] ^= state[2];
|
||
state[0] ^= state[3];
|
||
state[2] ^= temporary;
|
||
state[3] = rotateLeft(state[3], 11);
|
||
for (let index = 0; index < 4; index++) state[index] >>>= 0;
|
||
return result >>> 0;
|
||
}
|
||
|
||
nextFloat() { return this.nextUint32() / UINT32_RANGE; }
|
||
|
||
nextInteger(minimum, maximum) {
|
||
if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || maximum < minimum) {
|
||
throw new RangeError('Integer bounds must be safe integers with maximum >= minimum.');
|
||
}
|
||
const span = maximum - minimum + 1;
|
||
if (span > UINT32_RANGE) throw new RangeError('Integer range cannot exceed 2^32 values.');
|
||
const limit = UINT32_RANGE - (UINT32_RANGE % span);
|
||
let value;
|
||
do value = this.nextUint32(); while (value >= limit);
|
||
return minimum + (value % span);
|
||
}
|
||
}
|
||
|
||
class SeededRNG {
|
||
constructor(rootSeed) {
|
||
this.rootSeed = validateRootSeed(rootSeed);
|
||
}
|
||
|
||
stream(domain, stableInstanceKey) {
|
||
return new RandomStream(deriveStreamState(this.rootSeed, domain, stableInstanceKey));
|
||
}
|
||
}
|
||
|
||
function validateRootSeed(seed) {
|
||
if (!Number.isInteger(seed) || seed < 0 || seed >= UINT32_RANGE) {
|
||
throw new RangeError('Root seed must be an unsigned 32-bit integer.');
|
||
}
|
||
return seed >>> 0;
|
||
}
|
||
|
||
function resolveRootSeed(authoredSeed = 'random', cryptoProvider = globalThis.crypto) {
|
||
if (authoredSeed !== 'random') return validateRootSeed(authoredSeed);
|
||
if (!cryptoProvider?.getRandomValues) throw new Error('Cryptographic entropy is unavailable; a random exhibit seed cannot be resolved.');
|
||
return cryptoProvider.getRandomValues(new Uint32Array(1))[0] >>> 0;
|
||
}
|
||
|
||
/* src/runtime/types.js */
|
||
const PARAMETER_TYPES = Object.freeze(['number', 'integer', 'boolean', 'string', 'color', 'enum']);
|
||
const STATE_TYPES = Object.freeze(['number', 'integer', 'boolean', 'string']);
|
||
const EASINGS = Object.freeze(['linear', 'ease-in', 'ease-out', 'ease-in-out']);
|
||
const DURATION_PATTERN = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)$/;
|
||
const RUNTIME_SIGNAL_TYPES = Object.freeze({
|
||
'signals.time.elapsed': 'number',
|
||
'signals.time.delta': 'number',
|
||
'signals.audio.low': 'number',
|
||
'signals.audio.mid': 'number',
|
||
'signals.audio.high': 'number',
|
||
'signals.audio.energy': 'number',
|
||
'signals.pointer.x': 'number',
|
||
'signals.pointer.y': 'number',
|
||
'signals.viewport.width': 'number',
|
||
'signals.viewport.height': 'number',
|
||
'signals.scenario.active': 'boolean'
|
||
});
|
||
|
||
class RuntimeFault extends Error {
|
||
constructor(code, message, path = '$') {
|
||
super(message);
|
||
this.name = 'RuntimeFault';
|
||
this.code = code;
|
||
this.path = path;
|
||
}
|
||
}
|
||
|
||
function isRecord(value) {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function isNumericType(type) { return type === 'number' || type === 'integer'; }
|
||
|
||
function clamp(value, minimum = -Infinity, maximum = Infinity) {
|
||
return Math.min(maximum, Math.max(minimum, value));
|
||
}
|
||
|
||
function roundHalfAwayFromZero(value) {
|
||
return Math.sign(value) * Math.floor(Math.abs(value) + 0.5);
|
||
}
|
||
|
||
function easingValue(name, progress) {
|
||
const value = clamp(progress, 0, 1);
|
||
switch (name) {
|
||
case 'linear': return value;
|
||
case 'ease-in': return value * value;
|
||
case 'ease-out': return 1 - ((1 - value) * (1 - value));
|
||
case 'ease-in-out': return value < 0.5 ? 2 * value * value : 1 - (((-2 * value + 2) ** 2) / 2);
|
||
default: throw new RuntimeFault('ERR_INVALID_TRANSITION', `Unsupported easing '${name}'.`);
|
||
}
|
||
}
|
||
|
||
function lerp(from, to, amount) { return from + ((to - from) * amount); }
|
||
|
||
function parseDuration(value, path = '$') {
|
||
// C5 (6.1): a value that is already a number is a non-negative count of
|
||
// milliseconds and passes through. The literal form remains the authored one.
|
||
if (typeof value === 'number') {
|
||
if (!Number.isFinite(value) || value < 0) throw new RuntimeFault('ERR_INVALID_DURATION', `Duration ${value} is not a non-negative finite number of milliseconds.`, path);
|
||
return value;
|
||
}
|
||
if (typeof value !== 'string') throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration must be a duration literal or a finite number.', path);
|
||
const match = DURATION_PATTERN.exec(value);
|
||
if (!match) throw new RuntimeFault('ERR_INVALID_DURATION', `Invalid duration '${value}'.`, path);
|
||
const scalar = Number(match[1]);
|
||
const multipliers = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 };
|
||
const milliseconds = scalar * multipliers[match[2]];
|
||
if (!Number.isFinite(milliseconds)) throw new RuntimeFault('ERR_INVALID_DURATION', `Duration '${value}' is not finite.`, path);
|
||
return milliseconds;
|
||
}
|
||
|
||
function valueMatchesType(type, value, spec = {}) {
|
||
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
||
if (type === 'integer') return Number.isInteger(value);
|
||
if (type === 'boolean') return typeof value === 'boolean';
|
||
if (type === 'string') return typeof value === 'string';
|
||
if (type === 'color') return typeof value === 'string' && value.trim().length > 0;
|
||
if (type === 'enum') return typeof value === 'string' && Array.isArray(spec.values) && spec.values.includes(value);
|
||
return false;
|
||
}
|
||
|
||
function normalizeForSpec(spec, value, { clampNumeric = false } = {}) {
|
||
if (!valueMatchesType(spec.type, value, spec)) {
|
||
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Value ${JSON.stringify(value)} does not match type '${spec.type}'.`);
|
||
}
|
||
if (isNumericType(spec.type)) {
|
||
let result = value;
|
||
if (spec.type === 'integer') result = roundHalfAwayFromZero(result);
|
||
const minimum = spec.min ?? -Infinity;
|
||
const maximum = spec.max ?? Infinity;
|
||
if (!clampNumeric && (result < minimum || result > maximum)) {
|
||
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Value ${result} is outside [${minimum}, ${maximum}].`);
|
||
}
|
||
result = clamp(result, minimum, maximum);
|
||
return result;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
/* src/runtime/values.js */
|
||
const OPERATOR_ARITY = Object.freeze({
|
||
abs: 1, negate: 1, round: 1, floor: 1, ceil: 1,
|
||
add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2,
|
||
clamp: 3, lerp: 3
|
||
});
|
||
|
||
class ValueResolver {
|
||
constructor(resolveReference) {
|
||
this.resolveReference = resolveReference;
|
||
}
|
||
|
||
// Freeze procedural choices once, retaining only explicitly live component
|
||
// input references. Re-evaluating this tree never draws from an RNG.
|
||
sample(spec, stream, path = '$', retainReference = () => false) {
|
||
if (!isRecord(spec)) return this.evaluate(spec, stream, path);
|
||
if (Object.hasOwn(spec, 'ref')) return retainReference(spec.ref) ? { ref: spec.ref } : this.evaluate(spec, stream, path);
|
||
if (Object.hasOwn(spec, 'random')) return this.evaluate(spec, stream, path);
|
||
if (Object.hasOwn(spec, 'choose')) {
|
||
const index = this.evaluate({ choose: spec.choose.map((option, i) => ({ weight: option.weight, value: i })) }, stream, path);
|
||
return this.sample(spec.choose[index].value, stream, `${path}.choose[${index}].value`, retainReference);
|
||
}
|
||
if (Object.hasOwn(spec, 'op')) return { op: spec.op, args: spec.args.map((arg, i) => this.sample(arg, stream, `${path}.args[${i}]`, retainReference)) };
|
||
return this.evaluate(spec, stream, path);
|
||
}
|
||
|
||
evaluate(spec, stream, path = '$') {
|
||
if (typeof spec === 'number') {
|
||
if (!Number.isFinite(spec)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Numeric literals must be finite.', path);
|
||
return spec;
|
||
}
|
||
if (typeof spec === 'string' || typeof spec === 'boolean') return spec;
|
||
if (!isRecord(spec)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Invalid ValueSpec.', path);
|
||
|
||
if (Object.hasOwn(spec, 'ref')) return this.resolveReference(spec.ref);
|
||
if (Object.hasOwn(spec, 'random')) {
|
||
if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Random ValueSpec requires an owning random stream.', path);
|
||
const { min, max, integer = false } = spec.random;
|
||
if (!Number.isFinite(min) || !Number.isFinite(max) || max < min || typeof integer !== 'boolean') {
|
||
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Random range requires finite min <= max and an optional boolean integer flag.', `${path}.random`);
|
||
}
|
||
if (integer && Math.ceil(min) > Math.floor(max)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Integer random range contains no integers.', `${path}.random`);
|
||
return integer ? stream.nextInteger(Math.ceil(min), Math.floor(max)) : min + (stream.nextFloat() * (max - min));
|
||
}
|
||
if (Object.hasOwn(spec, 'choose')) {
|
||
if (!stream || !Array.isArray(spec.choose) || spec.choose.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Weighted choice requires a non-empty list and an owning stream.', `${path}.choose`);
|
||
let total = 0;
|
||
for (const option of spec.choose) {
|
||
if (!isRecord(option) || !Number.isFinite(option.weight) || option.weight <= 0 || !Object.hasOwn(option, 'value')) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Every choice requires a value and a finite positive weight.', `${path}.choose`);
|
||
total += option.weight;
|
||
}
|
||
const selected = stream.nextFloat() * total;
|
||
let cumulative = 0;
|
||
for (let index = 0; index < spec.choose.length; index += 1) {
|
||
cumulative += spec.choose[index].weight;
|
||
if (selected < cumulative || index === spec.choose.length - 1) return this.evaluate(spec.choose[index].value, stream, `${path}.choose[${index}].value`);
|
||
}
|
||
}
|
||
if (Object.hasOwn(spec, 'op')) {
|
||
const arity = OPERATOR_ARITY[spec.op];
|
||
if (!arity) throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported ValueSpec operator '${spec.op}'.`, `${path}.op`);
|
||
if (!Array.isArray(spec.args) || spec.args.length !== arity) throw new RuntimeFault('ERR_INVALID_ARITY', `Operator '${spec.op}' requires ${arity} arguments.`, `${path}.args`);
|
||
const values = spec.args.map((argument, index) => this.evaluate(argument, stream, `${path}.args[${index}]`));
|
||
if (values.some((value) => typeof value !== 'number' || !Number.isFinite(value))) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Operator '${spec.op}' requires finite numbers.`, path);
|
||
let result;
|
||
switch (spec.op) {
|
||
case 'abs': result = Math.abs(values[0]); break;
|
||
case 'negate': result = -values[0]; break;
|
||
case 'round': result = Math.round(values[0]); break;
|
||
case 'floor': result = Math.floor(values[0]); break;
|
||
case 'ceil': result = Math.ceil(values[0]); break;
|
||
case 'add': result = values[0] + values[1]; break;
|
||
case 'subtract': result = values[0] - values[1]; break;
|
||
case 'multiply': result = values[0] * values[1]; break;
|
||
case 'divide': result = values[1] === 0 ? 0 : values[0] / values[1]; break;
|
||
case 'min': result = Math.min(values[0], values[1]); break;
|
||
case 'max': result = Math.max(values[0], values[1]); break;
|
||
case 'clamp':
|
||
if (values[1] > values[2]) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Clamp minimum cannot exceed maximum.', path);
|
||
result = clamp(values[0], values[1], values[2]);
|
||
break;
|
||
case 'lerp': result = values[0] + ((values[1] - values[0]) * values[2]); break;
|
||
}
|
||
if (!Number.isFinite(result)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Operator '${spec.op}' produced a non-finite result.`, path);
|
||
return result;
|
||
}
|
||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Unrecognized ValueSpec form.', path);
|
||
}
|
||
}
|
||
|
||
class ConditionEvaluator {
|
||
constructor(valueResolver) { this.values = valueResolver; }
|
||
|
||
evaluate(condition, stream, path = '$') {
|
||
if (!isRecord(condition)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'ConditionSpec must be an object.', path);
|
||
if (Object.hasOwn(condition, 'and')) {
|
||
if (!Array.isArray(condition.and) || condition.and.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'and requires a non-empty array.', `${path}.and`);
|
||
return condition.and.every((child, index) => this.evaluate(child, stream, `${path}.and[${index}]`));
|
||
}
|
||
if (Object.hasOwn(condition, 'or')) {
|
||
if (!Array.isArray(condition.or) || condition.or.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'or requires a non-empty array.', `${path}.or`);
|
||
return condition.or.some((child, index) => this.evaluate(child, stream, `${path}.or[${index}]`));
|
||
}
|
||
if (Object.hasOwn(condition, 'not')) return !this.evaluate(condition.not, stream, `${path}.not`);
|
||
const comparisons = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
|
||
if (!comparisons.includes(condition.op) || !Object.hasOwn(condition, 'left') || !Object.hasOwn(condition, 'right')) throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported comparison operator '${condition.op}'.`, `${path}.op`);
|
||
const left = this.values.evaluate(condition.left, stream, `${path}.left`);
|
||
const right = this.values.evaluate(condition.right, stream, `${path}.right`);
|
||
if (typeof left !== typeof right || !['number', 'boolean', 'string'].includes(typeof left)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Condition operands must have compatible primitive types.', path);
|
||
switch (condition.op) {
|
||
case 'eq': return left === right;
|
||
case 'ne': return left !== right;
|
||
case 'gt': return left > right;
|
||
case 'gte': return left >= right;
|
||
case 'lt': return left < right;
|
||
case 'lte': return left <= right;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* src/runtime/audio-contract.js */
|
||
// Audio Subsystem Contract 0.1 - declarative tables shared by validation, expansion,
|
||
// instantiation, and realization. Format Specification 0.1 revision 0.4, sections 14-15.
|
||
|
||
const AUDIO_STATIC_MAX_FREQUENCY = 24000;
|
||
const AUDIO_NYQUIST_FACTOR = 0.45;
|
||
|
||
// Engine-owned candidate values; hardware/listening acceptance remains GC6.
|
||
const AUDIO_PROTECTION = Object.freeze({
|
||
ceilingDb: -1,
|
||
toleranceDb: 0.1,
|
||
lookaheadMs: 5,
|
||
attackMs: 0.5,
|
||
releaseMs: 250,
|
||
channels: 2
|
||
});
|
||
|
||
const AUDIO_LIMITS = Object.freeze({
|
||
nodesPerSound: 128,
|
||
routesPerSound: 256,
|
||
componentDepth: 8,
|
||
resonatorModes: 16,
|
||
oscillatorPartials: 64,
|
||
automationTracks: 64,
|
||
automationPoints: 256
|
||
});
|
||
|
||
const AUDIO_NOISE_COLORS = Object.freeze(['white', 'pink', 'brown']);
|
||
const AUDIO_RESERVED_NODE_KEYS = Object.freeze(['output', 'input']);
|
||
|
||
function num(min, max, fallback, extra = {}) {
|
||
return { kind: 'number', min, max, default: fallback, valuespec: true, ...extra };
|
||
}
|
||
function enumeration(values, fallback) {
|
||
return { kind: 'enum', values: Object.freeze(values), default: fallback, valuespec: false };
|
||
}
|
||
function duration(minMs, maxMs, fallback) {
|
||
return { kind: 'duration', min: minMs, max: maxMs, default: fallback, valuespec: false };
|
||
}
|
||
|
||
// `ceiling: 'audio'` marks a field validated against AUDIO_STATIC_MAX_FREQUENCY at the
|
||
// semantic stage and clamped to the live audioMaxFrequency at instantiation (section 14.5).
|
||
const AUDIO_NODE_TYPES = Object.freeze({
|
||
oscillator: {
|
||
class: 'source', acceptsAudio: false,
|
||
fields: {
|
||
waveform: enumeration(['sine', 'triangle', 'square', 'sawtooth', 'custom'], 'sine'),
|
||
frequency: num(0.1, AUDIO_STATIC_MAX_FREQUENCY, 440, { ceiling: 'audio' }),
|
||
detune: num(-4800, 4800, 0),
|
||
harmonics: { kind: 'partials', valuespec: false, requiredWhen: { waveform: 'custom' } }
|
||
}
|
||
},
|
||
noise: {
|
||
class: 'source', acceptsAudio: false,
|
||
fields: { color: enumeration(AUDIO_NOISE_COLORS, 'white') }
|
||
},
|
||
impulse: {
|
||
class: 'source', acceptsAudio: false,
|
||
fields: {
|
||
color: enumeration(AUDIO_NOISE_COLORS, 'white'),
|
||
duration: duration(1, 500, '10ms'),
|
||
amplitude: num(0, 1, 1),
|
||
decay: enumeration(['flat', 'linear', 'exponential'], 'exponential')
|
||
}
|
||
},
|
||
constant: {
|
||
class: 'control', acceptsAudio: false,
|
||
fields: { value: num(-1000, 1000, 1) }
|
||
},
|
||
lfo: {
|
||
class: 'control', acceptsAudio: false,
|
||
fields: {
|
||
waveform: enumeration(['sine', 'triangle', 'square', 'sawtooth'], 'sine'),
|
||
frequency: num(0.001, 40, 1),
|
||
amplitude: num(0, 1000, 1),
|
||
polarity: enumeration(['bipolar', 'unipolar'], 'bipolar'),
|
||
phase: num(0, 360, 0, { exclusiveMax: true })
|
||
}
|
||
},
|
||
'sample-hold': {
|
||
class: 'control', acceptsAudio: false,
|
||
fields: {
|
||
rate: num(0.01, 100, 2),
|
||
min: num(-1000, 1000, -1),
|
||
max: num(-1000, 1000, 1),
|
||
slew: duration(0, 1000, '0ms')
|
||
},
|
||
rangeOrder: ['min', 'max']
|
||
},
|
||
gain: {
|
||
class: 'processing', acceptsAudio: true,
|
||
fields: { gain: num(0, 4, 1) }
|
||
},
|
||
filter: {
|
||
class: 'processing', acceptsAudio: true,
|
||
fields: {
|
||
mode: enumeration(['lowpass', 'highpass', 'bandpass', 'notch', 'peaking', 'lowshelf', 'highshelf', 'allpass'], 'lowpass'),
|
||
frequency: num(10, AUDIO_STATIC_MAX_FREQUENCY, 1000, { ceiling: 'audio' }),
|
||
q: num(0.0001, 100, 1),
|
||
gain: num(-40, 40, 0),
|
||
detune: num(-4800, 4800, 0)
|
||
}
|
||
},
|
||
compressor: {
|
||
class: 'processing', acceptsAudio: true,
|
||
fields: {
|
||
threshold: num(-100, 0, -24),
|
||
knee: num(0, 40, 30),
|
||
ratio: num(1, 20, 12),
|
||
attack: duration(0, 1000, '3ms'),
|
||
release: duration(10, 1000, '250ms')
|
||
}
|
||
},
|
||
waveshaper: {
|
||
class: 'processing', acceptsAudio: true,
|
||
fields: {
|
||
shape: enumeration(['soft-clip', 'hard-clip', 'saturation'], 'soft-clip'),
|
||
amount: num(0, 1, 0.5),
|
||
oversample: enumeration(['none', '2x', '4x'], 'none')
|
||
}
|
||
},
|
||
delay: {
|
||
class: 'processing', acceptsAudio: true,
|
||
fields: {
|
||
time: duration(0, 10000, '250ms'),
|
||
feedback: num(0, 0.95, 0.2),
|
||
mix: num(0, 1, 0.5)
|
||
}
|
||
},
|
||
reverb: {
|
||
class: 'processing', acceptsAudio: true,
|
||
fields: {
|
||
size: num(0, 1, 0.5),
|
||
decay: duration(50, 30000, '2s'),
|
||
damping: num(0, 1, 0.5),
|
||
predelay: duration(0, 500, '0ms'),
|
||
mix: num(0, 1, 0.25)
|
||
}
|
||
},
|
||
'stereo-pan': {
|
||
class: 'processing', acceptsAudio: true,
|
||
fields: { pan: num(-1, 1, 0) }
|
||
},
|
||
mixer: {
|
||
class: 'routing', acceptsAudio: true,
|
||
fields: {}
|
||
},
|
||
resonator: {
|
||
class: 'processing', acceptsAudio: true,
|
||
fields: {
|
||
fundamental: num(0.1, AUDIO_STATIC_MAX_FREQUENCY, 120, { ceiling: 'audio' }),
|
||
modes: { kind: 'modes', valuespec: false, required: true },
|
||
mix: num(0, 1, 1)
|
||
}
|
||
},
|
||
component: {
|
||
class: 'composite', acceptsAudio: 'declared',
|
||
fields: {
|
||
use: { kind: 'component-ref', valuespec: false, required: true },
|
||
values: { kind: 'component-values', valuespec: false }
|
||
}
|
||
}
|
||
});
|
||
|
||
const AUDIO_NODE_TYPE_NAMES = Object.freeze(Object.keys(AUDIO_NODE_TYPES));
|
||
|
||
const AUDIO_PARTIAL_FIELDS = Object.freeze({
|
||
ratio: { min: 0.001, max: 256, required: true },
|
||
gain: { min: 0, max: 1, required: true },
|
||
phase: { min: 0, max: 360, default: 0, exclusiveMax: true }
|
||
});
|
||
|
||
const AUDIO_MODE_FIELDS = Object.freeze({
|
||
ratio: { min: 0.001, max: 256 },
|
||
frequency: { min: 0.1, max: AUDIO_STATIC_MAX_FREQUENCY, ceiling: 'audio' },
|
||
gain: { min: 0, max: 1, default: 1 },
|
||
decay: { kind: 'duration', min: 10, max: 20000, default: '1s' }
|
||
});
|
||
|
||
// Section 15.13. Depth is expressed in the listed unit; anything absent is ERR_UNSUPPORTED_TARGET.
|
||
const AUDIO_MODULATABLE = Object.freeze({
|
||
oscillator: Object.freeze({ frequency: 'hz', detune: 'cents' }),
|
||
gain: Object.freeze({ gain: 'linear' }),
|
||
filter: Object.freeze({ frequency: 'hz', q: 'unitless', gain: 'db', detune: 'cents' }),
|
||
delay: Object.freeze({ time: 'ms' }),
|
||
'stereo-pan': Object.freeze({ pan: 'pan' }),
|
||
resonator: Object.freeze({ fundamental: 'hz' })
|
||
});
|
||
|
||
const AUDIO_MODULATION_SOURCE_TYPES = Object.freeze(['constant', 'lfo', 'sample-hold', 'oscillator']);
|
||
|
||
const AUDIO_SOUND_FIELDS = Object.freeze(['name', 'tags', 'usage', 'cadence', 'bus', 'recipe']);
|
||
const AUDIO_SOUND_USAGE = Object.freeze(['automatic', 'manual', 'scenario']);
|
||
const AUDIO_RECIPE_MODES = Object.freeze(['oneshot', 'continuous']);
|
||
const AUDIO_GRAPH_FIELDS = Object.freeze(['nodes', 'routes', 'automation']);
|
||
const AUDIO_RECIPE_FIELDS = Object.freeze(['nodes', 'routes', 'automation', 'mode', 'release']);
|
||
const AUDIO_COMPONENT_FIELDS = Object.freeze(['nodes', 'routes', 'automation', 'parameters', 'input']);
|
||
const AUDIO_AUTOMATION_FIELDS = Object.freeze(['target', 'mode', 'interpolation', 'points']);
|
||
const AUDIO_AUTOMATION_MODES = Object.freeze(['absolute', 'offset', 'scale']);
|
||
const AUDIO_AUTOMATION_CURVES = Object.freeze(['step', 'linear', 'exponential', 'smooth']);
|
||
const AUDIO_COMPONENT_PARAMETER_FIELDS = Object.freeze(['type', 'default', 'min', 'max', 'unit']);
|
||
const AUDIO_BUS_FIELDS = Object.freeze(['gain']);
|
||
const AUDIO_BUS_GAIN_RANGE = Object.freeze({ min: 0, max: 4, default: 1 });
|
||
const AUDIO_ROUTE_FIELDS = Object.freeze(['from', 'to', 'depth']);
|
||
|
||
const AUDIO_VOICE_LIMITS = Object.freeze({
|
||
oneshot: 64,
|
||
continuous: 16
|
||
});
|
||
|
||
const AUDIO_DEFAULT_RELEASE_MS = 50;
|
||
const AUDIO_MAX_RELEASE_MS = 10000;
|
||
|
||
const AUDIO_LIFECYCLE_STATES = Object.freeze([
|
||
'CREATED',
|
||
'SCHEDULED',
|
||
'ACTIVE',
|
||
'RELEASING',
|
||
'FINISHED',
|
||
'DISPOSED',
|
||
'FAILED'
|
||
]);
|
||
|
||
const AUDIO_LIFECYCLE_TRANSITIONS = Object.freeze({
|
||
CREATED: Object.freeze(['SCHEDULED', 'FINISHED', 'FAILED']),
|
||
SCHEDULED: Object.freeze(['ACTIVE', 'RELEASING', 'FAILED']),
|
||
ACTIVE: Object.freeze(['RELEASING', 'FINISHED', 'FAILED']),
|
||
RELEASING: Object.freeze(['FINISHED', 'FAILED']),
|
||
FINISHED: Object.freeze(['DISPOSED']),
|
||
DISPOSED: Object.freeze([]),
|
||
FAILED: Object.freeze([])
|
||
});
|
||
|
||
function audioMaxFrequency(sampleRate) {
|
||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return AUDIO_STATIC_MAX_FREQUENCY;
|
||
return Math.min(AUDIO_STATIC_MAX_FREQUENCY, sampleRate * AUDIO_NYQUIST_FACTOR);
|
||
}
|
||
|
||
function isControlSourceType(type) {
|
||
return AUDIO_NODE_TYPES[type]?.class === 'control';
|
||
}
|
||
|
||
function isSourceType(type) {
|
||
const entry = AUDIO_NODE_TYPES[type];
|
||
return entry?.class === 'source' || entry?.class === 'control';
|
||
}
|
||
|
||
/* src/runtime/audio-protection.js */
|
||
// This exact class is serialized into the embedded worklet and exercised in tests.
|
||
// No browser globals or imports are used by the DSP core.
|
||
class MasterProtectionDSP {
|
||
constructor(rate, settings) {
|
||
this.ceiling = 10 ** (settings.ceilingDb / 20);
|
||
this.delayFrames = Math.max(1, Math.ceil(rate * settings.lookaheadMs / 1000));
|
||
this.delay = Array.from({ length: settings.channels }, () => new Float32Array(this.delayFrames));
|
||
this.attack = Math.exp(-1 / (rate * settings.attackMs / 1000));
|
||
this.release = Math.exp(-1 / (rate * settings.releaseMs / 1000));
|
||
this.position = 0;
|
||
this.gain = 1;
|
||
this.heldPeak = 0;
|
||
this.hold = 0;
|
||
this.affectedBlocks = 0;
|
||
}
|
||
|
||
process(input, output, fault = false) {
|
||
let badSamples = 0;
|
||
for (const channel of input) for (const sample of channel) if (!Number.isFinite(sample)) badSamples++;
|
||
const muted = fault || badSamples > 0;
|
||
if (muted) {
|
||
// Flush pending audio as well: no corrupted history can reappear later.
|
||
for (const channel of this.delay) channel.fill(0);
|
||
for (const channel of output) channel.fill(0);
|
||
this.heldPeak = 0;
|
||
this.hold = 0;
|
||
this.affectedBlocks++;
|
||
return { muted, badSamples, peak: 0, clampedSamples: 0 };
|
||
}
|
||
let peak = 0, clampedSamples = 0;
|
||
for (let i = 0; i < output[0].length; i++) {
|
||
let incomingPeak = 0;
|
||
for (const channel of input) incomingPeak = Math.max(incomingPeak, Math.abs(channel[i] ?? 0));
|
||
if (incomingPeak >= this.heldPeak) {
|
||
this.heldPeak = incomingPeak;
|
||
this.hold = this.delayFrames;
|
||
} else if (this.hold > 0) this.hold--;
|
||
else this.heldPeak = incomingPeak;
|
||
const target = this.heldPeak > this.ceiling ? this.ceiling / this.heldPeak : 1;
|
||
const coefficient = target < this.gain ? this.attack : this.release;
|
||
this.gain = target + coefficient * (this.gain - target);
|
||
for (let c = 0; c < output.length; c++) {
|
||
const delayed = this.delay[c][this.position];
|
||
this.delay[c][this.position] = input[c]?.[i] ?? 0;
|
||
const value = delayed * this.gain;
|
||
if (Math.abs(value) > this.ceiling) clampedSamples++;
|
||
// Final sample clamp is required even during attack and extreme overload.
|
||
output[c][i] = Math.max(-this.ceiling, Math.min(this.ceiling, value));
|
||
peak = Math.max(peak, Math.abs(output[c][i]));
|
||
}
|
||
this.position = (this.position + 1) % this.delayFrames;
|
||
}
|
||
return { muted, badSamples, peak, clampedSamples };
|
||
}
|
||
}
|
||
|
||
function protectionWorkletSource() {
|
||
return `const SETTINGS = ${JSON.stringify(AUDIO_PROTECTION)};
|
||
${MasterProtectionDSP.toString()}
|
||
class XZBTProtectionProcessor extends AudioWorkletProcessor {
|
||
constructor(options) {
|
||
super();
|
||
this.guard = options.processorOptions?.guard === true;
|
||
this.dsp = this.guard ? null : new MasterProtectionDSP(sampleRate, SETTINGS);
|
||
this.warned = false;
|
||
this.capture = null;
|
||
this.port.onmessage = ({ data }) => {
|
||
if (data.type === 'capture' && !this.guard) {
|
||
this.capture = { id: data.id, skip: data.warmupFrames, remaining: data.frames,
|
||
frames: 0, peak: 0, affectedBlocks: 0, nonfiniteSamples: 0, clampedSamples: 0 };
|
||
}
|
||
};
|
||
}
|
||
process(inputs, outputs) {
|
||
const input = inputs[0] ?? [], output = outputs[0];
|
||
let result;
|
||
if (this.guard) {
|
||
let badSamples = 0;
|
||
for (const channel of input) for (const sample of channel) if (!Number.isFinite(sample)) badSamples++;
|
||
for (let c = 0; c < output.length; c++) {
|
||
output[c].fill(0);
|
||
if (!badSamples && input[c]) output[c].set(input[c]);
|
||
}
|
||
// Separate finite fault lane lets the final master mute the same whole
|
||
// render quantum, while attribution remains attached to this voice.
|
||
outputs[1][0].fill(badSamples);
|
||
result = { badSamples, muted: badSamples > 0 };
|
||
} else {
|
||
const fault = (inputs[1] ?? []).some(channel => channel.some(value => value !== 0));
|
||
result = this.dsp.process(input, output, fault);
|
||
const capture = this.capture;
|
||
if (capture) {
|
||
const start = Math.min(capture.skip, output[0].length);
|
||
capture.skip -= start;
|
||
const count = Math.min(capture.remaining, output[0].length - start);
|
||
if (count > 0) {
|
||
for (const channel of output) for (let i = start; i < start + count; i++) capture.peak = Math.max(capture.peak, Math.abs(channel[i]));
|
||
capture.frames += count;
|
||
capture.remaining -= count;
|
||
capture.affectedBlocks += result.muted ? 1 : 0;
|
||
capture.nonfiniteSamples += result.badSamples + (inputs[1]?.[0]?.[0] ?? 0);
|
||
capture.clampedSamples += result.clampedSamples;
|
||
}
|
||
if (capture.remaining === 0) {
|
||
this.port.postMessage({ type: 'capture', ...capture, sampleRate });
|
||
this.capture = null;
|
||
}
|
||
}
|
||
}
|
||
if (result.badSamples > 0 && !this.warned) {
|
||
this.warned = true;
|
||
this.port.postMessage({ type: 'nonfinite' });
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
registerProcessor('xzbt-protection', XZBTProtectionProcessor);`;
|
||
}
|
||
|
||
async function loadProtectionWorklet(context) {
|
||
if (!context.audioWorklet || typeof globalThis.AudioWorkletNode !== 'function') {
|
||
throw new Error('AudioWorklet master protection is unavailable.');
|
||
}
|
||
// The Phase 0 direct-file probe verified this embedded data-URL loading path.
|
||
await context.audioWorklet.addModule(`data:text/javascript;charset=utf-8,${encodeURIComponent(protectionWorkletSource())}`);
|
||
}
|
||
|
||
function createProtectionNode(context, guard = false) {
|
||
return new AudioWorkletNode(context, 'xzbt-protection', {
|
||
numberOfInputs: guard ? 1 : 2,
|
||
numberOfOutputs: guard ? 2 : 1,
|
||
outputChannelCount: guard ? [AUDIO_PROTECTION.channels, 1] : [AUDIO_PROTECTION.channels],
|
||
channelCount: AUDIO_PROTECTION.channels,
|
||
channelCountMode: 'explicit',
|
||
processorOptions: { guard }
|
||
});
|
||
}
|
||
|
||
/* src/runtime/audio-automation.js */
|
||
// Shared numeric stages and immutable, once-sampled automation (spec 8.1 / 16.1).
|
||
|
||
|
||
function sampleAutomationTrack(definition, evaluate, warn = () => {}) {
|
||
const mode = definition.mode ?? 'absolute';
|
||
const interpolation = definition.interpolation ?? 'linear';
|
||
if (!AUDIO_AUTOMATION_MODES.includes(mode) || !AUDIO_AUTOMATION_CURVES.includes(interpolation)) {
|
||
throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Invalid automation mode or interpolation.', definition.path);
|
||
}
|
||
if (!Array.isArray(definition.points) || definition.points.length < 2) {
|
||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Automation requires at least two points.', definition.path);
|
||
}
|
||
if (definition.points.length > AUDIO_LIMITS.automationPoints) {
|
||
throw new RuntimeFault('ERR_NODE_LIMIT_EXCEEDED', 'Automation exceeds the point limit.', definition.path);
|
||
}
|
||
let previous = -1;
|
||
const points = definition.points.map((point, index) => {
|
||
const path = `${definition.path ?? definition.target}.points[${index}]`;
|
||
if (typeof point.at !== 'string') throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation time must be a duration literal.', path);
|
||
const at = parseDuration(point.at, path);
|
||
if (at <= previous) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'Automation times must strictly increase.', path);
|
||
previous = at;
|
||
const value = evaluate(point.value, `${path}.value`);
|
||
if (typeof value !== 'number' || !Number.isFinite(value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation values must resolve to finite numbers.', path);
|
||
return Object.freeze({ at, value });
|
||
});
|
||
if (interpolation === 'exponential' && points.some((point) => point.value <= 0)) {
|
||
warn({ code: 'WARN_AUTOMATION_FALLBACK', path: definition.path ?? definition.target, message: 'Nonpositive exponential endpoints use linear interpolation (once per track).' });
|
||
}
|
||
return Object.freeze({ ...definition, mode, interpolation, points: Object.freeze(points) });
|
||
}
|
||
|
||
function interpolateAutomation(curve, v0, v1, t) {
|
||
if (curve === 'step') return v0;
|
||
if (curve === 'exponential' && v0 > 0 && v1 > 0) return Math.exp(Math.log(v0) * (1 - t) + Math.log(v1) * t);
|
||
const progress = curve === 'smooth' ? t * t * (3 - 2 * t) : t;
|
||
return v0 * (1 - progress) + v1 * progress;
|
||
}
|
||
|
||
function automationValueAt(track, milliseconds) {
|
||
const points = track.points;
|
||
if (track.loop) {
|
||
const period = points.at(-1).at;
|
||
const pingPong = track.loop.mode === 'ping-pong';
|
||
const cycle = period * (pingPong ? 2 : 1);
|
||
const count = track.loop.count ?? 'infinite';
|
||
if (count !== 'infinite' && milliseconds >= cycle * count) return pingPong ? points[0].value : points.at(-1).value;
|
||
milliseconds = Math.max(0, milliseconds) % cycle;
|
||
if (pingPong && milliseconds > period) milliseconds = cycle - milliseconds;
|
||
}
|
||
if (milliseconds <= points[0].at) return points[0].value;
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
const left = points[index - 1], right = points[index];
|
||
if (milliseconds < right.at) return interpolateAutomation(track.interpolation, left.value, right.value, (milliseconds - left.at) / (right.at - left.at));
|
||
}
|
||
return points[points.length - 1].value;
|
||
}
|
||
|
||
function applyAutomationMode(base, value, mode) {
|
||
return mode === 'offset' ? base + value : mode === 'scale' ? base * value : value;
|
||
}
|
||
|
||
// Callers supply only stages their target exposes; the override sees the current
|
||
// lower value on every evaluation, even while it masks automation.
|
||
function resolveNumericStages(base, { binding, automation, override, modulation = 0, min = -Infinity, max = Infinity, round } = {}) {
|
||
let value = binding ? binding(base) : base;
|
||
if (automation) value = automation(value);
|
||
if (override) value = override(value);
|
||
value += modulation;
|
||
if (!Number.isFinite(value)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Numeric resolution produced a non-finite value.');
|
||
if (round) value = round(value);
|
||
return clamp(value, min, max);
|
||
}
|
||
|
||
/* src/runtime/audio-graph.js */
|
||
// Audio graph validation, component expansion, legality checking, and deterministic
|
||
// instantiation. Pure: never touches an AudioContext (Format Specification 14.5).
|
||
|
||
|
||
|
||
|
||
const DURATION_UNITS = Object.freeze({ ms: 1, s: 1000, m: 60_000, h: 3_600_000 });
|
||
|
||
function isObject(value) {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function durationMilliseconds(value) {
|
||
if (typeof value !== 'string') return null;
|
||
const match = DURATION_PATTERN.exec(value);
|
||
if (!match) return null;
|
||
const milliseconds = Number(match[1]) * DURATION_UNITS[match[2]];
|
||
return Number.isFinite(milliseconds) ? milliseconds : null;
|
||
}
|
||
|
||
function fail(errors, code, path, message) {
|
||
errors.push({ code, path, message });
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Structural validation of a single graph object (section 14.3)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function validateNumericField(document, value, spec, path, errors, helpers, scope) {
|
||
if (typeof value === 'number') {
|
||
if (!Number.isFinite(value)) return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Numeric field must be finite.');
|
||
const overMax = spec.exclusiveMax ? value >= spec.max : value > spec.max;
|
||
if (value < spec.min || overMax) {
|
||
fail(errors, 'ERR_OUT_OF_BOUNDS', path, `Value ${value} is outside [${spec.min}, ${spec.max}${spec.exclusiveMax ? ')' : ']'}.`);
|
||
}
|
||
return;
|
||
}
|
||
if (!spec.valuespec) return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Field requires a literal number.');
|
||
helpers.validateValueSpec(document, value, path, errors, scope);
|
||
}
|
||
|
||
function validateDurationField(value, spec, path, errors) {
|
||
const milliseconds = durationMilliseconds(value);
|
||
if (milliseconds === null) return fail(errors, 'ERR_INVALID_DURATION', path, `Invalid duration ${JSON.stringify(value)}.`);
|
||
if (milliseconds < spec.min || milliseconds > spec.max) {
|
||
fail(errors, 'ERR_OUT_OF_BOUNDS', path, `Duration ${value} is outside [${spec.min}ms, ${spec.max}ms].`);
|
||
}
|
||
}
|
||
|
||
function validatePartials(document, node, path, errors, helpers, scope) {
|
||
const waveform = node.waveform ?? 'sine';
|
||
const present = Object.hasOwn(node, 'harmonics');
|
||
if (waveform !== 'custom') {
|
||
if (present) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.harmonics`, "harmonics is only declared when waveform is 'custom'.");
|
||
return;
|
||
}
|
||
if (!present) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.harmonics`, "waveform 'custom' requires harmonics.");
|
||
const partials = node.harmonics;
|
||
if (!Array.isArray(partials) || partials.length === 0) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.harmonics`, 'harmonics must be a non-empty array.');
|
||
if (partials.length > AUDIO_LIMITS.oscillatorPartials) {
|
||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', `${path}.harmonics`, `At most ${AUDIO_LIMITS.oscillatorPartials} partials are permitted.`);
|
||
}
|
||
partials.forEach((partial, index) => {
|
||
const entryPath = `${path}.harmonics[${index}]`;
|
||
if (!isObject(partial)) return fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Partial must be an object.');
|
||
for (const field of Object.keys(partial)) {
|
||
if (!Object.hasOwn(AUDIO_PARTIAL_FIELDS, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${entryPath}.${field}`, `Unrecognized partial field '${field}'.`);
|
||
}
|
||
for (const [field, rule] of Object.entries(AUDIO_PARTIAL_FIELDS)) {
|
||
if (!Object.hasOwn(partial, field)) {
|
||
if (rule.required) fail(errors, 'ERR_SCHEMA_VALIDATION', `${entryPath}.${field}`, `Partial requires '${field}'.`);
|
||
continue;
|
||
}
|
||
validateNumericField(document, partial[field], { ...rule, valuespec: true }, `${entryPath}.${field}`, errors, helpers, scope);
|
||
}
|
||
});
|
||
}
|
||
|
||
function validateModes(document, node, path, errors, helpers, scope) {
|
||
const modes = node.modes;
|
||
if (!Array.isArray(modes) || modes.length === 0) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.modes`, 'resonator requires a non-empty modes array.');
|
||
if (modes.length > AUDIO_LIMITS.resonatorModes) {
|
||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', `${path}.modes`, `At most ${AUDIO_LIMITS.resonatorModes} resonator modes are permitted.`);
|
||
}
|
||
modes.forEach((mode, index) => {
|
||
const entryPath = `${path}.modes[${index}]`;
|
||
if (!isObject(mode)) return fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Mode must be an object.');
|
||
for (const field of Object.keys(mode)) {
|
||
if (!Object.hasOwn(AUDIO_MODE_FIELDS, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${entryPath}.${field}`, `Unrecognized mode field '${field}'.`);
|
||
}
|
||
const hasRatio = Object.hasOwn(mode, 'ratio');
|
||
const hasFrequency = Object.hasOwn(mode, 'frequency');
|
||
if (hasRatio === hasFrequency) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', entryPath, 'Each mode declares exactly one of ratio or frequency.');
|
||
}
|
||
if (hasRatio) validateNumericField(document, mode.ratio, { ...AUDIO_MODE_FIELDS.ratio, valuespec: true }, `${entryPath}.ratio`, errors, helpers, scope);
|
||
if (hasFrequency) validateNumericField(document, mode.frequency, { ...AUDIO_MODE_FIELDS.frequency, valuespec: true }, `${entryPath}.frequency`, errors, helpers, scope);
|
||
if (Object.hasOwn(mode, 'gain')) validateNumericField(document, mode.gain, { ...AUDIO_MODE_FIELDS.gain, valuespec: true }, `${entryPath}.gain`, errors, helpers, scope);
|
||
if (Object.hasOwn(mode, 'decay')) validateDurationField(mode.decay, AUDIO_MODE_FIELDS.decay, `${entryPath}.decay`, errors);
|
||
});
|
||
}
|
||
|
||
function validateComponentInstance(document, node, path, errors, helpers, scope) {
|
||
const components = document.components?.audio;
|
||
if (typeof node.use !== 'string' || !isObject(components?.[node.use])) {
|
||
return fail(errors, 'ERR_INVALID_REFERENCE', `${path}.use`, `Component '${node.use}' is not declared.`);
|
||
}
|
||
const declared = components[node.use].parameters ?? {};
|
||
const values = node.values;
|
||
if (values !== undefined && !isObject(values)) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values`, 'values must be an object.');
|
||
for (const [key, value] of Object.entries(values ?? {})) {
|
||
const rule = declared[key];
|
||
if (!isObject(rule)) {
|
||
fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.values.${key}`, `Component '${node.use}' does not expose '${key}'.`);
|
||
continue;
|
||
}
|
||
validateNumericField(document, value, {
|
||
min: rule.min ?? -Infinity, max: rule.max ?? Infinity, valuespec: true
|
||
}, `${path}.values.${key}`, errors, helpers, scope);
|
||
}
|
||
for (const [key, rule] of Object.entries(declared)) {
|
||
if (!Object.hasOwn(values ?? {}, key) && !Object.hasOwn(rule, 'default')) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values.${key}`, `Exposed parameter '${key}' has no default and no supplied value.`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateNode(document, key, node, path, errors, helpers, scope) {
|
||
if (!ID_PATTERN.test(key)) fail(errors, 'ERR_INVALID_ID', path, `Node key '${key}' is not a valid identifier.`);
|
||
if (key === 'output') fail(errors, 'ERR_INVALID_ID', path, "'output' is reserved and cannot be declared as a node.");
|
||
if (key === 'input') fail(errors, 'ERR_INVALID_ID', path, "'input' is reserved and cannot be declared as a node.");
|
||
if (!isObject(node)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Node must be an object.');
|
||
const contract = AUDIO_NODE_TYPES[node.type];
|
||
if (!contract) return fail(errors, 'ERR_INVALID_NODE_TYPE', `${path}.type`, `Unrecognized audio node type '${node.type}'.`);
|
||
|
||
for (const field of Object.keys(node)) {
|
||
if (field === 'type') continue;
|
||
if (!Object.hasOwn(contract.fields, field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Node type '${node.type}' does not declare '${field}'.`);
|
||
}
|
||
|
||
for (const [field, spec] of Object.entries(contract.fields)) {
|
||
if (spec.kind === 'partials' || spec.kind === 'modes' || spec.kind === 'component-ref' || spec.kind === 'component-values') continue;
|
||
if (!Object.hasOwn(node, field)) continue;
|
||
const value = node[field];
|
||
const fieldPath = `${path}.${field}`;
|
||
if (spec.kind === 'enum') {
|
||
if (typeof value !== 'string' || !spec.values.includes(value)) fail(errors, 'ERR_TYPE_MISMATCH', fieldPath, `'${field}' must be one of: ${spec.values.join(', ')}.`);
|
||
} else if (spec.kind === 'duration') {
|
||
validateDurationField(value, spec, fieldPath, errors);
|
||
} else {
|
||
validateNumericField(document, value, spec, fieldPath, errors, helpers, scope);
|
||
}
|
||
}
|
||
|
||
if (node.type === 'oscillator') validatePartials(document, node, path, errors, helpers, scope);
|
||
if (node.type === 'resonator') validateModes(document, node, path, errors, helpers, scope);
|
||
if (node.type === 'component') validateComponentInstance(document, node, path, errors, helpers, scope);
|
||
|
||
if (contract.rangeOrder) {
|
||
const [lowField, highField] = contract.rangeOrder;
|
||
const low = node[lowField] ?? contract.fields[lowField].default;
|
||
const high = node[highField] ?? contract.fields[highField].default;
|
||
if (typeof low === 'number' && typeof high === 'number' && low >= high) {
|
||
fail(errors, 'ERR_INVALID_RANGE_ORDER', `${path}.${lowField}`, `${lowField} must be strictly less than ${highField}.`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateRoute(document, route, path, errors, helpers, scope) {
|
||
if (!isObject(route)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Route must be an object.');
|
||
for (const field of Object.keys(route)) {
|
||
if (!AUDIO_ROUTE_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized route field '${field}'.`);
|
||
}
|
||
for (const field of ['from', 'to']) {
|
||
if (typeof route[field] !== 'string' || route[field].length === 0) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${field}`, `Route '${field}' must be a non-empty string.`);
|
||
}
|
||
const isModulation = typeof route.to === 'string' && route.to.includes('.');
|
||
if (isModulation && !Object.hasOwn(route, 'depth')) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.depth`, 'A modulation route requires depth.');
|
||
}
|
||
if (!isModulation && Object.hasOwn(route, 'depth')) {
|
||
fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.depth`, 'depth is only declared on a modulation route.');
|
||
}
|
||
if (Object.hasOwn(route, 'depth')) {
|
||
validateNumericField(document, route.depth, { min: -Infinity, max: Infinity, valuespec: true }, `${path}.depth`, errors, helpers, scope);
|
||
}
|
||
}
|
||
|
||
function automationTarget(document, graph, name, path, errors) {
|
||
if (typeof name !== 'string') { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Automation requires a target string.'); return null; }
|
||
const [key, property, extra] = name.split('.');
|
||
const node = graph.nodes?.[key];
|
||
if (!isObject(node)) { fail(errors, 'ERR_INVALID_REFERENCE', path, `Automation node '${key}' is not declared in this graph.`); return null; }
|
||
if (node.type === 'component' && extra !== undefined) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', path, `Component internals are encapsulated; '${name}' is not reachable.`);
|
||
return null;
|
||
}
|
||
const registry = node.type === 'component' ? document.components?.audio?.[node.use]?.parameters : AUDIO_MODULATABLE[node.type];
|
||
if (extra !== undefined || !Object.hasOwn(registry ?? {}, property)) {
|
||
fail(errors, 'ERR_UNSUPPORTED_TARGET', path, `'${name}' is not an automatable property.`);
|
||
return null;
|
||
}
|
||
return { key, property };
|
||
}
|
||
|
||
// Structural ValueSpec checks are shared with each validator. This additional
|
||
// expected-number walk rejects string/boolean leaves and references, including
|
||
// branches of a choice which might not be sampled in a particular run.
|
||
function validateAutomationNumber(document, value, path, errors) {
|
||
if (typeof value === 'string' || typeof value === 'boolean') return fail(errors, 'ERR_TYPE_MISMATCH', path, 'Automation requires a numeric ValueSpec.');
|
||
if (!isObject(value)) return;
|
||
if (typeof value.ref === 'string') {
|
||
const [namespace, id] = value.ref.split('.');
|
||
const type = document[namespace]?.[id]?.type;
|
||
if (type && !['number', 'integer'].includes(type) || value.ref === 'signals.scenario.active') fail(errors, 'ERR_TYPE_MISMATCH', path, 'Automation reference must be numeric.');
|
||
}
|
||
if (Array.isArray(value.args)) value.args.forEach((child, i) => validateAutomationNumber(document, child, `${path}.args[${i}]`, errors));
|
||
if (Array.isArray(value.choose)) value.choose.forEach((child, i) => validateAutomationNumber(document, child?.value, `${path}.choose[${i}].value`, errors));
|
||
}
|
||
|
||
function validateAutomation(document, graph, path, errors, helpers, scope) {
|
||
if (graph.automation === undefined) return;
|
||
if (!Array.isArray(graph.automation)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'automation must be an array.');
|
||
const targets = new Set();
|
||
graph.automation.forEach((track, index) => {
|
||
const location = `${path}[${index}]`;
|
||
if (!isObject(track)) return fail(errors, 'ERR_SCHEMA_VALIDATION', location, 'Automation track must be an object.');
|
||
for (const key of Object.keys(track)) if (!AUDIO_AUTOMATION_FIELDS.includes(key)) fail(errors, 'ERR_UNKNOWN_FIELD', `${location}.${key}`, `Unknown automation field '${key}'.`);
|
||
if (automationTarget(document, graph, track.target, `${location}.target`, errors)) {
|
||
if (targets.has(track.target)) fail(errors, 'ERR_AUTOMATION_CONFLICT', location, `More than one track controls '${track.target}'.`);
|
||
targets.add(track.target);
|
||
}
|
||
if (track.mode !== undefined && !AUDIO_AUTOMATION_MODES.includes(track.mode)) fail(errors, 'ERR_TYPE_MISMATCH', `${location}.mode`, 'Unsupported automation mode.');
|
||
if (track.interpolation !== undefined && !AUDIO_AUTOMATION_CURVES.includes(track.interpolation)) fail(errors, 'ERR_TYPE_MISMATCH', `${location}.interpolation`, 'Unsupported automation interpolation.');
|
||
if (!Array.isArray(track.points) || track.points.length < 2) return fail(errors, 'ERR_SCHEMA_VALIDATION', `${location}.points`, 'Automation requires at least two points.');
|
||
let previous = -1;
|
||
track.points.forEach((point, i) => {
|
||
const pointPath = `${location}.points[${i}]`;
|
||
if (!isObject(point)) return fail(errors, 'ERR_SCHEMA_VALIDATION', pointPath, 'Automation point must be an object.');
|
||
for (const key of Object.keys(point)) if (!['at', 'value'].includes(key)) fail(errors, 'ERR_UNKNOWN_FIELD', `${pointPath}.${key}`, `Unknown point field '${key}'.`);
|
||
const at = durationMilliseconds(point.at);
|
||
if (typeof point.at !== 'string') fail(errors, 'ERR_TYPE_MISMATCH', `${pointPath}.at`, 'Automation times must be duration literals, not TimeSpecs.');
|
||
else if (at === null) fail(errors, 'ERR_INVALID_DURATION', `${pointPath}.at`, 'Invalid automation duration.');
|
||
else {
|
||
if (at <= previous) fail(errors, 'ERR_INVALID_RANGE_ORDER', `${pointPath}.at`, 'Automation times must strictly increase.');
|
||
previous = at;
|
||
}
|
||
helpers.validateValueSpec(document, point.value, `${pointPath}.value`, errors, scope);
|
||
validateAutomationNumber(document, point.value, `${pointPath}.value`, errors);
|
||
});
|
||
});
|
||
checkAutomationLimits(graph.automation, path, errors);
|
||
}
|
||
|
||
function checkAutomationLimits(tracks, path, errors) {
|
||
const count = tracks.reduce((sum, track) => sum + (Array.isArray(track?.points) ? track.points.length : 0), 0);
|
||
if (tracks.length > AUDIO_LIMITS.automationTracks || count > AUDIO_LIMITS.automationPoints) fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', path, `Automation exceeds ${AUDIO_LIMITS.automationTracks} tracks or ${AUDIO_LIMITS.automationPoints} total points per expanded sound.`);
|
||
}
|
||
|
||
function validateGraphObject(document, graph, path, errors, helpers, { allowedFields, scope }) {
|
||
if (!isObject(graph)) return fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Audio graph must be an object.');
|
||
for (const field of Object.keys(graph)) {
|
||
if (!allowedFields.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized audio graph field '${field}'.`);
|
||
}
|
||
if (!isObject(graph.nodes) || Object.keys(graph.nodes).length === 0) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.nodes`, 'An audio graph requires a non-empty nodes object.');
|
||
} else {
|
||
for (const [key, node] of Object.entries(graph.nodes)) validateNode(document, key, node, `${path}.nodes.${key}`, errors, helpers, scope);
|
||
}
|
||
if (graph.routes !== undefined) {
|
||
if (!Array.isArray(graph.routes)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.routes`, 'routes must be an array.');
|
||
else graph.routes.forEach((route, index) => validateRoute(document, route, `${path}.routes[${index}]`, errors, helpers, scope));
|
||
}
|
||
validateAutomation(document, graph, `${path}.automation`, errors, helpers, scope);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Expansion (section 15.11 / 15.15) and legality (section 15.14)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function recipeGraphFor(document, sound, path, errors) {
|
||
const recipe = sound?.recipe;
|
||
if (!isObject(recipe)) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.recipe`, 'A sound requires a recipe object.');
|
||
return null;
|
||
}
|
||
if (Object.hasOwn(recipe, 'use')) {
|
||
if (Object.keys(recipe).some((field) => field !== 'use')) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.recipe`, 'A recipe reference declares only use.');
|
||
return null;
|
||
}
|
||
const shared = document.audio?.recipes?.[recipe.use];
|
||
if (!isObject(shared)) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.recipe.use`, `Recipe '${recipe.use}' is not declared.`);
|
||
return null;
|
||
}
|
||
return { graph: shared, path: `$.audio.recipes.${recipe.use}` };
|
||
}
|
||
return { graph: recipe, path: `${path}.recipe` };
|
||
}
|
||
|
||
function expandSoundGraph(document, soundId) {
|
||
const errors = [];
|
||
const sound = document.sounds?.[soundId];
|
||
const soundPath = `$.sounds.${soundId}`;
|
||
const located = recipeGraphFor(document, sound, soundPath, errors);
|
||
if (!located) return { nodes: [], routes: [], automation: [], samplingOrder: [], errors, mode: 'oneshot', release: AUDIO_DEFAULT_RELEASE_MS };
|
||
|
||
const nodes = new Map();
|
||
const routes = [];
|
||
const automation = [];
|
||
const samplingOrder = [];
|
||
const components = document.components?.audio ?? {};
|
||
|
||
const join = (prefix, key) => (prefix ? `${prefix}.${key}` : key);
|
||
|
||
function expand(graph, prefix, depth, trail, graphPath) {
|
||
if (depth > AUDIO_LIMITS.componentDepth) {
|
||
fail(errors, 'ERR_COMPONENT_RECURSION', graphPath, `Component nesting exceeds ${AUDIO_LIMITS.componentDepth} levels.`);
|
||
return;
|
||
}
|
||
const declared = isObject(graph.nodes) ? graph.nodes : {};
|
||
for (const [key, node] of Object.entries(declared)) {
|
||
if (!isObject(node)) continue;
|
||
const path = join(prefix, key);
|
||
samplingOrder.push({ kind: 'node', path });
|
||
if (node.type === 'component') {
|
||
const component = components[node.use];
|
||
if (!isObject(component)) continue;
|
||
if (trail.includes(node.use)) {
|
||
fail(errors, 'ERR_COMPONENT_RECURSION', `${graphPath}.nodes.${key}`, `Component '${node.use}' instantiates itself.`);
|
||
continue;
|
||
}
|
||
nodes.set(`${path}.output`, { path: `${path}.output`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
|
||
if (component.input === true) {
|
||
nodes.set(`${path}.input`, { path: `${path}.input`, type: 'gain', spec: { type: 'gain' }, implicit: true, scope: path });
|
||
}
|
||
nodes.set(path, {
|
||
path, type: 'component', spec: node, implicit: false, scope: prefix || null,
|
||
component: node.use, acceptsAudio: component.input === true, exposes: component.parameters ?? {}
|
||
});
|
||
expand(component, path, depth + 1, [...trail, node.use], `$.components.audio.${node.use}`);
|
||
} else {
|
||
nodes.set(path, { path, type: node.type, spec: node, implicit: false, scope: prefix || null });
|
||
}
|
||
}
|
||
|
||
const declaresInput = prefix ? graph.input === true : false;
|
||
const resolve = (name, side, routePath) => {
|
||
if (typeof name !== 'string') return null;
|
||
if (name === 'output') return prefix ? `${prefix}.output` : 'output';
|
||
if (name === 'input') {
|
||
if (!declaresInput) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'input' is only available inside a component declaring input: true.");
|
||
return null;
|
||
}
|
||
return `${prefix}.input`;
|
||
}
|
||
const head = name.split('.')[0];
|
||
const local = declared[head];
|
||
if (!isObject(local)) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Route endpoint '${name}' does not resolve in this graph.`);
|
||
return null;
|
||
}
|
||
const localPath = join(prefix, head);
|
||
const property = name.includes('.') ? name.slice(head.length + 1) : null;
|
||
if (local.type !== 'component') {
|
||
if (side === 'from' && property !== null) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Route source '${name}' names a property; only nodes emit signal.`);
|
||
return null;
|
||
}
|
||
return property === null ? localPath : `${localPath}::${property}`;
|
||
}
|
||
if (side === 'from') {
|
||
if (property !== null) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', routePath, `Component internals are encapsulated; '${name}' is not reachable.`);
|
||
return null;
|
||
}
|
||
return `${localPath}.output`;
|
||
}
|
||
if (property !== null) return `${localPath}::${property}`;
|
||
const component = components[local.use];
|
||
if (component?.input !== true) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Component '${local.use}' declares no audio input.`);
|
||
return null;
|
||
}
|
||
return `${localPath}.input`;
|
||
};
|
||
|
||
const declaredRoutes = Array.isArray(graph.routes) ? graph.routes : [];
|
||
declaredRoutes.forEach((route, index) => {
|
||
if (!isObject(route)) return;
|
||
const routePath = `${graphPath}.routes[${index}]`;
|
||
const from = resolve(route.from, 'from', routePath);
|
||
if (from === null) return;
|
||
if (from === 'output' || from.endsWith('.output') && route.from === 'output') {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, "'output' is sink-only and cannot be a route source.");
|
||
return;
|
||
}
|
||
const isModulation = typeof route.to === 'string' && route.to.includes('.');
|
||
const to = resolve(route.to, 'to', routePath);
|
||
if (to === null) return;
|
||
if (isModulation) {
|
||
const [target, property] = to.includes('::') ? to.split('::') : [to, null];
|
||
if (property === null) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Modulation target '${route.to}' does not name a node property.`);
|
||
return;
|
||
}
|
||
routes.push({ kind: 'modulation', from, to: target, property, depth: route.depth, scope: prefix || null, path: routePath });
|
||
} else {
|
||
if (to.includes('::')) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', routePath, `Audio route target '${route.to}' names a property.`);
|
||
return;
|
||
}
|
||
routes.push({ kind: 'audio', from, to, path: routePath });
|
||
}
|
||
samplingOrder.push({ kind: 'route', index: routes.length - 1 });
|
||
});
|
||
for (const [index, track] of (Array.isArray(graph.automation) ? graph.automation : []).entries()) {
|
||
if (!isObject(track)) continue;
|
||
const trackPath = `${graphPath}.automation[${index}]`;
|
||
const target = automationTarget(document, graph, track.target, `${trackPath}.target`, errors);
|
||
if (!target) continue;
|
||
automation.push({ ...track, target: join(prefix, target.key), property: target.property, scope: prefix || null, path: trackPath });
|
||
samplingOrder.push({ kind: 'automation', index: automation.length - 1 });
|
||
}
|
||
}
|
||
|
||
expand(located.graph, '', 1, [], located.path);
|
||
const mode = AUDIO_RECIPE_MODES.includes(located.graph.mode) ? located.graph.mode : 'oneshot';
|
||
const release = Object.hasOwn(located.graph, 'release')
|
||
? (durationMilliseconds(located.graph.release) ?? AUDIO_DEFAULT_RELEASE_MS)
|
||
: AUDIO_DEFAULT_RELEASE_MS;
|
||
checkAutomationLimits(automation, located.path, errors);
|
||
const seen = new Set();
|
||
for (const track of automation) {
|
||
const target = `${track.target}::${track.property}`;
|
||
if (seen.has(target)) fail(errors, 'ERR_AUTOMATION_CONFLICT', track.path, `More than one track controls '${target}'.`);
|
||
seen.add(target);
|
||
}
|
||
return { nodes: [...nodes.values()], routes, automation, samplingOrder, errors, mode, release, graphPath: located.path };
|
||
}
|
||
|
||
function detectCycle(adjacency) {
|
||
const visiting = new Set();
|
||
const done = new Set();
|
||
let cycle = null;
|
||
const visit = (node, trail) => {
|
||
if (cycle) return;
|
||
if (visiting.has(node)) { cycle = [...trail, node]; return; }
|
||
if (done.has(node)) return;
|
||
visiting.add(node);
|
||
for (const next of adjacency.get(node) ?? []) visit(next, [...trail, node]);
|
||
visiting.delete(node);
|
||
done.add(node);
|
||
};
|
||
for (const node of adjacency.keys()) visit(node, []);
|
||
return cycle;
|
||
}
|
||
|
||
function checkGraphLegality(document, soundId, expansion, errors) {
|
||
const { nodes, routes, graphPath } = expansion;
|
||
const byPath = new Map(nodes.map((node) => [node.path, node]));
|
||
const audible = new Map();
|
||
const combined = new Map();
|
||
const link = (map, from, to) => {
|
||
if (!map.has(from)) map.set(from, []);
|
||
map.get(from).push(to);
|
||
};
|
||
|
||
const authored = nodes.filter((node) => !node.implicit && node.type !== 'component');
|
||
if (authored.length > AUDIO_LIMITS.nodesPerSound) {
|
||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${authored.length} nodes; the limit is ${AUDIO_LIMITS.nodesPerSound}.`);
|
||
}
|
||
if (routes.length > AUDIO_LIMITS.routesPerSound) {
|
||
fail(errors, 'ERR_NODE_LIMIT_EXCEEDED', graphPath, `Expanded graph declares ${routes.length} routes; the limit is ${AUDIO_LIMITS.routesPerSound}.`);
|
||
}
|
||
|
||
for (const route of routes) {
|
||
if (route.from === route.to) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, 'A route cannot connect a node to itself.');
|
||
continue;
|
||
}
|
||
if (route.kind === 'audio') {
|
||
const target = byPath.get(route.to);
|
||
if (route.to !== 'output') {
|
||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Route target '${route.to}' does not resolve.`); continue; }
|
||
const contract = AUDIO_NODE_TYPES[target.type];
|
||
const accepts = target.implicit || (target.type === 'component' ? target.acceptsAudio : contract?.acceptsAudio === true);
|
||
if (!accepts) { fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node '${route.to}' does not accept audio input.`); continue; }
|
||
}
|
||
link(audible, route.from, route.to);
|
||
link(combined, route.from, route.to);
|
||
continue;
|
||
}
|
||
const target = byPath.get(route.to);
|
||
if (!target) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation target '${route.to}' does not resolve.`); continue; }
|
||
const source = byPath.get(route.from);
|
||
if (!source) { fail(errors, 'ERR_INVALID_REFERENCE', route.path, `Modulation source '${route.from}' does not resolve.`); continue; }
|
||
const sourceType = source.implicit ? 'gain' : source.type;
|
||
if (!AUDIO_MODULATION_SOURCE_TYPES.includes(sourceType)) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Node type '${sourceType}' cannot drive a modulation route.`);
|
||
continue;
|
||
}
|
||
const permitted = target.type === 'component'
|
||
? Object.hasOwn(target.exposes ?? {}, route.property)
|
||
: Object.hasOwn(AUDIO_MODULATABLE[target.type] ?? {}, route.property);
|
||
if (!permitted) {
|
||
fail(errors, 'ERR_UNSUPPORTED_TARGET', route.path, `'${target.type}.${route.property}' is not a modulatable property.`);
|
||
continue;
|
||
}
|
||
link(combined, route.from, route.to);
|
||
}
|
||
|
||
const audioCycle = detectCycle(audible);
|
||
if (audioCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Audio route cycle: ${audioCycle.join(' -> ')}.`);
|
||
const combinedCycle = detectCycle(combined);
|
||
if (!audioCycle && combinedCycle) fail(errors, 'ERR_CYCLIC_DEPENDENCY', graphPath, `Modulation dependency cycle: ${combinedCycle.join(' -> ')}.`);
|
||
|
||
if (audioCycle) return;
|
||
const reaches = (start) => {
|
||
const seen = new Set();
|
||
const queue = [start];
|
||
while (queue.length) {
|
||
const current = queue.shift();
|
||
if (current === 'output') return true;
|
||
if (seen.has(current)) continue;
|
||
seen.add(current);
|
||
for (const next of audible.get(current) ?? []) queue.push(next);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
let audiblePath = false;
|
||
for (const node of nodes) {
|
||
if (node.implicit || node.type === 'component') continue;
|
||
const contract = AUDIO_NODE_TYPES[node.type];
|
||
if (!contract) continue;
|
||
if (contract.acceptsAudio === false && reaches(node.path)) {
|
||
if (contract.class === 'control') {
|
||
fail(errors, 'ERR_INVALID_ROUTE', node.path, `Control source '${node.path}' reaches audible output.`);
|
||
} else {
|
||
audiblePath = true;
|
||
if (expansion.mode === 'oneshot' && (node.type === 'oscillator' || node.type === 'noise')) {
|
||
fail(errors, 'ERR_INDETERMINATE_ONESHOT', graphPath, `Sound '${soundId}' is a oneshot with an unbounded audible path from '${node.path}' (${node.type}).`);
|
||
}
|
||
}
|
||
}
|
||
if (contract.acceptsAudio === false) {
|
||
for (const route of routes) {
|
||
if (route.kind === 'audio' && route.to === node.path) {
|
||
fail(errors, 'ERR_INVALID_ROUTE', route.path, `Source node '${node.path}' cannot receive audio input.`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!audiblePath) fail(errors, 'ERR_NO_AUDIBLE_PATH', graphPath, `Sound '${soundId}' has no audio path from a source to output.`);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Document-level entry point
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function validateAudioSubsystem(document, errors, helpers) {
|
||
const audio = document.audio;
|
||
if (audio !== undefined) {
|
||
if (!isObject(audio)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio', 'audio must be an object.');
|
||
else {
|
||
for (const field of Object.keys(audio)) {
|
||
if (field === 'master') fail(errors, 'ERR_UNKNOWN_FIELD', '$.audio.master', 'audio.master is engine-provided and is reserved for the Phase 3c master-protection contract.');
|
||
else if (!['buses', 'recipes'].includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `$.audio.${field}`, `Unrecognized audio field '${field}'.`);
|
||
}
|
||
if (audio.buses !== undefined) {
|
||
if (!isObject(audio.buses)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.buses', 'buses must be an object.');
|
||
else for (const [id, bus] of Object.entries(audio.buses)) {
|
||
const path = `$.audio.buses.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Bus ID '${id}' is not a valid identifier.`);
|
||
if (id === 'master') fail(errors, 'ERR_INVALID_ID', path, "'master' is engine-provided and cannot be declared.");
|
||
if (!isObject(bus)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Bus must be an object.'); continue; }
|
||
for (const field of Object.keys(bus)) if (!AUDIO_BUS_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized bus field '${field}'.`);
|
||
if (Object.hasOwn(bus, 'gain')) {
|
||
validateNumericField(document, bus.gain, { min: AUDIO_BUS_GAIN_RANGE.min, max: AUDIO_BUS_GAIN_RANGE.max, valuespec: true }, `${path}.gain`, errors, helpers, null);
|
||
}
|
||
}
|
||
}
|
||
if (audio.recipes !== undefined) {
|
||
if (!isObject(audio.recipes)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.audio.recipes', 'recipes must be an object.');
|
||
else for (const [id, recipe] of Object.entries(audio.recipes)) {
|
||
const path = `$.audio.recipes.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Recipe ID '${id}' is not a valid identifier.`);
|
||
validateGraphObject(document, recipe, path, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||
if (Object.hasOwn(recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(recipe.mode)) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
|
||
}
|
||
if (Object.hasOwn(recipe, 'release')) {
|
||
validateDurationField(recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.release`, errors);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const componentRoot = document.components?.audio;
|
||
if (componentRoot !== undefined) {
|
||
if (!isObject(componentRoot)) fail(errors, 'ERR_SCHEMA_VALIDATION', '$.components.audio', 'components.audio must be an object.');
|
||
else for (const [id, component] of Object.entries(componentRoot)) {
|
||
const path = `$.components.audio.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Component ID '${id}' is not a valid identifier.`);
|
||
if (!isObject(component)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Component must be an object.'); continue; }
|
||
const exposed = new Set();
|
||
if (component.parameters !== undefined) {
|
||
if (!isObject(component.parameters)) fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.parameters`, 'parameters must be an object.');
|
||
else for (const [name, rule] of Object.entries(component.parameters)) {
|
||
const rulePath = `${path}.parameters.${name}`;
|
||
if (!ID_PATTERN.test(name)) fail(errors, 'ERR_INVALID_ID', rulePath, `Exposed parameter '${name}' is not a valid identifier.`);
|
||
if (!isObject(rule)) { fail(errors, 'ERR_SCHEMA_VALIDATION', rulePath, 'Exposed parameter must be an object.'); continue; }
|
||
for (const field of Object.keys(rule)) if (!AUDIO_COMPONENT_PARAMETER_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${rulePath}.${field}`, `Unrecognized parameter field '${field}'.`);
|
||
if (rule.type !== 'number') fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.type`, "Exposed audio parameters must declare type 'number' in 0.1.");
|
||
for (const field of ['default', 'min', 'max']) {
|
||
if (Object.hasOwn(rule, field) && !Number.isFinite(rule[field])) fail(errors, 'ERR_TYPE_MISMATCH', `${rulePath}.${field}`, `${field} must be a finite number.`);
|
||
}
|
||
if (Number.isFinite(rule.min) && Number.isFinite(rule.max) && rule.min > rule.max) fail(errors, 'ERR_OUT_OF_BOUNDS', `${rulePath}.min`, 'min cannot exceed max.');
|
||
exposed.add(name);
|
||
}
|
||
}
|
||
if (Object.hasOwn(component, 'input') && typeof component.input !== 'boolean') {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.input`, 'input must be a boolean.');
|
||
}
|
||
validateGraphObject(document, component, path, errors, helpers, {
|
||
allowedFields: AUDIO_COMPONENT_FIELDS,
|
||
scope: { componentParameters: exposed }
|
||
});
|
||
}
|
||
}
|
||
|
||
const sounds = document.sounds;
|
||
if (sounds === undefined) return;
|
||
if (!isObject(sounds)) return fail(errors, 'ERR_SCHEMA_VALIDATION', '$.sounds', 'sounds must be an object.');
|
||
for (const [id, sound] of Object.entries(sounds)) {
|
||
const path = `$.sounds.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail(errors, 'ERR_INVALID_ID', path, `Sound ID '${id}' is not a valid identifier.`);
|
||
if (!isObject(sound)) { fail(errors, 'ERR_SCHEMA_VALIDATION', path, 'Sound must be an object.'); continue; }
|
||
for (const field of Object.keys(sound)) if (!AUDIO_SOUND_FIELDS.includes(field)) fail(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized sound field '${field}'.`);
|
||
if (typeof sound.name !== 'string' || sound.name.trim().length === 0 || sound.name.length > 128) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.name`, 'Sound name must be a non-empty string of at most 128 characters.');
|
||
}
|
||
if (sound.tags !== undefined && (!Array.isArray(sound.tags) || sound.tags.length > 16 || sound.tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.tags`, 'Tags must contain at most 16 strings of at most 32 characters.');
|
||
}
|
||
if (sound.usage !== undefined && (!Array.isArray(sound.usage) || sound.usage.length === 0 || sound.usage.some((entry) => !AUDIO_SOUND_USAGE.includes(entry)))) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.usage`, `usage entries must be among: ${AUDIO_SOUND_USAGE.join(', ')}.`);
|
||
}
|
||
if (sound.cadence !== undefined && !isObject(sound.cadence)) {
|
||
fail(errors, 'ERR_SCHEMA_VALIDATION', `${path}.cadence`, 'cadence must be an object.');
|
||
}
|
||
if (sound.bus !== undefined && !isObject(document.audio?.buses?.[sound.bus])) {
|
||
fail(errors, 'ERR_INVALID_REFERENCE', `${path}.bus`, `Bus '${sound.bus}' is not declared.`);
|
||
}
|
||
if (isObject(sound.recipe) && !Object.hasOwn(sound.recipe, 'use')) {
|
||
validateGraphObject(document, sound.recipe, `${path}.recipe`, errors, helpers, { allowedFields: AUDIO_RECIPE_FIELDS, scope: null });
|
||
if (Object.hasOwn(sound.recipe, 'mode') && !AUDIO_RECIPE_MODES.includes(sound.recipe.mode)) {
|
||
fail(errors, 'ERR_TYPE_MISMATCH', `${path}.recipe.mode`, `Recipe mode must be one of: ${AUDIO_RECIPE_MODES.join(', ')}.`);
|
||
}
|
||
if (Object.hasOwn(sound.recipe, 'release')) {
|
||
validateDurationField(sound.recipe.release, { min: 0, max: AUDIO_MAX_RELEASE_MS }, `${path}.recipe.release`, errors);
|
||
}
|
||
}
|
||
const expansion = expandSoundGraph(document, id);
|
||
errors.push(...expansion.errors);
|
||
if (expansion.nodes.length > 0) checkGraphLegality(document, id, expansion, errors);
|
||
}
|
||
}
|
||
|
||
function computeDeterminableEndingBound(expansion, resolvedNodes = null) {
|
||
const { nodes, routes, release = AUDIO_DEFAULT_RELEASE_MS } = expansion;
|
||
const resolvedMap = resolvedNodes instanceof Map
|
||
? resolvedNodes
|
||
: Array.isArray(resolvedNodes)
|
||
? new Map(resolvedNodes.map((n) => [n.path, n]))
|
||
: null;
|
||
|
||
const nodeMap = new Map(nodes.map((n) => [n.path, n]));
|
||
|
||
function nodeContribution(path) {
|
||
if (path === 'output') return 0;
|
||
const node = nodeMap.get(path);
|
||
if (!node || node.implicit || node.type === 'component') return 0;
|
||
const type = node.type;
|
||
const resolved = resolvedMap?.get(path)?.values;
|
||
|
||
if (type === 'impulse') {
|
||
if (resolved && typeof resolved.duration === 'number') return resolved.duration;
|
||
return durationMilliseconds(node.spec?.duration ?? '10ms') ?? 10;
|
||
}
|
||
if (type === 'oscillator' || type === 'noise') {
|
||
return Infinity;
|
||
}
|
||
if (type === 'delay') {
|
||
let time, feedback;
|
||
if (resolved) {
|
||
time = resolved.time ?? 250;
|
||
feedback = resolved.feedback ?? 0.2;
|
||
} else {
|
||
time = durationMilliseconds(node.spec?.time ?? '250ms') ?? 250;
|
||
feedback = typeof node.spec?.feedback === 'number' ? node.spec.feedback : 0.2;
|
||
}
|
||
if (feedback > 0) {
|
||
const clampedFeedback = Math.min(0.9999, Math.max(1e-6, feedback));
|
||
const multiplier = Math.ceil(Math.log(0.001) / Math.log(clampedFeedback));
|
||
return time * multiplier;
|
||
}
|
||
return time;
|
||
}
|
||
if (type === 'reverb') {
|
||
let predelay, decay;
|
||
if (resolved) {
|
||
predelay = resolved.predelay ?? 0;
|
||
decay = resolved.decay ?? 2000;
|
||
} else {
|
||
predelay = durationMilliseconds(node.spec?.predelay ?? '0ms') ?? 0;
|
||
decay = durationMilliseconds(node.spec?.decay ?? '2s') ?? 2000;
|
||
}
|
||
return predelay + decay;
|
||
}
|
||
if (type === 'resonator') {
|
||
if (resolved?.modes && Array.isArray(resolved.modes) && resolved.modes.length > 0) {
|
||
return Math.max(...resolved.modes.map((m) => m.decay ?? 1000));
|
||
}
|
||
const modes = node.spec?.modes ?? [];
|
||
if (modes.length > 0) {
|
||
return Math.max(...modes.map((m) => durationMilliseconds(m.decay ?? '1s') ?? 1000));
|
||
}
|
||
return 1000;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const audioAdjacency = new Map();
|
||
const inDegree = new Map();
|
||
const allNodes = new Set();
|
||
for (const n of nodes) allNodes.add(n.path);
|
||
allNodes.add('output');
|
||
|
||
for (const n of allNodes) {
|
||
audioAdjacency.set(n, []);
|
||
inDegree.set(n, 0);
|
||
}
|
||
|
||
for (const route of routes) {
|
||
if (route.kind === 'audio') {
|
||
if (!audioAdjacency.has(route.from)) audioAdjacency.set(route.from, []);
|
||
audioAdjacency.get(route.from).push(route.to);
|
||
inDegree.set(route.to, (inDegree.get(route.to) ?? 0) + 1);
|
||
}
|
||
}
|
||
|
||
const dist = new Map();
|
||
for (const n of allNodes) dist.set(n, -Infinity);
|
||
|
||
for (const n of nodes) {
|
||
if (n.implicit || n.type === 'component') continue;
|
||
if (n.type === 'impulse' || n.type === 'oscillator' || n.type === 'noise') {
|
||
dist.set(n.path, nodeContribution(n.path));
|
||
}
|
||
}
|
||
|
||
const queue = [];
|
||
for (const [node, deg] of inDegree.entries()) {
|
||
if (deg === 0) queue.push(node);
|
||
}
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift();
|
||
const currentDist = dist.get(current);
|
||
for (const next of audioAdjacency.get(current) ?? []) {
|
||
if (currentDist !== -Infinity) {
|
||
const nextContrib = nodeContribution(next);
|
||
const newDist = currentDist === Infinity ? Infinity : currentDist + nextContrib;
|
||
if (newDist > dist.get(next)) dist.set(next, newDist);
|
||
}
|
||
inDegree.set(next, inDegree.get(next) - 1);
|
||
if (inDegree.get(next) === 0) queue.push(next);
|
||
}
|
||
}
|
||
|
||
const outDist = dist.get('output');
|
||
if (outDist === Infinity) return Infinity;
|
||
if (outDist === -Infinity) return release;
|
||
return outDist + release;
|
||
}
|
||
|
||
/* src/runtime/audio-controls.js */
|
||
// Native control-signal graph. Automation is scheduled once on the audio clock;
|
||
// modulation sums before the final clamp, not directly on an unclamped AudioParam.
|
||
|
||
|
||
|
||
|
||
function audioPropertyRange(node, property, ceiling) {
|
||
const rule = node.type === 'component' ? node.exposes[property] : AUDIO_NODE_TYPES[node.type].fields[property];
|
||
return { min: rule.min ?? -Infinity, max: rule.ceiling === 'audio' ? Math.min(rule.max, ceiling) : rule.max ?? Infinity };
|
||
}
|
||
|
||
// Exact reference evaluator used by traces and by callers inspecting a voice.
|
||
// Source samples are supplied in their own units; sampling this function draws no RNG.
|
||
function audioPropertyAt(plan, target, property, milliseconds, sourceValues = {}) {
|
||
const visiting = new Set();
|
||
const cache = new Map();
|
||
const resolve = (path, field) => {
|
||
const key = `${path}::${field}`;
|
||
if (cache.has(key)) return cache.get(key);
|
||
if (visiting.has(key)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Audio control cycle at '${key}'.`);
|
||
const node = plan.nodes.find((item) => item.path === path);
|
||
if (!node || !Object.hasOwn(node.type === 'component' ? node.exposes : AUDIO_MODULATABLE[node.type] ?? {}, field)) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Unsupported audio property '${key}'.`);
|
||
visiting.add(key);
|
||
const resolver = new ValueResolver((ref) => resolve(node.scope, ref.slice('inputs.'.length)));
|
||
const base = resolver.evaluate(node.expressions?.[field] ?? node.values[field], null);
|
||
const track = plan.automation?.find((item) => item.target === path && item.property === field);
|
||
const modulation = plan.routes.filter((route) => route.kind === 'modulation' && route.to === path && route.property === field)
|
||
.reduce((sum, route) => sum + (sourceValues[route.from] ?? 0) * route.depth, 0);
|
||
const value = resolveNumericStages(base, {
|
||
automation: track ? (lower) => applyAutomationMode(lower, automationValueAt(track, milliseconds), track.mode) : undefined,
|
||
modulation, ...audioPropertyRange(node, field, plan.ceiling)
|
||
});
|
||
visiting.delete(key);
|
||
cache.set(key, value);
|
||
return value;
|
||
};
|
||
return resolve(target, property);
|
||
}
|
||
|
||
function scheduleNativeAutomation(param, track, startTime) {
|
||
if (track.interpolation === 'smooth') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Smooth automation requires the polynomial control graph.');
|
||
const points = track.points;
|
||
param.setValueAtTime(points[0].value, startTime);
|
||
param.setValueAtTime(points[0].value, startTime + points[0].at / 1000);
|
||
for (let i = 1; i < points.length; i += 1) {
|
||
const left = points[i - 1], right = points[i];
|
||
const end = startTime + right.at / 1000;
|
||
if (track.interpolation === 'step') param.setValueAtTime(right.value, end);
|
||
else if (track.interpolation === 'exponential' && left.value > 0 && right.value > 0) param.exponentialRampToValueAtTime(right.value, end);
|
||
else param.linearRampToValueAtTime(right.value, end);
|
||
}
|
||
}
|
||
|
||
function createAudioControls(context, plan, entries, disposeWith, startTime) {
|
||
const nodes = new Map(plan.nodes.map((node) => [node.path, node]));
|
||
const cache = new Map();
|
||
const building = new Set();
|
||
const constants = new Map();
|
||
const literal = (value) => ({ value, min: value, max: value });
|
||
const own = (node) => { disposeWith(() => node.disconnect()); return node; };
|
||
const scheduledSource = (value, min, max, schedule) => {
|
||
const source = own(context.createConstantSource());
|
||
source.offset.value = value;
|
||
disposeWith(() => { source.offset.cancelScheduledValues?.(context.currentTime); try { source.stop(); } catch { /* not started */ } });
|
||
schedule?.(source.offset);
|
||
source.start(startTime);
|
||
return { output: source, min, max };
|
||
};
|
||
const constant = (value, track) => {
|
||
if (track?.interpolation === 'smooth') return smoothSignal(track);
|
||
if (!track && constants.has(value)) return constants.get(value);
|
||
const values = track ? track.points.map((point) => point.value) : [value];
|
||
const signal = scheduledSource(value, Math.min(...values), Math.max(...values), track ? (param) => scheduleNativeAutomation(param, track, startTime) : undefined);
|
||
if (!track) constants.set(value, signal);
|
||
return signal;
|
||
};
|
||
const materialize = (signal) => signal.output ? signal : constant(signal.value);
|
||
const scale = (signal, factor) => {
|
||
if (signal.value !== undefined) return literal(signal.value * factor);
|
||
if (factor === 0) return literal(0);
|
||
if (factor === 1) return signal;
|
||
const gain = own(context.createGain());
|
||
gain.gain.value = factor;
|
||
signal.output.connect(gain);
|
||
return { output: gain, min: Math.min(signal.min * factor, signal.max * factor), max: Math.max(signal.min * factor, signal.max * factor) };
|
||
};
|
||
const add = (a, b) => {
|
||
if (a.value !== undefined && b.value !== undefined) return literal(a.value + b.value);
|
||
if (a.value === 0) return b;
|
||
if (b.value === 0) return a;
|
||
const sum = own(context.createGain());
|
||
materialize(a).output.connect(sum);
|
||
materialize(b).output.connect(sum);
|
||
return { output: sum, min: a.min + b.min, max: a.max + b.max };
|
||
};
|
||
const multiply = (a, b) => {
|
||
if (a.value !== undefined) return scale(b, a.value);
|
||
if (b.value !== undefined) return scale(a, b.value);
|
||
const gain = own(context.createGain());
|
||
gain.gain.value = 0;
|
||
a.output.connect(gain);
|
||
b.output.connect(gain.gain);
|
||
const bounds = [a.min * b.min, a.min * b.max, a.max * b.min, a.max * b.max];
|
||
return { output: gain, min: Math.min(...bounds), max: Math.max(...bounds) };
|
||
};
|
||
const smoothSignal = (track) => {
|
||
const points = track.points;
|
||
const spans = points.slice(1).map((point, i) => point.value - points[i].value);
|
||
const values = points.map((point) => point.value);
|
||
const low = Math.min(...values), high = Math.max(...values);
|
||
const progress = scheduledSource(0, 0, 1, (param) => {
|
||
param.setValueAtTime(0, startTime);
|
||
for (let i = 1; i < points.length; i++) {
|
||
param.setValueAtTime(0, startTime + points[i - 1].at / 1000);
|
||
param.linearRampToValueAtTime(1, startTime + points[i].at / 1000);
|
||
}
|
||
});
|
||
const steps = (initial, min, max, valueAt) => scheduledSource(initial, min, max, (param) => {
|
||
param.setValueAtTime(initial, startTime);
|
||
for (let i = 0; i < points.length - 1; i++) param.setValueAtTime(valueAt(i), startTime + points[i].at / 1000);
|
||
});
|
||
const origin = steps(points[0].value, low, high, (i) => points[i].value);
|
||
const excursion = steps(spans[0], Math.min(...spans), Math.max(...spans), (i) => spans[i]);
|
||
// a-rate polynomial 3t² - 2t³, not a sampled approximation of the curve.
|
||
const smooth = multiply(multiply(progress, progress), add(literal(3), scale(progress, -2)));
|
||
return { ...add(origin, multiply(excursion, smooth)), min: low, max: high };
|
||
};
|
||
const shape = (signal, fn, lower = signal.min, upper = signal.max, samples = 4097) => {
|
||
if (signal.value !== undefined || lower === upper) return literal(fn(signal.value ?? lower));
|
||
if (!Number.isFinite(lower) || !Number.isFinite(upper)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Audio control bounds must be finite.');
|
||
const half = (upper - lower) / 2;
|
||
const normalized = add(scale(signal, 1 / half), literal(-lower / half - 1));
|
||
const shaper = own(context.createWaveShaper());
|
||
shaper.curve = Float32Array.from({ length: samples }, (_, i) => fn(lower + (upper - lower) * i / (samples - 1)));
|
||
const bounds = [...shaper.curve];
|
||
materialize(normalized).output.connect(shaper);
|
||
disposeWith(() => { shaper.curve = null; });
|
||
return { output: shaper, min: Math.min(...bounds), max: Math.max(...bounds) };
|
||
};
|
||
const bounded = (signal, min, max) => {
|
||
const lower = Math.max(signal.min, min), upper = Math.min(signal.max, max);
|
||
if (signal.max <= min) return literal(min);
|
||
if (signal.min >= max) return literal(max);
|
||
if (signal.min >= min && signal.max <= max) return signal;
|
||
// WaveShaper saturates outside [-1,1]; these three collinear points make
|
||
// the property safety clamp exact, including after an arbitrary route sum.
|
||
return shape(signal, (value) => value, lower, upper, 3);
|
||
};
|
||
const abs = (signal) => signal.min >= 0 ? signal : signal.max <= 0 ? scale(signal, -1) : shape(signal, Math.abs, -Math.max(-signal.min, signal.max), Math.max(-signal.min, signal.max), 3);
|
||
const minimum = (a, b) => scale(add(add(a, b), scale(abs(add(a, scale(b, -1))), -1)), 0.5);
|
||
const maximum = (a, b) => scale(add(add(a, b), abs(add(a, scale(b, -1)))), 0.5);
|
||
|
||
const expression = (spec, scope) => {
|
||
if (typeof spec === 'number') return literal(spec);
|
||
if (spec.ref) return property(scope, spec.ref.slice('inputs.'.length));
|
||
const args = spec.args.map((arg) => expression(arg, scope));
|
||
if (args.every((arg) => arg.value !== undefined)) return literal(new ValueResolver(() => {}).evaluate({ op: spec.op, args: args.map((arg) => arg.value) }, null));
|
||
const [a, b, c] = args;
|
||
switch (spec.op) {
|
||
case 'add': return add(a, b);
|
||
case 'subtract': return add(a, scale(b, -1));
|
||
case 'negate': return scale(a, -1);
|
||
case 'multiply': return multiply(a, b);
|
||
case 'divide': return multiply(a, shape(b, (value) => value === 0 ? 0 : 1 / value));
|
||
case 'abs': return abs(a);
|
||
case 'min': return minimum(a, b);
|
||
case 'max': return maximum(a, b);
|
||
case 'clamp': return minimum(maximum(a, b), c);
|
||
case 'lerp': return add(a, multiply(add(b, scale(a, -1)), c));
|
||
case 'round': case 'floor': case 'ceil': return shape(a, Math[spec.op]);
|
||
default: throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported audio expression '${spec.op}'.`);
|
||
}
|
||
};
|
||
const modulationSource = (route) => {
|
||
const node = nodes.get(route.from), output = entries.get(route.from)?.output;
|
||
if (!output) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Missing modulation source '${route.from}'.`);
|
||
const v = node.values;
|
||
const range = node.type === 'constant' ? [v.value, v.value]
|
||
: node.type === 'sample-hold' ? [Math.min(0, v.min), Math.max(0, v.max)]
|
||
: node.type === 'lfo' ? [v.polarity === 'unipolar' ? 0 : -v.amplitude, v.amplitude] : [-1, 1];
|
||
return { output, min: range[0], max: range[1] };
|
||
};
|
||
const property = (path, field) => {
|
||
const key = `${path}::${field}`;
|
||
if (cache.has(key)) return cache.get(key);
|
||
if (building.has(key)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Audio control cycle at '${key}'.`);
|
||
building.add(key);
|
||
const node = nodes.get(path);
|
||
let signal = expression(node.expressions?.[field] ?? node.values[field], node.scope);
|
||
const track = plan.automation?.find((item) => item.target === path && item.property === field);
|
||
if (track) {
|
||
const curve = constant(track.points[0].value, track);
|
||
signal = track.mode === 'offset' ? add(signal, curve) : track.mode === 'scale' ? multiply(signal, curve) : curve;
|
||
}
|
||
for (const route of plan.routes) if (route.kind === 'modulation' && route.to === path && route.property === field) signal = add(signal, scale(modulationSource(route), route.depth));
|
||
const { min, max } = audioPropertyRange(node, field, plan.ceiling);
|
||
signal = bounded(signal, min, max);
|
||
cache.set(key, signal);
|
||
building.delete(key);
|
||
return signal;
|
||
};
|
||
const connect = (signal, param, factor = 1) => {
|
||
if (signal.value !== undefined) param.value = signal.value * factor;
|
||
else {
|
||
param.value = 0;
|
||
scale(signal, factor).output.connect(param);
|
||
}
|
||
disposeWith(() => param.cancelScheduledValues?.(context.currentTime));
|
||
};
|
||
return {
|
||
apply() {
|
||
for (const node of plan.nodes) {
|
||
if (node.implicit || node.type === 'component') continue;
|
||
const entry = entries.get(node.path);
|
||
for (const field of Object.keys(AUDIO_MODULATABLE[node.type] ?? {})) {
|
||
const signal = property(node.path, field);
|
||
if (field === 'fundamental') {
|
||
for (const band of entry.bands ?? []) if (band.ratio !== undefined) {
|
||
const frequency = bounded(scale(signal, band.ratio), 0.1, plan.ceiling);
|
||
connect(frequency, band.frequency);
|
||
connect(maximum(literal(1), scale(frequency, band.decay / 3000)), band.q);
|
||
}
|
||
} else connect(signal, entry.params[field], field === 'time' ? 0.001 : 1);
|
||
}
|
||
}
|
||
},
|
||
dispose() { cache.clear(); constants.clear(); nodes.clear(); building.clear(); }
|
||
};
|
||
}
|
||
|
||
/* src/runtime/visual-contract.js */
|
||
// Visual subsystem contract registries, sections 17-19 of the Format
|
||
// Specification at revision 0.8. This module is the single place the runtime
|
||
// reads the normative vocabularies, ceilings, and target capabilities from, in
|
||
// the same role `audio-contract.js` plays for sections 14-16.
|
||
//
|
||
// Stage 0 scope (pre-slice-4d): vocabularies, the centralized ceiling table,
|
||
// the four section 8.1 target families, and the structural surface of the
|
||
// `visuals` block. It draws nothing. Slices 4d, 4e, and 4f add the renderer,
|
||
// the procedural systems, and the automation/lifecycle/effect execution that
|
||
// these registries describe.
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Vocabularies
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const COORDINATE_SPACES = Object.freeze(['normalized', 'viewport', 'virtual']);
|
||
const FIT_MODES = Object.freeze(['contain', 'cover', 'stretch']);
|
||
|
||
/** Visual System Set 0.1 (17.7, 18.2, 18.4, 18.5). */
|
||
const VISUAL_SYSTEM_TYPES = Object.freeze(['graphic', 'particles', 'emitter', 'repeater']);
|
||
|
||
/** Visual Primitive Set 0.1 (17.9) — closed at fourteen geometry primitives. */
|
||
const VISUAL_PRIMITIVE_TYPES = Object.freeze([
|
||
'point', 'line', 'polyline', 'polygon', 'rectangle', 'rounded-rectangle',
|
||
'ellipse', 'arc', 'ring', 'path', 'bezier', 'spline', 'text', 'group'
|
||
]);
|
||
|
||
/** The fifteenth visual *object* type, which declares no geometry (18.1). */
|
||
const VISUAL_OBJECT_TYPES = Object.freeze([...VISUAL_PRIMITIVE_TYPES, 'component']);
|
||
|
||
/** Visual Behavior Set 0.1 (18.6). */
|
||
const VISUAL_BEHAVIOR_TYPES = Object.freeze([
|
||
'drift', 'rotate', 'oscillate', 'orbit', 'wander', 'follow-path', 'point-wander',
|
||
'pulse', 'twinkle', 'noise-displace', 'face-motion', 'wrap', 'bounce',
|
||
'attract', 'repel', 'field-follow', 'morph'
|
||
]);
|
||
|
||
/** Procedural field set (18.7). */
|
||
const VISUAL_FIELD_TYPES = Object.freeze([
|
||
'directional', 'radial', 'vortex', 'attractor', 'repulsor', 'noise'
|
||
]);
|
||
|
||
/** Placement distribution set (18.3). */
|
||
const DISTRIBUTION_TYPES = Object.freeze([
|
||
'point', 'uniform', 'line', 'rectangle', 'ellipse', 'ring', 'path', 'grid', 'depth'
|
||
]);
|
||
|
||
/** Safe blend set (17.12). */
|
||
const BLEND_MODES = Object.freeze([
|
||
'normal', 'add', 'screen', 'multiply', 'overlay', 'lighten', 'darken', 'difference'
|
||
]);
|
||
|
||
/** Object filter vocabulary (17.12). */
|
||
const FILTER_TYPES = Object.freeze([
|
||
'brightness', 'contrast', 'saturate', 'hue-rotate', 'grayscale', 'sepia', 'invert'
|
||
]);
|
||
|
||
/** Automation curves and loop modes (19.1). */
|
||
const AUTOMATION_CURVES = Object.freeze(['step', 'linear', 'exponential', 'smooth']);
|
||
const AUTOMATION_MODES = Object.freeze(['absolute', 'offset', 'scale']);
|
||
const LOOP_MODES = Object.freeze(['repeat', 'ping-pong']);
|
||
|
||
const LIFECYCLE_MODES = Object.freeze(['persistent', 'spawned']);
|
||
|
||
/**
|
||
* Fields of the `spawn` container (19.2). Section 17.7 makes each of these
|
||
* `ERR_UNKNOWN_FIELD` at the top level of a system, which is what keeps a
|
||
* spawned instance's `lifetime` distinct from a particle's.
|
||
*/
|
||
const SPAWN_FIELDS = Object.freeze(['lifetime', 'release', 'ownership', 'inputs', 'cancelWithScenario']);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Camera and post-effects
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 19.3. `projection` is authored once: not automatable, not bindable. */
|
||
const CAMERA_FIELDS = Object.freeze({
|
||
x: { type: 'number' },
|
||
y: { type: 'number' },
|
||
zoom: { type: 'number', min: 0.01, max: 100, default: 1 },
|
||
rotation: { type: 'number', default: 0 },
|
||
focalLength: { type: 'number', min: 1, max: 100_000, default: 1000 }
|
||
});
|
||
const CAMERA_PROJECTIONS = Object.freeze(['orthographic', 'perspective']);
|
||
|
||
/**
|
||
* 19.4, in normative array order. `numeric` carries each parameter's range and
|
||
* default; `colors` are authored literals and take no ValueSpec. `passes` is
|
||
* the frame budget cost: blur and bloom read back the frame and cost two.
|
||
*/
|
||
const POST_EFFECTS = Object.freeze({
|
||
vignette: {
|
||
passes: 1,
|
||
numeric: { amount: { min: 0, max: 1, default: 0.5 }, radius: { min: 0, max: 1, default: 0.75 }, softness: { min: 0, max: 1, default: 0.5 } },
|
||
colors: { color: '#000000' }
|
||
},
|
||
scanlines: {
|
||
passes: 1,
|
||
numeric: { amount: { min: 0, max: 1, default: 0.3 }, spacing: { min: 1, max: 64, default: 3 }, thickness: { min: 0, max: 1, default: 0.5 }, speed: { default: 0 } },
|
||
colors: {}
|
||
},
|
||
grain: {
|
||
passes: 1,
|
||
numeric: { amount: { min: 0, max: 1, default: 0.15 }, scale: { min: 0.25, max: 16, default: 1 }, speed: { min: 0, max: 60, default: 24 } },
|
||
colors: {}
|
||
},
|
||
'color-adjust': {
|
||
passes: 1,
|
||
numeric: { brightness: { min: 0, max: 4, default: 1 }, contrast: { min: 0, max: 4, default: 1 }, saturation: { min: 0, max: 4, default: 1 }, hueRotate: { default: 0 } },
|
||
colors: {}
|
||
},
|
||
blur: { passes: 2, numeric: { radius: { min: 0, max: 32, default: 4 } }, colors: {} },
|
||
bloom: {
|
||
passes: 2,
|
||
numeric: { threshold: { min: 0, max: 1, default: 0.7 }, intensity: { min: 0, max: 2, default: 0.6 }, radius: { min: 0, max: 32, default: 8 } },
|
||
colors: {}
|
||
},
|
||
fade: { passes: 1, numeric: { amount: { min: 0, max: 1, default: 0 } }, colors: { color: '#000000' } }
|
||
});
|
||
|
||
const POST_EFFECT_TYPES = Object.freeze(Object.keys(POST_EFFECTS));
|
||
|
||
/**
|
||
* `color-adjust`'s authored parameter names map onto the 17.12 filter
|
||
* operations without renaming the authored fields (19.4).
|
||
*/
|
||
const EFFECT_FILTER_OPERATIONS = Object.freeze({
|
||
brightness: 'brightness', contrast: 'contrast', saturation: 'saturate', hueRotate: 'hue-rotate'
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The centralized ceiling table (19.5)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Authoring bounds reject an exhibit at import; runtime ceilings shed work by a
|
||
* documented deterministic rule and keep the exhibit running. Every aggregate
|
||
* value here is provisional until the slice 4h GC6 measurement runs — its
|
||
* shape is normative now, its number is not measured.
|
||
*/
|
||
const VISUAL_LIMITS = Object.freeze({
|
||
authoring: Object.freeze({
|
||
layers: 16,
|
||
declaredSystems: 64,
|
||
expandedStaticObjects: 16_384,
|
||
groupNesting: 8,
|
||
componentNesting: 8,
|
||
polygonVertices: 512,
|
||
pathCommands: 512,
|
||
splinePoints: 256,
|
||
gradientStops: 16,
|
||
strokeDashEntries: 8,
|
||
filtersPerObject: 4,
|
||
behaviorsPerObject: 8,
|
||
declaredFields: 8,
|
||
referencedFieldsPerSystem: 4,
|
||
particleCapacity: 4096,
|
||
emitterCapacity: 512,
|
||
repeaterCount: 1024,
|
||
burstEntries: 16,
|
||
gridDimension: 256,
|
||
trailLength: 128,
|
||
maxLinks: 1024,
|
||
nearestLinksPerItem: 8,
|
||
pairwiseLinkedPopulation: 256,
|
||
textCharacters: 256,
|
||
postEffectEntries: 4,
|
||
effectRadius: 32,
|
||
automationTracks: 128,
|
||
automationPoints: 2048,
|
||
automationPointsPerTrack: 256
|
||
}),
|
||
runtime: Object.freeze({
|
||
liveParticles: 8192,
|
||
liveEmittedItems: 2048,
|
||
liveSpawnedInstances: 64,
|
||
linkSegmentsPerTick: 4096,
|
||
trailHistorySamples: 32_768,
|
||
fieldEvaluationsPerTick: 32_768,
|
||
offscreenBuffersPerFrame: 16,
|
||
postEffectPassesPerFrame: 8,
|
||
devicePixelRatioCeiling: 2,
|
||
backingStoreEdge: 4096,
|
||
liveAutomationTracks: 128,
|
||
liveAutomationPoints: 2048,
|
||
sustainedShedTicks: 120
|
||
})
|
||
});
|
||
|
||
/** 19.5: one diagnostic per (code, subject) key per logical second. */
|
||
const DIAGNOSTIC_CADENCE_MS = 1000;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The four section 8.1 visual target families (19.1)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const EFFECT_INDEX_PATTERN = /^visuals\.effects\[(\d+)\]\.([A-Za-z][A-Za-z0-9]*)$/;
|
||
|
||
/**
|
||
* Resolve a target path against the four visual target families. Returns a
|
||
* capability record, or `null` when the path is not a visual target at all.
|
||
*
|
||
* A path inside a family that names something undeclared returns
|
||
* `{ reason: 'reference' }` so the caller can raise ERR_INVALID_REFERENCE
|
||
* rather than ERR_UNSUPPORTED_TARGET; a path that looks visual but is outside
|
||
* every family returns `{ reason: 'unsupported' }`. That distinction is what
|
||
* trace 4 of 19.7 and trace 14 of 17.16 both check.
|
||
*/
|
||
function matchVisualTarget(document, path) {
|
||
if (typeof path !== 'string' || !path.startsWith('visuals.')) return null;
|
||
const visuals = document?.visuals;
|
||
|
||
const effect = EFFECT_INDEX_PATTERN.exec(path);
|
||
if (effect) {
|
||
const index = Number(effect[1]);
|
||
const entry = Array.isArray(visuals?.effects) ? visuals.effects[index] : undefined;
|
||
if (!entry) return { reason: 'reference' };
|
||
const definition = POST_EFFECTS[entry.type];
|
||
const parameter = definition?.numeric?.[effect[2]];
|
||
if (!parameter) return { reason: 'unsupported' };
|
||
return {
|
||
family: 'visuals.effects', namespace: 'visual-effect', id: `${index}.${effect[2]}`,
|
||
index, parameter: effect[2],
|
||
spec: { type: 'number', min: parameter.min, max: parameter.max },
|
||
default: parameter.default,
|
||
stages: { binding: true, automation: true, override: true, modulation: false }
|
||
};
|
||
}
|
||
|
||
const parts = path.split('.');
|
||
if (parts.length === 3 && parts[1] === 'camera') {
|
||
const field = CAMERA_FIELDS[parts[2]];
|
||
if (!field) return { reason: 'unsupported' };
|
||
return {
|
||
family: 'visuals.camera', namespace: 'visual-camera', id: parts[2],
|
||
spec: { type: 'number', min: field.min, max: field.max },
|
||
default: field.default,
|
||
stages: { binding: true, automation: true, override: true, modulation: true }
|
||
};
|
||
}
|
||
|
||
if (parts.length === 4 && parts[1] === 'layers') {
|
||
if (!ID_PATTERN.test(parts[2]) || !visuals?.layers?.[parts[2]]) return { reason: 'reference' };
|
||
if (parts[3] !== 'opacity') return { reason: 'unsupported' };
|
||
return {
|
||
family: 'visuals.layers', namespace: 'visual-layer', id: parts[2],
|
||
spec: { type: 'number', min: 0, max: 1 }, default: 1,
|
||
stages: { binding: true, automation: true, override: true, modulation: false }
|
||
};
|
||
}
|
||
|
||
if (parts.length === 4 && parts[1] === 'systems') {
|
||
if (!ID_PATTERN.test(parts[2]) || !visuals?.systems?.[parts[2]]) return { reason: 'reference' };
|
||
if (parts[3] !== 'visible') return { reason: 'unsupported' };
|
||
return {
|
||
family: 'visuals.systems', namespace: 'visual-system', id: parts[2],
|
||
spec: { type: 'boolean' }, default: true,
|
||
// Boolean targets take no automation and no modulation stage: those
|
||
// stages are absent, not identity hooks (8.1).
|
||
stages: { binding: true, automation: false, override: true, modulation: false }
|
||
};
|
||
}
|
||
|
||
return { reason: 'unsupported' };
|
||
}
|
||
|
||
/** The authored ValueSpec behind a visual target, or its documented default. */
|
||
function visualTargetSource(document, target) {
|
||
const visuals = document?.visuals ?? {};
|
||
switch (target.namespace) {
|
||
case 'visual-camera': return visuals.camera?.[target.id] ?? cameraDefault(visuals, target.id);
|
||
case 'visual-layer': return visuals.layers?.[target.id]?.opacity ?? 1;
|
||
case 'visual-system': return visuals.systems?.[target.id]?.visible ?? true;
|
||
case 'visual-effect': return visuals.effects?.[target.index]?.[target.parameter] ?? target.default;
|
||
default: return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 19.3: the camera default is the scene center, so an exhibit that never
|
||
* mentions the camera looks identical to one that declares its defaults.
|
||
*/
|
||
function cameraDefault(visuals, field) {
|
||
if (field !== 'x' && field !== 'y') return CAMERA_FIELDS[field].default;
|
||
const scene = visuals.scene ?? {};
|
||
if (scene.coordinateSpace === 'normalized') return 0.5;
|
||
// In `viewport` space the scene rectangle *is* the display rectangle, whose
|
||
// size is not known at import. The resolved base is 0 here and slice 4d
|
||
// substitutes the live display center for an unauthored camera position.
|
||
const extent = field === 'x' ? scene.width : scene.height;
|
||
return Number.isFinite(extent) ? extent / 2 : 0;
|
||
}
|
||
|
||
/** Frame pass cost of an authored effect chain (19.4). */
|
||
function postEffectPassCount(effects) {
|
||
if (!Array.isArray(effects)) return 0;
|
||
return effects.reduce((total, entry) => total + (POST_EFFECTS[entry?.type]?.passes ?? 0), 0);
|
||
}
|
||
|
||
/* src/runtime/visual-diagnostics.js */
|
||
// The single visual diagnostic cadence of 19.5.
|
||
//
|
||
// Every resource diagnostic is keyed by (code, subject) and raised at most once
|
||
// per logical second. A key counts consecutive shedding ticks; on reaching 120
|
||
// the next raise for that key carries a `sustained` detail, which does not
|
||
// bypass the rate limit and does not mint a second code. One tick with no shed
|
||
// on a key resets both the counter and the rate limit, so a recovery followed
|
||
// by a new overload reports promptly instead of being suppressed.
|
||
|
||
|
||
const SUSTAINED_TICKS = VISUAL_LIMITS.runtime.sustainedShedTicks;
|
||
|
||
class VisualDiagnosticCadence {
|
||
constructor(diagnostics, { section = 'visual' } = {}) {
|
||
this.diagnostics = diagnostics;
|
||
this.section = section;
|
||
this.keys = new Map();
|
||
this.raised = [];
|
||
}
|
||
|
||
entry(key) {
|
||
if (!this.keys.has(key)) this.keys.set(key, { lastRaisedAt: -Infinity, consecutive: 0, seenThisTick: false });
|
||
return this.keys.get(key);
|
||
}
|
||
|
||
// Report one shed. Returns the diagnostic raised, or null when the key's
|
||
// per-second slot is still occupied.
|
||
shed(code, subject, message, logicalMilliseconds, context = {}) {
|
||
const key = code + ' ' + subject;
|
||
const state = this.entry(key);
|
||
if (!state.seenThisTick) state.consecutive += 1;
|
||
state.seenThisTick = true;
|
||
if (logicalMilliseconds - state.lastRaisedAt < DIAGNOSTIC_CADENCE_MS) return null;
|
||
state.lastRaisedAt = logicalMilliseconds;
|
||
const sustained = state.consecutive >= SUSTAINED_TICKS;
|
||
const text = sustained ? message + ' Sustained for ' + state.consecutive + ' consecutive ticks.' : message;
|
||
const record = { code, subject, message: text, sustained, at: logicalMilliseconds, ...context };
|
||
this.raised.push(record);
|
||
this.diagnostics?.warn(code, text, { section: this.section, objectId: subject, ...context });
|
||
return record;
|
||
}
|
||
|
||
// Close a tick: any key that did not shed resets its counter and its slot.
|
||
endTick() {
|
||
for (const state of this.keys.values()) {
|
||
if (state.seenThisTick) state.seenThisTick = false;
|
||
else if (state.consecutive !== 0) {
|
||
state.consecutive = 0;
|
||
state.lastRaisedAt = -Infinity;
|
||
}
|
||
}
|
||
// P3.1: Cap raised list to prevent unbounded growth over long sessions.
|
||
if (this.raised.length > 1024) this.raised.splice(0, this.raised.length - 1024);
|
||
}
|
||
|
||
/** P3.1: Snapshot raised diagnostics and clear the list to prevent unbounded growth. */
|
||
snapshot() {
|
||
const copy = this.raised.slice();
|
||
this.raised.length = 0;
|
||
return copy;
|
||
}
|
||
|
||
// A capability warning, not a resource one: 17.12's conic fallback and
|
||
// 19.4's reduced-resolution blur report a renderer fact once per instance for
|
||
// the instance's life, and are deliberately outside the per-second cadence.
|
||
once(code, subject, message, context = {}) {
|
||
const key = 'once ' + code + ' ' + subject;
|
||
if (this.keys.has(key)) return null;
|
||
this.keys.set(key, { lastRaisedAt: Infinity, consecutive: 0, seenThisTick: false });
|
||
const record = { code, subject, message, sustained: false, at: null, ...context };
|
||
this.raised.push(record);
|
||
this.diagnostics?.warn(code, message, { section: this.section, objectId: subject, ...context });
|
||
return record;
|
||
}
|
||
|
||
clear() { this.keys.clear(); this.raised.length = 0; }
|
||
}
|
||
|
||
/* src/runtime/visual-math.js */
|
||
// 2D affine matrices, angle conventions, and color arithmetic for the visual
|
||
// subsystem (sections 17.2, 17.6, 17.11, 19.3 of the Format Specification at
|
||
// revision 0.8).
|
||
//
|
||
// Matrices are the six-element affine form [a, b, c, d, e, f]:
|
||
//
|
||
// x' = a * x + c * y + e
|
||
// y' = b * x + d * y + f
|
||
//
|
||
// `multiply(m, n)` composes so that `n` is applied to the point first, which is
|
||
// the right-to-left reading the transform model of 17.11 and the camera chain
|
||
// of 19.3 are both written in.
|
||
|
||
|
||
const IDENTITY = Object.freeze([1, 0, 0, 1, 0, 0]);
|
||
|
||
const DEGREES_TO_RADIANS = Math.PI / 180;
|
||
|
||
function multiply(m, n) {
|
||
return [
|
||
m[0] * n[0] + m[2] * n[1],
|
||
m[1] * n[0] + m[3] * n[1],
|
||
m[0] * n[2] + m[2] * n[3],
|
||
m[1] * n[2] + m[3] * n[3],
|
||
m[0] * n[4] + m[2] * n[5] + m[4],
|
||
m[1] * n[4] + m[3] * n[5] + m[5]
|
||
];
|
||
}
|
||
|
||
function compose(...matrices) {
|
||
return matrices.reduce((total, matrix) => multiply(total, matrix), IDENTITY);
|
||
}
|
||
|
||
function translation(tx, ty) { return [1, 0, 0, 1, tx, ty]; }
|
||
function scaling(sx, sy) { return [sx, 0, 0, sy, 0, 0]; }
|
||
|
||
/** 17.2: degrees, `0` along `+x`, positive angles turning toward `+y`. */
|
||
function rotation(degrees) {
|
||
const radians = degrees * DEGREES_TO_RADIANS;
|
||
const cos = Math.cos(radians);
|
||
const sin = Math.sin(radians);
|
||
return [cos, sin, -sin, cos, 0, 0];
|
||
}
|
||
|
||
/** 17.11: `skew` is authored in degrees on each axis. */
|
||
function skewing(xDegrees, yDegrees) {
|
||
return [1, Math.tan(yDegrees * DEGREES_TO_RADIANS), Math.tan(xDegrees * DEGREES_TO_RADIANS), 1, 0, 0];
|
||
}
|
||
|
||
function apply(matrix, x, y) {
|
||
return [matrix[0] * x + matrix[2] * y + matrix[4], matrix[1] * x + matrix[3] * y + matrix[5]];
|
||
}
|
||
|
||
/** The uniform factor an axis-less appearance dimension takes (19.3). */
|
||
function uniformFactor(matrix) {
|
||
return Math.sqrt(Math.abs(matrix[0] * matrix[3] - matrix[1] * matrix[2]));
|
||
}
|
||
|
||
/**
|
||
* 17.11: `M_local = T(position + translate) x T(origin) x R x K x S x T(-origin)`.
|
||
* The order is normative even where a particular object would not notice.
|
||
*/
|
||
function localMatrix({ position = { x: 0, y: 0 }, translate = { x: 0, y: 0 }, rotation: angle = 0, skew = { x: 0, y: 0 }, scale = { x: 1, y: 1 }, origin = { x: 0, y: 0 } } = {}) {
|
||
return compose(
|
||
translation(position.x + translate.x, position.y + translate.y),
|
||
translation(origin.x, origin.y),
|
||
rotation(angle),
|
||
skewing(skew.x, skew.y),
|
||
scaling(scale.x, scale.y),
|
||
translation(-origin.x, -origin.y)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 17.9: the directed sweep of an `arc` or `ring`. The `360` bound is checked on
|
||
* the authored numbers before any normalization, so `0 -> 720` is rejected
|
||
* rather than folded into a full turn.
|
||
*/
|
||
function directedSweep(startAngle, endAngle, direction = 'clockwise', path = '$') {
|
||
const delta = direction === 'counter-clockwise' ? startAngle - endAngle : endAngle - startAngle;
|
||
if (!Number.isFinite(delta)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Arc angles must be finite.', path);
|
||
if (Math.abs(delta) > 360) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc sweep may not exceed 360 degrees.', path);
|
||
const sign = direction === 'counter-clockwise' ? -1 : 1;
|
||
if (delta === 0) return { sweep: 0, sign };
|
||
// Normalize the *signed* delta, so 350 -> 10 clockwise is a 20-degree sweep
|
||
// through zero rather than a 340-degree one the long way round.
|
||
const magnitude = Math.abs(delta) === 360 ? 360 : ((delta % 360) + 360) % 360;
|
||
return { sweep: magnitude, sign };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Color (section 2: `#rgb`, `#rrggbb`, `#rrggbbaa`, or a CSS color keyword)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const KEYWORDS = Object.freeze({
|
||
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff',
|
||
beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000', blanchedalmond: '#ffebcd', blue: '#0000ff',
|
||
blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
|
||
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
|
||
cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b', darkgray: '#a9a9a9',
|
||
darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b', darkmagenta: '#8b008b', darkolivegreen: '#556b2f',
|
||
darkorange: '#ff8c00', darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f',
|
||
darkslateblue: '#483d8b', darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1',
|
||
darkviolet: '#9400d3', deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
|
||
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22', fuchsia: '#ff00ff',
|
||
gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', gray: '#808080',
|
||
green: '#008000', greenyellow: '#adff2f', grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4',
|
||
indianred: '#cd5c5c', indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
|
||
lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', lightcoral: '#f08080',
|
||
lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3', lightgreen: '#90ee90',
|
||
lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa',
|
||
lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de', lightyellow: '#ffffe0',
|
||
lime: '#00ff00', limegreen: '#32cd32', linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000',
|
||
mediumaquamarine: '#66cdaa', mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db',
|
||
mediumseagreen: '#3cb371', mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc',
|
||
mediumvioletred: '#c71585', midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
|
||
navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23',
|
||
orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6', palegoldenrod: '#eee8aa', palegreen: '#98fb98',
|
||
paleturquoise: '#afeeee', palevioletred: '#db7093', papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f',
|
||
pink: '#ffc0cb', plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399',
|
||
red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513', salmon: '#fa8072',
|
||
sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee', sienna: '#a0522d', silver: '#c0c0c0',
|
||
skyblue: '#87ceeb', slateblue: '#6a5acd', slategray: '#708090', slategrey: '#708090', snow: '#fffafa',
|
||
springgreen: '#00ff7f', steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
|
||
tomato: '#ff6347', transparent: '#00000000', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3',
|
||
white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32'
|
||
});
|
||
|
||
const HEX3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i;
|
||
const HEX6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
|
||
const HEX8 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
|
||
|
||
/**
|
||
* Parse a section 2 `color` into `{ r, g, b, a }` with components on `0..255`
|
||
* and alpha on `0..1`. Anything outside the four documented forms is
|
||
* ERR_TYPE_MISMATCH: the strict coercion ban of section 2 leaves no room for
|
||
* guessing, and depth fog (17.6) needs exact components to be renderer-independent.
|
||
*/
|
||
function parseColor(value, path = '$') {
|
||
if (typeof value !== 'string' || value.length === 0) {
|
||
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Expected a color, received ${typeof value}.`, path);
|
||
}
|
||
const text = KEYWORDS[value.toLowerCase()] ?? value;
|
||
let match = HEX8.exec(text);
|
||
if (match) return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16), a: parseInt(match[4], 16) / 255 };
|
||
match = HEX6.exec(text);
|
||
if (match) return { r: parseInt(match[1], 16), g: parseInt(match[2], 16), b: parseInt(match[3], 16), a: 1 };
|
||
match = HEX3.exec(text);
|
||
if (match) return { r: parseInt(match[1] + match[1], 16), g: parseInt(match[2] + match[2], 16), b: parseInt(match[3] + match[3], 16), a: 1 };
|
||
throw new RuntimeFault('ERR_TYPE_MISMATCH', `'${value}' is not a #rgb, #rrggbb, #rrggbbaa, or CSS keyword color.`, path);
|
||
}
|
||
|
||
function isColorLiteral(value) {
|
||
try { parseColor(value); return true; } catch { return false; }
|
||
}
|
||
|
||
function formatColor({ r, g, b, a }) {
|
||
const byte = (component) => Math.max(0, Math.min(255, Math.round(component))).toString(16).padStart(2, '0');
|
||
const hex = `#${byte(r)}${byte(g)}${byte(b)}`;
|
||
return a >= 1 ? hex : `${hex}${byte(a * 255)}`;
|
||
}
|
||
|
||
/**
|
||
* 17.6: depth fog blends per component in non-premultiplied sRGB with no
|
||
* linearization step. Alpha is never fogged, and the fog color's own alpha is
|
||
* ignored, so an object keeps exactly the transparency it was authored with.
|
||
*/
|
||
function fogColor(color, fog, fraction) {
|
||
if (fraction <= 0) return color;
|
||
const f = Math.min(1, fraction);
|
||
return {
|
||
r: color.r + (fog.r - color.r) * f,
|
||
g: color.g + (fog.g - color.g) * f,
|
||
b: color.b + (fog.b - color.b) * f,
|
||
a: color.a
|
||
};
|
||
}
|
||
|
||
/** 18.2: a `color` life ramp interpolates in sRGB component space including alpha. */
|
||
function mixColor(from, to, amount) {
|
||
return {
|
||
r: from.r + (to.r - from.r) * amount,
|
||
g: from.g + (to.g - from.g) * amount,
|
||
b: from.b + (to.b - from.b) * amount,
|
||
a: from.a + (to.a - from.a) * amount
|
||
};
|
||
}
|
||
|
||
/* src/runtime/visual-geometry.js */
|
||
// Local-space geometry for Visual Primitive Set 0.1 (17.9, 17.13).
|
||
//
|
||
// Every primitive is reduced to the same shape: a list of subpaths whose
|
||
// segments are straight lines and cubic Beziers. Curves are represented rather
|
||
// than flattened, because an affine map carries a cubic Bezier to a cubic
|
||
// Bezier exactly, which is what lets the engine transform geometry through the
|
||
// full composition chain of 19.3 without approximating it.
|
||
//
|
||
// Local extents and anchors follow the table of 17.9: a rectangle's top-left
|
||
// corner sits at the origin, circular primitives are centered on it, and a
|
||
// path's commands carry their own local coordinates.
|
||
|
||
|
||
|
||
/** The circular Bezier constant: 4/3 * tan(pi/8). */
|
||
const KAPPA = 0.5522847498307936;
|
||
|
||
function subpath(start) {
|
||
return { start, segments: [], closed: false };
|
||
}
|
||
|
||
function lineTo(current, to) {
|
||
current.segments.push({ type: 'line', to });
|
||
}
|
||
|
||
function cubicTo(current, c1, c2, to) {
|
||
current.segments.push({ type: 'cubic', c1, c2, to });
|
||
}
|
||
|
||
/** One elliptical arc segment, center-parameterized, as cubic Beziers. */
|
||
function arcSegments(current, cx, cy, rx, ry, startDegrees, sweepDegrees, sign, rotationDegrees = 0) {
|
||
if (sweepDegrees === 0) return;
|
||
const pieces = Math.max(1, Math.ceil(sweepDegrees / 90));
|
||
const step = (sweepDegrees / pieces) * sign * DEGREES_TO_RADIANS;
|
||
const phi = rotationDegrees * DEGREES_TO_RADIANS;
|
||
const cosPhi = Math.cos(phi);
|
||
const sinPhi = Math.sin(phi);
|
||
const place = (angle, radial) => {
|
||
const x = radial ? rx * Math.cos(angle) : -rx * Math.sin(angle);
|
||
const y = radial ? ry * Math.sin(angle) : ry * Math.cos(angle);
|
||
const px = x * cosPhi - y * sinPhi;
|
||
const py = x * sinPhi + y * cosPhi;
|
||
return radial ? [cx + px, cy + py] : [px, py];
|
||
};
|
||
let theta = startDegrees * DEGREES_TO_RADIANS;
|
||
for (let piece = 0; piece < pieces; piece += 1) {
|
||
const next = theta + step;
|
||
const alpha = (4 / 3) * Math.tan((next - theta) / 4);
|
||
const p0 = place(theta, true);
|
||
const p1 = place(next, true);
|
||
const d0 = place(theta, false);
|
||
const d1 = place(next, false);
|
||
cubicTo(current, [p0[0] + alpha * d0[0], p0[1] + alpha * d0[1]], [p1[0] - alpha * d1[0], p1[1] - alpha * d1[1]], p1);
|
||
theta = next;
|
||
}
|
||
}
|
||
|
||
function ellipseSubpath(cx, cy, rx, ry) {
|
||
const path = subpath([cx + rx, cy]);
|
||
arcSegments(path, cx, cy, rx, ry, 0, 360, 1);
|
||
path.closed = true;
|
||
return path;
|
||
}
|
||
|
||
function radiusPair(radius, path) {
|
||
if (typeof radius === 'number') return [radius, radius];
|
||
if (radius && typeof radius === 'object') return [radius.x ?? 0, radius.y ?? 0];
|
||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'radius must be a number or {x, y}.', path);
|
||
}
|
||
|
||
// Points carry three components. A point's own `z` is not a sortable depth
|
||
// (17.6); it is carried so the engine can project each point with its own
|
||
// perspective factor, which is the one effect 17.6 gives it.
|
||
function point(value, path) {
|
||
if (!value || typeof value !== 'object') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Expected a point.', path);
|
||
return [value.x ?? 0, value.y ?? 0, value.z ?? 0];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Splines (17.13)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 17.13: `catmull-rom` is a uniform cardinal spline. For the segment between
|
||
* `p1` and `p2` with neighbours `p0` and `p3`, tangents are
|
||
* `m1 = tension * (p2 - p0)` and `m2 = tension * (p3 - p1)`, and the cubic
|
||
* Hermite form converts to the Bezier control points below.
|
||
*/
|
||
function catmullRomSegments(points, closed, tension) {
|
||
const count = points.length;
|
||
const at = (index) => {
|
||
if (closed) return points[((index % count) + count) % count];
|
||
return points[Math.max(0, Math.min(count - 1, index))];
|
||
};
|
||
const path = subpath(points[0].slice(0, 3));
|
||
const last = closed ? count : count - 1;
|
||
for (let index = 0; index < last; index += 1) {
|
||
const p0 = at(index - 1);
|
||
const p1 = at(index);
|
||
const p2 = at(index + 1);
|
||
const p3 = at(index + 2);
|
||
const m1 = [0, 1, 2].map((axis) => tension * ((p2[axis] ?? 0) - (p0[axis] ?? 0)));
|
||
const m2 = [0, 1, 2].map((axis) => tension * ((p3[axis] ?? 0) - (p1[axis] ?? 0)));
|
||
cubicTo(path,
|
||
[0, 1, 2].map((axis) => (p1[axis] ?? 0) + m1[axis] / 3),
|
||
[0, 1, 2].map((axis) => (p2[axis] ?? 0) - m2[axis] / 3),
|
||
[(p2[0] ?? 0), (p2[1] ?? 0), (p2[2] ?? 0)]);
|
||
}
|
||
path.closed = closed;
|
||
return path;
|
||
}
|
||
|
||
function bezierSpline(points, closed, path) {
|
||
if ((points.length - 1) % 3 !== 0 || points.length < 4) {
|
||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A bezier-mode spline needs 3n + 1 points.', path);
|
||
}
|
||
const result = subpath(points[0].slice(0, 3));
|
||
for (let index = 1; index + 2 < points.length; index += 3) {
|
||
cubicTo(result, points[index].slice(0, 3), points[index + 1].slice(0, 3), points[index + 2].slice(0, 3));
|
||
}
|
||
// 17.13: a closed bezier spline closes with a straight line; the point list
|
||
// carries no control points for a closing segment and inventing two would be
|
||
// authoring geometry the author did not write.
|
||
if (closed) {
|
||
lineTo(result, points[0].slice(0, 3));
|
||
result.closed = true;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function linearSpline(points, closed) {
|
||
const result = subpath(points[0].slice(0, 3));
|
||
for (let index = 1; index < points.length; index += 1) lineTo(result, points[index].slice(0, 3));
|
||
if (closed) result.closed = true;
|
||
return result;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Path commands (17.13)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Endpoint-parameterized elliptical arc, converted to center parameterization. */
|
||
function endpointArc(current, from, command, path) {
|
||
const [rxRaw, ryRaw] = radiusPair(command.radius, path);
|
||
if (rxRaw <= 0 || ryRaw <= 0) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'An arc radius component must be above zero.', path);
|
||
const to = point(command.to, path);
|
||
if (from[0] === to[0] && from[1] === to[1]) return;
|
||
const rx = rxRaw;
|
||
const ry = ryRaw;
|
||
const phi = (command.rotation ?? 0) * DEGREES_TO_RADIANS;
|
||
const cosPhi = Math.cos(phi);
|
||
const sinPhi = Math.sin(phi);
|
||
const dx = (from[0] - to[0]) / 2;
|
||
const dy = (from[1] - to[1]) / 2;
|
||
const x1 = cosPhi * dx + sinPhi * dy;
|
||
const y1 = -sinPhi * dx + cosPhi * dy;
|
||
let rxs = rx * rx;
|
||
let rys = ry * ry;
|
||
const lambda = (x1 * x1) / rxs + (y1 * y1) / rys;
|
||
let scaledRx = rx;
|
||
let scaledRy = ry;
|
||
if (lambda > 1) {
|
||
const factor = Math.sqrt(lambda);
|
||
scaledRx = rx * factor;
|
||
scaledRy = ry * factor;
|
||
rxs = scaledRx * scaledRx;
|
||
rys = scaledRy * scaledRy;
|
||
}
|
||
const numerator = Math.max(0, rxs * rys - rxs * y1 * y1 - rys * x1 * x1);
|
||
const denominator = rxs * y1 * y1 + rys * x1 * x1;
|
||
const coefficient = (command.largeArc === !!command.sweep ? -1 : 1) * Math.sqrt(denominator === 0 ? 0 : numerator / denominator);
|
||
const cx1 = coefficient * ((scaledRx * y1) / scaledRy);
|
||
const cy1 = coefficient * (-(scaledRy * x1) / scaledRx);
|
||
const cx = cosPhi * cx1 - sinPhi * cy1 + (from[0] + to[0]) / 2;
|
||
const cy = sinPhi * cx1 + cosPhi * cy1 + (from[1] + to[1]) / 2;
|
||
const angle = (ux, uy, vx, vy) => {
|
||
const dot = ux * vx + uy * vy;
|
||
const length = Math.hypot(ux, uy) * Math.hypot(vx, vy);
|
||
const sign = ux * vy - uy * vx < 0 ? -1 : 1;
|
||
return sign * Math.acos(Math.max(-1, Math.min(1, dot / (length || 1))));
|
||
};
|
||
const start = angle(1, 0, (x1 - cx1) / scaledRx, (y1 - cy1) / scaledRy);
|
||
let delta = angle((x1 - cx1) / scaledRx, (y1 - cy1) / scaledRy, (-x1 - cx1) / scaledRx, (-y1 - cy1) / scaledRy);
|
||
if (!command.sweep && delta > 0) delta -= 2 * Math.PI;
|
||
if (command.sweep && delta < 0) delta += 2 * Math.PI;
|
||
arcSegments(current, cx, cy, scaledRx, scaledRy, start / DEGREES_TO_RADIANS, Math.abs(delta) / DEGREES_TO_RADIANS, Math.sign(delta) || 1, command.rotation ?? 0);
|
||
}
|
||
|
||
function pathSubpaths(commands, path = '$') {
|
||
if (!Array.isArray(commands) || commands.length === 0) throw new RuntimeFault('ERR_INVALID_PATH', 'A path needs at least one command.', path);
|
||
if (commands[0].op !== 'move') throw new RuntimeFault('ERR_INVALID_PATH', 'A path must begin with a move command.', path);
|
||
const subpaths = [];
|
||
let current = null;
|
||
let cursor = [0, 0];
|
||
let previousWasClose = false;
|
||
for (const command of commands) {
|
||
switch (command.op) {
|
||
case 'move': {
|
||
cursor = point(command.to, path);
|
||
current = subpath(cursor);
|
||
subpaths.push(current);
|
||
previousWasClose = false;
|
||
break;
|
||
}
|
||
case 'line': {
|
||
cursor = point(command.to, path);
|
||
lineTo(current, cursor);
|
||
previousWasClose = false;
|
||
break;
|
||
}
|
||
case 'quadratic': {
|
||
const control = point(command.c, path);
|
||
const to = point(command.to, path);
|
||
// Degree-elevate the quadratic so every stored curve is a cubic.
|
||
cubicTo(current,
|
||
[cursor[0] + (2 / 3) * (control[0] - cursor[0]), cursor[1] + (2 / 3) * (control[1] - cursor[1])],
|
||
[to[0] + (2 / 3) * (control[0] - to[0]), to[1] + (2 / 3) * (control[1] - to[1])],
|
||
to);
|
||
cursor = to;
|
||
previousWasClose = false;
|
||
break;
|
||
}
|
||
case 'cubic': {
|
||
const to = point(command.to, path);
|
||
cubicTo(current, point(command.c1, path), point(command.c2, path), to);
|
||
cursor = to;
|
||
previousWasClose = false;
|
||
break;
|
||
}
|
||
case 'arc': {
|
||
endpointArc(current, cursor, command, path);
|
||
cursor = point(command.to, path);
|
||
previousWasClose = false;
|
||
break;
|
||
}
|
||
case 'close': {
|
||
if (current === null || current.closed || previousWasClose) throw new RuntimeFault('ERR_INVALID_PATH', 'close has no open subpath.', path);
|
||
current.closed = true;
|
||
cursor = current.start.slice();
|
||
previousWasClose = true;
|
||
break;
|
||
}
|
||
default:
|
||
throw new RuntimeFault('ERR_INVALID_PATH', `Unknown path command '${command.op}'.`, path);
|
||
}
|
||
}
|
||
return subpaths;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Primitives
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Build the local-space subpaths of one visual object. `point`, `text`,
|
||
* `group`, and `component` have no path geometry and return an empty list;
|
||
* the engine draws them by their own rules.
|
||
*/
|
||
function primitiveSubpaths(object, path = '$') {
|
||
switch (object.type) {
|
||
case 'point':
|
||
case 'text':
|
||
case 'group':
|
||
case 'component':
|
||
return [];
|
||
case 'line': {
|
||
const result = subpath([0, 0]);
|
||
lineTo(result, point(object.to, path));
|
||
return [result];
|
||
}
|
||
case 'bezier': {
|
||
const result = subpath([0, 0]);
|
||
cubicTo(result, point(object.c1, path), point(object.c2, path), point(object.to, path));
|
||
return [result];
|
||
}
|
||
case 'polyline':
|
||
case 'polygon': {
|
||
const points = (object.points ?? []).map((entry) => point(entry, path));
|
||
if (points.length < (object.type === 'polygon' ? 3 : 2)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `${object.type} needs more points.`, path);
|
||
const result = linearSpline(points, object.type === 'polygon');
|
||
return [result];
|
||
}
|
||
case 'rectangle': {
|
||
const width = object.size?.width ?? 0;
|
||
const height = object.size?.height ?? 0;
|
||
const result = subpath([0, 0]);
|
||
lineTo(result, [width, 0]);
|
||
lineTo(result, [width, height]);
|
||
lineTo(result, [0, height]);
|
||
result.closed = true;
|
||
return [result];
|
||
}
|
||
case 'rounded-rectangle': {
|
||
const width = object.size?.width ?? 0;
|
||
const height = object.size?.height ?? 0;
|
||
const limit = Math.min(width, height) / 2;
|
||
const raw = object.radius;
|
||
const corners = typeof raw === 'number'
|
||
? { topLeft: raw, topRight: raw, bottomRight: raw, bottomLeft: raw }
|
||
: { topLeft: raw?.topLeft ?? 0, topRight: raw?.topRight ?? 0, bottomRight: raw?.bottomRight ?? 0, bottomLeft: raw?.bottomLeft ?? 0 };
|
||
const clampRadius = (value) => Math.max(0, Math.min(limit, value));
|
||
const tl = clampRadius(corners.topLeft);
|
||
const tr = clampRadius(corners.topRight);
|
||
const br = clampRadius(corners.bottomRight);
|
||
const bl = clampRadius(corners.bottomLeft);
|
||
const result = subpath([tl, 0]);
|
||
lineTo(result, [width - tr, 0]);
|
||
if (tr > 0) cubicTo(result, [width - tr + tr * KAPPA, 0], [width, tr - tr * KAPPA], [width, tr]);
|
||
lineTo(result, [width, height - br]);
|
||
if (br > 0) cubicTo(result, [width, height - br + br * KAPPA], [width - br + br * KAPPA, height], [width - br, height]);
|
||
lineTo(result, [bl, height]);
|
||
if (bl > 0) cubicTo(result, [bl - bl * KAPPA, height], [0, height - bl + bl * KAPPA], [0, height - bl]);
|
||
lineTo(result, [0, tl]);
|
||
if (tl > 0) cubicTo(result, [0, tl - tl * KAPPA], [tl - tl * KAPPA, 0], [tl, 0]);
|
||
result.closed = true;
|
||
return [result];
|
||
}
|
||
case 'ellipse': {
|
||
const [rx, ry] = radiusPair(object.radius, path);
|
||
return [ellipseSubpath(0, 0, rx, ry)];
|
||
}
|
||
case 'arc': {
|
||
const [rx, ry] = radiusPair(object.radius, path);
|
||
const { sweep, sign } = directedSweep(object.startAngle ?? 0, object.endAngle ?? 0, object.direction ?? 'clockwise', path);
|
||
const startDegrees = object.startAngle ?? 0;
|
||
const result = subpath([rx * Math.cos(startDegrees * DEGREES_TO_RADIANS), ry * Math.sin(startDegrees * DEGREES_TO_RADIANS)]);
|
||
arcSegments(result, 0, 0, rx, ry, startDegrees, sweep, sign);
|
||
return [result];
|
||
}
|
||
case 'ring': {
|
||
const [rx, ry] = radiusPair(object.radius, path);
|
||
const inner = object.innerRadius ?? 0;
|
||
if (inner >= Math.min(rx, ry)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'innerRadius must be less than radius.', path);
|
||
const startDegrees = object.startAngle ?? 0;
|
||
const endDegrees = object.endAngle ?? 360;
|
||
const { sweep, sign } = directedSweep(startDegrees, endDegrees, object.direction ?? 'clockwise', path);
|
||
const scaleInnerX = inner;
|
||
const scaleInnerY = inner * (ry / (rx || 1));
|
||
if (sweep === 360) {
|
||
// A full annulus is two closed rings; the even-odd or nonzero rule of
|
||
// the fill leaves the hole, so the inner ring runs the other way.
|
||
const outer = ellipseSubpath(0, 0, rx, ry);
|
||
const hole = subpath([scaleInnerX, 0]);
|
||
arcSegments(hole, 0, 0, scaleInnerX, scaleInnerY, 0, 360, -1);
|
||
hole.closed = true;
|
||
return inner > 0 ? [outer, hole] : [outer];
|
||
}
|
||
// C16: §17.9: sweep === 0 draws nothing and raises no diagnostic.
|
||
if (sweep === 0) return [];
|
||
const endAngle = startDegrees + sweep * sign;
|
||
const result = subpath([rx * Math.cos(startDegrees * DEGREES_TO_RADIANS), ry * Math.sin(startDegrees * DEGREES_TO_RADIANS)]);
|
||
arcSegments(result, 0, 0, rx, ry, startDegrees, sweep, sign);
|
||
lineTo(result, [scaleInnerX * Math.cos(endAngle * DEGREES_TO_RADIANS), scaleInnerY * Math.sin(endAngle * DEGREES_TO_RADIANS)]);
|
||
arcSegments(result, 0, 0, scaleInnerX, scaleInnerY, endAngle, sweep, -sign);
|
||
result.closed = true;
|
||
return [result];
|
||
}
|
||
case 'path':
|
||
return pathSubpaths(object.commands, path);
|
||
case 'spline': {
|
||
const points = (object.points ?? []).map((entry) => point(entry, path));
|
||
if (points.length < 2) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A spline needs at least 2 points.', path);
|
||
const mode = object.mode ?? 'catmull-rom';
|
||
if (mode === 'linear') return [linearSpline(points, object.closed === true)];
|
||
if (mode === 'bezier') return [bezierSpline(points, object.closed === true, path)];
|
||
return [catmullRomSegments(points, object.closed === true, object.tension ?? 0.5)];
|
||
}
|
||
default:
|
||
throw new RuntimeFault('ERR_INVALID_PRIMITIVE_TYPE', `Unknown visual primitive '${object.type}'.`, path);
|
||
}
|
||
}
|
||
|
||
/** Every point a subpath list touches, for bounding-box work (17.12 fallback axis). */
|
||
function subpathPoints(subpaths) {
|
||
const points = [];
|
||
for (const path of subpaths) {
|
||
points.push(path.start);
|
||
for (const segment of path.segments) {
|
||
if (segment.type === 'cubic') points.push(segment.c1, segment.c2);
|
||
points.push(segment.to);
|
||
}
|
||
}
|
||
return points;
|
||
}
|
||
|
||
function boundingBox(subpaths) {
|
||
const points = subpathPoints(subpaths);
|
||
if (points.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
|
||
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
|
||
for (const [x, y] of points) {
|
||
if (x < minX) minX = x;
|
||
if (y < minY) minY = y;
|
||
if (x > maxX) maxX = x;
|
||
if (y > maxY) maxY = y;
|
||
}
|
||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||
}
|
||
|
||
/* src/runtime/visual-noise.js */
|
||
// The normative coherent noise of 18.7.
|
||
//
|
||
// Reproducibility (9.3) promises identical procedural decisions, not identical
|
||
// pixels — but a field that drove motion differently on two conforming
|
||
// renderers would make a fixture untestable, so the function is fixed as an
|
||
// algorithm rather than described. Every constant, the draw count of the
|
||
// shuffle, the octave normalization, and the three scalar-to-vector modes are
|
||
// exactly as 18.7 states them.
|
||
|
||
/** The twelve edge-midpoint vectors of a cube, in this order, unnormalized. */
|
||
const GRADIENTS = Object.freeze([
|
||
[1, 1, 0], [-1, 1, 0], [1, -1, 0], [-1, -1, 0],
|
||
[1, 0, 1], [-1, 0, 1], [1, 0, -1], [-1, 0, -1],
|
||
[0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1]
|
||
].map(Object.freeze));
|
||
|
||
/** The central-difference step for the derivative modes, in noise space. */
|
||
const DERIVATIVE_STEP = 1e-3;
|
||
|
||
function fade(a) { return a * a * a * (a * (a * 6 - 15) + 10); }
|
||
function mix(s, a, b) { return a + s * (b - a); }
|
||
|
||
/**
|
||
* 18.7: a 256-entry permutation table shuffled by Fisher-Yates from the owning
|
||
* stream, consuming exactly 255 samples — one per swap, not one per entry — and
|
||
* duplicated into a 512-entry tail so the lattice hash needs no wrap arithmetic.
|
||
*/
|
||
function permutationTable(stream) {
|
||
const p = new Uint8Array(256);
|
||
for (let index = 0; index < 256; index += 1) p[index] = index;
|
||
for (let index = 255; index >= 1; index -= 1) {
|
||
const swap = Math.floor(stream.nextFloat() * (index + 1));
|
||
const held = p[index];
|
||
p[index] = p[swap];
|
||
p[swap] = held;
|
||
}
|
||
const table = new Uint16Array(512);
|
||
for (let index = 0; index < 256; index += 1) {
|
||
table[index] = p[index];
|
||
table[index + 256] = p[index];
|
||
}
|
||
return table;
|
||
}
|
||
|
||
/** The number of samples `permutationTable` draws, stated so a trace can assert it. */
|
||
const PERMUTATION_DRAWS = 255;
|
||
|
||
function gradientDot(hash, dx, dy, dz) {
|
||
const g = GRADIENTS[hash % 12];
|
||
return g[0] * dx + g[1] * dy + g[2] * dz;
|
||
}
|
||
|
||
/** One octave of three-dimensional gradient noise on the integer lattice. */
|
||
function singleOctave(table, x, y, z) {
|
||
const fx = x - Math.floor(x);
|
||
const fy = y - Math.floor(y);
|
||
const fz = z - Math.floor(z);
|
||
const X = Math.floor(x) & 255;
|
||
const Y = Math.floor(y) & 255;
|
||
const Z = Math.floor(z) & 255;
|
||
const u = fade(fx);
|
||
const v = fade(fy);
|
||
const w = fade(fz);
|
||
const A = table[X] + Y;
|
||
const AA = table[A & 511] + Z;
|
||
const AB = table[(A + 1) & 511] + Z;
|
||
const B = table[X + 1] + Y;
|
||
const BA = table[B & 511] + Z;
|
||
const BB = table[(B + 1) & 511] + Z;
|
||
const value = mix(w,
|
||
mix(v,
|
||
mix(u, gradientDot(table[AA & 511], fx, fy, fz), gradientDot(table[BA & 511], fx - 1, fy, fz)),
|
||
mix(u, gradientDot(table[AB & 511], fx, fy - 1, fz), gradientDot(table[BB & 511], fx - 1, fy - 1, fz))),
|
||
mix(v,
|
||
mix(u, gradientDot(table[(AA + 1) & 511], fx, fy, fz - 1), gradientDot(table[(BA + 1) & 511], fx - 1, fy, fz - 1)),
|
||
mix(u, gradientDot(table[(AB + 1) & 511], fx, fy - 1, fz - 1), gradientDot(table[(BB + 1) & 511], fx - 1, fy - 1, fz - 1))));
|
||
// A guard, never a shaping step: with these gradients the magnitude does not
|
||
// exceed 1.
|
||
return Math.max(-1, Math.min(1, value));
|
||
}
|
||
|
||
/**
|
||
* 18.7: octaves sum at doubling frequency and `persistence` amplitude decay,
|
||
* normalized by the accumulated amplitude so `persistence: 0` stays defined.
|
||
*/
|
||
function octaveNoise(table, x, y, z, octaves = 1, persistence = 0.5) {
|
||
let total = 0;
|
||
let amplitude = 1;
|
||
let frequency = 1;
|
||
let norm = 0;
|
||
for (let octave = 0; octave < octaves; octave += 1) {
|
||
total += amplitude * singleOctave(table, x * frequency, y * frequency, z * frequency);
|
||
norm += amplitude;
|
||
amplitude *= persistence;
|
||
frequency *= 2;
|
||
}
|
||
return norm === 0 ? 0 : total / norm;
|
||
}
|
||
|
||
/**
|
||
* 18.7: `curl` is the explicit perpendicular of the gradient of the scalar
|
||
* potential, which is divergence-free by construction; `gradient` is that
|
||
* gradient; `value` is the scalar along `direction` degrees.
|
||
*/
|
||
function noiseVector(table, x, y, z, { mode = 'curl', octaves = 1, persistence = 0.5, amplitude = 1, direction = 0 } = {}) {
|
||
if (mode === 'value') {
|
||
const scalar = octaveNoise(table, x, y, z, octaves, persistence);
|
||
const radians = direction * (Math.PI / 180);
|
||
return [amplitude * scalar * Math.cos(radians), amplitude * scalar * Math.sin(radians)];
|
||
}
|
||
const h = DERIVATIVE_STEP;
|
||
const dx = (octaveNoise(table, x + h, y, z, octaves, persistence) - octaveNoise(table, x - h, y, z, octaves, persistence)) / (2 * h);
|
||
const dy = (octaveNoise(table, x, y + h, z, octaves, persistence) - octaveNoise(table, x, y - h, z, octaves, persistence)) / (2 * h);
|
||
if (mode === 'gradient') return [amplitude * dx, amplitude * dy];
|
||
return [amplitude * dy, -amplitude * dx];
|
||
}
|
||
|
||
/**
|
||
* The exhibit-wide table the four noise-using behaviors of 18.6 share. Deriving
|
||
* 255 samples for every particle would make instantiation quadratic in nothing
|
||
* an author asked for; per-object independence comes from the offset pair each
|
||
* behavior draws instead.
|
||
*/
|
||
function behaviorNoiseTable(rng) {
|
||
return permutationTable(rng.stream('visual', 'behavior-noise'));
|
||
}
|
||
|
||
/** 18.7: each noise-using behavior draws one offset pair, uniform in [0, 1024). */
|
||
function noiseOffsets(stream) {
|
||
return [stream.nextFloat() * 1024, stream.nextFloat() * 1024];
|
||
}
|
||
|
||
/* src/runtime/visual-behaviors.js */
|
||
// Visual Behavior Set 0.1 — the seventeen behaviors of 18.6.
|
||
//
|
||
// A behavior's *configuration* resolves once at its owning object's
|
||
// instantiation boundary; its *effect* varies with logical time. Behaviors
|
||
// advance on the fixed tick of 9.1, after the particle integration of 18.2 and
|
||
// in array order, and compose by accumulation on position, z, and rotation, by
|
||
// multiplication on scale and opacity, and last-writer-wins elsewhere.
|
||
//
|
||
// The accumulating-versus-fresh division of 18.6 is the load-bearing rule here:
|
||
// an orbit recomputed from `t` traces its ellipse, while an orbit added as a
|
||
// fresh displacement each tick integrates into an outward spiral.
|
||
|
||
|
||
|
||
|
||
const BEHAVIOR_TYPES = Object.freeze([
|
||
'drift', 'rotate', 'oscillate', 'orbit', 'wander', 'follow-path', 'point-wander',
|
||
'pulse', 'twinkle', 'noise-displace', 'face-motion', 'wrap', 'bounce',
|
||
'attract', 'repel', 'field-follow', 'morph'
|
||
]);
|
||
|
||
/** 18.6: the channel set `oscillate`, `pulse`, and `twinkle` may name. */
|
||
const PROPERTY_CHANNELS = Object.freeze([
|
||
'position.x', 'position.y', 'z',
|
||
'transform.rotation', 'transform.scale.x', 'transform.scale.y',
|
||
'style.opacity', 'style.strokeWidth', 'style.pointSize',
|
||
'size.width', 'size.height', 'radius'
|
||
]);
|
||
|
||
/**
|
||
* 18.6/19.1: each behavior's write set as *scalar* channels, so a behavior and
|
||
* an automation track on one channel can be detected as ERR_AUTOMATION_CONFLICT.
|
||
* `velocity.*` and `points[*].*` are outside the automatable registry, so the
|
||
* behaviors that write only those can never collide with a track.
|
||
*/
|
||
function behaviorChannels(behavior) {
|
||
switch (behavior?.type) {
|
||
case 'drift': case 'noise-displace': return ['position.x', 'position.y', 'z'];
|
||
case 'field-follow': return (behavior.mode ?? 'force') === 'direct' ? ['position.x', 'position.y', 'z'] : ['velocity.x', 'velocity.y', 'velocity.z'];
|
||
case 'orbit': case 'wander': case 'wrap': return ['position.x', 'position.y'];
|
||
case 'rotate': case 'face-motion': return ['transform.rotation'];
|
||
case 'follow-path': return behavior.align === true ? ['position.x', 'position.y', 'transform.rotation'] : ['position.x', 'position.y'];
|
||
case 'oscillate': case 'pulse': return [behavior.property];
|
||
case 'twinkle': return [behavior.property ?? 'style.opacity'];
|
||
case 'bounce': case 'attract': case 'repel': return ['velocity.x', 'velocity.y', 'velocity.z'];
|
||
case 'point-wander': case 'morph': return ['points[*].x', 'points[*].y', 'points[*].z'];
|
||
default: return [];
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Waveforms and curves (18.6, 18.2)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const frac = (value) => value - Math.floor(value);
|
||
|
||
function waveform(name, phi) {
|
||
switch (name ?? 'sine') {
|
||
case 'sine': return Math.sin(2 * Math.PI * phi);
|
||
case 'triangle': return 1 - 4 * Math.abs(frac(phi + 0.25) - 0.5);
|
||
case 'square': return phi < 0.5 ? 1 : -1;
|
||
case 'sawtooth': return 2 * frac(phi + 0.5) - 1;
|
||
default: throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown waveform '${name}'.`);
|
||
}
|
||
}
|
||
|
||
/** The four-curve set 18.2 fixes for ramps and 18.6 reuses for `pulse`. */
|
||
function curveValue(name, t) {
|
||
const u = Math.max(0, Math.min(1, t));
|
||
switch (name ?? 'linear') {
|
||
case 'step': return u < 1 ? 0 : 1;
|
||
case 'linear': return u;
|
||
case 'exponential': return u * u;
|
||
case 'smooth': return u * u * (3 - 2 * u);
|
||
default: throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown curve '${name}'.`);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Instances
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Create one behavior instance. `wander`, `twinkle`, `point-wander`, and
|
||
* `noise-displace` each draw exactly one offset pair here and never sample
|
||
* again — the whole of their stream consumption (18.6).
|
||
*/
|
||
/** 18.6: which behaviors accumulate their contribution and which recompute it. */
|
||
const ACCUMULATING = new Set(['drift', 'rotate', 'wander', 'bounce', 'attract', 'repel', 'wrap']);
|
||
|
||
function isAccumulating(behavior) {
|
||
if (behavior?.type === 'field-follow') return (behavior.mode ?? 'force') !== 'direct';
|
||
return ACCUMULATING.has(behavior?.type);
|
||
}
|
||
|
||
function createBehavior(specification, { stream = null, noiseTable = null, path = '$' } = {}) {
|
||
if (!BEHAVIOR_TYPES.includes(specification?.type)) {
|
||
throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TYPE', `'${specification?.type}' is outside Visual Behavior Set 0.1.`, path);
|
||
}
|
||
const instance = { spec: specification, type: specification.type, path, noiseTable, state: {}, accumulating: isAccumulating(specification) };
|
||
if (['wander', 'twinkle', 'point-wander', 'noise-displace'].includes(specification.type)) {
|
||
if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A noise behavior requires an owning random stream.', path);
|
||
instance.offsets = noiseOffsets(stream);
|
||
}
|
||
if (specification.type === 'drift') instance.state.velocity = { ...vector(specification.velocity) };
|
||
if (specification.type === 'wander') instance.state.velocity = { x: 0, y: 0 };
|
||
return instance;
|
||
}
|
||
|
||
function vector(value, fallback = 0) {
|
||
return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback };
|
||
}
|
||
|
||
function bounds(specification, scene) {
|
||
const declared = specification.bounds;
|
||
if (declared === undefined || declared === 'scene') return scene;
|
||
return { x: declared.x ?? 0, y: declared.y ?? 0, width: declared.width ?? 0, height: declared.height ?? 0 };
|
||
}
|
||
|
||
function writeChannel(item, channel, value) {
|
||
switch (channel) {
|
||
case 'position.x': item.offset.x += value; break;
|
||
case 'position.y': item.offset.y += value; break;
|
||
case 'z': item.offset.z += value; break;
|
||
case 'transform.rotation': item.rotationOffset += value; break;
|
||
case 'transform.scale.x': item.scale.x *= value; break;
|
||
case 'transform.scale.y': item.scale.y *= value; break;
|
||
case 'style.opacity': item.opacityMultiplier *= value; break;
|
||
default: item.channels[channel] = value; break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* `oscillate`, `pulse`, and `twinkle` write one named channel. Additive
|
||
* channels accumulate, multiplicative ones multiply, and the rest are
|
||
* last-writer-wins, exactly as 18.6 states.
|
||
*/
|
||
function writeNamedChannel(item, channel, absolute, additive) {
|
||
if (channel === 'transform.scale.x' || channel === 'transform.scale.y' || channel === 'style.opacity') {
|
||
writeChannel(item, channel, absolute);
|
||
return;
|
||
}
|
||
if (['position.x', 'position.y', 'z', 'transform.rotation'].includes(channel)) {
|
||
writeChannel(item, channel, additive);
|
||
return;
|
||
}
|
||
item.channels[channel] = absolute;
|
||
}
|
||
|
||
/**
|
||
* Advance one behavior by `dt` logical seconds at logical time `t`, measured
|
||
* from the owning object's instantiation boundary.
|
||
*/
|
||
function advanceBehavior(instance, item, { dt, time, env = {} }) {
|
||
const spec = instance.spec;
|
||
switch (instance.type) {
|
||
case 'drift': {
|
||
// Accumulating: the contribution grows by velocity * dt, with the
|
||
// velocity decaying first.
|
||
const damping = spec.damping ?? 0;
|
||
const velocity = instance.state.velocity;
|
||
const decay = (1 - damping) ** dt;
|
||
velocity.x *= decay; velocity.y *= decay; velocity.z *= decay;
|
||
item.offset.x += velocity.x * dt;
|
||
item.offset.y += velocity.y * dt;
|
||
item.offset.z += velocity.z * dt;
|
||
break;
|
||
}
|
||
case 'rotate': {
|
||
item.rotationOffset += (spec.speed ?? 0) * dt;
|
||
break;
|
||
}
|
||
case 'oscillate': {
|
||
const phi = frac((spec.frequency ?? 1) * time + (spec.phase ?? 0) / 360);
|
||
const value = (spec.center ?? 0) + (spec.amplitude ?? 0) * waveform(spec.waveform, phi);
|
||
writeNamedChannel(item, spec.property, value, value);
|
||
break;
|
||
}
|
||
case 'orbit': {
|
||
// Fresh every tick: an absolute placement expressed as an offset from the
|
||
// resolved base position.
|
||
const theta = ((spec.speed ?? 0) * time + (spec.phase ?? 0)) * DEGREES_TO_RADIANS;
|
||
const [rx, ry] = typeof spec.radius === 'number' ? [spec.radius, spec.radius] : [spec.radius?.x ?? 0, spec.radius?.y ?? 0];
|
||
const center = vector(spec.center);
|
||
item.offset.x += center.x + rx * Math.cos(theta) - item.base.x;
|
||
item.offset.y += center.y + ry * Math.sin(theta) - item.base.y;
|
||
break;
|
||
}
|
||
case 'wander': {
|
||
const rate = spec.rate ?? 1;
|
||
const scalar = octaveNoise(instance.noiseTable, instance.offsets[0], instance.offsets[1], time * rate, 1, 0.5);
|
||
const theta = 360 * scalar * DEGREES_TO_RADIANS;
|
||
const strength = spec.strength ?? 0;
|
||
const velocity = instance.state.velocity;
|
||
velocity.x += strength * Math.cos(theta) * dt;
|
||
velocity.y += strength * Math.sin(theta) * dt;
|
||
const maxSpeed = spec.maxSpeed;
|
||
if (maxSpeed !== undefined) {
|
||
const speed = Math.hypot(velocity.x, velocity.y);
|
||
if (speed > maxSpeed && speed > 0) {
|
||
velocity.x *= maxSpeed / speed;
|
||
velocity.y *= maxSpeed / speed;
|
||
}
|
||
}
|
||
item.offset.x += velocity.x * dt;
|
||
item.offset.y += velocity.y * dt;
|
||
break;
|
||
}
|
||
case 'follow-path': {
|
||
const flat = env.resolvePath?.(spec.path, instance.path);
|
||
if (!flat || flat.total === 0) break;
|
||
const duration = spec.duration !== undefined ? env.duration(spec.duration) / 1000 : flat.total / Math.max(1e-9, spec.speed ?? 1);
|
||
let progress = (spec.offset ?? 0) + (duration === 0 ? 0 : time / duration);
|
||
const loop = spec.loop ?? 'once';
|
||
if (loop === 'repeat') progress = frac(progress);
|
||
else if (loop === 'ping-pong') { const cycle = frac(progress / 2) * 2; progress = cycle <= 1 ? cycle : 2 - cycle; }
|
||
else progress = Math.max(0, Math.min(1, progress));
|
||
const at = env.alongPath(flat, progress * flat.total);
|
||
item.offset.x += at.x - item.base.x;
|
||
item.offset.y += at.y - item.base.y;
|
||
if (spec.align === true) item.rotationOffset += at.tangent;
|
||
break;
|
||
}
|
||
case 'point-wander': {
|
||
if (!Array.isArray(item.points)) throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TARGET', 'point-wander requires an addressable point list.', instance.path);
|
||
const rate = spec.rate ?? 1;
|
||
const amplitude = vector(spec.amplitude);
|
||
const indices = Array.isArray(spec.indices) ? spec.indices : null;
|
||
for (let index = 0; index < item.points.length; index += 1) {
|
||
if (indices && !indices.includes(index)) continue;
|
||
const [o1, o2] = instance.offsets;
|
||
item.points[index].x += amplitude.x * octaveNoise(instance.noiseTable, o1 + 64 * index, o2, time * rate, 1, 0.5);
|
||
item.points[index].y += amplitude.y * octaveNoise(instance.noiseTable, o1, o2 + 64 * index, time * rate, 1, 0.5);
|
||
if (amplitude.z !== 0) item.points[index].z += amplitude.z * octaveNoise(instance.noiseTable, o1 + 64 * index, o2 + 64 * index, time * rate, 1, 0.5);
|
||
}
|
||
break;
|
||
}
|
||
case 'pulse': {
|
||
const duty = spec.duty ?? 0.5;
|
||
const phi = frac((spec.frequency ?? 1) * time);
|
||
let envelope = 0;
|
||
if (phi < duty / 2) envelope = curveValue(spec.curve ?? 'smooth', (2 * phi) / duty);
|
||
else if (phi < duty) envelope = curveValue(spec.curve ?? 'smooth', 2 - (2 * phi) / duty);
|
||
const value = (spec.amplitude ?? 0) * envelope;
|
||
writeNamedChannel(item, spec.property, 1 + value, value);
|
||
break;
|
||
}
|
||
case 'twinkle': {
|
||
const rate = spec.rate ?? 1;
|
||
const scalar = octaveNoise(instance.noiseTable, instance.offsets[0], instance.offsets[1], time * rate, 1, 0.5);
|
||
const min = spec.min ?? 0;
|
||
const max = spec.max ?? 1;
|
||
const value = min + (max - min) * ((scalar + 1) / 2);
|
||
writeNamedChannel(item, spec.property ?? 'style.opacity', value, value);
|
||
break;
|
||
}
|
||
case 'noise-displace': {
|
||
const scale = spec.scale ?? 100;
|
||
const speed = spec.speed ?? 0;
|
||
const octaves = spec.octaves ?? 1;
|
||
const persistence = spec.persistence ?? 0.5;
|
||
const amplitude = vector(spec.amplitude);
|
||
const px = (item.base.x + item.offset.x) / scale;
|
||
const py = (item.base.y + item.offset.y) / scale;
|
||
const table = instance.noiseTable;
|
||
item.offset.x += amplitude.x * octaveNoise(table, px, py, time * speed, octaves, persistence);
|
||
item.offset.y += amplitude.y * octaveNoise(table, px + 137, py + 71, time * speed, octaves, persistence);
|
||
if (amplitude.z !== 0) item.offset.z += amplitude.z * octaveNoise(table, px + 271, py + 193, time * speed, octaves, persistence);
|
||
break;
|
||
}
|
||
case 'face-motion': {
|
||
const speed = Math.hypot(item.vx, item.vy);
|
||
if (speed < 1e-6) {
|
||
// 18.6: An object with zero velocity holds its previous rotation.
|
||
if (instance.state.rotation !== undefined) {
|
||
item.rotationOffset += instance.state.rotation - item.base.rotation;
|
||
}
|
||
break;
|
||
}
|
||
const target = Math.atan2(item.vy, item.vx) / DEGREES_TO_RADIANS + (spec.offset ?? 0);
|
||
const smoothing = spec.smoothing ?? 0;
|
||
// Lazy init: use the current drawn rotation, not the target, on first tick.
|
||
const current = instance.state.rotation ?? item.base.rotation;
|
||
let delta = ((target - current + 540) % 360) - 180;
|
||
const step = 1 - smoothing ** dt;
|
||
instance.state.rotation = current + delta * step;
|
||
item.rotationOffset += instance.state.rotation - item.base.rotation;
|
||
break;
|
||
}
|
||
case 'wrap': {
|
||
const box = bounds(spec, env.scene);
|
||
const margin = spec.margin ?? 0;
|
||
const x = item.base.x + item.offset.x;
|
||
const y = item.base.y + item.offset.y;
|
||
if (x < box.x - margin) item.offset.x += box.width + 2 * margin;
|
||
else if (x > box.x + box.width + margin) item.offset.x -= box.width + 2 * margin;
|
||
if (y < box.y - margin) item.offset.y += box.height + 2 * margin;
|
||
else if (y > box.y + box.height + margin) item.offset.y -= box.height + 2 * margin;
|
||
break;
|
||
}
|
||
case 'bounce': {
|
||
const box = bounds(spec, env.scene);
|
||
const restitution = spec.restitution ?? 1;
|
||
const axes = spec.axes ?? 'both';
|
||
const x = item.base.x + item.offset.x;
|
||
const y = item.base.y + item.offset.y;
|
||
if (axes !== 'y' && ((x <= box.x && item.vx < 0) || (x >= box.x + box.width && item.vx > 0))) item.vx = -item.vx * restitution;
|
||
if (axes !== 'x' && ((y <= box.y && item.vy < 0) || (y >= box.y + box.height && item.vy > 0))) item.vy = -item.vy * restitution;
|
||
break;
|
||
}
|
||
case 'attract':
|
||
case 'repel': {
|
||
const target = env.resolveTarget?.(spec.target, instance.path) ?? vector(spec.target);
|
||
const dx = target.x - (item.base.x + item.offset.x);
|
||
const dy = target.y - (item.base.y + item.offset.y);
|
||
const distance = Math.hypot(dx, dy);
|
||
const maxDistance = spec.maxDistance;
|
||
if (maxDistance !== undefined && distance > maxDistance) break;
|
||
const clamped = Math.max(spec.minDistance ?? 1, distance);
|
||
const magnitude = (spec.strength ?? 0) * falloffFactor(spec.falloff ?? 'linear', clamped, maxDistance);
|
||
const sign = instance.type === 'attract' ? 1 : -1;
|
||
if (distance > 0) {
|
||
item.vx += sign * magnitude * (dx / distance) * dt;
|
||
item.vy += sign * magnitude * (dy / distance) * dt;
|
||
}
|
||
break;
|
||
}
|
||
case 'field-follow': {
|
||
const field = env.sampleField?.(spec.field, item.base.x + item.offset.x, item.base.y + item.offset.y, time);
|
||
if (!field) break;
|
||
const strength = spec.strength ?? 1;
|
||
const mode = spec.mode ?? 'force';
|
||
if (mode === 'velocity') { item.vx = field[0] * strength; item.vy = field[1] * strength; }
|
||
// 18.6: `direct` is the mode that does not integrate, so no dt factor.
|
||
else if (mode === 'direct') { item.offset.x += field[0] * strength; item.offset.y += field[1] * strength; }
|
||
else { item.vx += field[0] * strength * dt; item.vy += field[1] * strength * dt; }
|
||
break;
|
||
}
|
||
case 'morph': {
|
||
const target = env.resolveMorphTarget?.(spec.to, instance.path);
|
||
if (!target || !Array.isArray(item.points)) break;
|
||
const duration = env.duration(spec.duration) / 1000;
|
||
let progress = duration === 0 ? 1 : time / duration;
|
||
const loop = spec.loop ?? 'once';
|
||
if (loop === 'repeat') progress = frac(progress);
|
||
else if (loop === 'ping-pong') { const cycle = frac(progress / 2) * 2; progress = cycle <= 1 ? cycle : 2 - cycle; }
|
||
else progress = Math.max(0, Math.min(1, progress));
|
||
const amount = curveValue(spec.curve ?? 'linear', progress);
|
||
for (let index = 0; index < item.points.length && index < target.length; index += 1) {
|
||
item.points[index].x += (target[index].x - item.points[index].x) * amount;
|
||
item.points[index].y += (target[index].y - item.points[index].y) * amount;
|
||
item.points[index].z += ((target[index].z ?? 0) - (item.points[index].z ?? 0)) * amount;
|
||
}
|
||
break;
|
||
}
|
||
default:
|
||
throw new RuntimeFault('ERR_INVALID_BEHAVIOR_TYPE', `'${instance.type}' is outside Visual Behavior Set 0.1.`, instance.path);
|
||
}
|
||
}
|
||
|
||
/** 18.6: the shared falloff vocabulary, also used by the fields of 18.7. */
|
||
function falloffFactor(falloff, distance, maxDistance) {
|
||
switch (falloff ?? 'linear') {
|
||
case 'none': return 1;
|
||
case 'inverse': return 1 / distance;
|
||
case 'inverse-square': return 1 / (distance * distance);
|
||
case 'linear':
|
||
default: {
|
||
if (maxDistance === undefined) return 1 / distance;
|
||
return Math.max(0, 1 - distance / maxDistance);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* src/runtime/visual-distributions.js */
|
||
// The nine placement distributions of 18.3.
|
||
//
|
||
// A distribution answers one question: where does an item start? Its sample
|
||
// consumption is normative — an explicit list of draws per distribution and
|
||
// mode, in a fixed order — so a fixture's placements are byte-identical across
|
||
// renderers and a trace can assert the final stream position.
|
||
|
||
|
||
|
||
|
||
/** 18.3: the flattening tolerance for arc-length placement, in scene units. */
|
||
const PATH_FLATNESS = 0.1;
|
||
|
||
function fraction(index, count) {
|
||
// The n == 1 convention `repeat.fraction` uses (18.5).
|
||
return count <= 1 ? 0 : index / (count - 1);
|
||
}
|
||
|
||
function pointOf(value, fallback = { x: 0, y: 0, z: 0 }) {
|
||
return { x: value?.x ?? fallback.x, y: value?.y ?? fallback.y, z: value?.z ?? fallback.z };
|
||
}
|
||
|
||
function distributionRadii(radius) {
|
||
return typeof radius === 'number' ? [radius, radius] : [radius?.x ?? 0, radius?.y ?? 0];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Arc-length flattening for the `path` distribution
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function flattenCubic(from, c1, c2, to, out, depth = 0) {
|
||
// Recursive subdivision to within PATH_FLATNESS, which fixes the sampled
|
||
// length so two renderers place an item at the same distance.
|
||
const dx = to[0] - from[0];
|
||
const dy = to[1] - from[1];
|
||
const d1 = Math.abs((c1[0] - to[0]) * dy - (c1[1] - to[1]) * dx);
|
||
const d2 = Math.abs((c2[0] - to[0]) * dy - (c2[1] - to[1]) * dx);
|
||
const flat = (d1 + d2) * (d1 + d2) < PATH_FLATNESS * (dx * dx + dy * dy);
|
||
if (flat || depth >= 16) { out.push(to); return; }
|
||
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||
const p01 = mid(from, c1);
|
||
const p12 = mid(c1, c2);
|
||
const p23 = mid(c2, to);
|
||
const p012 = mid(p01, p12);
|
||
const p123 = mid(p12, p23);
|
||
const middle = mid(p012, p123);
|
||
flattenCubic(from, p01, p012, middle, out, depth + 1);
|
||
flattenCubic(middle, p123, p23, to, out, depth + 1);
|
||
}
|
||
|
||
function flattenPath(commands, path = '$') {
|
||
const subpaths = pathSubpaths(commands, path);
|
||
const points = [];
|
||
for (const subpath of subpaths) {
|
||
let cursor = subpath.start;
|
||
if (points.length === 0) points.push(cursor);
|
||
for (const segment of subpath.segments) {
|
||
if (segment.type === 'line') points.push(segment.to);
|
||
else flattenCubic(cursor, segment.c1, segment.c2, segment.to, points);
|
||
cursor = segment.to;
|
||
}
|
||
if (subpath.closed) points.push(subpath.start);
|
||
}
|
||
const lengths = [0];
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
lengths.push(lengths[index - 1] + Math.hypot(points[index][0] - points[index - 1][0], points[index][1] - points[index - 1][1]));
|
||
}
|
||
return { points, lengths, total: lengths.at(-1) ?? 0, closed: subpaths.every((entry) => entry.closed) };
|
||
}
|
||
|
||
function alongFlattened(flat, distance) {
|
||
const { points, lengths, total } = flat;
|
||
if (total === 0) return { x: points[0]?.[0] ?? 0, y: points[0]?.[1] ?? 0, tangent: 0 };
|
||
const target = Math.max(0, Math.min(total, distance));
|
||
let index = 1;
|
||
while (index < lengths.length - 1 && lengths[index] < target) index += 1;
|
||
const span = lengths[index] - lengths[index - 1];
|
||
const t = span === 0 ? 0 : (target - lengths[index - 1]) / span;
|
||
const from = points[index - 1];
|
||
const to = points[index];
|
||
return {
|
||
x: from[0] + (to[0] - from[0]) * t,
|
||
y: from[1] + (to[1] - from[1]) * t,
|
||
tangent: Math.atan2(to[1] - from[1], to[0] - from[0]) / DEGREES_TO_RADIANS
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Depth (as a type and as a sub-block)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 18.3: three inverse functions, both non-uniform curves biasing toward `near`. */
|
||
function depthValue(block, u) {
|
||
const near = block.near ?? 0;
|
||
const far = block.far ?? 0;
|
||
if (!(far > near)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A depth distribution needs far above near.', '$');
|
||
const curve = block.curve ?? 'uniform';
|
||
if (curve === 'linear') return near + (far - near) * u * u;
|
||
if (curve === 'exponential') return near + (far - near) * ((1 - Math.exp(-3 * u)) / (1 - Math.exp(-3)));
|
||
return near + (far - near) * u;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Placement
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const CONTINUOUS_ILLEGAL = new Set(['grid']);
|
||
|
||
/**
|
||
* 18.3: `even` on `line`, `ring`, or `path`, and the `grid` type itself, place
|
||
* item `i` of `n` by index and are ERR_INVALID_DISTRIBUTION on a continuous
|
||
* `rate` emission, where `n` is unknown at creation time.
|
||
*/
|
||
function requiresKnownCount(distribution) {
|
||
if (!distribution) return false;
|
||
if (CONTINUOUS_ILLEGAL.has(distribution.type)) return true;
|
||
return distribution.mode === 'even' && ['line', 'ring', 'path'].includes(distribution.type);
|
||
}
|
||
|
||
/**
|
||
* Place item `index` of `count`. Samples are drawn from `stream` in exactly the
|
||
* order the table of 18.3 fixes; an optional `depth` sub-block draws its `u`
|
||
* after the host distribution's own samples.
|
||
*/
|
||
function placeItem(distribution, { index = 0, count = 1, stream = null, path = '$' } = {}) {
|
||
const type = distribution?.type ?? 'point';
|
||
const place = { x: 0, y: 0, z: 0, rotation: undefined };
|
||
const next = () => {
|
||
if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'This distribution requires an owning random stream.', path);
|
||
return stream.nextFloat();
|
||
};
|
||
|
||
switch (type) {
|
||
case 'point': {
|
||
const at = pointOf(distribution?.at);
|
||
place.x = at.x; place.y = at.y; place.z = at.z;
|
||
break;
|
||
}
|
||
case 'uniform': {
|
||
const min = pointOf(distribution.min);
|
||
const max = pointOf(distribution.max);
|
||
if (max.x < min.x || max.y < min.y) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A uniform box needs max above min.', path);
|
||
place.x = min.x + next() * (max.x - min.x);
|
||
place.y = min.y + next() * (max.y - min.y);
|
||
const carriesZ = distribution.min?.z !== undefined || distribution.max?.z !== undefined;
|
||
if (carriesZ) place.z = min.z + next() * (max.z - min.z);
|
||
break;
|
||
}
|
||
case 'line': {
|
||
const from = pointOf(distribution.from);
|
||
const to = pointOf(distribution.to);
|
||
const t = (distribution.mode ?? 'random') === 'even' ? fraction(index, count) : next();
|
||
place.x = from.x + (to.x - from.x) * t;
|
||
place.y = from.y + (to.y - from.y) * t;
|
||
place.z = from.z + (to.z - from.z) * t;
|
||
break;
|
||
}
|
||
case 'rectangle': {
|
||
const center = pointOf(distribution.center);
|
||
const width = distribution.size?.width ?? 0;
|
||
const height = distribution.size?.height ?? 0;
|
||
if ((distribution.fill ?? 'area') === 'perimeter') {
|
||
// One sample mapped to a distance around the perimeter, clockwise from
|
||
// the top-left corner.
|
||
const perimeter = 2 * (width + height);
|
||
let walk = next() * perimeter;
|
||
const left = center.x - width / 2;
|
||
const top = center.y - height / 2;
|
||
if (walk < width) { place.x = left + walk; place.y = top; break; }
|
||
walk -= width;
|
||
if (walk < height) { place.x = left + width; place.y = top + walk; break; }
|
||
walk -= height;
|
||
if (walk < width) { place.x = left + width - walk; place.y = top + height; break; }
|
||
walk -= width;
|
||
place.x = left; place.y = top + height - walk;
|
||
break;
|
||
}
|
||
place.x = center.x + (next() - 0.5) * width;
|
||
place.y = center.y + (next() - 0.5) * height;
|
||
break;
|
||
}
|
||
case 'ellipse': {
|
||
const center = pointOf(distribution.center);
|
||
const [rx, ry] = distributionRadii(distribution.radius);
|
||
const angle = next() * 360 * DEGREES_TO_RADIANS;
|
||
if ((distribution.fill ?? 'area') === 'perimeter') {
|
||
place.x = center.x + rx * Math.cos(angle);
|
||
place.y = center.y + ry * Math.sin(angle);
|
||
break;
|
||
}
|
||
const radial = Math.sqrt(next());
|
||
place.x = center.x + rx * radial * Math.cos(angle);
|
||
place.y = center.y + ry * radial * Math.sin(angle);
|
||
break;
|
||
}
|
||
case 'ring': {
|
||
const center = pointOf(distribution.center);
|
||
const outer = distribution.radius ?? 0;
|
||
const inner = distribution.innerRadius ?? 0;
|
||
if (inner >= outer) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'A ring needs innerRadius below radius.', path);
|
||
const startAngle = distribution.startAngle ?? 0;
|
||
const endAngle = distribution.endAngle ?? 360;
|
||
const { sweep, sign } = directedSweep(startAngle, endAngle, distribution.direction ?? 'clockwise', path);
|
||
const full = sweep === 360;
|
||
let degrees;
|
||
let radial;
|
||
if ((distribution.mode ?? 'random') === 'even') {
|
||
degrees = startAngle + sweep * sign * (full ? index / Math.max(1, count) : fraction(index, count));
|
||
// An even angular placement with a random radius would be neither even
|
||
// nor sample-free, so the radius is the mid-annulus.
|
||
radial = (inner + outer) / 2;
|
||
} else {
|
||
degrees = startAngle + sweep * sign * next();
|
||
radial = Math.sqrt(inner * inner + next() * (outer * outer - inner * inner));
|
||
}
|
||
place.x = center.x + radial * Math.cos(degrees * DEGREES_TO_RADIANS);
|
||
place.y = center.y + radial * Math.sin(degrees * DEGREES_TO_RADIANS);
|
||
break;
|
||
}
|
||
case 'path': {
|
||
const flat = flattenPath(distribution.path, `${path}.path`);
|
||
const even = (distribution.mode ?? 'random') === 'even';
|
||
const t = even ? (flat.closed ? index / Math.max(1, count) : fraction(index, count)) : next();
|
||
const at = alongFlattened(flat, t * flat.total);
|
||
place.x = at.x;
|
||
place.y = at.y;
|
||
if (distribution.align === true) place.rotation = at.tangent;
|
||
break;
|
||
}
|
||
case 'grid': {
|
||
const origin = pointOf(distribution.origin);
|
||
const columns = distribution.columns ?? 1;
|
||
const rows = distribution.rows ?? 1;
|
||
const cells = Math.max(1, columns * rows);
|
||
const cell = ((index % cells) + cells) % cells;
|
||
const column = cell % columns;
|
||
const row = Math.floor(cell / columns);
|
||
place.x = origin.x + column * (distribution.spacing?.x ?? 0);
|
||
place.y = origin.y + row * (distribution.spacing?.y ?? 0);
|
||
const jitter = distribution.jitter;
|
||
const jx = jitter?.x ?? 0;
|
||
const jy = jitter?.y ?? 0;
|
||
if (jx !== 0 || jy !== 0) {
|
||
place.x += (next() * 2 - 1) * jx;
|
||
place.y += (next() * 2 - 1) * jy;
|
||
}
|
||
break;
|
||
}
|
||
case 'depth': {
|
||
place.z = depthValue(distribution, next());
|
||
break;
|
||
}
|
||
default:
|
||
throw new RuntimeFault('ERR_INVALID_DISTRIBUTION_TYPE', `Unknown placement distribution '${type}'.`, path);
|
||
}
|
||
|
||
// 18.3: the optional depth sub-block draws its sample after the host's own.
|
||
if (type !== 'depth' && distribution?.depth) {
|
||
place.z = depthValue(distribution.depth, next());
|
||
}
|
||
return place;
|
||
}
|
||
|
||
/** The documented number of samples a placement draws, for the traces of 18.10. */
|
||
function sampleCount(distribution, mode = 'random') {
|
||
const type = distribution?.type ?? 'point';
|
||
const selected = distribution?.mode ?? mode;
|
||
let count = 0;
|
||
if (type === 'uniform') count = 2 + ((distribution.min?.z !== undefined || distribution.max?.z !== undefined) ? 1 : 0);
|
||
else if (type === 'line') count = selected === 'even' ? 0 : 1;
|
||
else if (type === 'rectangle') count = (distribution.fill ?? 'area') === 'perimeter' ? 1 : 2;
|
||
else if (type === 'ellipse') count = (distribution.fill ?? 'area') === 'perimeter' ? 1 : 2;
|
||
else if (type === 'ring') count = selected === 'even' ? 0 : 2;
|
||
else if (type === 'path') count = selected === 'even' ? 0 : 1;
|
||
else if (type === 'grid') count = (distribution.jitter?.x ?? 0) !== 0 || (distribution.jitter?.y ?? 0) !== 0 ? 2 : 0;
|
||
else if (type === 'depth') count = 1;
|
||
if (type !== 'depth' && distribution?.depth) count += 1;
|
||
return count;
|
||
}
|
||
|
||
/* src/runtime/visual-fields.js */
|
||
// Procedural fields (18.7): named vector functions over scene space that other
|
||
// systems read. A field draws nothing and depends only on position and logical
|
||
// time, so there is no evaluation order to fix and no cycle to detect.
|
||
|
||
|
||
|
||
|
||
|
||
class FieldSet {
|
||
constructor(declarations = {}, { rng = null } = {}) {
|
||
this.fields = new Map();
|
||
for (const [id, declaration] of Object.entries(declarations)) {
|
||
const entry = { id, spec: declaration, table: null };
|
||
if (declaration.type === 'noise') {
|
||
if (!rng) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A noise field requires a seeded stream.', `$.visuals.fields.${id}`);
|
||
// 18.7: the permutation table is derived at field instantiation from
|
||
// the field's own stream, consuming exactly 255 samples.
|
||
entry.table = permutationTable(rng.stream('visual', id));
|
||
}
|
||
this.fields.set(id, entry);
|
||
}
|
||
this.evaluations = 0;
|
||
}
|
||
|
||
has(id) { return this.fields.has(id); }
|
||
|
||
/** The field's vector at a scene point at logical time `t` seconds. */
|
||
sample(id, x, y, t) {
|
||
const entry = this.fields.get(id);
|
||
if (!entry) throw new RuntimeFault('ERR_INVALID_REFERENCE', `No visual field '${id}' is declared.`, `$.visuals.fields.${id}`);
|
||
const spec = entry.spec;
|
||
if (spec.enabled === false) return [0, 0];
|
||
const box = spec.bounds;
|
||
if (box && (x < box.x || y < box.y || x > box.x + box.width || y > box.y + box.height)) return [0, 0];
|
||
if (this.evaluations >= (this.budget ?? Infinity)) { this.refused = true; return [0, 0]; }
|
||
this.evaluations += 1;
|
||
switch (spec.type) {
|
||
case 'directional': {
|
||
const radians = (spec.direction ?? 0) * DEGREES_TO_RADIANS;
|
||
const strength = spec.strength ?? 0;
|
||
return [strength * Math.cos(radians), strength * Math.sin(radians)];
|
||
}
|
||
case 'noise': {
|
||
const region = spec.center && spec.size;
|
||
if (region) {
|
||
const halfWidth = (spec.size.width ?? 0) / 2;
|
||
const halfHeight = (spec.size.height ?? 0) / 2;
|
||
if (Math.abs(x - spec.center.x) > halfWidth || Math.abs(y - spec.center.y) > halfHeight) return [0, 0];
|
||
}
|
||
const scale = spec.scale ?? 100;
|
||
return noiseVector(entry.table, x / scale, y / scale, t * (spec.speed ?? 0), {
|
||
mode: spec.mode ?? 'curl',
|
||
octaves: spec.octaves ?? 1,
|
||
persistence: spec.persistence ?? 0.5,
|
||
amplitude: spec.amplitude ?? 1,
|
||
direction: spec.direction ?? 0
|
||
});
|
||
}
|
||
default: {
|
||
const center = { x: spec.center?.x ?? 0, y: spec.center?.y ?? 0 };
|
||
const dx = x - center.x;
|
||
const dy = y - center.y;
|
||
const distance = Math.hypot(dx, dy);
|
||
if (spec.maxDistance !== undefined && distance > spec.maxDistance) return [0, 0];
|
||
const clamped = Math.max(spec.minDistance ?? 1, distance);
|
||
const magnitude = (spec.strength ?? 0) * falloffFactor(spec.falloff ?? 'linear', clamped, spec.maxDistance);
|
||
if (distance === 0) return [0, 0];
|
||
const ux = dx / distance;
|
||
const uy = dy / distance;
|
||
switch (spec.type) {
|
||
// 18.7: the outward ray, with negative strength pointing inward.
|
||
case 'radial': return [magnitude * ux, magnitude * uy];
|
||
case 'attractor': return [-magnitude * ux, -magnitude * uy];
|
||
case 'repulsor': return [magnitude * ux, magnitude * uy];
|
||
// 18.7: counter-clockwise as seen on the display, in y-down space.
|
||
case 'vortex': return [magnitude * uy, -magnitude * ux];
|
||
default: throw new RuntimeFault('ERR_INVALID_FIELD_TYPE', `Unknown field type '${spec.type}'.`, `$.visuals.fields.${id}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Sum a system's declared fields into one acceleration, in array order. */
|
||
sum(ids, x, y, t) {
|
||
let ax = 0;
|
||
let ay = 0;
|
||
for (const id of ids ?? []) {
|
||
const [fx, fy] = this.sample(id, x, y, t);
|
||
ax += fx;
|
||
ay += fy;
|
||
}
|
||
return [ax, ay];
|
||
}
|
||
}
|
||
|
||
/* src/runtime/visual-systems.js */
|
||
// Procedural systems (18.2, 18.4, 18.5) with their trails and links (18.8).
|
||
//
|
||
// A system owns a bounded population of items. Its fields divide into three
|
||
// classes (18.2): system-instantiation values resolved once before any item
|
||
// exists, live system channels whose base is resolved there, and per-item
|
||
// values resolved at each item's creation from the stream child key
|
||
// `<system-path>#<ordinal>`. Motion integrates on the fixed logical tick of
|
||
// 9.1 with the normative semi-implicit Euler order, never on the frame.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const SYSTEM_LIMITS = VISUAL_LIMITS.authoring;
|
||
|
||
/** 18.2: fields resolved once per item, at that item's creation. */
|
||
const PER_ITEM_KEYS = new Set(['distribution', 'velocity', 'size', 'rotation', 'angularVelocity', 'opacity', 'color', 'z', 'lifetime', 'align']);
|
||
|
||
function isLifeRamp(value) {
|
||
return isRecord(value) && Object.hasOwn(value, 'from') && Object.hasOwn(value, 'to');
|
||
}
|
||
|
||
function seconds(duration, path) {
|
||
return duration === undefined ? null : parseDuration(duration, path) / 1000;
|
||
}
|
||
|
||
class ProceduralSystem {
|
||
constructor(id, spec, options) {
|
||
this.id = id;
|
||
this.spec = spec;
|
||
this.type = spec.type;
|
||
this.path = options.systemPath ?? `visuals.systems.${id}`;
|
||
this.resolver = options.resolver;
|
||
this.rng = options.rng;
|
||
this.noiseTable = options.noiseTable;
|
||
this.scene = options.scene ?? { x: 0, y: 0, width: 0, height: 0 };
|
||
this.fields = options.fields ?? null;
|
||
this.instantiateItemNode = options.instantiateItemNode;
|
||
this.layer = spec.layer ?? null;
|
||
this.time = 0;
|
||
this.ordinal = 0;
|
||
this.created = 0;
|
||
this.accumulator = 0;
|
||
this.burstsFired = new Set();
|
||
this.items = [];
|
||
|
||
const systemStream = this.rng ? this.rng.stream('visual', this.path) : null;
|
||
this.systemStream = systemStream;
|
||
const sample = (value, key) => this.resolver && value !== undefined
|
||
? this.resolver.evaluate(value, systemStream, `${this.path}.${key}`)
|
||
: value;
|
||
|
||
// ---- system-instantiation values (18.2) --------------------------------
|
||
this.capacity = spec.capacity ?? (this.type === 'emitter' ? 64 : 256);
|
||
this.limit = spec.limit;
|
||
const resolvedCount = sample(spec.count, 'count') ?? (this.type === 'repeater' ? 0 : 0);
|
||
const maximum = this.type === 'repeater' ? SYSTEM_LIMITS.repeaterCount : this.capacity;
|
||
if (resolvedCount > maximum) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Resolved initial count exceeds the system bound.', this.path);
|
||
// C18: Throw ERR_TYPE_MISMATCH on non-integer resolved count (§18.2).
|
||
if (Number.isFinite(resolvedCount) && !Number.isInteger(resolvedCount)) {
|
||
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Resolved count ${resolvedCount} is not an integer.`, this.path);
|
||
}
|
||
this.count = Math.round(resolvedCount);
|
||
this.rate = sample(spec.rate, 'rate') ?? 0;
|
||
this.bursts = (spec.burst ?? []).map((entry, index) => {
|
||
const resolvedBurstCount = sample(entry.count, `burst[${index}].count`) ?? 0;
|
||
// C18: Throw ERR_TYPE_MISMATCH on non-integer resolved burst count.
|
||
if (Number.isFinite(resolvedBurstCount) && !Number.isInteger(resolvedBurstCount)) {
|
||
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Resolved burst count ${resolvedBurstCount} is not an integer.`, `${this.path}.burst[${index}].count`);
|
||
}
|
||
return {
|
||
at: seconds(entry.at, `${this.path}.burst[${index}].at`) ?? 0,
|
||
count: Math.round(resolvedBurstCount)
|
||
};
|
||
});
|
||
this.itemLifetime = spec.lifetime;
|
||
this.trailSpec = spec.trail ?? null;
|
||
this.linksSpec = spec.links ?? null;
|
||
this.fieldIds = spec.fields ?? [];
|
||
this.distribution = spec.distribution ? sampleBehavior(spec.distribution, this.resolver, systemStream, `${this.path}.distribution`) : undefined;
|
||
|
||
// ---- live system channels: base resolved here (19.1 drives them) -------
|
||
this.position = { x: sample(spec.position?.x, 'position.x') ?? 0, y: sample(spec.position?.y, 'position.y') ?? 0 };
|
||
this.acceleration = {
|
||
x: sample(spec.acceleration?.x, 'acceleration.x') ?? 0,
|
||
y: sample(spec.acceleration?.y, 'acceleration.y') ?? 0,
|
||
z: sample(spec.acceleration?.z, 'acceleration.z') ?? 0
|
||
};
|
||
this.drag = sample(spec.drag, 'drag') ?? 0;
|
||
|
||
this.validate();
|
||
if (this.type === 'repeater') this.createRepeaterCopies();
|
||
else for (let index = 0; index < this.count; index += 1) this.createItem(index, this.count);
|
||
}
|
||
|
||
validate() {
|
||
if (!Number.isSafeInteger(this.capacity) || this.capacity < 1) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Capacity must be a positive bounded integer.', this.path);
|
||
if (!Number.isFinite(this.rate) || this.rate < 0 || this.rate > Number.MAX_SAFE_INTEGER) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Rate exceeds finite safe arithmetic.', this.path);
|
||
const maximum = this.type === 'repeater' ? SYSTEM_LIMITS.repeaterCount : this.capacity;
|
||
if (!Number.isFinite(this.count) || this.count < 0 || this.count > maximum) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', 'Resolved initial count exceeds the system bound.', this.path);
|
||
if (!Number.isFinite(this.drag)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Drag must be finite.', this.path);
|
||
this.drag = Math.max(0, Math.min(1, this.drag));
|
||
const emits = this.rate > 0 || this.bursts.length > 0;
|
||
// 18.2/18.4: emission with neither a per-item lifetime nor a total limit
|
||
// would create items without bound.
|
||
if (emits && this.itemLifetime === undefined && this.limit === undefined) {
|
||
throw new RuntimeFault('ERR_UNBOUNDED_EMISSION', 'Emission declares neither a lifetime nor a limit.', this.path);
|
||
}
|
||
if (this.type === 'particles' && this.capacity > SYSTEM_LIMITS.particleCapacity) {
|
||
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A particle capacity above ${SYSTEM_LIMITS.particleCapacity}.`, this.path);
|
||
}
|
||
if (this.type === 'emitter' && this.capacity > SYSTEM_LIMITS.emitterCapacity) {
|
||
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `An emitter capacity above ${SYSTEM_LIMITS.emitterCapacity}.`, this.path);
|
||
}
|
||
if (this.type === 'repeater' && this.count > SYSTEM_LIMITS.repeaterCount) {
|
||
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A repeater count above ${SYSTEM_LIMITS.repeaterCount}.`, this.path);
|
||
}
|
||
// 18.3: an index-driven placement needs a known count, so it is illegal on
|
||
// a continuous rate emission.
|
||
if (this.rate > 0 && requiresKnownCount(this.spec.distribution)) {
|
||
throw new RuntimeFault('ERR_INVALID_DISTRIBUTION', 'An index-driven placement has no count on a continuous rate emission.', `${this.path}.distribution`);
|
||
}
|
||
// 18.8: a pairwise link rule over a statically known population above 256.
|
||
const rule = this.linksSpec?.rule ?? 'distance';
|
||
if (this.linksSpec && rule !== 'index') {
|
||
const population = this.type === 'repeater' ? this.count : this.capacity;
|
||
if (population > SYSTEM_LIMITS.pairwiseLinkedPopulation) {
|
||
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A '${rule}' link rule over a population above ${SYSTEM_LIMITS.pairwiseLinkedPopulation}.`, `${this.path}.links`);
|
||
}
|
||
}
|
||
if (this.linksSpec?.fadeWithDistance === true && this.linksSpec.maxDistance === undefined) {
|
||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'fadeWithDistance needs a maxDistance to normalize against.', `${this.path}.links`);
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Item creation
|
||
// -------------------------------------------------------------------------
|
||
|
||
itemTemplateKey() {
|
||
return this.type === 'particles' ? 'render' : this.type === 'emitter' ? 'emit' : 'repeat';
|
||
}
|
||
|
||
/**
|
||
* 18.2: per-item fields are sampled in the system's own property-document
|
||
* order, from the stream child key `<system-path>#<ordinal>`, so one seed
|
||
* creates identical items in identical order however the ticks fall.
|
||
*/
|
||
createItem(index, count, { burstIndex = null } = {}) {
|
||
const ordinal = this.ordinal;
|
||
this.ordinal += 1;
|
||
this.created += 1;
|
||
const stream = this.rng ? this.rng.stream('visual', `${this.path}#${ordinal}`) : null;
|
||
const spec = this.spec;
|
||
const evaluate = (value, key) => value === undefined ? undefined : this.resolver.evaluate(value, stream, `${this.path}.${key}`);
|
||
const templateKey = this.itemTemplateKey();
|
||
|
||
const item = {
|
||
ordinal, index, burstIndex,
|
||
birth: this.time,
|
||
lifetime: null,
|
||
base: { x: this.position.x, y: this.position.y, z: 0, rotation: 0 },
|
||
acc: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 },
|
||
fresh: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 },
|
||
scale: { x: 1, y: 1 },
|
||
opacityMultiplier: 1,
|
||
channels: {},
|
||
vx: 0, vy: 0, vz: 0,
|
||
angularVelocity: 0,
|
||
size: 1, opacity: 1, color: null,
|
||
ramps: {},
|
||
behaviors: [],
|
||
trail: [],
|
||
node: null,
|
||
points: null
|
||
};
|
||
|
||
for (const key of Object.keys(spec)) {
|
||
if (!PER_ITEM_KEYS.has(key) && key !== templateKey && key !== 'behaviors') continue;
|
||
switch (key) {
|
||
case 'distribution': {
|
||
const place = placeItem(this.distribution, { index, count, stream, path: `${this.path}.distribution` });
|
||
item.base.x += place.x;
|
||
item.base.y += place.y;
|
||
item.base.z += place.z;
|
||
if (place.rotation !== undefined) item.base.rotation = place.rotation;
|
||
break;
|
||
}
|
||
case 'velocity': {
|
||
item.vx = evaluate(spec.velocity?.x, 'velocity.x') ?? 0;
|
||
item.vy = evaluate(spec.velocity?.y, 'velocity.y') ?? 0;
|
||
item.vz = evaluate(spec.velocity?.z, 'velocity.z') ?? 0;
|
||
break;
|
||
}
|
||
case 'size': item.ramps.size = this.rampOrValue(spec.size, evaluate, 'size', 1); break;
|
||
case 'opacity': item.ramps.opacity = this.rampOrValue(spec.opacity, evaluate, 'opacity', 1); break;
|
||
case 'color': item.ramps.color = this.rampOrValue(spec.color, evaluate, 'color', null); break;
|
||
case 'rotation': item.base.rotation = evaluate(spec.rotation, 'rotation') ?? item.base.rotation; break;
|
||
case 'angularVelocity': item.angularVelocity = evaluate(spec.angularVelocity, 'angularVelocity') ?? 0; break;
|
||
case 'z': item.base.z += evaluate(spec.z, 'z') ?? 0; break;
|
||
case 'lifetime': item.lifetime = seconds(evaluate(spec.lifetime, 'lifetime'), `${this.path}.lifetime`); break;
|
||
case 'align': item.align = spec.align === true; break;
|
||
case 'behaviors': {
|
||
item.behaviors = (spec.behaviors ?? []).map((behavior, position) => createBehavior(
|
||
this.resolver.evaluate ? sampleBehavior(behavior, this.resolver, stream, `${this.path}.behaviors[${position}]`) : behavior,
|
||
{ stream, noiseTable: this.noiseTable, path: `${this.path}.behaviors[${position}]` }
|
||
));
|
||
break;
|
||
}
|
||
default: {
|
||
if (key === templateKey) item.node = this.instantiateItemNode?.(spec[templateKey], stream, `${this.path}.${templateKey}#${ordinal}`, this.repeatScope(index, count));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 18.2: a life ramp on an item with no lifetime has no normalized age.
|
||
for (const [field, ramp] of Object.entries(item.ramps)) {
|
||
if (ramp?.isRamp && item.lifetime === null) {
|
||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `A ${field} life ramp needs a lifetime.`, this.path);
|
||
}
|
||
}
|
||
if (item.align && (item.vx !== 0 || item.vy !== 0)) item.base.rotation = Math.atan2(item.vy, item.vx) / DEGREES_TO_RADIANS;
|
||
this.items.push(item);
|
||
return item;
|
||
}
|
||
|
||
repeatScope(index, count) {
|
||
if (this.type !== 'repeater') return null;
|
||
// 18.5: `repeat.*` is construct-scoped and grants no 8.1 capability.
|
||
return { repeat: { index, count, fraction: count <= 1 ? 0 : index / (count - 1) } };
|
||
}
|
||
|
||
rampOrValue(value, evaluate, key, fallback) {
|
||
if (value === undefined) return { isRamp: false, value: fallback };
|
||
if (isLifeRamp(value)) {
|
||
return {
|
||
isRamp: true,
|
||
from: evaluate(value.from, `${key}.from`),
|
||
to: evaluate(value.to, `${key}.to`),
|
||
curve: value.curve ?? 'linear'
|
||
};
|
||
}
|
||
return { isRamp: false, value: evaluate(value, key) };
|
||
}
|
||
|
||
createRepeaterCopies() {
|
||
for (let index = 0; index < this.count; index += 1) this.createItem(index, this.count);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Advance
|
||
// -------------------------------------------------------------------------
|
||
|
||
advance(dt, env = {}) {
|
||
this.time += dt;
|
||
if (this.type !== 'repeater' && !this.releasing) this.emit(dt);
|
||
const fieldEnv = {
|
||
...env,
|
||
scene: this.scene,
|
||
duration: (value) => parseDuration(value, this.path),
|
||
alongPath: alongFlattened,
|
||
resolvePath: (value) => flattenPath(value, this.path),
|
||
sampleField: (id, x, y, t) => this.fields?.sample(id, x, y, t)
|
||
};
|
||
const survivors = [];
|
||
for (const item of this.items) {
|
||
const age = this.time - item.birth;
|
||
if (item.lifetime !== null && age >= item.lifetime) continue;
|
||
if (!env.deferItems) this.advanceItem(item, dt, age, fieldEnv);
|
||
survivors.push(item);
|
||
}
|
||
this.items = survivors;
|
||
return fieldEnv;
|
||
}
|
||
|
||
emit(dt) {
|
||
const reachedLimit = () => this.limit !== undefined && this.created >= this.limit;
|
||
// 18.4: a burst emits its whole count on the first tick at or after its `at`.
|
||
this.bursts.forEach((burst, index) => {
|
||
if (this.burstsFired.has(index) || this.time < burst.at) return;
|
||
this.burstsFired.add(index);
|
||
const count = Math.min(burst.count, this.limit === undefined ? burst.count : Math.max(0, this.limit - this.created));
|
||
const skipped = Math.max(0, count - this.capacity);
|
||
this.ordinal += skipped; this.created += skipped;
|
||
for (let position = skipped; position < count; position += 1) {
|
||
if (reachedLimit()) return;
|
||
this.admit(() => this.createItem(position, burst.count, { burstIndex: index }));
|
||
}
|
||
});
|
||
if (this.rate <= 0) return;
|
||
// 18.4: a fractional accumulator, so the cumulative count after t seconds
|
||
// is exactly floor(rate * t), with no drift and no tick-alignment term.
|
||
this.accumulator += this.rate * dt;
|
||
const due = Math.floor(this.accumulator);
|
||
this.accumulator -= due;
|
||
const count = Math.min(due, this.limit === undefined ? due : Math.max(0, this.limit - this.created));
|
||
const skipped = Math.max(0, count - this.capacity);
|
||
this.ordinal += skipped; this.created += skipped;
|
||
for (let position = skipped; position < count; position += 1) {
|
||
if (reachedLimit()) return;
|
||
this.admit(() => this.createItem(this.ordinal, 1));
|
||
}
|
||
}
|
||
|
||
/** 18.2/18.4: at capacity the oldest live item is evicted and replaced. */
|
||
admit(create) {
|
||
if (this.items.length >= this.capacity) {
|
||
let oldest = 0;
|
||
for (let index = 1; index < this.items.length; index += 1) {
|
||
if (this.items[index].ordinal < this.items[oldest].ordinal) oldest = index;
|
||
}
|
||
this.items.splice(oldest, 1);
|
||
}
|
||
create();
|
||
}
|
||
|
||
advanceItem(item, dt, age, env) {
|
||
// Fresh contributions are recomputed every tick; accumulated ones persist.
|
||
item.fresh.offset.x = 0; item.fresh.offset.y = 0; item.fresh.offset.z = 0;
|
||
item.fresh.rotation = 0;
|
||
item.scale.x = 1; item.scale.y = 1;
|
||
item.opacityMultiplier = 1;
|
||
|
||
if (this.type !== 'repeater') {
|
||
const [fx, fy] = this.fields ? this.fields.sum(this.fieldIds, item.base.x + item.acc.offset.x, item.base.y + item.acc.offset.y, this.time) : [0, 0];
|
||
// 18.2: v <- (v + a*dt) * (1 - drag)^dt, then p <- p + v*dt.
|
||
const decay = (1 - this.drag) ** dt;
|
||
item.vx = (item.vx + (this.acceleration.x + fx) * dt) * decay;
|
||
item.vy = (item.vy + (this.acceleration.y + fy) * dt) * decay;
|
||
item.vz = (item.vz + this.acceleration.z * dt) * decay;
|
||
item.base.x += item.vx * dt;
|
||
item.base.y += item.vy * dt;
|
||
item.base.z += item.vz * dt;
|
||
item.base.rotation += (item.angularVelocity ?? 0) * dt;
|
||
}
|
||
|
||
for (const instance of item.behaviors) {
|
||
const bucket = instance.accumulating ? item.acc : item.fresh;
|
||
const view = {
|
||
base: item.base, points: item.points, channels: item.channels,
|
||
offset: bucket.offset, rotationOffset: 0,
|
||
scale: item.scale, opacityMultiplier: 1,
|
||
vx: item.vx, vy: item.vy, vz: item.vz
|
||
};
|
||
advanceBehavior(instance, view, { dt, time: age, env });
|
||
bucket.rotation += view.rotationOffset;
|
||
item.opacityMultiplier *= view.opacityMultiplier;
|
||
item.vx = view.vx; item.vy = view.vy; item.vz = view.vz;
|
||
}
|
||
|
||
this.sampleTrail(item);
|
||
}
|
||
|
||
/** 18.8: history is sampled on the logical clock, never on the frame. */
|
||
sampleTrail(item) {
|
||
if (!this.trailSpec) return;
|
||
const length = this.trailSpec.length ?? 16;
|
||
if (length > SYSTEM_LIMITS.trailLength) throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `A trail length above ${SYSTEM_LIMITS.trailLength}.`, `${this.path}.trail`);
|
||
const interval = this.trailSpec.interval ? parseDuration(this.trailSpec.interval) / 1000 : null;
|
||
const last = item.trail.at(-1);
|
||
if (interval !== null && last && this.time - last.t < interval) return;
|
||
const position = this.itemPosition(item);
|
||
item.trail.push({ x: position.x, y: position.y, z: position.z, t: this.time });
|
||
while (item.trail.length > length) item.trail.shift();
|
||
}
|
||
|
||
itemPosition(item) {
|
||
return {
|
||
x: item.base.x + item.acc.offset.x + item.fresh.offset.x,
|
||
y: item.base.y + item.acc.offset.y + item.fresh.offset.y,
|
||
z: item.base.z + item.acc.offset.z + item.fresh.offset.z,
|
||
rotation: item.base.rotation + item.acc.rotation + item.fresh.rotation
|
||
};
|
||
}
|
||
|
||
/** Normalized age drives every life ramp (18.2). */
|
||
itemAge(item) {
|
||
if (item.lifetime === null || item.lifetime === 0) return 0;
|
||
return Math.max(0, Math.min(1, (this.time - item.birth) / item.lifetime));
|
||
}
|
||
|
||
rampValue(ramp, age) {
|
||
if (!ramp) return undefined;
|
||
if (!ramp.isRamp) return ramp.value;
|
||
const amount = curveValue(ramp.curve, age);
|
||
if (typeof ramp.from === 'string') return mixColor(parseColor(ramp.from), parseColor(ramp.to), amount);
|
||
return ramp.from + (ramp.to - ramp.from) * amount;
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Links (18.8)
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 18.8: pairs are enumerated in ascending (lower index, higher index) order
|
||
* and de-duplicated, so a pair is drawn once regardless of rule. The live
|
||
* ordering is re-indexed densely each tick, so a death closes a chain's gap.
|
||
*/
|
||
linkPairs() {
|
||
if (!this.linksSpec) return [];
|
||
const ordered = [...this.items].sort((a, b) => a.ordinal - b.ordinal);
|
||
const positions = ordered.map((item) => this.itemPosition(item));
|
||
const rule = this.linksSpec.rule ?? 'distance';
|
||
const maxDistance = this.linksSpec.maxDistance;
|
||
const pairs = new Set();
|
||
if (rule === 'index') {
|
||
const stride = this.linksSpec.stride ?? 1;
|
||
for (let index = 0; index + stride < ordered.length; index += 1) pairs.add(`${index}:${index + stride}`);
|
||
if (this.linksSpec.closed === true && ordered.length > 2) pairs.add(`0:${ordered.length - 1}`);
|
||
} else if (rule === 'nearest') {
|
||
const count = this.linksSpec.count ?? 1;
|
||
for (let index = 0; index < ordered.length; index += 1) {
|
||
const candidates = [];
|
||
for (let other = 0; other < ordered.length; other += 1) {
|
||
if (other === index) continue;
|
||
const distance = Math.hypot(positions[other].x - positions[index].x, positions[other].y - positions[index].y);
|
||
if (maxDistance !== undefined && distance > maxDistance) continue;
|
||
candidates.push({ other, distance });
|
||
}
|
||
// Equidistant neighbours break by ascending creation ordinal, which the
|
||
// dense live ordering already expresses.
|
||
candidates.sort((a, b) => (a.distance - b.distance) || (a.other - b.other));
|
||
for (const candidate of candidates.slice(0, count)) {
|
||
const low = Math.min(index, candidate.other);
|
||
const high = Math.max(index, candidate.other);
|
||
pairs.add(`${low}:${high}`);
|
||
}
|
||
}
|
||
} else {
|
||
for (let index = 0; index < ordered.length; index += 1) {
|
||
for (let other = index + 1; other < ordered.length; other += 1) {
|
||
const distance = Math.hypot(positions[other].x - positions[index].x, positions[other].y - positions[index].y);
|
||
if (distance <= (maxDistance ?? Infinity)) pairs.add(`${index}:${other}`);
|
||
}
|
||
}
|
||
}
|
||
const sorted = [...pairs].map((key) => key.split(':').map(Number)).sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]));
|
||
const maxLinks = this.linksSpec.maxLinks ?? 256;
|
||
const kept = sorted.slice(0, maxLinks);
|
||
return kept.map(([low, high]) => {
|
||
const distance = Math.hypot(positions[high].x - positions[low].x, positions[high].y - positions[low].y);
|
||
const fade = this.linksSpec.fadeWithDistance === true && maxDistance ? Math.max(0, 1 - distance / maxDistance) : 1;
|
||
return { from: positions[low], to: positions[high], opacity: fade, distance };
|
||
});
|
||
}
|
||
|
||
/** 17.6: a procedural system is one atomic sortable unit. */
|
||
representativeDepth() {
|
||
if (this.items.length === 0) return 0;
|
||
let lowest = Infinity;
|
||
for (const item of this.items) lowest = Math.min(lowest, this.itemPosition(item).z);
|
||
return lowest;
|
||
}
|
||
}
|
||
|
||
function sampleBehavior(behavior, resolver, stream, path) {
|
||
if (Array.isArray(behavior)) return behavior.map((entry, index) => isRecord(entry) || Array.isArray(entry) ? sampleBehavior(entry, resolver, stream, `${path}[${index}]`) : entry);
|
||
const result = {};
|
||
for (const key of Object.keys(behavior)) {
|
||
const value = behavior[key];
|
||
if (isRecord(value) && (Object.hasOwn(value, 'ref') || Object.hasOwn(value, 'random') || Object.hasOwn(value, 'choose') || (Object.hasOwn(value, 'op') && Array.isArray(value.args)))) {
|
||
result[key] = resolver.evaluate(value, stream, `${path}.${key}`);
|
||
} else if (isRecord(value)) {
|
||
result[key] = sampleBehavior(value, resolver, stream, `${path}.${key}`);
|
||
} else {
|
||
result[key] = value;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/* src/runtime/visual-validation.js */
|
||
// Structural and semantic validation of the `visuals` block, sections 17-19 at
|
||
// Format Specification revision 0.8.
|
||
//
|
||
// Stage 0 scope (pre-slice-4d). This module validates the *shared* surface that
|
||
// the schema, the target resolver, and every later visual slice all depend on:
|
||
// the `visuals` container and its allowed fields, the scene model, layers, the
|
||
// common system fields and the `spawn` container, procedural fields, the
|
||
// post-effect chain, and visual automation with its two declaration scopes and
|
||
// its automatable registry. Type-specific system fields — a particle system's
|
||
// emission block, an emitter's bursts, a repeater's distribution — are carried
|
||
// through unvalidated and are tightened by slices 4d, 4e, and 4f, which is
|
||
// where the runtime that reads them lands.
|
||
|
||
|
||
|
||
|
||
const AUTHORING = VISUAL_LIMITS.authoring;
|
||
|
||
const VISUALS_FIELDS = new Set(['scene', 'layers', 'systems', 'fields', 'camera', 'effects', 'automation']);
|
||
const SCENE_FIELDS = new Set(['coordinateSpace', 'width', 'height', 'fit', 'background', 'depthFog']);
|
||
const DEPTH_FOG_FIELDS = new Set(['color', 'near', 'far', 'density']);
|
||
const LAYER_FIELDS = new Set(['opacity', 'blend', 'visible', 'parallax']);
|
||
const CAMERA_ALLOWED = new Set([...Object.keys(CAMERA_FIELDS), 'projection']);
|
||
const COMMON_SYSTEM_FIELDS = new Set(['type', 'layer', 'visible', 'lifecycle', 'automation', 'spawn']);
|
||
const SPAWN_ONLY_AT_TOP_LEVEL = new Set(['release', 'ownership', 'inputs', 'cancelWithScenario']);
|
||
/** 17.7: `lifetime` at a system's top level is the per-item duration, and only these two types have one. */
|
||
const TYPES_WITH_ITEM_LIFETIME = new Set(['particles', 'emitter']);
|
||
const TRACK_FIELDS = new Set(['target', 'mode', 'interpolation', 'loop', 'points']);
|
||
const FIELD_COMMON = new Set(['type', 'bounds', 'enabled']);
|
||
const FIELD_TYPE_FIELDS = Object.freeze({
|
||
directional: ['direction', 'strength'],
|
||
radial: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
|
||
vortex: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
|
||
attractor: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
|
||
repulsor: ['center', 'strength', 'falloff', 'minDistance', 'maxDistance'],
|
||
noise: ['scale', 'speed', 'octaves', 'persistence', 'amplitude', 'mode', 'direction', 'center', 'size']
|
||
});
|
||
|
||
/** Numeric object properties a `graphic` system's automation may address (19.1). */
|
||
const GRAPHIC_NUMERIC_PROPERTIES = new Set([
|
||
'position.x', 'position.y', 'z',
|
||
'transform.translate.x', 'transform.translate.y', 'transform.translate.z',
|
||
'transform.rotation', 'transform.scale.x', 'transform.scale.y',
|
||
'transform.skew.x', 'transform.skew.y', 'transform.origin.x', 'transform.origin.y',
|
||
'style.opacity', 'style.strokeWidth', 'style.pointSize', 'style.strokeDashOffset', 'style.blur',
|
||
'size.width', 'size.height', 'radius', 'innerRadius', 'startAngle', 'endAngle', 'tension'
|
||
]);
|
||
const POINT_PROPERTY = /^points\[\d+\]\.[xyz]$/;
|
||
|
||
/** 19.1: the automatable properties of a procedural system. A repeater has none. */
|
||
const SYSTEM_AUTOMATABLE = Object.freeze({
|
||
particles: new Set(['rate', 'position.x', 'position.y', 'acceleration.x', 'acceleration.y', 'acceleration.z', 'drag']),
|
||
emitter: new Set(['rate', 'position.x', 'position.y', 'acceleration.x', 'acceleration.y', 'acceleration.z', 'drag']),
|
||
repeater: new Set()
|
||
});
|
||
|
||
const EFFECT_INDEX = /^effects\[(\d+)\]\.([A-Za-z][A-Za-z0-9]*)$/;
|
||
|
||
function isColor(value) {
|
||
return typeof value === 'string' && value.length > 0;
|
||
}
|
||
|
||
function validateVisualSubsystem(document, errors, helpers) {
|
||
const { validateValueSpec, pushError } = helpers;
|
||
const fail = (code, path, message) => pushError(errors, code, path, message);
|
||
const visuals = document.visuals;
|
||
if (visuals === undefined) return;
|
||
if (!isRecord(visuals)) return fail('ERR_SCHEMA_VALIDATION', '$.visuals', 'visuals must be an object.');
|
||
|
||
for (const field of Object.keys(visuals)) {
|
||
if (!VISUALS_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.${field}`, `Unrecognized visuals field '${field}'.`);
|
||
}
|
||
|
||
const scene = validateScene(document, visuals.scene, errors, helpers);
|
||
const layers = validateLayers(document, visuals.layers, errors, helpers);
|
||
validateCamera(document, visuals.camera, errors, helpers);
|
||
const effects = validateEffects(document, visuals.effects, errors, helpers);
|
||
const fields = validateFields(document, visuals.fields, errors, helpers);
|
||
const systems = validateSystems(document, visuals.systems, layers, fields, errors, helpers);
|
||
|
||
const context = { scene, layers, effects, systems };
|
||
let tracks = 0;
|
||
let points = 0;
|
||
const count = (result) => { tracks += result.tracks; points += result.points; };
|
||
|
||
count(validateAutomationArray(document, visuals.automation, '$.visuals.automation', { scope: 'exhibit', context }, errors, helpers));
|
||
for (const [id, system] of Object.entries(isRecord(visuals.systems) ? visuals.systems : {})) {
|
||
if (!isRecord(system)) continue;
|
||
count(validateAutomationArray(document, system.automation, `$.visuals.systems.${id}.automation`, { scope: 'system', systemId: id, system, context }, errors, helpers));
|
||
}
|
||
|
||
// 19.1: the authoring bound counts declared records once per declaration.
|
||
// The live budget of the same numbers is a runtime check at spawn time.
|
||
if (tracks > AUTHORING.automationTracks) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals', `Declared automation tracks (${tracks}) exceed the ${AUTHORING.automationTracks} authoring bound.`);
|
||
if (points > AUTHORING.automationPoints) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals', `Declared automation points (${points}) exceed the ${AUTHORING.automationPoints} authoring bound.`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function validateScene(document, scene, errors, { validateValueSpec, pushError }) {
|
||
const fail = (code, path, message) => pushError(errors, code, path, message);
|
||
if (scene === undefined) {
|
||
fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene', 'visuals.scene is required when visuals is present.');
|
||
return {};
|
||
}
|
||
if (!isRecord(scene)) {
|
||
fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene', 'scene must be an object.');
|
||
return {};
|
||
}
|
||
for (const field of Object.keys(scene)) {
|
||
if (!SCENE_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.${field}`, `Unrecognized scene field '${field}'.`);
|
||
}
|
||
const space = scene.coordinateSpace ?? 'virtual';
|
||
if (!COORDINATE_SPACES.includes(space)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.coordinateSpace', `Unsupported coordinate space '${scene.coordinateSpace}'.`);
|
||
|
||
const virtual = space === 'virtual';
|
||
for (const axis of ['width', 'height']) {
|
||
const value = scene[axis];
|
||
if (!virtual && value !== undefined) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.${axis}`, `${axis} is only declared in the 'virtual' coordinate space.`);
|
||
else if (virtual && value === undefined) fail('ERR_SCHEMA_VALIDATION', `$.visuals.scene.${axis}`, `${axis} is required in the 'virtual' coordinate space.`);
|
||
else if (virtual && (!Number.isFinite(value) || value < 1 || value > 16_384)) fail('ERR_OUT_OF_BOUNDS', `$.visuals.scene.${axis}`, `${axis} must be between 1 and 16384.`);
|
||
}
|
||
|
||
// 17.4 (V17): in `viewport` an absent `fit` is accepted and takes no default;
|
||
// only an explicitly authored non-`stretch` value is rejected.
|
||
if (scene.fit !== undefined) {
|
||
if (!FIT_MODES.includes(scene.fit)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.fit', `Unsupported fit '${scene.fit}'.`);
|
||
else if (space === 'viewport' && scene.fit !== 'stretch') fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.fit', "An explicit fit other than 'stretch' has no meaning in the 'viewport' coordinate space.");
|
||
}
|
||
if (scene.background !== undefined && !isColor(scene.background)) fail('ERR_TYPE_MISMATCH', '$.visuals.scene.background', 'background must be a color.');
|
||
|
||
if (scene.depthFog !== undefined) {
|
||
const fog = scene.depthFog;
|
||
if (!isRecord(fog)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog', 'depthFog must be an object.');
|
||
else {
|
||
for (const field of Object.keys(fog)) if (!DEPTH_FOG_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.scene.depthFog.${field}`, `Unrecognized depthFog field '${field}'.`);
|
||
if (!isColor(fog.color)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog.color', 'depthFog.color is required.');
|
||
if (!Number.isFinite(fog.far)) fail('ERR_SCHEMA_VALIDATION', '$.visuals.scene.depthFog.far', 'depthFog.far is required.');
|
||
else if (Number.isFinite(fog.near ?? 0) && fog.far <= (fog.near ?? 0)) fail('ERR_INVALID_RANGE_ORDER', '$.visuals.scene.depthFog.far', 'depthFog.far must exceed depthFog.near.');
|
||
if (fog.density !== undefined && (!Number.isFinite(fog.density) || fog.density < 0 || fog.density > 1)) fail('ERR_OUT_OF_BOUNDS', '$.visuals.scene.depthFog.density', 'depthFog.density must be between 0 and 1.');
|
||
}
|
||
}
|
||
return scene;
|
||
}
|
||
|
||
function validateLayers(document, layers, errors, { validateValueSpec, pushError }) {
|
||
const fail = (code, path, message) => pushError(errors, code, path, message);
|
||
if (layers === undefined) return null;
|
||
if (!isRecord(layers)) {
|
||
fail('ERR_SCHEMA_VALIDATION', '$.visuals.layers', 'layers must be an object.');
|
||
return null;
|
||
}
|
||
const ids = Object.keys(layers);
|
||
// 17.5 (V17): a present-but-empty layer map is rejected; an exhibit that
|
||
// wants the implicit layer omits the key entirely.
|
||
if (ids.length === 0) {
|
||
fail('ERR_SCHEMA_VALIDATION', '$.visuals.layers', 'An empty layer map is not a layer set; omit the key for the implicit layer.');
|
||
return null;
|
||
}
|
||
if (ids.length > AUTHORING.layers) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.layers', `At most ${AUTHORING.layers} layers.`);
|
||
for (const [id, layer] of Object.entries(layers)) {
|
||
const path = `$.visuals.layers.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `Layer ID '${id}' is invalid.`);
|
||
if (!isRecord(layer)) { fail('ERR_SCHEMA_VALIDATION', path, 'Layer must be an object.'); continue; }
|
||
for (const field of Object.keys(layer)) if (!LAYER_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized layer field '${field}'.`);
|
||
if (layer.opacity !== undefined) validateValueSpec(document, layer.opacity, `${path}.opacity`, errors);
|
||
if (layer.visible !== undefined) {
|
||
validateValueSpec(document, layer.visible, `${path}.visible`, errors);
|
||
if (typeof layer.visible !== 'boolean' && !isRecord(layer.visible)) {
|
||
fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.');
|
||
}
|
||
}
|
||
if (layer.blend !== undefined && !BLEND_MODES.includes(layer.blend)) fail('ERR_SCHEMA_VALIDATION', `${path}.blend`, `Unsupported blend mode '${layer.blend}'.`);
|
||
if (layer.parallax !== undefined && !Number.isFinite(layer.parallax)) fail('ERR_TYPE_MISMATCH', `${path}.parallax`, 'parallax must be a finite number.');
|
||
}
|
||
return new Set(ids);
|
||
}
|
||
|
||
function validateCamera(document, camera, errors, { validateValueSpec, pushError }) {
|
||
const fail = (code, path, message) => pushError(errors, code, path, message);
|
||
if (camera === undefined) return;
|
||
if (!isRecord(camera)) return fail('ERR_SCHEMA_VALIDATION', '$.visuals.camera', 'camera must be an object.');
|
||
for (const field of Object.keys(camera)) {
|
||
if (!CAMERA_ALLOWED.has(field)) fail('ERR_UNKNOWN_FIELD', `$.visuals.camera.${field}`, `Unrecognized camera field '${field}'.`);
|
||
}
|
||
if (camera.projection !== undefined && !CAMERA_PROJECTIONS.includes(camera.projection)) {
|
||
fail('ERR_SCHEMA_VALIDATION', '$.visuals.camera.projection', `Unsupported projection '${camera.projection}'.`);
|
||
}
|
||
for (const [field, spec] of Object.entries(CAMERA_FIELDS)) {
|
||
const value = camera[field];
|
||
if (value === undefined) continue;
|
||
validateValueSpec(document, value, `$.visuals.camera.${field}`, errors);
|
||
// 19.3 (V20): a *literal* outside the closed range is rejected at import;
|
||
// a value that resolves outside it is clamped by the 8.1 safety stage.
|
||
if (typeof value === 'number' && ((spec.min !== undefined && value < spec.min) || (spec.max !== undefined && value > spec.max))) {
|
||
fail('ERR_OUT_OF_BOUNDS', `$.visuals.camera.${field}`, `camera.${field} must be between ${spec.min} and ${spec.max}.`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateEffects(document, effects, errors, { validateValueSpec, pushError }) {
|
||
const fail = (code, path, message) => pushError(errors, code, path, message);
|
||
if (effects === undefined) return [];
|
||
if (!Array.isArray(effects)) {
|
||
fail('ERR_SCHEMA_VALIDATION', '$.visuals.effects', 'effects must be an array.');
|
||
return [];
|
||
}
|
||
if (effects.length > AUTHORING.postEffectEntries) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.effects', `At most ${AUTHORING.postEffectEntries} post-effect entries.`);
|
||
effects.forEach((entry, index) => {
|
||
const path = `$.visuals.effects[${index}]`;
|
||
if (!isRecord(entry)) return fail('ERR_SCHEMA_VALIDATION', path, 'Effect entry must be an object.');
|
||
const definition = POST_EFFECTS[entry.type];
|
||
if (!definition) return fail('ERR_INVALID_EFFECT_TYPE', `${path}.type`, `Unsupported post-effect type '${entry.type}'.`);
|
||
for (const field of Object.keys(entry)) {
|
||
if (field === 'type' || field === 'enabled') continue;
|
||
if (!Object.hasOwn(definition.numeric, field) && !Object.hasOwn(definition.colors, field)) {
|
||
fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `Effect '${entry.type}' declares no parameter '${field}'.`);
|
||
}
|
||
}
|
||
if (entry.enabled !== undefined) {
|
||
validateValueSpec(document, entry.enabled, `${path}.enabled`, errors);
|
||
if (typeof entry.enabled !== 'boolean' && !isRecord(entry.enabled)) {
|
||
fail('ERR_TYPE_MISMATCH', `${path}.enabled`, 'enabled must be a boolean.');
|
||
}
|
||
}
|
||
for (const [parameter, range] of Object.entries(definition.numeric)) {
|
||
const value = entry[parameter];
|
||
if (value === undefined) continue;
|
||
validateValueSpec(document, value, `${path}.${parameter}`, errors);
|
||
if (typeof value === 'number' && ((range.min !== undefined && value < range.min) || (range.max !== undefined && value > range.max))) {
|
||
fail('ERR_OUT_OF_BOUNDS', `${path}.${parameter}`, `${parameter} must be between ${range.min} and ${range.max}.`);
|
||
}
|
||
}
|
||
// 19.4: `color` parameters and `type` are authored once, never ValueSpecs.
|
||
for (const parameter of Object.keys(definition.colors)) {
|
||
if (entry[parameter] !== undefined && !isColor(entry[parameter])) fail('ERR_TYPE_MISMATCH', `${path}.${parameter}`, `${parameter} must be an authored color literal.`);
|
||
}
|
||
});
|
||
return effects;
|
||
}
|
||
|
||
function validateFields(document, fields, errors, { validateValueSpec, pushError }) {
|
||
const fail = (code, path, message) => pushError(errors, code, path, message);
|
||
if (fields === undefined) return new Set();
|
||
if (!isRecord(fields)) {
|
||
fail('ERR_SCHEMA_VALIDATION', '$.visuals.fields', 'fields must be an object.');
|
||
return new Set();
|
||
}
|
||
const ids = Object.keys(fields);
|
||
if (ids.length > AUTHORING.declaredFields) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.fields', `At most ${AUTHORING.declaredFields} declared fields.`);
|
||
for (const [id, field] of Object.entries(fields)) {
|
||
const path = `$.visuals.fields.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `Field ID '${id}' is invalid.`);
|
||
if (!isRecord(field)) { fail('ERR_SCHEMA_VALIDATION', path, 'Field must be an object.'); continue; }
|
||
if (!VISUAL_FIELD_TYPES.includes(field.type)) { fail('ERR_INVALID_FIELD_TYPE', `${path}.type`, `Unsupported field type '${field.type}'.`); continue; }
|
||
const allowed = new Set([...FIELD_COMMON, ...FIELD_TYPE_FIELDS[field.type]]);
|
||
for (const key of Object.keys(field)) if (!allowed.has(key)) fail('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Field type '${field.type}' declares no '${key}'.`);
|
||
if (field.enabled !== undefined) {
|
||
validateValueSpec(document, field.enabled, `${path}.enabled`, errors);
|
||
if (typeof field.enabled !== 'boolean' && !isRecord(field.enabled)) {
|
||
fail('ERR_TYPE_MISMATCH', `${path}.enabled`, 'enabled must be a boolean.');
|
||
}
|
||
}
|
||
if (field.type === 'noise') {
|
||
const mode = field.mode ?? 'curl';
|
||
if (!['curl', 'gradient', 'value'].includes(mode)) fail('ERR_SCHEMA_VALIDATION', `${path}.mode`, `Unsupported noise mode '${field.mode}'.`);
|
||
// 18.7 (A2): `direction` is required under `value` and unknown otherwise.
|
||
if (mode === 'value' && field.direction === undefined) fail('ERR_SCHEMA_VALIDATION', `${path}.direction`, "A 'value' noise field requires a direction.");
|
||
if (mode !== 'value' && field.direction !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.direction`, `direction is not declared under noise mode '${mode}'.`);
|
||
if (field.octaves !== undefined && (!Number.isInteger(field.octaves) || field.octaves < 1 || field.octaves > 4)) fail('ERR_OUT_OF_BOUNDS', `${path}.octaves`, 'octaves must be an integer from 1 to 4.');
|
||
if (field.persistence !== undefined && (!Number.isFinite(field.persistence) || field.persistence < 0 || field.persistence > 1)) fail('ERR_OUT_OF_BOUNDS', `${path}.persistence`, 'persistence must be between 0 and 1.');
|
||
if (field.scale !== undefined && (!Number.isFinite(field.scale) || field.scale <= 0)) fail('ERR_OUT_OF_BOUNDS', `${path}.scale`, 'scale must be above 0.');
|
||
}
|
||
}
|
||
return new Set(ids);
|
||
}
|
||
|
||
function validateSystems(document, systems, layers, fields, errors, { validateValueSpec, pushError }) {
|
||
const fail = (code, path, message) => pushError(errors, code, path, message);
|
||
if (systems === undefined) return {};
|
||
if (!isRecord(systems)) {
|
||
fail('ERR_SCHEMA_VALIDATION', '$.visuals.systems', 'systems must be an object.');
|
||
return {};
|
||
}
|
||
const ids = Object.keys(systems);
|
||
if (ids.length > AUTHORING.declaredSystems) fail('ERR_VISUAL_LIMIT_EXCEEDED', '$.visuals.systems', `At most ${AUTHORING.declaredSystems} declared visual systems.`);
|
||
for (const [id, system] of Object.entries(systems)) {
|
||
const path = `$.visuals.systems.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', path, `System ID '${id}' is invalid.`);
|
||
if (!isRecord(system)) { fail('ERR_SCHEMA_VALIDATION', path, 'System must be an object.'); continue; }
|
||
if (!VISUAL_SYSTEM_TYPES.includes(system.type)) fail('ERR_INVALID_SYSTEM_TYPE', `${path}.type`, `Unsupported visual system type '${system.type}'.`);
|
||
|
||
// 17.7 (V17): the presence of the layer map decides whether `layer` is required.
|
||
if (layers) {
|
||
if (system.layer === undefined) fail('ERR_INVALID_REFERENCE', `${path}.layer`, 'A system must name a declared layer when visuals.layers is present.');
|
||
else if (!layers.has(system.layer)) fail('ERR_INVALID_REFERENCE', `${path}.layer`, `Layer '${system.layer}' is not declared.`);
|
||
} else if (system.layer !== undefined) {
|
||
fail('ERR_INVALID_REFERENCE', `${path}.layer`, 'No layers are declared, so there is no layer to name.');
|
||
}
|
||
|
||
if (system.visible !== undefined) validateValueSpec(document, system.visible, `${path}.visible`, errors);
|
||
// C11: Type-check boolean leaves (§2: no coercion).
|
||
if (system.visible !== undefined && typeof system.visible !== 'boolean' && !isRecord(system.visible)) {
|
||
fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.');
|
||
}
|
||
|
||
const lifecycle = system.lifecycle ?? 'persistent';
|
||
if (!LIFECYCLE_MODES.includes(lifecycle)) fail('ERR_SCHEMA_VALIDATION', `${path}.lifecycle`, `Unsupported lifecycle '${system.lifecycle}'.`);
|
||
|
||
// 17.7 (A1): the four spawn-only names never appear at a system's top
|
||
// level; `lifetime` does, but only where the type's own table declares it
|
||
// as the per-item duration.
|
||
for (const field of Object.keys(system)) {
|
||
if (SPAWN_ONLY_AT_TOP_LEVEL.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `'${field}' is a field of the spawn container, not of the system.`);
|
||
}
|
||
if (system.lifetime !== undefined && !TYPES_WITH_ITEM_LIFETIME.has(system.type)) {
|
||
fail('ERR_UNKNOWN_FIELD', `${path}.lifetime`, `A '${system.type}' system declares no lifetime; a spawned instance's duration is spawn.lifetime.`);
|
||
}
|
||
validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError });
|
||
// C9: Enforce per-type required fields at import (§18.2/18.4).
|
||
if (system.type === 'particles' && system.render === undefined) {
|
||
fail('ERR_SCHEMA_VALIDATION', `${path}.render`, "A 'particles' system requires 'render'.");
|
||
}
|
||
if (system.type === 'emitter' && system.emit === undefined) {
|
||
fail('ERR_SCHEMA_VALIDATION', `${path}.emit`, "An 'emitter' system requires 'emit'.");
|
||
}
|
||
// C9: Enforce the static half of the unbounded-emission rule at import (§18.2/18.4).
|
||
const emitsStatically = (typeof system.rate === 'number' ? system.rate > 0 : system.rate !== undefined) || (Array.isArray(system.burst) && system.burst.length > 0);
|
||
if ((system.type === 'particles' || system.type === 'emitter') && emitsStatically && system.lifetime === undefined && system.limit === undefined) {
|
||
fail('ERR_UNBOUNDED_EMISSION', path, 'Emission declares neither a lifetime nor a limit.');
|
||
}
|
||
|
||
if (system.automation !== undefined && !Array.isArray(system.automation)) fail('ERR_SCHEMA_VALIDATION', `${path}.automation`, 'automation must be an array.');
|
||
// 18.8: an emitter's population changes continuously, so its link set
|
||
// would have to be rebuilt every tick at the cost the ceiling prevents.
|
||
if (system.type === 'emitter' && system.links !== undefined) {
|
||
fail('ERR_UNKNOWN_FIELD', `${path}.links`, 'An emitter declares no links.');
|
||
}
|
||
if (system.type === 'graphic') {
|
||
for (const field of ['render', 'emit', 'repeat', 'capacity', 'count', 'rate', 'burst', 'limit', 'distribution', 'velocity', 'acceleration', 'drag', 'trail', 'links']) {
|
||
if (system[field] !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A graphic system declares no '${field}'.`);
|
||
}
|
||
}
|
||
if (system.type === 'repeater') {
|
||
// C8: Reject emitter/particle-only fields on repeaters (§18.5).
|
||
for (const field of ['rate', 'burst', 'limit', 'capacity', 'trail']) {
|
||
if (system[field] !== undefined) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A repeater system declares no '${field}'.`);
|
||
}
|
||
}
|
||
if (Array.isArray(system.behaviors) && system.behaviors.length > AUTHORING.behaviorsPerObject) {
|
||
fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.behaviors`, `At most ${AUTHORING.behaviorsPerObject} behaviors.`);
|
||
}
|
||
if (Array.isArray(system.behaviors)) {
|
||
// C2: Run semantic validation on system-level behavior arrays at import.
|
||
validateBehaviors(document, system, path, errors, { validateValueSpec, pushError }, { fields });
|
||
}
|
||
|
||
// The drawn object tree. 18.2, 18.4, and 18.5 each say the owning system
|
||
// owns the item's placement, so those fields are unknown on the template.
|
||
if (system.type === 'graphic') validateVisualObjectMap(document, system.content, `${path}.content`, errors, { validateValueSpec, pushError }, { fields });
|
||
if (system.type === 'particles' && system.render !== undefined) validateVisualObject(document, system.render, `${path}.render`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'], fields });
|
||
if (system.type === 'emitter' && system.emit !== undefined) validateVisualObject(document, system.emit, `${path}.emit`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z', 'visible'], fields });
|
||
if (system.type === 'repeater' && system.repeat !== undefined) validateVisualObject(document, system.repeat, `${path}.repeat`, errors, { validateValueSpec, pushError }, { ownedByOwner: ['position', 'z'], fields });
|
||
if (Array.isArray(system.fields)) {
|
||
if (system.fields.length > AUTHORING.referencedFieldsPerSystem) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.fields`, `A system may reference at most ${AUTHORING.referencedFieldsPerSystem} fields.`);
|
||
system.fields.forEach((reference, index) => {
|
||
if (!fields.has(reference)) fail('ERR_INVALID_REFERENCE', `${path}.fields[${index}]`, `Field '${reference}' is not declared.`);
|
||
});
|
||
}
|
||
}
|
||
return systems;
|
||
}
|
||
|
||
function validateSpawn(document, system, lifecycle, path, errors, { validateValueSpec, pushError }) {
|
||
const fail = (code, location, message) => pushError(errors, code, location, message);
|
||
const spawn = system.spawn;
|
||
if (spawn === undefined) return;
|
||
if (lifecycle !== 'spawned') return fail('ERR_UNKNOWN_FIELD', `${path}.spawn`, 'A persistent system has no spawn container.');
|
||
if (!isRecord(spawn)) return fail('ERR_SCHEMA_VALIDATION', `${path}.spawn`, 'spawn must be an object.');
|
||
for (const field of Object.keys(spawn)) {
|
||
if (!SPAWN_FIELDS.includes(field)) fail('ERR_UNKNOWN_FIELD', `${path}.spawn.${field}`, `Unrecognized spawn field '${field}'.`);
|
||
}
|
||
for (const field of ['lifetime', 'release']) {
|
||
const value = spawn[field];
|
||
if (value === undefined) continue;
|
||
// C5 (6.1): a duration is a literal string, or a non-negative finite number
|
||
// already expressed in milliseconds. Both forms reach `parseDuration`.
|
||
const literal = typeof value === 'string' && DURATION_PATTERN.test(value);
|
||
const milliseconds = typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||
if (!literal && !milliseconds) fail('ERR_INVALID_DURATION', `${path}.spawn.${field}`, `spawn.${field} must be a duration literal or a non-negative number of milliseconds.`);
|
||
}
|
||
if (spawn.ownership !== undefined && spawn.ownership !== 'persistent') fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.ownership`, "spawn.ownership accepts only 'persistent'.");
|
||
if (spawn.cancelWithScenario !== undefined) {
|
||
if (typeof spawn.cancelWithScenario !== 'boolean') fail('ERR_TYPE_MISMATCH', `${path}.spawn.cancelWithScenario`, 'spawn.cancelWithScenario must be a boolean.');
|
||
// 19.2 (V23): severing the originating-scenario relationship requires that
|
||
// the instance's resources belong to the performance root.
|
||
else if (spawn.cancelWithScenario === false && spawn.ownership !== 'persistent') fail('ERR_UNSUPPORTED_TARGET', `${path}.spawn.cancelWithScenario`, "cancelWithScenario: false requires ownership: 'persistent'.");
|
||
}
|
||
if (spawn.inputs !== undefined && !isRecord(spawn.inputs)) fail('ERR_SCHEMA_VALIDATION', `${path}.spawn.inputs`, 'spawn.inputs must be an object.');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Visual automation (19.1)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function validateAutomationArray(document, tracks, path, scope, errors, helpers) {
|
||
const { validateValueSpec, pushError } = helpers;
|
||
const fail = (code, location, message) => pushError(errors, code, location, message);
|
||
if (tracks === undefined) return { tracks: 0, points: 0 };
|
||
if (!Array.isArray(tracks)) {
|
||
fail('ERR_SCHEMA_VALIDATION', path, 'automation must be an array.');
|
||
return { tracks: 0, points: 0 };
|
||
}
|
||
let points = 0;
|
||
const written = new Set();
|
||
tracks.forEach((track, index) => {
|
||
const location = `${path}[${index}]`;
|
||
if (!isRecord(track)) return fail('ERR_SCHEMA_VALIDATION', location, 'Automation track must be an object.');
|
||
for (const field of Object.keys(track)) if (!TRACK_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${location}.${field}`, `Unrecognized automation field '${field}'.`);
|
||
if (track.mode !== undefined && !AUTOMATION_MODES.includes(track.mode)) fail('ERR_SCHEMA_VALIDATION', `${location}.mode`, `Unsupported automation mode '${track.mode}'.`);
|
||
if (track.interpolation !== undefined && !AUTOMATION_CURVES.includes(track.interpolation)) fail('ERR_SCHEMA_VALIDATION', `${location}.interpolation`, `Unsupported interpolation '${track.interpolation}'.`);
|
||
|
||
if (track.loop !== undefined) {
|
||
const loop = track.loop;
|
||
if (!isRecord(loop)) fail('ERR_SCHEMA_VALIDATION', `${location}.loop`, 'loop must be an object.');
|
||
else {
|
||
for (const field of Object.keys(loop)) if (field !== 'mode' && field !== 'count') fail('ERR_UNKNOWN_FIELD', `${location}.loop.${field}`, `Unrecognized loop field '${field}'.`);
|
||
if (!LOOP_MODES.includes(loop.mode)) fail('ERR_SCHEMA_VALIDATION', `${location}.loop.mode`, `Unsupported loop mode '${loop.mode}'.`);
|
||
// 19.1 (V12): `infinite` is legal in every scope, bounded by whatever
|
||
// owns the track, so no scope check belongs here.
|
||
if (loop.count !== undefined && loop.count !== 'infinite' && (!Number.isInteger(loop.count) || loop.count < 1)) {
|
||
fail('ERR_OUT_OF_BOUNDS', `${location}.loop.count`, "loop.count must be 'infinite' or an integer of at least 1.");
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!Array.isArray(track.points) || track.points.length < 2) fail('ERR_SCHEMA_VALIDATION', `${location}.points`, 'A track requires at least 2 points.');
|
||
else {
|
||
if (track.points.length > AUTHORING.automationPointsPerTrack) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${location}.points`, `At most ${AUTHORING.automationPointsPerTrack} points per track.`);
|
||
points += track.points.length;
|
||
let previous = -Infinity;
|
||
track.points.forEach((point, pointIndex) => {
|
||
const pointPath = `${location}.points[${pointIndex}]`;
|
||
if (!isRecord(point)) return fail('ERR_SCHEMA_VALIDATION', pointPath, 'Automation point must be an object.');
|
||
for (const field of Object.keys(point)) if (field !== 'at' && field !== 'value') fail('ERR_UNKNOWN_FIELD', `${pointPath}.${field}`, `Unrecognized point field '${field}'.`);
|
||
// 19.1: `at` is a duration literal only, never a procedural TimeSpec,
|
||
// so the strictly-increasing rule stays decidable at import.
|
||
if (typeof point.at !== 'string' || !DURATION_PATTERN.test(point.at)) fail('ERR_INVALID_DURATION', `${pointPath}.at`, 'Point at must be a duration literal.');
|
||
else {
|
||
const milliseconds = trackPointMilliseconds(point.at);
|
||
if (milliseconds <= previous) fail('ERR_INVALID_RANGE_ORDER', `${pointPath}.at`, 'Automation point times must be strictly increasing.');
|
||
previous = milliseconds;
|
||
}
|
||
if (!Object.hasOwn(point, 'value')) fail('ERR_SCHEMA_VALIDATION', `${pointPath}.value`, 'Automation point requires a value.');
|
||
else validateValueSpec(document, point.value, `${pointPath}.value`, errors);
|
||
});
|
||
}
|
||
|
||
const resolution = resolveAutomationTarget(track.target, scope);
|
||
if (resolution.code) fail(resolution.code, `${location}.target`, resolution.message);
|
||
else if (written.has(resolution.key)) fail('ERR_AUTOMATION_CONFLICT', `${location}.target`, `More than one track controls '${track.target}'.`);
|
||
else {
|
||
written.add(resolution.key);
|
||
if (scope.system?.type === 'graphic') {
|
||
const parts = track.target.split('.');
|
||
let object = scope.system.content?.[parts.shift()];
|
||
while (object?.children?.[parts[0]]) object = object.children[parts.shift()];
|
||
const property = parts.join('.');
|
||
if ((object?.behaviors ?? []).some(behavior => behaviorChannels(behavior).some(channel => channel === property || (channel === 'points[*].x' && /^points\[\d+\]\.x$/.test(property)) || (channel === 'points[*].y' && /^points\[\d+\]\.y$/.test(property))))) {
|
||
fail('ERR_AUTOMATION_CONFLICT', `${location}.target`, 'A behavior and automation write the same channel.');
|
||
}
|
||
}
|
||
}
|
||
});
|
||
return { tracks: tracks.length, points };
|
||
}
|
||
|
||
function trackPointMilliseconds(value) {
|
||
const [, scalar, unit] = DURATION_PATTERN.exec(value);
|
||
return Number(scalar) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit];
|
||
}
|
||
|
||
/**
|
||
* 19.1: two declaration scopes, and a track in one naming a target in the other
|
||
* is ERR_INVALID_REFERENCE. A target that exists but exposes no automation
|
||
* stage — a repeater property, a boolean, a color — is ERR_UNSUPPORTED_TARGET.
|
||
*/
|
||
function resolveAutomationTarget(target, scope) {
|
||
if (typeof target !== 'string' || target.length === 0) {
|
||
return { code: 'ERR_SCHEMA_VALIDATION', message: 'A track requires a target.' };
|
||
}
|
||
const context = scope.context;
|
||
const head = target.split('.')[0].replace(/\[\d+\]$/, '');
|
||
|
||
if (scope.scope === 'exhibit') {
|
||
if (head === 'systems') return { code: 'ERR_INVALID_REFERENCE', message: 'Exhibit-scope automation never reaches into a system; declare the track on the system.' };
|
||
if (head === 'scene') {
|
||
if (!['scene.depthFog.near', 'scene.depthFog.far', 'scene.depthFog.density'].includes(target)) {
|
||
return { code: 'ERR_UNSUPPORTED_TARGET', message: `Scene property '${target}' is not automatable.` };
|
||
}
|
||
return { key: target };
|
||
}
|
||
if (head === 'layers') {
|
||
const parts = target.split('.');
|
||
if (parts.length !== 3 || !context.layers?.has(parts[1])) return { code: 'ERR_INVALID_REFERENCE', message: `Layer '${parts[1]}' is not declared.` };
|
||
if (parts[2] !== 'opacity' && parts[2] !== 'parallax') return { code: 'ERR_UNSUPPORTED_TARGET', message: `Layer property '${parts[2]}' is not automatable.` };
|
||
return { key: target };
|
||
}
|
||
if (head === 'camera') {
|
||
const parts = target.split('.');
|
||
// 19.3: `projection` is authored once — not automatable, not bindable.
|
||
if (parts.length !== 2 || !Object.hasOwn(CAMERA_FIELDS, parts[1])) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Camera property '${target}' is not automatable.` };
|
||
return { key: target };
|
||
}
|
||
if (head === 'effects') {
|
||
const match = EFFECT_INDEX.exec(target);
|
||
if (!match) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Effect target '${target}' is not automatable.` };
|
||
const entry = context.effects?.[Number(match[1])];
|
||
if (!entry) return { code: 'ERR_INVALID_REFERENCE', message: `Effect index ${match[1]} is outside the authored chain.` };
|
||
if (!Object.hasOwn(POST_EFFECTS[entry.type]?.numeric ?? {}, match[2])) return { code: 'ERR_UNSUPPORTED_TARGET', message: `Effect '${entry.type}' has no numeric parameter '${match[2]}'.` };
|
||
return { key: target };
|
||
}
|
||
return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' is not in the automatable registry.` };
|
||
}
|
||
|
||
// System scope. Targets are relative to the owning system.
|
||
if (['scene', 'layers', 'camera', 'effects'].includes(head)) {
|
||
return { code: 'ERR_INVALID_REFERENCE', message: 'System-scope automation never reaches exhibit-scope properties.' };
|
||
}
|
||
if (head === 'systems') return { code: 'ERR_INVALID_REFERENCE', message: 'No track may address another system.' };
|
||
const type = scope.system?.type;
|
||
if (type === 'graphic') return resolveGraphicTarget(target, scope.system);
|
||
const automatable = SYSTEM_AUTOMATABLE[type];
|
||
if (!automatable) return { code: 'ERR_UNSUPPORTED_TARGET', message: `A '${type}' system exposes no automatable property.` };
|
||
// 19.1 (V11): a repeater has no automatable property at all, `step` included.
|
||
if (!automatable.has(target)) return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' is not automatable on a '${type}' system.` };
|
||
return { key: `${scope.systemId}.${target}` };
|
||
}
|
||
|
||
function resolveGraphicTarget(target, system) {
|
||
const parts = target.split('.');
|
||
let container = system?.content;
|
||
let index = 0;
|
||
while (index < parts.length) {
|
||
if (!isRecord(container) || !Object.hasOwn(container, parts[index])) {
|
||
return { code: 'ERR_INVALID_REFERENCE', message: `'${target}' does not name an object in this system's content.` };
|
||
}
|
||
const object = container[parts[index]];
|
||
index += 1;
|
||
const remainder = parts.slice(index).join('.');
|
||
if (remainder.length === 0) return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${target}' names an object, not a numeric property of one.` };
|
||
if (GRAPHIC_NUMERIC_PROPERTIES.has(remainder) || POINT_PROPERTY.test(remainder)) return { key: target };
|
||
// Not a property tail, so the next segment must be a container key.
|
||
if (isRecord(object) && (object.type === 'group' || object.type === 'component') && isRecord(object.children)) {
|
||
container = object.children;
|
||
continue;
|
||
}
|
||
return { code: 'ERR_UNSUPPORTED_TARGET', message: `'${remainder}' is not a numeric object property.` };
|
||
}
|
||
return { code: 'ERR_INVALID_REFERENCE', message: `'${target}' does not name an object in this system's content.` };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Visual objects (17.9, 17.10, 17.12, 17.13)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const COMMON_OBJECT_FIELDS = ['type', 'position', 'z', 'transform', 'style', 'behaviors', 'visible', 'lifetime'];
|
||
|
||
/** Type-specific geometry fields, per the tables of 17.9 and 17.13. */
|
||
const OBJECT_TYPE_FIELDS = Object.freeze({
|
||
point: [],
|
||
line: ['to'],
|
||
polyline: ['points'],
|
||
polygon: ['points'],
|
||
rectangle: ['size'],
|
||
'rounded-rectangle': ['size', 'radius'],
|
||
ellipse: ['radius'],
|
||
arc: ['radius', 'startAngle', 'endAngle', 'direction'],
|
||
ring: ['radius', 'innerRadius', 'startAngle', 'endAngle', 'direction'],
|
||
path: ['commands', 'fillRule'],
|
||
bezier: ['c1', 'c2', 'to'],
|
||
spline: ['points', 'mode', 'closed', 'tension', 'fillRule'],
|
||
text: ['text', 'font', 'size', 'weight', 'italic', 'align', 'baseline', 'letterSpacing', 'maxWidth'],
|
||
group: ['children'],
|
||
component: ['component', 'inputs']
|
||
});
|
||
|
||
const REQUIRED_GEOMETRY = Object.freeze({
|
||
line: ['to'], polyline: ['points'], polygon: ['points'], rectangle: ['size'],
|
||
'rounded-rectangle': ['size', 'radius'], ellipse: ['radius'], arc: ['radius'], ring: ['radius'],
|
||
path: ['commands'], bezier: ['c1', 'c2', 'to'], spline: ['points'], text: ['text'],
|
||
group: ['children'], component: ['component']
|
||
});
|
||
|
||
/** 17.12: these four primitives have no interior. */
|
||
const STROKE_ONLY_TYPES = new Set(['line', 'polyline', 'arc', 'bezier']);
|
||
|
||
const POINT_BOUNDS = Object.freeze({ polyline: [2, 512], polygon: [3, 512], spline: [2, 256] });
|
||
|
||
const STYLE_FIELDS = new Set(['fill', 'stroke', 'strokeWidth', 'strokeCap', 'strokeJoin', 'strokeDash',
|
||
'strokeDashOffset', 'pointSize', 'opacity', 'blend', 'glow', 'shadow', 'blur', 'filters', 'clip', 'mask']);
|
||
const TRANSFORM_FIELDS = new Set(['translate', 'rotation', 'scale', 'skew', 'origin']);
|
||
const PATH_OPS = new Set(['move', 'line', 'quadratic', 'cubic', 'arc', 'close']);
|
||
|
||
function validateVisualObjectMap(document, container, path, errors, helpers, options = {}) {
|
||
const { pushError } = helpers;
|
||
if (!isRecord(container)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'A visual object container must be an object.');
|
||
if (Object.keys(container).length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'An empty object container is not a container.');
|
||
for (const [key, object] of Object.entries(container)) {
|
||
if (!ID_PATTERN.test(key)) pushError(errors, 'ERR_INVALID_ID', `${path}.${key}`, `Object key '${key}' is invalid.`);
|
||
// 18.6: a `morph` and a `follow-path` name a *sibling* key, so the
|
||
// container travels with the object being validated.
|
||
validateVisualObject(document, object, `${path}.${key}`, errors, helpers, { ...options, depth: options.depth ?? 1, siblings: container });
|
||
}
|
||
}
|
||
|
||
function validateVisualObject(document, object, path, errors, helpers, options = {}) {
|
||
const { validateValueSpec, pushError } = helpers;
|
||
const fail = (code, location, message) => pushError(errors, code, location, message);
|
||
if (!isRecord(object)) return fail('ERR_SCHEMA_VALIDATION', path, 'A visual object must be an object.');
|
||
const type = object.type;
|
||
if (!Object.hasOwn(OBJECT_TYPE_FIELDS, type)) return fail('ERR_INVALID_PRIMITIVE_TYPE', `${path}.type`, `'${type}' is outside Visual Primitive Set 0.1 and the component object type.`);
|
||
|
||
const depth = options.depth ?? 1;
|
||
if (depth > AUTHORING.groupNesting) fail('ERR_VISUAL_LIMIT_EXCEEDED', path, `Group nesting deeper than ${AUTHORING.groupNesting} levels.`);
|
||
|
||
// 17.8: a visual object carries no `id`; its container key is its identity.
|
||
// 17.10: `layer` is a system property, never an object one.
|
||
const allowed = new Set([...COMMON_OBJECT_FIELDS, ...OBJECT_TYPE_FIELDS[type]]);
|
||
const owned = new Set(options.ownedByOwner ?? []);
|
||
for (const field of Object.keys(object)) {
|
||
if (!allowed.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `A '${type}' object declares no '${field}'.`);
|
||
else if (owned.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.${field}`, `The owning system owns '${field}'; the drawn object does not declare it.`);
|
||
}
|
||
for (const field of REQUIRED_GEOMETRY[type] ?? []) {
|
||
if (object[field] === undefined) fail('ERR_SCHEMA_VALIDATION', `${path}.${field}`, `A '${type}' object requires '${field}'.`);
|
||
}
|
||
|
||
if (object.behaviors !== undefined) {
|
||
if (!Array.isArray(object.behaviors)) fail('ERR_SCHEMA_VALIDATION', `${path}.behaviors`, 'behaviors must be an array.');
|
||
else if (object.behaviors.length > AUTHORING.behaviorsPerObject) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.behaviors`, `At most ${AUTHORING.behaviorsPerObject} behaviors per object.`);
|
||
}
|
||
if (object.transform !== undefined) {
|
||
if (!isRecord(object.transform)) fail('ERR_SCHEMA_VALIDATION', `${path}.transform`, 'transform must be an object.');
|
||
else for (const field of Object.keys(object.transform)) if (!TRANSFORM_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.transform.${field}`, `Unrecognized transform field '${field}'.`);
|
||
}
|
||
|
||
const bounds = POINT_BOUNDS[type];
|
||
if (bounds && object.points !== undefined) {
|
||
if (!Array.isArray(object.points)) fail('ERR_SCHEMA_VALIDATION', `${path}.points`, 'points must be an array.');
|
||
else if (object.points.length < bounds[0]) fail('ERR_SCHEMA_VALIDATION', `${path}.points`, `A '${type}' needs at least ${bounds[0]} points.`);
|
||
else if (object.points.length > bounds[1]) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.points`, `At most ${bounds[1]} points.`);
|
||
}
|
||
if (type === 'spline') {
|
||
const mode = object.mode ?? 'catmull-rom';
|
||
if (!['catmull-rom', 'bezier', 'linear'].includes(mode)) fail('ERR_SCHEMA_VALIDATION', `${path}.mode`, `Unsupported spline mode '${mode}'.`);
|
||
// 17.13: `tension` is catmull-rom only.
|
||
if (object.tension !== undefined && mode !== 'catmull-rom') fail('ERR_UNKNOWN_FIELD', `${path}.tension`, "tension is declared only under the 'catmull-rom' mode.");
|
||
if (mode === 'bezier' && Array.isArray(object.points) && (object.points.length - 1) % 3 !== 0) {
|
||
fail('ERR_SCHEMA_VALIDATION', `${path}.points`, 'A bezier-mode spline needs 3n + 1 points.');
|
||
}
|
||
}
|
||
if (type === 'ring' && Number.isFinite(object.innerRadius) && Number.isFinite(object.radius) && object.innerRadius >= object.radius) {
|
||
fail('ERR_INVALID_RANGE_ORDER', `${path}.innerRadius`, 'innerRadius must be less than radius.');
|
||
}
|
||
if ((type === 'arc' || type === 'ring') && object.direction !== undefined && !['clockwise', 'counter-clockwise'].includes(object.direction)) {
|
||
fail('ERR_SCHEMA_VALIDATION', `${path}.direction`, `Unsupported direction '${object.direction}'.`);
|
||
}
|
||
if ((type === 'arc' || type === 'ring') && Number.isFinite(object.startAngle) && Number.isFinite(object.endAngle)) {
|
||
const delta = (object.direction === 'counter-clockwise' ? object.startAngle - object.endAngle : object.endAngle - object.startAngle);
|
||
// 17.9: the 360 bound is checked on the authored numbers, before normalization.
|
||
if (Math.abs(delta) > 360) fail('ERR_OUT_OF_BOUNDS', `${path}.endAngle`, 'An arc sweep may not exceed 360 degrees.');
|
||
}
|
||
if (type === 'path') validatePathCommands(object.commands, `${path}.commands`, errors, helpers);
|
||
if (type === 'text' && typeof object.font === 'string' && !['sans-serif', 'serif', 'monospace'].includes(object.font)) {
|
||
fail('ERR_SCHEMA_VALIDATION', `${path}.font`, "font is limited to 'sans-serif', 'serif', and 'monospace' (13.3).");
|
||
}
|
||
|
||
validateVisualStyle(document, object, type, path, errors, helpers);
|
||
// C11: Type-check boolean leaves for visual objects.
|
||
if (object.visible !== undefined && typeof object.visible !== 'boolean' && !isRecord(object.visible)) {
|
||
fail('ERR_TYPE_MISMATCH', `${path}.visible`, 'visible must be a boolean.');
|
||
}
|
||
validateBehaviors(document, object, path, errors, helpers, options);
|
||
|
||
if (type === 'group') {
|
||
validateVisualObjectMap(document, object.children, `${path}.children`, errors, helpers, { ...options, depth: depth + 1, ownedByOwner: [], siblings: object.children });
|
||
if (object.style?.mask !== undefined && object.style.mask !== null) {
|
||
if (!isRecord(object.children) || !Object.hasOwn(object.children, object.style.mask)) {
|
||
fail('ERR_INVALID_REFERENCE', `${path}.style.mask`, `mask '${object.style.mask}' names no child of this group.`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateVisualStyle(document, object, type, path, errors, helpers) {
|
||
const { validateValueSpec, pushError } = helpers;
|
||
const fail = (code, location, message) => pushError(errors, code, location, message);
|
||
const style = object.style;
|
||
if (style === undefined) return;
|
||
if (!isRecord(style)) return fail('ERR_SCHEMA_VALIDATION', `${path}.style`, 'style must be an object.');
|
||
for (const field of Object.keys(style)) if (!STYLE_FIELDS.has(field)) fail('ERR_UNKNOWN_FIELD', `${path}.style.${field}`, `Unrecognized style field '${field}'.`);
|
||
// 17.12: a `fill` *declared* on a stroke-only primitive is an error; one
|
||
// *inherited* is ignored for it, which is a runtime rule, not a validation one.
|
||
if (STROKE_ONLY_TYPES.has(type) && Object.hasOwn(style, 'fill')) {
|
||
fail('ERR_UNKNOWN_FIELD', `${path}.style.fill`, `A '${type}' has no interior, so it declares no fill.`);
|
||
}
|
||
// 17.12: `mask` is legal only on a group.
|
||
if (Object.hasOwn(style, 'mask') && type !== 'group') fail('ERR_UNKNOWN_FIELD', `${path}.style.mask`, 'mask is declared only on a group.');
|
||
if (style.blend !== undefined && !BLEND_MODES.includes(style.blend)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.blend`, `Unsupported blend mode '${style.blend}'.`);
|
||
if (style.strokeCap !== undefined && !['butt', 'round', 'square'].includes(style.strokeCap)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeCap`, `Unsupported strokeCap '${style.strokeCap}'.`);
|
||
if (style.strokeJoin !== undefined && !['miter', 'round', 'bevel'].includes(style.strokeJoin)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeJoin`, `Unsupported strokeJoin '${style.strokeJoin}'.`);
|
||
if (style.strokeDash !== undefined) {
|
||
if (!Array.isArray(style.strokeDash) || style.strokeDash.length < 1) fail('ERR_SCHEMA_VALIDATION', `${path}.style.strokeDash`, 'strokeDash must be a non-empty array.');
|
||
else if (style.strokeDash.length > AUTHORING.strokeDashEntries) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.style.strokeDash`, `At most ${AUTHORING.strokeDashEntries} strokeDash entries.`);
|
||
}
|
||
if (style.filters !== undefined) {
|
||
if (!Array.isArray(style.filters)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.filters`, 'filters must be an array.');
|
||
else if (style.filters.length > AUTHORING.filtersPerObject) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.style.filters`, `At most ${AUTHORING.filtersPerObject} filters per object.`);
|
||
else style.filters.forEach((entry, index) => {
|
||
if (!isRecord(entry) || !FILTER_TYPES.includes(entry.type)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.filters[${index}].type`, 'Unsupported filter type.');
|
||
});
|
||
}
|
||
for (const role of ['fill', 'stroke']) validatePaint(document, style[role], `${path}.style.${role}`, errors, helpers);
|
||
for (const role of ['glow', 'shadow']) {
|
||
const entry = style[role];
|
||
if (entry === undefined || entry === null) continue;
|
||
if (!isRecord(entry)) fail('ERR_SCHEMA_VALIDATION', `${path}.style.${role}`, `${role} must be an object or null.`);
|
||
else if (entry.color !== undefined) validateValueSpec(document, entry.color, `${path}.style.${role}.color`, errors);
|
||
}
|
||
if (isRecord(style.clip) && !['rectangle', 'ellipse'].includes(style.clip.shape)) {
|
||
fail('ERR_SCHEMA_VALIDATION', `${path}.style.clip.shape`, "A clip shape is 'rectangle' or 'ellipse'.");
|
||
}
|
||
}
|
||
|
||
function validatePaint(document, paint, path, errors, helpers) {
|
||
const { validateValueSpec, pushError } = helpers;
|
||
if (paint === undefined || paint === null) return;
|
||
if (!isRecord(paint) || Object.hasOwn(paint, 'ref') || Object.hasOwn(paint, 'random') || Object.hasOwn(paint, 'choose') || Object.hasOwn(paint, 'op')) {
|
||
// 17.12 (V5): a color leaf is a ValueSpec<color>; it never returns a paint object.
|
||
return validateValueSpec(document, paint, path, errors);
|
||
}
|
||
const fail = (code, location, message) => pushError(errors, code, location, message);
|
||
if (!['linear-gradient', 'radial-gradient', 'conic-gradient'].includes(paint.type)) {
|
||
return fail('ERR_SCHEMA_VALIDATION', `${path}.type`, `Unsupported paint type '${paint.type}'.`);
|
||
}
|
||
if (!Array.isArray(paint.stops) || paint.stops.length < 2) return fail('ERR_SCHEMA_VALIDATION', `${path}.stops`, 'A gradient needs at least 2 stops.');
|
||
if (paint.stops.length > AUTHORING.gradientStops) fail('ERR_VISUAL_LIMIT_EXCEEDED', `${path}.stops`, `At most ${AUTHORING.gradientStops} gradient stops.`);
|
||
let previous = -Infinity;
|
||
paint.stops.forEach((stop, index) => {
|
||
if (!isRecord(stop) || !Number.isFinite(stop.offset)) return fail('ERR_SCHEMA_VALIDATION', `${path}.stops[${index}]`, 'A stop needs a numeric offset and a color.');
|
||
if (stop.offset < 0 || stop.offset > 1) fail('ERR_OUT_OF_BOUNDS', `${path}.stops[${index}].offset`, 'A stop offset is between 0 and 1.');
|
||
if (stop.offset <= previous) fail('ERR_INVALID_RANGE_ORDER', `${path}.stops[${index}].offset`, 'Stop offsets must be strictly increasing.');
|
||
previous = stop.offset;
|
||
validateValueSpec(document, stop.color, `${path}.stops[${index}].color`, errors);
|
||
});
|
||
}
|
||
|
||
function validatePathCommands(commands, path, errors, { pushError }) {
|
||
const fail = (code, location, message) => pushError(errors, code, location, message);
|
||
if (!Array.isArray(commands) || commands.length === 0) return fail('ERR_SCHEMA_VALIDATION', path, 'A path needs at least one command.');
|
||
if (commands.length > AUTHORING.pathCommands) fail('ERR_VISUAL_LIMIT_EXCEEDED', path, `At most ${AUTHORING.pathCommands} path commands.`);
|
||
if (!isRecord(commands[0]) || commands[0].op !== 'move') fail('ERR_INVALID_PATH', `${path}[0]`, 'A path must begin with a move command.');
|
||
let open = false;
|
||
commands.forEach((command, index) => {
|
||
if (!isRecord(command) || !PATH_OPS.has(command.op)) return fail('ERR_INVALID_PATH', `${path}[${index}].op`, `Unknown path command '${command?.op}'.`);
|
||
if (command.op === 'move') open = true;
|
||
else if (command.op === 'close') {
|
||
if (!open) fail('ERR_INVALID_PATH', `${path}[${index}]`, 'close has no open subpath.');
|
||
open = false;
|
||
}
|
||
if (command.op === 'arc') {
|
||
const radius = command.radius;
|
||
const components = typeof radius === 'number' ? [radius] : [radius?.x, radius?.y];
|
||
// 17.13: a zero or negative radius component is ERR_OUT_OF_BOUNDS. It is
|
||
// not silently degraded to a line.
|
||
if (components.some((value) => !Number.isFinite(value) || value <= 0)) {
|
||
fail('ERR_OUT_OF_BOUNDS', `${path}[${index}].radius`, 'An arc radius component must be above zero.');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Behaviors (18.6)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 18.6: the channels `oscillate`, `pulse`, and `twinkle` may name. */
|
||
const BEHAVIOR_PROPERTY_CHANNELS = new Set([
|
||
'position.x', 'position.y', 'z',
|
||
'transform.rotation', 'transform.scale.x', 'transform.scale.y',
|
||
'style.opacity', 'style.strokeWidth', 'style.pointSize',
|
||
'size.width', 'size.height', 'radius'
|
||
]);
|
||
|
||
/** Which primitives actually have each conditional channel (18.6). */
|
||
const CHANNEL_OWNERS = Object.freeze({
|
||
'size.width': new Set(['rectangle', 'rounded-rectangle']),
|
||
'size.height': new Set(['rectangle', 'rounded-rectangle']),
|
||
radius: new Set(['ellipse', 'arc', 'ring', 'rounded-rectangle']),
|
||
'style.pointSize': new Set(['point'])
|
||
});
|
||
|
||
/** 18.6: `point-wander` needs an owner with an addressable point list. */
|
||
const POINT_LIST_TYPES = new Set(['spline', 'polyline', 'polygon']);
|
||
|
||
/** 18.6/V15: morph interpolates a point list, so only these types participate. */
|
||
const MORPH_TYPES = new Set(['polyline', 'polygon', 'spline']);
|
||
const MORPH_PATH_OPS = new Set(['move', 'line', 'close']);
|
||
|
||
function pathOps(object) {
|
||
return (object.commands ?? []).map((command) => command?.op);
|
||
}
|
||
|
||
function morphPointCount(object) {
|
||
if (object.type === 'path') return pathOps(object).filter((op) => op !== 'close').length;
|
||
return (object.points ?? []).length;
|
||
}
|
||
|
||
function validateBehaviors(document, owner, path, errors, helpers, options = {}) {
|
||
const { pushError } = helpers;
|
||
const fail = (code, location, message) => pushError(errors, code, location, message);
|
||
const behaviors = owner.behaviors;
|
||
if (!Array.isArray(behaviors)) return;
|
||
behaviors.forEach((behavior, index) => {
|
||
const location = `${path}.behaviors[${index}]`;
|
||
if (!isRecord(behavior)) return fail('ERR_SCHEMA_VALIDATION', location, 'A behavior must be an object.');
|
||
if (!VISUAL_BEHAVIOR_TYPES.includes(behavior.type)) {
|
||
return fail('ERR_INVALID_BEHAVIOR_TYPE', `${location}.type`, `'${behavior.type}' is outside Visual Behavior Set 0.1.`);
|
||
}
|
||
if (['oscillate', 'pulse', 'twinkle'].includes(behavior.type)) {
|
||
const channel = behavior.property ?? (behavior.type === 'twinkle' ? 'style.opacity' : undefined);
|
||
if (channel === undefined) fail('ERR_SCHEMA_VALIDATION', `${location}.property`, `A '${behavior.type}' behavior requires a property.`);
|
||
else if (!BEHAVIOR_PROPERTY_CHANNELS.has(channel)) fail('ERR_INVALID_BEHAVIOR_TARGET', `${location}.property`, `'${channel}' is outside the behavior channel set.`);
|
||
else {
|
||
const owners = CHANNEL_OWNERS[channel];
|
||
if (owners && !owners.has(owner.type)) fail('ERR_INVALID_BEHAVIOR_TARGET', `${location}.property`, `A '${owner.type}' has no '${channel}'.`);
|
||
}
|
||
}
|
||
if (behavior.type === 'point-wander' && !POINT_LIST_TYPES.has(owner.type)) {
|
||
fail('ERR_INVALID_BEHAVIOR_TARGET', location, `point-wander requires a point list; a '${owner.type}' has none.`);
|
||
}
|
||
if (behavior.type === 'follow-path') {
|
||
const hasSpeed = behavior.speed !== undefined;
|
||
const hasDuration = behavior.duration !== undefined;
|
||
if (hasSpeed === hasDuration) fail('ERR_SCHEMA_VALIDATION', location, 'follow-path requires exactly one of speed and duration.');
|
||
}
|
||
if (behavior.type === 'field-follow') {
|
||
if (!options.fields?.has(behavior.field)) fail('ERR_INVALID_REFERENCE', `${location}.field`, `Field '${behavior.field}' is not declared.`);
|
||
}
|
||
if (behavior.type === 'morph') {
|
||
const siblings = options.siblings ?? {};
|
||
const target = siblings[behavior.to];
|
||
if (!isRecord(target)) fail('ERR_INVALID_REFERENCE', `${location}.to`, `morph target '${behavior.to}' names no sibling.`);
|
||
else if (!MORPH_TYPES.has(owner.type) || !MORPH_TYPES.has(target.type)) {
|
||
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph is defined only between point-list geometries.');
|
||
} else if (owner.type !== target.type) {
|
||
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same primitive type.');
|
||
} else if (morphPointCount(owner) !== morphPointCount(target)) {
|
||
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same point count.');
|
||
} else if (owner.type === 'spline' && (owner.mode ?? 'catmull-rom') !== (target.mode ?? 'catmull-rom')) {
|
||
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires the same spline mode.');
|
||
} else if (owner.type === 'path') {
|
||
const ours = pathOps(owner);
|
||
const theirs = pathOps(target);
|
||
if (ours.some((op) => !MORPH_PATH_OPS.has(op)) || theirs.some((op) => !MORPH_PATH_OPS.has(op))) {
|
||
fail('ERR_MORPH_INCOMPATIBLE', location, 'a curved path command carries control points a point list cannot express.');
|
||
} else if (ours.join(',') !== theirs.join(',')) {
|
||
fail('ERR_MORPH_INCOMPATIBLE', location, 'morph requires an identical path command sequence.');
|
||
}
|
||
}
|
||
// 18.6/V15: target points are read live, so a mutual pair has no order.
|
||
const back = (target?.behaviors ?? []).some((entry) => entry?.type === 'morph' && siblings[entry.to] === owner);
|
||
if (back) fail('ERR_CYCLIC_DEPENDENCY', location, 'two objects cannot morph onto each other.');
|
||
}
|
||
});
|
||
}
|
||
|
||
/* src/runtime/cadence-validation.js */
|
||
const CADENCE_CLASSES = Object.freeze([
|
||
'ambient',
|
||
'routine',
|
||
'intermittent',
|
||
'occasional',
|
||
'rare',
|
||
'scenario'
|
||
]);
|
||
|
||
const SOUND_USAGE = Object.freeze([
|
||
'automatic',
|
||
'manual',
|
||
'scenario'
|
||
]);
|
||
|
||
const ALLOWED_CADENCE_FIELDS = Object.freeze([
|
||
'intensity',
|
||
'minGap',
|
||
'clocks'
|
||
]);
|
||
|
||
const ALLOWED_CLOCK_CLASSES = Object.freeze([
|
||
'routine',
|
||
'intermittent',
|
||
'occasional',
|
||
'rare'
|
||
]);
|
||
|
||
const ALLOWED_SOUND_CADENCE_FIELDS = Object.freeze([
|
||
'class',
|
||
'weight',
|
||
'cooldown',
|
||
'overlap',
|
||
'when'
|
||
]);
|
||
|
||
const ALLOWED_EVENT_FIELDS = Object.freeze([
|
||
'inputs',
|
||
'actions'
|
||
]);
|
||
|
||
const ALLOWED_EVENT_INPUT_FIELDS = Object.freeze([
|
||
'type',
|
||
'default',
|
||
'min',
|
||
'max',
|
||
'step',
|
||
'values',
|
||
'label',
|
||
'unit'
|
||
]);
|
||
|
||
function validateCadenceSubsystem(document, errors, helpers = {}) {
|
||
const pushError = helpers.pushError ?? ((errs, code, path, message) => errs.push({ code, path, message }));
|
||
const validateValueSpec = helpers.validateValueSpec ?? (() => {});
|
||
const validateCondition = helpers.validateCondition ?? (() => {});
|
||
|
||
// 1. Validate top-level cadence container
|
||
if (document.cadence !== undefined) {
|
||
if (!isRecord(document.cadence)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.cadence', 'cadence must be an object.');
|
||
} else {
|
||
for (const field of Object.keys(document.cadence)) {
|
||
if (!ALLOWED_CADENCE_FIELDS.includes(field)) {
|
||
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.${field}`, `Unrecognized field '${field}' in cadence.`);
|
||
}
|
||
}
|
||
|
||
if (document.cadence.intensity !== undefined) {
|
||
validateValueSpec(document, document.cadence.intensity, '$.cadence.intensity', errors);
|
||
}
|
||
|
||
if (document.cadence.minGap !== undefined) {
|
||
if (typeof document.cadence.minGap !== 'string' || !DURATION_PATTERN.test(document.cadence.minGap)) {
|
||
pushError(errors, 'ERR_INVALID_DURATION', '$.cadence.minGap', 'minGap must be a valid DurationSpec string.');
|
||
}
|
||
}
|
||
|
||
if (document.cadence.clocks !== undefined) {
|
||
if (!isRecord(document.cadence.clocks)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.cadence.clocks', 'clocks must be an object.');
|
||
} else {
|
||
for (const [className, range] of Object.entries(document.cadence.clocks)) {
|
||
if (!ALLOWED_CLOCK_CLASSES.includes(className)) {
|
||
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.clocks.${className}`, `Unrecognized cadence clock class '${className}'.`);
|
||
} else if (!isRecord(range)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.cadence.clocks.${className}`, 'Clock range must be an object.');
|
||
} else {
|
||
for (const field of Object.keys(range)) {
|
||
if (field !== 'min' && field !== 'max') {
|
||
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.cadence.clocks.${className}.${field}`, `Unrecognized field '${field}' in clock range.`);
|
||
}
|
||
}
|
||
if (range.min === undefined || range.max === undefined) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.cadence.clocks.${className}`, 'Clock range requires min and max.');
|
||
} else {
|
||
const validMin = typeof range.min === 'string' && DURATION_PATTERN.test(range.min);
|
||
const validMax = typeof range.max === 'string' && DURATION_PATTERN.test(range.max);
|
||
if (!validMin) pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}.min`, 'min must be a valid DurationSpec.');
|
||
if (!validMax) pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}.max`, 'max must be a valid DurationSpec.');
|
||
if (validMin && validMax) {
|
||
try {
|
||
const minMs = parseDuration(range.min);
|
||
const maxMs = parseDuration(range.max);
|
||
if (minMs > maxMs) {
|
||
pushError(errors, 'ERR_INVALID_RANGE_ORDER', `$.cadence.clocks.${className}`, `Clock min (${range.min}) cannot exceed max (${range.max}).`);
|
||
}
|
||
} catch (e) {
|
||
pushError(errors, 'ERR_INVALID_DURATION', `$.cadence.clocks.${className}`, e.message);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Validate sound cadence metadata
|
||
if (document.sounds !== undefined && isRecord(document.sounds)) {
|
||
for (const [soundId, sound] of Object.entries(document.sounds)) {
|
||
if (!isRecord(sound)) continue;
|
||
if (sound.cadence !== undefined) {
|
||
if (!isRecord(sound.cadence)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence`, 'cadence must be an object.');
|
||
continue;
|
||
}
|
||
for (const field of Object.keys(sound.cadence)) {
|
||
if (!ALLOWED_SOUND_CADENCE_FIELDS.includes(field)) {
|
||
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.sounds.${soundId}.cadence.${field}`, `Unrecognized sound cadence field '${field}'.`);
|
||
}
|
||
}
|
||
if (sound.cadence.class === undefined) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.class`, 'cadence.class is required.');
|
||
} else if (!CADENCE_CLASSES.includes(sound.cadence.class)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.class`, `Invalid cadence class '${sound.cadence.class}'.`);
|
||
}
|
||
|
||
if (sound.cadence.weight !== undefined) {
|
||
if (typeof sound.cadence.weight === 'number') {
|
||
if (!Number.isFinite(sound.cadence.weight) || sound.cadence.weight < 0) {
|
||
pushError(errors, 'ERR_OUT_OF_BOUNDS', `$.sounds.${soundId}.cadence.weight`, 'Cadence weight cannot be negative.');
|
||
}
|
||
} else {
|
||
validateValueSpec(document, sound.cadence.weight, `$.sounds.${soundId}.cadence.weight`, errors);
|
||
}
|
||
}
|
||
|
||
if (sound.cadence.cooldown !== undefined) {
|
||
if (typeof sound.cadence.cooldown !== 'string' || !DURATION_PATTERN.test(sound.cadence.cooldown)) {
|
||
pushError(errors, 'ERR_INVALID_DURATION', `$.sounds.${soundId}.cadence.cooldown`, 'cooldown must be a valid DurationSpec.');
|
||
}
|
||
}
|
||
|
||
if (sound.cadence.overlap !== undefined && typeof sound.cadence.overlap !== 'boolean') {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.sounds.${soundId}.cadence.overlap`, 'overlap must be a boolean.');
|
||
}
|
||
|
||
if (sound.cadence.when !== undefined) {
|
||
validateCondition(document, sound.cadence.when, `$.sounds.${soundId}.cadence.when`, errors);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Validate events container & event definitions
|
||
const eventGraph = new Map();
|
||
if (document.events !== undefined) {
|
||
if (!isRecord(document.events)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.events', 'events must be an object.');
|
||
} else {
|
||
for (const [eventId, eventDef] of Object.entries(document.events)) {
|
||
if (!ID_PATTERN.test(eventId)) {
|
||
pushError(errors, 'ERR_INVALID_ID', `$.events.${eventId}`, `Invalid event identifier '${eventId}'.`);
|
||
}
|
||
if (!isRecord(eventDef)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}`, 'Event definition must be an object.');
|
||
continue;
|
||
}
|
||
|
||
eventGraph.set(eventId, new Set());
|
||
|
||
for (const field of Object.keys(eventDef)) {
|
||
if (!ALLOWED_EVENT_FIELDS.includes(field)) {
|
||
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.events.${eventId}.${field}`, `Unrecognized field '${field}' in event.`);
|
||
}
|
||
}
|
||
|
||
if (eventDef.inputs !== undefined) {
|
||
if (!isRecord(eventDef.inputs)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs`, 'Event inputs must be an object.');
|
||
} else {
|
||
for (const [inputId, inputSpec] of Object.entries(eventDef.inputs)) {
|
||
if (!ID_PATTERN.test(inputId)) {
|
||
pushError(errors, 'ERR_INVALID_ID', `$.events.${eventId}.inputs.${inputId}`, `Invalid input identifier '${inputId}'.`);
|
||
}
|
||
if (!isRecord(inputSpec)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs.${inputId}`, 'Input spec must be an object.');
|
||
} else {
|
||
for (const field of Object.keys(inputSpec)) {
|
||
if (!ALLOWED_EVENT_INPUT_FIELDS.includes(field)) {
|
||
pushError(errors, 'ERR_UNKNOWN_FIELD', `$.events.${eventId}.inputs.${inputId}.${field}`, `Unrecognized field '${field}' in input spec.`);
|
||
}
|
||
}
|
||
if (!PARAMETER_TYPES.includes(inputSpec.type)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.inputs.${inputId}.type`, `Invalid input type '${inputSpec.type}'.`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!Array.isArray(eventDef.actions) || eventDef.actions.length === 0) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.events.${eventId}.actions`, 'actions must be a non-empty array.');
|
||
} else {
|
||
validateEventActions(document, eventDef.actions, `$.events.${eventId}.actions`, eventId, errors, helpers, eventGraph, eventDef.inputs ?? {});
|
||
}
|
||
}
|
||
|
||
// 4. Static cycle detection in event dependency graph
|
||
const visiting = new Set();
|
||
const visited = new Set();
|
||
const detectCycle = (node, trail) => {
|
||
if (visiting.has(node)) {
|
||
const cycleTrail = [...trail, node].join(' -> ');
|
||
pushError(errors, 'ERR_CYCLIC_DEPENDENCY', `$.events.${node}`, `Cyclic event invocation detected: ${cycleTrail}.`);
|
||
return;
|
||
}
|
||
if (visited.has(node)) return;
|
||
visiting.add(node);
|
||
const targets = eventGraph.get(node) ?? new Set();
|
||
for (const target of targets) {
|
||
if (eventGraph.has(target)) {
|
||
detectCycle(target, [...trail, node]);
|
||
}
|
||
}
|
||
visiting.delete(node);
|
||
visited.add(node);
|
||
};
|
||
|
||
for (const eventId of eventGraph.keys()) {
|
||
detectCycle(eventId, []);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateEventActions(document, actions, path, owningEventId, errors, helpers, eventGraph, localInputs) {
|
||
const pushError = helpers.pushError ?? ((errs, code, p, m) => errs.push({ code, path: p, message: m }));
|
||
const validateValueSpec = helpers.validateValueSpec ?? (() => {});
|
||
const validateCondition = helpers.validateCondition ?? (() => {});
|
||
|
||
actions.forEach((action, index) => {
|
||
const actionPath = `${path}[${index}]`;
|
||
if (!isRecord(action)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', actionPath, 'Action must be an object.');
|
||
return;
|
||
}
|
||
if (typeof action.type !== 'string') {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.type`, 'Action requires a type.');
|
||
return;
|
||
}
|
||
if (action.id !== undefined && !ID_PATTERN.test(action.id)) {
|
||
pushError(errors, 'ERR_INVALID_ID', `${actionPath}.id`, `Invalid action identifier '${action.id}'.`);
|
||
}
|
||
if (action.when !== undefined) {
|
||
validateCondition(document, action.when, `${actionPath}.when`, errors);
|
||
}
|
||
if (action.chance !== undefined) {
|
||
if (typeof action.chance !== 'number' || !Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1) {
|
||
pushError(errors, 'ERR_OUT_OF_BOUNDS', `${actionPath}.chance`, 'Action chance must be between 0 and 1.');
|
||
}
|
||
}
|
||
if (action.critical !== undefined && typeof action.critical !== 'boolean') {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.critical`, 'critical must be a boolean.');
|
||
}
|
||
|
||
if (action.type === 'event') {
|
||
if (typeof action.event !== 'string') {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.event`, 'Event action requires an event identifier.');
|
||
} else {
|
||
if (!document.events?.[action.event]) {
|
||
pushError(errors, 'ERR_INVALID_REFERENCE', `${actionPath}.event`, `Referenced event '${action.event}' does not exist.`);
|
||
} else {
|
||
eventGraph.get(owningEventId)?.add(action.event);
|
||
}
|
||
if (action.with !== undefined) {
|
||
if (!isRecord(action.with)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.with`, 'with must be an object.');
|
||
} else {
|
||
for (const [paramName, valSpec] of Object.entries(action.with)) {
|
||
validateValueSpec(document, valSpec, `${actionPath}.with.${paramName}`, errors, { inputs: localInputs });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if (action.type === 'sound') {
|
||
if (typeof action.sound !== 'string') {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.sound`, 'Sound action requires a sound identifier.');
|
||
} else {
|
||
if (!document.sounds?.[action.sound]) {
|
||
pushError(errors, 'ERR_INVALID_REFERENCE', `${actionPath}.sound`, `Referenced sound '${action.sound}' does not exist.`);
|
||
}
|
||
if (action.with !== undefined && !isRecord(action.with)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.with`, 'with must be an object.');
|
||
}
|
||
if (action.ownership !== undefined && !['performance', 'scenario', 'persistent'].includes(action.ownership)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `${actionPath}.ownership`, `Invalid sound action ownership '${action.ownership}'.`);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* src/runtime/scenario-validation.js */
|
||
const SCENARIO_LIMITS = Object.freeze({ definitions: 64, active: 16, entries: 1024, repeats: 1024, cleanupMs: 5000 });
|
||
|
||
// Both import paths use this validator; no clocks or resources are created here.
|
||
function validateScenarioSubsystem(document, errors, helpers = {}) {
|
||
const fail = (code, path, message) => errors.push({ code, path, message });
|
||
const shape = (value, fields, path) => {
|
||
if (!isRecord(value)) { fail('ERR_SCHEMA_VALIDATION', path, 'Expected an object.'); return false; }
|
||
for (const key of Object.keys(value)) if (!fields.includes(key)) fail('ERR_UNKNOWN_FIELD', `${path}.${key}`, `Unknown field '${key}'.`);
|
||
return true;
|
||
};
|
||
const time = (value, path, positive = false, random = false) => {
|
||
if (random && isRecord(value)) {
|
||
if (!shape(value, ['random'], path) || !shape(value.random, ['min', 'max'], `${path}.random`)) return;
|
||
const min = time(value.random.min, `${path}.random.min`, positive), max = time(value.random.max, `${path}.random.max`, positive);
|
||
if (min > max) fail('ERR_INVALID_RANGE_ORDER', path, 'Time range is reversed.');
|
||
return;
|
||
}
|
||
try {
|
||
const ms = parseDuration(value, path);
|
||
if (!Number.isFinite(ms) || (positive && ms <= 0)) throw new Error('Duration must be finite and positive.');
|
||
return ms;
|
||
} catch (error) { fail('ERR_INVALID_DURATION', path, error.message); }
|
||
};
|
||
const condition = (value, path) => helpers.validateCondition?.(document, value, path, errors);
|
||
const value = (spec, path, inputs = {}) => helpers.validateValueSpec?.(document, spec, path, errors, { inputs, componentParameters: new Map(Object.entries(inputs)) });
|
||
const fields = {
|
||
set: ['target', 'value', 'transition'], override: ['target', 'value', 'scope', 'duration', 'priority', 'transition'],
|
||
event: ['event', 'with'], sound: ['sound', 'with', 'ownership'], spawn: ['target', 'with', 'ownership', 'lifetime'],
|
||
remove: ['target'], control: ['target', 'command']
|
||
};
|
||
const soundMode = sound => (sound?.recipe?.use ? document.audio?.recipes?.[sound.recipe.use]?.mode : sound?.recipe?.mode) ?? 'oneshot';
|
||
const actions = (list, path, hook = false, inputs = {}, scenario = true) => {
|
||
if (!Array.isArray(list)) { fail('ERR_SCHEMA_VALIDATION', path, 'Actions must be an array.'); return; }
|
||
list.forEach((action, index) => {
|
||
const p = `${path}[${index}]`;
|
||
if (!isRecord(action) || !fields[action.type]) { fail('ERR_SCHEMA_VALIDATION', p, 'Unsupported action type.'); return; }
|
||
shape(action, ['type', 'id', 'when', 'chance', 'critical', ...fields[action.type]], p);
|
||
if (action.id !== undefined && !ID_PATTERN.test(action.id)) fail('ERR_INVALID_ID', `${p}.id`, 'Invalid action ID.');
|
||
if (action.critical !== undefined && typeof action.critical !== 'boolean') fail('ERR_TYPE_MISMATCH', p, 'critical must be boolean.');
|
||
if (action.chance !== undefined && (!Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1)) fail('ERR_OUT_OF_BOUNDS', p, 'chance must be in [0,1].');
|
||
if (action.when !== undefined) condition(action.when, `${p}.when`);
|
||
if (hook && (!['set', 'sound'].includes(action.type) || (action.type === 'sound' && (soundMode(document.sounds?.[action.sound]) !== 'oneshot' || ['persistent', 'performance'].includes(action.ownership))))) fail('ERR_UNSUPPORTED_TARGET', p, 'Termination hooks permit only set and nonpersistent one-shot sound actions.');
|
||
if (['set', 'override'].includes(action.type)) {
|
||
const target = action.target;
|
||
const visual = matchVisualTarget(document, target);
|
||
const spec = typeof target === 'string' ? target.startsWith('state.') ? document.state?.[target.slice(6)] : target.startsWith('parameters.') ? document.parameters?.[target.slice(11)] : /^audio\.buses\.[^.]+\.gain$/.test(target) ? document.audio?.buses?.[target.split('.')[2]] && { type: 'number' } : visual && !visual.reason ? visual.spec : null : null;
|
||
if (!spec || (action.type === 'set' && !target.startsWith('state.'))) fail('ERR_UNSUPPORTED_TARGET', `${p}.target`, 'Target is not exposed for this action.');
|
||
value(action.value, `${p}.value`, inputs);
|
||
if (spec && !isRecord(action.value) && !valueMatchesType(spec.type, action.value, spec)) fail('ERR_TYPE_MISMATCH', `${p}.value`, 'Action value has the wrong type.');
|
||
if (action.type === 'override') {
|
||
if (!['scenario', 'duration'].includes(action.scope)) fail('ERR_SCHEMA_VALIDATION', p, 'Override scope is required.');
|
||
if (action.scope === 'duration') time(action.duration, `${p}.duration`, true);
|
||
else if (action.duration !== undefined) fail('ERR_SCHEMA_VALIDATION', p, 'Scenario override cannot declare duration.');
|
||
if (action.priority !== undefined && (!Number.isInteger(action.priority) || Math.abs(action.priority) > 1000)) fail('ERR_OUT_OF_BOUNDS', p, 'Override priority must be -1000 through 1000.');
|
||
}
|
||
if (action.transition !== undefined && shape(action.transition, action.type === 'set' ? ['duration', 'easing'] : ['in', 'out', 'easing'], `${p}.transition`)) {
|
||
for (const key of ['duration', 'in', 'out']) if (action.transition[key] !== undefined) time(action.transition[key], `${p}.transition.${key}`);
|
||
if (action.transition.easing !== undefined && !EASINGS.includes(action.transition.easing)) fail('ERR_INVALID_TRANSITION', p, 'Unknown easing.');
|
||
}
|
||
}
|
||
if (action.type === 'event' || action.type === 'sound') {
|
||
const def = action.type === 'event' ? document.events?.[action.event] : document.sounds?.[action.sound];
|
||
if (!def) fail('ERR_INVALID_REFERENCE', p, 'Unknown event or sound.');
|
||
if (scenario && action.type === 'sound' && def && !(def.usage ?? ['automatic']).includes('scenario')) fail('ERR_UNSUPPORTED_TARGET', p, 'Sound does not permit scenario usage.');
|
||
}
|
||
if (action.type === 'spawn') {
|
||
const def = document.visuals?.systems?.[action.target?.replace(/^visuals\.systems\./, '')];
|
||
if (!def || def.lifecycle !== 'spawned') fail('ERR_INVALID_REFERENCE', p, 'Spawn requires a spawned visual template.');
|
||
if (action.ownership === 'persistent' && def?.spawn?.ownership !== 'persistent') fail('ERR_UNSUPPORTED_TARGET', p, 'Template does not permit persistent ownership.');
|
||
if (action.lifetime !== undefined) value(action.lifetime, `${p}.lifetime`, inputs);
|
||
}
|
||
if (action.type === 'remove' && (typeof action.target !== 'string' || !action.target.startsWith('instances.'))) fail('ERR_UNSUPPORTED_TARGET', p, 'remove requires an instance ID.');
|
||
if (action.ownership !== undefined && !['scenario', 'persistent', ...(action.type === 'sound' ? ['performance'] : [])].includes(action.ownership)) fail('ERR_UNSUPPORTED_TARGET', p, 'Unsupported resource ownership.');
|
||
if (action.with !== undefined) {
|
||
if (isRecord(action.with)) for (const [key, spec] of Object.entries(action.with)) value(spec, `${p}.with.${key}`, inputs);
|
||
else fail('ERR_SCHEMA_VALIDATION', `${p}.with`, 'with must be an object.');
|
||
}
|
||
if (action.type === 'control') {
|
||
if (!/^scenarios\.[a-z][a-z0-9_-]*$/.test(action.target) || !document.scenarios?.[action.target?.slice(10)]) fail('ERR_INVALID_REFERENCE', p, 'Unknown scenario control target.');
|
||
if (!['start', 'stop', 'enable', 'disable'].includes(action.command)) fail('ERR_UNSUPPORTED_TARGET', p, 'Scenario controls support start, stop, enable, disable.');
|
||
}
|
||
});
|
||
};
|
||
// Strict action checking also closes the event-to-scenario dispatch boundary.
|
||
for (const [id, event] of Object.entries(document.events ?? {})) if (isRecord(event) && Array.isArray(event.actions)) actions(event.actions, `$.events.${id}.actions`, false, event.inputs, false);
|
||
if (document.scenarios === undefined) return;
|
||
if (!isRecord(document.scenarios)) { fail('ERR_SCHEMA_VALIDATION', '$.scenarios', 'scenarios must be an object.'); return; }
|
||
const definitions = Object.entries(document.scenarios);
|
||
if (definitions.length > SCENARIO_LIMITS.definitions) fail('ERR_OUT_OF_BOUNDS', '$.scenarios', 'At most 64 scenario definitions.');
|
||
for (const [id, def] of definitions) {
|
||
const p = `$.scenarios.${id}`;
|
||
if (!ID_PATTERN.test(id)) fail('ERR_INVALID_ID', p, 'Invalid scenario ID.');
|
||
if (!shape(def, ['name', 'enabled', 'priority', 'group', 'trigger', 'eligibility', 'concurrency', 'cooldown', 'duration', 'onStart', 'timeline', 'onComplete', 'onCancel', 'tags'], p)) continue;
|
||
if (def.name !== undefined && typeof def.name !== 'string') fail('ERR_TYPE_MISMATCH', `${p}.name`, 'name must be a string.');
|
||
if (def.enabled !== undefined && typeof def.enabled !== 'boolean') fail('ERR_TYPE_MISMATCH', `${p}.enabled`, 'enabled must be boolean.');
|
||
if (def.priority !== undefined && (!Number.isInteger(def.priority) || def.priority < 0 || def.priority > 100)) fail('ERR_OUT_OF_BOUNDS', `${p}.priority`, 'Scenario priority must be 0 through 100.');
|
||
if (def.group !== undefined && (typeof def.group !== 'string' || !ID_PATTERN.test(def.group))) fail('ERR_INVALID_ID', `${p}.group`, 'Invalid group ID.');
|
||
if (def.tags !== undefined && (!Array.isArray(def.tags) || def.tags.length > 16 || def.tags.some(t => typeof t !== 'string' || t.length > 32))) fail('ERR_TYPE_MISMATCH', `${p}.tags`, 'tags must contain at most 16 short strings.');
|
||
if (def.duration !== undefined) time(def.duration, `${p}.duration`, true);
|
||
if (def.cooldown !== undefined) time(def.cooldown, `${p}.cooldown`);
|
||
if (def.eligibility !== undefined && shape(def.eligibility, ['when', 'timeout'], `${p}.eligibility`)) {
|
||
if (def.eligibility.when !== undefined) condition(def.eligibility.when, `${p}.eligibility.when`);
|
||
if (def.eligibility.timeout !== undefined) time(def.eligibility.timeout, `${p}.eligibility.timeout`, true);
|
||
}
|
||
if (def.concurrency !== undefined && shape(def.concurrency, ['mode', 'scope', 'policy'], `${p}.concurrency`)) {
|
||
if (!['parallel', 'exclusive'].includes(def.concurrency.mode)) fail('ERR_SCHEMA_VALIDATION', `${p}.concurrency`, 'Expected parallel or exclusive mode.');
|
||
if (def.concurrency.scope !== undefined && !['group', 'global'].includes(def.concurrency.scope)) fail('ERR_SCHEMA_VALIDATION', p, 'Expected group or global scope.');
|
||
if (def.concurrency.policy !== undefined && !['defer', 'reject', 'replace'].includes(def.concurrency.policy)) fail('ERR_SCHEMA_VALIDATION', p, 'Expected defer, reject or replace policy.');
|
||
if (def.concurrency.scope === 'group' && !def.group) fail('ERR_SCHEMA_VALIDATION', p, 'Group scope requires group.');
|
||
}
|
||
const trigger = def.trigger ?? { type: 'manual' };
|
||
const triggerFields = { manual: [], once: ['at'], interval: ['every'], 'random-interval': ['min', 'max'], probability: ['every', 'chance'], condition: ['when', 'for'], event: ['event'] };
|
||
if (!isRecord(trigger) || !triggerFields[trigger.type]) fail('ERR_SCHEMA_VALIDATION', `${p}.trigger`, 'Unknown trigger type.');
|
||
else {
|
||
shape(trigger, ['type', ...triggerFields[trigger.type]], `${p}.trigger`);
|
||
if (trigger.type === 'once') time(trigger.at ?? '0ms', `${p}.trigger.at`);
|
||
if (['interval', 'probability'].includes(trigger.type)) time(trigger.every, `${p}.trigger.every`, true);
|
||
if (trigger.type === 'random-interval') time({ random: { min: trigger.min, max: trigger.max } }, `${p}.trigger`, true, true);
|
||
if (trigger.type === 'probability' && (!Number.isFinite(trigger.chance) || trigger.chance < 0 || trigger.chance > 1)) fail('ERR_OUT_OF_BOUNDS', p, 'Trigger chance must be in [0,1].');
|
||
if (trigger.type === 'condition') { condition(trigger.when, `${p}.trigger.when`); time(trigger.for ?? '0ms', `${p}.trigger.for`); }
|
||
if (trigger.type === 'event' && !document.events?.[trigger.event]) fail('ERR_INVALID_REFERENCE', p, 'Unknown trigger event.');
|
||
}
|
||
for (const key of ['onStart', 'onComplete', 'onCancel']) if (def[key] !== undefined) actions(def[key], `${p}.${key}`, key !== 'onStart');
|
||
if (!Array.isArray(def.timeline)) { fail('ERR_SCHEMA_VALIDATION', `${p}.timeline`, 'timeline is required.'); continue; }
|
||
if (def.timeline.length > SCENARIO_LIMITS.entries) fail('ERR_OUT_OF_BOUNDS', `${p}.timeline`, 'At most 1024 timeline entries.');
|
||
const ids = new Map();
|
||
def.timeline.forEach((entry, index) => {
|
||
const ep = `${p}.timeline[${index}]`;
|
||
if (!shape(entry, ['id', 'at', 'after', 'delay', 'repeat', 'actions', 'choose'], ep)) return;
|
||
if (entry.id !== undefined) {
|
||
if (!ID_PATTERN.test(entry.id) || ids.has(entry.id)) fail('ERR_INVALID_ID', ep, 'Timeline IDs must be valid and unique.');
|
||
ids.set(entry.id, index);
|
||
}
|
||
if ((entry.at !== undefined) === (entry.after !== undefined)) fail('ERR_SCHEMA_VALIDATION', ep, 'Use exactly one of at and after.');
|
||
if (entry.at !== undefined) { time(entry.at, `${ep}.at`); if (entry.delay !== undefined) fail('ERR_UNKNOWN_FIELD', `${ep}.delay`, 'delay requires after.'); }
|
||
else time(entry.delay ?? '0ms', `${ep}.delay`, false, true);
|
||
if (entry.repeat !== undefined && shape(entry.repeat, ['count', 'every'], `${ep}.repeat`)) {
|
||
if (!Number.isInteger(entry.repeat.count) || entry.repeat.count < 1 || entry.repeat.count > SCENARIO_LIMITS.repeats) fail('ERR_OUT_OF_BOUNDS', ep, 'Repeat count must be 1 through 1024, including the first beat.');
|
||
time(entry.repeat.every, `${ep}.repeat.every`, true, true);
|
||
}
|
||
if ((entry.actions !== undefined) === (entry.choose !== undefined)) fail('ERR_SCHEMA_VALIDATION', ep, 'Use exactly one of actions and choose.');
|
||
if (entry.actions !== undefined) actions(entry.actions, `${ep}.actions`);
|
||
if (entry.choose !== undefined) {
|
||
if (!Array.isArray(entry.choose) || !entry.choose.length) fail('ERR_SCHEMA_VALIDATION', ep, 'choose requires branches.');
|
||
else entry.choose.forEach((branch, bi) => {
|
||
const bp = `${ep}.choose[${bi}]`;
|
||
if (!shape(branch, ['weight', 'when', 'actions'], bp)) return;
|
||
if (!Number.isFinite(branch.weight) || branch.weight < 0) fail('ERR_OUT_OF_BOUNDS', bp, 'Branch weight must be finite and nonnegative.');
|
||
if (branch.when !== undefined) condition(branch.when, `${bp}.when`);
|
||
actions(branch.actions, `${bp}.actions`);
|
||
});
|
||
}
|
||
});
|
||
const visited = new Set(), visiting = new Set();
|
||
const visit = index => {
|
||
if (visiting.has(index)) { fail('ERR_CYCLIC_DEPENDENCY', `${p}.timeline`, 'Relative timing cycle.'); return; }
|
||
if (visited.has(index)) return;
|
||
visiting.add(index);
|
||
const entry = def.timeline[index];
|
||
if (entry?.after !== undefined) {
|
||
if (!ids.has(entry.after)) fail('ERR_INVALID_REFERENCE', `${p}.timeline[${index}].after`, 'Unknown timeline anchor.');
|
||
else visit(ids.get(entry.after));
|
||
}
|
||
visiting.delete(index); visited.add(index);
|
||
};
|
||
def.timeline.forEach((_, i) => visit(i));
|
||
}
|
||
// Include indirect event invocation and event-triggered starts in the same graph.
|
||
const graph = new Map();
|
||
const scan = (node, list) => {
|
||
for (const action of Array.isArray(list) ? list : []) {
|
||
if (action?.type === 'event') graph.get(node).add(`event:${action.event}`);
|
||
if (action?.type === 'control' && action.command === 'start') graph.get(node).add(`scenario:${action.target?.slice(10)}`);
|
||
}
|
||
};
|
||
for (const id of Object.keys(document.events ?? {})) graph.set(`event:${id}`, new Set());
|
||
for (const [id] of definitions) graph.set(`scenario:${id}`, new Set());
|
||
for (const [id, event] of Object.entries(document.events ?? {})) scan(`event:${id}`, event?.actions);
|
||
for (const [id, def] of definitions) {
|
||
if (def?.trigger?.type === 'event') graph.get(`event:${def.trigger.event}`)?.add(`scenario:${id}`);
|
||
const node = `scenario:${id}`;
|
||
scan(node, def?.onStart);
|
||
for (const entry of Array.isArray(def?.timeline) ? def.timeline : []) {
|
||
scan(node, entry?.actions);
|
||
for (const branch of Array.isArray(entry?.choose) ? entry.choose : []) scan(node, branch?.actions);
|
||
}
|
||
}
|
||
const done = new Set(), stack = new Set();
|
||
const visit = node => {
|
||
if (stack.has(node)) { fail('ERR_CYCLIC_DEPENDENCY', '$.scenarios', `Event/scenario feedback at ${node}.`); return; }
|
||
if (done.has(node)) return;
|
||
stack.add(node);
|
||
for (const child of graph.get(node) ?? []) visit(child);
|
||
stack.delete(node); done.add(node);
|
||
};
|
||
for (const node of graph.keys()) visit(node);
|
||
}
|
||
|
||
/* src/runtime/validator.js */
|
||
function issue(code, path, message, context = {}) {
|
||
return { code, path, message, ...context };
|
||
}
|
||
|
||
function isPlainObject(value) {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function parseExhibit(source, filename = 'document.xzbt') {
|
||
if (typeof source !== 'string') {
|
||
return { valid: false, document: null, errors: [issue('ERR_SCHEMA_VALIDATION', '$', 'Exhibit source must be UTF-8 JSON text.')], filename };
|
||
}
|
||
const normalizedSource = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
|
||
try {
|
||
return { valid: true, document: JSON.parse(normalizedSource), errors: [], filename, source: normalizedSource };
|
||
} catch (error) {
|
||
return { valid: false, document: null, errors: [issue('ERR_SCHEMA_VALIDATION', '$', `Malformed JSON: ${error.message}`)], filename, source: normalizedSource };
|
||
}
|
||
}
|
||
|
||
function validateExhibit(document, filename = 'document.xzbt') {
|
||
const errors = [];
|
||
const add = (code, path, message, context) => errors.push(issue(code, path, message, context));
|
||
if (!isPlainObject(document)) {
|
||
add('ERR_SCHEMA_VALIDATION', '$', 'Top-level document must be a JSON object.');
|
||
return { valid: false, errors, warnings: [], filename };
|
||
}
|
||
|
||
if (document.xzbt !== XZBT_FORMAT_VERSION) {
|
||
add('ERR_UNSUPPORTED_VERSION', '$.xzbt', `Unsupported XZBT format version: '${document.xzbt}'. Expected '${XZBT_FORMAT_VERSION}'.`, { property: 'xzbt' });
|
||
}
|
||
for (const field of Object.keys(document)) {
|
||
if (!ALLOWED_TOP_LEVEL_FIELDS.includes(field)) add('ERR_UNKNOWN_FIELD', `$.${field}`, `Unrecognized top-level field: '${field}'.`, { section: field });
|
||
}
|
||
|
||
if (!isPlainObject(document.meta)) {
|
||
add('ERR_SCHEMA_VALIDATION', '$.meta', 'Missing or invalid required object: meta.', { section: 'meta' });
|
||
} else {
|
||
for (const field of Object.keys(document.meta)) {
|
||
if (!ALLOWED_META_FIELDS.includes(field)) add('ERR_UNKNOWN_FIELD', `$.meta.${field}`, `Unrecognized metadata field: '${field}'.`, { section: 'meta', property: field });
|
||
}
|
||
const { id, name, version, author, description, license, tags } = document.meta;
|
||
if (typeof id !== 'string' || !ID_PATTERN.test(id) || id.length > 64) {
|
||
add('ERR_INVALID_ID', '$.meta.id', "Exhibit id must match ^[a-z][a-z0-9_-]*$ and contain at most 64 characters.", { section: 'meta', property: 'id' });
|
||
}
|
||
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 128) {
|
||
add('ERR_SCHEMA_VALIDATION', '$.meta.name', 'Exhibit name must be a non-empty string of at most 128 characters.', { section: 'meta', property: 'name' });
|
||
}
|
||
if (version !== undefined && typeof version !== 'string') add('ERR_TYPE_MISMATCH', '$.meta.version', 'Exhibit version must be a string.', { section: 'meta', property: 'version' });
|
||
if (author !== undefined && (typeof author !== 'string' || author.length > 128)) add('ERR_TYPE_MISMATCH', '$.meta.author', 'Author must be a string of at most 128 characters.', { section: 'meta', property: 'author' });
|
||
if (description !== undefined && (typeof description !== 'string' || description.length > 1024)) add('ERR_TYPE_MISMATCH', '$.meta.description', 'Description must be a string of at most 1024 characters.', { section: 'meta', property: 'description' });
|
||
if (license !== undefined && typeof license !== 'string') add('ERR_TYPE_MISMATCH', '$.meta.license', 'License must be a string.', { section: 'meta', property: 'license' });
|
||
if (tags !== undefined && (!Array.isArray(tags) || tags.length > 16 || tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
|
||
add('ERR_TYPE_MISMATCH', '$.meta.tags', 'Tags must contain at most 16 strings of at most 32 characters.', { section: 'meta', property: 'tags' });
|
||
}
|
||
}
|
||
|
||
if (document.runtime !== undefined) {
|
||
if (!isPlainObject(document.runtime)) {
|
||
add('ERR_SCHEMA_VALIDATION', '$.runtime', 'runtime must be an object.', { section: 'runtime' });
|
||
} else {
|
||
for (const field of Object.keys(document.runtime)) {
|
||
if (field !== 'seed') add('ERR_UNKNOWN_FIELD', `$.runtime.${field}`, `Unrecognized runtime field: '${field}'.`, { section: 'runtime', property: field });
|
||
}
|
||
const seed = document.runtime.seed;
|
||
if (seed !== undefined && seed !== 'random' && (!Number.isInteger(seed) || seed < 0 || seed >= UINT32_RANGE)) {
|
||
add('ERR_OUT_OF_BOUNDS', '$.runtime.seed', "Seed must be 'random' or an unsigned 32-bit integer.", { section: 'runtime', property: 'seed' });
|
||
}
|
||
}
|
||
}
|
||
|
||
validateDefinitions(document, errors);
|
||
validateBindings(document, errors);
|
||
validateAudioSubsystem(document, errors, { validateValueSpec, pushError });
|
||
validateVisualSubsystem(document, errors, { validateValueSpec, pushError });
|
||
validateCadenceSubsystem(document, errors, { validateValueSpec, validateCondition, pushError });
|
||
validateScenarioSubsystem(document, errors, { validateValueSpec, validateCondition, pushError });
|
||
|
||
return { valid: errors.length === 0, errors, warnings: [], filename };
|
||
}
|
||
|
||
const PARAMETER_FIELDS = new Set(['type', 'default', 'min', 'max', 'step', 'values', 'label', 'unit']);
|
||
const STATE_FIELDS = new Set(['type', 'initial', 'min', 'max']);
|
||
const BINDING_FIELDS = new Set(['source', 'target', 'scale', 'offset', 'clamp', 'smoothing', 'when']);
|
||
const VALUE_OPERATORS = Object.freeze({ abs: 1, negate: 1, round: 1, floor: 1, ceil: 1, add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2, clamp: 3, lerp: 3 });
|
||
const COMPARISONS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']);
|
||
|
||
function pushError(errors, code, path, message) { errors.push(issue(code, path, message)); }
|
||
|
||
function validateDefinitions(document, errors) {
|
||
validateDefinitionMap(document.parameters, 'parameters', PARAMETER_TYPES, 'default', PARAMETER_FIELDS, errors);
|
||
validateDefinitionMap(document.state, 'state', STATE_TYPES, 'initial', STATE_FIELDS, errors);
|
||
}
|
||
|
||
function validateDefinitionMap(definitions, namespace, allowedTypes, valueField, allowedFields, errors) {
|
||
if (definitions === undefined) return;
|
||
if (!isPlainObject(definitions)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.${namespace}`, `${namespace} must be an object.`);
|
||
return;
|
||
}
|
||
for (const [id, spec] of Object.entries(definitions)) {
|
||
const path = `$.${namespace}.${id}`;
|
||
if (!ID_PATTERN.test(id)) pushError(errors, 'ERR_INVALID_ID', path, `${namespace} ID '${id}' is invalid.`);
|
||
if (!isPlainObject(spec)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, `${namespace} definition must be an object.`);
|
||
continue;
|
||
}
|
||
for (const field of Object.keys(spec)) if (!allowedFields.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ${namespace} field '${field}'.`);
|
||
if (!allowedTypes.includes(spec.type)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.type`, `Unsupported ${namespace} type '${spec.type}'.`);
|
||
if (!Object.hasOwn(spec, valueField)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${valueField}`, `Missing required field '${valueField}'.`);
|
||
else if (!valueMatchesType(spec.type, spec[valueField], spec)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${valueField}`, `Value does not match declared type '${spec.type}'.`);
|
||
if (spec.min !== undefined && !Number.isFinite(spec.min)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.min`, 'min must be a finite number.');
|
||
if (spec.max !== undefined && !Number.isFinite(spec.max)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.max`, 'max must be a finite number.');
|
||
if (Number.isFinite(spec.min) && Number.isFinite(spec.max) && spec.min > spec.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.min`, 'min cannot exceed max.');
|
||
if (isNumericType(spec.type) && typeof spec[valueField] === 'number') {
|
||
if (spec.min !== undefined && spec[valueField] < spec.min) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, `${valueField} is below min.`);
|
||
if (spec.max !== undefined && spec[valueField] > spec.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, `${valueField} is above max.`);
|
||
}
|
||
if (namespace === 'parameters' && spec.step !== undefined && (!Number.isFinite(spec.step) || spec.step <= 0)) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.step`, 'step must be a finite positive number.');
|
||
if (spec.type === 'enum') {
|
||
if (!Array.isArray(spec.values) || spec.values.length === 0 || spec.values.some((value) => typeof value !== 'string')) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values`, 'Enum values must be a non-empty string array.');
|
||
else if (!spec.values.includes(spec[valueField])) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, 'Enum default is not declared in values.');
|
||
}
|
||
}
|
||
}
|
||
|
||
function referenceType(document, path, scope = null) {
|
||
if (typeof path !== 'string') return null;
|
||
const parts = path.split('.');
|
||
if (parts[0] === 'inputs') return parts.length === 2 && scope?.componentParameters?.has(parts[1]) ? 'number' : null;
|
||
if (parts.length === 2 && parts[0] === 'parameters') return document.parameters?.[parts[1]]?.type ?? null;
|
||
if (parts.length === 2 && parts[0] === 'state') return document.state?.[parts[1]]?.type ?? null;
|
||
if (parts[0] === 'signals') return RUNTIME_SIGNAL_TYPES[path] ?? null;
|
||
if (parts.length === 2 && parts[0] === 'modulators') return document.modulators?.[parts[1]] ? 'number' : null;
|
||
if (parts.length === 4 && parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain') return document.audio?.buses?.[parts[2]] ? 'number' : null;
|
||
return null;
|
||
}
|
||
|
||
function validateReference(document, path, location, errors, scope = null) {
|
||
if (typeof path !== 'string' || !/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/.test(path) || !referenceType(document, path, scope)) pushError(errors, 'ERR_INVALID_REFERENCE', location, `Reference '${path}' does not resolve.`);
|
||
}
|
||
|
||
function validateValueSpec(document, spec, path, errors, scope = null) {
|
||
if (typeof spec === 'number') {
|
||
if (!Number.isFinite(spec)) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Number must be finite.');
|
||
return;
|
||
}
|
||
if (typeof spec === 'string' || typeof spec === 'boolean') return;
|
||
if (!isPlainObject(spec)) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Invalid ValueSpec.');
|
||
return;
|
||
}
|
||
const forms = ['ref', 'random', 'choose', 'op'].filter((field) => Object.hasOwn(spec, field));
|
||
if (forms.length !== 1) {
|
||
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'ValueSpec must use exactly one recognized form.');
|
||
return;
|
||
}
|
||
const form = forms[0];
|
||
for (const field of Object.keys(spec)) if (field !== form && !(form === 'op' && field === 'args')) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ValueSpec field '${field}'.`);
|
||
if (form === 'ref') return validateReference(document, spec.ref, `${path}.ref`, errors, scope);
|
||
if (form === 'random') {
|
||
const random = spec.random;
|
||
if (!isPlainObject(random)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random`, 'random must be an object.');
|
||
for (const field of Object.keys(random)) if (!['min', 'max', 'integer', 'distribution'].includes(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.random.${field}`, `Unknown random field '${field}'.`);
|
||
if (!Number.isFinite(random.min) || !Number.isFinite(random.max)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.random`, 'random min and max must be finite numbers.');
|
||
else if (random.min > random.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.random`, 'random min cannot exceed max.');
|
||
if (random.integer !== undefined && typeof random.integer !== 'boolean') pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.random.integer`, 'integer must be boolean.');
|
||
if (random.distribution !== undefined && !['uniform', 'gaussian'].includes(random.distribution)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random.distribution`, 'Unsupported distribution.');
|
||
return;
|
||
}
|
||
if (form === 'choose') {
|
||
if (!Array.isArray(spec.choose) || spec.choose.length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose`, 'choose must be a non-empty array.');
|
||
spec.choose.forEach((option, index) => {
|
||
if (!isPlainObject(option) || !Object.hasOwn(option, 'value') || !Number.isFinite(option.weight) || option.weight <= 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose[${index}]`, 'Choice requires value and a finite positive weight.');
|
||
else validateValueSpec(document, option.value, `${path}.choose[${index}].value`, errors, scope);
|
||
});
|
||
return;
|
||
}
|
||
if (!Object.hasOwn(VALUE_OPERATORS, spec.op)) pushError(errors, 'ERR_INVALID_OPERATOR', `${path}.op`, `Unknown operator '${spec.op}'.`);
|
||
if (!Array.isArray(spec.args)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.args`, 'Operator args must be an array.');
|
||
else {
|
||
if (spec.args.length !== VALUE_OPERATORS[spec.op]) pushError(errors, 'ERR_INVALID_ARITY', `${path}.args`, `Operator '${spec.op}' has invalid arity.`);
|
||
spec.args.forEach((argument, index) => validateValueSpec(document, argument, `${path}.args[${index}]`, errors, scope));
|
||
}
|
||
}
|
||
|
||
function validateCondition(document, condition, path, errors) {
|
||
if (!isPlainObject(condition)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'ConditionSpec must be an object.');
|
||
if (Object.hasOwn(condition, 'and') || Object.hasOwn(condition, 'or')) {
|
||
const key = Object.hasOwn(condition, 'and') ? 'and' : 'or';
|
||
if (Object.keys(condition).length !== 1) pushError(errors, 'ERR_UNKNOWN_FIELD', path, 'Logical condition contains extra fields.');
|
||
if (!Array.isArray(condition[key]) || condition[key].length === 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${key}`, `${key} requires a non-empty array.`);
|
||
else condition[key].forEach((child, index) => validateCondition(document, child, `${path}.${key}[${index}]`, errors));
|
||
return;
|
||
}
|
||
if (Object.hasOwn(condition, 'not')) {
|
||
if (Object.keys(condition).length !== 1) pushError(errors, 'ERR_UNKNOWN_FIELD', path, 'not condition contains extra fields.');
|
||
return validateCondition(document, condition.not, `${path}.not`, errors);
|
||
}
|
||
if (!COMPARISONS.has(condition.op)) pushError(errors, 'ERR_INVALID_OPERATOR', `${path}.op`, `Unknown comparison '${condition.op}'.`);
|
||
if (!Object.hasOwn(condition, 'left') || !Object.hasOwn(condition, 'right')) pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Comparison requires left and right.');
|
||
else {
|
||
validateValueSpec(document, condition.left, `${path}.left`, errors);
|
||
validateValueSpec(document, condition.right, `${path}.right`, errors);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The exposed binding-target surface: `parameters.*`, `state.*`,
|
||
* `audio.buses.<id>.gain`, and the four visual target families 19.1 adds. A
|
||
* target inside a family that names something undeclared is
|
||
* ERR_INVALID_REFERENCE; one outside every family is ERR_UNSUPPORTED_TARGET.
|
||
* Per-object visual properties fall in the second case and stay there in 0.1.
|
||
*/
|
||
function bindingTargetType(document, target) {
|
||
const visual = matchVisualTarget(document, target);
|
||
if (visual) return visual.reason ? null : visual.spec.type;
|
||
if (typeof target !== 'string') return null;
|
||
if (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(target)) return referenceType(document, target);
|
||
return null;
|
||
}
|
||
|
||
function validateBindingTarget(document, target, location, errors) {
|
||
const visual = matchVisualTarget(document, target);
|
||
if (visual) {
|
||
if (visual.reason === 'reference') pushError(errors, 'ERR_INVALID_REFERENCE', location, `Binding target '${target}' does not resolve.`);
|
||
else if (visual.reason === 'unsupported') pushError(errors, 'ERR_UNSUPPORTED_TARGET', location, `Binding target '${target}' is not exposed. Per-object visual properties are not externally addressable in 0.1.`);
|
||
return;
|
||
}
|
||
validateReference(document, target, location, errors);
|
||
if (!(typeof target === 'string' && (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(target)))) {
|
||
pushError(errors, 'ERR_UNSUPPORTED_TARGET', location, `Binding target '${target}' is not exposed.`);
|
||
}
|
||
}
|
||
|
||
function validateBindings(document, errors) {
|
||
if (document.bindings === undefined) return;
|
||
if (!Array.isArray(document.bindings)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.bindings', 'bindings must be an array.');
|
||
const writers = new Map();
|
||
const dependencies = new Map();
|
||
document.bindings.forEach((binding, index) => {
|
||
const path = `$.bindings[${index}]`;
|
||
if (!isPlainObject(binding)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Binding must be an object.');
|
||
for (const field of Object.keys(binding)) if (!BINDING_FIELDS.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unknown binding field '${field}'.`);
|
||
validateReference(document, binding.source, `${path}.source`, errors);
|
||
validateBindingTarget(document, binding.target, `${path}.target`, errors);
|
||
for (const field of ['scale', 'offset']) if (binding[field] !== undefined && !Number.isFinite(binding[field])) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${field}`, `${field} must be finite.`);
|
||
if (binding.clamp !== undefined && (!Array.isArray(binding.clamp) || binding.clamp.length !== 2 || binding.clamp.some((value) => !Number.isFinite(value)))) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.clamp`, 'clamp must contain two finite numbers.');
|
||
else if (binding.clamp?.[0] > binding.clamp?.[1]) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.clamp`, 'clamp minimum cannot exceed maximum.');
|
||
if (binding.smoothing !== undefined && (typeof binding.smoothing !== 'string' || !DURATION_PATTERN.test(binding.smoothing))) pushError(errors, 'ERR_INVALID_DURATION', `${path}.smoothing`, 'Invalid smoothing duration.');
|
||
if (binding.when !== undefined) validateCondition(document, binding.when, `${path}.when`, errors);
|
||
const sourceType = referenceType(document, binding.source);
|
||
const targetType = bindingTargetType(document, binding.target);
|
||
if (sourceType && targetType) {
|
||
const transformed = binding.scale !== undefined || binding.offset !== undefined || binding.clamp !== undefined || (binding.smoothing !== undefined && binding.smoothing !== '0ms');
|
||
if (transformed && (!isNumericType(sourceType) || !isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding transforms require numeric endpoints.');
|
||
else if (!transformed && sourceType !== targetType && !(isNumericType(sourceType) && isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding endpoint types do not match.');
|
||
}
|
||
if (typeof binding.target === 'string') {
|
||
if (writers.has(binding.target)) pushError(errors, 'ERR_CONFLICTING_BINDING', `${path}.target`, `Target already written by binding ${writers.get(binding.target)}.`);
|
||
else writers.set(binding.target, index);
|
||
if (!dependencies.has(binding.target)) dependencies.set(binding.target, []);
|
||
if (typeof binding.source === 'string') dependencies.get(binding.target).push(binding.source);
|
||
}
|
||
});
|
||
const visiting = new Set();
|
||
const visited = new Set();
|
||
const visit = (node, trail) => {
|
||
if (visiting.has(node)) return pushError(errors, 'ERR_CYCLIC_DEPENDENCY', '$.bindings', `Binding cycle: ${[...trail, node].join(' -> ')}.`);
|
||
if (visited.has(node)) return;
|
||
visiting.add(node);
|
||
for (const source of dependencies.get(node) ?? []) visit(source, [...trail, node]);
|
||
visiting.delete(node);
|
||
visited.add(node);
|
||
};
|
||
for (const target of dependencies.keys()) visit(target, []);
|
||
}
|
||
|
||
function parseAndValidateExhibit(source, filename) {
|
||
const parsed = parseExhibit(source, filename);
|
||
if (!parsed.valid) return parsed;
|
||
return { ...validateExhibit(parsed.document, filename), document: parsed.document, source: parsed.source };
|
||
}
|
||
|
||
/* src/runtime/persistence.js */
|
||
function requestResult(request) {
|
||
return new Promise((resolve, reject) => {
|
||
request.onsuccess = () => resolve(request.result);
|
||
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed.'));
|
||
});
|
||
}
|
||
|
||
function transactionDone(transaction) {
|
||
return new Promise((resolve, reject) => {
|
||
transaction.oncomplete = () => resolve();
|
||
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.'));
|
||
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
|
||
});
|
||
}
|
||
|
||
class PersistenceManager {
|
||
constructor({ indexedDBProvider = globalThis.indexedDB, diagnostics } = {}) {
|
||
this.indexedDB = indexedDBProvider;
|
||
this.diagnostics = diagnostics;
|
||
this.database = null;
|
||
this.available = false;
|
||
}
|
||
|
||
async open() {
|
||
if (!this.indexedDB) return this.failOpen('IndexedDB is unavailable in this browser context.');
|
||
try {
|
||
const request = this.indexedDB.open(DATABASE.name, DATABASE.version);
|
||
request.onupgradeneeded = () => {
|
||
const database = request.result;
|
||
if (!database.objectStoreNames.contains(DATABASE.exhibits)) database.createObjectStore(DATABASE.exhibits, { keyPath: 'id' });
|
||
if (!database.objectStoreNames.contains(DATABASE.preferences)) database.createObjectStore(DATABASE.preferences, { keyPath: 'key' });
|
||
};
|
||
this.database = await requestResult(request);
|
||
this.database.onversionchange = () => {
|
||
this.database.close();
|
||
this.database = null;
|
||
this.available = false;
|
||
};
|
||
this.available = true;
|
||
this.diagnostics?.info('INFO_STORAGE_READY', 'Local exhibit storage is ready.', { section: 'persistence' });
|
||
return true;
|
||
} catch (error) {
|
||
return this.failOpen(error.message);
|
||
}
|
||
}
|
||
|
||
failOpen(reason) {
|
||
this.available = false;
|
||
this.diagnostics?.warn('WARN_STORAGE_UNAVAILABLE', `Session-only mode: ${reason} Imported exhibits must be imported again on a later launch.`, { section: 'persistence' });
|
||
return false;
|
||
}
|
||
|
||
async loadSnapshot() {
|
||
if (!this.available) return { exhibits: [], preferences: {} };
|
||
const transaction = this.database.transaction([DATABASE.exhibits, DATABASE.preferences], 'readonly');
|
||
const exhibitsRequest = transaction.objectStore(DATABASE.exhibits).getAll();
|
||
const preferencesRequest = transaction.objectStore(DATABASE.preferences).getAll();
|
||
const [exhibits, rows] = await Promise.all([requestResult(exhibitsRequest), requestResult(preferencesRequest), transactionDone(transaction)]);
|
||
return { exhibits, preferences: Object.fromEntries(rows.map(({ key, value }) => [key, value])) };
|
||
}
|
||
|
||
async saveExhibit(record) {
|
||
if (!this.available) return false;
|
||
const transaction = this.database.transaction(DATABASE.exhibits, 'readwrite');
|
||
transaction.objectStore(DATABASE.exhibits).put(record);
|
||
await transactionDone(transaction);
|
||
return true;
|
||
}
|
||
|
||
async savePreference(key, value) {
|
||
if (!this.available) return false;
|
||
const transaction = this.database.transaction(DATABASE.preferences, 'readwrite');
|
||
transaction.objectStore(DATABASE.preferences).put({ key, value });
|
||
await transactionDone(transaction);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/* src/runtime/library.js */
|
||
function bytesToHex(bytes) {
|
||
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
async function sha256(source, cryptoProvider = globalThis.crypto) {
|
||
if (!cryptoProvider?.subtle) throw new Error('Web Crypto SHA-256 is unavailable.');
|
||
const digest = await cryptoProvider.subtle.digest('SHA-256', new TextEncoder().encode(source));
|
||
return bytesToHex(new Uint8Array(digest));
|
||
}
|
||
|
||
class LibraryManager {
|
||
constructor({ persistence, diagnostics, cryptoProvider = globalThis.crypto } = {}) {
|
||
this.persistence = persistence;
|
||
this.diagnostics = diagnostics;
|
||
this.crypto = cryptoProvider;
|
||
this.records = new Map();
|
||
}
|
||
|
||
list() {
|
||
return [...this.records.values()].sort((left, right) => left.document.meta.name.localeCompare(right.document.meta.name));
|
||
}
|
||
|
||
get(id) { return this.records.get(id) ?? null; }
|
||
|
||
restore(records) {
|
||
for (const record of records) {
|
||
const checked = validateStoredRecord(record);
|
||
if (!checked.valid) {
|
||
this.diagnostics?.error('ERR_CACHE_INVALID', `Cached exhibit was ignored: ${checked.message}`, { exhibitId: record?.id ?? null, section: 'persistence' });
|
||
continue;
|
||
}
|
||
this.records.set(record.id, record);
|
||
}
|
||
if (this.records.size > 0) this.diagnostics?.info('INFO_LIBRARY_RESTORED', `Restored ${this.records.size} cached exhibit${this.records.size === 1 ? '' : 's'}.`, { section: 'library' });
|
||
}
|
||
|
||
async importSource(source, filename, { confirmReplacement = async () => false } = {}) {
|
||
const result = parseAndValidateExhibit(source, filename);
|
||
if (!result.valid) {
|
||
for (const error of result.errors) this.diagnostics?.error(error.code, error.message, { exhibitId: result.document?.meta?.id ?? null, section: error.section ?? 'loader', objectId: error.objectId, property: error.property });
|
||
return { status: 'invalid', ...result };
|
||
}
|
||
|
||
let digest;
|
||
try {
|
||
digest = await sha256(result.source, this.crypto);
|
||
} catch (error) {
|
||
this.diagnostics?.error('ERR_IMPORT_DIGEST', error.message, { exhibitId: result.document.meta.id, section: 'loader' });
|
||
return { status: 'invalid', valid: false, errors: [{ code: 'ERR_IMPORT_DIGEST', path: '$', message: error.message }] };
|
||
}
|
||
|
||
const id = result.document.meta.id;
|
||
const existing = this.records.get(id);
|
||
if (existing?.digest === digest) {
|
||
this.diagnostics?.info('INFO_IMPORT_IDENTICAL', `${result.document.meta.name} is already in the library; no changes were made.`, { exhibitId: id, section: 'library' });
|
||
return { status: 'identical', record: existing };
|
||
}
|
||
if (existing && !(await confirmReplacement(existing, result.document))) {
|
||
this.diagnostics?.info('INFO_IMPORT_REPLACEMENT_CANCELLED', `Replacement of ${existing.document.meta.name} was cancelled.`, { exhibitId: id, section: 'library' });
|
||
return { status: 'cancelled', record: existing };
|
||
}
|
||
|
||
const record = Object.freeze({
|
||
id,
|
||
digest,
|
||
source: result.source,
|
||
document: result.document,
|
||
sourceInfo: { kind: 'file', name: filename },
|
||
importedAt: new Date().toISOString()
|
||
});
|
||
this.records.set(id, record);
|
||
try {
|
||
await this.persistence?.saveExhibit(record);
|
||
} catch (error) {
|
||
this.diagnostics?.warn('WARN_STORAGE_WRITE_FAILED', `The exhibit is available for this session but could not be cached: ${error.message}`, { exhibitId: id, section: 'persistence' });
|
||
}
|
||
this.diagnostics?.info(existing ? 'INFO_EXHIBIT_REPLACED' : 'INFO_EXHIBIT_IMPORTED', `${result.document.meta.name} ${existing ? 'replaced' : 'imported'}.`, { exhibitId: id, section: 'library' });
|
||
return { status: existing ? 'replaced' : 'imported', record };
|
||
}
|
||
}
|
||
|
||
function validateStoredRecord(record) {
|
||
if (!record || typeof record.source !== 'string' || typeof record.digest !== 'string' || record.id !== record.document?.meta?.id) {
|
||
return { valid: false, message: 'record shape is invalid.' };
|
||
}
|
||
const result = parseAndValidateExhibit(record.source, record.sourceInfo?.name ?? 'cached.xzbt');
|
||
if (!result.valid || result.document.meta.id !== record.id) return { valid: false, message: 'definition no longer validates.' };
|
||
return { valid: true };
|
||
}
|
||
|
||
/* src/runtime/resolution.js */
|
||
const STEP_SECONDS = 1 / 60;
|
||
|
||
const NO_STAGES = Object.freeze({ binding: true, automation: false, override: true, modulation: false });
|
||
const AUDIO_BUS_STAGES = Object.freeze({ binding: true, automation: true, override: true, modulation: true });
|
||
|
||
class SignalProvider {
|
||
constructor() {
|
||
this.values = new Map([
|
||
['signals.time.elapsed', 0], ['signals.time.delta', 0],
|
||
['signals.audio.low', 0], ['signals.audio.mid', 0], ['signals.audio.high', 0], ['signals.audio.energy', 0],
|
||
['signals.pointer.x', 0], ['signals.pointer.y', 0],
|
||
['signals.viewport.width', globalThis.innerWidth ?? 0], ['signals.viewport.height', globalThis.innerHeight ?? 0],
|
||
['signals.scenario.active', false]
|
||
]);
|
||
}
|
||
|
||
get(path) {
|
||
if (!this.values.has(path)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown runtime signal '${path}'.`, path);
|
||
return this.values.get(path);
|
||
}
|
||
|
||
set(path, value) {
|
||
const type = RUNTIME_SIGNAL_TYPES[path];
|
||
if (!type) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown runtime signal '${path}'.`, path);
|
||
if ((type === 'number' && (!Number.isFinite(value))) || (type === 'boolean' && typeof value !== 'boolean')) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Signal '${path}' requires ${type}.`, path);
|
||
this.values.set(path, value);
|
||
}
|
||
|
||
updateTime(elapsedSeconds, deltaSeconds) {
|
||
this.values.set('signals.time.elapsed', elapsedSeconds);
|
||
this.values.set('signals.time.delta', deltaSeconds);
|
||
}
|
||
}
|
||
|
||
class OverrideStack {
|
||
constructor(engine) {
|
||
this.engine = engine;
|
||
this.instances = new Map();
|
||
this.activationSequence = 0;
|
||
}
|
||
|
||
add(action, sampledValue, { owner = 'performance', inheritedPriority = 0 } = {}) {
|
||
const target = this.engine.target(action.target);
|
||
if (!target) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Override target '${action.target}' is not exposed.`, action.target);
|
||
const normalized = normalizeForSpec(target.spec, sampledValue);
|
||
if (action.scope !== 'scenario' && action.scope !== 'duration') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', "Override scope must be 'scenario' or 'duration'.");
|
||
const durationMs = action.scope === 'duration' ? parseDuration(action.duration, '$.duration') : null;
|
||
if (action.scope === 'duration' && durationMs <= 0) throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration overrides require duration greater than 0ms.');
|
||
if (action.scope === 'scenario' && action.duration !== undefined) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Scenario-scoped overrides cannot declare duration.');
|
||
const priority = action.priority ?? inheritedPriority;
|
||
if (!Number.isInteger(priority) || priority < -1000 || priority > 1000) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Override priority must be an integer from -1000 through 1000.');
|
||
const transition = action.transition ?? {};
|
||
const attackMs = parseDuration(transition.in ?? '0ms', '$.transition.in');
|
||
const releaseMs = parseDuration(transition.out ?? '0ms', '$.transition.out');
|
||
const easing = transition.easing ?? 'linear';
|
||
easingValue(easing, 0);
|
||
if (!isNumericType(target.spec.type) && (attackMs > 0 || releaseMs > 0)) throw new RuntimeFault('ERR_INVALID_TRANSITION', 'Non-numeric overrides require zero-duration transitions.');
|
||
const sequence = ++this.activationSequence;
|
||
const instance = {
|
||
id: `instances.override-${sequence}`,
|
||
authorId: action.id ?? null,
|
||
target: action.target,
|
||
owner,
|
||
scope: action.scope,
|
||
priority,
|
||
activationSequence: sequence,
|
||
sampledValue: normalized,
|
||
attackOrigin: this.engine.preModulationValue(action.target),
|
||
attackMs,
|
||
releaseMs,
|
||
easing,
|
||
activatedAt: this.engine.logicalMilliseconds,
|
||
expiresAt: durationMs === null ? null : this.engine.logicalMilliseconds + durationMs,
|
||
phase: 'active',
|
||
releaseStartedAt: null,
|
||
releaseStartValue: null,
|
||
lastValue: null
|
||
};
|
||
this.instances.set(instance.id, instance);
|
||
this.engine.invalidate();
|
||
return instance.id;
|
||
}
|
||
|
||
beginRelease(id) {
|
||
const instance = this.instances.get(id);
|
||
if (!instance || instance.phase === 'releasing') return false;
|
||
if (instance.releaseMs === 0) {
|
||
this.instances.delete(id);
|
||
} else {
|
||
instance.phase = 'releasing';
|
||
instance.releaseStartedAt = this.engine.logicalMilliseconds;
|
||
instance.releaseStartValue = instance.lastValue ?? instance.sampledValue;
|
||
}
|
||
this.engine.invalidate();
|
||
return true;
|
||
}
|
||
|
||
releaseOwner(owner) {
|
||
for (const instance of [...this.instances.values()]) if (instance.owner === owner) {
|
||
instance.releaseMs = Math.min(instance.releaseMs, 5000);
|
||
this.beginRelease(instance.id);
|
||
}
|
||
}
|
||
|
||
advance() {
|
||
const now = this.engine.logicalMilliseconds;
|
||
for (const instance of [...this.instances.values()]) {
|
||
if (instance.phase === 'active' && instance.expiresAt !== null && now >= instance.expiresAt) this.beginRelease(instance.id);
|
||
if (instance.phase === 'releasing' && now - instance.releaseStartedAt >= instance.releaseMs) this.instances.delete(instance.id);
|
||
}
|
||
}
|
||
|
||
forTarget(path) { return [...this.instances.values()].filter((instance) => instance.target === path); }
|
||
has(path) { return this.forTarget(path).length > 0; }
|
||
|
||
select(path) {
|
||
return this.forTarget(path).reduce((winner, candidate) => {
|
||
if (!winner || candidate.priority > winner.priority || (candidate.priority === winner.priority && candidate.activationSequence > winner.activationSequence)) return candidate;
|
||
return winner;
|
||
}, null);
|
||
}
|
||
|
||
value(instance, lowerValue) {
|
||
const now = this.engine.logicalMilliseconds;
|
||
let value = instance.sampledValue;
|
||
if (instance.phase === 'releasing') {
|
||
const progress = (now - instance.releaseStartedAt) / instance.releaseMs;
|
||
value = lerp(lowerValue, instance.releaseStartValue, 1 - easingValue(instance.easing, progress));
|
||
} else if (instance.attackMs > 0) {
|
||
const progress = (now - instance.activatedAt) / instance.attackMs;
|
||
value = lerp(instance.attackOrigin, instance.sampledValue, easingValue(instance.easing, progress));
|
||
}
|
||
instance.lastValue = value;
|
||
return value;
|
||
}
|
||
|
||
clear() { this.instances.clear(); this.engine.invalidate(); }
|
||
}
|
||
|
||
class ResolutionEngine {
|
||
constructor(document, rootRng, { diagnostics, initialParameters = {}, onParameterChange = () => {} } = {}) {
|
||
this.document = document;
|
||
this.rng = rootRng;
|
||
this.diagnostics = diagnostics;
|
||
this.onParameterChange = onParameterChange;
|
||
this.parameters = new Map();
|
||
this.state = new Map();
|
||
this.busValues = new Map();
|
||
this.visualValues = new Map();
|
||
this.automation = new Map();
|
||
this.modulation = new Map();
|
||
this.preModulation = new Map();
|
||
this.listeners = new Set();
|
||
this.signals = new SignalProvider();
|
||
this.bindings = (document.bindings ?? []).map((binding, index) => ({ definition: binding, index, smoother: undefined, enabled: false, lastTick: -1 }));
|
||
this.transitions = new Map();
|
||
this.resolved = new Map();
|
||
this.resolving = new Set();
|
||
this.tickIndex = 0;
|
||
this.logicalMilliseconds = 0;
|
||
this.valueResolver = new ValueResolver((path) => this.get(path));
|
||
this.conditions = new ConditionEvaluator(this.valueResolver);
|
||
this.overrides = new OverrideStack(this);
|
||
for (const [id, spec] of Object.entries(document.parameters ?? {})) {
|
||
const candidate = Object.hasOwn(initialParameters, id) ? initialParameters[id] : spec.default;
|
||
this.parameters.set(id, valueMatchesType(spec.type, candidate, spec) ? normalizeForSpec(spec, candidate, { clampNumeric: true }) : spec.default);
|
||
}
|
||
for (const [id, spec] of Object.entries(document.state ?? {})) this.state.set(id, spec.initial);
|
||
this.visualEffects = (document.visuals?.effects ?? []).map((effect, index) => {
|
||
const stream = this.rng.stream('visual', `effects[${index}]`);
|
||
const resolved = { type: effect.type };
|
||
for (const [key, value] of Object.entries(effect)) {
|
||
if (key === 'type') continue;
|
||
resolved[key] = this.valueResolver.evaluate(value, stream, `visuals.effects[${index}].${key}`);
|
||
if (POST_EFFECTS[effect.type]?.numeric[key]) this.visualValues.set(`visuals.effects[${index}].${key}`, resolved[key]);
|
||
}
|
||
return resolved;
|
||
});
|
||
// Bus ValueSpecs are sampled once, lazily, so references may resolve through
|
||
// the same dependency graph as bindings (and cycles are diagnosed there).
|
||
this.resolveAll();
|
||
}
|
||
|
||
target(path) {
|
||
const parts = path.split('.');
|
||
if (parts.length === 4 && parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain' && this.document.audio?.buses?.[parts[2]]) {
|
||
return { namespace: 'buses', id: parts[2], spec: { type: 'number', min: 0, max: 4 }, stages: AUDIO_BUS_STAGES };
|
||
}
|
||
// The four visual target families of 19.1. A record with only a `reason` is
|
||
// a visual path that resolves to no capability; the caller reports it.
|
||
const visual = matchVisualTarget(this.document, path);
|
||
if (visual) return visual.reason ? null : visual;
|
||
const [namespace, id, extra] = path.split('.');
|
||
if (extra !== undefined) return null;
|
||
if (namespace === 'parameters' && this.document.parameters?.[id]) return { namespace, id, spec: this.document.parameters[id], stages: NO_STAGES };
|
||
if (namespace === 'state' && this.document.state?.[id]) return { namespace, id, spec: this.document.state[id], stages: NO_STAGES };
|
||
return null;
|
||
}
|
||
|
||
base(path) {
|
||
const target = this.target(path);
|
||
if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown value reference '${path}'.`, path);
|
||
if (target.namespace === 'buses') {
|
||
if (!this.busValues.has(target.id)) this.busValues.set(target.id, this.valueResolver.evaluate(this.document.audio.buses[target.id].gain ?? 1, this.rng.stream('sound', `bus:${target.id}`), path));
|
||
return this.busValues.get(target.id);
|
||
}
|
||
if (target.namespace.startsWith('visual-')) {
|
||
// Visual system-level bases are sampled once, lazily, from the `visual`
|
||
// stream domain — the exhibit-scope instantiation boundary of 17.14.
|
||
if (!this.visualValues.has(path)) this.visualValues.set(path, this.valueResolver.evaluate(visualTargetSource(this.document, target), this.rng.stream('visual', `target:${path}`), path));
|
||
return this.visualValues.get(path);
|
||
}
|
||
return target.namespace === 'parameters' ? this.parameters.get(target.id) : this.state.get(target.id);
|
||
}
|
||
|
||
get(path) {
|
||
if (path.startsWith('signals.')) return this.signals.get(path);
|
||
if (this.resolved.has(path)) return this.resolved.get(path);
|
||
return this.resolveTarget(path);
|
||
}
|
||
|
||
resolveAll() {
|
||
this.resolved.clear();
|
||
for (const id of Object.keys(this.document.parameters ?? {})) this.resolveTarget(`parameters.${id}`);
|
||
for (const id of Object.keys(this.document.state ?? {})) this.resolveTarget(`state.${id}`);
|
||
for (const id of Object.keys(this.document.audio?.buses ?? {})) this.resolveTarget(`audio.buses.${id}.gain`);
|
||
for (const path of this.visualTargetPaths()) this.resolveTarget(path);
|
||
for (const listener of this.listeners) listener(this);
|
||
return this.snapshot();
|
||
}
|
||
|
||
resolveTarget(path) {
|
||
if (this.resolved.has(path)) return this.resolved.get(path);
|
||
const target = this.target(path);
|
||
if (!target) {
|
||
// A visual path inside a family that names something undeclared is a
|
||
// reference error; one outside every family exposes no capability (19.1).
|
||
const visual = matchVisualTarget(this.document, path);
|
||
if (visual?.reason === 'unsupported') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `'${path}' exposes no resolution capability.`, path);
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown or unsupported reference '${path}'.`, path);
|
||
}
|
||
if (this.resolving.has(path)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Resolution cycle reached '${path}'.`, path);
|
||
this.resolving.add(path);
|
||
try {
|
||
let lower = this.base(path);
|
||
const binding = this.bindings.find((item) => item.definition.target === path);
|
||
const winner = this.overrides.select(path);
|
||
let value;
|
||
if (isNumericType(target.spec.type)) {
|
||
const track = target.stages?.automation ? this.automation.get(path) : null;
|
||
value = resolveNumericStages(lower, {
|
||
binding: binding ? (base) => this.bindingValue(binding, target, base) : undefined,
|
||
automation: track ? (base) => applyAutomationMode(base, automationValueAt(track, this.logicalMilliseconds - track.startedAt), track.mode) : undefined,
|
||
override: (currentLower) => {
|
||
const preModulation = winner ? this.overrides.value(winner, currentLower) : currentLower;
|
||
this.preModulation.set(path, preModulation);
|
||
return preModulation;
|
||
},
|
||
modulation: target.stages?.modulation ? [...(this.modulation.get(path)?.values() ?? [])].reduce((sum, contribution) => sum + contribution, 0) : 0,
|
||
min: target.spec.min, max: target.spec.max,
|
||
round: target.spec.type === 'integer' ? roundHalfAwayFromZero : undefined
|
||
});
|
||
} else {
|
||
if (binding) lower = this.bindingValue(binding, target, lower);
|
||
value = winner ? this.overrides.value(winner, lower) : lower;
|
||
this.preModulation.set(path, value);
|
||
}
|
||
this.resolved.set(path, value);
|
||
return value;
|
||
} finally {
|
||
this.resolving.delete(path);
|
||
}
|
||
}
|
||
|
||
bindingValue(binding, target, baseValue) {
|
||
const definition = binding.definition;
|
||
const conditionStream = this.rng.stream('scenario', `binding-${binding.index}:condition`);
|
||
const enabled = definition.when === undefined || this.conditions.evaluate(definition.when, conditionStream, `$.bindings[${binding.index}].when`);
|
||
if (!enabled) {
|
||
binding.enabled = false;
|
||
binding.smoother = undefined;
|
||
return baseValue;
|
||
}
|
||
const source = this.get(definition.source);
|
||
const numeric = typeof source === 'number' && isNumericType(target.spec.type);
|
||
let value = source;
|
||
if (numeric) {
|
||
value = (source * (definition.scale ?? 1)) + (definition.offset ?? 0);
|
||
if (definition.clamp) value = clamp(value, definition.clamp[0], definition.clamp[1]);
|
||
const tau = parseDuration(definition.smoothing ?? '0ms') / 1000;
|
||
if (tau === 0 || !binding.enabled || binding.smoother === undefined) {
|
||
binding.smoother = value;
|
||
binding.lastTick = this.tickIndex;
|
||
} else if (binding.lastTick !== this.tickIndex) {
|
||
binding.smoother += (1 - Math.exp(-STEP_SECONDS / tau)) * (value - binding.smoother);
|
||
binding.lastTick = this.tickIndex;
|
||
}
|
||
value = binding.smoother;
|
||
}
|
||
binding.enabled = true;
|
||
if (numeric) return value;
|
||
return normalizeForSpec(target.spec, value, { clampNumeric: true });
|
||
}
|
||
|
||
preModulationValue(path) { this.get(path); return this.preModulation.get(path); }
|
||
|
||
/**
|
||
* Every target family the shared pipeline exposes, enumerated so bindings and
|
||
* overrides against them resolve on the same tick as everything else. The
|
||
* four visual families are system-level only (19.1); no per-object property
|
||
* appears here, and none may.
|
||
*/
|
||
visualTargetPaths() {
|
||
const visuals = this.document.visuals;
|
||
if (!visuals) return [];
|
||
const paths = [];
|
||
for (const field of Object.keys(CAMERA_FIELDS)) paths.push(`visuals.camera.${field}`);
|
||
for (const id of Object.keys(visuals.layers ?? {})) paths.push(`visuals.layers.${id}.opacity`);
|
||
for (const id of Object.keys(visuals.systems ?? {})) paths.push(`visuals.systems.${id}.visible`);
|
||
(visuals.effects ?? []).forEach((entry, index) => {
|
||
for (const parameter of Object.keys(POST_EFFECTS[entry?.type]?.numeric ?? {})) paths.push(`visuals.effects[${index}].${parameter}`);
|
||
});
|
||
return paths;
|
||
}
|
||
|
||
requireAudioStage(path) {
|
||
this.requireStage(path, 'automation');
|
||
}
|
||
|
||
requireStage(path, stage) {
|
||
const target = this.target(path);
|
||
if (!target?.stages?.[stage]) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `The ${stage} stage is not exposed for '${path}'.`, path);
|
||
}
|
||
|
||
// Internal engine registration surface. The graph-only authoring syntax does
|
||
// not introduce new bus document fields, or external node targets.
|
||
addAutomation(path, definition, { startedAt = this.logicalMilliseconds, stream = this.rng.stream('sound', `bus:${path}:automation`) } = {}) {
|
||
this.requireAudioStage(path);
|
||
if (!Number.isFinite(startedAt)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Automation start time must be finite.', path);
|
||
if (this.automation.has(path)) throw new RuntimeFault('ERR_AUTOMATION_CONFLICT', `More than one track controls '${path}'.`, path);
|
||
const track = sampleAutomationTrack({ ...definition, target: path }, (value, location) => this.evaluateValue(value, stream, location), (warning) => this.diagnostics?.warn(warning.code, warning.message, { path: warning.path, section: 'audio' }));
|
||
const registration = { ...track, startedAt };
|
||
this.automation.set(path, registration);
|
||
this.invalidate();
|
||
this.resolveAll();
|
||
return () => {
|
||
if (this.automation.get(path) !== registration) return;
|
||
this.automation.delete(path); this.invalidate(); this.resolveAll();
|
||
};
|
||
}
|
||
|
||
setModulation(path, id, value) {
|
||
this.requireAudioStage(path);
|
||
if (!Number.isFinite(value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Modulation contribution must be finite.', path);
|
||
if (!this.modulation.has(path)) this.modulation.set(path, new Map());
|
||
this.modulation.get(path).set(id, value);
|
||
this.invalidate();
|
||
return this.resolveAll();
|
||
}
|
||
|
||
removeModulation(path, id) {
|
||
this.requireAudioStage(path);
|
||
this.modulation.get(path)?.delete(id);
|
||
this.invalidate();
|
||
return this.resolveAll();
|
||
}
|
||
|
||
setBusBase(id, value) {
|
||
const path = `audio.buses.${id}.gain`;
|
||
this.requireAudioStage(path);
|
||
this.busValues.set(id, normalizeForSpec(this.target(path).spec, value));
|
||
this.invalidate();
|
||
return this.resolveAll();
|
||
}
|
||
|
||
subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
||
|
||
setParameter(id, value) {
|
||
const spec = this.document.parameters?.[id];
|
||
if (!spec) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown parameter '${id}'.`, `parameters.${id}`);
|
||
this.parameters.set(id, normalizeForSpec(spec, value));
|
||
this.invalidate();
|
||
this.onParameterChange(this.parameterSnapshot());
|
||
return this.resolveAll();
|
||
}
|
||
|
||
setState(path, value, transition = {}) {
|
||
const target = this.target(path);
|
||
if (!target || target.namespace !== 'state') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `set may target state only; got '${path}'.`, path);
|
||
const normalized = normalizeForSpec(target.spec, value);
|
||
const durationMs = parseDuration(transition.duration ?? '0ms', '$.transition.duration');
|
||
const easing = transition.easing ?? 'linear';
|
||
easingValue(easing, 0);
|
||
if (!isNumericType(target.spec.type) && durationMs > 0) throw new RuntimeFault('ERR_INVALID_TRANSITION', 'Non-numeric state values require zero-duration transitions.');
|
||
if (durationMs === 0) {
|
||
this.state.set(target.id, normalized);
|
||
this.transitions.delete(path);
|
||
} else {
|
||
this.transitions.set(path, { path, from: this.base(path), to: normalized, start: this.logicalMilliseconds, durationMs, easing, spec: target.spec });
|
||
}
|
||
this.invalidate();
|
||
return this.resolveAll();
|
||
}
|
||
|
||
advance(milliseconds = 1000 / 60) {
|
||
if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new RangeError('Advance duration must be finite and nonnegative.');
|
||
this.logicalMilliseconds += milliseconds;
|
||
this.tickIndex += 1;
|
||
this.signals.updateTime(this.logicalMilliseconds / 1000, milliseconds / 1000);
|
||
for (const [path, transition] of [...this.transitions]) {
|
||
const progress = clamp((this.logicalMilliseconds - transition.start) / transition.durationMs, 0, 1);
|
||
let value = lerp(transition.from, transition.to, easingValue(transition.easing, progress));
|
||
if (transition.spec.type === 'integer') value = roundHalfAwayFromZero(value);
|
||
value = normalizeForSpec(transition.spec, value, { clampNumeric: true });
|
||
this.state.set(path.split('.')[1], value);
|
||
if (progress >= 1) this.transitions.delete(path);
|
||
}
|
||
this.overrides.advance();
|
||
return this.resolveAll();
|
||
}
|
||
|
||
evaluateValue(spec, stream, path) { return this.valueResolver.evaluate(spec, stream, path); }
|
||
evaluateCondition(spec, stream, path) { return this.conditions.evaluate(spec, stream, path); }
|
||
invalidate() { this.resolved.clear(); }
|
||
parameterSnapshot() { return Object.fromEntries(this.parameters); }
|
||
stateSnapshot() { return Object.fromEntries(this.state); }
|
||
snapshot() { return Object.fromEntries(this.resolved); }
|
||
dispose() {
|
||
this.listeners.clear(); this.automation.clear(); this.modulation.clear(); this.busValues.clear(); this.visualValues.clear(); this.preModulation.clear();
|
||
this.overrides.clear(); this.transitions.clear(); this.resolved.clear();
|
||
}
|
||
}
|
||
|
||
/* src/runtime/visual-motion.js */
|
||
// Object-local behavior state, shared by graphics and procedural render trees.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function visualSetPath(object, path, value) {
|
||
const keys = path.replace(/\[(\d+)\]/g, '.$1').split('.');
|
||
const last = keys.pop();
|
||
for (const key of keys) object = object[key] ??= {};
|
||
object[last] = value;
|
||
}
|
||
|
||
function visualGetPath(object, path) {
|
||
return path.replace(/\[(\d+)\]/g, '.$1').split('.').reduce((value, key) => value?.[key], object);
|
||
}
|
||
|
||
function initializeVisualMotion(node, context) {
|
||
node.baseRaw = structuredClone(node.raw);
|
||
node.motion = {
|
||
base: { x: 0, y: 0, z: 0, rotation: 0 },
|
||
acc: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 },
|
||
fresh: { offset: { x: 0, y: 0, z: 0 }, rotation: 0 },
|
||
scale: { x: 1, y: 1 }, opacityMultiplier: 1, channels: {},
|
||
vx: 0, vy: 0, vz: 0, trail: [], points: null,
|
||
behaviors: (node.raw.behaviors ?? []).map((spec, index) => createBehavior(spec, {
|
||
stream: context.stream, noiseTable: context.noiseTable, path: `${node.path}.behaviors[${index}]`
|
||
}))
|
||
};
|
||
}
|
||
|
||
function advanceVisualMotion(node, dt, time, scene, fields, siblings = [node], visited = new Set()) {
|
||
if (visited.has(node)) return;
|
||
visited.add(node);
|
||
for (const behavior of node.baseRaw.behaviors ?? []) if (behavior.type === 'morph') {
|
||
const target = siblings.find(sibling => sibling.key === behavior.to);
|
||
if (target) advanceVisualMotion(target, dt, time, scene, fields, siblings, visited);
|
||
}
|
||
const item = node.motion;
|
||
const raw = structuredClone(node.baseRaw);
|
||
item.base = { x: raw.position?.x ?? 0, y: raw.position?.y ?? 0, z: raw.z ?? 0, rotation: raw.transform?.rotation ?? 0 };
|
||
const pointsOf = value => value?.points ?? value?.commands?.filter(command => command.op !== 'close');
|
||
item.points = pointsOf(raw) ?? null;
|
||
item.channels = {};
|
||
ProceduralSystem.prototype.advanceItem.call({ type: 'repeater', sampleTrail() {} }, item, dt, time, {
|
||
scene, duration: parseDuration, alongPath: alongFlattened,
|
||
resolvePath: key => flattenPath(siblings.find(sibling => sibling.key === key)?.raw.commands ?? key),
|
||
resolveMorphTarget: key => pointsOf(siblings.find(sibling => sibling.key === key)?.raw),
|
||
sampleField: (id, x, y, t) => fields?.sample(id, x, y, t)
|
||
});
|
||
// C3: Integrate velocity for graphic-object hosts into accumulated displacement.
|
||
item.acc.offset.x += (item.vx ?? 0) * dt;
|
||
item.acc.offset.y += (item.vy ?? 0) * dt;
|
||
item.acc.offset.z += (item.vz ?? 0) * dt;
|
||
for (const [key, value] of Object.entries(item.channels)) visualSetPath(raw, key, value);
|
||
raw.position = { x: item.base.x + item.acc.offset.x + item.fresh.offset.x, y: item.base.y + item.acc.offset.y + item.fresh.offset.y };
|
||
raw.z = item.base.z + item.acc.offset.z + item.fresh.offset.z;
|
||
const transform = raw.transform ??= {};
|
||
transform.rotation = item.base.rotation + item.acc.rotation + item.fresh.rotation;
|
||
transform.scale = { x: (transform.scale?.x ?? 1) * item.scale.x, y: (transform.scale?.y ?? 1) * item.scale.y };
|
||
node.raw = raw;
|
||
node.position = raw.position;
|
||
node.z = raw.z;
|
||
node.translateZ = transform.translate?.z ?? 0;
|
||
node.matrix = localMatrix({ ...transform, position: raw.position,
|
||
translate: { x: 0, y: 0, ...transform.translate }, origin: { x: 0, y: 0, ...transform.origin }, skew: { x: 0, y: 0, ...transform.skew } });
|
||
node.style = { ...node.style, ...raw.style };
|
||
if (['line', 'polyline', 'arc', 'bezier'].includes(node.type)) node.style.fill = null;
|
||
node.ownOpacity = (raw.style?.opacity ?? 1) * item.opacityMultiplier;
|
||
node.geometry = primitiveSubpaths(raw, node.path);
|
||
if (raw.lifetime !== undefined && time * 1000 >= parseDuration(raw.lifetime)) node.visible = false;
|
||
for (const child of node.children) {
|
||
child.style = { ...child.style };
|
||
for (const [key, value] of Object.entries(node.style)) if (!['opacity', 'clip', 'mask'].includes(key) && child.baseRaw.style?.[key] === undefined) child.style[key] = value;
|
||
advanceVisualMotion(child, dt, time, scene, fields, node.children, visited);
|
||
}
|
||
}
|
||
|
||
/* src/runtime/visual-automation.js */
|
||
function visualLocalTracks(definitions, root, evaluate, warn) {
|
||
return (definitions ?? []).map(definition => {
|
||
const track = sampleAutomationTrack(definition, evaluate, warn);
|
||
let object = root, property = definition.target;
|
||
if (Array.isArray(root)) {
|
||
const parts = property.split('.');
|
||
const first = parts.shift();
|
||
object = root.find(node => node.key === first);
|
||
if (!object) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${first}' not found.`);
|
||
}
|
||
while (object?.children?.some(node => node.key === parts[0])) {
|
||
const child = parts.shift();
|
||
object = object.children.find(node => node.key === child);
|
||
}
|
||
if (!object) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Automation target object '${definition.target}' not found.`);
|
||
}
|
||
property = parts.join('.');
|
||
object = object.baseRaw;
|
||
}
|
||
const authored = visualGetPath(object, property);
|
||
const base = authored ?? (/opacity|scale|zoom/.test(property) ? 1 : 0);
|
||
return { track, object, property, base };
|
||
});
|
||
}
|
||
|
||
function advanceVisualTracks(tracks, milliseconds) {
|
||
for (const { track, object, property, base } of tracks ?? []) {
|
||
const value = resolveNumericStages(base, { automation: lower => applyAutomationMode(lower, automationValueAt(track, milliseconds), track.mode) });
|
||
visualSetPath(object, property, property === 'drag' ? Math.max(0, Math.min(1, value)) : property === 'rate' ? Math.max(0, value) : value);
|
||
}
|
||
}
|
||
|
||
/* src/runtime/visual-lifecycle.js */
|
||
const VISUAL_TRANSITIONS = Object.freeze({
|
||
CREATED: ['ACTIVE', 'FINISHED', 'FAILED'], ACTIVE: ['RELEASING', 'FINISHED', 'FAILED'],
|
||
RELEASING: ['FINISHED', 'FAILED'], FINISHED: ['DISPOSED'], DISPOSED: [], FAILED: []
|
||
});
|
||
|
||
class VisualInstance {
|
||
constructor({ id, template, system, at, lifetime = null, release = 0, owner = 'root', origin = null, cancelWithScenario = true }) {
|
||
Object.assign(this, { id, template, system, at, lifetime, release, owner, origin, cancelWithScenario });
|
||
this.state = 'CREATED'; this.factor = 1; this.history = ['CREATED'];
|
||
}
|
||
transition(next) {
|
||
if (!VISUAL_TRANSITIONS[this.state].includes(next)) throw new RuntimeFault('ERR_INVALID_STATE', `Cannot transition ${this.state} to ${next}.`);
|
||
this.state = next; this.history.push(next);
|
||
if (next === 'DISPOSED' || next === 'FAILED') {
|
||
this.system.objects.length = 0;
|
||
if (this.system.procedural) this.system.procedural.items.length = 0;
|
||
this.system.tracks = [];
|
||
}
|
||
}
|
||
remove(now, cleanup = false) {
|
||
if (this.state === 'CREATED') { this.transition('FINISHED'); return; }
|
||
if (this.state !== 'ACTIVE') return;
|
||
this.transition('RELEASING'); this.releaseAt = now;
|
||
this.releaseDuration = cleanup ? Math.min(this.release, 5000) : this.release;
|
||
if (this.system.procedural) this.system.procedural.releasing = true;
|
||
if (this.releaseDuration === 0) this.factor = 0;
|
||
}
|
||
advance(now) {
|
||
if (this.cleanupDeadline !== undefined && now > this.cleanupDeadline && !['DISPOSED', 'FAILED'].includes(this.state)) return 'forced';
|
||
if (this.state === 'CREATED') this.transition('ACTIVE');
|
||
if (this.state === 'ACTIVE' && this.lifetime !== null && now - this.at >= this.lifetime) { this.remove(now); return; }
|
||
if (this.state === 'RELEASING') {
|
||
this.factor = this.releaseDuration === 0 ? 0 : Math.max(0, 1 - (now - this.releaseAt) / this.releaseDuration);
|
||
if (this.factor === 0) this.transition('FINISHED');
|
||
}
|
||
if (this.state === 'FINISHED') this.transition('DISPOSED');
|
||
}
|
||
}
|
||
|
||
/* src/runtime/visual-effects.js */
|
||
// Frame-only post processing. No scene or procedural random stream is accessible here.
|
||
|
||
function visualBoxBlur(source, width, height, radius) {
|
||
const maxDeviceRadius = Math.min(Math.max(width, height), 128);
|
||
const r = Math.min(maxDeviceRadius, Math.max(0, Math.round(radius)));
|
||
if (!r) return new Uint8ClampedArray(source);
|
||
const horizontal = new Float32Array(source.length), output = new Uint8ClampedArray(source.length);
|
||
// Sliding windows keep work linear in pixel count even for large projected radii.
|
||
for (let axis = 0; axis < 2; axis += 1) {
|
||
const input = axis ? horizontal : source, target = axis ? output : horizontal;
|
||
const major = axis ? width : height, minor = axis ? height : width;
|
||
const offset = (a, b, c) => ((axis ? b * width + a : a * width + b) * 4 + c);
|
||
for (let a = 0; a < major; a += 1) for (let c = 0; c < 4; c += 1) {
|
||
let sum = 0;
|
||
for (let k = -r; k <= r; k += 1) sum += input[offset(a, Math.max(0, Math.min(minor - 1, k)), c)];
|
||
for (let b = 0; b < minor; b += 1) {
|
||
target[offset(a, b, c)] = sum / (2 * r + 1);
|
||
sum += input[offset(a, Math.min(minor - 1, b + r + 1), c)] - input[offset(a, Math.max(0, b - r), c)];
|
||
}
|
||
}
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function applyVisualEffect(pixels, width, height, effect, seconds = 0) {
|
||
const output = new Uint8ClampedArray(pixels);
|
||
const color = parseColor(effect.color ?? '#000000');
|
||
if (effect.type === 'blur') return visualBoxBlur(pixels, width, height, effect.deviceRadius ?? effect.radius ?? 4);
|
||
if (effect.type === 'bloom') {
|
||
const bright = new Uint8ClampedArray(pixels.length);
|
||
for (let i = 0; i < pixels.length; i += 4) {
|
||
const luminance = (pixels[i] * 0.2126 + pixels[i + 1] * 0.7152 + pixels[i + 2] * 0.0722) / 255;
|
||
const contribution = luminance > (effect.threshold ?? 0.7) ? 1 : 0;
|
||
for (let c = 0; c < 3; c += 1) bright[i + c] = pixels[i + c] * contribution;
|
||
bright[i + 3] = pixels[i + 3];
|
||
}
|
||
const glow = visualBoxBlur(bright, width, height, effect.deviceRadius ?? effect.radius ?? 8);
|
||
for (let i = 0; i < pixels.length; i += 4) for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, pixels[i + c] + glow[i + c] * (effect.intensity ?? 0.6)));
|
||
return output;
|
||
}
|
||
const theta = (effect.hueRotate ?? 0) * Math.PI / 180, cs = Math.cos(theta), sn = Math.sin(theta);
|
||
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
|
||
const i = (y * width + x) * 4;
|
||
let amount = effect.amount ?? 0, target = [color.r, color.g, color.b];
|
||
switch (effect.type) {
|
||
case 'fade': break;
|
||
case 'vignette': {
|
||
const d = Math.hypot((x + 0.5 - width / 2) / (width / 2), (y + 0.5 - height / 2) / (height / 2)) / Math.SQRT2;
|
||
const edge = Math.max(0.000001, effect.softness ?? 0.5);
|
||
const u = Math.max(0, Math.min(1, (d - (effect.radius ?? 0.75)) / edge));
|
||
amount = (effect.amount ?? 0.5) * u * u * (3 - 2 * u); break;
|
||
}
|
||
case 'scanlines': {
|
||
const phase = ((y / (effect.spacing ?? 3) + seconds * (effect.speed ?? 0)) % 1 + 1) % 1;
|
||
amount = phase < (effect.thickness ?? 0.5) ? (effect.amount ?? 0.3) : 0;
|
||
target = [0, 0, 0]; break;
|
||
}
|
||
case 'grain': {
|
||
const cell = effect.scale ?? 1;
|
||
let hash = Math.imul(Math.floor(x / cell) + 1, 374761393) ^ Math.imul(Math.floor(y / cell) + 1, 668265263) ^ Math.imul(Math.floor(seconds * (effect.speed ?? 24)), 1274126177);
|
||
hash = Math.imul(hash ^ (hash >>> 13), 1274126177);
|
||
const noise = ((hash ^ (hash >>> 16)) >>> 0) / 4294967296 - 0.5;
|
||
for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] + noise * 255 * (effect.amount ?? 0.15);
|
||
continue;
|
||
}
|
||
case 'color-adjust': {
|
||
const [r, g, b] = [pixels[i], pixels[i + 1], pixels[i + 2]].map(v => v / 255);
|
||
const hue = [
|
||
(0.213 + cs * 0.787 - sn * 0.213) * r + (0.715 - cs * 0.715 - sn * 0.715) * g + (0.072 - cs * 0.072 + sn * 0.928) * b,
|
||
(0.213 - cs * 0.213 + sn * 0.143) * r + (0.715 + cs * 0.285 + sn * 0.140) * g + (0.072 - cs * 0.072 - sn * 0.283) * b,
|
||
(0.213 - cs * 0.213 - sn * 0.787) * r + (0.715 - cs * 0.715 + sn * 0.715) * g + (0.072 + cs * 0.928 + sn * 0.072) * b
|
||
];
|
||
const l = hue[0] * 0.2126 + hue[1] * 0.7152 + hue[2] * 0.0722;
|
||
for (let c = 0; c < 3; c += 1) output[i + c] = Math.max(0, Math.min(255, (((l + (hue[c] - l) * (effect.saturation ?? 1)) * (effect.brightness ?? 1) - 0.5) * (effect.contrast ?? 1) + 0.5) * 255));
|
||
continue;
|
||
}
|
||
default: throw new Error(`Unavailable visual effect '${effect.type}'.`);
|
||
}
|
||
for (let c = 0; c < 3; c += 1) output[i + c] = pixels[i + c] * (1 - amount) + target[c] * amount;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function renderVisualEffects(context, plan, warn = () => {}) {
|
||
for (const effect of plan.effects ?? []) {
|
||
try {
|
||
const frame = context.getImageData(0, 0, plan.backing.width, plan.backing.height);
|
||
frame.data.set(applyVisualEffect(frame.data, frame.width, frame.height, effect, plan.logicalMilliseconds / 1000));
|
||
context.putImageData(frame, 0, 0);
|
||
if (['blur', 'bloom'].includes(effect.type)) warn(effect, 'Separable blur approximation.');
|
||
} catch { warn(effect, 'Effect unavailable on this rendering surface; skipped.'); }
|
||
}
|
||
}
|
||
|
||
/* src/runtime/visual-engine.js */
|
||
// Slice 4d — the visual renderer core.
|
||
//
|
||
// The engine is renderer-neutral by construction (17.1): it resolves an
|
||
// exhibit's declared visual objects once at their instantiation boundary
|
||
// (17.14), composes the scene-to-device chain of 19.3, sorts by depth (17.6),
|
||
// applies depth fog and the compositing order of 17.12, and emits a **frame
|
||
// plan** — an ordered list of draw operations in device coordinates. A backend
|
||
// (visual-canvas2d.js) turns that plan into drawing calls.
|
||
//
|
||
// The plan is the testable surface. Every automated trace of 17.16 asks for a
|
||
// numeric oracle — a known scene point at a known display point, a documented
|
||
// perspective factor, a fog fraction, a sort order — and a plan carries those
|
||
// as data, where a canvas carries only pixels.
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
const RUNTIME = VISUAL_LIMITS.runtime;
|
||
|
||
const DEFAULT_STYLE = Object.freeze({
|
||
fill: null, stroke: null, strokeWidth: 1, strokeCap: 'butt', strokeJoin: 'miter',
|
||
strokeDash: undefined, strokeDashOffset: 0, pointSize: 1, opacity: 1, blend: 'normal',
|
||
glow: null, shadow: null, blur: 0, filters: [], clip: null, mask: null
|
||
});
|
||
|
||
// 17.12: inheritance is per field. `opacity` is deliberately absent — it
|
||
// *multiplies* with any inherited group opacity rather than replacing it, and
|
||
// is accumulated separately as the effective alpha of the compositing order.
|
||
const INHERITED_STYLE_FIELDS = Object.freeze([
|
||
'fill', 'stroke', 'strokeWidth', 'strokeCap', 'strokeJoin', 'strokeDash', 'strokeDashOffset',
|
||
'pointSize', 'blend', 'glow', 'shadow', 'blur', 'filters'
|
||
]);
|
||
|
||
/** 17.12: `line`, `polyline`, `arc`, and `bezier` have no interior. */
|
||
const STROKE_ONLY = new Set(['line', 'polyline', 'arc', 'bezier']);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Instantiation (17.14)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function isValueSpec(value) {
|
||
if (!isRecord(value)) return false;
|
||
if (Object.hasOwn(value, 'ref') || Object.hasOwn(value, 'random') || Object.hasOwn(value, 'choose')) return true;
|
||
// A ValueSpec `op` carries `args`; a path command's `op` is a command token.
|
||
return Object.hasOwn(value, 'op') && Array.isArray(value.args);
|
||
}
|
||
|
||
/**
|
||
* Resolve every ValueSpec in a raw subtree, depth-first in property-document
|
||
* order, from one stream — the sampling rule 17.14 shares with 14.4 and 9.3.
|
||
* Everything that is not a ValueSpec is copied through unchanged.
|
||
*/
|
||
function sampleTree(raw, resolver, stream, path = '$', scope = null) {
|
||
if (Array.isArray(raw)) return raw.map((entry, index) => sampleTree(entry, resolver, stream, `${path}[${index}]`, scope));
|
||
if (!isRecord(raw)) return raw;
|
||
if (isValueSpec(raw)) {
|
||
// 18.1/18.5: `inputs.*` and `repeat.*` are construct-scoped. They are not
|
||
// section 1.3 document namespaces and grant no 8.1 capability, so they are
|
||
// resolved here rather than through the document resolver.
|
||
const scoped = scopedReference(raw, scope, path);
|
||
if (scoped !== undefined) return scoped;
|
||
if (scope) {
|
||
const substitute = value => {
|
||
if (Array.isArray(value)) return value.map(substitute);
|
||
if (!isRecord(value)) return value;
|
||
const scoped = scopedReference(value, scope, path);
|
||
return scoped !== undefined ? scoped : Object.fromEntries(Object.entries(value).map(([key, child]) => [key, substitute(child)]));
|
||
};
|
||
return resolver.evaluate(substitute(raw), stream, path);
|
||
}
|
||
return resolver.evaluate(raw, stream, path);
|
||
}
|
||
const result = {};
|
||
for (const key of Object.keys(raw)) result[key] = sampleTree(raw[key], resolver, stream, `${path}.${key}`, scope);
|
||
return result;
|
||
}
|
||
|
||
function scopedReference(spec, scope, path) {
|
||
const reference = spec.ref;
|
||
if (typeof reference !== 'string') return undefined;
|
||
if (reference.startsWith('inputs.')) {
|
||
const key = reference.slice('inputs.'.length);
|
||
if (!scope?.inputs || !Object.hasOwn(scope.inputs, key)) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `'${reference}' resolves to no declared input in this scope.`, path);
|
||
}
|
||
return scope.inputs[key];
|
||
}
|
||
if (reference.startsWith('repeat.')) {
|
||
const key = reference.slice('repeat.'.length);
|
||
if (!scope?.repeat || !Object.hasOwn(scope.repeat, key)) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `'${reference}' is legal only inside a repeater's repeat block.`, path);
|
||
}
|
||
return scope.repeat[key];
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* 18.1: a `component` object instantiates its sub-assembly in place and behaves
|
||
* as a group whose children are the component's `content`. Expanding it into a
|
||
* group here is what lets one instantiation path serve both.
|
||
*/
|
||
function expandComponent(object, context, path, componentDepth) {
|
||
const definition = context.components?.[object.component];
|
||
if (!definition) throw new RuntimeFault('ERR_INVALID_REFERENCE', `No visual component '${object.component}' is declared.`, path);
|
||
if (componentDepth > VISUAL_LIMITS.authoring.componentNesting) {
|
||
throw new RuntimeFault('ERR_COMPONENT_RECURSION', `Component nesting deeper than ${VISUAL_LIMITS.authoring.componentNesting} levels.`, path);
|
||
}
|
||
if (context.componentTrail.includes(object.component)) {
|
||
throw new RuntimeFault('ERR_COMPONENT_RECURSION', `Component '${object.component}' instantiates itself.`, path);
|
||
}
|
||
const declared = definition.parameters ?? {};
|
||
const inputs = {};
|
||
for (const key of Object.keys(object.inputs ?? {})) {
|
||
if (!Object.hasOwn(declared, key)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component '${object.component}' declares no parameter '${key}'.`, `${path}.inputs.${key}`);
|
||
}
|
||
// Sampled in declaration order, so a component's inputs consume the stream
|
||
// reproducibly whatever order the instantiating site wrote them in.
|
||
for (const [key, parameter] of Object.entries(declared)) {
|
||
const supplied = object.inputs?.[key];
|
||
if (supplied === undefined && !Object.hasOwn(parameter, 'default')) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Component parameter '${key}' has no default and no supplied value.`, `${path}.inputs.${key}`);
|
||
}
|
||
inputs[key] = sampleTree(supplied === undefined ? parameter.default : supplied, context.resolver, context.stream, `${path}.inputs.${key}`, context.scope);
|
||
if (!inputValueMatches(parameter.type, inputs[key])) {
|
||
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Component input '${key}' is not a ${parameter.type}.`, `${path}.inputs.${key}`);
|
||
}
|
||
if (parameter.type === 'number') {
|
||
const below = parameter.min !== undefined && inputs[key] < parameter.min;
|
||
const above = parameter.max !== undefined && inputs[key] > parameter.max;
|
||
if (below || above) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Component input '${key}' is outside its declared range.`, `${path}.inputs.${key}`);
|
||
}
|
||
}
|
||
return {
|
||
group: {
|
||
type: 'group',
|
||
position: object.position,
|
||
z: object.z,
|
||
transform: mergeTransforms(definition.transform, object.transform),
|
||
style: { ...(definition.style ?? {}), ...(object.style ?? {}) },
|
||
behaviors: [...(definition.behaviors ?? []), ...(object.behaviors ?? [])],
|
||
visible: object.visible,
|
||
lifetime: object.lifetime,
|
||
children: definition.content
|
||
},
|
||
inputs,
|
||
component: object.component
|
||
};
|
||
}
|
||
|
||
function inputValueMatches(type, value) {
|
||
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
|
||
if (type === 'boolean') return typeof value === 'boolean';
|
||
return typeof value === 'string';
|
||
}
|
||
|
||
function mergeTransforms(componentTransform, objectTransform) {
|
||
if (!componentTransform) return objectTransform;
|
||
if (!objectTransform) return componentTransform;
|
||
return { ...componentTransform, ...objectTransform };
|
||
}
|
||
|
||
function mergeStyle(inherited, own) {
|
||
if (!isRecord(own)) return inherited;
|
||
const merged = { ...inherited };
|
||
for (const field of INHERITED_STYLE_FIELDS) {
|
||
if (Object.hasOwn(own, field)) merged[field] = own[field];
|
||
}
|
||
// `clip` and `mask` are local: a clip intersects with any inherited one and a
|
||
// mask names a child of its own group (17.12), so neither inherits downward.
|
||
merged.clip = Object.hasOwn(own, 'clip') ? own.clip : null;
|
||
merged.mask = Object.hasOwn(own, 'mask') ? own.mask : null;
|
||
return merged;
|
||
}
|
||
|
||
function vectorOf(value, fallback = 0) {
|
||
return { x: value?.x ?? fallback, y: value?.y ?? fallback, z: value?.z ?? fallback };
|
||
}
|
||
|
||
/** One instantiated visual object: resolved values, its local matrix, its subtree. */
|
||
function instantiateObject(key, raw, context, parentPath, depth) {
|
||
if (!isRecord(raw)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'A visual object must be an object.', parentPath);
|
||
const path = parentPath ? `${parentPath}.${key}` : key;
|
||
// 18.1: component nesting and group nesting are counted independently, each
|
||
// bounded at 8, so the group a component expands into costs no group depth.
|
||
const isComponentRoot = context.pendingComponentRoot === true;
|
||
context.pendingComponentRoot = false;
|
||
if (depth > VISUAL_LIMITS.authoring.groupNesting) {
|
||
throw new RuntimeFault('ERR_VISUAL_LIMIT_EXCEEDED', `Group nesting deeper than ${VISUAL_LIMITS.authoring.groupNesting} levels.`, path);
|
||
}
|
||
if (raw.type === 'component') {
|
||
const expansion = expandComponent(raw, context, path, (context.componentDepth ?? 0) + 1);
|
||
const previousScope = context.scope;
|
||
const previousTrail = context.componentTrail;
|
||
const previousDepth = context.componentDepth ?? 0;
|
||
context.scope = { ...(context.scope ?? {}), inputs: expansion.inputs };
|
||
context.componentTrail = [...previousTrail, expansion.component];
|
||
context.componentDepth = previousDepth + 1;
|
||
context.pendingComponentRoot = true;
|
||
try {
|
||
// 18.1: the expansion path, not the component ID, is the stable instance
|
||
// key, so two instances sample independently and reproducibly.
|
||
const node = instantiateObject(key, expansion.group, context, parentPath, depth);
|
||
node.component = expansion.component;
|
||
return node;
|
||
} finally {
|
||
context.scope = previousScope;
|
||
context.componentTrail = previousTrail;
|
||
context.componentDepth = previousDepth;
|
||
}
|
||
}
|
||
// 17.14: an object's own fields are sampled in document order; its
|
||
// `children` are instantiated after them, whatever position the container key
|
||
// holds, because a group's style scope must exist before its descendants can
|
||
// inherit it. Sampling the whole subtree here and then instantiating the
|
||
// children from the raw tree would consume the stream twice.
|
||
const own = {};
|
||
for (const key of Object.keys(raw)) {
|
||
if (key === 'children') continue;
|
||
own[key] = sampleTree(raw[key], context.resolver, context.stream, `${path}.${key}`, context.scope);
|
||
}
|
||
const resolved = own;
|
||
const transform = resolved.transform ?? {};
|
||
const node = {
|
||
key,
|
||
path,
|
||
type: resolved.type,
|
||
raw: resolved,
|
||
position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
|
||
z: resolved.z ?? 0,
|
||
translateZ: transform.translate?.z ?? 0,
|
||
visible: resolved.visible ?? true,
|
||
matrix: localMatrix({
|
||
position: { x: resolved.position?.x ?? 0, y: resolved.position?.y ?? 0 },
|
||
translate: vectorOf(transform.translate),
|
||
rotation: transform.rotation ?? 0,
|
||
skew: vectorOf(transform.skew),
|
||
scale: { x: transform.scale?.x ?? 1, y: transform.scale?.y ?? 1 },
|
||
origin: vectorOf(transform.origin)
|
||
}),
|
||
style: mergeStyle(context.style, resolved.style),
|
||
// 17.12: opacity multiplies down the tree rather than being inherited.
|
||
ownOpacity: typeof resolved.style?.opacity === 'number' ? resolved.style.opacity : 1,
|
||
children: []
|
||
};
|
||
if (resolved.type === 'group') {
|
||
const previousStyle = context.style;
|
||
context.style = node.style;
|
||
for (const childKey of Object.keys(raw.children ?? {})) {
|
||
node.children.push(instantiateObject(childKey, raw.children[childKey], context, path, depth + (isComponentRoot ? 0 : 1)));
|
||
}
|
||
context.style = previousStyle;
|
||
}
|
||
// 17.12: a `fill` declared on a stroke-only primitive is a validation error;
|
||
// one inherited from an ancestor is ignored for it and still applies to its
|
||
// fillable siblings. Inheritance never turns into a validation error.
|
||
if (STROKE_ONLY.has(node.type)) node.style = { ...node.style, fill: null };
|
||
node.geometry = primitiveSubpaths(resolved, path);
|
||
initializeVisualMotion(node, context);
|
||
return node;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Display, fit, and camera (17.4, 19.3)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 19.3: the fit matrix F, carrying the centering offsets contain and cover need. */
|
||
function resolveFit(scene, displayWidth, displayHeight) {
|
||
const space = scene?.coordinateSpace ?? 'virtual';
|
||
const sceneWidth = space === 'virtual' ? scene.width : space === 'normalized' ? 1 : displayWidth;
|
||
const sceneHeight = space === 'virtual' ? scene.height : space === 'normalized' ? 1 : displayHeight;
|
||
let sx = 1;
|
||
let sy = 1;
|
||
if (space !== 'viewport') {
|
||
// 17.4 (V17): in `viewport` the fit field takes no default and has no
|
||
// mapping effect, so the scale stays 1 whatever was authored.
|
||
const fit = scene?.fit ?? 'contain';
|
||
if (fit === 'stretch') {
|
||
sx = displayWidth / sceneWidth;
|
||
sy = displayHeight / sceneHeight;
|
||
} else {
|
||
const uniform = fit === 'cover'
|
||
? Math.max(displayWidth / sceneWidth, displayHeight / sceneHeight)
|
||
: Math.min(displayWidth / sceneWidth, displayHeight / sceneHeight);
|
||
sx = uniform;
|
||
sy = uniform;
|
||
}
|
||
}
|
||
const ox = (displayWidth - sx * sceneWidth) / 2;
|
||
const oy = (displayHeight - sy * sceneHeight) / 2;
|
||
return { space, sceneWidth, sceneHeight, sx, sy, ox, oy, matrix: multiply(translation(ox, oy), scaling(sx, sy)) };
|
||
}
|
||
|
||
/**
|
||
* 19.5 and A3: the device-pixel multiplier is `min(devicePixelRatio, 2)`,
|
||
* lowered — below `1` where a display larger than 4096 requires it — until the
|
||
* backing store fits `4096 x 4096`.
|
||
*/
|
||
function resolveBackingStore(displayWidth, displayHeight, devicePixelRatio) {
|
||
const requested = Math.min(devicePixelRatio, RUNTIME.devicePixelRatioCeiling);
|
||
const longest = Math.max(displayWidth, displayHeight);
|
||
const permitted = longest > 0 ? RUNTIME.backingStoreEdge / longest : requested;
|
||
const multiplier = Math.min(requested, permitted);
|
||
return {
|
||
multiplier,
|
||
reduced: multiplier < requested,
|
||
below1: multiplier < 1,
|
||
width: Math.max(1, Math.round(displayWidth * multiplier)),
|
||
height: Math.max(1, Math.round(displayHeight * multiplier))
|
||
};
|
||
}
|
||
|
||
/** 19.3: `V = T(c) x S(zoom) x R(-rotation) x T(-c) x T(-p * (q - c))`. */
|
||
function cameraMatrix({ center, zoom, rotationDegrees, cameraCss, parallax }) {
|
||
const [cx, cy] = center;
|
||
const [qx, qy] = cameraCss;
|
||
return compose(
|
||
translation(cx, cy),
|
||
scaling(zoom, zoom),
|
||
rotation(-rotationDegrees),
|
||
translation(-cx, -cy),
|
||
translation(-parallax * (qx - cx), -parallax * (qy - cy))
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The engine
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class VisualEngine {
|
||
constructor(document, { resolution = null, rng = null, diagnostics = null, capabilities = {} } = {}) {
|
||
this.document = document;
|
||
this.resolution = resolution;
|
||
this.rng = rng;
|
||
this.capabilities = { conicGradient: true, ...capabilities };
|
||
this.cadence = new VisualDiagnosticCadence(diagnostics);
|
||
this.visuals = document?.visuals ?? null;
|
||
this.systems = [];
|
||
this.time = 0;
|
||
this.instances = new Map();
|
||
this.spawnOrdinals = new Map();
|
||
this.unregister = [];
|
||
this.layers = [];
|
||
this.deferred = [];
|
||
this.deferredKeys = new Set();
|
||
if (this.visuals) this.instantiate();
|
||
}
|
||
|
||
/** 17.14: activation is the instantiation boundary of a persistent system. */
|
||
instantiate() {
|
||
const visuals = this.visuals;
|
||
const declared = visuals.layers ? Object.keys(visuals.layers) : [];
|
||
this.layers = declared.length > 0
|
||
? declared.map((id) => ({ id, ...visuals.layers[id] }))
|
||
: [{ id: null, implicit: true }];
|
||
|
||
const resolver = this.resolution?.valueResolver;
|
||
// 18.7: the four noise-using behaviors share one exhibit-wide permutation
|
||
// table; per-object independence comes from their offset pairs.
|
||
this.noiseTable = this.rng ? behaviorNoiseTable(this.rng) : null;
|
||
this.fieldSet = new FieldSet(sampleTree(visuals.fields ?? {}, resolver, this.rng?.stream('visual', 'fields')), { rng: this.rng });
|
||
this.systems = [];
|
||
this.deferred = [];
|
||
this.deferredKeys = new Set();
|
||
for (const [id, system] of Object.entries(visuals.systems ?? {})) {
|
||
if ((system.lifecycle ?? 'persistent') !== 'spawned') this.systems.push(this.createSystem(id, system, `visuals.systems.${id}`));
|
||
}
|
||
this.scene = sampleTree(visuals.scene ?? {}, resolver, this.rng?.stream('visual', 'scene'));
|
||
this.effects = (this.resolution?.visualEffects ?? visuals.effects ?? []).map((effect, index) => ({
|
||
...effect, index
|
||
}));
|
||
const automationStream = this.rng?.stream('visual', 'automation');
|
||
this.tracks = [];
|
||
for (const track of visuals.automation ?? []) {
|
||
const path = `visuals.${track.target}`;
|
||
const match = matchVisualTarget(this.document, path);
|
||
if (match && !match.reason && match.stages.automation && this.resolution) {
|
||
this.unregister.push(this.resolution.addAutomation(path, track, { startedAt: 0, stream: automationStream }));
|
||
} else this.tracks.push(...visualLocalTracks([track], { scene: this.scene, layers: Object.fromEntries(this.layers.map(layer => [layer.id, layer])) },
|
||
(value, path) => resolver.evaluate(value, automationStream, path), warning => this.cadence.once(warning.code, warning.path, warning.message)));
|
||
}
|
||
advanceVisualTracks(this.tracks, 0);
|
||
this.enforcePopulations();
|
||
return this;
|
||
}
|
||
|
||
createSystem(id, spec, systemPath, scope = null) {
|
||
const resolver = this.resolution?.valueResolver;
|
||
const stream = this.rng?.stream('visual', systemPath);
|
||
const context = this.objectContext(resolver, stream, scope);
|
||
const system = { id, index: this.systems.length, spec, layer: spec.layer ?? null, visible: spec.visible ?? true, objects: [], at: this.time };
|
||
if (spec.type === 'graphic') {
|
||
for (const [key, raw] of Object.entries(spec.content ?? {})) {
|
||
const node = instantiateObject(key, raw, context, `${systemPath}.content`, 1);
|
||
node.order = system.objects.length; system.objects.push(node);
|
||
}
|
||
} else {
|
||
system.procedural = new ProceduralSystem(id, spec, {
|
||
systemPath, resolver, rng: this.rng, noiseTable: this.noiseTable,
|
||
scene: this.sceneRectangle(), fields: this.fieldSet,
|
||
instantiateItemNode: (template, itemStream, path, itemScope) => instantiateObject('item', template,
|
||
this.objectContext(resolver, itemStream, { ...scope, ...itemScope }), path, 1)
|
||
});
|
||
}
|
||
system.tracks = visualLocalTracks(spec.automation, system.procedural ?? system.objects,
|
||
(value, path) => sampleTree(value, resolver, stream, path, scope), warning => this.cadence.once(warning.code, `${systemPath}:${warning.path}`, warning.message));
|
||
advanceVisualTracks(system.tracks, 0);
|
||
const visited = new Set();
|
||
for (const node of system.objects) advanceVisualMotion(node, 0, 0, this.sceneRectangle(), this.fieldSet, system.objects, visited);
|
||
return system;
|
||
}
|
||
|
||
spawn(template, { inputs = {}, owner = 'root', origin = owner, ownership, lifetime } = {}) {
|
||
const spec = this.visuals?.systems?.[template];
|
||
if (!spec || spec.lifecycle !== 'spawned') throw new RuntimeFault('ERR_INVALID_REFERENCE', `No spawned template '${template}'.`);
|
||
const live = [...this.instances.values()];
|
||
const tracks = (this.visuals.automation ?? []).length + this.systems.reduce((n, system) => n + (system.tracks?.length ?? 0), 0);
|
||
const points = (this.visuals.automation ?? []).reduce((n, track) => n + track.points.length, 0) + this.systems.reduce((n, system) => n + (system.tracks ?? []).reduce((m, entry) => m + entry.track.points.length, 0), 0);
|
||
const key = live.length >= RUNTIME.liveSpawnedInstances ? 'liveSpawnedInstances'
|
||
: tracks + (spec.automation?.length ?? 0) > RUNTIME.liveAutomationTracks ? 'liveAutomationTracks'
|
||
: points + (spec.automation ?? []).reduce((n, track) => n + track.points.length, 0) > RUNTIME.liveAutomationPoints ? 'liveAutomationPoints' : null;
|
||
if (key) { this.cadence.shed('WARN_VISUAL_CEILING', key, `Spawn of '${template}' refused by ${key}.`, this.time); return null; }
|
||
const parameters = spec.spawn?.inputs ?? {};
|
||
for (const key of Object.keys(inputs)) if (!Object.hasOwn(parameters, key)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown spawn input '${key}'.`);
|
||
const bound = {};
|
||
for (const [key, parameter] of Object.entries(parameters)) {
|
||
const value = inputs[key] ?? parameter.default;
|
||
if (!inputValueMatches(parameter.type, value)) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Invalid spawn input '${key}'.`);
|
||
if (typeof value === 'number' && (value < (parameter.min ?? -Infinity) || value > (parameter.max ?? Infinity))) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Spawn input '${key}' is out of range.`);
|
||
bound[key] = value;
|
||
}
|
||
if (ownership === 'persistent' && spec.spawn?.ownership !== 'persistent') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Template does not permit persistent ownership.');
|
||
const ordinal = this.spawnOrdinals.get(template) ?? 0;
|
||
const id = `instances.${template}#${ordinal}`;
|
||
// Substitute only scoped input leaves; preserve procedural values for their own instantiation boundaries.
|
||
const substitute = value => Array.isArray(value) ? value.map(substitute) : isRecord(value)
|
||
? (typeof value.ref === 'string' && value.ref.startsWith('inputs.') ? bound[value.ref.slice(7)] : Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v)]))) : value;
|
||
const resolvedSpec = substitute(spec);
|
||
const system = this.createSystem(template, resolvedSpec, `${template}#${ordinal}`, { inputs: bound });
|
||
const stream = this.rng?.stream('visual', `${template}#${ordinal}`);
|
||
const duration = (value, fallback) => value === undefined ? fallback : parseDuration(sampleTree(value, this.resolution.valueResolver, stream));
|
||
const instance = new VisualInstance({ id, template, system, at: this.time,
|
||
lifetime: duration(lifetime ?? resolvedSpec.spawn?.lifetime, null), release: duration(resolvedSpec.spawn?.release, 0),
|
||
owner: ownership === 'persistent' ? 'root' : owner, origin, cancelWithScenario: spec.spawn?.cancelWithScenario !== false });
|
||
system.instance = instance;
|
||
this.spawnOrdinals.set(template, ordinal + 1); this.instances.set(id, instance); this.systems.push(system);
|
||
this.enforcePopulations();
|
||
return instance;
|
||
}
|
||
|
||
remove(id) {
|
||
const instance = this.instances.get(id);
|
||
if (!instance) return false;
|
||
instance.remove(this.time);
|
||
return true;
|
||
}
|
||
|
||
failSystem(system, error) {
|
||
if (system.instance && !['FAILED', 'DISPOSED'].includes(system.instance.state)) {
|
||
system.instance.transition('FAILED'); this.instances.delete(system.instance.id);
|
||
}
|
||
system.objects.length = 0;
|
||
if (system.procedural) system.procedural.items.length = 0;
|
||
system.tracks = [];
|
||
this.systems = this.systems.filter(candidate => candidate !== system);
|
||
this.cadence.diagnostics?.error?.(error.code ?? 'ERR_RUNTIME', error.message, { section: 'visual', objectId: system.instance?.id ?? system.id });
|
||
}
|
||
|
||
cleanup(owner) {
|
||
for (const instance of this.instances.values()) if (instance.owner === owner || (instance.origin === owner && instance.cancelWithScenario)) {
|
||
instance.remove(this.time, true); instance.cleanupDeadline = this.time + 5000;
|
||
}
|
||
}
|
||
|
||
dispose() {
|
||
for (const release of this.unregister) release();
|
||
this.unregister = []; this.tracks = [];
|
||
for (const instance of this.instances.values()) {
|
||
if (instance.state !== 'FINISHED') instance.transition('FINISHED');
|
||
instance.transition('DISPOSED');
|
||
}
|
||
this.instances.clear(); this.systems = []; this.fieldSet?.fields.clear(); this.cadence.clear();
|
||
}
|
||
|
||
enforcePopulations() {
|
||
for (const [type, key] of [['particles', 'liveParticles'], ['emitter', 'liveEmittedItems']]) {
|
||
const systems = this.systems.map(system => system.procedural).filter(system => system?.type === type);
|
||
let total = systems.reduce((n, system) => n + system.items.length, 0);
|
||
if (total > RUNTIME[key]) this.cadence.shed('WARN_VISUAL_CEILING', key, `Exceeded ${key}; evicting oldest items in the largest system.`, this.time);
|
||
while (total > RUNTIME[key]) {
|
||
const largest = systems.reduce((a, b) => a.items.length >= b.items.length ? a : b);
|
||
largest.items.shift(); total -= 1;
|
||
}
|
||
}
|
||
const histories = this.systems.flatMap(system => (system.procedural?.items ?? []).map(item => item.trail));
|
||
let samples = histories.reduce((n, history) => n + history.length, 0);
|
||
if (samples > RUNTIME.trailHistorySamples) this.cadence.shed('WARN_VISUAL_CEILING', 'trailHistorySamples', 'Truncating oldest samples of longest histories.', this.time);
|
||
while (samples > RUNTIME.trailHistorySamples) { histories.reduce((a, b) => a.length >= b.length ? a : b).shift(); samples -= 1; }
|
||
let links = RUNTIME.linkSegmentsPerTick;
|
||
for (const system of this.systems) if (system.procedural) {
|
||
const pairs = system.procedural.linkPairs();
|
||
if (pairs.length > links) this.cadence.shed('WARN_VISUAL_CEILING', 'linkSegmentsPerTick', 'Dropping the tail of link pair order.', this.time);
|
||
system.links = pairs.slice(0, links); links -= system.links.length;
|
||
}
|
||
}
|
||
|
||
effectPlan(fit, backing, zoom) {
|
||
let passes = 0;
|
||
const effects = [];
|
||
for (const effect of this.effects ?? []) {
|
||
if (effect.enabled === false) continue;
|
||
const definition = POST_EFFECTS[effect.type];
|
||
if (passes + definition.passes > RUNTIME.postEffectPassesPerFrame) {
|
||
this.cadence.shed('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, 'Trailing effect skipped by pass ceiling.', this.time); break;
|
||
}
|
||
const resolved = { ...definition.colors, ...effect };
|
||
for (const [parameter, range] of Object.entries(definition.numeric)) {
|
||
const value = this.resolution?.get(`visuals.effects[${effect.index}].${parameter}`) ?? effect[parameter] ?? range.default;
|
||
resolved[parameter] = clamp(value, range.min ?? -Infinity, range.max ?? Infinity);
|
||
}
|
||
if (resolved.radius !== undefined && ['blur', 'bloom'].includes(effect.type)) resolved.deviceRadius = resolved.radius * zoom * Math.sqrt(fit.sx * fit.sy) * backing.multiplier;
|
||
effects.push(resolved); passes += definition.passes;
|
||
}
|
||
return effects;
|
||
}
|
||
|
||
objectContext(resolver, stream, scope = null) {
|
||
return {
|
||
resolver, stream, noiseTable: this.noiseTable, style: DEFAULT_STYLE, scope,
|
||
components: this.document?.components?.visual ?? {}, componentTrail: [], componentDepth: 0
|
||
};
|
||
}
|
||
|
||
/** The scene rectangle in scene units, for the `scene` bounds of 18.6. */
|
||
sceneRectangle() {
|
||
const scene = this.visuals?.scene ?? {};
|
||
const space = scene.coordinateSpace ?? 'virtual';
|
||
if (space === 'normalized') return { x: 0, y: 0, width: 1, height: 1 };
|
||
if (space === 'viewport') return { x: 0, y: 0, width: this.display?.width ?? 0, height: this.display?.height ?? 0 };
|
||
return { x: 0, y: 0, width: scene.width ?? 0, height: scene.height ?? 0 };
|
||
}
|
||
|
||
/**
|
||
* One logical tick (9.1). Procedural systems advance here and nowhere else: a
|
||
* frame between two ticks draws the most recent tick's state (18.2).
|
||
*/
|
||
advance(deltaMilliseconds) {
|
||
this.cadence.endTick();
|
||
this.time += deltaMilliseconds;
|
||
const dt = deltaMilliseconds / 1000;
|
||
advanceVisualTracks(this.tracks, this.time);
|
||
for (const instance of this.instances.values()) {
|
||
if (instance.advance(this.time) === 'forced') {
|
||
this.cadence.once('WARN_CLEANUP_FORCED', instance.id, 'Visual cleanup exceeded its deadline.');
|
||
if (instance.state !== 'FINISHED') instance.transition('FINISHED');
|
||
instance.transition('DISPOSED');
|
||
}
|
||
if (['DISPOSED', 'FAILED'].includes(instance.state)) {
|
||
this.instances.delete(instance.id); this.systems = this.systems.filter(system => system !== instance.system);
|
||
}
|
||
}
|
||
this.fieldSet.evaluations = 0; this.fieldSet.refused = false; this.fieldSet.budget = RUNTIME.fieldEvaluationsPerTick;
|
||
const work = [];
|
||
for (const system of this.systems) {
|
||
try {
|
||
advanceVisualTracks(system.tracks, this.time - system.at);
|
||
if (system.procedural) {
|
||
const procedural = system.procedural;
|
||
const env = procedural.advance(dt, { deferItems: true });
|
||
for (const item of procedural.items) work.push({ system, procedural, item, env });
|
||
}
|
||
} catch (error) { this.failSystem(system, error); }
|
||
}
|
||
work.sort((a, b) => a.procedural.itemPosition(a.item).z - b.procedural.itemPosition(b.item).z);
|
||
for (const { system, procedural, item, env } of work) {
|
||
if (!this.systems.includes(system)) continue;
|
||
try {
|
||
procedural.advanceItem(item, dt, procedural.time - item.birth, env);
|
||
if (item.node) {
|
||
advanceVisualMotion(item.node, dt, procedural.time - item.birth, this.sceneRectangle(), this.fieldSet);
|
||
for (const [key, value] of Object.entries(item.channels)) visualSetPath(item.node.raw, key, value);
|
||
item.node.style = { ...item.node.style, ...item.node.raw.style };
|
||
item.node.geometry = primitiveSubpaths(item.node.raw, item.node.path);
|
||
}
|
||
} catch (error) { this.failSystem(system, error); }
|
||
}
|
||
for (const system of this.systems) {
|
||
try {
|
||
const visited = new Set();
|
||
for (const node of system.objects) advanceVisualMotion(node, dt, (this.time - system.at) / 1000, this.sceneRectangle(), this.fieldSet, system.objects, visited);
|
||
} catch (error) { this.failSystem(system, error); }
|
||
}
|
||
if (this.fieldSet.refused) this.cadence.shed('WARN_VISUAL_CEILING', 'fieldEvaluationsPerTick', 'Field budget exhausted; remaining forces are zero.', this.time);
|
||
this.enforcePopulations();
|
||
return this;
|
||
}
|
||
|
||
defer(id, reason) {
|
||
if (this.deferredKeys.has(id)) return;
|
||
this.deferredKeys.add(id);
|
||
this.deferred.push({ id, reason });
|
||
}
|
||
|
||
cameraValue(field) {
|
||
const path = `visuals.camera.${field}`;
|
||
if (this.resolution) {
|
||
try { return this.resolution.get(path); } catch { /* fall through to the authored default */ }
|
||
}
|
||
const authored = this.visuals?.camera?.[field];
|
||
if (typeof authored === 'number') return authored;
|
||
return CAMERA_FIELDS[field].default ?? 0;
|
||
}
|
||
|
||
layerValue(layer, field, fallback) {
|
||
if (layer.id && this.resolution) {
|
||
try { return this.resolution.get(`visuals.layers.${layer.id}.${field}`); } catch { /* authored value below */ }
|
||
}
|
||
const authored = layer[field];
|
||
return authored === undefined || isRecord(authored) ? fallback : authored;
|
||
}
|
||
|
||
systemVisible(system) {
|
||
if (system.instance && !['ACTIVE', 'RELEASING'].includes(system.instance.state)) return false;
|
||
if (this.resolution) {
|
||
try { return this.resolution.get(`visuals.systems.${system.id}.visible`) !== false; } catch { /* authored value below */ }
|
||
}
|
||
return system.visible !== false;
|
||
}
|
||
|
||
/**
|
||
* Build one frame's plan. `width` and `height` are the display rectangle in
|
||
* CSS pixels; everything in the returned plan is in device pixels.
|
||
*/
|
||
planFrame({ width, height, devicePixelRatio = 1, logicalMilliseconds = 0 } = {}) {
|
||
if (!this.visuals) return null;
|
||
this.display = { width, height };
|
||
for (const system of this.systems) if (system.procedural) system.procedural.scene = this.sceneRectangle();
|
||
const scene = this.scene ?? this.visuals.scene ?? {};
|
||
const fit = resolveFit(scene, width, height);
|
||
const backing = resolveBackingStore(width, height, devicePixelRatio);
|
||
if (fit.space === 'viewport' && this.resolution) {
|
||
for (const [field, value] of [['x', width / 2], ['y', height / 2]]) {
|
||
const path = `visuals.camera.${field}`;
|
||
if (this.visuals.camera?.[field] === undefined && this.resolution.visualValues.get(path) !== value) {
|
||
this.resolution.visualValues.set(path, value); this.resolution.invalidate();
|
||
}
|
||
}
|
||
}
|
||
if (backing.below1) {
|
||
this.cadence.once('WARN_VISUAL_APPROXIMATION', `backing-store:${backing.width}x${backing.height}`,
|
||
`The display exceeds ${RUNTIME.backingStoreEdge} device pixels, so the frame is rendered at a multiplier of ${backing.multiplier.toFixed(4)} and upsampled.`);
|
||
}
|
||
const center = [width / 2, height / 2];
|
||
const B = scaling(backing.multiplier, backing.multiplier);
|
||
|
||
const projection = this.visuals.camera?.projection ?? 'orthographic';
|
||
const zoom = this.cameraValue('zoom');
|
||
const rotationDegrees = this.cameraValue('rotation');
|
||
const focalLength = clamp(this.cameraValue('focalLength'), CAMERA_FIELDS.focalLength.min, CAMERA_FIELDS.focalLength.max);
|
||
let cameraX = this.cameraValue('x');
|
||
let cameraY = this.cameraValue('y');
|
||
if (fit.space === 'viewport' && !this.resolution && this.visuals.camera?.x === undefined) cameraX = center[0];
|
||
if (fit.space === 'viewport' && !this.resolution && this.visuals.camera?.y === undefined) cameraY = center[1];
|
||
const cameraCss = fit.space === 'viewport' ? [cameraX, cameraY] : apply(fit.matrix, cameraX, cameraY);
|
||
|
||
const fog = this.resolveFog(scene);
|
||
// `stats` is shared by reference: each unit is emitted with a shallow copy
|
||
// of this context carrying its own layer view matrix, so per-frame counters
|
||
// have to live behind a reference rather than on the copy.
|
||
const context = {
|
||
fit, backing, B, center, projection, zoom, focalLength, fog,
|
||
logicalMilliseconds, buffers: [], stats: { culled: 0 }
|
||
};
|
||
|
||
const layers = [];
|
||
for (const layer of this.layers) {
|
||
const parallax = this.layerValue(layer, 'parallax', 1);
|
||
const view = cameraMatrix({ center, zoom, rotationDegrees, cameraCss, parallax });
|
||
const opacity = clamp(this.layerValue(layer, 'opacity', 1), 0, 1);
|
||
const visible = this.layerValue(layer, 'visible', true) !== false;
|
||
const blend = layer.blend ?? 'normal';
|
||
const units = this.sortableUnits(layer);
|
||
const nodes = [];
|
||
for (const unit of units) {
|
||
const plan = unit.system
|
||
? this.emitSystem(unit.system, { ...context, view, layerId: layer.id })
|
||
: this.emitNode(unit.node, IDENTITY, 0, unit.releaseFactor ?? 1, { ...context, view, layerId: layer.id });
|
||
if (!plan) continue;
|
||
// The plan node is pushed by reference: buffer allocation runs after
|
||
// every layer is emitted and mutates the nodes it refuses.
|
||
plan.representativeDepth = unit.representativeDepth;
|
||
nodes.push(plan);
|
||
}
|
||
layers.push({
|
||
id: layer.id, opacity, blend, visible, parallax,
|
||
// 17.12: a layer whose opacity or blend is not the default needs a buffer.
|
||
buffered: opacity !== 1 || blend !== 'normal',
|
||
nodes
|
||
});
|
||
}
|
||
|
||
this.allocateBuffers(layers, context);
|
||
const effects = this.effectPlan(fit, backing, zoom);
|
||
|
||
return {
|
||
background: formatColor(parseColor(scene.background ?? '#000000', '$.visuals.scene.background')),
|
||
display: { width, height },
|
||
backing,
|
||
fit,
|
||
camera: { x: cameraX, y: cameraY, zoom, rotation: rotationDegrees, projection, focalLength, cameraCss, center },
|
||
fog,
|
||
layers, effects, logicalMilliseconds: this.time,
|
||
culled: context.stats.culled,
|
||
buffers: context.bufferSummary,
|
||
deferred: this.deferred,
|
||
diagnostics: this.cadence.raised.slice()
|
||
};
|
||
}
|
||
|
||
resolveFog(scene) {
|
||
const declared = scene.depthFog;
|
||
if (!declared) return null;
|
||
const near = declared.near ?? 0;
|
||
const far = declared.far;
|
||
if (!(far > near)) throw new RuntimeFault('ERR_INVALID_RANGE_ORDER', 'depthFog.far must exceed depthFog.near.', '$.visuals.scene.depthFog');
|
||
return { color: parseColor(declared.color, '$.visuals.scene.depthFog.color'), near, far, density: declared.density ?? 1 };
|
||
}
|
||
|
||
/**
|
||
* 17.6: within a layer the sortable units are each `graphic` system's
|
||
* top-level objects and each procedural system as one atomic unit. They sort
|
||
* by representative depth descending, ties by system key order then object
|
||
* key order, and the sort is stable.
|
||
*/
|
||
sortableUnits(layer) {
|
||
const units = [];
|
||
for (const system of this.systems) {
|
||
if ((system.layer ?? null) !== (layer.id ?? null)) continue;
|
||
if (!this.systemVisible(system)) continue;
|
||
if (system.procedural) {
|
||
units.push({ system, representativeDepth: system.procedural.representativeDepth(), systemIndex: system.index, objectIndex: 0 });
|
||
continue;
|
||
}
|
||
for (const node of system.objects) {
|
||
units.push({ node, representativeDepth: node.z + node.translateZ, systemIndex: system.index, objectIndex: node.order, releaseFactor: system.instance?.factor ?? 1 });
|
||
}
|
||
}
|
||
return units.sort((a, b) => (b.representativeDepth - a.representativeDepth)
|
||
|| (a.systemIndex - b.systemIndex)
|
||
|| (a.objectIndex - b.objectIndex));
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Emitting one object's plan node (17.6, 17.11, 17.12, 19.3)
|
||
// -------------------------------------------------------------------------
|
||
|
||
emitNode(node, parentMatrix, parentZ, parentAlpha, ctx) {
|
||
const zEffective = parentZ + node.z + node.translateZ;
|
||
// 17.7/17.10: a hidden object and its descendants are not drawn; their
|
||
// behaviors and automation still advance, which is not this pass's concern.
|
||
if (node.visible === false) return null;
|
||
if (ctx.projection === 'perspective' && zEffective <= -ctx.focalLength) {
|
||
// 17.6: at or behind the eye. Culled for the frame, no diagnostic.
|
||
ctx.stats.culled += 1;
|
||
return null;
|
||
}
|
||
if (node.type === 'component') {
|
||
// Recorded once, not once per frame: a plan is built every frame and the
|
||
// deferral is a property of the document, not of the frame.
|
||
this.defer(node.path, 'component expansion lands in slice 4e (18.1)');
|
||
return null;
|
||
}
|
||
const matrix = multiply(parentMatrix, node.matrix);
|
||
const alpha = parentAlpha * node.ownOpacity;
|
||
const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
|
||
// 19.3: the one uniform factor an axis-less appearance dimension takes.
|
||
const g = ctx.zoom * k * Math.sqrt(ctx.fit.sx * ctx.fit.sy);
|
||
const scalar = (value) => (value ?? 0) * g * ctx.backing.multiplier;
|
||
const project = (px, py, pz = 0) => {
|
||
let [x, y] = apply(matrix, px, py);
|
||
[x, y] = apply(ctx.fit.matrix, x, y);
|
||
[x, y] = apply(ctx.view, x, y);
|
||
if (ctx.projection === 'perspective') {
|
||
const factor = ctx.focalLength / (ctx.focalLength + zEffective + pz);
|
||
x = ctx.center[0] + (x - ctx.center[0]) * factor;
|
||
y = ctx.center[1] + (y - ctx.center[1]) * factor;
|
||
}
|
||
return apply(ctx.B, x, y);
|
||
};
|
||
|
||
const style = node.style;
|
||
const fogFraction = ctx.fog
|
||
? ctx.fog.density * clamp((zEffective - ctx.fog.near) / (ctx.fog.far - ctx.fog.near), 0, 1)
|
||
: 0;
|
||
const shade = (value, path) => {
|
||
const parsed = parseColor(value, path);
|
||
return formatColor(ctx.fog ? fogColor(parsed, ctx.fog.color, fogFraction) : parsed);
|
||
};
|
||
|
||
const plan = {
|
||
kind: node.type === 'group' ? 'group' : node.type === 'text' ? 'text' : node.type === 'point' ? 'point' : 'shape',
|
||
path: node.path,
|
||
type: node.type,
|
||
z: zEffective,
|
||
perspective: k,
|
||
alpha,
|
||
blend: style.blend ?? 'normal',
|
||
fill: this.buildPaint(style.fill, node, project, scalar, shade, ctx, 'fill'),
|
||
stroke: this.buildPaint(style.stroke, node, project, scalar, shade, ctx, 'stroke'),
|
||
strokeWidth: scalar(style.strokeWidth ?? 1),
|
||
strokeCap: style.strokeCap ?? 'butt',
|
||
strokeJoin: style.strokeJoin ?? 'miter',
|
||
strokeDash: Array.isArray(style.strokeDash) ? style.strokeDash.map(scalar) : undefined,
|
||
strokeDashOffset: scalar(style.strokeDashOffset ?? 0),
|
||
glow: style.glow ? { color: shade(style.glow.color, `${node.path}.style.glow.color`), radius: scalar(style.glow.radius), strength: style.glow.strength ?? 1 } : null,
|
||
shadow: style.shadow ? {
|
||
color: shade(style.shadow.color, `${node.path}.style.shadow.color`),
|
||
blurRadius: scalar(style.shadow.blurRadius ?? 0),
|
||
offsetX: scalar(style.shadow.offsetX ?? 0),
|
||
offsetY: scalar(style.shadow.offsetY ?? 0)
|
||
} : null,
|
||
blur: scalar(style.blur ?? 0),
|
||
filters: Array.isArray(style.filters) ? style.filters.map((entry) => ({ ...entry })) : [],
|
||
clip: this.buildClip(style.clip, project),
|
||
fogFraction,
|
||
children: [],
|
||
mask: null
|
||
};
|
||
|
||
if (node.type === 'group') {
|
||
const maskKey = style.mask ?? null;
|
||
for (const child of node.children) {
|
||
const childPlan = this.emitNode(child, matrix, zEffective, alpha, ctx);
|
||
if (!childPlan) continue;
|
||
// 17.12: the named child is not drawn; its rendered alpha multiplies
|
||
// the alpha of the group's remaining children.
|
||
if (maskKey !== null && child.key === maskKey) plan.mask = childPlan;
|
||
else plan.children.push(childPlan);
|
||
}
|
||
if (maskKey !== null && plan.mask === null) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `mask '${maskKey}' names no child of this group.`, node.path);
|
||
}
|
||
} else if (node.type === 'point') {
|
||
plan.center = project(0, 0);
|
||
plan.diameter = scalar(style.pointSize ?? 1);
|
||
} else if (node.type === 'text') {
|
||
// Text is the one primitive drawn by the backend's own text engine, so it
|
||
// carries the matrix rather than transformed geometry.
|
||
plan.matrix = this.deviceMatrix(matrix, zEffective, ctx);
|
||
plan.text = String(node.raw.text ?? '');
|
||
if (plan.text.length > VISUAL_LIMITS.authoring.textCharacters) {
|
||
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Resolved text exceeds ${VISUAL_LIMITS.authoring.textCharacters} characters.`, node.path);
|
||
}
|
||
plan.font = node.raw.font ?? 'sans-serif';
|
||
plan.size = node.raw.size ?? 16;
|
||
plan.weight = node.raw.weight ?? 'normal';
|
||
plan.italic = node.raw.italic === true;
|
||
plan.align = node.raw.align ?? 'left';
|
||
plan.baseline = node.raw.baseline ?? 'alphabetic';
|
||
plan.letterSpacing = node.raw.letterSpacing ?? 0;
|
||
plan.maxWidth = node.raw.maxWidth;
|
||
if (plan.maxWidth !== undefined && !(plan.maxWidth > 0)) {
|
||
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'maxWidth must be above zero.', node.path);
|
||
}
|
||
} else {
|
||
plan.subpaths = node.geometry.map((subpath) => ({
|
||
closed: subpath.closed,
|
||
start: project(subpath.start[0], subpath.start[1], subpath.start[2] ?? 0),
|
||
segments: subpath.segments.map((segment) => segment.type === 'line'
|
||
? { type: 'line', to: project(segment.to[0], segment.to[1], segment.to[2] ?? 0) }
|
||
: {
|
||
type: 'cubic',
|
||
c1: project(segment.c1[0], segment.c1[1], segment.c1[2] ?? 0),
|
||
c2: project(segment.c2[0], segment.c2[1], segment.c2[2] ?? 0),
|
||
to: project(segment.to[0], segment.to[1], segment.to[2] ?? 0)
|
||
})
|
||
}));
|
||
plan.fillRule = node.raw.fillRule ?? 'nonzero';
|
||
}
|
||
|
||
// 17.12: a non-default blend, a mask, a blur, a glow, or any filters entry
|
||
// requires an offscreen buffer for the object or group.
|
||
plan.needsBuffer = plan.blend !== 'normal' || plan.mask !== null || plan.blur > 0 || plan.glow !== null || plan.filters.length > 0;
|
||
if (plan.needsBuffer) ctx.buffers.push({ plan, depth: zEffective, order: ctx.buffers.length });
|
||
return plan;
|
||
}
|
||
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Procedural systems (18.2-18.5, 18.8)
|
||
// -------------------------------------------------------------------------
|
||
|
||
/** Project a scene-space point at a given effective depth into device pixels. */
|
||
projectScene(ctx, x, y, zEffective) {
|
||
let [px, py] = apply(ctx.fit.matrix, x, y);
|
||
[px, py] = apply(ctx.view, px, py);
|
||
if (ctx.projection === 'perspective') {
|
||
const factor = ctx.focalLength / (ctx.focalLength + zEffective);
|
||
px = ctx.center[0] + (px - ctx.center[0]) * factor;
|
||
py = ctx.center[1] + (py - ctx.center[1]) * factor;
|
||
}
|
||
return apply(ctx.B, px, py);
|
||
}
|
||
|
||
sceneScalar(ctx, value, zEffective) {
|
||
const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
|
||
return value * ctx.zoom * k * Math.sqrt(ctx.fit.sx * ctx.fit.sy) * ctx.backing.multiplier;
|
||
}
|
||
|
||
/**
|
||
* 17.6: a procedural system draws as one atomic unit. Within it, links draw
|
||
* before their items (18.8) and items sort by depth then creation ordinal.
|
||
*/
|
||
emitSystem(system, ctx) {
|
||
const procedural = system.procedural;
|
||
const unit = {
|
||
kind: 'group', path: procedural.path, type: system.spec?.type ?? procedural.type,
|
||
z: procedural.representativeDepth(), perspective: 1, alpha: 1, blend: 'normal',
|
||
fill: null, stroke: null, strokeWidth: 0, strokeCap: 'butt', strokeJoin: 'miter',
|
||
strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0,
|
||
filters: [], clip: null, fogFraction: 0, children: [], mask: null,
|
||
needsBuffer: false, system: procedural.id, itemCount: procedural.items.length
|
||
};
|
||
|
||
for (const link of system.links ?? procedural.linkPairs()) {
|
||
const node = this.emitLink(procedural, link, ctx);
|
||
if (node) unit.children.push(node);
|
||
}
|
||
|
||
const ordered = [...procedural.items].sort((a, b) => {
|
||
const az = procedural.itemPosition(a).z;
|
||
const bz = procedural.itemPosition(b).z;
|
||
return (bz - az) || (a.ordinal - b.ordinal);
|
||
});
|
||
for (const item of ordered) {
|
||
const trail = this.emitTrail(procedural, item, ctx);
|
||
if (trail) unit.children.push(trail);
|
||
const node = this.emitItem(procedural, item, ctx);
|
||
if (node) unit.children.push(node);
|
||
}
|
||
if (system.instance) {
|
||
const fade = node => { node.alpha *= system.instance.factor; for (const child of node.children ?? []) fade(child); };
|
||
for (const child of unit.children) fade(child);
|
||
}
|
||
return unit;
|
||
}
|
||
|
||
emitItem(procedural, item, ctx) {
|
||
if (!item.node) return null;
|
||
const position = procedural.itemPosition(item);
|
||
const age = procedural.itemAge(item);
|
||
const size = procedural.rampValue(item.ramps.size, age) ?? 1;
|
||
const opacity = (procedural.rampValue(item.ramps.opacity, age) ?? 1) * item.opacityMultiplier;
|
||
const color = procedural.rampValue(item.ramps.color, age);
|
||
// 18.2: `color` replaces the render object's resolved fill, and its stroke
|
||
// where that is non-null.
|
||
if (color !== undefined && color !== null) {
|
||
const text = typeof color === 'string' ? color : formatColor(color);
|
||
item.node.style = { ...item.node.style, fill: text, stroke: item.node.style.stroke === null ? null : text };
|
||
}
|
||
// V29: `size` is a uniform local geometry scale, applied innermost, before
|
||
// the render object's own transform.
|
||
const matrix = compose(translation(position.x, position.y), rotation(position.rotation), scaling(size * item.scale.x, size * item.scale.y));
|
||
const node = this.emitNode(item.node, matrix, position.z, opacity, ctx);
|
||
if (!node) return null;
|
||
// A `point` and a `text` carry scalar dimensions that a geometry scale
|
||
// cannot reach, so `size` multiplies them explicitly.
|
||
if (node.kind === 'point') node.diameter *= size;
|
||
if (node.kind === 'text') { node.size *= size; node.letterSpacing *= size; }
|
||
node.ordinal = item.ordinal;
|
||
return node;
|
||
}
|
||
|
||
/** 18.8: a trail is the item's recent positions, drawn under its head. */
|
||
emitTrail(procedural, item, ctx) {
|
||
const spec = procedural.trailSpec;
|
||
if (!spec || item.trail.length < 2) return null;
|
||
const style = spec.style ?? item.node?.style ?? DEFAULT_STYLE;
|
||
const head = item.trail.at(-1);
|
||
const width = spec.width ?? style.strokeWidth ?? 1;
|
||
const taper = spec.taper ?? 1;
|
||
const fade = spec.fade ?? 1;
|
||
const mode = spec.mode ?? 'line';
|
||
const samples = item.trail;
|
||
const project = (sample) => this.projectScene(ctx, sample.x, sample.y, sample.z ?? 0);
|
||
|
||
if (mode === 'points') {
|
||
return {
|
||
kind: 'group', path: `${procedural.path}#${item.ordinal}.trail`, type: 'trail', z: head.z ?? 0,
|
||
perspective: 1, alpha: 1, blend: 'normal', fill: null, stroke: null, strokeWidth: 0,
|
||
strokeCap: 'butt', strokeJoin: 'miter', glow: null, shadow: null, blur: 0, filters: [],
|
||
clip: null, fogFraction: 0, mask: null, needsBuffer: false,
|
||
children: samples.map((sample, index) => ({
|
||
kind: 'point', path: `${procedural.path}#${item.ordinal}.trail[${index}]`, type: 'point',
|
||
z: sample.z ?? 0, perspective: 1,
|
||
alpha: fade + (1 - fade) * (index / Math.max(1, samples.length - 1)),
|
||
blend: style.blend ?? 'normal',
|
||
fill: style.fill ? { type: 'color', color: typeof style.fill === 'string' ? style.fill : '#ffffff' } : null,
|
||
stroke: null, strokeWidth: 0, strokeCap: 'butt', strokeJoin: 'miter', glow: null,
|
||
shadow: null, blur: 0, filters: [], clip: null, fogFraction: 0, children: [], mask: null,
|
||
needsBuffer: false,
|
||
center: project(sample), diameter: this.sceneScalar(ctx, style.pointSize ?? 1, sample.z ?? 0)
|
||
}))
|
||
};
|
||
}
|
||
|
||
const base = {
|
||
kind: 'shape', path: `${procedural.path}#${item.ordinal}.trail`, type: 'trail', z: head.z ?? 0,
|
||
perspective: 1, alpha: 1, blend: style.blend ?? 'normal',
|
||
strokeCap: style.strokeCap ?? 'butt', strokeJoin: style.strokeJoin ?? 'miter',
|
||
strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0, filters: [],
|
||
clip: null, fogFraction: 0, children: [], mask: null, needsBuffer: false, fillRule: 'nonzero'
|
||
};
|
||
|
||
if (mode === 'ribbon') {
|
||
// 18.8: a ribbon is the same history buffer drawn with area instead of a
|
||
// stroke, its half-width interpolating from the head to `width * taper`.
|
||
const left = [];
|
||
const right = [];
|
||
for (let index = 0; index < samples.length; index += 1) {
|
||
const previous = samples[Math.max(0, index - 1)];
|
||
const next = samples[Math.min(samples.length - 1, index + 1)];
|
||
const angle = Math.atan2(next.y - previous.y, next.x - previous.x);
|
||
const fraction = index / Math.max(1, samples.length - 1);
|
||
const half = (width * (taper + (1 - taper) * fraction)) / 2;
|
||
const nx = -Math.sin(angle) * half;
|
||
const ny = Math.cos(angle) * half;
|
||
left.push(this.projectScene(ctx, samples[index].x + nx, samples[index].y + ny, samples[index].z ?? 0));
|
||
right.push(this.projectScene(ctx, samples[index].x - nx, samples[index].y - ny, samples[index].z ?? 0));
|
||
}
|
||
const outline = [...left, ...right.reverse()];
|
||
return {
|
||
...base,
|
||
fill: style.fill ? { type: 'color', color: typeof style.fill === 'string' ? style.fill : '#ffffff' } : null,
|
||
stroke: null, strokeWidth: 0, alpha: (1 + fade) / 2,
|
||
subpaths: [{ closed: true, start: outline[0], segments: outline.slice(1).map((point) => ({ type: 'line', to: point })) }]
|
||
};
|
||
}
|
||
|
||
const points = samples.map(project);
|
||
return {
|
||
...base,
|
||
fill: null,
|
||
stroke: style.stroke || style.fill ? { type: 'color', color: typeof (style.stroke ?? style.fill) === 'string' ? (style.stroke ?? style.fill) : '#ffffff' } : null,
|
||
strokeWidth: this.sceneScalar(ctx, width, head.z ?? 0),
|
||
alpha: (1 + fade) / 2,
|
||
subpaths: [{ closed: false, start: points[0], segments: points.slice(1).map((point) => ({ type: 'line', to: point })) }]
|
||
};
|
||
}
|
||
|
||
/** 18.8: link geometry borrows the item template's resolved style. */
|
||
emitLink(procedural, link, ctx) {
|
||
const spec = procedural.linksSpec;
|
||
const style = spec.style ?? procedural.items[0]?.node?.style ?? DEFAULT_STYLE;
|
||
const paint = style.stroke ?? style.fill;
|
||
return {
|
||
kind: 'shape', path: `${procedural.path}.links`, type: 'link', z: Math.min(link.from.z, link.to.z),
|
||
perspective: 1, alpha: link.opacity, blend: style.blend ?? 'normal',
|
||
fill: null,
|
||
stroke: typeof paint === 'string' ? { type: 'color', color: paint } : null,
|
||
strokeWidth: this.sceneScalar(ctx, style.strokeWidth ?? 1, link.from.z),
|
||
strokeCap: style.strokeCap ?? 'butt', strokeJoin: style.strokeJoin ?? 'miter',
|
||
strokeDash: undefined, strokeDashOffset: 0, glow: null, shadow: null, blur: 0, filters: [],
|
||
clip: null, fogFraction: 0, children: [], mask: null, needsBuffer: false, fillRule: 'nonzero',
|
||
subpaths: [{
|
||
closed: false,
|
||
start: this.projectScene(ctx, link.from.x, link.from.y, link.from.z),
|
||
segments: [{ type: 'line', to: this.projectScene(ctx, link.to.x, link.to.y, link.to.z) }]
|
||
}]
|
||
};
|
||
}
|
||
|
||
/** The single affine chain for a constant-depth object: `B x P x V x F x M`. */
|
||
deviceMatrix(matrix, zEffective, ctx) {
|
||
const k = ctx.projection === 'perspective' ? ctx.focalLength / (ctx.focalLength + zEffective) : 1;
|
||
const perspective = compose(translation(ctx.center[0], ctx.center[1]), scaling(k, k), translation(-ctx.center[0], -ctx.center[1]));
|
||
return compose(ctx.B, perspective, ctx.view, ctx.fit.matrix, matrix);
|
||
}
|
||
|
||
buildClip(clip, project) {
|
||
if (!isRecord(clip)) return null;
|
||
const { shape, x, y, width, height } = clip;
|
||
if (shape === 'ellipse') {
|
||
const cx = x + width / 2;
|
||
const cy = y + height / 2;
|
||
return { shape: 'ellipse', center: project(cx, cy), corners: [project(x, y), project(x + width, y), project(x + width, y + height), project(x, y + height)] };
|
||
}
|
||
return { shape: 'rectangle', corners: [project(x, y), project(x + width, y), project(x + width, y + height), project(x, y + height)] };
|
||
}
|
||
|
||
/**
|
||
* 17.12: a paint is a color, a gradient object, or null. Gradient stops are
|
||
* fogged individually before the paint is constructed, so a fogged gradient
|
||
* keeps its offsets and its shape and loses only its contrast.
|
||
*/
|
||
buildPaint(paint, node, project, scalar, shade, ctx, role) {
|
||
if (paint === null || paint === undefined) return null;
|
||
if (typeof paint === 'string') return { type: 'color', color: shade(paint, `${node.path}.style.${role}`) };
|
||
if (!isRecord(paint)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'A paint must be a color, a paint object, or null.', node.path);
|
||
const stops = (paint.stops ?? []).map((stop) => ({ offset: stop.offset, color: shade(stop.color, `${node.path}.style.${role}.stops`) }));
|
||
if (paint.type === 'linear-gradient') {
|
||
return { type: 'linear-gradient', stops, from: project(paint.from?.x ?? 0, paint.from?.y ?? 0), to: project(paint.to?.x ?? 0, paint.to?.y ?? 0) };
|
||
}
|
||
if (paint.type === 'radial-gradient') {
|
||
return {
|
||
type: 'radial-gradient', stops,
|
||
center: project(paint.center?.x ?? 0, paint.center?.y ?? 0),
|
||
radius: scalar(paint.radius ?? 0),
|
||
innerRadius: scalar(paint.innerRadius ?? 0)
|
||
};
|
||
}
|
||
if (paint.type === 'conic-gradient') {
|
||
const center = { x: paint.center?.x ?? 0, y: paint.center?.y ?? 0 };
|
||
const angle = paint.angle ?? 0;
|
||
if (this.capabilities.conicGradient) {
|
||
return { type: 'conic-gradient', stops, center: project(center.x, center.y), angle };
|
||
}
|
||
return this.conicFallback(node, stops, center, angle, project, `${node.path}.style.${role}`);
|
||
}
|
||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Unknown paint type '${paint.type}'.`, node.path);
|
||
}
|
||
|
||
/**
|
||
* 17.12: the documented conic fallback. The axis runs from `center` along
|
||
* `angle` to the local-space bounding box's boundary; where the center is
|
||
* outside the box or the ray never meets it, it runs to the distance of the
|
||
* farthest corner, which reduces to the intersection case when one exists.
|
||
* The stops and their offsets are preserved exactly.
|
||
*/
|
||
conicFallback(node, stops, center, angle, project, path) {
|
||
const box = boundingBox(node.geometry);
|
||
const corners = [[box.x, box.y], [box.x + box.width, box.y], [box.x + box.width, box.y + box.height], [box.x, box.y + box.height]];
|
||
const distance = Math.max(...corners.map(([x, y]) => Math.hypot(x - center.x, y - center.y)), 0);
|
||
this.cadence.once('WARN_VISUAL_APPROXIMATION', path,
|
||
'The renderer has no conic gradient; the documented linear fallback was used with the same stops.');
|
||
if (distance === 0) {
|
||
// A degenerate box has no axis. A flat first stop is a materially milder
|
||
// substitution than omitting the paint.
|
||
return { type: 'color', color: stops[0]?.color ?? '#000000', approximated: true };
|
||
}
|
||
const radians = angle * (Math.PI / 180);
|
||
return {
|
||
type: 'linear-gradient', stops, approximated: true,
|
||
from: project(center.x, center.y),
|
||
to: project(center.x + distance * Math.cos(radians), center.y + distance * Math.sin(radians))
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 17.12 and 19.5: sixteen buffer allocations per frame. Layer buffers are
|
||
* allocated first, then object buffers nearest-first, so what runs out is the
|
||
* far content and the refusals are taken farthest-first.
|
||
*/
|
||
allocateBuffers(layers, ctx) {
|
||
const layerBuffers = layers.filter((layer) => layer.visible && layer.buffered).length;
|
||
let budget = Math.max(0, RUNTIME.offscreenBuffersPerFrame - layerBuffers);
|
||
const candidates = ctx.buffers.slice().sort((a, b) => (a.depth - b.depth) || (a.order - b.order));
|
||
let granted = 0;
|
||
let shed = 0;
|
||
for (const candidate of candidates) {
|
||
if (budget > 0) {
|
||
candidate.plan.buffered = true;
|
||
budget -= 1;
|
||
granted += 1;
|
||
continue;
|
||
}
|
||
// Refused: the owner draws without its buffer-requiring features. This is
|
||
// a diagnosed exception to 17.1, not a silent omission.
|
||
const plan = candidate.plan;
|
||
plan.buffered = false;
|
||
plan.shed = true;
|
||
plan.blend = 'normal';
|
||
plan.mask = null;
|
||
plan.blur = 0;
|
||
plan.glow = null;
|
||
plan.filters = [];
|
||
shed += 1;
|
||
this.cadence.shed('WARN_VISUAL_APPROXIMATION', 'offscreen-buffers',
|
||
`The frame needs more than ${RUNTIME.offscreenBuffersPerFrame} offscreen buffers; the farthest objects drew without their buffered appearance features.`,
|
||
ctx.logicalMilliseconds);
|
||
}
|
||
ctx.bufferSummary = { limit: RUNTIME.offscreenBuffersPerFrame, layers: layerBuffers, granted, shed };
|
||
}
|
||
|
||
}
|
||
|
||
/* src/runtime/visual-canvas2d.js */
|
||
// Canvas 2D backend for the frame plans of visual-engine.js.
|
||
//
|
||
// XZBT 0.1 realizes the renderer-neutral schema of 17.1 with Canvas 2D (PRD
|
||
// 69). The plan arrives in device pixels with every scalar already converted by
|
||
// the uniform factor of 19.3, so this module sets no transform for geometry: it
|
||
// strokes with an explicit device-pixel `lineWidth`, which is what keeps a
|
||
// stroke from being distorted by a nonuniform `stretch` fit. Text is the one
|
||
// exception and carries its own matrix.
|
||
|
||
|
||
const BLEND_OPERATIONS = Object.freeze({
|
||
normal: 'source-over', add: 'lighter', screen: 'screen', multiply: 'multiply',
|
||
overlay: 'overlay', lighten: 'lighten', darken: 'darken', difference: 'difference'
|
||
});
|
||
|
||
const FILTER_UNITS = Object.freeze({
|
||
brightness: '', contrast: '', saturate: '', 'hue-rotate': 'deg', grayscale: '', sepia: '', invert: ''
|
||
});
|
||
|
||
function tracePath(context, node) {
|
||
context.beginPath();
|
||
for (const subpath of node.subpaths ?? []) {
|
||
context.moveTo(subpath.start[0], subpath.start[1]);
|
||
for (const segment of subpath.segments) {
|
||
if (segment.type === 'line') context.lineTo(segment.to[0], segment.to[1]);
|
||
else context.bezierCurveTo(segment.c1[0], segment.c1[1], segment.c2[0], segment.c2[1], segment.to[0], segment.to[1]);
|
||
}
|
||
if (subpath.closed) context.closePath();
|
||
}
|
||
}
|
||
|
||
function buildPaint(context, paint) {
|
||
if (!paint) return null;
|
||
if (paint.type === 'color') return paint.color;
|
||
let gradient;
|
||
if (paint.type === 'linear-gradient') {
|
||
gradient = context.createLinearGradient(paint.from[0], paint.from[1], paint.to[0], paint.to[1]);
|
||
} else if (paint.type === 'radial-gradient') {
|
||
gradient = context.createRadialGradient(paint.center[0], paint.center[1], paint.innerRadius ?? 0, paint.center[0], paint.center[1], paint.radius);
|
||
} else if (paint.type === 'conic-gradient') {
|
||
if (typeof context.createConicGradient !== 'function') return paint.stops[0]?.color ?? null;
|
||
gradient = context.createConicGradient((paint.angle ?? 0) * (Math.PI / 180), paint.center[0], paint.center[1]);
|
||
} else {
|
||
return null;
|
||
}
|
||
for (const stop of paint.stops ?? []) gradient.addColorStop(stop.offset, stop.color);
|
||
return gradient;
|
||
}
|
||
|
||
function filterString(node) {
|
||
const parts = [];
|
||
for (const entry of node.filters ?? []) {
|
||
const unit = FILTER_UNITS[entry.type] ?? '';
|
||
parts.push(`${entry.type}(${entry.amount ?? 0}${unit})`);
|
||
}
|
||
if (node.blur > 0) parts.push(`blur(${node.blur}px)`);
|
||
return parts.join(' ');
|
||
}
|
||
|
||
function applyClip(context, clip) {
|
||
if (!clip) return;
|
||
context.beginPath();
|
||
const [a, b, c, d] = clip.corners;
|
||
if (clip.shape === 'ellipse') {
|
||
// The clip rectangle's device corners describe the ellipse's bounding
|
||
// parallelogram; its axes are the half-diagonals of that shape.
|
||
const cx = clip.center[0];
|
||
const cy = clip.center[1];
|
||
const rx = Math.hypot(b[0] - a[0], b[1] - a[1]) / 2;
|
||
const ry = Math.hypot(d[0] - a[0], d[1] - a[1]) / 2;
|
||
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||
context.ellipse(cx, cy, rx, ry, angle, 0, Math.PI * 2);
|
||
} else {
|
||
context.moveTo(a[0], a[1]);
|
||
context.lineTo(b[0], b[1]);
|
||
context.lineTo(c[0], c[1]);
|
||
context.lineTo(d[0], d[1]);
|
||
context.closePath();
|
||
}
|
||
context.clip();
|
||
}
|
||
|
||
/**
|
||
* The compositing order of 17.12, stage for stage: shadow, geometry, glow,
|
||
* blur, filters, alpha, clip and mask, blend.
|
||
*/
|
||
function drawNode(context, node, surface) {
|
||
if (node.alpha === 0) return;
|
||
|
||
// B1: When node.buffered is true, rasterize into a pool surface with default
|
||
// state, then composite once with the node's alpha/blend (§17.12 stage order).
|
||
if (node.buffered && surface && typeof surface.create === 'function') {
|
||
const target = surface.create();
|
||
if (target?.context) {
|
||
target.context.save();
|
||
target.context.globalAlpha = 1;
|
||
target.context.globalCompositeOperation = 'source-over';
|
||
const unbuffered = { ...node, buffered: false, alpha: 1, blend: 'normal', clip: null };
|
||
drawNode(target.context, unbuffered, surface);
|
||
target.context.restore();
|
||
|
||
context.save();
|
||
context.globalAlpha = node.alpha;
|
||
context.globalCompositeOperation = BLEND_OPERATIONS[node.blend] ?? 'source-over';
|
||
applyClip(context, node.clip);
|
||
const filters = filterString(node);
|
||
if (filters && 'filter' in context) context.filter = filters;
|
||
context.drawImage(target.canvas, 0, 0);
|
||
context.restore();
|
||
|
||
surface.release(target);
|
||
return;
|
||
}
|
||
}
|
||
|
||
context.save();
|
||
context.globalAlpha = node.alpha;
|
||
context.globalCompositeOperation = BLEND_OPERATIONS[node.blend] ?? 'source-over';
|
||
applyClip(context, node.clip);
|
||
const filters = filterString(node);
|
||
if (filters && 'filter' in context) context.filter = filters;
|
||
|
||
if (node.kind === 'group') {
|
||
if (node.mask) {
|
||
const masked = surface.create();
|
||
for (const child of node.children) drawNode(masked.context, child, surface);
|
||
masked.context.save();
|
||
masked.context.globalCompositeOperation = 'destination-in';
|
||
drawNode(masked.context, node.mask, surface);
|
||
masked.context.restore();
|
||
context.drawImage(masked.canvas, 0, 0);
|
||
surface.release(masked);
|
||
} else {
|
||
for (const child of node.children) drawNode(context, child, surface);
|
||
}
|
||
context.restore();
|
||
return;
|
||
}
|
||
|
||
if (node.shadow) {
|
||
context.shadowColor = node.shadow.color;
|
||
context.shadowBlur = node.shadow.blurRadius;
|
||
context.shadowOffsetX = node.shadow.offsetX;
|
||
context.shadowOffsetY = node.shadow.offsetY;
|
||
}
|
||
|
||
if (node.kind === 'point') {
|
||
context.beginPath();
|
||
context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2);
|
||
if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fill(); }
|
||
if (node.stroke && node.strokeWidth > 0) {
|
||
context.strokeStyle = buildPaint(context, node.stroke);
|
||
context.lineWidth = node.strokeWidth;
|
||
context.stroke();
|
||
}
|
||
} else if (node.kind === 'text') {
|
||
const matrix = node.matrix;
|
||
context.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]);
|
||
context.font = `${node.italic ? 'italic ' : ''}${node.weight === 'bold' ? 'bold ' : ''}${node.size}px ${node.font}`;
|
||
context.textAlign = node.align === 'center' ? 'center' : node.align === 'right' ? 'right' : 'left';
|
||
context.textBaseline = node.baseline;
|
||
if ('letterSpacing' in context) context.letterSpacing = `${node.letterSpacing}px`;
|
||
// 17.13: `maxWidth` condenses horizontally about the align anchor and never
|
||
// wraps or truncates. Canvas 2D's own maxWidth argument does exactly that.
|
||
if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fillText(node.text, 0, 0, node.maxWidth); }
|
||
if (node.stroke && node.strokeWidth > 0) {
|
||
context.strokeStyle = buildPaint(context, node.stroke);
|
||
context.lineWidth = node.strokeWidth;
|
||
context.strokeText(node.text, 0, 0, node.maxWidth);
|
||
}
|
||
} else {
|
||
tracePath(context, node);
|
||
if (node.fill) {
|
||
context.fillStyle = buildPaint(context, node.fill);
|
||
context.fill(node.fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
|
||
}
|
||
if (node.stroke && node.strokeWidth > 0) {
|
||
context.strokeStyle = buildPaint(context, node.stroke);
|
||
context.lineWidth = node.strokeWidth;
|
||
context.lineCap = node.strokeCap;
|
||
context.lineJoin = node.strokeJoin;
|
||
if (node.strokeDash) context.setLineDash(node.strokeDash);
|
||
context.lineDashOffset = node.strokeDashOffset;
|
||
context.stroke();
|
||
}
|
||
}
|
||
|
||
// 17.12: glow is drawn *around* the result, not over it. We offset the
|
||
// geometry far off-screen so only the blurred shadow (the halo) appears.
|
||
if (node.glow && node.glow.strength > 0) {
|
||
context.save();
|
||
context.globalAlpha = node.alpha * node.glow.strength;
|
||
context.shadowColor = node.glow.color;
|
||
context.shadowBlur = node.glow.radius;
|
||
// Offset geometry far away; counter-offset the shadow so it lands in place.
|
||
const glowShift = 1e5;
|
||
context.shadowOffsetX = -glowShift;
|
||
context.shadowOffsetY = 0;
|
||
context.translate(glowShift, 0);
|
||
if (node.kind === 'point') {
|
||
context.beginPath();
|
||
context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2);
|
||
context.fillStyle = 'rgba(0,0,0,1)';
|
||
context.fill();
|
||
} else if (node.kind === 'text') {
|
||
const matrix = node.matrix;
|
||
context.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4] + glowShift, matrix[5]);
|
||
context.shadowOffsetX = -glowShift;
|
||
context.shadowOffsetY = 0;
|
||
context.font = `${node.italic ? 'italic ' : ''}${node.weight === 'bold' ? 'bold ' : ''}${node.size}px ${node.font}`;
|
||
context.textAlign = node.align === 'center' ? 'center' : node.align === 'right' ? 'right' : 'left';
|
||
context.textBaseline = node.baseline;
|
||
context.fillStyle = 'rgba(0,0,0,1)';
|
||
context.fillText(node.text, 0, 0, node.maxWidth);
|
||
} else {
|
||
tracePath(context, node);
|
||
if (node.fill) {
|
||
context.fillStyle = 'rgba(0,0,0,1)';
|
||
context.fill(node.fillRule === 'evenodd' ? 'evenodd' : 'nonzero');
|
||
}
|
||
if (node.stroke && node.strokeWidth > 0) {
|
||
context.strokeStyle = 'rgba(0,0,0,1)';
|
||
context.lineWidth = node.strokeWidth;
|
||
context.stroke();
|
||
}
|
||
}
|
||
context.restore();
|
||
}
|
||
|
||
context.restore();
|
||
}
|
||
|
||
/** A pool of offscreen surfaces for buffered layers, masks, and groups. */
|
||
class SurfacePool {
|
||
constructor(factory, width, height) {
|
||
this.factory = factory;
|
||
this.width = width;
|
||
this.height = height;
|
||
this.free = [];
|
||
this.allocated = 0;
|
||
}
|
||
|
||
create() {
|
||
const reused = this.free.pop();
|
||
if (reused) {
|
||
reused.context.setTransform(1, 0, 0, 1, 0, 0);
|
||
reused.context.clearRect(0, 0, this.width, this.height);
|
||
return reused;
|
||
}
|
||
this.allocated += 1;
|
||
const canvas = this.factory(this.width, this.height);
|
||
return { canvas, context: canvas.getContext('2d') };
|
||
}
|
||
|
||
release(surface) { this.free.push(surface); }
|
||
}
|
||
|
||
/**
|
||
* Render one frame plan into a Canvas 2D context. `createSurface(w, h)` returns
|
||
* an offscreen canvas; in a browser that is `new OffscreenCanvas(w, h)` or a
|
||
* detached `<canvas>`.
|
||
*/
|
||
function renderFrame(context, plan, { createSurface, warnEffect } = {}) {
|
||
const { width, height } = plan.backing;
|
||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||
context.globalAlpha = 1;
|
||
context.globalCompositeOperation = 'source-over';
|
||
if ('filter' in context) context.filter = 'none';
|
||
context.fillStyle = plan.background;
|
||
context.fillRect(0, 0, width, height);
|
||
|
||
const surfaces = createSurface ? new SurfacePool(createSurface, width, height) : null;
|
||
for (const layer of plan.layers) {
|
||
if (!layer.visible || layer.opacity === 0) continue;
|
||
const buffered = layer.buffered && surfaces !== null;
|
||
const target = buffered ? surfaces.create() : null;
|
||
const layerContext = buffered ? target.context : context;
|
||
for (const node of layer.nodes) drawNode(layerContext, node, surfaces ?? { create: () => target, release: () => {} });
|
||
if (buffered) {
|
||
context.save();
|
||
context.globalAlpha = layer.opacity;
|
||
context.globalCompositeOperation = BLEND_OPERATIONS[layer.blend] ?? 'source-over';
|
||
context.drawImage(target.canvas, 0, 0);
|
||
context.restore();
|
||
surfaces.release(target);
|
||
}
|
||
}
|
||
renderVisualEffects(context, plan, warnEffect);
|
||
return { allocated: surfaces?.allocated ?? 0 };
|
||
}
|
||
|
||
/* src/runtime/visual-subsystem.js */
|
||
// The visual subsystem's attachment to a display surface.
|
||
//
|
||
// The engine (visual-engine.js) is renderer-neutral and produces a frame plan;
|
||
// this module owns the canvas element, the display rectangle, and the backing
|
||
// store, and hands each plan to the Canvas 2D backend. A frame between two
|
||
// logical ticks draws the most recent tick's state and advances nothing (9.1).
|
||
|
||
|
||
|
||
class VisualSubsystem {
|
||
constructor({ diagnostics = null } = {}) {
|
||
this.diagnostics = diagnostics;
|
||
this.canvas = null;
|
||
this.context = null;
|
||
this.engine = null;
|
||
this.lastPlan = null;
|
||
this.frames = 0;
|
||
}
|
||
|
||
attach(canvas) {
|
||
this.canvas = canvas;
|
||
this.context = canvas?.getContext ? canvas.getContext('2d') : null;
|
||
return this;
|
||
}
|
||
|
||
/** 17.14: activation is the instantiation boundary of every persistent system. */
|
||
activate(document, { resolution, rng }) {
|
||
this.engine?.dispose();
|
||
if (!document?.visuals) {
|
||
this.engine = null;
|
||
return null;
|
||
}
|
||
const capabilities = { conicGradient: typeof this.context?.createConicGradient === 'function' };
|
||
this.engine = new VisualEngine(document, { resolution, rng, diagnostics: this.diagnostics, capabilities });
|
||
return this.engine;
|
||
}
|
||
|
||
/** One logical tick (9.1): procedural systems advance, nothing draws. */
|
||
advance(deltaMilliseconds) {
|
||
this.engine?.advance(deltaMilliseconds);
|
||
}
|
||
|
||
displayRectangle() {
|
||
const canvas = this.canvas;
|
||
if (!canvas) return { width: 0, height: 0 };
|
||
const width = canvas.clientWidth || canvas.width || 0;
|
||
const height = canvas.clientHeight || canvas.height || 0;
|
||
return { width, height };
|
||
}
|
||
|
||
render({ logicalMilliseconds = 0 } = {}) {
|
||
if (!this.engine || !this.context) return null;
|
||
const { width, height } = this.displayRectangle();
|
||
if (width <= 0 || height <= 0) return null;
|
||
const devicePixelRatio = globalThis.devicePixelRatio ?? 1;
|
||
const plan = this.engine.planFrame({ width, height, devicePixelRatio, logicalMilliseconds });
|
||
if (!plan) return null;
|
||
if (this.canvas.width !== plan.backing.width) this.canvas.width = plan.backing.width;
|
||
if (this.canvas.height !== plan.backing.height) this.canvas.height = plan.backing.height;
|
||
renderFrame(this.context, plan, {
|
||
warnEffect: (effect, message) => this.engine.cadence.once('WARN_VISUAL_APPROXIMATION', `effect:${effect.index}`, message),
|
||
createSurface: (surfaceWidth, surfaceHeight) => {
|
||
if (typeof OffscreenCanvas === 'function') return new OffscreenCanvas(surfaceWidth, surfaceHeight);
|
||
const surface = globalThis.document?.createElement('canvas');
|
||
if (!surface) return null;
|
||
surface.width = surfaceWidth;
|
||
surface.height = surfaceHeight;
|
||
return surface;
|
||
}
|
||
});
|
||
this.lastPlan = plan;
|
||
this.frames += 1;
|
||
return plan;
|
||
}
|
||
|
||
deactivate() {
|
||
this.engine?.dispose();
|
||
if (this.context && this.canvas) {
|
||
this.context.setTransform(1, 0, 0, 1, 0, 0);
|
||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||
}
|
||
this.engine = null;
|
||
this.lastPlan = null;
|
||
}
|
||
}
|
||
|
||
/* src/runtime/actions.js */
|
||
class ActionExecutor {
|
||
constructor(engine, { diagnostics } = {}) {
|
||
this.engine = engine;
|
||
this.diagnostics = diagnostics;
|
||
this.invocationOrdinal = 0;
|
||
this.visual = null;
|
||
this.audio = null;
|
||
this.dispatchUnits = 0;
|
||
}
|
||
|
||
resetBudget() {
|
||
this.dispatchUnits = 0;
|
||
}
|
||
|
||
consumeUnit(context = {}) {
|
||
if (context.isTerminationHook) {
|
||
if ((context.terminationUnits = (context.terminationUnits ?? 0) + 1) > 256) {
|
||
throw new RuntimeFault('ERR_DISPATCH_BUDGET', 'Termination hook action limit (256) exceeded.');
|
||
}
|
||
} else {
|
||
if (this.dispatchUnits >= 1024) {
|
||
throw new RuntimeFault('ERR_DISPATCH_BUDGET', 'Ordinary event/action work exceeded the per-tick dispatch budget (1024).');
|
||
}
|
||
this.dispatchUnits += 1;
|
||
}
|
||
}
|
||
|
||
executeAction(action, context = {}) {
|
||
try {
|
||
const res = this.execute([action], context);
|
||
return res[0]?.status === 'executed';
|
||
} catch (error) {
|
||
const fault = error instanceof RuntimeFault ? error : new RuntimeFault('ERR_ACTION_FAILURE', error.message);
|
||
this.diagnostics?.error?.(fault.code, fault.message);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
get tickActionCount() {
|
||
return this.dispatchUnits;
|
||
}
|
||
|
||
resetTickBudget() {
|
||
this.resetBudget();
|
||
}
|
||
|
||
evaluateValue(spec, stream, path, inputScope = null) {
|
||
if (inputScope) {
|
||
const resolver = new ValueResolver((ref) => {
|
||
if (typeof ref === 'string' && ref.startsWith('inputs.')) {
|
||
const inputName = ref.slice('inputs.'.length);
|
||
if (inputScope && Object.hasOwn(inputScope, inputName)) {
|
||
return inputScope[inputName];
|
||
}
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Event input '${inputName}' is not available here.`, path);
|
||
}
|
||
return this.engine.get(ref);
|
||
});
|
||
return resolver.evaluate(spec, stream, path);
|
||
}
|
||
return this.engine.evaluateValue(spec, stream, path);
|
||
}
|
||
|
||
evaluateCondition(spec, stream, path, inputScope = null) {
|
||
if (inputScope) {
|
||
const resolver = new ValueResolver((ref) => {
|
||
if (typeof ref === 'string' && ref.startsWith('inputs.')) {
|
||
const inputName = ref.slice('inputs.'.length);
|
||
if (inputScope && Object.hasOwn(inputScope, inputName)) {
|
||
return inputScope[inputName];
|
||
}
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Event input '${inputName}' is not available here.`, path);
|
||
}
|
||
return this.engine.get(ref);
|
||
});
|
||
const evaluator = new ConditionEvaluator(resolver);
|
||
return evaluator.evaluate(spec, stream, path);
|
||
}
|
||
return this.engine.evaluateCondition(spec, stream, path);
|
||
}
|
||
|
||
execute(actions, context = {}) {
|
||
if (!Array.isArray(actions)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Actions must be an array.');
|
||
const domain = context.domain ?? 'scenario';
|
||
const stream = context.stream ?? this.engine.rng.stream(domain, `actions:${++this.invocationOrdinal}`);
|
||
const results = [];
|
||
for (let index = 0; index < actions.length; index += 1) {
|
||
if (!context.isTerminationHook && context.owner?.startsWith('instances.scenario-') && this.scenarios && !this.scenarios.active.has(context.owner)) break;
|
||
const action = actions[index];
|
||
this.consumeUnit(context);
|
||
try {
|
||
results.push(this.executeOne(action, stream, context, `$.actions[${index}]`));
|
||
} catch (error) {
|
||
const fault = error instanceof RuntimeFault ? error : new RuntimeFault('ERR_ACTION_FAILURE', error.message);
|
||
this.diagnostics?.error(fault.code, fault.message, { exhibitId: this.engine.document.meta.id, section: 'actions', objectId: action?.id ?? null, property: fault.path });
|
||
results.push({ status: 'failed', error: fault });
|
||
if (action?.critical !== false || fault.code === 'ERR_DISPATCH_BUDGET') throw fault;
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
executeOne(action, stream, context, path) {
|
||
if (!isRecord(action) || typeof action.type !== 'string') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Action requires a type.', path);
|
||
if (context.isTerminationHook && (!['set', 'sound'].includes(action.type) || ['persistent', 'performance'].includes(action.ownership))) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Unsupported termination-hook action.', path);
|
||
if (action.when !== undefined && !this.evaluateCondition(action.when, stream, `${path}.when`, context.inputScope)) return { status: 'skipped', reason: 'condition' };
|
||
if (action.chance !== undefined) {
|
||
if (!Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Action chance must be from 0 through 1.', `${path}.chance`);
|
||
if (stream.nextFloat() >= action.chance) return { status: 'skipped', reason: 'chance' };
|
||
}
|
||
|
||
if (action.type === 'control' && this.scenarios) {
|
||
return this.scenarios.control(action.target?.replace(/^scenarios\./, ''), action.command, context);
|
||
}
|
||
if (action.type === 'set') {
|
||
const value = this.evaluateValue(action.value, stream, `${path}.value`, context.inputScope);
|
||
this.engine.setState(action.target, value, action.transition);
|
||
return { status: 'executed', type: 'set', target: action.target, value };
|
||
}
|
||
if (action.type === 'override') {
|
||
const value = this.evaluateValue(action.value, stream, `${path}.value`, context.inputScope);
|
||
const instanceId = this.engine.overrides.add(action, value, { owner: context.owner, inheritedPriority: context.priority });
|
||
this.engine.resolveAll();
|
||
return { status: 'executed', type: 'override', target: action.target, value, instanceId };
|
||
}
|
||
if (action.type === 'spawn' && this.visual) {
|
||
const inputs = Object.fromEntries(Object.entries(action.with ?? {}).map(([key, value]) => [key, this.evaluateValue(value, stream, `${path}.with.${key}`, context.inputScope)]));
|
||
const lifetime = action.lifetime === undefined ? undefined : this.evaluateValue(action.lifetime, stream, `${path}.lifetime`, context.inputScope);
|
||
const instance = this.visual.spawn(action.target?.replace(/^visuals\.systems\./, ''), { ...context, inputs, lifetime, ownership: action.ownership });
|
||
return { status: instance ? 'executed' : 'refused', type: 'spawn', instanceId: instance?.id ?? null };
|
||
}
|
||
if (action.type === 'remove' && this.visual) {
|
||
// C20: Return refused/failed when the instance ID is unknown.
|
||
const removed = this.visual.remove(action.target);
|
||
return { status: removed ? 'executed' : 'refused', type: 'remove', target: action.target };
|
||
}
|
||
if (action.type === 'event') {
|
||
this.consumeUnit(context);
|
||
const depth = (context.depth ?? 0) + 1;
|
||
if (depth > 16) {
|
||
throw new RuntimeFault('ERR_DISPATCH_BUDGET', `Event nesting depth (${depth}) exceeded maximum allowed depth (16).`, `${path}.event`);
|
||
}
|
||
const eventDef = this.engine.document.events?.[action.event];
|
||
if (!eventDef) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Referenced event '${action.event}' does not exist.`, `${path}.event`);
|
||
}
|
||
|
||
const inputScope = {};
|
||
for (const [inputId, inputSpec] of Object.entries(eventDef.inputs ?? {})) {
|
||
if (inputSpec.default !== undefined) {
|
||
inputScope[inputId] = inputSpec.default;
|
||
}
|
||
}
|
||
for (const [inputId, inputValue] of Object.entries(action.with ?? {})) {
|
||
const inputSpec = eventDef.inputs?.[inputId];
|
||
if (!inputSpec) {
|
||
throw new RuntimeFault('ERR_UNKNOWN_FIELD', `Unknown input '${inputId}' for event '${action.event}'.`, `${path}.with.${inputId}`);
|
||
}
|
||
const evaluated = this.evaluateValue(inputValue, stream, `${path}.with.${inputId}`, context.inputScope);
|
||
if (!valueMatchesType(inputSpec.type, evaluated, inputSpec)) {
|
||
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Input '${inputId}' value does not match declared type '${inputSpec.type}'.`, `${path}.with.${inputId}`);
|
||
}
|
||
inputScope[inputId] = evaluated;
|
||
}
|
||
for (const [inputId, inputSpec] of Object.entries(eventDef.inputs ?? {})) {
|
||
if (!Object.hasOwn(inputScope, inputId) && inputSpec.default === undefined) {
|
||
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', `Required input '${inputId}' not provided for event '${action.event}'.`, `${path}.with.${inputId}`);
|
||
}
|
||
}
|
||
|
||
this.diagnostics?.info?.('INFO_EVENT_INVOKED', `Event '${action.event}' invoked.`, { exhibitId: this.engine.document.meta?.id, section: 'events', objectId: action.event });
|
||
this.scenarios?.notifyEvent(action.event, { ...context, inputScope });
|
||
const results = this.execute(eventDef.actions, {
|
||
...context,
|
||
depth,
|
||
inputScope,
|
||
owner: context.owner
|
||
});
|
||
return { status: 'executed', type: 'event', event: action.event, results };
|
||
}
|
||
if (action.type === 'sound') {
|
||
const soundId = action.sound;
|
||
const targetSound = this.engine.document.sounds?.[soundId];
|
||
if (!targetSound) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Referenced sound '${soundId}' does not exist.`, `${path}.sound`);
|
||
}
|
||
const allowedUsage = targetSound.usage ?? ['automatic'];
|
||
const callerUsage = context.usage ?? (context.domain === 'cadence' ? 'automatic' : 'manual');
|
||
if (!allowedUsage.includes(callerUsage)) {
|
||
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Sound '${soundId}' does not permit usage '${callerUsage}'.`, `${path}.sound`);
|
||
}
|
||
const inputs = action.with ? Object.fromEntries(Object.entries(action.with).map(([k, v]) => [k, this.evaluateValue(v, stream, `${path}.with.${k}`, context.inputScope)])) : undefined;
|
||
const audio = this.audio ?? this.engine.audio;
|
||
const recipe = targetSound.recipe?.use ? this.engine.document.audio?.recipes?.[targetSound.recipe.use] : targetSound.recipe;
|
||
if (context.isTerminationHook && (recipe?.mode ?? 'oneshot') !== 'oneshot') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', 'Termination sounds must be one-shot.', path);
|
||
const owner = ['persistent', 'performance'].includes(action.ownership) ? 'performance' : context.owner ?? 'performance';
|
||
if ((!audio || !audio.unlocked) && recipe?.mode === 'continuous' && this.scenarios && !context.isTerminationHook) {
|
||
return this.scenarios.deferContinuous(soundId, { inputs, owner });
|
||
}
|
||
const voice = audio ? audio.play(soundId, { inputs, owner }) : null;
|
||
return { status: voice ? 'executed' : 'refused', type: 'sound', sound: soundId, voiceId: voice?.id ?? null };
|
||
}
|
||
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Action '${action.type}' belongs to a later subsystem phase.`, `${path}.type`);
|
||
}
|
||
}
|
||
|
||
/* src/runtime/cadence.js */
|
||
const DEFAULT_CLASS_INTERVALS = Object.freeze({
|
||
routine: Object.freeze({ min: 10_000, max: 45_000 }),
|
||
intermittent: Object.freeze({ min: 45_000, max: 240_000 }),
|
||
occasional: Object.freeze({ min: 180_000, max: 900_000 }),
|
||
rare: Object.freeze({ min: 900_000, max: 3_600_000 })
|
||
});
|
||
|
||
const CADENCE_PRIORITY = Object.freeze(['rare', 'occasional', 'intermittent', 'routine']);
|
||
const RECENCY_MULTIPLIERS = Object.freeze([0.0, 0.25, 0.50, 0.75]);
|
||
|
||
class CadenceSubsystem {
|
||
constructor({ document, rng, resolution, audio = null, diagnostics = null } = {}) {
|
||
this.document = document;
|
||
this.rng = rng;
|
||
this.resolution = resolution;
|
||
this.audio = audio;
|
||
this.diagnostics = diagnostics;
|
||
|
||
this.intensity = 1.0;
|
||
this.minGapMs = 1500;
|
||
if (document?.cadence?.minGap) {
|
||
try {
|
||
this.minGapMs = parseDuration(document.cadence.minGap);
|
||
} catch {
|
||
this.minGapMs = 1500;
|
||
}
|
||
}
|
||
|
||
this.logicalMilliseconds = 0;
|
||
this.lastAutomaticSoundTime = -Infinity;
|
||
this.lastFiredTimes = new Map();
|
||
this.ambientInstances = new Map(); // soundId -> voice instance
|
||
|
||
this.classes = {};
|
||
for (const className of CADENCE_PRIORITY) {
|
||
let range = DEFAULT_CLASS_INTERVALS[className];
|
||
if (document?.cadence?.clocks?.[className]) {
|
||
try {
|
||
const authored = document.cadence.clocks[className];
|
||
range = {
|
||
min: parseDuration(authored.min),
|
||
max: parseDuration(authored.max)
|
||
};
|
||
} catch {
|
||
range = DEFAULT_CLASS_INTERVALS[className];
|
||
}
|
||
}
|
||
this.classes[className] = {
|
||
name: className,
|
||
range,
|
||
recencyHistory: [],
|
||
ordinal: 0,
|
||
timer: 0,
|
||
due: false
|
||
};
|
||
}
|
||
|
||
this.initializeTimers();
|
||
}
|
||
|
||
setAudio(audio) {
|
||
this.audio = audio;
|
||
}
|
||
|
||
initializeTimers() {
|
||
this.updateIntensity();
|
||
for (const className of CADENCE_PRIORITY) {
|
||
const cls = this.classes[className];
|
||
cls.ordinal += 1;
|
||
const stream = this.rng.stream('cadence', `${className}:interval:${cls.ordinal}`);
|
||
const baseMs = cls.range.min + stream.nextFloat() * (cls.range.max - cls.range.min);
|
||
cls.timer = this.intensity > 1e-6 ? baseMs / this.intensity : Infinity;
|
||
cls.due = false;
|
||
}
|
||
}
|
||
|
||
setIntensity(value) {
|
||
this.intensity = clamp(value, 0, 10);
|
||
}
|
||
|
||
getEffectiveInterval(className) {
|
||
const range = this.classes[className]?.range ?? DEFAULT_CLASS_INTERVALS[className];
|
||
const intensity = Math.max(1e-6, this.intensity);
|
||
return {
|
||
min: Math.max(100, Math.round(range.min / intensity)),
|
||
max: Math.max(100, Math.round(range.max / intensity))
|
||
};
|
||
}
|
||
|
||
updateIntensity() {
|
||
if (this.document?.cadence?.intensity !== undefined && this.resolution) {
|
||
try {
|
||
const stream = this.rng.stream('cadence', `intensity:${this.logicalMilliseconds}`);
|
||
const resolved = this.resolution.evaluateValue(this.document.cadence.intensity, stream, '$.cadence.intensity');
|
||
if (Number.isFinite(resolved)) {
|
||
this.intensity = clamp(resolved, 0, 1);
|
||
}
|
||
} catch {
|
||
this.intensity = 1.0;
|
||
}
|
||
} else {
|
||
this.intensity = 1.0;
|
||
}
|
||
}
|
||
|
||
hasActiveVoice(soundId) {
|
||
if (!this.audio) return false;
|
||
const activeStates = ['SCHEDULED', 'ACTIVE', 'RELEASING'];
|
||
if (typeof this.audio.getActiveSounds === 'function') {
|
||
for (const active of this.audio.getActiveSounds()) {
|
||
if (active.soundId === soundId) return true;
|
||
}
|
||
}
|
||
if (this.audio.activeInstances instanceof Map) {
|
||
for (const inst of this.audio.activeInstances.values()) {
|
||
if (inst.soundId === soundId && !inst.disposed) return true;
|
||
}
|
||
}
|
||
if (this.audio.voices) {
|
||
for (const voice of this.audio.voices) {
|
||
if (voice.soundId === soundId && activeStates.includes(voice.state)) return true;
|
||
}
|
||
}
|
||
for (const voice of this.audio.oneshotVoices ?? []) {
|
||
if (voice.soundId === soundId && activeStates.includes(voice.state)) return true;
|
||
}
|
||
for (const voice of this.audio.continuousVoices ?? []) {
|
||
if (voice.soundId === soundId && activeStates.includes(voice.state)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
update(timeMs) {
|
||
const step = timeMs - this.logicalMilliseconds;
|
||
this.advance(Math.max(0, step));
|
||
}
|
||
|
||
getLastEvaluatedPool(className) {
|
||
return this.classes[className]?.lastEvaluatedPool ?? [];
|
||
}
|
||
|
||
advance(stepMs) {
|
||
this.logicalMilliseconds += stepMs;
|
||
this.updateIntensity();
|
||
|
||
// 1. Advance ambient maintenance
|
||
this.maintainAmbience();
|
||
|
||
// 2. If intensity <= 0, pause automatic one-shot scheduling
|
||
if (this.intensity <= 1e-6) {
|
||
return;
|
||
}
|
||
|
||
// 3. Advance class timers
|
||
for (const className of CADENCE_PRIORITY) {
|
||
const cls = this.classes[className];
|
||
if (!cls.due) {
|
||
cls.timer -= stepMs;
|
||
if (cls.timer <= 1e-7) {
|
||
cls.due = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. Check minGap and service highest priority due class
|
||
if (this.logicalMilliseconds - this.lastAutomaticSoundTime >= this.minGapMs) {
|
||
for (const className of CADENCE_PRIORITY) {
|
||
const cls = this.classes[className];
|
||
if (cls.due) {
|
||
const fired = this.fireClass(cls);
|
||
if (fired) {
|
||
this.lastAutomaticSoundTime = this.logicalMilliseconds;
|
||
cls.due = false;
|
||
// Reschedule next timer
|
||
cls.ordinal += 1;
|
||
const stream = this.rng.stream('cadence', `${className}:interval:${cls.ordinal}`);
|
||
const baseMs = cls.range.min + stream.nextFloat() * (cls.range.max - cls.range.min);
|
||
cls.timer = baseMs / Math.max(1e-6, this.intensity);
|
||
break; // Only one class sound per gap window
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
maintainAmbience() {
|
||
if (!this.audio) return;
|
||
if (this.audio.unlocked === false) return;
|
||
const sounds = this.document?.sounds ?? {};
|
||
for (const [soundId, sound] of Object.entries(sounds)) {
|
||
if (sound.cadence?.class === 'ambient') {
|
||
const allowedUsage = sound.usage ?? ['automatic'];
|
||
if (!allowedUsage.includes('automatic')) continue;
|
||
|
||
const currentVoice = this.ambientInstances.get(soundId);
|
||
const isRunning = currentVoice && !currentVoice.disposed && (currentVoice.state === undefined || currentVoice.state === 'ACTIVE' || currentVoice.state === 'SCHEDULED');
|
||
if (!isRunning) {
|
||
const voice = this.audio.play(soundId, { owner: 'performance' });
|
||
if (voice) {
|
||
this.ambientInstances.set(soundId, voice);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fireClass(cls) {
|
||
const className = cls.name;
|
||
const sounds = this.document?.sounds ?? {};
|
||
const eligible = [];
|
||
|
||
for (const [soundId, sound] of Object.entries(sounds)) {
|
||
if (sound.cadence?.class !== className) continue;
|
||
const allowedUsage = sound.usage ?? ['automatic'];
|
||
if (!allowedUsage.includes('automatic')) continue;
|
||
|
||
// Check condition
|
||
if (sound.cadence.when !== undefined && this.resolution) {
|
||
const condStream = this.rng.stream('cadence', `${className}:when:${soundId}:${cls.ordinal}`);
|
||
if (!this.resolution.evaluateCondition(sound.cadence.when, condStream, `$.sounds.${soundId}.cadence.when`)) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Check cooldown
|
||
const lastFired = this.lastFiredTimes.get(soundId) ?? -Infinity;
|
||
let cooldownMs = 0;
|
||
if (sound.cadence.cooldown) {
|
||
try { cooldownMs = parseDuration(sound.cadence.cooldown); } catch { cooldownMs = 0; }
|
||
}
|
||
if (this.logicalMilliseconds - lastFired < cooldownMs) {
|
||
continue;
|
||
}
|
||
|
||
// Check overlap
|
||
if (sound.cadence.overlap === false) {
|
||
if (this.hasActiveVoice(soundId)) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
eligible.push({ id: soundId, sound });
|
||
}
|
||
|
||
if (eligible.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// Evaluate base weights
|
||
const weightStream = this.rng.stream('cadence', `${className}:weight:${cls.ordinal}`);
|
||
const evaluated = [];
|
||
for (const candidate of eligible) {
|
||
let baseWeight = 1.0;
|
||
if (candidate.sound.cadence.weight !== undefined) {
|
||
if (typeof candidate.sound.cadence.weight === 'number') {
|
||
baseWeight = candidate.sound.cadence.weight;
|
||
} else if (this.resolution) {
|
||
baseWeight = this.resolution.evaluateValue(candidate.sound.cadence.weight, weightStream, `$.sounds.${candidate.id}.cadence.weight`);
|
||
}
|
||
}
|
||
baseWeight = Math.max(0, Number.isFinite(baseWeight) ? baseWeight : 0);
|
||
|
||
// Anti-repetition multiplier
|
||
const historyIndex = cls.recencyHistory.indexOf(candidate.id);
|
||
let multiplier = 1.0;
|
||
if (historyIndex >= 0 && historyIndex < RECENCY_MULTIPLIERS.length) {
|
||
multiplier = RECENCY_MULTIPLIERS[historyIndex];
|
||
}
|
||
|
||
evaluated.push({
|
||
id: candidate.id,
|
||
sound: candidate.sound,
|
||
baseWeight,
|
||
effectiveWeight: baseWeight * multiplier
|
||
});
|
||
}
|
||
|
||
let totalWeight = evaluated.reduce((sum, item) => sum + item.effectiveWeight, 0);
|
||
|
||
// Pool relaxation: if all effective weights are 0, relax penalties to base weights
|
||
if (totalWeight <= 1e-9) {
|
||
for (const item of evaluated) {
|
||
item.effectiveWeight = item.baseWeight;
|
||
}
|
||
totalWeight = evaluated.reduce((sum, item) => sum + item.effectiveWeight, 0);
|
||
}
|
||
|
||
cls.lastEvaluatedPool = evaluated;
|
||
|
||
if (totalWeight <= 1e-9) {
|
||
return null;
|
||
}
|
||
|
||
// Weighted random selection
|
||
const selectStream = this.rng.stream('cadence', `${className}:select:${cls.ordinal}`);
|
||
const roll = selectStream.nextFloat() * totalWeight;
|
||
let accumulated = 0;
|
||
let chosen = null;
|
||
for (const item of evaluated) {
|
||
if (item.effectiveWeight <= 1e-9) continue;
|
||
accumulated += item.effectiveWeight;
|
||
if (roll <= accumulated) {
|
||
chosen = item;
|
||
break;
|
||
}
|
||
}
|
||
if (!chosen) {
|
||
chosen = evaluated.find(item => item.effectiveWeight > 1e-9) ?? evaluated[0];
|
||
}
|
||
|
||
// Play chosen sound
|
||
this.lastFiredTimes.set(chosen.id, this.logicalMilliseconds);
|
||
cls.recencyHistory.unshift(chosen.id);
|
||
if (cls.recencyHistory.length > 4) {
|
||
cls.recencyHistory.pop();
|
||
}
|
||
|
||
if (this.audio) {
|
||
this.audio.play(chosen.id, { owner: 'performance' });
|
||
}
|
||
|
||
return chosen.id;
|
||
}
|
||
}
|
||
|
||
/* src/runtime/scenario.js */
|
||
const SCENARIO_EPSILON = 1e-6;
|
||
|
||
class ScenarioDirector {
|
||
constructor({ document, resolution, actions, rng, diagnostics = null, onTrace = null }) {
|
||
Object.assign(this, { document, resolution, actions, rng, diagnostics, onTrace });
|
||
this.active = new Map();
|
||
this.pending = new Map();
|
||
this.cleanups = new Map();
|
||
this.continuousRequests = [];
|
||
this.definitions = new Map();
|
||
this.disposed = false;
|
||
this.now = resolution.logicalMilliseconds;
|
||
let order = 0;
|
||
for (const [id, definition] of Object.entries(document.scenarios ?? {})) {
|
||
const trigger = definition.trigger ?? { type: 'manual' };
|
||
const stream = rng.stream('scenario', `${id}:trigger:1`);
|
||
const state = { id, definition, trigger, order: order++, priority: definition.priority ?? 50,
|
||
enabled: definition.enabled !== false, ordinal: 0, lastEnd: -Infinity, stream,
|
||
conditionState: 'disarmed', holdAt: null, next: Infinity, status: 'DEFINED', lastState: null };
|
||
if (trigger.type === 'once') state.next = parseDuration(trigger.at ?? '0ms');
|
||
if (['interval', 'probability'].includes(trigger.type)) state.next = parseDuration(trigger.every);
|
||
if (trigger.type === 'random-interval') state.next = this.sampleTime({ random: trigger }, stream);
|
||
this.definitions.set(id, state);
|
||
}
|
||
actions.scenarios = this;
|
||
}
|
||
|
||
sampleTime(spec, stream) {
|
||
if (typeof spec === 'string' || typeof spec === 'number') return parseDuration(spec);
|
||
const min = parseDuration(spec.random.min), max = parseDuration(spec.random.max);
|
||
return min + stream.nextFloat() * (max - min);
|
||
}
|
||
|
||
trace(type, instance, details = {}) {
|
||
this.onTrace?.({ type, time: this.now, scenario: instance.definitionId ?? instance.id, instance: instance.owner ?? null, ...details });
|
||
}
|
||
|
||
audioVoices() {
|
||
const audio = this.actions.audio;
|
||
return [...new Set([...(audio?.voices ?? []), ...(audio?.oneshotVoices ?? []), ...(audio?.continuousVoices ?? [])])];
|
||
}
|
||
|
||
deferContinuous(soundId, options) {
|
||
if (this.continuousRequests.length >= 16) return { status: 'refused', type: 'sound' };
|
||
this.continuousRequests.push({ soundId, options });
|
||
return { status: 'scheduled', type: 'sound' };
|
||
}
|
||
|
||
resumeContinuous() {
|
||
if (!this.actions.audio?.unlocked) return;
|
||
for (const request of this.continuousRequests.splice(0)) this.actions.audio.play(request.soundId, request.options);
|
||
}
|
||
|
||
eligible(state) {
|
||
return state.enabled && this.now + SCENARIO_EPSILON >= state.lastEnd + parseDuration(state.definition.cooldown ?? '0ms')
|
||
&& (!state.definition.eligibility?.when || this.resolution.evaluateCondition(state.definition.eligibility.when, state.stream));
|
||
}
|
||
|
||
conflicts(state) {
|
||
return [...this.active.values()].filter(instance => {
|
||
if (instance.definitionId === state.id) return true;
|
||
const other = this.definitions.get(instance.definitionId);
|
||
const a = state.definition.concurrency ?? {}, b = other.definition.concurrency ?? {};
|
||
const blocks = (config, left, right) => config.mode === 'exclusive'
|
||
&& ((config.scope ?? 'global') === 'global' || left.definition.group === right.definition.group);
|
||
return blocks(a, state, other) || blocks(b, other, state);
|
||
});
|
||
}
|
||
|
||
request(id, context = {}) {
|
||
if (this.disposed) return { status: 'refused' };
|
||
const state = this.definitions.get(id);
|
||
if (!state) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown scenario '${id}'.`);
|
||
if (this.pending.has(id)) return { status: 'deferred' };
|
||
this.pending.set(id, { id, created: this.now, expires: this.now + parseDuration(state.definition.eligibility?.timeout ?? '5m'),
|
||
sourceOwner: context.owner ?? 'performance', triggerInputs: context.inputScope ? { ...context.inputScope } : null, attempted: false });
|
||
if (![...this.active.values()].some(i => i.definitionId === id)) state.status = 'SCHEDULED';
|
||
return { status: 'scheduled' };
|
||
}
|
||
|
||
notifyEvent(id, context = {}) {
|
||
if (this.disposed || context.isTerminationHook) return;
|
||
for (const state of this.definitions.values()) if (state.trigger.type === 'event' && state.trigger.event === id) this.request(state.id, context);
|
||
}
|
||
|
||
control(id, command, context = {}) {
|
||
const state = this.definitions.get(id);
|
||
if (!state) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown scenario '${id}'.`);
|
||
if (command === 'start') return this.request(id, context);
|
||
if (command === 'stop') { this.cancel(id); return { status: 'executed' }; }
|
||
if (command === 'enable' || command === 'disable') {
|
||
state.enabled = command === 'enable';
|
||
return { status: 'executed' };
|
||
}
|
||
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Unsupported scenario command '${command}'.`);
|
||
}
|
||
|
||
cancel(id) {
|
||
this.pending.delete(id);
|
||
for (const instance of [...this.active.values()]) if (instance.definitionId === id || instance.owner === id) this.terminate(instance, 'CANCELLED');
|
||
}
|
||
|
||
sampleTimeline(state, stream) {
|
||
const entries = state.definition.timeline;
|
||
const byId = new Map(entries.map((entry, i) => [entry.id, i]));
|
||
const times = new Map();
|
||
const at = index => {
|
||
if (times.has(index)) return times.get(index);
|
||
const entry = entries[index];
|
||
const due = entry.at !== undefined ? parseDuration(entry.at) : at(byId.get(entry.after)) + this.sampleTime(entry.delay ?? '0ms', stream);
|
||
times.set(index, due); return due;
|
||
};
|
||
return entries.map((entry, index) => ({ entry, index, due: at(index), beat: 0 }));
|
||
}
|
||
|
||
start(state) {
|
||
const ordinal = ++state.ordinal;
|
||
const owner = `instances.scenario-${state.id}-${ordinal}`;
|
||
const instance = { owner, definitionId: state.id, priority: state.priority, order: state.order, started: this.now,
|
||
stream: this.rng.stream('scenario', `${state.id}:${ordinal}`), state: 'STARTING', records: [],
|
||
end: state.definition.duration === undefined ? Infinity : parseDuration(state.definition.duration) };
|
||
this.active.set(owner, instance);
|
||
this.resolution.signals.set('signals.scenario.active', true);
|
||
this.resolution.invalidate();
|
||
state.status = 'STARTING';
|
||
this.trace('start', instance);
|
||
try {
|
||
this.actions.execute(state.definition.onStart ?? [], this.context(instance));
|
||
if (!this.active.has(owner)) return;
|
||
instance.records = this.sampleTimeline(state, instance.stream);
|
||
instance.state = state.status = 'ACTIVE';
|
||
} catch (error) { this.fail(instance, error); }
|
||
}
|
||
|
||
context(instance) { return { owner: instance.owner, origin: instance.owner, priority: instance.priority, domain: 'scenario', usage: 'scenario', stream: instance.stream }; }
|
||
|
||
fail(instance, error) {
|
||
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', objectId: instance.definitionId, owner: instance.owner, tick: this.resolution.tickIndex });
|
||
this.terminate(instance, 'FAILED');
|
||
}
|
||
|
||
evaluateTriggers() {
|
||
for (const state of this.definitions.values()) {
|
||
const t = state.trigger;
|
||
if (t.type === 'condition') {
|
||
const truth = this.resolution.evaluateCondition(t.when, state.stream);
|
||
if (!truth) { state.conditionState = 'armed'; state.holdAt = null; }
|
||
else if (state.conditionState !== 'disarmed') {
|
||
if (state.holdAt === null) state.holdAt = this.now;
|
||
state.conditionState = 'holding';
|
||
if (this.now - state.holdAt + SCENARIO_EPSILON >= parseDuration(t.for ?? '0ms')) {
|
||
state.conditionState = 'disarmed'; this.request(state.id);
|
||
}
|
||
}
|
||
} else if (state.next <= this.now + SCENARIO_EPSILON) {
|
||
if (t.type !== 'probability' || state.stream.nextFloat() < t.chance) this.request(state.id);
|
||
if (t.type === 'once') state.next = Infinity;
|
||
else state.next += t.type === 'random-interval' ? this.sampleTime({ random: t }, state.stream) : parseDuration(t.every);
|
||
// No catch-up burst for sub-tick authoring intervals.
|
||
if (state.next <= this.now) state.next = this.now + 1000 / 60;
|
||
}
|
||
}
|
||
}
|
||
|
||
dispatchPending() {
|
||
let started = false;
|
||
const queue = [...this.pending.values()].sort((a, b) => this.definitions.get(b.id).priority - this.definitions.get(a.id).priority || a.created - b.created || this.definitions.get(a.id).order - this.definitions.get(b.id).order);
|
||
for (const request of queue) {
|
||
if (!this.pending.has(request.id)) continue;
|
||
const state = this.definitions.get(request.id);
|
||
const policy = state.definition.concurrency?.policy ?? 'defer';
|
||
const conflicts = this.conflicts(state);
|
||
const allowed = this.eligible(state);
|
||
const canReplace = conflicts.length && policy === 'replace' && conflicts.every(i => state.priority > i.priority);
|
||
if (!allowed || (conflicts.length && !canReplace) || this.active.size - (canReplace ? conflicts.length : 0) >= SCENARIO_LIMITS.active) {
|
||
request.attempted = true;
|
||
if (policy !== 'defer') { this.pending.delete(request.id); state.status = 'DEFINED'; }
|
||
continue;
|
||
}
|
||
this.actions.consumeUnit({ owner: request.sourceOwner });
|
||
this.pending.delete(request.id);
|
||
if (canReplace) for (const instance of conflicts) this.terminate(instance, 'CANCELLED');
|
||
this.start(state); started = true;
|
||
}
|
||
return started;
|
||
}
|
||
|
||
nextRecord() {
|
||
const due = [];
|
||
for (const instance of this.active.values()) for (const record of instance.records) {
|
||
if (record.due <= instance.end + SCENARIO_EPSILON && instance.started + record.due <= this.now + SCENARIO_EPSILON) due.push({ instance, record });
|
||
}
|
||
due.sort((a, b) => (a.instance.started + a.record.due) - (b.instance.started + b.record.due) || a.instance.order - b.instance.order || a.record.index - b.record.index);
|
||
return due[0];
|
||
}
|
||
|
||
dispatchRecord(instance, record) {
|
||
this.actions.consumeUnit(this.context(instance)); // even an empty/skipped beat is bounded
|
||
const entry = record.entry;
|
||
let list = entry.actions ?? [];
|
||
if (entry.choose) {
|
||
const branches = entry.choose.filter(branch => branch.weight > 0 && (!branch.when || this.resolution.evaluateCondition(branch.when, instance.stream)));
|
||
// Normalize first, so finite large weights cannot overflow their sum.
|
||
const max = Math.max(0, ...branches.map(branch => branch.weight));
|
||
const sum = branches.reduce((n, branch) => n + branch.weight / max, 0);
|
||
let roll = instance.stream.nextFloat() * sum;
|
||
const branch = branches.find(branch => (roll -= branch.weight / max) < 0);
|
||
list = branch?.actions ?? [];
|
||
this.trace('branch', instance, { entry: record.index, branch: branch ? entry.choose.indexOf(branch) : null });
|
||
}
|
||
this.trace('beat', instance, { entry: record.index, beat: record.beat, due: record.due });
|
||
this.actions.execute(list, this.context(instance));
|
||
if (!this.active.has(instance.owner)) return;
|
||
record.beat += 1;
|
||
if (record.beat < (entry.repeat?.count ?? 1)) record.due += this.sampleTime(entry.repeat.every, instance.stream);
|
||
else instance.records.splice(instance.records.indexOf(record), 1);
|
||
}
|
||
|
||
update() {
|
||
if (this.disposed) return;
|
||
this.now = this.resolution.logicalMilliseconds;
|
||
this.resumeContinuous();
|
||
this.advanceCleanup();
|
||
for (const [id, request] of this.pending) if (request.expires <= this.now + SCENARIO_EPSILON) { this.pending.delete(id); this.definitions.get(id).status = 'DEFINED'; }
|
||
try {
|
||
this.evaluateTriggers();
|
||
// Work generated by an event/control joins this tick's bounded drain.
|
||
for (;;) {
|
||
const started = this.dispatchPending();
|
||
const next = this.nextRecord();
|
||
if (next) {
|
||
try { this.dispatchRecord(next.instance, next.record); }
|
||
catch (error) { this.fail(next.instance, error); if (error.code === 'ERR_DISPATCH_BUDGET') throw error; }
|
||
continue;
|
||
}
|
||
let completed = false;
|
||
for (const instance of [...this.active.values()]) if (this.now - instance.started + SCENARIO_EPSILON >= instance.end || (instance.end === Infinity && instance.records.length === 0)) {
|
||
this.terminate(instance, 'COMPLETED'); completed = true;
|
||
}
|
||
if (!started && !completed) break;
|
||
}
|
||
} catch (error) {
|
||
// Only owners implicated in this tick's discarded work fail.
|
||
const discarded = [...this.pending.values()].filter(r => !r.attempted);
|
||
const owners = new Set(discarded.map(r => r.sourceOwner));
|
||
for (const i of this.active.values()) if (i.records.some(r => i.started + r.due <= this.now + SCENARIO_EPSILON)) owners.add(i.owner);
|
||
for (const request of discarded) this.pending.delete(request.id);
|
||
for (const owner of owners) if (this.active.has(owner)) this.fail(this.active.get(owner), error);
|
||
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', tick: this.resolution.tickIndex });
|
||
}
|
||
this.resolution.signals.set('signals.scenario.active', this.active.size > 0);
|
||
this.resolution.invalidate();
|
||
}
|
||
|
||
terminate(instance, cause) {
|
||
if (!this.active.has(instance.owner)) return;
|
||
this.active.delete(instance.owner); // blocks reentrant dispatch before the hook
|
||
this.resolution.signals.set('signals.scenario.active', this.active.size > 0);
|
||
this.resolution.invalidate();
|
||
instance.records.length = 0;
|
||
const state = this.definitions.get(instance.definitionId);
|
||
instance.state = cause === 'COMPLETED' ? 'COMPLETING' : 'CANCELLING';
|
||
const cleanupOwner = `cleanup:${instance.owner}`;
|
||
const hook = cause === 'COMPLETED' ? state.definition.onComplete : state.definition.onCancel;
|
||
const context = { ...this.context(instance), owner: cleanupOwner, isTerminationHook: true, terminationUnits: 0 };
|
||
try {
|
||
for (const action of hook ?? []) {
|
||
try { this.actions.execute([action], context); }
|
||
catch (error) {
|
||
this.diagnostics?.error?.(error.code ?? 'ERR_ACTION_FAILURE', error.message, { section: 'scenarios', objectId: state.id });
|
||
if (context.terminationUnits > 256) break;
|
||
}
|
||
}
|
||
} finally {
|
||
const until = this.now + SCENARIO_LIMITS.cleanupMs;
|
||
const overrides = this.resolution.overrides;
|
||
for (const override of [...overrides.instances.values()]) if (override.owner === instance.owner) {
|
||
override.owner = cleanupOwner; override.releaseMs = Math.min(override.releaseMs, SCENARIO_LIMITS.cleanupMs);
|
||
overrides.beginRelease(override.id);
|
||
}
|
||
this.continuousRequests = this.continuousRequests.filter(r => r.options.owner !== instance.owner);
|
||
for (const voice of this.audioVoices()) if (voice.owner === instance.owner || voice.owner === cleanupOwner) {
|
||
voice.owner = cleanupOwner;
|
||
voice.releaseMs = Math.min(voice.releaseMs, SCENARIO_LIMITS.cleanupMs);
|
||
if (voice.mode === 'continuous') voice.stop();
|
||
// Logical cleanup also works with native audio paused or accelerated tests.
|
||
voice.cleanupAt = Math.min(until, this.now + (voice.mode === 'continuous' ? voice.releaseMs : (voice.endingBoundMs ?? SCENARIO_LIMITS.cleanupMs) + voice.releaseMs));
|
||
}
|
||
this.actions.visual?.cleanup(instance.owner);
|
||
this.cleanups.set(cleanupOwner, { owner: cleanupOwner, origin: instance.owner, until });
|
||
instance.state = state.lastState = cause;
|
||
state.status = cause; state.lastEnd = this.now;
|
||
this.trace('terminate', instance, { cause });
|
||
this.advanceCleanup();
|
||
}
|
||
}
|
||
|
||
advanceCleanup(force = false) {
|
||
for (const [id, cleanup] of this.cleanups) {
|
||
const expired = force || this.now + SCENARIO_EPSILON >= cleanup.until;
|
||
let remaining = 0, forced = false;
|
||
for (const voice of this.audioVoices()) if (voice.owner === id) {
|
||
if (expired || this.now + SCENARIO_EPSILON >= voice.cleanupAt) { voice.dispose(); forced ||= expired && voice.cleanupAt > this.now; }
|
||
else remaining++;
|
||
}
|
||
for (const override of [...this.resolution.overrides.instances.values()]) if (override.owner === id) {
|
||
if (expired) { this.resolution.overrides.instances.delete(override.id); forced = true; }
|
||
else remaining++;
|
||
}
|
||
const visual = this.actions.visual;
|
||
for (const v of [...(visual?.instances.values() ?? [])]) if (v.owner === cleanup.origin || (v.origin === cleanup.origin && v.cancelWithScenario)) {
|
||
if (expired) {
|
||
if (v.state !== 'FINISHED') v.transition('FINISHED');
|
||
v.transition('DISPOSED'); visual.instances.delete(v.id);
|
||
visual.systems = visual.systems.filter(s => s !== v.system); forced = true;
|
||
} else remaining++;
|
||
}
|
||
if (forced && !force) this.diagnostics?.warn?.('WARN_CLEANUP_FORCED', 'Scenario cleanup reached its five-second deadline.', { section: 'scenarios', owner: id });
|
||
if (!remaining || expired) this.cleanups.delete(id);
|
||
}
|
||
this.resolution.invalidate();
|
||
}
|
||
|
||
counters() {
|
||
const voices = this.audioVoices();
|
||
const visual = this.actions.visual;
|
||
return { scenarios: this.active.size, deferred: this.pending.size, cleanupOwners: this.cleanups.size,
|
||
schedulerRecords: [...this.active.values()].reduce((n, i) => n + i.records.length, 0) + this.pending.size + this.continuousRequests.length,
|
||
subscriptions: this.disposed ? 0 : [...this.definitions.values()].filter(s => s.trigger.type === 'event').length,
|
||
overrides: this.resolution.overrides.instances.size, audioVoices: voices.length,
|
||
audioNodes: voices.reduce((n, v) => n + (v.plan?.nodes?.length ?? 0), 0),
|
||
visualInstances: visual?.instances.size ?? 0, visualSystems: visual?.systems.length ?? 0 };
|
||
}
|
||
|
||
dispose() {
|
||
if (this.disposed) return;
|
||
this.disposed = true;
|
||
this.now = this.resolution.logicalMilliseconds;
|
||
this.pending.clear();
|
||
this.continuousRequests.length = 0;
|
||
for (const instance of [...this.active.values()]) this.terminate(instance, 'CANCELLED');
|
||
this.advanceCleanup(true);
|
||
this.resolution.signals.set('signals.scenario.active', false);
|
||
this.resolution.invalidate();
|
||
this.actions.scenarios = null;
|
||
}
|
||
}
|
||
|
||
/* src/runtime/performance.js */
|
||
class CommonGrammarPerformance {
|
||
constructor(record, rootSeed, options = {}) {
|
||
this.record = record;
|
||
this.rootSeed = rootSeed;
|
||
this.rng = new SeededRNG(rootSeed);
|
||
this.diagnostics = options.diagnostics;
|
||
this.onUpdate = options.onUpdate ?? (() => {});
|
||
// Called once per animation frame, after any logical ticks. A frame draws
|
||
// the most recent tick's state and advances nothing (9.1, 18.2).
|
||
this.onFrame = options.onFrame ?? (() => {});
|
||
// Called once per logical tick, with the fixed step of 9.1. Procedural
|
||
// motion advances here and nowhere else.
|
||
this.onTick = options.onTick ?? (() => {});
|
||
this.engine = new ResolutionEngine(record.document, this.rng, {
|
||
diagnostics: this.diagnostics,
|
||
initialParameters: options.initialParameters,
|
||
onParameterChange: options.onParameterChange
|
||
});
|
||
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
|
||
this.actions.audio = options.audio ?? null;
|
||
if (this.actions.audio) this.actions.audio.useLogicalClock = true;
|
||
this.scenarios = new ScenarioDirector({ document: record.document, resolution: this.engine, actions: this.actions, rng: this.rng, diagnostics: this.diagnostics, onTrace: options.onScenarioTrace });
|
||
this.cadence = new CadenceSubsystem({
|
||
document: record.document,
|
||
rng: this.rng,
|
||
resolution: this.engine,
|
||
audio: options.audio ?? null,
|
||
diagnostics: this.diagnostics
|
||
});
|
||
this.state = 'prepared';
|
||
this.resources = new Set();
|
||
this.frameRequest = null;
|
||
this.lastFrame = undefined;
|
||
this.accumulator = 0;
|
||
this.pauseReasons = new Set();
|
||
this.boundFrame = (now) => this.frame(now);
|
||
this.boundPointer = (event) => {
|
||
this.engine.signals.set('signals.pointer.x', event.clientX);
|
||
this.engine.signals.set('signals.pointer.y', event.clientY);
|
||
this.engine.invalidate();
|
||
};
|
||
this.boundResize = () => {
|
||
this.engine.signals.set('signals.viewport.width', globalThis.innerWidth ?? 0);
|
||
this.engine.signals.set('signals.viewport.height', globalThis.innerHeight ?? 0);
|
||
this.engine.invalidate();
|
||
};
|
||
}
|
||
|
||
async activate() {
|
||
if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`);
|
||
this.state = 'active';
|
||
this.onUpdate(this.engine);
|
||
if (typeof globalThis.addEventListener === 'function') {
|
||
globalThis.addEventListener('pointermove', this.boundPointer, { passive: true });
|
||
globalThis.addEventListener('resize', this.boundResize, { passive: true });
|
||
}
|
||
if (typeof globalThis.requestAnimationFrame === 'function') this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
||
}
|
||
|
||
frame(now) {
|
||
if (this.state !== 'active') return;
|
||
if (this.lastFrame === undefined) this.lastFrame = now;
|
||
const observed = Math.max(0, now - this.lastFrame);
|
||
this.lastFrame = now;
|
||
const accepted = Math.min(observed, 250);
|
||
if (this.pauseReasons.size) {
|
||
this.onFrame(this.engine);
|
||
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
||
return;
|
||
}
|
||
this.accumulator += accepted;
|
||
const step = 1000 / 60;
|
||
let ticks = 0;
|
||
while (this.accumulator + 1e-9 >= step && ticks < 8) {
|
||
this.tick();
|
||
this.accumulator -= step;
|
||
ticks += 1;
|
||
}
|
||
if (observed > 250 || this.accumulator >= step) {
|
||
this.accumulator = Math.min(this.accumulator, step - 1e-9);
|
||
this.diagnostics?.warn('WARN_CLOCK_STALL', 'Discarded excess elapsed time to preserve the fixed-step work bound.', { exhibitId: this.record.id, section: 'scheduler' });
|
||
}
|
||
if (ticks > 0) this.onUpdate(this.engine);
|
||
this.onFrame(this.engine);
|
||
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
|
||
}
|
||
|
||
setAudio(audio) {
|
||
this.actions.audio = audio;
|
||
if (audio) audio.useLogicalClock = true;
|
||
this.cadence.setAudio(audio);
|
||
}
|
||
|
||
tick() {
|
||
if (this.pauseReasons.size || this.scenarios.disposed) return;
|
||
const step = 1000 / 60;
|
||
this.actions.resetBudget();
|
||
this.engine.advance(step);
|
||
this.actions.audio?.advanceLogical?.();
|
||
this.cadence.advance(step);
|
||
this.scenarios.update();
|
||
this.onTick(step, this.engine);
|
||
}
|
||
|
||
// Development acceleration executes the same fixed ticks without rendering.
|
||
advanceTicks(count) {
|
||
if (!Number.isSafeInteger(count) || count < 0) throw new RangeError('Tick count must be a nonnegative safe integer.');
|
||
for (let i = 0; i < count; i++) this.tick();
|
||
}
|
||
|
||
pause(reason = 'user') { this.pauseReasons.add(reason); this.lastFrame = undefined; this.accumulator = 0; }
|
||
resume(reason = 'user') { this.pauseReasons.delete(reason); this.lastFrame = undefined; this.accumulator = 0; }
|
||
|
||
setParameter(id, value) {
|
||
const result = this.engine.setParameter(id, value);
|
||
this.onUpdate(this.engine);
|
||
return result;
|
||
}
|
||
|
||
execute(actionArray, context) {
|
||
const result = this.actions.execute(actionArray, context);
|
||
this.onUpdate(this.engine);
|
||
return result;
|
||
}
|
||
|
||
releaseOverride(id) {
|
||
const released = this.engine.overrides.beginRelease(id);
|
||
this.engine.resolveAll();
|
||
this.onUpdate(this.engine);
|
||
return released;
|
||
}
|
||
|
||
async deactivate() {
|
||
this.scenarios.dispose();
|
||
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
|
||
this.frameRequest = null;
|
||
this.lastFrame = undefined;
|
||
if (typeof globalThis.removeEventListener === 'function') {
|
||
globalThis.removeEventListener('pointermove', this.boundPointer);
|
||
globalThis.removeEventListener('resize', this.boundResize);
|
||
}
|
||
if (this.state === 'active') this.state = 'inactive';
|
||
this.engine.overrides.clear();
|
||
this.resources.clear();
|
||
}
|
||
|
||
async dispose() {
|
||
this.scenarios.dispose();
|
||
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
|
||
this.frameRequest = null;
|
||
if (typeof globalThis.removeEventListener === 'function') {
|
||
globalThis.removeEventListener('pointermove', this.boundPointer);
|
||
globalThis.removeEventListener('resize', this.boundResize);
|
||
}
|
||
this.engine.dispose();
|
||
this.resources.clear();
|
||
this.state = 'disposed';
|
||
}
|
||
}
|
||
|
||
/* src/runtime/activation.js */
|
||
class InertPerformance {
|
||
constructor(record, rootSeed) {
|
||
this.record = record;
|
||
this.rootSeed = rootSeed;
|
||
this.rng = new SeededRNG(rootSeed);
|
||
this.state = 'prepared';
|
||
this.resources = new Set();
|
||
}
|
||
|
||
async activate() {
|
||
if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`);
|
||
this.state = 'active';
|
||
}
|
||
|
||
async deactivate() {
|
||
if (this.state === 'active') this.state = 'inactive';
|
||
this.resources.clear();
|
||
}
|
||
|
||
async dispose() {
|
||
this.resources.clear();
|
||
this.state = 'disposed';
|
||
}
|
||
}
|
||
|
||
class ActivationController {
|
||
constructor({ diagnostics, persistence, performanceFactory, cryptoProvider = globalThis.crypto } = {}) {
|
||
this.diagnostics = diagnostics;
|
||
this.persistence = persistence;
|
||
this.crypto = cryptoProvider;
|
||
this.performanceFactory = performanceFactory ?? ((record, seed) => new CommonGrammarPerformance(record, seed));
|
||
this.current = null;
|
||
}
|
||
|
||
prepare(record) {
|
||
const seed = resolveRootSeed(record.document.runtime?.seed ?? 'random', this.crypto);
|
||
const performance = this.performanceFactory(record, seed);
|
||
if (!performance || typeof performance.activate !== 'function' || typeof performance.deactivate !== 'function' || typeof performance.dispose !== 'function') {
|
||
throw new TypeError('Performance factory returned an invalid lifecycle object.');
|
||
}
|
||
return { record, seed, performance };
|
||
}
|
||
|
||
async activate(record) {
|
||
let candidate;
|
||
try {
|
||
candidate = this.prepare(record);
|
||
} catch (error) {
|
||
this.diagnostics?.error('ERR_ACTIVATION_PREPARE', `Could not prepare exhibit: ${error.message}`, { exhibitId: record.id, section: 'activation' });
|
||
return false;
|
||
}
|
||
|
||
const previous = this.current;
|
||
if (previous) {
|
||
try {
|
||
await previous.performance.deactivate('replacement');
|
||
await previous.performance.dispose();
|
||
} catch (error) {
|
||
await candidate.performance.dispose();
|
||
this.diagnostics?.error('ERR_DEACTIVATION', `Could not safely deactivate the current exhibit: ${error.message}`, { exhibitId: previous.record.id, section: 'activation' });
|
||
return false;
|
||
}
|
||
this.current = null;
|
||
}
|
||
|
||
try {
|
||
await candidate.performance.activate();
|
||
this.current = candidate;
|
||
try {
|
||
await this.persistence?.savePreference('lastExhibitId', record.id);
|
||
} catch (error) {
|
||
this.diagnostics?.warn('WARN_STORAGE_WRITE_FAILED', `The active selection could not be remembered: ${error.message}`, { exhibitId: record.id, section: 'persistence' });
|
||
}
|
||
this.diagnostics?.info('INFO_EXHIBIT_ACTIVATED', `${record.document.meta.name} activated with resolved seed ${candidate.seed}.`, { exhibitId: record.id, section: 'activation', property: 'runtime.seed' });
|
||
return true;
|
||
} catch (error) {
|
||
await candidate.performance.dispose();
|
||
this.diagnostics?.error('ERR_ACTIVATION', `Activation failed: ${error.message}`, { exhibitId: record.id, section: 'activation' });
|
||
if (previous) await this.recover(previous);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async recover(previous) {
|
||
try {
|
||
const recovery = this.prepare(previous.record);
|
||
await recovery.performance.activate();
|
||
this.current = recovery;
|
||
this.diagnostics?.warn('WARN_ACTIVATION_RECOVERED', `The previous exhibit was restarted after activation failure.`, { exhibitId: previous.record.id, section: 'activation' });
|
||
} catch (error) {
|
||
this.diagnostics?.error('ERR_ACTIVATION_RECOVERY', `The previous exhibit could not be restarted: ${error.message}`, { exhibitId: previous.record.id, section: 'activation' });
|
||
}
|
||
}
|
||
|
||
async deactivate() {
|
||
if (!this.current) return true;
|
||
const current = this.current;
|
||
this.current = null;
|
||
try {
|
||
await current.performance.deactivate('manual');
|
||
await current.performance.dispose();
|
||
this.diagnostics?.info('INFO_EXHIBIT_DEACTIVATED', `${current.record.document.meta.name} deactivated.`, { exhibitId: current.record.id, section: 'activation' });
|
||
return true;
|
||
} catch (error) {
|
||
this.diagnostics?.error('ERR_DEACTIVATION', `Exhibit teardown failed: ${error.message}`, { exhibitId: current.record.id, section: 'activation' });
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* src/runtime/audio-engine.js */
|
||
// Deterministic instantiation of an expanded audio graph, plus its Web Audio realization.
|
||
// Instantiation is pure and testable without an AudioContext (Format Specification 14.4-14.6).
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
function soundInstanceKey(soundId, ordinal) {
|
||
return `${soundId}#${ordinal}`;
|
||
}
|
||
|
||
function sampleHoldStreamKey(instanceKey, nodePath) {
|
||
return `${instanceKey}|node|${nodePath}`;
|
||
}
|
||
|
||
function instantiateSoundGraph(document, soundId, options = {}) {
|
||
const {
|
||
sampleRate = 48000,
|
||
rng = null,
|
||
ordinal = 0,
|
||
resolveReference = () => { throw new RuntimeFault('ERR_INVALID_REFERENCE', 'No reference resolver supplied.'); }
|
||
} = options;
|
||
|
||
const expansion = options.expansion ?? expandSoundGraph(document, soundId);
|
||
if (expansion.errors.length > 0) {
|
||
return {
|
||
nodes: [],
|
||
routes: [],
|
||
warnings: [],
|
||
errors: expansion.errors,
|
||
mode: expansion.mode,
|
||
release: expansion.release,
|
||
endingBoundMs: null
|
||
};
|
||
}
|
||
|
||
const ceiling = audioMaxFrequency(sampleRate);
|
||
const instanceKey = soundInstanceKey(soundId, ordinal);
|
||
const stream = rng ? rng.stream('sound', instanceKey) : null;
|
||
const warnings = [];
|
||
const componentValues = new Map();
|
||
|
||
const resolver = new ValueResolver((reference) => {
|
||
if (typeof reference === 'string' && reference.startsWith('inputs.')) {
|
||
const scope = resolver.currentScope;
|
||
const values = scope ? componentValues.get(scope) : options.inputs;
|
||
const name = reference.slice('inputs.'.length);
|
||
if (!values || !Object.hasOwn(values, name)) {
|
||
throw new RuntimeFault('ERR_INVALID_REFERENCE', `Input '${name}' is not available here.`);
|
||
}
|
||
return values[name];
|
||
}
|
||
return resolveReference(reference);
|
||
});
|
||
|
||
const evaluate = (spec, scope, path) => {
|
||
resolver.currentScope = scope;
|
||
return resolver.evaluate(spec, stream, path);
|
||
};
|
||
const sample = (spec, scope, path) => {
|
||
resolver.currentScope = scope;
|
||
return resolver.sample(spec, stream, path, (ref) => scope && ref.startsWith('inputs.'));
|
||
};
|
||
|
||
const clampField = (value, spec, nodePath, field) => {
|
||
let limitMax = spec.max;
|
||
if (spec.ceiling === 'audio' && ceiling < spec.max) limitMax = ceiling;
|
||
const bounded = clamp(value, spec.min, limitMax);
|
||
if (spec.ceiling === 'audio' && value > limitMax) {
|
||
warnings.push({ code: 'WARN_AUDIO_RATE_CLAMP', path: `${nodePath}.${field}`, message: `Clamped ${value} Hz to the device ceiling ${limitMax} Hz.` });
|
||
}
|
||
return bounded;
|
||
};
|
||
|
||
const nodes = expansion.nodes.filter((node) => node.implicit).map((node) => ({ path: node.path, type: 'gain', implicit: true, values: { gain: 1 } }));
|
||
const routes = [];
|
||
const automation = [];
|
||
const expandedNodes = new Map(expansion.nodes.map((node) => [node.path, node]));
|
||
for (const item of expansion.samplingOrder) {
|
||
if (item.kind === 'route') {
|
||
const route = expansion.routes[item.index];
|
||
routes.push(route.kind === 'audio' ? { ...route } : { ...route, depth: evaluate(route.depth, route.scope, `${route.path}.depth`) });
|
||
continue;
|
||
}
|
||
if (item.kind === 'automation') {
|
||
const track = expansion.automation[item.index];
|
||
automation.push(sampleAutomationTrack(track, (value, path) => evaluate(value, track.scope, path), (warning) => warnings.push(warning)));
|
||
continue;
|
||
}
|
||
const node = expandedNodes.get(item.path);
|
||
if (!node) continue;
|
||
const contract = AUDIO_NODE_TYPES[node.type];
|
||
if (!contract) continue;
|
||
const scope = node.scope;
|
||
const values = {};
|
||
const expressions = {};
|
||
|
||
if (node.type === 'component') {
|
||
const declared = node.exposes ?? {};
|
||
const supplied = node.spec.values ?? {};
|
||
const resolved = {};
|
||
const names = [...Object.keys(supplied), ...Object.keys(declared).filter((name) => !Object.hasOwn(supplied, name))];
|
||
for (const name of names) {
|
||
const rule = declared[name];
|
||
const raw = Object.hasOwn(supplied, name) ? supplied[name] : rule.default;
|
||
expressions[name] = sample(raw, scope, `${node.path}.values.${name}`);
|
||
const value = evaluate(expressions[name], scope, `${node.path}.values.${name}`);
|
||
resolved[name] = clamp(value, rule.min ?? -Infinity, rule.max ?? Infinity);
|
||
}
|
||
componentValues.set(node.path, resolved);
|
||
nodes.push({ path: node.path, type: 'component', component: node.component, values: resolved, expressions, exposes: declared, scope, passthrough: true });
|
||
continue;
|
||
}
|
||
|
||
const fields = [...Object.keys(node.spec).filter((field) => Object.hasOwn(contract.fields, field)), ...Object.keys(contract.fields).filter((field) => !Object.hasOwn(node.spec, field))];
|
||
for (const field of fields) {
|
||
const spec = contract.fields[field];
|
||
if (spec.kind === 'partials' || spec.kind === 'modes') {
|
||
const table = spec.kind === 'partials' ? AUDIO_PARTIAL_FIELDS : AUDIO_MODE_FIELDS;
|
||
values[field] = (node.spec[field] ?? []).map((entry, index) => {
|
||
const sampled = {};
|
||
const names = [...Object.keys(entry), ...Object.keys(table).filter((name) => !Object.hasOwn(entry, name) && table[name].default !== undefined)];
|
||
for (const name of names) {
|
||
const value = Object.hasOwn(entry, name) ? entry[name] : table[name].default;
|
||
sampled[name] = name === 'decay' ? durationMilliseconds(value) : evaluate(value, scope, `${node.path}.${field}[${index}].${name}`);
|
||
}
|
||
return sampled;
|
||
});
|
||
continue;
|
||
}
|
||
const authored = Object.hasOwn(node.spec, field) ? node.spec[field] : spec.default;
|
||
if (authored === undefined) continue;
|
||
if (spec.kind === 'enum') { values[field] = authored; continue; }
|
||
if (spec.kind === 'duration') { values[field] = durationMilliseconds(authored); continue; }
|
||
const frozen = sample(authored, scope, `${node.path}.${field}`);
|
||
const raw = evaluate(frozen, scope, `${node.path}.${field}`);
|
||
if (Object.hasOwn(AUDIO_MODULATABLE[node.type] ?? {}, field)) expressions[field] = frozen;
|
||
values[field] = clampField(raw, spec, node.path, field);
|
||
}
|
||
|
||
if (node.type === 'oscillator' && values.waveform === 'custom') {
|
||
values.harmonics = values.harmonics.filter((partial) => partial.ratio * values.frequency <= ceiling);
|
||
}
|
||
|
||
if (node.type === 'resonator') {
|
||
values.modes = values.modes.map((mode) => ({ ...mode, frequency: mode.frequency ?? mode.ratio * values.fundamental })).filter((mode) => mode.frequency <= ceiling);
|
||
}
|
||
|
||
if (node.type === 'sample-hold') {
|
||
const slewLimit = 1000 / values.rate;
|
||
values.slew = Math.min(values.slew, slewLimit);
|
||
values.streamKey = sampleHoldStreamKey(instanceKey, node.path);
|
||
values.stream = rng ? rng.stream('sound', values.streamKey) : null;
|
||
}
|
||
|
||
nodes.push({ path: node.path, type: node.type, values, expressions, scope });
|
||
}
|
||
|
||
let endingBoundMs = null;
|
||
if (expansion.mode === 'oneshot') {
|
||
const boundedNodes = nodes.map((node) => {
|
||
if (node.type !== 'delay') return node;
|
||
const track = automation.find((item) => item.target === node.path && item.property === 'time');
|
||
const modulated = routes.some((route) => route.kind === 'modulation' && route.to === node.path && route.property === 'time');
|
||
// A varying delay must not dispose its voice at the shorter base-time tail.
|
||
const maximumTime = modulated ? 10000 : track ? clamp(Math.max(...track.points.map((point) => applyAutomationMode(node.values.time, point.value, track.mode))), 0, 10000) : node.values.time;
|
||
return { ...node, values: { ...node.values, time: maximumTime } };
|
||
});
|
||
endingBoundMs = computeDeterminableEndingBound(expansion, boundedNodes);
|
||
}
|
||
|
||
return {
|
||
nodes,
|
||
routes,
|
||
automation,
|
||
warnings,
|
||
errors: [],
|
||
mode: expansion.mode,
|
||
release: expansion.release,
|
||
endingBoundMs,
|
||
instanceKey,
|
||
ceiling
|
||
};
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Web Audio realization
|
||
* ------------------------------------------------------------------ */
|
||
|
||
const NOISE_SECONDS = 2;
|
||
|
||
function noiseBuffer(context, color) {
|
||
const length = Math.floor(context.sampleRate * NOISE_SECONDS);
|
||
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||
const data = buffer.getChannelData(0);
|
||
if (color === 'white') {
|
||
for (let index = 0; index < length; index += 1) data[index] = (Math.random() * 2) - 1;
|
||
} else if (color === 'pink') {
|
||
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const white = (Math.random() * 2) - 1;
|
||
b0 = 0.99886 * b0 + white * 0.0555179;
|
||
b1 = 0.99332 * b1 + white * 0.0750759;
|
||
b2 = 0.969 * b2 + white * 0.153852;
|
||
b3 = 0.8665 * b3 + white * 0.3104856;
|
||
b4 = 0.55 * b4 + white * 0.5329522;
|
||
b5 = -0.7616 * b5 - white * 0.016898;
|
||
data[index] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362;
|
||
b6 = white * 0.115926;
|
||
}
|
||
} else {
|
||
let last = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const white = (Math.random() * 2) - 1;
|
||
last = (last + 0.02 * white) / 1.02;
|
||
data[index] = last;
|
||
}
|
||
}
|
||
let sum = 0;
|
||
for (let index = 0; index < length; index += 1) sum += data[index] * data[index];
|
||
const rms = Math.sqrt(sum / length) || 1;
|
||
for (let index = 0; index < length; index += 1) data[index] = clamp(data[index] / rms * 0.2, -1, 1);
|
||
return buffer;
|
||
}
|
||
|
||
function shaperCurve(shape, amount) {
|
||
const points = 1024;
|
||
const curve = new Float32Array(points);
|
||
const drive = 1 + (amount * 24);
|
||
for (let index = 0; index < points; index += 1) {
|
||
const x = (index * 2 / (points - 1)) - 1;
|
||
if (amount === 0) curve[index] = x;
|
||
else if (shape === 'hard-clip') curve[index] = clamp(x * drive, -1, 1);
|
||
else if (shape === 'saturation') curve[index] = Math.tanh(x * drive);
|
||
else curve[index] = Math.sign(x) * (1 - Math.exp(-Math.abs(x * drive))) / (1 - Math.exp(-drive));
|
||
}
|
||
return curve;
|
||
}
|
||
|
||
function reverbBuffer(context, { size, decay, damping }) {
|
||
const seconds = Math.max(0.05, (decay / 1000) * (0.4 + (size * 0.6)));
|
||
const length = Math.max(1, Math.floor(context.sampleRate * seconds));
|
||
const buffer = context.createBuffer(2, length, context.sampleRate);
|
||
for (let channel = 0; channel < 2; channel += 1) {
|
||
const data = buffer.getChannelData(channel);
|
||
let smoothed = 0;
|
||
for (let index = 0; index < length; index += 1) {
|
||
const envelope = (1 - (index / length)) ** (2 + (damping * 4));
|
||
const impulse = ((Math.random() * 2) - 1) * envelope;
|
||
smoothed += (impulse - smoothed) * (1 - (damping * 0.7));
|
||
data[index] = smoothed;
|
||
}
|
||
}
|
||
return buffer;
|
||
}
|
||
|
||
function periodicWave(context, partials) {
|
||
const count = Math.max(2, partials.reduce((highest, partial) => Math.max(highest, Math.round(partial.ratio)), 1) + 1);
|
||
const real = new Float32Array(count);
|
||
const imaginary = new Float32Array(count);
|
||
for (const partial of partials) {
|
||
const index = Math.round(partial.ratio);
|
||
if (index < 1 || index >= count) continue;
|
||
const radians = (partial.phase ?? 0) * Math.PI / 180;
|
||
real[index] += partial.gain * Math.cos(radians);
|
||
imaginary[index] += partial.gain * Math.sin(radians);
|
||
}
|
||
return context.createPeriodicWave(real, imaginary, { disableNormalization: false });
|
||
}
|
||
|
||
// Builds one voice and its audio-clock automation; partial construction is also
|
||
// disposable, so a failed scheduler cannot leave connected or running sources.
|
||
function realizeSoundGraph(context, plan, destination) {
|
||
const created = new Map();
|
||
const disposers = [];
|
||
const starters = [];
|
||
const sink = context.createGain();
|
||
sink.gain.value = 1;
|
||
const releaseGain = context.createGain();
|
||
releaseGain.gain.value = 1;
|
||
sink.connect(releaseGain).connect(destination);
|
||
disposers.push(() => releaseGain.disconnect());
|
||
created.set('output', { input: sink, output: sink });
|
||
|
||
const now = () => context.currentTime;
|
||
const startTime = now();
|
||
const dispose = () => {
|
||
for (const release of disposers.splice(0).reverse()) {
|
||
try { release(); } catch { /* disposal is best effort */ }
|
||
}
|
||
sink.disconnect();
|
||
releaseGain.disconnect();
|
||
created.clear();
|
||
starters.length = 0;
|
||
};
|
||
|
||
try {
|
||
for (const node of plan.nodes) {
|
||
const { path, type, values } = node;
|
||
if (type === 'component') continue;
|
||
let entry = null;
|
||
|
||
if (type === 'oscillator') {
|
||
const oscillator = context.createOscillator();
|
||
if (values.waveform === 'custom') oscillator.setPeriodicWave(periodicWave(context, values.harmonics ?? []));
|
||
else oscillator.type = values.waveform;
|
||
oscillator.frequency.value = values.frequency;
|
||
oscillator.detune.value = values.detune;
|
||
entry = { input: null, output: oscillator, params: { frequency: oscillator.frequency, detune: oscillator.detune } };
|
||
starters.push(() => oscillator.start(startTime));
|
||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); });
|
||
} else if (type === 'noise') {
|
||
const source = context.createBufferSource();
|
||
source.buffer = noiseBuffer(context, values.color);
|
||
source.loop = true;
|
||
entry = { input: null, output: source, params: {} };
|
||
starters.push(() => source.start(startTime));
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||
} else if (type === 'impulse') {
|
||
const source = context.createBufferSource();
|
||
source.buffer = noiseBuffer(context, values.color);
|
||
const envelope = context.createGain();
|
||
const seconds = values.duration / 1000;
|
||
const fade = Math.min(0.001, seconds * 0.1);
|
||
const start = startTime;
|
||
envelope.gain.setValueAtTime(values.amplitude, start);
|
||
if (values.decay === 'linear') envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
else if (values.decay === 'exponential') {
|
||
envelope.gain.exponentialRampToValueAtTime(Math.max(1e-4, values.amplitude * 0.001), start + seconds - fade);
|
||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
} else {
|
||
envelope.gain.setValueAtTime(values.amplitude, start + seconds - fade);
|
||
envelope.gain.linearRampToValueAtTime(0, start + seconds);
|
||
}
|
||
source.connect(envelope);
|
||
entry = { input: null, output: envelope, params: {} };
|
||
starters.push(() => source.start(startTime, 0, seconds));
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); envelope.disconnect(); });
|
||
} else if (type === 'constant') {
|
||
const source = context.createConstantSource();
|
||
source.offset.value = values.value;
|
||
entry = { input: null, output: source, params: { value: source.offset } };
|
||
starters.push(() => source.start(startTime));
|
||
disposers.push(() => { try { source.stop(); } catch { /* already stopped */ } source.disconnect(); });
|
||
} else if (type === 'lfo') {
|
||
const oscillator = context.createOscillator();
|
||
oscillator.type = values.waveform;
|
||
oscillator.frequency.value = values.frequency;
|
||
const depth = context.createGain();
|
||
depth.gain.value = values.polarity === 'unipolar' ? values.amplitude / 2 : values.amplitude;
|
||
oscillator.connect(depth);
|
||
let output = depth;
|
||
if (values.polarity === 'unipolar') {
|
||
const offset = context.createConstantSource();
|
||
offset.offset.value = values.amplitude / 2;
|
||
const sum = context.createGain();
|
||
depth.connect(sum);
|
||
offset.connect(sum);
|
||
output = sum;
|
||
starters.push(() => offset.start(startTime));
|
||
disposers.push(() => { try { offset.stop(); } catch { /* already stopped */ } offset.disconnect(); sum.disconnect(); });
|
||
}
|
||
entry = { input: null, output, params: {} };
|
||
starters.push(() => oscillator.start(startTime + ((values.phase / 360) / Math.max(values.frequency, 1e-6))));
|
||
disposers.push(() => { try { oscillator.stop(); } catch { /* already stopped */ } oscillator.disconnect(); depth.disconnect(); });
|
||
} else if (type === 'sample-hold') {
|
||
const source = context.createConstantSource();
|
||
const period = 1 / values.rate;
|
||
const slew = values.slew / 1000;
|
||
let held = 0;
|
||
source.offset.value = 0;
|
||
const schedule = (index) => {
|
||
const target = values.stream
|
||
? values.min + (values.stream.nextFloat() * (values.max - values.min))
|
||
: values.min;
|
||
const at = now() + (index * period);
|
||
if (slew > 0) source.offset.linearRampToValueAtTime(target, at + slew);
|
||
else source.offset.setValueAtTime(target, at);
|
||
held = target;
|
||
return held;
|
||
};
|
||
let tick = 0;
|
||
const timer = setInterval(() => { schedule(0); tick += 1; }, Math.max(10, period * 1000));
|
||
if (typeof timer.unref === 'function') timer.unref();
|
||
schedule(0);
|
||
entry = { input: null, output: source, params: {} };
|
||
starters.push(() => source.start(startTime));
|
||
disposers.push(() => { clearInterval(timer); try { source.stop(); } catch { /* already stopped */ } source.disconnect(); void tick; });
|
||
} else if (type === 'gain') {
|
||
const gain = context.createGain();
|
||
gain.gain.value = values.gain ?? 1;
|
||
entry = { input: gain, output: gain, params: { gain: gain.gain } };
|
||
disposers.push(() => gain.disconnect());
|
||
} else if (type === 'filter') {
|
||
const filter = context.createBiquadFilter();
|
||
filter.type = values.mode;
|
||
filter.frequency.value = values.frequency;
|
||
filter.Q.value = values.q;
|
||
filter.gain.value = values.gain;
|
||
filter.detune.value = values.detune;
|
||
entry = { input: filter, output: filter, params: { frequency: filter.frequency, q: filter.Q, gain: filter.gain, detune: filter.detune } };
|
||
disposers.push(() => filter.disconnect());
|
||
} else if (type === 'compressor') {
|
||
const compressor = context.createDynamicsCompressor();
|
||
compressor.threshold.value = values.threshold;
|
||
compressor.knee.value = values.knee;
|
||
compressor.ratio.value = values.ratio;
|
||
compressor.attack.value = values.attack / 1000;
|
||
compressor.release.value = values.release / 1000;
|
||
entry = { input: compressor, output: compressor, params: {} };
|
||
disposers.push(() => compressor.disconnect());
|
||
} else if (type === 'waveshaper') {
|
||
const shaper = context.createWaveShaper();
|
||
shaper.curve = shaperCurve(values.shape, values.amount);
|
||
shaper.oversample = values.oversample;
|
||
entry = { input: shaper, output: shaper, params: {} };
|
||
disposers.push(() => shaper.disconnect());
|
||
} else if (type === 'delay') {
|
||
const input = context.createGain();
|
||
const delay = context.createDelay(10);
|
||
delay.delayTime.value = values.time / 1000;
|
||
const feedback = context.createGain();
|
||
feedback.gain.value = values.feedback;
|
||
const dry = context.createGain();
|
||
const wet = context.createGain();
|
||
const output = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
wet.gain.value = values.mix;
|
||
input.connect(dry).connect(output);
|
||
input.connect(delay);
|
||
delay.connect(feedback).connect(delay);
|
||
delay.connect(wet).connect(output);
|
||
entry = { input, output, params: { time: delay.delayTime } };
|
||
disposers.push(() => [input, delay, feedback, dry, wet, output].forEach((item) => item.disconnect()));
|
||
} else if (type === 'reverb') {
|
||
const input = context.createGain();
|
||
const convolver = context.createConvolver();
|
||
convolver.buffer = reverbBuffer(context, values);
|
||
const predelay = context.createDelay(1);
|
||
predelay.delayTime.value = values.predelay / 1000;
|
||
const dry = context.createGain();
|
||
const wet = context.createGain();
|
||
const output = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
wet.gain.value = values.mix;
|
||
input.connect(dry).connect(output);
|
||
input.connect(predelay).connect(convolver).connect(wet).connect(output);
|
||
entry = { input, output, params: {} };
|
||
disposers.push(() => [input, convolver, predelay, dry, wet, output].forEach((item) => item.disconnect()));
|
||
} else if (type === 'stereo-pan') {
|
||
const panner = context.createStereoPanner();
|
||
panner.pan.value = values.pan;
|
||
entry = { input: panner, output: panner, params: { pan: panner.pan } };
|
||
disposers.push(() => panner.disconnect());
|
||
} else if (type === 'mixer') {
|
||
const mixer = context.createGain();
|
||
mixer.gain.value = 1;
|
||
entry = { input: mixer, output: mixer, params: {} };
|
||
disposers.push(() => mixer.disconnect());
|
||
} else if (type === 'resonator') {
|
||
const input = context.createGain();
|
||
const output = context.createGain();
|
||
const dry = context.createGain();
|
||
dry.gain.value = 1 - values.mix;
|
||
input.connect(dry).connect(output);
|
||
const wet = context.createGain();
|
||
wet.gain.value = values.mix;
|
||
wet.connect(output);
|
||
const bands = [];
|
||
const controls = [];
|
||
for (const mode of values.modes ?? []) {
|
||
const band = context.createBiquadFilter();
|
||
band.type = 'bandpass';
|
||
band.frequency.value = mode.frequency;
|
||
band.Q.value = Math.max(1, (mode.frequency * (mode.decay / 1000)) / 3);
|
||
const level = context.createGain();
|
||
level.gain.value = mode.gain;
|
||
input.connect(band).connect(level).connect(wet);
|
||
bands.push(band, level);
|
||
controls.push({ ratio: mode.ratio, decay: mode.decay, frequency: band.frequency, q: band.Q });
|
||
}
|
||
entry = { input, output, params: {}, bands: controls };
|
||
disposers.push(() => [input, output, dry, wet, ...bands].forEach((item) => item.disconnect()));
|
||
}
|
||
|
||
if (entry) created.set(path, entry);
|
||
}
|
||
|
||
for (const route of plan.routes) {
|
||
const source = created.get(route.from);
|
||
if (!source?.output) continue;
|
||
if (route.kind === 'audio') {
|
||
const target = created.get(route.to);
|
||
if (target?.input) source.output.connect(target.input);
|
||
continue;
|
||
}
|
||
// Modulation routes are connected by the final, clamped control pipeline.
|
||
}
|
||
|
||
const controls = createAudioControls(context, plan, created, (release) => disposers.push(release), startTime);
|
||
disposers.push(() => controls.dispose());
|
||
controls.apply();
|
||
|
||
for (const start of starters) start();
|
||
|
||
return {
|
||
sink,
|
||
releaseGain,
|
||
startTime,
|
||
dispose
|
||
};
|
||
} catch (error) {
|
||
dispose();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
class SoundInstance {
|
||
constructor({
|
||
soundId,
|
||
mode = 'oneshot',
|
||
releaseMs = AUDIO_DEFAULT_RELEASE_MS,
|
||
endingBoundMs = null,
|
||
plan = null,
|
||
subsystem = null,
|
||
bus = null,
|
||
context = null,
|
||
creationOrder = 0,
|
||
owner = null
|
||
} = {}) {
|
||
this.soundId = soundId;
|
||
this.mode = mode;
|
||
this.releaseMs = releaseMs ?? AUDIO_DEFAULT_RELEASE_MS;
|
||
this.endingBoundMs = endingBoundMs;
|
||
this.plan = plan;
|
||
this.subsystem = subsystem;
|
||
this.bus = bus;
|
||
this.context = context;
|
||
this.creationOrder = creationOrder;
|
||
this.owner = owner;
|
||
this.state = 'CREATED';
|
||
this.realized = null;
|
||
this.startTime = null;
|
||
this.releaseTimer = null;
|
||
this.endingTimer = null;
|
||
}
|
||
|
||
canTransition(nextState) {
|
||
const allowed = AUDIO_LIFECYCLE_TRANSITIONS[this.state] ?? [];
|
||
return allowed.includes(nextState);
|
||
}
|
||
|
||
transition(nextState) {
|
||
if (!this.canTransition(nextState)) {
|
||
throw new RuntimeFault('ERR_RUNTIME_FAULT', `Invalid lifecycle transition from '${this.state}' to '${nextState}'.`);
|
||
}
|
||
this.state = nextState;
|
||
return true;
|
||
}
|
||
|
||
realize(context, destination) {
|
||
const guard = this.subsystem?.createVoiceGuard(this);
|
||
this.guard = guard;
|
||
if (guard) guard.connect(destination);
|
||
this.realized = realizeSoundGraph(context, this.plan, guard ?? destination);
|
||
return this.realized;
|
||
}
|
||
|
||
stop() {
|
||
if (this.state === 'RELEASING' || this.state === 'FINISHED' || this.state === 'DISPOSED' || this.state === 'FAILED') {
|
||
return;
|
||
}
|
||
if (this.subsystem) {
|
||
this.subsystem.voices.delete(this);
|
||
}
|
||
if (this.subsystem?.useLogicalClock) this.logicalReleaseAt = this.subsystem.resolution.logicalMilliseconds;
|
||
if (this.state === 'CREATED') {
|
||
this.transition('FINISHED');
|
||
return;
|
||
}
|
||
if (this.state === 'SCHEDULED') {
|
||
this.transition('RELEASING');
|
||
if (this.releaseMs === 0) {
|
||
this.transition('FINISHED');
|
||
} else if (!this.subsystem?.useLogicalClock) {
|
||
this.releaseTimer = setTimeout(() => {
|
||
if (this.state === 'RELEASING') {
|
||
this.transition('FINISHED');
|
||
}
|
||
}, this.releaseMs);
|
||
if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
|
||
}
|
||
return;
|
||
}
|
||
if (this.state === 'ACTIVE') {
|
||
if (this.endingTimer) {
|
||
clearTimeout(this.endingTimer);
|
||
this.endingTimer = null;
|
||
}
|
||
this.transition('RELEASING');
|
||
if (this.releaseMs === 0) {
|
||
if (this.realized?.releaseGain?.gain) {
|
||
const now = this.context?.currentTime ?? 0;
|
||
this.realized.releaseGain.gain.cancelScheduledValues?.(now);
|
||
this.realized.releaseGain.gain.setValueAtTime?.(0, now);
|
||
}
|
||
this.transition('FINISHED');
|
||
} else {
|
||
const now = this.context?.currentTime ?? 0;
|
||
const durationSec = this.releaseMs / 1000;
|
||
const gainParam = this.realized?.releaseGain?.gain;
|
||
if (gainParam) {
|
||
gainParam.cancelScheduledValues?.(now);
|
||
gainParam.setValueAtTime?.(gainParam.value, now);
|
||
gainParam.linearRampToValueAtTime?.(0, now + durationSec);
|
||
}
|
||
if (this.subsystem?.useLogicalClock) return;
|
||
this.releaseTimer = setTimeout(() => {
|
||
if (this.state === 'RELEASING') {
|
||
this.transition('FINISHED');
|
||
}
|
||
}, this.releaseMs);
|
||
if (typeof this.releaseTimer?.unref === 'function') this.releaseTimer.unref();
|
||
}
|
||
}
|
||
}
|
||
|
||
forceFinishAndDispose() {
|
||
if (this.state === 'RELEASING') {
|
||
if (this.releaseTimer) {
|
||
clearTimeout(this.releaseTimer);
|
||
this.releaseTimer = null;
|
||
}
|
||
if (this.realized?.releaseGain?.gain) {
|
||
const now = this.context?.currentTime ?? 0;
|
||
this.realized.releaseGain.gain.cancelScheduledValues?.(now);
|
||
this.realized.releaseGain.gain.setValueAtTime?.(0, now);
|
||
}
|
||
this.transition('FINISHED');
|
||
}
|
||
this.dispose();
|
||
}
|
||
|
||
dispose() {
|
||
if (this.state === 'DISPOSED') return;
|
||
if (this.releaseTimer) {
|
||
clearTimeout(this.releaseTimer);
|
||
this.releaseTimer = null;
|
||
}
|
||
if (this.endingTimer) {
|
||
clearTimeout(this.endingTimer);
|
||
this.endingTimer = null;
|
||
}
|
||
if (this.subsystem) {
|
||
this.subsystem.voices.delete(this);
|
||
this.subsystem.oneshotVoices?.delete(this);
|
||
this.subsystem.continuousVoices?.delete(this);
|
||
}
|
||
if (this.state !== 'FINISHED' && this.state !== 'FAILED') {
|
||
if (this.state === 'ACTIVE' || this.state === 'SCHEDULED') {
|
||
this.transition('RELEASING');
|
||
this.transition('FINISHED');
|
||
} else if (this.state === 'CREATED') {
|
||
this.transition('FINISHED');
|
||
} else if (this.state === 'RELEASING') {
|
||
this.transition('FINISHED');
|
||
}
|
||
}
|
||
// FAILED is terminal, but still owns the same resource cleanup obligation.
|
||
if (this.state !== 'FAILED') this.transition('DISPOSED');
|
||
if (this.realized) {
|
||
try { this.realized.dispose(); } catch { /* best effort */ }
|
||
this.realized = null;
|
||
}
|
||
this.plan = null;
|
||
if (this.guard) {
|
||
this.guard.port.onmessage = null;
|
||
this.guard.onprocessorerror = null;
|
||
this.guard.port.close();
|
||
this.guard.disconnect();
|
||
this.guard = null;
|
||
}
|
||
this.bus = null;
|
||
this.context = null;
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Runtime subsystem
|
||
* ------------------------------------------------------------------ */
|
||
|
||
// Owns the AudioContext, the declared buses, and the engine master chain, and turns a
|
||
// sound definition into a realized voice. All buses and volume controls are upstream
|
||
// of the final protection worklet; hardware/listening acceptance is tracked in GC6.
|
||
class AudioSubsystem {
|
||
constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null, resolutionEngine = null,
|
||
protectionFactory = { load: loadProtectionWorklet, create: createProtectionNode } } = {}) {
|
||
this.document = document;
|
||
this.rng = rng;
|
||
this.diagnostics = diagnostics;
|
||
this.ownsResolution = !resolutionEngine;
|
||
this.resolution = resolutionEngine ?? new ResolutionEngine(document ?? {}, rng ?? new SeededRNG(0), { diagnostics });
|
||
this.unsubscribeResolution = this.resolution.subscribe(() => this.updateBusGains());
|
||
this.hasCustomContext = Boolean(contextFactory);
|
||
this.contextFactory = contextFactory ?? (() => new (globalThis.AudioContext ?? globalThis.webkitAudioContext)());
|
||
this.context = null;
|
||
this.master = null;
|
||
this.protection = null;
|
||
this.buses = new Map();
|
||
this.voices = new Set();
|
||
this.oneshotVoices = new Set();
|
||
this.continuousVoices = new Set();
|
||
this.voiceLimits = voiceLimits ?? { ...AUDIO_VOICE_LIMITS };
|
||
this.ordinals = new Map();
|
||
this.nextCreationOrder = 0;
|
||
this.masterVolume = 0.8;
|
||
this.protectionFactory = protectionFactory;
|
||
this.ready = false;
|
||
this.disposed = false;
|
||
this.unlockPending = null;
|
||
this.capturePending = null;
|
||
this.captureSequence = 0;
|
||
this.unavailableWarned = false;
|
||
}
|
||
|
||
get available() {
|
||
return this.hasCustomContext || typeof (globalThis.AudioContext ?? globalThis.webkitAudioContext) === 'function';
|
||
}
|
||
|
||
get unlocked() {
|
||
return this.ready && this.context !== null && this.context.state === 'running';
|
||
}
|
||
|
||
// Must be called from a user gesture; browsers refuse to start audio otherwise.
|
||
async unlock() {
|
||
if (this.disposed) return false;
|
||
if (this.unlockPending) return this.unlockPending;
|
||
this.unlockPending = this.initializeAudio();
|
||
try { return await this.unlockPending; }
|
||
finally { this.unlockPending = null; }
|
||
}
|
||
|
||
async initializeAudio() {
|
||
try {
|
||
if (this.protectionFailed) throw new Error('Master protection failed; reactivate the exhibit to restart audio.');
|
||
if (!this.available) throw new Error('This browser exposes no AudioContext.');
|
||
if (!this.context) this.context = this.contextFactory();
|
||
const context = this.context;
|
||
// Resume synchronously with the gesture before awaiting module loading.
|
||
const resume = context.state === 'suspended' ? context.resume() : Promise.resolve();
|
||
await Promise.all([resume, this.ready ? Promise.resolve() : this.buildMaster()]);
|
||
if (this.disposed || this.context !== context) return false;
|
||
if (context.state !== 'running') throw new Error('The audio context did not enter the running state.');
|
||
if (!this.ready) this.buildBuses();
|
||
this.ready = true;
|
||
return this.unlocked;
|
||
} catch (error) {
|
||
this.ready = false;
|
||
for (const instance of [...this.oneshotVoices, ...this.continuousVoices]) instance.dispose();
|
||
this.disconnectMaster();
|
||
const context = this.context;
|
||
this.context = null;
|
||
try { await context?.close(); } catch { /* already closed */ }
|
||
if (!this.unavailableWarned && !this.disposed) {
|
||
this.unavailableWarned = true;
|
||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', `Audio disabled: ${error.message}`, { section: 'audio' });
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async buildMaster() {
|
||
const context = this.context;
|
||
await this.protectionFactory.load(context);
|
||
if (this.disposed || this.context !== context) return;
|
||
this.protection = this.protectionFactory.create(context, false);
|
||
this.protection.port.onmessage = ({ data }) => {
|
||
if (data.type === 'nonfinite') this.warnNonfinite('master');
|
||
if (data.type === 'capture' && data.id === this.capturePending?.id) {
|
||
const pending = this.capturePending;
|
||
this.capturePending = null;
|
||
pending.resolve(data);
|
||
}
|
||
};
|
||
this.protection.onprocessorerror = () => {
|
||
this.ready = false;
|
||
this.protectionFailed = true;
|
||
this.capturePending?.reject(new Error('Master protection processor failed.'));
|
||
this.capturePending = null;
|
||
this.protection?.disconnect();
|
||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', 'Master protection failed; output is muted.', { section: 'audio' });
|
||
};
|
||
this.master = context.createGain();
|
||
this.master.gain.value = this.masterVolume;
|
||
this.master.connect(this.protection).connect(context.destination);
|
||
}
|
||
|
||
warnNonfinite(instanceId) {
|
||
this.diagnostics?.warn('WARN_AUDIO_NONFINITE', 'A non-finite audio sample was detected; the containing output block was muted.', { section: 'audio', objectId: instanceId });
|
||
}
|
||
|
||
createVoiceGuard(instance) {
|
||
const guard = this.protectionFactory.create(this.context, true);
|
||
const instanceId = instance.plan?.instanceKey ?? soundInstanceKey(instance.soundId, instance.creationOrder);
|
||
guard.port.onmessage = ({ data }) => {
|
||
if (data.type === 'nonfinite') this.warnNonfinite(instanceId);
|
||
};
|
||
guard.onprocessorerror = () => {
|
||
instance.dispose();
|
||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', `Audio guard failed for '${instance.soundId}'; voice disposed.`, { section: 'audio', objectId: instance.soundId });
|
||
};
|
||
try { guard.connect(this.protection, 1, 1); }
|
||
catch (error) {
|
||
guard.port.onmessage = null;
|
||
guard.onprocessorerror = null;
|
||
guard.port.close();
|
||
guard.disconnect();
|
||
throw error;
|
||
}
|
||
return guard;
|
||
}
|
||
|
||
captureOutput({ seconds = 120, warmupSeconds = 30 } = {}) {
|
||
if (!this.unlocked) return Promise.reject(new Error('Unlock protected audio before capturing.'));
|
||
if (this.capturePending) return Promise.reject(new Error('An output capture is already running.'));
|
||
if (!Number.isFinite(seconds) || seconds <= 0 || !Number.isFinite(warmupSeconds) || warmupSeconds < 0) return Promise.reject(new Error('Invalid capture duration.'));
|
||
return new Promise((resolve, reject) => {
|
||
const id = ++this.captureSequence;
|
||
this.capturePending = { id, resolve, reject };
|
||
this.protection.port.postMessage({ type: 'capture', id,
|
||
frames: Math.max(1, Math.round(seconds * this.context.sampleRate)),
|
||
warmupFrames: Math.round(warmupSeconds * this.context.sampleRate) });
|
||
});
|
||
}
|
||
|
||
disconnectMaster() {
|
||
for (const bus of this.buses.values()) bus.disconnect();
|
||
this.buses.clear();
|
||
this.master?.disconnect();
|
||
if (this.protection) {
|
||
this.protection.port.onmessage = null;
|
||
this.protection.port.close();
|
||
this.protection.onprocessorerror = null;
|
||
this.protection.disconnect();
|
||
}
|
||
this.master = null;
|
||
this.protection = null;
|
||
}
|
||
|
||
buildBuses() {
|
||
const declared = this.document?.audio?.buses ?? {};
|
||
for (const id of Object.keys(declared)) {
|
||
const gain = this.context.createGain();
|
||
gain.gain.value = this.resolution.get(`audio.buses.${id}.gain`);
|
||
gain.connect(this.master);
|
||
this.buses.set(id, gain);
|
||
}
|
||
}
|
||
|
||
busFor(soundId) {
|
||
const name = this.document?.sounds?.[soundId]?.bus;
|
||
return (name && this.buses.get(name)) || this.master;
|
||
}
|
||
|
||
setBusGain(id, value) {
|
||
if (this.document?.audio?.buses?.[id]) this.resolution.setBusBase(id, Math.min(4, Math.max(0, value)));
|
||
}
|
||
|
||
updateBusGains() {
|
||
for (const [id, bus] of this.buses) bus.gain.value = this.resolution.get(`audio.buses.${id}.gain`);
|
||
}
|
||
|
||
setMasterVolume(value) {
|
||
if (!Number.isFinite(value)) return;
|
||
this.masterVolume = Math.min(1, Math.max(0, value));
|
||
if (this.master) this.master.gain.value = this.masterVolume;
|
||
}
|
||
|
||
nextOrdinal(soundId) {
|
||
const next = (this.ordinals.get(soundId) ?? -1) + 1;
|
||
this.ordinals.set(soundId, next);
|
||
return next;
|
||
}
|
||
|
||
play(soundId, { resolveReference = (path) => this.resolution.get(path), inputs = null, owner = null } = {}) {
|
||
if (!this.unlocked) return null;
|
||
const sound = this.document?.sounds?.[soundId];
|
||
if (!sound) return null;
|
||
|
||
const expansion = expandSoundGraph(this.document, soundId);
|
||
if (expansion.errors.length > 0) {
|
||
for (const error of expansion.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
|
||
return null;
|
||
}
|
||
|
||
const mode = expansion.mode ?? 'oneshot';
|
||
const pool = mode === 'oneshot' ? this.oneshotVoices : this.continuousVoices;
|
||
const ceiling = mode === 'oneshot' ? this.voiceLimits.oneshot : this.voiceLimits.continuous;
|
||
|
||
if (pool.size >= ceiling) {
|
||
// 16.6 Eviction policy applied in strict order:
|
||
// 1. Dispose the oldest instance already in FINISHED.
|
||
const finishedInstances = [...pool].filter((i) => i.state === 'FINISHED');
|
||
if (finishedInstances.length > 0) {
|
||
finishedInstances.sort((a, b) => a.creationOrder - b.creationOrder);
|
||
const candidate = finishedInstances[0];
|
||
candidate.dispose();
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; disposed oldest FINISHED instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
|
||
} else {
|
||
// 2. Evict the oldest instance in RELEASING by advancing its release ramp to immediate completion and disposing it.
|
||
const releasingInstances = [...pool].filter((i) => i.state === 'RELEASING');
|
||
if (releasingInstances.length > 0) {
|
||
releasingInstances.sort((a, b) => a.creationOrder - b.creationOrder);
|
||
const candidate = releasingInstances[0];
|
||
candidate.forceFinishAndDispose();
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicted oldest RELEASING instance of '${candidate.soundId}'.`, { section: 'audio', objectId: soundId });
|
||
} else if (mode === 'oneshot') {
|
||
// 3. For a one-shot request only: evict the oldest ACTIVE one-shot by starting its release.
|
||
const activeInstances = [...pool].filter((i) => i.state === 'ACTIVE' || i.state === 'SCHEDULED');
|
||
if (activeInstances.length > 0) {
|
||
activeInstances.sort((a, b) => a.creationOrder - b.creationOrder);
|
||
const candidate = activeInstances[0];
|
||
candidate.stop();
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; evicting oldest ACTIVE oneshot instance of '${candidate.soundId}' via release.`, { section: 'audio', objectId: soundId });
|
||
} else {
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; request refused.`, { section: 'audio', objectId: soundId });
|
||
return null;
|
||
}
|
||
} else {
|
||
// 4. Otherwise refuse the new instance.
|
||
this.diagnostics?.warn('WARN_VOICE_LIMIT', `Voice ceiling (${ceiling}) reached for '${soundId}'; continuous request refused.`, { section: 'audio', objectId: soundId });
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
const plan = instantiateSoundGraph(this.document, soundId, {
|
||
sampleRate: this.context.sampleRate,
|
||
rng: this.rng,
|
||
ordinal: this.nextOrdinal(soundId),
|
||
resolveReference,
|
||
expansion,
|
||
inputs
|
||
});
|
||
for (const error of plan.errors) this.diagnostics?.error(error.code, error.message, { section: 'audio', objectId: soundId });
|
||
if (plan.errors.length > 0) return null;
|
||
for (const warning of plan.warnings) this.diagnostics?.warn(warning.code, warning.message, { section: 'audio', objectId: soundId, property: warning.path });
|
||
|
||
const instance = new SoundInstance({
|
||
soundId,
|
||
mode: plan.mode,
|
||
releaseMs: plan.release,
|
||
endingBoundMs: plan.endingBoundMs,
|
||
plan,
|
||
subsystem: this,
|
||
bus: this.busFor(soundId),
|
||
context: this.context,
|
||
creationOrder: this.nextCreationOrder++,
|
||
owner
|
||
});
|
||
pool.add(instance);
|
||
|
||
try {
|
||
instance.transition('SCHEDULED');
|
||
instance.realize(this.context, this.busFor(soundId));
|
||
instance.transition('ACTIVE');
|
||
instance.startTime = instance.realized.startTime;
|
||
instance.logicalStartedAt = this.resolution.logicalMilliseconds;
|
||
this.voices.add(instance);
|
||
} catch (error) {
|
||
this.diagnostics?.error('ERR_AUDIO_REALIZATION', `Sound '${soundId}' could not be realized: ${error.message}`, { section: 'audio', objectId: soundId });
|
||
instance.transition('FAILED');
|
||
instance.dispose();
|
||
return null;
|
||
}
|
||
|
||
if (!this.useLogicalClock && mode === 'oneshot' && instance.endingBoundMs != null && Number.isFinite(instance.endingBoundMs)) {
|
||
instance.endingTimer = setTimeout(() => {
|
||
if (instance.state === 'ACTIVE') {
|
||
this.voices.delete(instance);
|
||
instance.transition('FINISHED');
|
||
}
|
||
}, instance.endingBoundMs);
|
||
if (typeof instance.endingTimer?.unref === 'function') instance.endingTimer.unref();
|
||
}
|
||
|
||
return instance;
|
||
}
|
||
|
||
stopAll() {
|
||
for (const instance of [...this.voices]) instance.stop();
|
||
}
|
||
|
||
advanceLogical() {
|
||
if (!this.useLogicalClock) return;
|
||
const now = this.resolution.logicalMilliseconds;
|
||
for (const voice of [...this.oneshotVoices, ...this.continuousVoices]) {
|
||
if (voice.state === 'ACTIVE' && voice.mode === 'oneshot' && Number.isFinite(voice.endingBoundMs) && now - voice.logicalStartedAt >= voice.endingBoundMs) {
|
||
this.voices.delete(voice); voice.transition('FINISHED');
|
||
}
|
||
if (voice.state === 'RELEASING' && now - voice.logicalReleaseAt >= voice.releaseMs) voice.transition('FINISHED');
|
||
if (voice.state === 'FINISHED') voice.dispose();
|
||
}
|
||
}
|
||
|
||
async dispose() {
|
||
this.disposed = true;
|
||
this.ready = false;
|
||
this.capturePending?.reject(new Error('Audio disposed during output capture.'));
|
||
this.capturePending = null;
|
||
this.unsubscribeResolution?.();
|
||
this.unsubscribeResolution = null;
|
||
this.stopAll();
|
||
for (const instance of [...this.oneshotVoices]) instance.dispose();
|
||
for (const instance of [...this.continuousVoices]) instance.dispose();
|
||
this.disconnectMaster();
|
||
if (this.context) {
|
||
try { await this.context.close(); } catch { /* already closed */ }
|
||
}
|
||
this.context = null;
|
||
this.master = null;
|
||
this.protection = null;
|
||
if (this.ownsResolution) this.resolution.dispose();
|
||
}
|
||
}
|
||
|
||
/* src/runtime/app.js */
|
||
function element(id) { return document.getElementById(id); }
|
||
|
||
function text(tag, value, className) {
|
||
const node = document.createElement(tag);
|
||
node.textContent = value;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
class XZBTApplication {
|
||
constructor() {
|
||
this.diagnostics = new Diagnostics({ onChange: (entries) => this.renderDiagnostics(entries) });
|
||
this.persistence = new PersistenceManager({ diagnostics: this.diagnostics });
|
||
this.library = new LibraryManager({ persistence: this.persistence, diagnostics: this.diagnostics });
|
||
this.parameterValues = {};
|
||
this.visual = new VisualSubsystem({ diagnostics: this.diagnostics });
|
||
this.activation = new ActivationController({
|
||
persistence: this.persistence,
|
||
diagnostics: this.diagnostics,
|
||
performanceFactory: (record, seed) => new CommonGrammarPerformance(record, seed, {
|
||
diagnostics: this.diagnostics,
|
||
initialParameters: this.parameterValues[record.id],
|
||
onParameterChange: (values) => this.rememberParameters(record.id, values),
|
||
onUpdate: (engine) => {
|
||
if (this.activation.current?.record.id === record.id) this.renderValues(engine);
|
||
},
|
||
onTick: (step) => {
|
||
if (this.activation.current?.record.id !== record.id) return;
|
||
this.visual.advance(step);
|
||
},
|
||
// The renderer runs at display rate; the simulation stays on the fixed
|
||
// logical tick (9.1), so a frame draws the most recent tick's state.
|
||
onFrame: (engine) => {
|
||
if (this.activation.current?.record.id !== record.id) return;
|
||
try {
|
||
this.visual.render({ logicalMilliseconds: engine.logicalMilliseconds });
|
||
} catch (error) {
|
||
this.diagnostics.error(error.code ?? 'ERR_SCHEMA_VALIDATION', `Visual frame failed: ${error.message}`, { section: 'visual', exhibitId: record.id, objectId: error.path });
|
||
this.visual.deactivate();
|
||
}
|
||
}
|
||
})
|
||
});
|
||
this.busy = false;
|
||
this.audio = null;
|
||
}
|
||
|
||
async start() {
|
||
element('runtime-version').textContent = `Runtime ${XZBT_RUNTIME_VERSION}`;
|
||
this.bindEvents();
|
||
await this.persistence.open();
|
||
this.renderStorage();
|
||
try {
|
||
const snapshot = await this.persistence.loadSnapshot();
|
||
for (const [key, value] of Object.entries(snapshot.preferences)) {
|
||
if (key.startsWith('parameters:')) this.parameterValues[key.slice('parameters:'.length)] = value;
|
||
}
|
||
this.library.restore(snapshot.exhibits);
|
||
this.renderLibrary();
|
||
const last = this.library.get(snapshot.preferences.lastExhibitId);
|
||
if (last) await this.activate(last.id);
|
||
} catch (error) {
|
||
this.diagnostics.warn('WARN_STORAGE_READ_FAILED', `Cached exhibits could not be restored: ${error.message}`, { section: 'persistence' });
|
||
this.renderLibrary();
|
||
}
|
||
this.setStatus(this.activation.current ? 'Exhibit active' : (this.library.list().length ? 'Library ready' : 'Import exhibits to begin'));
|
||
}
|
||
|
||
bindEvents() {
|
||
element('pause-button').addEventListener('click', async () => {
|
||
const p = this.activation.current?.performance;
|
||
if (!p) return;
|
||
if (p.pauseReasons.has('user')) p.resume(); else p.pause();
|
||
element('pause-button').textContent = p.pauseReasons.has('user') ? 'Resume' : 'Pause';
|
||
await this.syncAudioPause();
|
||
});
|
||
document.addEventListener('visibilitychange', async () => {
|
||
const p = this.activation.current?.performance;
|
||
if (!p) return;
|
||
if (document.hidden) p.pause('visibility'); else p.resume('visibility');
|
||
await this.syncAudioPause();
|
||
});
|
||
element('import-files').addEventListener('change', async (event) => {
|
||
await this.importFiles([...event.target.files]);
|
||
event.target.value = '';
|
||
});
|
||
element('import-button').addEventListener('click', () => element('import-files').click());
|
||
element('deactivate-button').addEventListener('click', () => this.deactivate());
|
||
element('audio-unlock').addEventListener('click', () => this.unlockAudio());
|
||
element('audio-stop').addEventListener('click', () => {
|
||
this.audio?.stopAll();
|
||
this.renderAudio();
|
||
});
|
||
element('master-volume').addEventListener('input', (event) => {
|
||
this.audio?.setMasterVolume(Number(event.target.value));
|
||
});
|
||
element('clear-diagnostics').addEventListener('click', () => this.diagnostics.clear());
|
||
const dropZone = element('drop-zone');
|
||
for (const type of ['dragenter', 'dragover']) dropZone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
dropZone.classList.add('is-dragging');
|
||
});
|
||
for (const type of ['dragleave', 'drop']) dropZone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
dropZone.classList.remove('is-dragging');
|
||
});
|
||
dropZone.addEventListener('drop', (event) => this.importFiles([...event.dataTransfer.files]));
|
||
window.addEventListener('unhandledrejection', (event) => {
|
||
this.diagnostics.error('ERR_RUNTIME', event.reason?.message ?? String(event.reason), { section: 'runtime' });
|
||
});
|
||
}
|
||
|
||
async importFiles(files) {
|
||
if (this.busy) return;
|
||
const exhibitFiles = files.filter((file) => file.name.toLowerCase().endsWith('.xzbt'));
|
||
if (!exhibitFiles.length) {
|
||
this.diagnostics.warn('WARN_IMPORT_EMPTY', 'Choose one or more files with the .xzbt extension.', { section: 'loader' });
|
||
return;
|
||
}
|
||
this.setBusy(true, `Importing ${exhibitFiles.length} exhibit${exhibitFiles.length === 1 ? '' : 's'}…`);
|
||
try {
|
||
for (const file of exhibitFiles) {
|
||
let source;
|
||
try {
|
||
source = await file.text();
|
||
} catch (error) {
|
||
this.diagnostics.error('ERR_IMPORT_READ', `Could not read ${file.name}: ${error.message}`, { section: 'loader' });
|
||
continue;
|
||
}
|
||
await this.library.importSource(source, file.name, {
|
||
confirmReplacement: async (existing, candidate) => window.confirm(
|
||
`${candidate.meta.name} has the same exhibit ID as ${existing.document.meta.name}, but different source bytes. Replace the cached exhibit?`
|
||
)
|
||
});
|
||
}
|
||
this.renderLibrary();
|
||
} finally {
|
||
this.setBusy(false, 'Library ready');
|
||
}
|
||
}
|
||
|
||
async activate(id) {
|
||
if (this.busy || this.activation.current?.record.id === id) return;
|
||
const record = this.library.get(id);
|
||
if (!record) return;
|
||
this.setBusy(true, `Preparing ${record.document.meta.name}…`);
|
||
try {
|
||
await this.activation.activate(record);
|
||
await this.disposeAudio();
|
||
this.attachVisuals();
|
||
this.renderLibrary();
|
||
this.renderStage();
|
||
} finally {
|
||
this.setBusy(false, this.activation.current ? 'Exhibit active' : 'Library ready');
|
||
}
|
||
}
|
||
|
||
async deactivate() {
|
||
if (this.busy || !this.activation.current) return;
|
||
this.setBusy(true, 'Deactivating exhibit…');
|
||
try {
|
||
await this.activation.deactivate();
|
||
await this.disposeAudio();
|
||
this.visual.deactivate();
|
||
this.renderLibrary();
|
||
this.renderStage();
|
||
} finally {
|
||
this.setBusy(false, 'Library ready');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 17.14: activation is the instantiation boundary of every persistent visual
|
||
* system, so the engine is built once here and never per frame.
|
||
*/
|
||
attachVisuals() {
|
||
const current = this.activation.current;
|
||
const canvas = element('stage-canvas');
|
||
if (!current || !canvas) {
|
||
this.visual.deactivate();
|
||
return;
|
||
}
|
||
this.visual.attach(canvas);
|
||
try {
|
||
const engine = this.visual.activate(current.record.document, {
|
||
resolution: current.performance.engine,
|
||
rng: current.performance.rng
|
||
});
|
||
canvas.hidden = engine === null;
|
||
current.performance.actions.visual = engine;
|
||
if (engine) this.visual.render({ logicalMilliseconds: current.performance.engine.logicalMilliseconds });
|
||
} catch (error) {
|
||
canvas.hidden = true;
|
||
this.visual.deactivate();
|
||
this.diagnostics.error(error.code ?? 'ERR_SCHEMA_VALIDATION', `The visual subsystem could not be instantiated: ${error.message}`, { section: 'visual', exhibitId: current.record.id, objectId: error.path });
|
||
}
|
||
}
|
||
|
||
renderLibrary() {
|
||
const records = this.library.list();
|
||
const container = element('library-list');
|
||
container.replaceChildren();
|
||
element('empty-state').hidden = records.length > 0;
|
||
for (const record of records) {
|
||
const meta = record.document.meta;
|
||
const active = this.activation.current?.record.id === record.id;
|
||
const card = document.createElement('article');
|
||
card.className = `exhibit-card${active ? ' is-active' : ''}`;
|
||
card.append(text('h3', meta.name));
|
||
card.append(text('p', meta.description || 'Minimal XZBT 0.1 exhibit.', 'description'));
|
||
const details = [meta.author, meta.version && `v${meta.version}`, record.id].filter(Boolean).join(' · ');
|
||
card.append(text('p', details, 'meta'));
|
||
const button = text('button', active ? 'Active' : 'Activate');
|
||
button.type = 'button';
|
||
button.disabled = active || this.busy;
|
||
button.addEventListener('click', () => this.activate(record.id));
|
||
card.append(button);
|
||
container.append(card);
|
||
}
|
||
}
|
||
|
||
renderStage() {
|
||
const current = this.activation.current;
|
||
element('deactivate-button').disabled = !current || this.busy;
|
||
element('stage-empty').hidden = Boolean(current);
|
||
element('stage-active').hidden = !current;
|
||
if (!current) {
|
||
element('configuration').replaceChildren();
|
||
element('resolved-values').replaceChildren();
|
||
return;
|
||
}
|
||
const { meta } = current.record.document;
|
||
element('active-name').textContent = meta.name;
|
||
element('active-id').textContent = meta.id;
|
||
element('active-seed').textContent = String(current.seed);
|
||
const preview = current.performance.rng.stream('visual', 'runtime-preview:1');
|
||
element('active-sequence').textContent = [preview.nextUint32(), preview.nextUint32(), preview.nextUint32()].join(' · ');
|
||
this.renderConfiguration(current.performance);
|
||
this.renderValues(current.performance.engine);
|
||
this.renderAudio();
|
||
this.renderScenarios();
|
||
}
|
||
|
||
async syncAudioPause() {
|
||
const context = this.audio?.context;
|
||
if (!context) return;
|
||
try {
|
||
if (this.activation.current?.performance.pauseReasons.size) await context.suspend();
|
||
else if (context.state === 'suspended') await context.resume();
|
||
} catch (error) { this.diagnostics.warn('WARN_AUDIO_UNAVAILABLE', error.message, { section: 'audio' }); }
|
||
}
|
||
|
||
renderScenarios() {
|
||
const container = element('scenario-list');
|
||
container.replaceChildren();
|
||
const director = this.activation.current?.performance.scenarios;
|
||
for (const [id, state] of director?.definitions ?? []) {
|
||
const row = document.createElement('div');
|
||
row.append(text('span', state.definition.name ?? id));
|
||
const start = text('button', 'Start'); start.type = 'button'; start.onclick = () => director.request(id);
|
||
const stop = text('button', 'Cancel'); stop.type = 'button'; stop.onclick = () => director.cancel(id);
|
||
const status = text('output', state.status); status.dataset.scenarioStatus = id;
|
||
row.append(start, stop, status); container.append(row);
|
||
}
|
||
if (!director?.definitions.size) container.append(text('p', 'No scenarios in this exhibit.'));
|
||
element('pause-button').textContent = 'Pause';
|
||
}
|
||
|
||
async unlockAudio() {
|
||
const current = this.activation.current;
|
||
if (!current) return;
|
||
if (!this.audio) {
|
||
this.audio = new AudioSubsystem({
|
||
document: current.record.document,
|
||
rng: current.performance.rng,
|
||
resolutionEngine: current.performance.engine,
|
||
diagnostics: this.diagnostics
|
||
});
|
||
this.audio.setMasterVolume(Number(element('master-volume').value));
|
||
}
|
||
await this.audio.unlock();
|
||
current.performance.setAudio(this.audio);
|
||
current.performance.scenarios.resumeContinuous();
|
||
await this.syncAudioPause();
|
||
this.renderAudio();
|
||
}
|
||
|
||
async disposeAudio() {
|
||
if (!this.audio) return;
|
||
const current = this.activation.current;
|
||
if (current) current.performance.setAudio(null);
|
||
await this.audio.dispose();
|
||
this.audio = null;
|
||
this.renderAudio();
|
||
}
|
||
|
||
playSound(id) {
|
||
const current = this.activation.current;
|
||
if (!this.audio?.unlocked || !current) return;
|
||
this.audio.play(id, { resolveReference: (path) => current.performance.engine.get(path) });
|
||
this.renderAudio();
|
||
}
|
||
|
||
renderAudio() {
|
||
const current = this.activation.current;
|
||
const state = element('audio-state');
|
||
const stopButton = element('audio-stop');
|
||
const busContainer = element('audio-buses');
|
||
const soundContainer = element('sound-list');
|
||
busContainer.replaceChildren();
|
||
soundContainer.replaceChildren();
|
||
if (!current) {
|
||
state.textContent = 'Audio is locked until you start it.';
|
||
stopButton.disabled = true;
|
||
return;
|
||
}
|
||
const document_ = current.record.document;
|
||
const sounds = document_.sounds ?? {};
|
||
const unlocked = Boolean(this.audio?.unlocked);
|
||
element('audio-unlock').disabled = unlocked;
|
||
stopButton.disabled = !unlocked || (this.audio?.voices.size ?? 0) === 0;
|
||
state.textContent = unlocked
|
||
? `Audio running at ${this.audio.context.sampleRate} Hz · ${this.audio.voices.size} live voice${this.audio.voices.size === 1 ? '' : 's'}`
|
||
: 'Audio is locked until you start it. Browsers require a user gesture.';
|
||
|
||
for (const [id, bus] of Object.entries(document_.audio?.buses ?? {})) {
|
||
const row = document.createElement('div');
|
||
row.className = 'parameter-control';
|
||
const label = text('label', `Bus ${id}`);
|
||
label.htmlFor = `bus-${id}`;
|
||
const input = document.createElement('input');
|
||
input.id = `bus-${id}`;
|
||
input.type = 'range';
|
||
input.min = '0';
|
||
input.max = '4';
|
||
input.step = '0.01';
|
||
input.value = String(current.performance.engine.base(`audio.buses.${id}.gain`));
|
||
input.disabled = !unlocked;
|
||
input.addEventListener('input', (event) => this.audio?.setBusGain(id, Number(event.target.value)));
|
||
row.append(label, input);
|
||
busContainer.append(row);
|
||
}
|
||
|
||
if (Object.keys(sounds).length === 0) {
|
||
soundContainer.append(text('p', 'This exhibit declares no sounds.', 'empty'));
|
||
return;
|
||
}
|
||
for (const [id, sound] of Object.entries(sounds)) {
|
||
const row = document.createElement('div');
|
||
row.className = 'sound-row';
|
||
row.append(text('span', sound.name ?? id));
|
||
const button = text('button', 'Play');
|
||
button.type = 'button';
|
||
button.disabled = !unlocked;
|
||
button.addEventListener('click', () => this.playSound(id));
|
||
row.append(button);
|
||
soundContainer.append(row);
|
||
}
|
||
}
|
||
|
||
renderConfiguration(performance) {
|
||
const container = element('configuration');
|
||
container.replaceChildren();
|
||
const definitions = performance.record.document.parameters ?? {};
|
||
if (Object.keys(definitions).length === 0) {
|
||
container.append(text('p', 'This exhibit declares no parameters.', 'empty'));
|
||
return;
|
||
}
|
||
for (const [id, spec] of Object.entries(definitions)) {
|
||
const path = `parameters.${id}`;
|
||
const row = document.createElement('div');
|
||
row.className = 'parameter-control';
|
||
const label = text('label', spec.label ?? id);
|
||
label.htmlFor = `parameter-${id}`;
|
||
const badge = text('span', 'overridden', 'override-badge');
|
||
badge.dataset.overrideFor = path;
|
||
badge.hidden = true;
|
||
label.append(badge);
|
||
const input = this.parameterInput(id, spec, performance.engine.parameters.get(id));
|
||
const update = () => {
|
||
try {
|
||
const value = spec.type === 'boolean' ? input.checked : (spec.type === 'number' || spec.type === 'integer' ? Number(input.value) : input.value);
|
||
performance.setParameter(id, value);
|
||
} catch (error) {
|
||
this.diagnostics.error(error.code ?? 'ERR_TYPE_MISMATCH', error.message, { exhibitId: performance.record.id, section: 'parameters', objectId: id });
|
||
}
|
||
};
|
||
input.addEventListener(spec.type === 'number' ? 'input' : 'change', update);
|
||
row.append(label, input);
|
||
const override = text('button', 'Temporary override');
|
||
override.type = 'button';
|
||
override.className = 'compact';
|
||
override.addEventListener('click', () => {
|
||
const value = this.demoOverrideValue(spec, performance.engine.parameters.get(id));
|
||
try {
|
||
performance.execute([{
|
||
type: 'override', target: path, value, scope: 'duration', duration: '3s',
|
||
transition: { in: spec.type === 'number' || spec.type === 'integer' ? '250ms' : '0ms', out: spec.type === 'number' || spec.type === 'integer' ? '750ms' : '0ms', easing: 'ease-in-out' }
|
||
}], { owner: 'phase2-ui', priority: 0 });
|
||
} catch {}
|
||
});
|
||
row.append(override);
|
||
container.append(row);
|
||
}
|
||
}
|
||
|
||
parameterInput(id, spec, value) {
|
||
let input;
|
||
if (spec.type === 'enum') {
|
||
input = document.createElement('select');
|
||
for (const optionValue of spec.values) {
|
||
const option = document.createElement('option');
|
||
option.value = optionValue;
|
||
option.textContent = optionValue;
|
||
input.append(option);
|
||
}
|
||
input.value = value;
|
||
} else {
|
||
input = document.createElement('input');
|
||
if (spec.type === 'boolean') { input.type = 'checkbox'; input.checked = value; }
|
||
else if (spec.type === 'color') { input.type = 'color'; input.value = value; }
|
||
else if (spec.type === 'number' || spec.type === 'integer') {
|
||
input.type = spec.min !== undefined && spec.max !== undefined ? 'range' : 'number';
|
||
if (spec.min !== undefined) input.min = String(spec.min);
|
||
if (spec.max !== undefined) input.max = String(spec.max);
|
||
input.step = String(spec.step ?? (spec.type === 'integer' ? 1 : 'any'));
|
||
input.value = String(value);
|
||
} else { input.type = 'text'; input.value = value; }
|
||
}
|
||
input.id = `parameter-${id}`;
|
||
input.dataset.parameter = id;
|
||
return input;
|
||
}
|
||
|
||
demoOverrideValue(spec, stored) {
|
||
if (spec.type === 'number' || spec.type === 'integer') return spec.max ?? (stored + 1);
|
||
if (spec.type === 'boolean') return !stored;
|
||
if (spec.type === 'enum') return spec.values[(spec.values.indexOf(stored) + 1) % spec.values.length];
|
||
if (spec.type === 'color') return stored.toLowerCase() === '#b8ff5a' ? '#ffca5c' : '#b8ff5a';
|
||
return `${stored}*`;
|
||
}
|
||
|
||
renderValues(engine) {
|
||
for (const output of document.querySelectorAll('[data-scenario-status]')) {
|
||
const state = this.activation.current?.performance.scenarios.definitions.get(output.dataset.scenarioStatus);
|
||
if (state) output.textContent = state.status;
|
||
}
|
||
const container = element('resolved-values');
|
||
if (!container) return;
|
||
const snapshot = engine.snapshot();
|
||
container.replaceChildren();
|
||
for (const [path, value] of Object.entries(snapshot)) {
|
||
const row = document.createElement('li');
|
||
row.append(text('code', path), text('output', typeof value === 'number' ? value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '') : String(value)));
|
||
container.append(row);
|
||
}
|
||
for (const badge of document.querySelectorAll('[data-override-for]')) badge.hidden = !engine.overrides.has(badge.dataset.overrideFor);
|
||
const first = Object.entries(engine.document.parameters ?? {})[0];
|
||
if (first) {
|
||
const [id, spec] = first;
|
||
const stored = engine.parameters.get(id);
|
||
const condition = (spec.type === 'number' || spec.type === 'integer')
|
||
? { op: 'gt', left: { ref: `parameters.${id}` }, right: ((spec.min ?? 0) + (spec.max ?? 1)) / 2 }
|
||
: { op: 'eq', left: { ref: `parameters.${id}` }, right: stored };
|
||
element('condition-result').textContent = String(engine.evaluateCondition(condition, engine.rng.stream('scenario', 'phase2-condition:1')));
|
||
} else element('condition-result').textContent = 'n/a';
|
||
}
|
||
|
||
rememberParameters(exhibitId, values) {
|
||
this.parameterValues[exhibitId] = values;
|
||
this.persistence.savePreference(`parameters:${exhibitId}`, values).catch((error) => {
|
||
this.diagnostics.warn('WARN_STORAGE_WRITE_FAILED', `Parameter changes remain session-only: ${error.message}`, { exhibitId, section: 'persistence' });
|
||
});
|
||
}
|
||
|
||
renderDiagnostics(entries) {
|
||
const container = element('diagnostic-list');
|
||
if (!container) return;
|
||
container.replaceChildren();
|
||
const newest = [...entries].reverse();
|
||
element('diagnostic-count').textContent = String(entries.length);
|
||
element('diagnostics-empty').hidden = newest.length > 0;
|
||
for (const entry of newest) {
|
||
const row = document.createElement('li');
|
||
row.className = `diagnostic ${entry.severity}`;
|
||
row.append(text('span', entry.severity.toUpperCase(), 'severity'));
|
||
row.append(text('code', entry.code));
|
||
row.append(text('span', entry.message, 'diagnostic-message'));
|
||
const context = [entry.exhibitId, entry.section, entry.objectId, entry.property].filter(Boolean).join(' › ');
|
||
if (context) row.append(text('small', context));
|
||
container.append(row);
|
||
}
|
||
}
|
||
|
||
renderStorage() {
|
||
const node = element('storage-status');
|
||
node.textContent = this.persistence.available ? 'Local cache available' : 'Session only';
|
||
node.className = this.persistence.available ? 'storage-ready' : 'storage-warning';
|
||
}
|
||
|
||
setBusy(busy, status) {
|
||
this.busy = busy;
|
||
element('import-button').disabled = busy;
|
||
element('deactivate-button').disabled = busy || !this.activation.current;
|
||
this.setStatus(status);
|
||
this.renderLibrary();
|
||
}
|
||
|
||
setStatus(value) { element('app-status').textContent = value; }
|
||
}
|
||
|
||
async function bootstrap() {
|
||
const application = new XZBTApplication();
|
||
globalThis.XZBT = Object.freeze({ application });
|
||
await application.start();
|
||
}
|
||
|
||
void bootstrap();
|
||
|
||
})();</script>
|
||
</body>
|
||
</html>
|