@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.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/compose.d.ts +11 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +152 -0
- package/dist/compose.js.map +1 -0
- package/dist/editor.d.ts +14 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +131 -0
- package/dist/editor.js.map +1 -0
- package/dist/harness.d.ts +199 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +1027 -0
- package/dist/harness.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +94 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +434 -0
- package/dist/inspect.js.map +1 -0
- package/dist/knob-utils.d.ts +22 -0
- package/dist/knob-utils.d.ts.map +1 -0
- package/dist/knob-utils.js +51 -0
- package/dist/knob-utils.js.map +1 -0
- package/dist/lab/css.d.ts +11 -0
- package/dist/lab/css.d.ts.map +1 -0
- package/dist/lab/css.js +57 -0
- package/dist/lab/css.js.map +1 -0
- package/dist/lab/dom.d.ts +13 -0
- package/dist/lab/dom.d.ts.map +1 -0
- package/dist/lab/dom.js +76 -0
- package/dist/lab/dom.js.map +1 -0
- package/dist/motion-probe.d.ts +47 -0
- package/dist/motion-probe.d.ts.map +1 -0
- package/dist/motion-probe.js +122 -0
- package/dist/motion-probe.js.map +1 -0
- package/dist/probe-utils.d.ts +14 -0
- package/dist/probe-utils.d.ts.map +1 -0
- package/dist/probe-utils.js +18 -0
- package/dist/probe-utils.js.map +1 -0
- package/dist/scene-cameras.d.ts +6 -0
- package/dist/scene-cameras.d.ts.map +1 -0
- package/dist/scene-cameras.js +20 -0
- package/dist/scene-cameras.js.map +1 -0
- package/dist/scene-delta.d.ts +21 -0
- package/dist/scene-delta.d.ts.map +1 -0
- package/dist/scene-delta.js +57 -0
- package/dist/scene-delta.js.map +1 -0
- package/dist/scene-introspect.d.ts +78 -0
- package/dist/scene-introspect.d.ts.map +1 -0
- package/dist/scene-introspect.js +164 -0
- package/dist/scene-introspect.js.map +1 -0
- package/dist/scene-registry.d.ts +36 -0
- package/dist/scene-registry.d.ts.map +1 -0
- package/dist/scene-registry.js +64 -0
- package/dist/scene-registry.js.map +1 -0
- package/dist/scene-view.d.ts +52 -0
- package/dist/scene-view.d.ts.map +1 -0
- package/dist/scene-view.js +171 -0
- package/dist/scene-view.js.map +1 -0
- package/dist/source-tag.d.ts +34 -0
- package/dist/source-tag.d.ts.map +1 -0
- package/dist/source-tag.js +120 -0
- package/dist/source-tag.js.map +1 -0
- package/dist/telemetry.d.ts +53 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +302 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/uniform-access.d.ts +32 -0
- package/dist/uniform-access.d.ts.map +1 -0
- package/dist/uniform-access.js +144 -0
- package/dist/uniform-access.js.map +1 -0
- package/dist/validate.d.ts +2 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +81 -0
- package/dist/validate.js.map +1 -0
- package/dist/vite.d.ts +3 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +4 -0
- package/dist/vite.js.map +1 -0
- package/dist/warnings.d.ts +24 -0
- package/dist/warnings.d.ts.map +1 -0
- package/dist/warnings.js +26 -0
- package/dist/warnings.js.map +1 -0
- package/package.json +60 -0
- package/src/compose.ts +164 -0
- package/src/editor.ts +138 -0
- package/src/harness.ts +1263 -0
- package/src/index.ts +58 -0
- package/src/inspect.ts +498 -0
- package/src/knob-utils.ts +60 -0
- package/src/lab/css.ts +56 -0
- package/src/lab/dom.ts +88 -0
- package/src/motion-probe.ts +135 -0
- package/src/probe-utils.ts +17 -0
- package/src/scene-cameras.ts +33 -0
- package/src/scene-delta.ts +69 -0
- package/src/scene-introspect.ts +230 -0
- package/src/scene-registry.ts +103 -0
- package/src/scene-view.ts +204 -0
- package/src/source-tag.ts +139 -0
- package/src/telemetry.ts +337 -0
- package/src/three-webgpu-shim.d.ts +130 -0
- package/src/types.ts +121 -0
- package/src/uniform-access.ts +152 -0
- package/src/validate.ts +82 -0
- package/src/vite.ts +5 -0
- package/src/warnings.ts +41 -0
package/dist/harness.js
ADDED
|
@@ -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
|