@triscope/core 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/dist/compose.d.ts +11 -0
  4. package/dist/compose.d.ts.map +1 -0
  5. package/dist/compose.js +152 -0
  6. package/dist/compose.js.map +1 -0
  7. package/dist/editor.d.ts +14 -0
  8. package/dist/editor.d.ts.map +1 -0
  9. package/dist/editor.js +131 -0
  10. package/dist/editor.js.map +1 -0
  11. package/dist/harness.d.ts +199 -0
  12. package/dist/harness.d.ts.map +1 -0
  13. package/dist/harness.js +1027 -0
  14. package/dist/harness.js.map +1 -0
  15. package/dist/index.d.ts +32 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +20 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/inspect.d.ts +94 -0
  20. package/dist/inspect.d.ts.map +1 -0
  21. package/dist/inspect.js +434 -0
  22. package/dist/inspect.js.map +1 -0
  23. package/dist/knob-utils.d.ts +22 -0
  24. package/dist/knob-utils.d.ts.map +1 -0
  25. package/dist/knob-utils.js +51 -0
  26. package/dist/knob-utils.js.map +1 -0
  27. package/dist/lab/css.d.ts +11 -0
  28. package/dist/lab/css.d.ts.map +1 -0
  29. package/dist/lab/css.js +57 -0
  30. package/dist/lab/css.js.map +1 -0
  31. package/dist/lab/dom.d.ts +13 -0
  32. package/dist/lab/dom.d.ts.map +1 -0
  33. package/dist/lab/dom.js +76 -0
  34. package/dist/lab/dom.js.map +1 -0
  35. package/dist/motion-probe.d.ts +47 -0
  36. package/dist/motion-probe.d.ts.map +1 -0
  37. package/dist/motion-probe.js +122 -0
  38. package/dist/motion-probe.js.map +1 -0
  39. package/dist/probe-utils.d.ts +14 -0
  40. package/dist/probe-utils.d.ts.map +1 -0
  41. package/dist/probe-utils.js +18 -0
  42. package/dist/probe-utils.js.map +1 -0
  43. package/dist/scene-cameras.d.ts +6 -0
  44. package/dist/scene-cameras.d.ts.map +1 -0
  45. package/dist/scene-cameras.js +20 -0
  46. package/dist/scene-cameras.js.map +1 -0
  47. package/dist/scene-delta.d.ts +21 -0
  48. package/dist/scene-delta.d.ts.map +1 -0
  49. package/dist/scene-delta.js +57 -0
  50. package/dist/scene-delta.js.map +1 -0
  51. package/dist/scene-introspect.d.ts +78 -0
  52. package/dist/scene-introspect.d.ts.map +1 -0
  53. package/dist/scene-introspect.js +164 -0
  54. package/dist/scene-introspect.js.map +1 -0
  55. package/dist/scene-registry.d.ts +36 -0
  56. package/dist/scene-registry.d.ts.map +1 -0
  57. package/dist/scene-registry.js +64 -0
  58. package/dist/scene-registry.js.map +1 -0
  59. package/dist/scene-view.d.ts +52 -0
  60. package/dist/scene-view.d.ts.map +1 -0
  61. package/dist/scene-view.js +171 -0
  62. package/dist/scene-view.js.map +1 -0
  63. package/dist/source-tag.d.ts +34 -0
  64. package/dist/source-tag.d.ts.map +1 -0
  65. package/dist/source-tag.js +120 -0
  66. package/dist/source-tag.js.map +1 -0
  67. package/dist/telemetry.d.ts +53 -0
  68. package/dist/telemetry.d.ts.map +1 -0
  69. package/dist/telemetry.js +302 -0
  70. package/dist/telemetry.js.map +1 -0
  71. package/dist/types.d.ts +142 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +9 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/uniform-access.d.ts +32 -0
  76. package/dist/uniform-access.d.ts.map +1 -0
  77. package/dist/uniform-access.js +144 -0
  78. package/dist/uniform-access.js.map +1 -0
  79. package/dist/validate.d.ts +2 -0
  80. package/dist/validate.d.ts.map +1 -0
  81. package/dist/validate.js +81 -0
  82. package/dist/validate.js.map +1 -0
  83. package/dist/vite.d.ts +3 -0
  84. package/dist/vite.d.ts.map +1 -0
  85. package/dist/vite.js +4 -0
  86. package/dist/vite.js.map +1 -0
  87. package/dist/warnings.d.ts +24 -0
  88. package/dist/warnings.d.ts.map +1 -0
  89. package/dist/warnings.js +26 -0
  90. package/dist/warnings.js.map +1 -0
  91. package/package.json +60 -0
  92. package/src/compose.ts +164 -0
  93. package/src/editor.ts +138 -0
  94. package/src/harness.ts +1263 -0
  95. package/src/index.ts +58 -0
  96. package/src/inspect.ts +498 -0
  97. package/src/knob-utils.ts +60 -0
  98. package/src/lab/css.ts +56 -0
  99. package/src/lab/dom.ts +88 -0
  100. package/src/motion-probe.ts +135 -0
  101. package/src/probe-utils.ts +17 -0
  102. package/src/scene-cameras.ts +33 -0
  103. package/src/scene-delta.ts +69 -0
  104. package/src/scene-introspect.ts +230 -0
  105. package/src/scene-registry.ts +103 -0
  106. package/src/scene-view.ts +204 -0
  107. package/src/source-tag.ts +139 -0
  108. package/src/telemetry.ts +337 -0
  109. package/src/three-webgpu-shim.d.ts +130 -0
  110. package/src/types.ts +121 -0
  111. package/src/uniform-access.ts +152 -0
  112. package/src/validate.ts +82 -0
  113. package/src/vite.ts +5 -0
  114. package/src/warnings.ts +41 -0
