@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/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Browser-safe surface. The Vite plugin lives at `@triscope/core/vite`
|
|
2
|
+
// (separate entry because it imports Node `fs`/`os`/`path`).
|
|
3
|
+
|
|
4
|
+
export type { ComposeOptions } from './compose.js';
|
|
5
|
+
export { composeElements } from './compose.js';
|
|
6
|
+
export { mountEditor } from './editor.js';
|
|
7
|
+
export type {
|
|
8
|
+
CommonLabOptions,
|
|
9
|
+
GpuProbeStats,
|
|
10
|
+
LabHandle,
|
|
11
|
+
LabOptions,
|
|
12
|
+
SceneLabOptions,
|
|
13
|
+
} from './harness.js';
|
|
14
|
+
export { fitCameraToBounds, makeCamera, runLab, runSceneLab } from './harness.js';
|
|
15
|
+
export type { InspectSelection } from './inspect.js';
|
|
16
|
+
export type { ClampResult } from './knob-utils.js';
|
|
17
|
+
export { clampKnob } from './knob-utils.js';
|
|
18
|
+
export { LAB_CSS } from './lab/css.js';
|
|
19
|
+
export type { LabDomRefs } from './lab/dom.js';
|
|
20
|
+
export { mountLabDom } from './lab/dom.js';
|
|
21
|
+
export { BLACK_FRAME_LUMINANCE, isBlackFrame } from './probe-utils.js';
|
|
22
|
+
export type { Bounds } from './scene-cameras.js';
|
|
23
|
+
export { autoCameras, UNIT_BOUNDS } from './scene-cameras.js';
|
|
24
|
+
export type { CameraDelta } from './scene-delta.js';
|
|
25
|
+
export { applyCameraDelta, applyElementToggle } from './scene-delta.js';
|
|
26
|
+
export type {
|
|
27
|
+
LightNode,
|
|
28
|
+
SceneNode,
|
|
29
|
+
SerializeSceneOptions,
|
|
30
|
+
SerializeSceneResult,
|
|
31
|
+
} from './scene-introspect.js';
|
|
32
|
+
export { serializeScene } from './scene-introspect.js';
|
|
33
|
+
export type { CameraBinding, KnobBinding, RegistryEntry, SceneRegistry } from './scene-registry.js';
|
|
34
|
+
export {
|
|
35
|
+
buildCameraMap,
|
|
36
|
+
buildKnobMap,
|
|
37
|
+
createSceneRegistry,
|
|
38
|
+
nsKey,
|
|
39
|
+
splitNs,
|
|
40
|
+
} from './scene-registry.js';
|
|
41
|
+
export type { SceneViewOptions, SceneViewTarget } from './scene-view.js';
|
|
42
|
+
export { autoKnobsFromObject, computeObjectBounds, runSceneView } from './scene-view.js';
|
|
43
|
+
export type { SourceFrame, SourceTag } from './source-tag.js';
|
|
44
|
+
export { installSourceTagPatch } from './source-tag.js';
|
|
45
|
+
export type {
|
|
46
|
+
CameraSpec,
|
|
47
|
+
Element,
|
|
48
|
+
Knob,
|
|
49
|
+
MountContext,
|
|
50
|
+
MountHandle,
|
|
51
|
+
TriscopeEvent,
|
|
52
|
+
} from './types.js';
|
|
53
|
+
export { knobDefault } from './types.js';
|
|
54
|
+
export type { UniformReadResult, UniformWriteResult } from './uniform-access.js';
|
|
55
|
+
export { parseUniformPath, readUniformValue, writeUniformValue } from './uniform-access.js';
|
|
56
|
+
export { validateElement } from './validate.js';
|
|
57
|
+
export type { WarningEntry, WarningRing } from './warnings.js';
|
|
58
|
+
export { createWarningRing } from './warnings.js';
|
package/src/inspect.ts
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect mode: solo-view + OrbitControls + raycaster picking + hover
|
|
3
|
+
* highlight + click-to-select. Writes selection into
|
|
4
|
+
* window.__TRISCOPE__.lastSelection so MCP read_telemetry .selection
|
|
5
|
+
* surfaces the source frame for the picked mesh.
|
|
6
|
+
*
|
|
7
|
+
* Activation: URL `?inspect=<element>&camera=<name>` (camera optional, falls
|
|
8
|
+
* back to the first declared camera). When inactive the harness behaves
|
|
9
|
+
* exactly as before — the grid view.
|
|
10
|
+
*
|
|
11
|
+
* Highlight is a single shared wireframe Mesh that borrows the hovered
|
|
12
|
+
* object's geometry (no per-frame allocation). Click-selection persists
|
|
13
|
+
* the same overlay with a different color until the next click.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
17
|
+
import * as THREE from 'three/webgpu';
|
|
18
|
+
import type { SourceTag } from './source-tag.js';
|
|
19
|
+
|
|
20
|
+
export interface InspectSelection {
|
|
21
|
+
/** Camera the click came from. */
|
|
22
|
+
camera: string;
|
|
23
|
+
/** World-space hit position. */
|
|
24
|
+
point: [number, number, number];
|
|
25
|
+
/** Distance from camera to hit. */
|
|
26
|
+
distance: number;
|
|
27
|
+
/** Tag from the auto source-tag patch. May drift in vite dev — see note in source-tag.ts. */
|
|
28
|
+
source: SourceTag['source'];
|
|
29
|
+
stack: SourceTag['stack'];
|
|
30
|
+
type: string;
|
|
31
|
+
geometry?: string;
|
|
32
|
+
material?: SourceTag['material'];
|
|
33
|
+
/** Object name if author set Object3D.name. */
|
|
34
|
+
name?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Object name chain, root → immediate parent → self. Useful as a
|
|
37
|
+
* cross-check when `source.line` drifts: even if the line is off,
|
|
38
|
+
* "the cyan mesh inside group 'mainmast' inside scene" disambiguates.
|
|
39
|
+
*/
|
|
40
|
+
parentChain: string[];
|
|
41
|
+
/** Object UUID — stable for the duration of the session. */
|
|
42
|
+
uuid: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildParentChain(obj: THREE.Object3D): string[] {
|
|
46
|
+
const chain: string[] = [];
|
|
47
|
+
let cur: THREE.Object3D | null = obj;
|
|
48
|
+
while (cur) {
|
|
49
|
+
chain.unshift(describeObj(cur));
|
|
50
|
+
cur = (cur as any).parent ?? null;
|
|
51
|
+
}
|
|
52
|
+
return chain;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compact human-readable description of an Object3D used in parentChain
|
|
57
|
+
* and clipboard formats. Prefers the author's `.name` (most specific);
|
|
58
|
+
* falls back to a self-describing shorthand like `Mesh<PlaneGeometry
|
|
59
|
+
* #e6dcc0>` so the user can still grep — even when no names are set on
|
|
60
|
+
* the scene tree.
|
|
61
|
+
*/
|
|
62
|
+
export function describeObj(obj: THREE.Object3D): string {
|
|
63
|
+
if (obj.name) return obj.name;
|
|
64
|
+
const ctor = obj.constructor?.name ?? '?';
|
|
65
|
+
const mesh = obj as THREE.Mesh;
|
|
66
|
+
const geom = mesh.geometry?.type;
|
|
67
|
+
let color: string | undefined;
|
|
68
|
+
try {
|
|
69
|
+
const mat = mesh.material as { color?: { getHexString?: () => string } };
|
|
70
|
+
if (mat?.color?.getHexString) color = '#' + mat.color.getHexString();
|
|
71
|
+
} catch {
|
|
72
|
+
/* color extraction is best-effort */
|
|
73
|
+
}
|
|
74
|
+
if (geom || color) {
|
|
75
|
+
const parts = [geom, color].filter(Boolean).join(' ');
|
|
76
|
+
return `${ctor}<${parts}>`;
|
|
77
|
+
}
|
|
78
|
+
return ctor;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface InspectMode {
|
|
82
|
+
active: boolean;
|
|
83
|
+
cameraName: string | null;
|
|
84
|
+
camera: THREE.PerspectiveCamera | null;
|
|
85
|
+
/** Renders the solo view for this frame. Call instead of grid renderAll(). */
|
|
86
|
+
render(): void;
|
|
87
|
+
/** Pull current hover/selection state. */
|
|
88
|
+
state(): {
|
|
89
|
+
hover: InspectSelection | null;
|
|
90
|
+
selection: InspectSelection | null;
|
|
91
|
+
selections: InspectSelection[];
|
|
92
|
+
};
|
|
93
|
+
dispose(): void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface InspectInit {
|
|
97
|
+
renderer: THREE.WebGPURenderer;
|
|
98
|
+
scene: THREE.Scene;
|
|
99
|
+
cameras: Record<string, THREE.PerspectiveCamera>;
|
|
100
|
+
/** Element name used for the manifest match. */
|
|
101
|
+
elementName: string;
|
|
102
|
+
/** Canvas the user clicks on. */
|
|
103
|
+
canvas: HTMLCanvasElement;
|
|
104
|
+
/**
|
|
105
|
+
* Called when selection changes. `sel` is the most-recently-clicked
|
|
106
|
+
* mesh (or null on background click), `all` is the full multi-select
|
|
107
|
+
* set including this one. Multi-select is built with Shift+click;
|
|
108
|
+
* a plain click clears the set and starts fresh with one item.
|
|
109
|
+
*/
|
|
110
|
+
onSelectionChange: (sel: InspectSelection | null, all: InspectSelection[]) => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Walk the scene tree and return the first Mesh whose source tag matches
|
|
115
|
+
* the given frame (same file + line). Used by inspect-mode persistence to
|
|
116
|
+
* restore the selection across full-reload — the Mesh object identity
|
|
117
|
+
* changes after reload but the source location is stable.
|
|
118
|
+
*/
|
|
119
|
+
export function findMeshBySource(
|
|
120
|
+
scene: THREE.Scene,
|
|
121
|
+
source: SourceTag['source'] | null,
|
|
122
|
+
): THREE.Object3D | null {
|
|
123
|
+
if (!source?.file) return null;
|
|
124
|
+
let match: THREE.Object3D | null = null;
|
|
125
|
+
(scene as any).traverse?.((obj: THREE.Object3D) => {
|
|
126
|
+
if (match) return;
|
|
127
|
+
const tag = obj.userData?.__tris as SourceTag | undefined;
|
|
128
|
+
if (!tag?.source) return;
|
|
129
|
+
if (tag.source.file === source.file && tag.source.line === source.line) {
|
|
130
|
+
match = obj;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
return match;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parse the URL for inspect activation. Returns null when off. */
|
|
137
|
+
export function readInspectFromUrl(elementName: string): { camera?: string } | null {
|
|
138
|
+
if (typeof window === 'undefined') return null;
|
|
139
|
+
const params = new URLSearchParams(window.location.search);
|
|
140
|
+
const inspectParam = params.get('inspect');
|
|
141
|
+
if (inspectParam == null) return null;
|
|
142
|
+
// `?inspect`, `?inspect=1`, `?inspect=<el>` all activate.
|
|
143
|
+
// `?inspect=<el>` activates only when the el matches our element.
|
|
144
|
+
if (inspectParam === '' || inspectParam === '1' || inspectParam === elementName) {
|
|
145
|
+
return { camera: params.get('camera') ?? undefined };
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createInspectMode(init: InspectInit & { cameraName?: string }): InspectMode {
|
|
151
|
+
const cameraName = init.cameraName ?? Object.keys(init.cameras)[0];
|
|
152
|
+
const camera = init.cameras[cameraName];
|
|
153
|
+
if (!camera) {
|
|
154
|
+
return {
|
|
155
|
+
active: false,
|
|
156
|
+
cameraName: null,
|
|
157
|
+
camera: null,
|
|
158
|
+
render: () => {},
|
|
159
|
+
state: () => ({ hover: null, selection: null, selections: [] }),
|
|
160
|
+
dispose: () => {},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// OrbitControls — right-button rotates per user request, left-button is
|
|
165
|
+
// reserved for picking (we wire click → raycast below). Scroll zooms.
|
|
166
|
+
const controls = new OrbitControls(camera, init.canvas);
|
|
167
|
+
controls.enablePan = true;
|
|
168
|
+
controls.enableDamping = true;
|
|
169
|
+
controls.dampingFactor = 0.08;
|
|
170
|
+
controls.mouseButtons = {
|
|
171
|
+
LEFT: null as any, // we handle left clicks for picking
|
|
172
|
+
MIDDLE: THREE.MOUSE.PAN,
|
|
173
|
+
RIGHT: THREE.MOUSE.ROTATE,
|
|
174
|
+
};
|
|
175
|
+
// OrbitControls' target defaults to (0,0,0); use the camera's declared target.
|
|
176
|
+
const t = camera.userData?.target;
|
|
177
|
+
if (Array.isArray(t) && t.length === 3) controls.target.set(t[0], t[1], t[2]);
|
|
178
|
+
controls.update();
|
|
179
|
+
|
|
180
|
+
// Highlight overlay: one wireframe mesh whose geometry is swapped to
|
|
181
|
+
// match the hovered/selected mesh. Bright green for hover, bright cyan
|
|
182
|
+
// for the persistent click-selection. `raycast` is a no-op so the
|
|
183
|
+
// overlay never grabs subsequent picks.
|
|
184
|
+
const hoverMat = new THREE.MeshBasicNodeMaterial({
|
|
185
|
+
color: 0x66ff66,
|
|
186
|
+
wireframe: true,
|
|
187
|
+
transparent: true,
|
|
188
|
+
opacity: 0.9,
|
|
189
|
+
depthTest: false,
|
|
190
|
+
});
|
|
191
|
+
const selectMat = new THREE.MeshBasicNodeMaterial({
|
|
192
|
+
color: 0x00ffff,
|
|
193
|
+
wireframe: true,
|
|
194
|
+
transparent: true,
|
|
195
|
+
opacity: 0.95,
|
|
196
|
+
depthTest: false,
|
|
197
|
+
});
|
|
198
|
+
const overlay = new THREE.Mesh(new THREE.BufferGeometry(), hoverMat);
|
|
199
|
+
overlay.renderOrder = 999;
|
|
200
|
+
overlay.frustumCulled = false;
|
|
201
|
+
overlay.visible = false;
|
|
202
|
+
overlay.raycast = () => {}; // ignore in picking
|
|
203
|
+
init.scene.add(overlay);
|
|
204
|
+
const selectOverlay = new THREE.Mesh(new THREE.BufferGeometry(), selectMat);
|
|
205
|
+
selectOverlay.renderOrder = 1000;
|
|
206
|
+
selectOverlay.frustumCulled = false;
|
|
207
|
+
selectOverlay.visible = false;
|
|
208
|
+
selectOverlay.raycast = () => {};
|
|
209
|
+
init.scene.add(selectOverlay);
|
|
210
|
+
|
|
211
|
+
const raycaster = new THREE.Raycaster();
|
|
212
|
+
const ndc = new THREE.Vector2();
|
|
213
|
+
let hoverHit: InspectSelection | null = null;
|
|
214
|
+
let selectionHit: InspectSelection | null = null;
|
|
215
|
+
let hoveredObject: THREE.Object3D | null = null;
|
|
216
|
+
let selectedObject: THREE.Object3D | null = null;
|
|
217
|
+
// Pending restore-poller handle (a chain of setTimeouts) + a disposed guard so
|
|
218
|
+
// dispose() can cancel it — otherwise it keeps writing selection state (and an
|
|
219
|
+
// overlay borrowing now-freed geometry) for up to ~3s after teardown.
|
|
220
|
+
let restoreTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
|
+
let disposed = false;
|
|
222
|
+
// Multi-select state. selectionsByUuid maps uuid → selection. extraOverlays
|
|
223
|
+
// maps uuid → its wireframe overlay Mesh (the primary cyan overlay is
|
|
224
|
+
// reserved for the most-recently-clicked item, kept in sync with
|
|
225
|
+
// selectedObject). Built up via Shift+click; plain click resets.
|
|
226
|
+
const selectionsByUuid = new Map<string, InspectSelection>();
|
|
227
|
+
const extraOverlaysByUuid = new Map<string, THREE.Mesh>();
|
|
228
|
+
const extraOverlayMat = new THREE.MeshBasicNodeMaterial({
|
|
229
|
+
color: 0x00ccff,
|
|
230
|
+
wireframe: true,
|
|
231
|
+
transparent: true,
|
|
232
|
+
opacity: 0.75,
|
|
233
|
+
depthTest: false,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
function eventToNdc(ev: MouseEvent): void {
|
|
237
|
+
const rect = init.canvas.getBoundingClientRect();
|
|
238
|
+
ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
|
239
|
+
ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function pick(): { obj: THREE.Object3D; distance: number; point: THREE.Vector3 } | null {
|
|
243
|
+
raycaster.setFromCamera(ndc, camera);
|
|
244
|
+
const hits = raycaster.intersectObjects(init.scene.children, true);
|
|
245
|
+
// Filter out overlays + lights + non-mesh helpers.
|
|
246
|
+
for (const h of hits) {
|
|
247
|
+
if (h.object === overlay || h.object === selectOverlay) continue;
|
|
248
|
+
if (!(h.object as THREE.Mesh).isMesh) continue;
|
|
249
|
+
return { obj: h.object, distance: h.distance, point: h.point };
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function selectionFrom(hit: {
|
|
255
|
+
obj: THREE.Object3D;
|
|
256
|
+
distance: number;
|
|
257
|
+
point: THREE.Vector3;
|
|
258
|
+
}): InspectSelection {
|
|
259
|
+
const tag = (hit.obj.userData?.__tris as SourceTag | undefined) ?? null;
|
|
260
|
+
return {
|
|
261
|
+
camera: cameraName,
|
|
262
|
+
point: [hit.point.x, hit.point.y, hit.point.z],
|
|
263
|
+
distance: +hit.distance.toFixed(3),
|
|
264
|
+
source: tag?.source ?? null,
|
|
265
|
+
stack: tag?.stack ?? [],
|
|
266
|
+
type: tag?.type ?? hit.obj.constructor.name,
|
|
267
|
+
geometry: tag?.geometry,
|
|
268
|
+
material: tag?.material,
|
|
269
|
+
name: tag?.name ?? hit.obj.name ?? undefined,
|
|
270
|
+
parentChain: buildParentChain(hit.obj),
|
|
271
|
+
uuid: hit.obj.uuid,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function syncOverlayTo(target: THREE.Mesh, which: THREE.Mesh): void {
|
|
276
|
+
which.geometry = target.geometry;
|
|
277
|
+
target.updateMatrixWorld(true);
|
|
278
|
+
which.matrix.copy(target.matrixWorld);
|
|
279
|
+
which.matrixAutoUpdate = false;
|
|
280
|
+
which.visible = true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// mousemove → hover (RAF-throttled so we don't raycast 1000x/s).
|
|
284
|
+
let pendingHover = false;
|
|
285
|
+
function onMouseMove(ev: MouseEvent): void {
|
|
286
|
+
eventToNdc(ev);
|
|
287
|
+
if (pendingHover) return;
|
|
288
|
+
pendingHover = true;
|
|
289
|
+
requestAnimationFrame(() => {
|
|
290
|
+
pendingHover = false;
|
|
291
|
+
const hit = pick();
|
|
292
|
+
if (!hit) {
|
|
293
|
+
if (hoveredObject) {
|
|
294
|
+
hoveredObject = null;
|
|
295
|
+
hoverHit = null;
|
|
296
|
+
overlay.visible = false;
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (hit.obj === hoveredObject) return;
|
|
301
|
+
hoveredObject = hit.obj;
|
|
302
|
+
hoverHit = selectionFrom(hit);
|
|
303
|
+
syncOverlayTo(hit.obj as THREE.Mesh, overlay);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function clearMultiOverlays(): void {
|
|
308
|
+
for (const ov of extraOverlaysByUuid.values()) init.scene.remove(ov);
|
|
309
|
+
extraOverlaysByUuid.clear();
|
|
310
|
+
selectionsByUuid.clear();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function ensureMultiOverlay(uuid: string, target: THREE.Mesh): void {
|
|
314
|
+
if (extraOverlaysByUuid.has(uuid)) {
|
|
315
|
+
// Already overlaid; just re-sync transform in case the mesh moved.
|
|
316
|
+
syncOverlayTo(target, extraOverlaysByUuid.get(uuid)!);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const ov = new THREE.Mesh(new THREE.BufferGeometry(), extraOverlayMat);
|
|
320
|
+
ov.renderOrder = 1000;
|
|
321
|
+
ov.frustumCulled = false;
|
|
322
|
+
ov.raycast = () => {};
|
|
323
|
+
init.scene.add(ov);
|
|
324
|
+
extraOverlaysByUuid.set(uuid, ov);
|
|
325
|
+
syncOverlayTo(target, ov);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Best-effort clipboard write — silently ignores rejections on browsers
|
|
330
|
+
* that gate navigator.clipboard behind a user-activation check (it's
|
|
331
|
+
* fine here because we're called from a click event handler).
|
|
332
|
+
*
|
|
333
|
+
* Format is rich enough to grep with when source.line drifts (which it
|
|
334
|
+
* does, because Error.stack in browsers reports positions in the
|
|
335
|
+
* vite-served file, not source-mapped originals). Example output:
|
|
336
|
+
* PirateShipMesh.ts:1599 — Mesh<PlaneGeometry #e6dcc0> @ (4.2,8.1,0.3) chain=Scene>pirate.ship>mainmast>Mesh<PlaneGeometry #e6dcc0>
|
|
337
|
+
* The user can paste this into chat or `rg` and find the real call
|
|
338
|
+
* site even if the line number is off by 100 lines.
|
|
339
|
+
*/
|
|
340
|
+
function copySelection(sel: InspectSelection): void {
|
|
341
|
+
const src = sel.source;
|
|
342
|
+
const fileLine = src ? `${src.file.split('/').slice(-1)[0]}:${src.line}` : `(uuid=${sel.uuid})`;
|
|
343
|
+
const desc =
|
|
344
|
+
sel.name ?? `${sel.type}<${[sel.geometry, sel.material?.color].filter(Boolean).join(' ')}>`;
|
|
345
|
+
const pos = `(${sel.point.map((n) => n.toFixed(2)).join(',')})`;
|
|
346
|
+
const chain = sel.parentChain?.length ? ` chain=${sel.parentChain.join('>')}` : '';
|
|
347
|
+
const text = `${fileLine} — ${desc} @ ${pos}${chain}`;
|
|
348
|
+
try {
|
|
349
|
+
(navigator as any).clipboard?.writeText(text);
|
|
350
|
+
} catch {}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function onMouseDown(ev: MouseEvent): void {
|
|
354
|
+
if (ev.button !== 0) return; // left only
|
|
355
|
+
eventToNdc(ev);
|
|
356
|
+
const hit = pick();
|
|
357
|
+
if (!hit) {
|
|
358
|
+
// Plain background click clears everything; Shift+background keeps
|
|
359
|
+
// the current multi-set so the user can de-target a stray click.
|
|
360
|
+
if (!ev.shiftKey) {
|
|
361
|
+
selectionHit = null;
|
|
362
|
+
selectedObject = null;
|
|
363
|
+
selectOverlay.visible = false;
|
|
364
|
+
clearMultiOverlays();
|
|
365
|
+
init.onSelectionChange(null, []);
|
|
366
|
+
try {
|
|
367
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
368
|
+
} catch {}
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const sel = selectionFrom(hit);
|
|
373
|
+
if (!ev.shiftKey) {
|
|
374
|
+
// Plain click: replace set with this one item.
|
|
375
|
+
clearMultiOverlays();
|
|
376
|
+
} else if (selectedObject && selectionHit) {
|
|
377
|
+
// Shift+click on a NEW mesh: the previous "primary" is about to lose
|
|
378
|
+
// the primary overlay (which will swap to the new mesh). Give the
|
|
379
|
+
// old primary its own multi-overlay so it stays visible. Without
|
|
380
|
+
// this the user sees only the latest pick, not the accumulated set.
|
|
381
|
+
ensureMultiOverlay(selectionHit.uuid, selectedObject as THREE.Mesh);
|
|
382
|
+
}
|
|
383
|
+
selectedObject = hit.obj;
|
|
384
|
+
selectionHit = sel;
|
|
385
|
+
syncOverlayTo(hit.obj as THREE.Mesh, selectOverlay);
|
|
386
|
+
selectionsByUuid.set(sel.uuid, sel);
|
|
387
|
+
init.onSelectionChange(sel, [...selectionsByUuid.values()]);
|
|
388
|
+
copySelection(sel);
|
|
389
|
+
try {
|
|
390
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(sel));
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
init.canvas.addEventListener('mousemove', onMouseMove);
|
|
395
|
+
init.canvas.addEventListener('mousedown', onMouseDown);
|
|
396
|
+
|
|
397
|
+
// Restore last selection across full-reload (vite force-reload on
|
|
398
|
+
// shader edits). Match by source frame (file:line) — the actual Mesh
|
|
399
|
+
// object is new after reload but the source location is stable.
|
|
400
|
+
//
|
|
401
|
+
// Element mounting may happen across many frames (TSL pipeline init,
|
|
402
|
+
// async texture loads, RAF-driven sub-mesh adds). Poll up to ~3 s
|
|
403
|
+
// looking for the stored source in the scene tree, so we don't give
|
|
404
|
+
// up before the element has finished assembling itself.
|
|
405
|
+
const STORAGE_KEY = `triscope:selection:${init.elementName}`;
|
|
406
|
+
try {
|
|
407
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
408
|
+
if (raw) {
|
|
409
|
+
const stored = JSON.parse(raw) as InspectSelection;
|
|
410
|
+
let attempts = 0;
|
|
411
|
+
const maxAttempts = 30; // 30 × 100ms ≈ 3s
|
|
412
|
+
const tryRestore = () => {
|
|
413
|
+
if (disposed) return;
|
|
414
|
+
attempts += 1;
|
|
415
|
+
try {
|
|
416
|
+
const target = findMeshBySource(init.scene, stored.source);
|
|
417
|
+
if (target) {
|
|
418
|
+
selectedObject = target;
|
|
419
|
+
selectionHit = selectionFrom({
|
|
420
|
+
obj: target,
|
|
421
|
+
distance: stored.distance,
|
|
422
|
+
point: new THREE.Vector3(...stored.point),
|
|
423
|
+
} as any);
|
|
424
|
+
syncOverlayTo(target as THREE.Mesh, selectOverlay);
|
|
425
|
+
selectionsByUuid.set(selectionHit.uuid, selectionHit);
|
|
426
|
+
init.onSelectionChange(selectionHit, [...selectionsByUuid.values()]);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
/* corrupt stored selection — drop it */
|
|
431
|
+
try {
|
|
432
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
433
|
+
} catch {}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (attempts < maxAttempts) restoreTimer = setTimeout(tryRestore, 100);
|
|
437
|
+
};
|
|
438
|
+
restoreTimer = setTimeout(tryRestore, 100);
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
/* localStorage unavailable — silent */
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function render(): void {
|
|
445
|
+
controls.update();
|
|
446
|
+
// Keep overlays glued to their target's current world matrix in case
|
|
447
|
+
// the underlying mesh moved (animation, knob change).
|
|
448
|
+
if (selectedObject && selectionHit) {
|
|
449
|
+
selectedObject.updateMatrixWorld(true);
|
|
450
|
+
selectOverlay.matrix.copy(selectedObject.matrixWorld);
|
|
451
|
+
}
|
|
452
|
+
if (hoveredObject) {
|
|
453
|
+
hoveredObject.updateMatrixWorld(true);
|
|
454
|
+
overlay.matrix.copy(hoveredObject.matrixWorld);
|
|
455
|
+
}
|
|
456
|
+
init.renderer.setScissorTest(false);
|
|
457
|
+
const w = init.renderer.domElement.width / init.renderer.getPixelRatio();
|
|
458
|
+
const h = init.renderer.domElement.height / init.renderer.getPixelRatio();
|
|
459
|
+
init.renderer.setViewport(0, 0, w, h);
|
|
460
|
+
camera.aspect = w / Math.max(h, 1);
|
|
461
|
+
camera.updateProjectionMatrix();
|
|
462
|
+
init.renderer.clear();
|
|
463
|
+
init.renderer.render(init.scene, camera);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function dispose(): void {
|
|
467
|
+
disposed = true;
|
|
468
|
+
if (restoreTimer) {
|
|
469
|
+
clearTimeout(restoreTimer);
|
|
470
|
+
restoreTimer = null;
|
|
471
|
+
}
|
|
472
|
+
init.canvas.removeEventListener('mousemove', onMouseMove);
|
|
473
|
+
init.canvas.removeEventListener('mousedown', onMouseDown);
|
|
474
|
+
// Remove every overlay from the shared scene + drop the borrowed geometry
|
|
475
|
+
// references so a disposed element's freed BufferGeometry can be GC'd.
|
|
476
|
+
init.scene.remove(overlay);
|
|
477
|
+
init.scene.remove(selectOverlay);
|
|
478
|
+
overlay.geometry = new THREE.BufferGeometry();
|
|
479
|
+
selectOverlay.geometry = new THREE.BufferGeometry();
|
|
480
|
+
clearMultiOverlays();
|
|
481
|
+
hoveredObject = null;
|
|
482
|
+
selectedObject = null;
|
|
483
|
+
controls.dispose();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
active: true,
|
|
488
|
+
cameraName,
|
|
489
|
+
camera,
|
|
490
|
+
render,
|
|
491
|
+
state: () => ({
|
|
492
|
+
hover: hoverHit,
|
|
493
|
+
selection: selectionHit,
|
|
494
|
+
selections: [...selectionsByUuid.values()],
|
|
495
|
+
}),
|
|
496
|
+
dispose,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Knob } from './types.js';
|
|
2
|
+
|
|
3
|
+
/** Result of clamping a candidate knob value against its spec. */
|
|
4
|
+
export interface ClampResult {
|
|
5
|
+
/** True when the final value differs from what the caller asked for. */
|
|
6
|
+
clamped: boolean;
|
|
7
|
+
/** The value actually safe to apply. */
|
|
8
|
+
final: number | string | boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Coerce + clamp a candidate knob value to what its spec actually allows, so a
|
|
15
|
+
* stray `set_knob('windPressure', 9999)` can't silently corrupt the scene.
|
|
16
|
+
* Returns both the safe `final` value and whether it had to be changed, so the
|
|
17
|
+
* caller (harness / MCP) can tell the agent its input was out of range.
|
|
18
|
+
*
|
|
19
|
+
* number → coerce to finite, snap to `step` (if any), clamp to [min, max]
|
|
20
|
+
* int → coerce, round, clamp to [min, max]
|
|
21
|
+
* color → keep valid #rgb / #rrggbb, else fall back to the spec default
|
|
22
|
+
* boolean → coerce truthiness
|
|
23
|
+
* trigger → pass the pulse through untouched (no persisted value)
|
|
24
|
+
*/
|
|
25
|
+
export function clampKnob(spec: Knob, value: number | string | boolean): ClampResult {
|
|
26
|
+
switch (spec.type) {
|
|
27
|
+
case 'number': {
|
|
28
|
+
const n = Number(value);
|
|
29
|
+
if (!Number.isFinite(n)) return { clamped: true, final: spec.default };
|
|
30
|
+
let next = n;
|
|
31
|
+
if (spec.step && spec.step > 0) {
|
|
32
|
+
next = spec.min + Math.round((next - spec.min) / spec.step) * spec.step;
|
|
33
|
+
}
|
|
34
|
+
next = Math.min(spec.max, Math.max(spec.min, next));
|
|
35
|
+
return { clamped: !nearlyEqual(next, n), final: next };
|
|
36
|
+
}
|
|
37
|
+
case 'int': {
|
|
38
|
+
const n = Number(value);
|
|
39
|
+
if (!Number.isFinite(n)) return { clamped: true, final: spec.default };
|
|
40
|
+
const next = Math.min(spec.max, Math.max(spec.min, Math.round(n)));
|
|
41
|
+
return { clamped: next !== n, final: next };
|
|
42
|
+
}
|
|
43
|
+
case 'color': {
|
|
44
|
+
if (typeof value === 'string' && HEX_COLOR.test(value)) {
|
|
45
|
+
return { clamped: false, final: value };
|
|
46
|
+
}
|
|
47
|
+
return { clamped: true, final: spec.default };
|
|
48
|
+
}
|
|
49
|
+
case 'boolean': {
|
|
50
|
+
const next = Boolean(value);
|
|
51
|
+
return { clamped: typeof value !== 'boolean', final: next };
|
|
52
|
+
}
|
|
53
|
+
case 'trigger':
|
|
54
|
+
return { clamped: false, final: value };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nearlyEqual(a: number, b: number): boolean {
|
|
59
|
+
return Math.abs(a - b) < 1e-9;
|
|
60
|
+
}
|
package/src/lab/css.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lab UI CSS, exported as a template string so consumers can inject it
|
|
3
|
+
* once via `mountLabDom()` instead of duplicating it in every per-element
|
|
4
|
+
* lab HTML page.
|
|
5
|
+
*
|
|
6
|
+
* Covers: full-bleed canvas, camera-label tags, HUD strip, boot overlay,
|
|
7
|
+
* knob-editor pane. The selectors match the DOM that `mountLabDom()`
|
|
8
|
+
* creates and that `runLab()` / `mountEditor()` populate.
|
|
9
|
+
*/
|
|
10
|
+
export const LAB_CSS = `
|
|
11
|
+
html, body { margin: 0; padding: 0; overflow: hidden; background: #000; color: #cfd6db; font-family: ui-monospace, monospace; }
|
|
12
|
+
#app { position: fixed; inset: 0; }
|
|
13
|
+
canvas { display: block; width: 100%; height: 100%; }
|
|
14
|
+
|
|
15
|
+
.triscope-label, .label {
|
|
16
|
+
position: absolute;
|
|
17
|
+
background: rgba(0,0,0,0.55);
|
|
18
|
+
color: #cfd6db;
|
|
19
|
+
padding: 4px 8px;
|
|
20
|
+
font-size: 11px;
|
|
21
|
+
font-weight: 600;
|
|
22
|
+
letter-spacing: 0.05em;
|
|
23
|
+
pointer-events: none;
|
|
24
|
+
border-radius: 3px;
|
|
25
|
+
z-index: 5;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#hud { position: fixed; bottom: 8px; left: 8px; z-index: 10; font-size: 11px; padding: 4px 8px; background: rgba(0,0,0,0.55); border-radius: 3px; }
|
|
29
|
+
#boot { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: #0a1a20; color: #cfd6db; font-size: 14px; z-index: 50; }
|
|
30
|
+
|
|
31
|
+
#lab-controls {
|
|
32
|
+
position: fixed;
|
|
33
|
+
right: 8px;
|
|
34
|
+
bottom: 8px;
|
|
35
|
+
z-index: 20;
|
|
36
|
+
width: min(320px, calc(100vw - 24px));
|
|
37
|
+
padding: 10px 12px;
|
|
38
|
+
background: rgba(5, 12, 16, 0.78);
|
|
39
|
+
border: 1px solid rgba(210, 230, 240, 0.16);
|
|
40
|
+
border-radius: 6px;
|
|
41
|
+
backdrop-filter: blur(8px);
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
}
|
|
44
|
+
.triscope-editor__row {
|
|
45
|
+
display: grid;
|
|
46
|
+
grid-template-columns: 110px 1fr 56px;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: 6px;
|
|
49
|
+
font-size: 11px;
|
|
50
|
+
color: #cfd6db;
|
|
51
|
+
margin: 4px 0;
|
|
52
|
+
}
|
|
53
|
+
.triscope-editor__row label { font-size: 10px; opacity: 0.85; }
|
|
54
|
+
.triscope-editor__row input { width: 100%; accent-color: #8fc7d9; }
|
|
55
|
+
.triscope-editor__row output { text-align: right; color: #f0f5f7; }
|
|
56
|
+
`;
|