@triscope/core 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/dist/compose.d.ts +11 -0
  4. package/dist/compose.d.ts.map +1 -0
  5. package/dist/compose.js +152 -0
  6. package/dist/compose.js.map +1 -0
  7. package/dist/editor.d.ts +14 -0
  8. package/dist/editor.d.ts.map +1 -0
  9. package/dist/editor.js +131 -0
  10. package/dist/editor.js.map +1 -0
  11. package/dist/harness.d.ts +199 -0
  12. package/dist/harness.d.ts.map +1 -0
  13. package/dist/harness.js +1027 -0
  14. package/dist/harness.js.map +1 -0
  15. package/dist/index.d.ts +32 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +20 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/inspect.d.ts +94 -0
  20. package/dist/inspect.d.ts.map +1 -0
  21. package/dist/inspect.js +434 -0
  22. package/dist/inspect.js.map +1 -0
  23. package/dist/knob-utils.d.ts +22 -0
  24. package/dist/knob-utils.d.ts.map +1 -0
  25. package/dist/knob-utils.js +51 -0
  26. package/dist/knob-utils.js.map +1 -0
  27. package/dist/lab/css.d.ts +11 -0
  28. package/dist/lab/css.d.ts.map +1 -0
  29. package/dist/lab/css.js +57 -0
  30. package/dist/lab/css.js.map +1 -0
  31. package/dist/lab/dom.d.ts +13 -0
  32. package/dist/lab/dom.d.ts.map +1 -0
  33. package/dist/lab/dom.js +76 -0
  34. package/dist/lab/dom.js.map +1 -0
  35. package/dist/motion-probe.d.ts +47 -0
  36. package/dist/motion-probe.d.ts.map +1 -0
  37. package/dist/motion-probe.js +122 -0
  38. package/dist/motion-probe.js.map +1 -0
  39. package/dist/probe-utils.d.ts +14 -0
  40. package/dist/probe-utils.d.ts.map +1 -0
  41. package/dist/probe-utils.js +18 -0
  42. package/dist/probe-utils.js.map +1 -0
  43. package/dist/scene-cameras.d.ts +6 -0
  44. package/dist/scene-cameras.d.ts.map +1 -0
  45. package/dist/scene-cameras.js +20 -0
  46. package/dist/scene-cameras.js.map +1 -0
  47. package/dist/scene-delta.d.ts +21 -0
  48. package/dist/scene-delta.d.ts.map +1 -0
  49. package/dist/scene-delta.js +57 -0
  50. package/dist/scene-delta.js.map +1 -0
  51. package/dist/scene-introspect.d.ts +78 -0
  52. package/dist/scene-introspect.d.ts.map +1 -0
  53. package/dist/scene-introspect.js +164 -0
  54. package/dist/scene-introspect.js.map +1 -0
  55. package/dist/scene-registry.d.ts +36 -0
  56. package/dist/scene-registry.d.ts.map +1 -0
  57. package/dist/scene-registry.js +64 -0
  58. package/dist/scene-registry.js.map +1 -0
  59. package/dist/scene-view.d.ts +52 -0
  60. package/dist/scene-view.d.ts.map +1 -0
  61. package/dist/scene-view.js +171 -0
  62. package/dist/scene-view.js.map +1 -0
  63. package/dist/source-tag.d.ts +34 -0
  64. package/dist/source-tag.d.ts.map +1 -0
  65. package/dist/source-tag.js +120 -0
  66. package/dist/source-tag.js.map +1 -0
  67. package/dist/telemetry.d.ts +53 -0
  68. package/dist/telemetry.d.ts.map +1 -0
  69. package/dist/telemetry.js +302 -0
  70. package/dist/telemetry.js.map +1 -0
  71. package/dist/types.d.ts +142 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +9 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/uniform-access.d.ts +32 -0
  76. package/dist/uniform-access.d.ts.map +1 -0
  77. package/dist/uniform-access.js +144 -0
  78. package/dist/uniform-access.js.map +1 -0
  79. package/dist/validate.d.ts +2 -0
  80. package/dist/validate.d.ts.map +1 -0
  81. package/dist/validate.js +81 -0
  82. package/dist/validate.js.map +1 -0
  83. package/dist/vite.d.ts +3 -0
  84. package/dist/vite.d.ts.map +1 -0
  85. package/dist/vite.js +4 -0
  86. package/dist/vite.js.map +1 -0
  87. package/dist/warnings.d.ts +24 -0
  88. package/dist/warnings.d.ts.map +1 -0
  89. package/dist/warnings.js +26 -0
  90. package/dist/warnings.js.map +1 -0
  91. package/package.json +60 -0
  92. package/src/compose.ts +164 -0
  93. package/src/editor.ts +138 -0
  94. package/src/harness.ts +1263 -0
  95. package/src/index.ts +58 -0
  96. package/src/inspect.ts +498 -0
  97. package/src/knob-utils.ts +60 -0
  98. package/src/lab/css.ts +56 -0
  99. package/src/lab/dom.ts +88 -0
  100. package/src/motion-probe.ts +135 -0
  101. package/src/probe-utils.ts +17 -0
  102. package/src/scene-cameras.ts +33 -0
  103. package/src/scene-delta.ts +69 -0
  104. package/src/scene-introspect.ts +230 -0
  105. package/src/scene-registry.ts +103 -0
  106. package/src/scene-view.ts +204 -0
  107. package/src/source-tag.ts +139 -0
  108. package/src/telemetry.ts +337 -0
  109. package/src/three-webgpu-shim.d.ts +130 -0
  110. package/src/types.ts +121 -0
  111. package/src/uniform-access.ts +152 -0
  112. package/src/validate.ts +82 -0
  113. package/src/vite.ts +5 -0
  114. package/src/warnings.ts +41 -0