package/src/harness.ts ADDED
@@ -0,0 +1,1263 @@
1
+ import * as THREE from 'three/webgpu';
2
+ import { mountEditor } from './editor.js';
3
+ import {
4
+ createInspectMode,
5
+ type InspectMode,
6
+ type InspectSelection,
7
+ readInspectFromUrl,
8
+ } from './inspect.js';
9
+ import { clampKnob } from './knob-utils.js';
10
+ import { MotionProbeBuffer, type ProbeStats } from './motion-probe.js';
11
+ import { isBlackFrame } from './probe-utils.js';
12
+ import { autoCameras } from './scene-cameras.js';
13
+ import { applyCameraDelta, applyElementToggle, type CameraDelta } from './scene-delta.js';
14
+ import { type LightNode, type SceneNode, serializeScene } from './scene-introspect.js';
15
+ import { nsKey, type RegistryEntry } from './scene-registry.js';
16
+ import { installSourceTagPatch } from './source-tag.js';
17
+ import type {
18
+ CameraSpec,
19
+ Element,
20
+ Knob,
21
+ MountContext,
22
+ MountHandle,
23
+ TriscopeEvent,
24
+ } from './types.js';
25
+ import { knobDefault } from './types.js';
26
+ import {
27
+ readUniformValue,
28
+ type UniformReadResult,
29
+ type UniformWriteResult,
30
+ writeUniformValue,
31
+ } from './uniform-access.js';
32
+ import { validateElement } from './validate.js';
33
+ import { createWarningRing, type WarningEntry } from './warnings.js';
34
+
35
+ // Patched once at module load — every Object3D.add() across any element in
36
+ // any lab gets the auto source-tag. Idempotent across multiple runLab().
37
+ installSourceTagPatch();
38
+
39
+ declare global {
40
+ interface Window {
41
+ __TRISCOPE__?: TriscopeGlobal;
42
+ /** Set when element.mount() throws or the contract is invalid; read by the
43
+ * MCP browser pool to report the real cause instead of a mount timeout. */
44
+ __TRISCOPE_MOUNT_ERROR__?: { element?: string; message: string; problems: string[] };
45
+ }
46
+ }
47
+
48
+ interface TriscopeGlobal {
49
+ element: Element;
50
+ handle: MountHandle;
51
+ renderer: THREE.WebGPURenderer;
52
+ scene: THREE.Scene;
53
+ cameras: Record<string, THREE.PerspectiveCamera>;
54
+ knobValues: Record<string, number | string | boolean>;
55
+ setKnob: (key: string, value: number | string | boolean) => void;
56
+ sampleTelemetry: () => Record<string, unknown>;
57
+ /** Inspect-mode selection — set by clicking a mesh in `?inspect=` mode. */
58
+ lastSelection?: InspectSelection | null;
59
+ /** Capture one frame per named camera; returns base64 PNGs keyed by camera. */
60
+ captureViews: () => Promise<Record<string, string>>;
61
+ /**
62
+ * Capture N frames of a single camera spaced by dt seconds. In `mode: 'time'`
63
+ * (default), the RAF loop is paused and `time.value` is stepped forward
64
+ * deterministically — works for shader-driven motion. In `mode: 'real'`, the
65
+ * RAF keeps running and frames are sampled at wall-clock intervals — needed
66
+ * for CPU-integrated state (springs, particles).
67
+ */
68
+ captureMotionFrames: (
69
+ camera: string,
70
+ opts?: { frames?: number; dt?: number; mode?: 'time' | 'real' },
71
+ ) => Promise<string[]>;
72
+ /** Per-camera GPU probe stats from the most recent captureViews() call. */
73
+ lastGpuProbes?: Record<string, GpuProbeStats>;
74
+ /** Recent non-fatal failures the harness swallowed (telemetry/knob/probe). */
75
+ warnings?: WarningEntry[];
76
+ /**
77
+ * Snapshot the live scene graph: meshes/lights/groups with triangle counts,
78
+ * world positions, material kind/color, uniform names, and the file:line
79
+ * source tag. One call replaces a flurry of capture probes when an agent
80
+ * needs to know what's actually in the scene.
81
+ */
82
+ queryScene: (maxNodes?: number) => {
83
+ nodes: SceneNode[];
84
+ lights: LightNode[];
85
+ total: number;
86
+ truncated: boolean;
87
+ };
88
+ /** Read any material uniform / material property / object property by
89
+ * "objectName|uuid.key" path — even undeclared ones. */
90
+ readUniform: (path: string) => UniformReadResult;
91
+ /** Write the same, live and transient (not persisted). Returns previous/current. */
92
+ setUniform: (path: string, value: unknown) => UniformWriteResult;
93
+ /**
94
+ * SDL: apply a scene delta live (repoint cameras, override knobs) WITHOUT a
95
+ * reload, and persist it via /__scene so it survives one. Returns the live
96
+ * scene state after applying.
97
+ */
98
+ setSceneParam: (delta: {
99
+ cameras?: Record<string, CameraDelta>;
100
+ knobs?: Record<string, unknown>;
101
+ elements?: Record<string, { enabled?: boolean }>;
102
+ }) => {
103
+ cameras: Record<string, { position: number[]; target: number[]; fov: number }>;
104
+ knobs: Record<string, number | string | boolean>;
105
+ elements: Record<string, { enabled: boolean }>;
106
+ };
107
+ /** Current live camera positions/targets/fov + knob values + element on/off. */
108
+ getScene: () => {
109
+ cameras: Record<string, { position: number[]; target: number[]; fov: number }>;
110
+ knobs: Record<string, number | string | boolean>;
111
+ elements: Record<string, { enabled: boolean }>;
112
+ };
113
+ /**
114
+ * Mount a registered-but-unmounted element live (runSceneLab scenes). Adds
115
+ * its namespaced cameras/knobs and rebuilds the grid. Returns false if the
116
+ * name is unknown or already mounted. A single-element runLab has nothing
117
+ * else to add, so this is a no-op there.
118
+ */
119
+ addElement: (name: string) => boolean;
120
+ /** Dispose a mounted element live + rebuild the grid. False if not mounted. */
121
+ removeElement: (name: string) => boolean;
122
+ /** All element names in the scene registry (mounted or not). */
123
+ availableElements: () => string[];
124
+ /** Currently mounted element names, in registry order. */
125
+ mountedElements: () => string[];
126
+ /** Monotonic count of rendered frames since boot (gates capture readiness). */
127
+ framesRendered: () => number;
128
+ }
129
+
130
+ export interface CommonLabOptions {
131
+ canvas: HTMLCanvasElement;
132
+ editorContainer?: HTMLElement | null;
133
+ labelContainer?: HTMLElement | null;
134
+ hud?: HTMLElement | null;
135
+ bootOverlay?: HTMLElement | null;
136
+ telemetryIntervalMs?: number;
137
+ knobPollMs?: number;
138
+ /** Optional clear color for the scene before each frame. Default `#0a1a20`. */
139
+ clearColor?: number;
140
+ /**
141
+ * Fixed [width, height] in CSS pixels to which the canvas is resized for
142
+ * every captureViews / captureMotionFrames call, then restored. Use this
143
+ * when you need reproducible framing across page reloads (otherwise the
144
+ * canvas tracks clientWidth/clientHeight which can drift). Off by default.
145
+ */
146
+ captureSize?: [number, number];
147
+ }
148
+
149
+ export interface LabOptions extends CommonLabOptions {
150
+ element: Element;
151
+ }
152
+
153
+ export interface SceneLabOptions extends CommonLabOptions {
154
+ /** Registry of element types the scene can mount/unmount at runtime. */
155
+ elements: Element[];
156
+ /** Names initially mounted (defaults to every registered element). */
157
+ mounted?: string[];
158
+ }
159
+
160
+ export interface LabHandle {
161
+ /** The primary mounted element (first slot); undefined-safe as slots change. */
162
+ element: Element;
163
+ setKnob: (key: string, value: number | string | boolean) => void;
164
+ captureViews: () => Promise<Record<string, string>>;
165
+ /** Mount a registered element live (runSceneLab). No-op for single-element labs. */
166
+ addElement: (name: string) => boolean;
167
+ /** Dispose a mounted element live (runSceneLab). */
168
+ removeElement: (name: string) => boolean;
169
+ /** All element names in the scene registry. */
170
+ availableElements: () => string[];
171
+ /** Currently mounted element names. */
172
+ mountedElements: () => string[];
173
+ stop: () => void;
174
+ }
175
+
176
+ /**
177
+ * Boot a multi-camera lab page for a single Element.
178
+ * One WebGPURenderer, one scene, one Element, N scissored viewports.
179
+ *
180
+ * Thin wrapper over {@link runSceneCore}: a one-entry, non-namespaced registry.
181
+ * The single-element path is behaviorally identical to before this was
182
+ * generalized — bare camera/knob names, `elements: { [name]: … }`,
183
+ * `project: name`.
184
+ */
185
+ export async function runLab(opts: LabOptions): Promise<LabHandle> {
186
+ return runSceneCore(opts, {
187
+ entries: [{ name: opts.element.name, element: opts.element }],
188
+ namespaced: false,
189
+ initialMounted: [opts.element.name],
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Boot a multi-camera lab page for a SCENE of N Elements, with runtime
195
+ * mount/unmount via the returned handle's addElement/removeElement (and the
196
+ * MCP add_element/remove_element tools). Camera + knob keys are namespaced
197
+ * `<element>.<key>` so two elements that both declare `top`/`speed` never
198
+ * collide. Shares one renderer/scene with runLab through {@link runSceneCore}.
199
+ */
200
+ export async function runSceneLab(opts: SceneLabOptions): Promise<LabHandle> {
201
+ const entries = opts.elements.map((e) => ({ name: e.name, element: e }));
202
+ return runSceneCore(opts, {
203
+ entries,
204
+ namespaced: true,
205
+ initialMounted: opts.mounted ?? entries.map((e) => e.name),
206
+ });
207
+ }
208
+
209
+ async function runSceneCore(
210
+ opts: CommonLabOptions,
211
+ registry: { entries: RegistryEntry[]; namespaced: boolean; initialMounted: string[] },
212
+ ): Promise<LabHandle> {
213
+ const { entries, namespaced, initialMounted } = registry;
214
+ const {
215
+ canvas,
216
+ editorContainer = null,
217
+ labelContainer = null,
218
+ hud = null,
219
+ bootOverlay = null,
220
+ telemetryIntervalMs = 500,
221
+ knobPollMs = 100,
222
+ clearColor = 0x0a1a20,
223
+ } = opts;
224
+
225
+ if (!('gpu' in navigator)) {
226
+ throw new Error(
227
+ 'navigator.gpu is unavailable — open this page in Chrome/Edge with WebGPU enabled.',
228
+ );
229
+ }
230
+
231
+ const renderer = new THREE.WebGPURenderer({ canvas, antialias: true });
232
+ await renderer.init();
233
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
234
+ renderer.setClearColor(clearColor, 1);
235
+ renderer.autoClear = false;
236
+
237
+ const scene = new THREE.Scene();
238
+ const time = { value: 0 };
239
+ const dt = { value: 0 };
240
+ const ctx: MountContext = { renderer, scene, time, dt };
241
+
242
+ // Ring of swallowed non-fatal failures, surfaced via telemetry .warnings so
243
+ // an agent can diagnose "why is telemetry stale / the knob not applying /
244
+ // this pane black?" instead of guessing. Created before mount so contract
245
+ // problems and a mount throw are both recorded.
246
+ const warnings = createWarningRing(32);
247
+
248
+ // ---- Multi-element registry -------------------------------------------
249
+ // runLab → one entry, namespaced=false (bare camera/knob names, identical to
250
+ // before this was generalized). runSceneLab → N entries, namespaced=true
251
+ // (`<element>.<key>` keys so two elements declaring `top`/`speed` don't
252
+ // collide), with runtime mountSlot/unmountSlot driving addElement/
253
+ // removeElement. A "slot" is one mounted element + its handle + probes.
254
+ interface Slot {
255
+ name: string;
256
+ element: Element;
257
+ handle: MountHandle;
258
+ probeKeys: string[];
259
+ probeBuffers: Record<string, MotionProbeBuffer>;
260
+ }
261
+ const slots: Slot[] = [];
262
+ const cameras: Record<string, THREE.PerspectiveCamera> = {};
263
+ const cameraOrder: string[] = [];
264
+ const cameraElement: Record<string, string> = {}; // display camera name → slot name
265
+ const knobs: Record<string, Knob> = {}; // display key → spec
266
+ const knobValues: Record<string, number | string | boolean> = {};
267
+ const knobBinding: Record<string, { slot: Slot; local: string }> = {}; // display key → routing
268
+
269
+ // Persisted knob values (previous session, before a full reload) keyed by
270
+ // element name → local key; override spec defaults so user tuning survives
271
+ // shader edits. Fetched once; mountSlot reads the per-element slice.
272
+ let persistedKnobs: Record<string, Record<string, unknown>> = {};
273
+ try {
274
+ const r = await fetch('/__knob/current');
275
+ if (r.ok) persistedKnobs = (await r.json()) as Record<string, Record<string, unknown>>;
276
+ } catch {
277
+ /* dev server transient — fall through to defaults */
278
+ }
279
+
280
+ // Validate the Element contract + run mount() inside an error boundary, so a
281
+ // bad element surfaces a precise error (boot overlay + __TRISCOPE_MOUNT_ERROR__,
282
+ // which the MCP browser pool reads) instead of a blind "harness not mounted"
283
+ // timeout. We deliberately do NOT set window.__TRISCOPE__ on failure — its
284
+ // absence stays the "not mounted" signal. Registers the element's namespaced
285
+ // cameras + knobs (with persisted-value restore + clamp + initial onKnob).
286
+ // Returns null if `name` is already mounted (so addElement is idempotent).
287
+ function mountSlot(name: string, el: Element): Slot | null {
288
+ if (slots.some((s) => s.name === name)) return null;
289
+ const contractProblems = validateElement(el);
290
+ for (const p of contractProblems) warnings.push('validate', namespaced ? `${name}: ${p}` : p);
291
+ let handle: MountHandle;
292
+ try {
293
+ handle = el.mount({ parent: scene, ctx });
294
+ if (!handle || typeof handle.dispose !== 'function') {
295
+ throw new Error('mount() did not return a { root, dispose } handle');
296
+ }
297
+ } catch (err) {
298
+ const msg = errDetail(err);
299
+ if (typeof window !== 'undefined') {
300
+ window.__TRISCOPE_MOUNT_ERROR__ = {
301
+ element: name,
302
+ message: msg,
303
+ problems: contractProblems,
304
+ };
305
+ }
306
+ if (bootOverlay) bootOverlay.textContent = `mount failed: ${msg}`;
307
+ throw new Error(`triscope: element "${name}" failed to mount: ${msg}`);
308
+ }
309
+ const probeKeys = Object.keys(el.motionProbes ?? {});
310
+ const probeBuffers: Record<string, MotionProbeBuffer> = {};
311
+ for (const k of probeKeys) probeBuffers[k] = new MotionProbeBuffer(120);
312
+ const slot: Slot = { name, element: el, handle, probeKeys, probeBuffers };
313
+ slots.push(slot);
314
+
315
+ // PerspectiveCamera per named CameraSpec (namespaced display key).
316
+ // `cameras: 'auto'` → synthesize 4 fitted presets from bounds.
317
+ const camSpecs = el.cameras === 'auto' ? autoCameras(el.bounds) : (el.cameras ?? {});
318
+ for (const [local, spec] of Object.entries(camSpecs)) {
319
+ const disp = nsKey(name, local, namespaced);
320
+ // Guard the pathological case where a dotted element name collides with
321
+ // another element's name+key (both → the same display key).
322
+ if (cameraElement[disp] && cameraElement[disp] !== name) {
323
+ warnings.push('ns-collision', `camera key "${disp}" collides across elements`);
324
+ }
325
+ cameras[disp] = makeCamera(spec, el.bounds);
326
+ cameraElement[disp] = name;
327
+ if (!cameraOrder.includes(disp)) cameraOrder.push(disp);
328
+ }
329
+ // Knob state with restore + clamp + initial onKnob.
330
+ const persisted = persistedKnobs?.[name] ?? {};
331
+ for (const [local, spec] of Object.entries(el.knobs ?? {})) {
332
+ const disp = nsKey(name, local, namespaced);
333
+ if (knobBinding[disp] && knobBinding[disp].slot !== slot) {
334
+ warnings.push('ns-collision', `knob key "${disp}" collides across elements`);
335
+ }
336
+ knobs[disp] = spec;
337
+ knobBinding[disp] = { slot, local };
338
+ // Trigger knobs have no persistent value and must not fire on mount —
339
+ // they are pure action signals; onKnob only when set_knob is called.
340
+ if (spec.type === 'trigger') {
341
+ knobValues[disp] = false;
342
+ continue;
343
+ }
344
+ // Persisted store is keyed element→<whatever key was POSTed>. set_knob
345
+ // POSTs the manifest's advertised name, which is the DISPLAY key (`disp`)
346
+ // in a namespaced scene, so read `disp` and fall back to the bare local
347
+ // key (single-element labs + legacy stores). Without the `disp` fallback,
348
+ // namespaced scenes silently lose all tuning on every reload.
349
+ const saved = persisted[disp] !== undefined ? persisted[disp] : persisted[local];
350
+ const candidate = (saved !== undefined ? saved : knobDefault(spec)) as
351
+ | number
352
+ | string
353
+ | boolean;
354
+ // Clamp restored/default values too — a persisted out-of-range value (or a
355
+ // spec whose range shrank since it was saved) must not reach the element.
356
+ knobValues[disp] = clampKnob(spec, candidate).final;
357
+ // Guard the initial onKnob like telemetry/events/probes: a throwing
358
+ // element must degrade to a warning, not abort the (multi-element) boot.
359
+ if (el.onKnob) {
360
+ try {
361
+ el.onKnob(handle, local, knobValues[disp]);
362
+ } catch (e) {
363
+ warnings.push('onKnob', `${name}.${local} onKnob threw on mount`, errDetail(e));
364
+ }
365
+ }
366
+ }
367
+ return slot;
368
+ }
369
+
370
+ // Dispose a mounted slot + drop its cameras/knobs/probes. Returns false if it
371
+ // wasn't mounted. Does NOT rebuild labels/editor/viewports — addElement/
372
+ // removeElement do that once after the mutation completes.
373
+ function unmountSlot(name: string): boolean {
374
+ const idx = slots.findIndex((s) => s.name === name);
375
+ if (idx < 0) return false;
376
+ const slot = slots[idx];
377
+ for (const disp of Object.keys(cameraElement)) {
378
+ if (cameraElement[disp] !== name) continue;
379
+ delete cameras[disp];
380
+ delete cameraElement[disp];
381
+ const oi = cameraOrder.indexOf(disp);
382
+ if (oi >= 0) cameraOrder.splice(oi, 1);
383
+ }
384
+ for (const [disp, b] of Object.entries(knobBinding)) {
385
+ if (b.slot !== slot) continue;
386
+ delete knobBinding[disp];
387
+ delete knobs[disp];
388
+ delete knobValues[disp];
389
+ }
390
+ slots.splice(idx, 1);
391
+ try {
392
+ slot.handle.dispose();
393
+ } catch (e) {
394
+ warnings.push('unmount', `dispose of "${name}" threw`, errDetail(e));
395
+ }
396
+ return true;
397
+ }
398
+
399
+ // Initial mount (registry declaration order, filtered to the mounted set).
400
+ // A mount throw here propagates out of runSceneCore exactly as before.
401
+ for (const e of entries) {
402
+ if (initialMounted.includes(e.name)) mountSlot(e.name, e.element);
403
+ }
404
+
405
+ // Editor — rebuilt on add/remove so newly mounted knobs appear and removed
406
+ // ones vanish. For the single-element path this builds the identical editor.
407
+ let editor: ReturnType<typeof mountEditor> | null = null;
408
+ function rebuildEditor(): void {
409
+ if (!editorContainer) return;
410
+ editorContainer.replaceChildren();
411
+ editor = null;
412
+ if (Object.keys(knobs).length > 0) {
413
+ editor = mountEditor(editorContainer, knobs, knobValues, (key, value) =>
414
+ applyKnob(key, value, false),
415
+ );
416
+ }
417
+ }
418
+ rebuildEditor();
419
+
420
+ function applyKnob(key: string, value: number | string | boolean, fromExternal: boolean): void {
421
+ const spec = knobs[key];
422
+ const bind = knobBinding[key];
423
+ if (spec?.type === 'trigger') {
424
+ // Trigger: don't persist value, always pass `true` to onKnob regardless
425
+ // of what the caller passed. The value is purely a pulse signal. Routed
426
+ // to the owning element's onKnob with the element-local key.
427
+ bind?.slot.element.onKnob?.(bind.slot.handle, bind.local, true);
428
+ if (fromExternal && editor) editor.setValue(key, true);
429
+ return;
430
+ }
431
+ // Clamp/validate against the spec so a stray value (e.g. windPressure=9999)
432
+ // can't silently corrupt the scene; record a warning the agent can read.
433
+ let next = value;
434
+ if (spec) {
435
+ const c = clampKnob(spec, value);
436
+ next = c.final;
437
+ if (c.clamped) {
438
+ warnings.push(
439
+ 'knob-clamp',
440
+ `${key}=${JSON.stringify(value)} out of range → ${JSON.stringify(next)}`,
441
+ );
442
+ }
443
+ }
444
+ knobValues[key] = next;
445
+ bind?.slot.element.onKnob?.(bind.slot.handle, bind.local, next);
446
+ if (fromExternal && editor) editor.setValue(key, next);
447
+ }
448
+
449
+ // Camera labels (HTML overlays) — positioned dynamically each frame; rebuilt
450
+ // when slots change so labels track the current camera grid.
451
+ const labelEls: Record<string, HTMLDivElement> = {};
452
+ function rebuildLabels(): void {
453
+ if (!labelContainer) return;
454
+ for (const el of Object.values(labelEls)) el.remove();
455
+ for (const k of Object.keys(labelEls)) delete labelEls[k];
456
+ for (const name of cameraOrder) {
457
+ const el = document.createElement('div');
458
+ el.className = 'triscope-label';
459
+ el.textContent = name.toUpperCase();
460
+ el.style.position = 'absolute';
461
+ el.style.pointerEvents = 'none';
462
+ el.style.zIndex = '5';
463
+ labelContainer.appendChild(el);
464
+ labelEls[name] = el;
465
+ }
466
+ }
467
+ rebuildLabels();
468
+
469
+ // Post one manifest entry per mounted element so MCP can discover them. For a
470
+ // scene each element resolves to this page's labUrl; camera + knob names are
471
+ // the element's namespaced display keys. Re-posted on add (addElement).
472
+ function postManifestFor(slot: Slot): void {
473
+ const specs =
474
+ slot.element.cameras === 'auto'
475
+ ? autoCameras(slot.element.bounds)
476
+ : (slot.element.cameras ?? {});
477
+ const cams = Object.entries(specs).map(([local, c]) => ({
478
+ name: nsKey(slot.name, local, namespaced),
479
+ ...c,
480
+ }));
481
+ const ks = Object.entries(knobBinding)
482
+ .filter(([, b]) => b.slot === slot)
483
+ .map(([disp]) => ({ name: disp, ...knobs[disp], current: knobValues[disp] }));
484
+ postManifest({
485
+ element: slot.name,
486
+ labUrl:
487
+ slot.element.labUrl ??
488
+ (typeof window !== 'undefined' ? window.location.pathname : undefined),
489
+ cameras: cams,
490
+ knobs: ks,
491
+ }).catch((e) => warnings.push('postManifest', 'POST /__manifest failed', errDetail(e)));
492
+ }
493
+ for (const slot of slots) postManifestFor(slot);
494
+
495
+ // FPS + frame loop state.
496
+ let frames = 0;
497
+ let fpsWindowMs = 0;
498
+ let fps = 0;
499
+ // Monotonic count of rendered frames since boot. Unlike `fps` (recomputed only
500
+ // every ~500ms, so it reads 0 for the first half-second even while rendering),
501
+ // this is true on the very first RAF after mount — the MCP browser pool gates
502
+ // captures on it so a fresh navigate never captures a black/pre-render frame.
503
+ let framesRendered = 0;
504
+ let lastT = performance.now();
505
+ let lastTelemetryT = 0;
506
+ let lastKnobPollT = 0;
507
+ let running = true;
508
+
509
+ // Motion-probe ring buffers are per slot (see Slot.probeBuffers) — 120
510
+ // samples ≈ 2 s at 60 fps; telemetry exposes summary stats per element.
511
+
512
+ // Discrete-event ring buffer (cap 128). The harness drains element.events()
513
+ // each frame and appends to this buffer; sampleTelemetry surfaces it as
514
+ // `telemetry.events` so MCP read_telemetry .events can verify post-fact.
515
+ const EVENT_BUFFER_CAP = 128;
516
+ const eventBuffer: TriscopeEvent[] = [];
517
+
518
+ function resize(): void {
519
+ const w = canvas.clientWidth || window.innerWidth;
520
+ const h = canvas.clientHeight || window.innerHeight;
521
+ renderer.setSize(w, h, false);
522
+ // Update aspect for all cameras.
523
+ const n = cameraOrder.length;
524
+ const cols = Math.ceil(Math.sqrt(n));
525
+ const rows = Math.ceil(n / cols);
526
+ const paneW = w / cols;
527
+ const paneH = h / rows;
528
+ for (const name of cameraOrder) {
529
+ cameras[name].aspect = paneW / Math.max(paneH, 1);
530
+ cameras[name].updateProjectionMatrix();
531
+ }
532
+ if (labelContainer) {
533
+ cameraOrder.forEach((name, i) => {
534
+ const col = i % cols;
535
+ const row = Math.floor(i / cols);
536
+ const el = labelEls[name];
537
+ if (!el) return;
538
+ el.style.left = `${col * paneW + 8}px`;
539
+ el.style.top = `${row * paneH + 8}px`;
540
+ });
541
+ }
542
+ }
543
+ window.addEventListener('resize', resize);
544
+ resize();
545
+
546
+ // Inspect mode (URL ?inspect=<el>&camera=<name>): solo full-canvas view
547
+ // with OrbitControls, click-picking, hover highlight. Falls back to the
548
+ // grid view when the URL param is absent or targets a different element.
549
+ let inspectMode: InspectMode | null = null;
550
+ let currentSelection: InspectSelection | null = null;
551
+ let currentSelections: InspectSelection[] = [];
552
+ // (Re)build inspect mode against the CURRENT slots. Called at boot and after
553
+ // every add/remove: disposing the prior instance frees its overlays + the
554
+ // geometry they borrowed (otherwise removing the inspected element leaves a
555
+ // ghost wireframe pinned to freed GPU geometry), and re-matching the URL means
556
+ // inspect follows a re-added element / falls back to the grid when its target
557
+ // is gone. Match ?inspect= against any mounted element (single-element labs
558
+ // reduce to readInspectFromUrl(element.name)).
559
+ function setupInspect(): void {
560
+ if (inspectMode) {
561
+ inspectMode.dispose();
562
+ inspectMode = null;
563
+ }
564
+ let inspectCfg: ReturnType<typeof readInspectFromUrl> = null;
565
+ let inspectName = slots[0]?.name ?? '';
566
+ for (const s of slots) {
567
+ const cfg = readInspectFromUrl(s.name);
568
+ if (cfg) {
569
+ inspectCfg = cfg;
570
+ inspectName = s.name;
571
+ break;
572
+ }
573
+ }
574
+ if (!inspectCfg) return;
575
+ inspectMode = createInspectMode({
576
+ renderer,
577
+ scene,
578
+ cameras,
579
+ elementName: inspectName,
580
+ canvas,
581
+ cameraName: inspectCfg.camera,
582
+ onSelectionChange: (sel, all) => {
583
+ currentSelection = sel;
584
+ currentSelections = all ?? [];
585
+ if (typeof window !== 'undefined' && window.__TRISCOPE__) {
586
+ (window.__TRISCOPE__ as any).lastSelection = sel;
587
+ (window.__TRISCOPE__ as any).selections = currentSelections;
588
+ }
589
+ },
590
+ });
591
+ }
592
+ setupInspect();
593
+
594
+ function renderAll(): void {
595
+ const n = cameraOrder.length;
596
+ const cols = Math.ceil(Math.sqrt(n));
597
+ const rows = Math.ceil(n / cols);
598
+ const w = renderer.domElement.width / renderer.getPixelRatio();
599
+ const h = renderer.domElement.height / renderer.getPixelRatio();
600
+ const paneW = w / cols;
601
+ const paneH = h / rows;
602
+ renderer.setScissorTest(false);
603
+ renderer.clear();
604
+ renderer.setScissorTest(true);
605
+ cameraOrder.forEach((name, i) => {
606
+ const col = i % cols;
607
+ const row = Math.floor(i / cols);
608
+ const x = col * paneW;
609
+ // Three.js viewport origin is bottom-left; flip rows.
610
+ const y = (rows - 1 - row) * paneH;
611
+ renderer.setViewport(x, y, paneW, paneH);
612
+ renderer.setScissor(x, y, paneW, paneH);
613
+ renderer.render(scene, cameras[name]);
614
+ });
615
+ renderer.setScissorTest(false);
616
+ }
617
+
618
+ function tick(): void {
619
+ if (!running) return;
620
+ const now = performance.now();
621
+ const delta = (now - lastT) / 1000;
622
+ lastT = now;
623
+ time.value += delta;
624
+ dt.value = delta;
625
+ fpsWindowMs += delta * 1000;
626
+ frames += 1;
627
+ if (fpsWindowMs > 500) {
628
+ fps = (frames * 1000) / fpsWindowMs;
629
+ frames = 0;
630
+ fpsWindowMs = 0;
631
+ if (hud) hud.textContent = `${fps.toFixed(0)} fps · WebGPU · ${labLabel()}`;
632
+ }
633
+
634
+ // Sample motion probes before render so they see the just-advanced time.
635
+ // Per slot: probe keys are element-local, buffered on the owning slot.
636
+ for (const slot of slots) {
637
+ if (slot.probeKeys.length === 0 || !slot.element.motionProbes) continue;
638
+ for (const k of slot.probeKeys) {
639
+ try {
640
+ const v = slot.element.motionProbes[k](slot.handle, ctx);
641
+ if (Number.isFinite(v)) slot.probeBuffers[k].push(v, time.value);
642
+ } catch (e) {
643
+ // probe failures must not break the loop — but the agent should know
644
+ warnings.push('motion-probe', `probe "${slot.name}.${k}" threw`, errDetail(e));
645
+ }
646
+ }
647
+ }
648
+
649
+ // Drain discrete events (cannon fires, collisions, etc.) into ring buffer.
650
+ for (const slot of slots) {
651
+ if (!slot.element.events) continue;
652
+ try {
653
+ const drained = slot.element.events(slot.handle, ctx) ?? [];
654
+ for (const ev of drained) {
655
+ eventBuffer.push(ev);
656
+ if (eventBuffer.length > EVENT_BUFFER_CAP) eventBuffer.shift();
657
+ }
658
+ } catch (e) {
659
+ warnings.push('event-drain', `events() of "${slot.name}" threw`, errDetail(e));
660
+ }
661
+ }
662
+
663
+ if (inspectMode?.active) inspectMode.render();
664
+ else renderAll();
665
+ framesRendered += 1;
666
+
667
+ if (now - lastTelemetryT > telemetryIntervalMs) {
668
+ lastTelemetryT = now;
669
+ postState(buildState()).catch((e) =>
670
+ warnings.push('postState', 'POST /__state failed', errDetail(e)),
671
+ );
672
+ }
673
+ if (now - lastKnobPollT > knobPollMs) {
674
+ lastKnobPollT = now;
675
+ pollKnobs().catch((e) => warnings.push('pollKnobs', 'GET /__knob failed', errDetail(e)));
676
+ // Same cadence as knob polling (no extra timer): drain SDL scene deltas.
677
+ pollScene().catch((e) => warnings.push('pollScene', 'GET /__scene failed', errDetail(e)));
678
+ }
679
+
680
+ requestAnimationFrame(tick);
681
+ }
682
+
683
+ // Display label for the lab (hud + telemetry .project): the single element's
684
+ // name for runLab, or `a+b+…` of mounted elements for a scene.
685
+ function labLabel(): string {
686
+ return namespaced ? slots.map((s) => s.name).join('+') || 'scene' : (slots[0]?.name ?? '');
687
+ }
688
+
689
+ function buildState(): Record<string, unknown> {
690
+ const elements: Record<string, unknown> = {};
691
+ for (const slot of slots) {
692
+ let tel: Record<string, unknown> = {};
693
+ if (slot.element.telemetry) {
694
+ try {
695
+ tel = slot.element.telemetry(slot.handle, ctx);
696
+ } catch (e) {
697
+ warnings.push('telemetry-fn', `telemetry() of "${slot.name}" threw`, errDetail(e));
698
+ }
699
+ }
700
+ if (slot.probeKeys.length > 0) {
701
+ const motion: Record<string, ProbeStats | null> = {};
702
+ for (const k of slot.probeKeys) motion[k] = slot.probeBuffers[k].stats();
703
+ elements[slot.name] = { ...tel, motion };
704
+ } else {
705
+ elements[slot.name] = tel;
706
+ }
707
+ }
708
+ return {
709
+ project: labLabel(),
710
+ // Wall-clock of this snapshot so a reader (MCP read_telemetry) can compute
711
+ // staleness — telemetry on disk / served by the dev server may be seconds
712
+ // old if the lab tab was backgrounded or closed.
713
+ postedAt: Date.now(),
714
+ perf: { fps, dpr: renderer.getPixelRatio() },
715
+ time: time.value,
716
+ knobs: { ...knobValues },
717
+ cameras: Object.fromEntries(
718
+ cameraOrder.map((n) => [
719
+ n,
720
+ {
721
+ position: cameras[n].position.toArray(),
722
+ target: targetOf(cameras[n]),
723
+ fov: cameras[n].fov,
724
+ },
725
+ ]),
726
+ ),
727
+ elements,
728
+ // Event ring buffer (cap 128). Drained per-frame via element.events();
729
+ // shallow-copied here so downstream consumers see a stable snapshot.
730
+ events: eventBuffer.slice(),
731
+ // Last clicked mesh in inspect mode (?inspect=<el>) — null when not
732
+ // in inspect mode or nothing was clicked. Read via MCP
733
+ // `read_telemetry .selection` so the model knows which file:line to
734
+ // edit when the user says "fix this".
735
+ selection: currentSelection,
736
+ selections: currentSelections,
737
+ inspectActive: !!inspectMode?.active,
738
+ // Swallowed non-fatal failures since boot (cap 32). Read via
739
+ // `read_telemetry .warnings` to diagnose stale telemetry / clamped
740
+ // knobs / black frames without guessing.
741
+ warnings: warnings.list(),
742
+ // Per-element on/off state (SDL show/hide) so get_scene / read_telemetry
743
+ // can observe which elements are currently visible.
744
+ sceneElements: sceneElementStates(),
745
+ };
746
+ }
747
+
748
+ async function pollKnobs(): Promise<void> {
749
+ try {
750
+ const res = await fetch('/__knob');
751
+ if (!res.ok) return;
752
+ const drained = (await res.json()) as Array<{
753
+ element?: string;
754
+ key: string;
755
+ value: unknown;
756
+ }>;
757
+ if (!Array.isArray(drained) || drained.length === 0) return;
758
+ for (const entry of drained) {
759
+ if (typeof entry.key !== 'string') continue;
760
+ // Resolve the posted (element, key) to one of our display keys, owned by
761
+ // that element. Accepts either an element-local key (the namespaced form
762
+ // the manifest advertises) or an already-namespaced key posted directly.
763
+ // For a single non-namespaced lab this reduces to the bare key, still
764
+ // filtered to the lab's element.
765
+ let disp: string | null = null;
766
+ if (entry.element) {
767
+ const ns = nsKey(entry.element, entry.key, namespaced);
768
+ if (knobBinding[ns]?.slot.name === entry.element) disp = ns;
769
+ }
770
+ if (!disp) {
771
+ const b = knobBinding[entry.key];
772
+ if (b && (!entry.element || b.slot.name === entry.element)) disp = entry.key;
773
+ }
774
+ if (!disp) continue;
775
+ applyKnob(disp, entry.value as number | string | boolean, true);
776
+ }
777
+ } catch (e) {
778
+ warnings.push('pollKnobs', 'knob poll failed', errDetail(e));
779
+ }
780
+ }
781
+
782
+ async function captureMotionFrames(
783
+ cameraName: string,
784
+ motionOpts: { frames?: number; dt?: number; mode?: 'time' | 'real' } = {},
785
+ ): Promise<string[]> {
786
+ const { frames: N = 6, dt: step = 0.25, mode = 'time' } = motionOpts;
787
+ const cam = cameras[cameraName];
788
+ if (!cam) throw new Error(`unknown camera: ${cameraName}`);
789
+ // If captureSize is set, snap the canvas to that fixed size for the
790
+ // capture (and restore at the end) so framing is deterministic across
791
+ // page reloads / window resizes.
792
+ const liveW = renderer.domElement.width / renderer.getPixelRatio();
793
+ const liveH = renderer.domElement.height / renderer.getPixelRatio();
794
+ const w = opts.captureSize?.[0] ?? liveW;
795
+ const h = opts.captureSize?.[1] ?? liveH;
796
+ if (opts.captureSize) renderer.setSize(w, h, false);
797
+ renderer.setScissorTest(false);
798
+ cam.aspect = w / Math.max(h, 1);
799
+ cam.updateProjectionMatrix();
800
+
801
+ const out: string[] = [];
802
+ if (mode === 'time') {
803
+ // Deterministic: pause the RAF, step time.value forward, render.
804
+ // CRITICAL: Three.js TSL's `time` node is `uniform(0).onRenderUpdate(
805
+ // (frame) => frame.time)` — it overwrites itself from renderer.nodeFrame
806
+ // on every render. So we must also override nodeFrame.time before each
807
+ // render or any shader using three/tsl's `time` will appear frozen.
808
+ const wasRunning = running;
809
+ running = false;
810
+ // Fully deterministic: always start the captured sequence at t=0 so
811
+ // two captures of the same element+shader produce byte-identical
812
+ // frames (no dependency on when captureMotionFrames was invoked).
813
+ const baseT = 0;
814
+ const baseDt = dt.value;
815
+ const liveTime = time.value;
816
+ const rendererAny = renderer as unknown as {
817
+ nodeFrame?: { time: number; deltaTime: number };
818
+ _nodes?: { nodeFrame?: { time: number; deltaTime: number } };
819
+ };
820
+ const nf = rendererAny.nodeFrame ?? rendererAny._nodes?.nodeFrame ?? null;
821
+ const baseFrameT = nf?.time ?? 0;
822
+ const baseFrameDt = nf?.deltaTime ?? 0;
823
+ try {
824
+ for (let i = 0; i < N; i++) {
825
+ const wantedT = baseT + i * step;
826
+ time.value = wantedT;
827
+ dt.value = step;
828
+ if (nf) {
829
+ nf.time = wantedT;
830
+ nf.deltaTime = step;
831
+ }
832
+ renderer.setViewport(0, 0, w, h);
833
+ renderer.clear();
834
+ renderer.render(scene, cam);
835
+ out.push(renderer.domElement.toDataURL('image/png'));
836
+ }
837
+ } finally {
838
+ time.value = liveTime; // restore the live RAF-accumulated time
839
+ dt.value = baseDt;
840
+ if (nf) {
841
+ nf.time = baseFrameT;
842
+ nf.deltaTime = baseFrameDt;
843
+ }
844
+ running = wasRunning;
845
+ if (running) {
846
+ lastT = performance.now();
847
+ requestAnimationFrame(tick);
848
+ }
849
+ }
850
+ } else {
851
+ // Real-time: keep RAF running, sample at wall-clock intervals.
852
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
853
+ for (let i = 0; i < N; i++) {
854
+ renderer.setViewport(0, 0, w, h);
855
+ renderer.setScissorTest(false);
856
+ renderer.clear();
857
+ renderer.render(scene, cam);
858
+ out.push(renderer.domElement.toDataURL('image/png'));
859
+ if (i < N - 1) await sleep(step * 1000);
860
+ }
861
+ }
862
+ resize();
863
+ return out;
864
+ }
865
+
866
+ async function captureViews(): Promise<Record<string, string>> {
867
+ // Capture each camera as a separate full-canvas render to a base64 PNG.
868
+ // Side effect: per-camera built-in GPU probe stats (luminance, dynamic
869
+ // range) are computed by decoding the just-written canvas via 2D context
870
+ // and saved into `__TRISCOPE__.lastGpuProbes` so MCP can read them.
871
+ const out: Record<string, string> = {};
872
+ const probeStats: Record<string, GpuProbeStats> = {};
873
+ const liveW = renderer.domElement.width / renderer.getPixelRatio();
874
+ const liveH = renderer.domElement.height / renderer.getPixelRatio();
875
+ const w = opts.captureSize?.[0] ?? liveW;
876
+ const h = opts.captureSize?.[1] ?? liveH;
877
+ if (opts.captureSize) renderer.setSize(w, h, false);
878
+ for (const name of cameraOrder) {
879
+ renderer.setScissorTest(false);
880
+ renderer.setViewport(0, 0, w, h);
881
+ renderer.clear();
882
+ cameras[name].aspect = w / Math.max(h, 1);
883
+ cameras[name].updateProjectionMatrix();
884
+ renderer.render(scene, cameras[name]);
885
+ // The canvas now holds this camera's view. Read as data URL.
886
+ out[name] = renderer.domElement.toDataURL('image/png');
887
+ // Compute GPU probe stats from the same render. We sample 64×36 px
888
+ // (≈2300 samples) — enough for stable luminance percentiles without
889
+ // making toBlob+getImageData a per-camera bottleneck.
890
+ probeStats[name] = sampleGpuProbes(renderer.domElement);
891
+ // Flag panes the GPU drew black so a single dead camera surfaces as a
892
+ // warning + a per-camera flag instead of silently shipping a black PNG.
893
+ if (isBlackFrame(probeStats[name].luminance)) {
894
+ probeStats[name].blackFrame = true;
895
+ warnings.push(
896
+ 'black-frame',
897
+ `camera "${name}" rendered black (luminance ${probeStats[name].luminance})`,
898
+ );
899
+ }
900
+ }
901
+ if (typeof window !== 'undefined') {
902
+ (window.__TRISCOPE__ as any).lastGpuProbes = probeStats;
903
+ }
904
+ // Restore live canvas dimensions + per-camera aspect ratios.
905
+ resize();
906
+ return out;
907
+ }
908
+
909
+ function queryScene(maxNodes = 500) {
910
+ return serializeScene(scene, {
911
+ maxNodes,
912
+ getWorldPosition: (o: THREE.Object3D) => {
913
+ try {
914
+ const v = new THREE.Vector3();
915
+ o.getWorldPosition(v);
916
+ return [v.x, v.y, v.z];
917
+ } catch {
918
+ return [o.position?.x ?? 0, o.position?.y ?? 0, o.position?.z ?? 0];
919
+ }
920
+ },
921
+ });
922
+ }
923
+
924
+ function readUniform(path: string): UniformReadResult {
925
+ return readUniformValue(scene, path);
926
+ }
927
+ function setUniform(path: string, value: unknown): UniformWriteResult {
928
+ return writeUniformValue(scene, path, value);
929
+ }
930
+
931
+ // ---- SDL: live scene mutation (camera repoint + knob override) ----------
932
+ function applySceneDelta(delta: {
933
+ cameras?: Record<string, CameraDelta>;
934
+ knobs?: Record<string, unknown>;
935
+ elements?: Record<string, { enabled?: boolean }>;
936
+ }): void {
937
+ if (delta?.cameras) {
938
+ for (const [name, cd] of Object.entries(delta.cameras)) applyCameraDelta(cameras[name], cd);
939
+ }
940
+ if (delta?.knobs) {
941
+ for (const [key, value] of Object.entries(delta.knobs)) {
942
+ applyKnob(key, value as number | string | boolean, true);
943
+ }
944
+ }
945
+ if (delta?.elements) {
946
+ for (const [name, e] of Object.entries(delta.elements)) {
947
+ if (typeof e?.enabled !== 'boolean') continue;
948
+ // Toggle whichever slot owns `name` — either a slot whose element name
949
+ // matches, or a composeElements child within a single-element lab.
950
+ for (const slot of slots) {
951
+ if (applyElementToggle(slot.handle, { name: slot.name }, name, e.enabled)) break;
952
+ }
953
+ }
954
+ }
955
+ }
956
+
957
+ function sceneElementStates(): Record<string, { enabled: boolean }> {
958
+ const out: Record<string, { enabled: boolean }> = {};
959
+ for (const slot of slots) {
960
+ const childMap = (slot.handle.userData as { childrenByName?: Record<string, MountHandle> })
961
+ ?.childrenByName;
962
+ if (childMap && Object.keys(childMap).length > 0) {
963
+ // Composed element: report its children (the D4 show/hide slice).
964
+ for (const [n, h] of Object.entries(childMap)) {
965
+ out[n] = { enabled: h.root?.visible !== false };
966
+ }
967
+ } else {
968
+ out[slot.name] = { enabled: slot.handle.root?.visible !== false };
969
+ }
970
+ }
971
+ return out;
972
+ }
973
+
974
+ function getScene() {
975
+ return {
976
+ cameras: Object.fromEntries(
977
+ cameraOrder.map((n) => [
978
+ n,
979
+ {
980
+ position: cameras[n].position.toArray(),
981
+ target: targetOf(cameras[n]),
982
+ fov: cameras[n].fov,
983
+ },
984
+ ]),
985
+ ),
986
+ knobs: { ...knobValues },
987
+ elements: sceneElementStates(),
988
+ };
989
+ }
990
+
991
+ function setSceneParam(delta: {
992
+ cameras?: Record<string, CameraDelta>;
993
+ knobs?: Record<string, unknown>;
994
+ elements?: Record<string, { enabled?: boolean }>;
995
+ }) {
996
+ applySceneDelta(delta);
997
+ // Persist so the delta survives a full reload (like knobs do).
998
+ postScene(delta).catch((e) => warnings.push('postScene', 'POST /__scene failed', errDetail(e)));
999
+ return getScene();
1000
+ }
1001
+
1002
+ async function pollScene(): Promise<void> {
1003
+ try {
1004
+ const res = await fetch('/__scene');
1005
+ if (!res.ok) return;
1006
+ const drained = (await res.json()) as Array<{
1007
+ cameras?: Record<string, CameraDelta>;
1008
+ knobs?: Record<string, unknown>;
1009
+ }>;
1010
+ if (!Array.isArray(drained) || drained.length === 0) return;
1011
+ for (const delta of drained) applySceneDelta(delta);
1012
+ } catch (e) {
1013
+ warnings.push('pollScene', 'scene poll failed', errDetail(e));
1014
+ }
1015
+ }
1016
+
1017
+ // Re-hydrate any persisted scene deltas from a previous session.
1018
+ try {
1019
+ const r = await fetch('/__scene/current');
1020
+ if (r.ok) applySceneDelta(await r.json());
1021
+ } catch {
1022
+ /* dev server transient — fall through */
1023
+ }
1024
+
1025
+ // ---- Runtime element add/remove (runSceneLab) ---------------------------
1026
+ // Mount a registered-but-unmounted element live: instantiate it, register its
1027
+ // namespaced cameras/knobs, then rebuild the grid (labels + editor + aspect)
1028
+ // and re-advertise it via the manifest. No-op (returns false) if it's already
1029
+ // mounted or not in the registry; a mount throw is contained as a warning.
1030
+ function addElement(name: string): boolean {
1031
+ const entry = entries.find((e) => e.name === name);
1032
+ if (!entry) {
1033
+ warnings.push('add-element', `unknown element "${name}" (not in scene registry)`);
1034
+ return false;
1035
+ }
1036
+ let slot: Slot | null;
1037
+ try {
1038
+ slot = mountSlot(entry.name, entry.element);
1039
+ if (!slot) return false; // already mounted
1040
+ // Rebuild the grid + inspect for the new slot. Wrapped with mountSlot so a
1041
+ // throw anywhere rolls the slot back rather than leaving a half-integrated
1042
+ // (rendered-but-unlabelled) zombie in cameraOrder.
1043
+ rebuildLabels();
1044
+ rebuildEditor();
1045
+ resize();
1046
+ setupInspect();
1047
+ postManifestFor(slot);
1048
+ } catch (e) {
1049
+ warnings.push('add-element', `add of "${name}" failed`, errDetail(e));
1050
+ if (slots.some((s) => s.name === name)) unmountSlot(name); // roll back
1051
+ return false;
1052
+ }
1053
+ return true;
1054
+ }
1055
+
1056
+ // Dispose a mounted element live + rebuild the grid. Returns false if it
1057
+ // wasn't mounted. The element stays in the registry and can be re-added.
1058
+ // Guard: never empty a single-element runLab — removing the only element of a
1059
+ // non-namespaced lab is almost certainly a mistargeted call, so it's a no-op.
1060
+ function removeElement(name: string): boolean {
1061
+ if (!namespaced && slots.length <= 1) return false;
1062
+ if (!unmountSlot(name)) return false;
1063
+ rebuildLabels();
1064
+ rebuildEditor();
1065
+ resize();
1066
+ setupInspect();
1067
+ return true;
1068
+ }
1069
+
1070
+ if (bootOverlay) bootOverlay.remove();
1071
+
1072
+ window.__TRISCOPE__ = {
1073
+ // Primary slot, kept live as slots change — back-compat for single-element
1074
+ // consumers (the MCP pool reads `.element?.name`).
1075
+ get element() {
1076
+ return slots[0]?.element as Element;
1077
+ },
1078
+ get handle() {
1079
+ return slots[0]?.handle as MountHandle;
1080
+ },
1081
+ renderer,
1082
+ scene,
1083
+ cameras,
1084
+ knobValues,
1085
+ setKnob: (k, v) => applyKnob(k, v, true),
1086
+ sampleTelemetry: buildState,
1087
+ captureViews,
1088
+ captureMotionFrames,
1089
+ queryScene,
1090
+ readUniform,
1091
+ setUniform,
1092
+ setSceneParam,
1093
+ getScene,
1094
+ addElement,
1095
+ removeElement,
1096
+ availableElements: () => entries.map((e) => e.name),
1097
+ mountedElements: () => slots.map((s) => s.name),
1098
+ // Monotonic rendered-frame count — the MCP pool polls this to wait for a
1099
+ // real rendered frame before capturing (avoids black/pre-render captures).
1100
+ framesRendered: () => framesRendered,
1101
+ };
1102
+ // Live view of the warning ring (snapshot per access) for in-page scripts.
1103
+ Object.defineProperty(window.__TRISCOPE__, 'warnings', {
1104
+ get: () => warnings.list(),
1105
+ enumerable: true,
1106
+ configurable: true,
1107
+ });
1108
+
1109
+ requestAnimationFrame(tick);
1110
+
1111
+ return {
1112
+ get element() {
1113
+ return slots[0]?.element as Element;
1114
+ },
1115
+ setKnob: (k, v) => applyKnob(k, v, true),
1116
+ captureViews,
1117
+ addElement,
1118
+ removeElement,
1119
+ availableElements: () => entries.map((e) => e.name),
1120
+ mountedElements: () => slots.map((s) => s.name),
1121
+ stop: () => {
1122
+ running = false;
1123
+ window.removeEventListener('resize', resize);
1124
+ for (const slot of slots) {
1125
+ try {
1126
+ slot.handle.dispose();
1127
+ } catch {
1128
+ /* best-effort teardown */
1129
+ }
1130
+ }
1131
+ renderer.dispose();
1132
+ },
1133
+ };
1134
+ }
1135
+
1136
+ /**
1137
+ * Aggregated brightness/contrast scalars computed from a rendered canvas.
1138
+ * Probes are run by the harness during `captureViews()` — they validate
1139
+ * that the GPU actually drew something (luminance > 0 means the frame is
1140
+ * not black; p95/p5 ratio > 1 means there's contrast).
1141
+ */
1142
+ export interface GpuProbeStats {
1143
+ /** Mean perceptual luminance (Rec.709) in [0, 1]. */
1144
+ luminance: number;
1145
+ /** 5th percentile luminance. */
1146
+ p5: number;
1147
+ /** 95th percentile luminance. */
1148
+ p95: number;
1149
+ /** p95 / max(p5, 1/255) — dynamic range proxy. */
1150
+ dynamicRange: number;
1151
+ /** Number of pixels sampled (typically 64×36 = 2304). */
1152
+ samples: number;
1153
+ /** Set true when this camera's mean luminance indicates a black render. */
1154
+ blackFrame?: boolean;
1155
+ }
1156
+
1157
+ const PROBE_SAMPLE_W = 64;
1158
+ const PROBE_SAMPLE_H = 36;
1159
+ let probeSampler: { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null = null;
1160
+
1161
+ function sampleGpuProbes(srcCanvas: HTMLCanvasElement): GpuProbeStats {
1162
+ // Reuse a single 64×36 2D scratch canvas so we don't allocate per frame.
1163
+ if (!probeSampler) {
1164
+ const c = document.createElement('canvas');
1165
+ c.width = PROBE_SAMPLE_W;
1166
+ c.height = PROBE_SAMPLE_H;
1167
+ probeSampler = { canvas: c, ctx: c.getContext('2d', { willReadFrequently: true })! };
1168
+ }
1169
+ const { canvas: sc, ctx } = probeSampler;
1170
+ ctx.clearRect(0, 0, sc.width, sc.height);
1171
+ // drawImage scales the WebGPU canvas down to our sample size in one call.
1172
+ ctx.drawImage(srcCanvas, 0, 0, sc.width, sc.height);
1173
+ const data = ctx.getImageData(0, 0, sc.width, sc.height).data;
1174
+ const n = sc.width * sc.height;
1175
+ const lums = new Float32Array(n);
1176
+ let sum = 0;
1177
+ for (let i = 0; i < n; i++) {
1178
+ const r = data[i * 4] / 255;
1179
+ const g = data[i * 4 + 1] / 255;
1180
+ const b = data[i * 4 + 2] / 255;
1181
+ // Rec.709 luminance.
1182
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1183
+ lums[i] = lum;
1184
+ sum += lum;
1185
+ }
1186
+ const sorted = Array.from(lums).sort((a, b) => a - b);
1187
+ const p5 = sorted[Math.floor(n * 0.05)];
1188
+ const p95 = sorted[Math.floor(n * 0.95)];
1189
+ const luminance = sum / n;
1190
+ const dynamicRange = p95 / Math.max(p5, 1 / 255);
1191
+ return {
1192
+ luminance: +luminance.toFixed(4),
1193
+ p5: +p5.toFixed(4),
1194
+ p95: +p95.toFixed(4),
1195
+ dynamicRange: +dynamicRange.toFixed(2),
1196
+ samples: n,
1197
+ };
1198
+ }
1199
+
1200
+ export function makeCamera(spec: CameraSpec, bounds?: Element['bounds']): THREE.PerspectiveCamera {
1201
+ const cam = new THREE.PerspectiveCamera(spec.fov ?? 45, 1, spec.near ?? 0.1, spec.far ?? 2000);
1202
+ cam.position.set(...spec.position);
1203
+ cam.lookAt(...spec.target);
1204
+ cam.userData.target = [...spec.target] as [number, number, number];
1205
+ if (spec.fit && bounds) {
1206
+ fitCameraToBounds(cam, spec, bounds);
1207
+ }
1208
+ return cam;
1209
+ }
1210
+
1211
+ export function fitCameraToBounds(
1212
+ cam: THREE.PerspectiveCamera,
1213
+ spec: CameraSpec,
1214
+ bounds: NonNullable<Element['bounds']>,
1215
+ ): void {
1216
+ const min = new THREE.Vector3(...bounds.min);
1217
+ const max = new THREE.Vector3(...bounds.max);
1218
+ const center = min.clone().add(max).multiplyScalar(0.5);
1219
+ const size = max.clone().sub(min).length();
1220
+ const target = new THREE.Vector3(...spec.target);
1221
+ const fovRad = (cam.fov * Math.PI) / 180;
1222
+ const distance = size / (2 * Math.tan(fovRad / 2));
1223
+ const dir = cam.position.clone().sub(target).normalize();
1224
+ cam.position.copy(center.clone().add(dir.multiplyScalar(distance * 1.2)));
1225
+ cam.lookAt(center);
1226
+ cam.userData.target = [center.x, center.y, center.z];
1227
+ }
1228
+
1229
+ function targetOf(cam: THREE.PerspectiveCamera): [number, number, number] {
1230
+ const t = cam.userData?.target;
1231
+ if (Array.isArray(t) && t.length === 3) return [t[0], t[1], t[2]];
1232
+ const fallback = new THREE.Vector3(0, 0, -1).applyQuaternion(cam.quaternion).add(cam.position);
1233
+ return [fallback.x, fallback.y, fallback.z];
1234
+ }
1235
+
1236
+ async function postState(payload: Record<string, unknown>): Promise<void> {
1237
+ await fetch('/__state', {
1238
+ method: 'POST',
1239
+ headers: { 'content-type': 'application/json' },
1240
+ body: JSON.stringify(payload),
1241
+ });
1242
+ }
1243
+
1244
+ async function postManifest(payload: Record<string, unknown>): Promise<void> {
1245
+ await fetch('/__manifest', {
1246
+ method: 'POST',
1247
+ headers: { 'content-type': 'application/json' },
1248
+ body: JSON.stringify(payload),
1249
+ });
1250
+ }
1251
+
1252
+ async function postScene(payload: Record<string, unknown>): Promise<void> {
1253
+ await fetch('/__scene', {
1254
+ method: 'POST',
1255
+ headers: { 'content-type': 'application/json' },
1256
+ body: JSON.stringify(payload),
1257
+ });
1258
+ }
1259
+
1260
+ function errDetail(e: unknown): string {
1261
+ const msg = (e as { message?: unknown })?.message ?? e;
1262
+ return String(msg).slice(0, 300);
1263
+ }