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