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