@@ -0,0 +1,204 @@
1
+ // runSceneView — the near-zero-boilerplate adoption entry. Wrap ANY existing
2
+ // THREE.Object3D (or a builder, or a {root,dispose} handle) into the triscope
3
+ // harness so the MCP tools (capture_views/inspect_scene/read_uniform/set_uniform
4
+ // + knobs) work, WITHOUT hand-authoring an Element. Cameras auto-fit from the
5
+ // object's bounds; optionally auto-derive knobs from its materials/lights.
6
+ //
7
+ // import { runSceneView } from '@triscope/core';
8
+ // runSceneView(myGroup); // read-only observability, ~1 line
9
+ // runSceneView(buildScene, { autoKnobs: true });
10
+ import * as THREE from 'three/webgpu';
11
+ import { type CommonLabOptions, type LabHandle, runLab } from './harness.js';
12
+ import { mountLabDom } from './lab/dom.js';
13
+ import { autoCameras, type Bounds, UNIT_BOUNDS } from './scene-cameras.js';
14
+ import type { CameraSpec, Element, Knob, MountHandle } from './types.js';
15
+ import { writeUniformValue } from './uniform-access.js';
16
+
17
+ /** A target the harness can render: an object, a builder, or a mounted handle. */
18
+ export type SceneViewTarget =
19
+ | THREE.Object3D
20
+ | (() => THREE.Object3D | { root: THREE.Object3D; dispose?: () => void })
21
+ | { root: THREE.Object3D; dispose?: () => void };
22
+
23
+ export interface SceneViewOptions extends Partial<CommonLabOptions> {
24
+ /** Element name (telemetry `project`). Defaults to the object's `.name` or "scene". */
25
+ name?: string;
26
+ /** Camera presets, or 'auto' (default) to fit 4 presets from the object's bounds. */
27
+ cameras?: Record<string, CameraSpec> | 'auto';
28
+ /** Explicit knobs (merged over autoKnobs when both are present). */
29
+ knobs?: Record<string, Knob>;
30
+ /** Knob handler. Defaults to the autoKnobs router when autoKnobs is on. */
31
+ onKnob?: Element['onKnob'];
32
+ /**
33
+ * Reflect named meshes' material props (roughness/metalness/opacity/color/
34
+ * emissive) and named lights' intensity/color into knobs, routed live via the
35
+ * same path as set_uniform. NOTE: only NAMED objects; TSL node-material params
36
+ * aren't reflectable (use set_uniform for those). Off by default.
37
+ */
38
+ autoKnobs?: boolean;
39
+ }
40
+
41
+ /**
42
+ * World-space AABB of an object tree → {min,max}; unit box if empty/non-finite.
43
+ * Computed manually (8 corners of each child geometry's bounding box × its world
44
+ * matrix) to avoid depending on Box3, which three/webgpu's .d.ts doesn't surface.
45
+ */
46
+ export function computeObjectBounds(obj: THREE.Object3D): Bounds {
47
+ try {
48
+ obj.updateWorldMatrix(true, true);
49
+ const min = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
50
+ const max = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
51
+ let found = false;
52
+ const v = new THREE.Vector3();
53
+ obj.traverse((o: any) => {
54
+ const geo = o?.geometry;
55
+ if (!geo) return;
56
+ if (!geo.boundingBox && typeof geo.computeBoundingBox === 'function')
57
+ geo.computeBoundingBox();
58
+ const bb = geo.boundingBox;
59
+ if (!bb) return;
60
+ for (let i = 0; i < 8; i++) {
61
+ v.set(
62
+ i & 1 ? bb.max.x : bb.min.x,
63
+ i & 2 ? bb.max.y : bb.min.y,
64
+ i & 4 ? bb.max.z : bb.min.z,
65
+ );
66
+ // applyMatrix4 is missing from three/webgpu's partial .d.ts (runtime ok).
67
+ // biome-ignore lint/suspicious/noExplicitAny: three/webgpu .d.ts gap
68
+ (v as any).applyMatrix4(o.matrixWorld);
69
+ if (!Number.isFinite(v.x) || !Number.isFinite(v.y) || !Number.isFinite(v.z)) continue;
70
+ min[0] = Math.min(min[0], v.x);
71
+ min[1] = Math.min(min[1], v.y);
72
+ min[2] = Math.min(min[2], v.z);
73
+ max[0] = Math.max(max[0], v.x);
74
+ max[1] = Math.max(max[1], v.y);
75
+ max[2] = Math.max(max[2], v.z);
76
+ found = true;
77
+ }
78
+ });
79
+ if (!found) return UNIT_BOUNDS;
80
+ return { min: [min[0], min[1], min[2]], max: [max[0], max[1], max[2]] };
81
+ } catch {
82
+ return UNIT_BOUNDS;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Derive knobs by reflecting an object tree's NAMED materials + lights. Returns
88
+ * the knob specs + an onKnob that applies them live via writeUniformValue (the
89
+ * exact path set_uniform uses, so "name.prop" addressing matches).
90
+ * NOTE: an `opacity` knob has no visible effect on a material without
91
+ * `transparent: true` (Three.js ignores opacity on opaque materials).
92
+ */
93
+ export function autoKnobsFromObject(root: THREE.Object3D): {
94
+ knobs: Record<string, Knob>;
95
+ onKnob: (handle: MountHandle, key: string, value: number | string | boolean) => void;
96
+ } {
97
+ const knobs: Record<string, Knob> = {};
98
+ const hex = (c: any): string | undefined => {
99
+ try {
100
+ return typeof c?.getHexString === 'function' ? `#${c.getHexString()}` : undefined;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ };
105
+ root.traverse((o: any) => {
106
+ const name = o?.name;
107
+ if (!name || typeof name !== 'string') return; // only addressable (named) objects
108
+ if (o.isLight) {
109
+ if (typeof o.intensity === 'number') {
110
+ knobs[`${name}.intensity`] = {
111
+ type: 'number',
112
+ min: 0,
113
+ max: Math.max(o.intensity * 3, 5),
114
+ step: 0.01,
115
+ default: o.intensity,
116
+ };
117
+ }
118
+ const lc = hex(o.color);
119
+ if (lc) knobs[`${name}.color`] = { type: 'color', default: lc };
120
+ return;
121
+ }
122
+ const mat = Array.isArray(o.material) ? o.material[0] : o.material;
123
+ if (!mat) return;
124
+ for (const p of ['roughness', 'metalness', 'opacity'] as const) {
125
+ if (typeof mat[p] === 'number') {
126
+ knobs[`${name}.${p}`] = { type: 'number', min: 0, max: 1, step: 0.01, default: mat[p] };
127
+ }
128
+ }
129
+ for (const p of ['color', 'emissive'] as const) {
130
+ const mc = hex(mat[p]);
131
+ if (mc) knobs[`${name}.${p}`] = { type: 'color', default: mc };
132
+ }
133
+ });
134
+ const onKnob = (handle: MountHandle, key: string, value: number | string | boolean): void => {
135
+ if (handle?.root) writeUniformValue(handle.root, key, value);
136
+ };
137
+ return { knobs, onKnob };
138
+ }
139
+
140
+ function resolveTarget(target: SceneViewTarget): {
141
+ obj: THREE.Object3D;
142
+ dispose?: () => void;
143
+ } {
144
+ const t = typeof target === 'function' ? target() : target;
145
+ if (t && (t as any).isObject3D) return { obj: t as THREE.Object3D };
146
+ if (t && (t as any).root?.isObject3D) {
147
+ return { obj: (t as any).root, dispose: (t as any).dispose };
148
+ }
149
+ throw new Error(
150
+ 'runSceneView: target must be a THREE.Object3D, a () => Object3D, or a { root, dispose } handle',
151
+ );
152
+ }
153
+
154
+ /**
155
+ * Boot a triscope lab around an existing object with (near) zero boilerplate.
156
+ * Returns the same LabHandle as runLab.
157
+ */
158
+ export async function runSceneView(
159
+ target: SceneViewTarget,
160
+ opts: SceneViewOptions = {},
161
+ ): Promise<LabHandle> {
162
+ const { obj, dispose: targetDispose } = resolveTarget(target);
163
+ // Build the DOM if no canvas was supplied (a bare <body> + script is enough).
164
+ const dom = opts.canvas ? null : mountLabDom();
165
+ const canvas = opts.canvas ?? dom?.canvas;
166
+ if (!canvas) throw new Error('runSceneView: no canvas (and DOM creation unavailable)');
167
+
168
+ const auto = opts.autoKnobs
169
+ ? autoKnobsFromObject(obj)
170
+ : { knobs: {} as Record<string, Knob>, onKnob: undefined };
171
+ const knobs = { ...auto.knobs, ...(opts.knobs ?? {}) };
172
+
173
+ const element: Element = {
174
+ name: opts.name ?? obj.name ?? 'scene',
175
+ bounds: computeObjectBounds(obj),
176
+ cameras: opts.cameras ?? 'auto',
177
+ knobs: Object.keys(knobs).length ? knobs : undefined,
178
+ onKnob: opts.onKnob ?? auto.onKnob,
179
+ mount: ({ parent }) => {
180
+ parent.add(obj);
181
+ return {
182
+ root: obj,
183
+ dispose: () => {
184
+ parent.remove(obj);
185
+ targetDispose?.();
186
+ },
187
+ userData: {},
188
+ };
189
+ },
190
+ };
191
+
192
+ return runLab({
193
+ element,
194
+ canvas,
195
+ hud: dom?.hud ?? opts.hud ?? null,
196
+ labelContainer: dom?.labelContainer ?? opts.labelContainer ?? null,
197
+ editorContainer: dom?.editorContainer ?? opts.editorContainer ?? null,
198
+ bootOverlay: dom?.boot ?? opts.bootOverlay ?? null,
199
+ clearColor: opts.clearColor,
200
+ captureSize: opts.captureSize,
201
+ telemetryIntervalMs: opts.telemetryIntervalMs,
202
+ knobPollMs: opts.knobPollMs,
203
+ });
204
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Auto source-tag for the scene graph.
3
+ *
4
+ * Monkey-patches THREE.Object3D.prototype.add exactly once so every object
5
+ * added to a scene gets a userData.__tris record containing the user
6
+ * source frame (file:line + fn name from V8 stack), the object class, the
7
+ * geometry class, and a material hint.
8
+ *
9
+ * Element authors do not call anything. The tag appears on every mesh
10
+ * added via .add(), Three's standard pattern. The picker in inspect.ts
11
+ * reads this back when the user clicks, so we map "this pixel on screen"
12
+ * to "this exact line in your code" without grep.
13
+ *
14
+ * Stack parsing skips any frame inside three/, @triscope/, or
15
+ * node_modules/. In vite dev mode the stack is already source-mapped,
16
+ * resolving to original .ts source files. Production minified builds
17
+ * lose the precision but triscope is dev-only.
18
+ */
19
+ import * as THREE from 'three/webgpu';
20
+
21
+ export interface SourceFrame {
22
+ file: string;
23
+ line: number;
24
+ col: number;
25
+ fn?: string;
26
+ }
27
+
28
+ export interface SourceTag {
29
+ source: SourceFrame | null;
30
+ stack: SourceFrame[];
31
+ type: string;
32
+ geometry?: string;
33
+ material?: { color?: string; map?: string | null };
34
+ name?: string;
35
+ /**
36
+ * Names of ancestor objects in the scene tree, root-first. Populated
37
+ * lazily when the picker reads the tag (parents may change after
38
+ * .add() if the user re-parents). Useful as a tie-breaker when the
39
+ * source-line attribution drifts (see note below).
40
+ */
41
+ parentChain?: string[];
42
+ }
43
+
44
+ /**
45
+ * Note on line accuracy: in browser dev mode, `new Error().stack` returns
46
+ * positions in the file as vite served it (after esbuild's TS-to-JS
47
+ * transform). Vite tries to preserve line counts but TSL `Fn(([uv]) => …)`
48
+ * blocks and other complex constructions can drift by tens of lines.
49
+ * The captured line is therefore "approximate" — close enough for
50
+ * `code --goto` to land in the right neighborhood, but verify visually
51
+ * (or use `parentChain` + `geometry` + `material.color` as cross-checks)
52
+ * before assuming it's exact.
53
+ */
54
+
55
+ let patched = false;
56
+
57
+ export function installSourceTagPatch(): boolean {
58
+ if (patched) return false;
59
+ patched = true;
60
+ const origAdd = THREE.Object3D.prototype.add;
61
+ THREE.Object3D.prototype.add = function (...children: THREE.Object3D[]) {
62
+ let stack: SourceFrame[] = [];
63
+ try {
64
+ const raw = new Error().stack ?? '';
65
+ stack = parseUserStack(raw);
66
+ } catch {
67
+ /* stack capture is best-effort */
68
+ }
69
+ const source = stack[0] ?? null;
70
+ for (const child of children) {
71
+ if (!child) continue;
72
+ const prior = child.userData?.__tris as SourceTag | undefined;
73
+ if (prior && prior.source) continue;
74
+ const tag: SourceTag = {
75
+ source,
76
+ stack,
77
+ type: child.constructor.name,
78
+ };
79
+ const asMesh = child as THREE.Mesh;
80
+ if (asMesh.geometry) tag.geometry = asMesh.geometry.type;
81
+ if (asMesh.material) tag.material = extractMaterialHint(asMesh.material);
82
+ if (child.name) tag.name = child.name;
83
+ child.userData = child.userData ?? {};
84
+ (child.userData as Record<string, unknown>).__tris = tag;
85
+ }
86
+ return origAdd.apply(this, children);
87
+ } as typeof origAdd;
88
+ return true;
89
+ }
90
+
91
+ export const FRAME_RE = /at (?:(?<fn>[^(]+?) \()?(?<url>[^()]+?):(?<line>\d+):(?<col>\d+)\)?$/;
92
+
93
+ export const SKIP_PATTERNS = [
94
+ /\/node_modules\//,
95
+ /\/three\//,
96
+ /\/@triscope\//,
97
+ /\/triscope\/packages\//,
98
+ /\/(harness|source-tag|inspect|editor|telemetry)\.[jt]sx?/,
99
+ /^node:/,
100
+ /^(?:webpack|vite):/,
101
+ ];
102
+
103
+ export function parseUserStack(raw: string, max = 8): SourceFrame[] {
104
+ const out: SourceFrame[] = [];
105
+ for (const lineStr of raw.split('\n')) {
106
+ const m = FRAME_RE.exec(lineStr.trim());
107
+ if (!m?.groups) continue;
108
+ const url = stripUrl(m.groups.url);
109
+ if (SKIP_PATTERNS.some((p) => p.test(url))) continue;
110
+ out.push({
111
+ file: url,
112
+ line: Number(m.groups.line),
113
+ col: Number(m.groups.col),
114
+ fn: m.groups.fn || undefined,
115
+ });
116
+ if (out.length >= max) break;
117
+ }
118
+ return out;
119
+ }
120
+
121
+ export function stripUrl(u: string): string {
122
+ return u.replace(/[?#].*$/, '');
123
+ }
124
+
125
+ export function extractMaterialHint(material: unknown): { color?: string; map?: string | null } {
126
+ const m = material as {
127
+ color?: { getHexString?: () => string };
128
+ map?: { name?: string; source?: { data?: { src?: string } } };
129
+ };
130
+ const hint: { color?: string; map?: string | null } = {};
131
+ try {
132
+ if (m?.color?.getHexString) hint.color = '#' + m.color.getHexString();
133
+ } catch {}
134
+ try {
135
+ const tex = m?.map;
136
+ if (tex) hint.map = tex.name || tex.source?.data?.src || null;
137
+ } catch {}
138
+ return hint;
139
+ }
@@ -0,0 +1,337 @@
1
+ import {
2
+ appendFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import type { Plugin } from 'vite';
12
+
13
+ interface TelemetryOptions {
14
+ /**
15
+ * Project name used to namespace state files in /tmp.
16
+ * Defaults to the name in package.json (sanitized).
17
+ */
18
+ project?: string;
19
+ /** Override state file path. */
20
+ statePath?: string;
21
+ /** Override log file path. */
22
+ logPath?: string;
23
+ /**
24
+ * Regex matching files that need a full-reload (instead of HMR) when they
25
+ * change. Default catches TSL material / mesh / element / shader sources,
26
+ * because vite HMR cannot remount a THREE.Material already in the scene.
27
+ * Pass `null` to disable.
28
+ */
29
+ forceReloadOn?: RegExp | null;
30
+ }
31
+
32
+ /**
33
+ * Read a request body to a string with a hard timeout. A broken/slow client
34
+ * could otherwise hold a Vite middleware slot open indefinitely (the original
35
+ * readBody had data/end/error listeners but no timeout). On timeout we destroy
36
+ * the socket so the slot is freed, and reject so the route returns 400.
37
+ * Override the budget with TRISCOPE_READBODY_TIMEOUT_MS.
38
+ */
39
+ export function readRequestBody(
40
+ req: {
41
+ on: (event: string, cb: (arg: never) => void) => void;
42
+ socket?: { destroy?: () => void };
43
+ },
44
+ timeoutMs: number = Number(process.env.TRISCOPE_READBODY_TIMEOUT_MS ?? 10000),
45
+ ): Promise<string> {
46
+ return new Promise((resolve, reject) => {
47
+ let body = '';
48
+ let done = false;
49
+ const timer = setTimeout(() => {
50
+ if (done) return;
51
+ done = true;
52
+ try {
53
+ req.socket?.destroy?.();
54
+ } catch {
55
+ /* best-effort */
56
+ }
57
+ reject(new Error(`request body read timed out after ${timeoutMs}ms`));
58
+ }, timeoutMs);
59
+ // Don't let a pending read keep the process alive.
60
+ (timer as { unref?: () => void }).unref?.();
61
+ req.on('data', (c: never) => {
62
+ body += c;
63
+ });
64
+ req.on('end', () => {
65
+ if (done) return;
66
+ done = true;
67
+ clearTimeout(timer);
68
+ resolve(body);
69
+ });
70
+ req.on('error', (e: never) => {
71
+ if (done) return;
72
+ done = true;
73
+ clearTimeout(timer);
74
+ reject(e as unknown as Error);
75
+ });
76
+ });
77
+ }
78
+
79
+ function readProjectLabs(cwd: string): Record<string, string> {
80
+ try {
81
+ const p = join(cwd, 'package.json');
82
+ if (!existsSync(p)) return {};
83
+ const pkg = JSON.parse(readFileSync(p, 'utf8'));
84
+ const labs = pkg?.triscope?.labs;
85
+ if (labs && typeof labs === 'object') {
86
+ const out: Record<string, string> = {};
87
+ for (const [k, v] of Object.entries(labs)) {
88
+ if (typeof v === 'string') out[k] = v;
89
+ }
90
+ return out;
91
+ }
92
+ return {};
93
+ } catch {
94
+ return {};
95
+ }
96
+ }
97
+
98
+ function readPackageName(cwd: string): string {
99
+ try {
100
+ const p = join(cwd, 'package.json');
101
+ if (!existsSync(p)) return 'triscope-project';
102
+ const pkg = JSON.parse(readFileSync(p, 'utf8'));
103
+ return String(pkg.name ?? 'triscope-project').replace(/[^A-Za-z0-9._-]/g, '-');
104
+ } catch {
105
+ return 'triscope-project';
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Vite plugin that wires the telemetry sink:
111
+ *
112
+ * POST /__state → writes /tmp/<project>-state.json + appends to /tmp/<project>-state.log
113
+ * GET /__state → returns the latest snapshot
114
+ * POST /__knob → stores a pending knob change for the harness to consume
115
+ * GET /__knob → returns and CLEARS the pending knob queue (polled by harness)
116
+ * GET /__manifest → returns the registered element manifest (POSTed by harness on boot)
117
+ * POST /__manifest → harness pushes the live manifest (elements/cameras/knobs)
118
+ *
119
+ * The names start with __ so they cannot collide with user routes.
120
+ */
121
+ export function triscopeTelemetryPlugin(opts: TelemetryOptions = {}): Plugin {
122
+ const project = opts.project ?? readPackageName(process.cwd());
123
+ const statePath = opts.statePath ?? join(tmpdir(), `${project}-state.json`);
124
+ const logPath = opts.logPath ?? join(tmpdir(), `${project}-state.log`);
125
+
126
+ // In-memory pending knob queue. Harness polls and drains.
127
+ const pendingKnobs: Array<{ element: string; key: string; value: unknown }> = [];
128
+ // Persisted knob state per element. Survives the harness's full-reload so
129
+ // the harness can re-hydrate to the last user-applied values instead of
130
+ // snapping back to spec defaults. Updated on every POST /__knob, read by
131
+ // the harness via GET /__knob/current on boot.
132
+ const lastKnobValues: Record<string, Record<string, unknown>> = {};
133
+ // SDL (set_scene_param): pending camera/knob deltas the harness drains via
134
+ // GET /__scene, plus the merged persisted scene state for re-hydration on
135
+ // reload (GET /__scene/current). Mirrors the /__knob pattern.
136
+ const pendingScene: Array<Record<string, unknown>> = [];
137
+ const lastSceneState: { cameras: Record<string, unknown>; knobs: Record<string, unknown> } = {
138
+ cameras: {},
139
+ knobs: {},
140
+ };
141
+ // Manifest is a map keyed by element name so multiple labs can co-exist —
142
+ // each harness POSTs its own entry on boot.
143
+ const manifestByElement: Record<string, unknown> = {};
144
+ // Pre-seed with package.json#triscope.labs so MCP capture_views works on
145
+ // the very first call (before any browser tab loads a lab).
146
+ for (const [name, labUrl] of Object.entries(readProjectLabs(process.cwd()))) {
147
+ manifestByElement[name] = { element: name, labUrl };
148
+ }
149
+ const forceReloadOn =
150
+ opts.forceReloadOn === null
151
+ ? null
152
+ : (opts.forceReloadOn ?? /(\.tsl|Element|Mesh|Material|Shader)\.(ts|tsx|js|mjs)$/i);
153
+
154
+ return {
155
+ name: 'triscope-telemetry',
156
+ configureServer(server) {
157
+ mkdirSync(dirname(statePath), { recursive: true });
158
+ // Clear last session's telemetry on dev-server boot so a previously-open
159
+ // lab's elements don't linger as phantoms (the POST /__state merge is for
160
+ // multiple tabs in the SAME session — it has no cross-session expiry).
161
+ // The harness re-POSTs on mount, so a live tab repopulates immediately.
162
+ try {
163
+ rmSync(statePath, { force: true });
164
+ } catch {
165
+ /* best-effort — a stale file just merges as before */
166
+ }
167
+
168
+ const readBody = (req: any): Promise<string> => readRequestBody(req);
169
+
170
+ server.middlewares.use('/__state', async (req, res, next) => {
171
+ if (!req.method) return next();
172
+ try {
173
+ if (req.method === 'POST') {
174
+ const body = await readBody(req);
175
+ const payload = JSON.parse(body);
176
+ // Merge the elements map across labs so two tabs on different
177
+ // lab pages don't clobber each other's telemetry. Top-level
178
+ // fields (perf/time/cameras) still reflect the last writer
179
+ // since they're per-tab — that's expected when read_telemetry
180
+ // is project-scoped, not lab-scoped.
181
+ let merged: any = payload;
182
+ try {
183
+ if (existsSync(statePath)) {
184
+ const existing = JSON.parse(readFileSync(statePath, 'utf8'));
185
+ merged = {
186
+ ...payload,
187
+ elements: { ...(existing?.elements ?? {}), ...(payload?.elements ?? {}) },
188
+ };
189
+ }
190
+ } catch {
191
+ /* corrupt file — overwrite with the new payload */
192
+ }
193
+ writeFileSync(statePath, JSON.stringify(merged, null, 2));
194
+ const ts = new Date().toISOString();
195
+ const fps = (payload?.perf?.fps as number | undefined)?.toFixed?.(0) ?? '?';
196
+ const cam = (payload?.activeCamera as string | undefined) ?? '?';
197
+ appendFileSync(logPath, `${ts} fps=${fps} cam=${cam}\n`);
198
+ res.statusCode = 200;
199
+ return res.end('ok');
200
+ }
201
+ if (req.method === 'GET') {
202
+ res.setHeader('content-type', 'application/json');
203
+ return res.end(existsSync(statePath) ? readFileSync(statePath) : '{}');
204
+ }
205
+ } catch (err) {
206
+ res.statusCode = 400;
207
+ return res.end(String(err));
208
+ }
209
+ return next();
210
+ });
211
+
212
+ server.middlewares.use('/__knob', async (req, res, next) => {
213
+ if (!req.method) return next();
214
+ try {
215
+ // GET /__knob/current → persisted state (sub-path on the same prefix).
216
+ if (req.method === 'GET' && (req.url ?? '').startsWith('/current')) {
217
+ res.setHeader('content-type', 'application/json');
218
+ return res.end(JSON.stringify(lastKnobValues));
219
+ }
220
+ if (req.method === 'POST') {
221
+ const body = await readBody(req);
222
+ const payload = JSON.parse(body);
223
+ const updates: Array<{ element?: string; key?: string; value?: unknown }> =
224
+ Array.isArray(payload) ? payload : [payload];
225
+ for (const u of updates) {
226
+ if (typeof u?.element === 'string' && typeof u?.key === 'string') {
227
+ lastKnobValues[u.element] ??= {};
228
+ lastKnobValues[u.element][u.key] = u.value;
229
+ }
230
+ }
231
+ pendingKnobs.push(...(updates as typeof pendingKnobs));
232
+ res.statusCode = 200;
233
+ return res.end('ok');
234
+ }
235
+ if (req.method === 'GET') {
236
+ res.setHeader('content-type', 'application/json');
237
+ const drained = pendingKnobs.splice(0, pendingKnobs.length);
238
+ return res.end(JSON.stringify(drained));
239
+ }
240
+ } catch (err) {
241
+ res.statusCode = 400;
242
+ return res.end(String(err));
243
+ }
244
+ return next();
245
+ });
246
+
247
+ server.middlewares.use('/__scene', async (req, res, next) => {
248
+ if (!req.method) return next();
249
+ try {
250
+ // GET /__scene/current → merged persisted scene state (re-hydration).
251
+ if (req.method === 'GET' && (req.url ?? '').startsWith('/current')) {
252
+ res.setHeader('content-type', 'application/json');
253
+ return res.end(JSON.stringify(lastSceneState));
254
+ }
255
+ if (req.method === 'POST') {
256
+ const body = await readBody(req);
257
+ const delta = JSON.parse(body) as {
258
+ cameras?: Record<string, Record<string, unknown>>;
259
+ knobs?: Record<string, unknown>;
260
+ };
261
+ // Merge into the persisted state so a reload re-applies it.
262
+ if (delta?.cameras && typeof delta.cameras === 'object') {
263
+ for (const [n, c] of Object.entries(delta.cameras)) {
264
+ lastSceneState.cameras[n] = { ...(lastSceneState.cameras[n] as object), ...c };
265
+ }
266
+ }
267
+ if (delta?.knobs && typeof delta.knobs === 'object') {
268
+ for (const [k, v] of Object.entries(delta.knobs)) lastSceneState.knobs[k] = v;
269
+ }
270
+ pendingScene.push(delta);
271
+ res.statusCode = 200;
272
+ return res.end('ok');
273
+ }
274
+ if (req.method === 'GET') {
275
+ res.setHeader('content-type', 'application/json');
276
+ const drained = pendingScene.splice(0, pendingScene.length);
277
+ return res.end(JSON.stringify(drained));
278
+ }
279
+ } catch (err) {
280
+ res.statusCode = 400;
281
+ return res.end(String(err));
282
+ }
283
+ return next();
284
+ });
285
+
286
+ server.middlewares.use('/__manifest', async (req, res, next) => {
287
+ if (!req.method) return next();
288
+ try {
289
+ if (req.method === 'POST') {
290
+ const body = await readBody(req);
291
+ const payload = JSON.parse(body) as { element?: string } & Record<string, unknown>;
292
+ if (payload?.element && typeof payload.element === 'string') {
293
+ manifestByElement[payload.element] = payload;
294
+ }
295
+ res.statusCode = 200;
296
+ return res.end('ok');
297
+ }
298
+ if (req.method === 'GET') {
299
+ res.setHeader('content-type', 'application/json');
300
+ return res.end(JSON.stringify({ elements: manifestByElement }));
301
+ }
302
+ } catch (err) {
303
+ res.statusCode = 400;
304
+ return res.end(String(err));
305
+ }
306
+ return next();
307
+ });
308
+ },
309
+ handleHotUpdate({ file, server }) {
310
+ // TSL materials (and any code that ends up baked into the renderer
311
+ // node graph) cannot be remounted via vite HMR because the THREE
312
+ // Material instance is already in the scene. Force full-reload so
313
+ // edits to shader/element/mesh files reach the renderer. All other
314
+ // files (plain ts/css/etc.) continue to HMR normally.
315
+ if (forceReloadOn && forceReloadOn.test(file)) {
316
+ server.ws.send({ type: 'full-reload' });
317
+ return [];
318
+ }
319
+ return undefined;
320
+ },
321
+ };
322
+ }
323
+
324
+ export interface TelemetryPaths {
325
+ project: string;
326
+ statePath: string;
327
+ logPath: string;
328
+ }
329
+
330
+ export function resolveTelemetryPaths(cwd: string = process.cwd()): TelemetryPaths {
331
+ const project = readPackageName(cwd);
332
+ return {
333
+ project,
334
+ statePath: join(tmpdir(), `${project}-state.json`),
335
+ logPath: join(tmpdir(), `${project}-state.log`),
336
+ };
337
+ }