@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Minimal ambient declarations for `three` and `three/webgpu`.
|
|
2
|
+
// Three.js does not ship .d.ts files for these entry points as of v0.176.x.
|
|
3
|
+
// Class declarations below give us both value and type bindings for
|
|
4
|
+
// `import * as THREE from 'three/webgpu'`. Members are typed `any` —
|
|
5
|
+
// downstream user code should rely on its own three.js typings if it wants
|
|
6
|
+
// stricter types.
|
|
7
|
+
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
declare module 'three/webgpu' {
|
|
10
|
+
export class WebGPURenderer {
|
|
11
|
+
constructor(opts?: any);
|
|
12
|
+
domElement: HTMLCanvasElement;
|
|
13
|
+
autoClear: boolean;
|
|
14
|
+
init(): Promise<void>;
|
|
15
|
+
setSize(w: number, h: number, updateStyle?: boolean): void;
|
|
16
|
+
setPixelRatio(value: number): void;
|
|
17
|
+
setClearColor(color: number, alpha?: number): void;
|
|
18
|
+
setViewport(x: number, y: number, w: number, h: number): void;
|
|
19
|
+
setScissor(x: number, y: number, w: number, h: number): void;
|
|
20
|
+
setScissorTest(enable: boolean): void;
|
|
21
|
+
clear(color?: boolean, depth?: boolean, stencil?: boolean): void;
|
|
22
|
+
render(scene: any, camera: any): void;
|
|
23
|
+
getPixelRatio(): number;
|
|
24
|
+
dispose(): void;
|
|
25
|
+
[key: string]: any;
|
|
26
|
+
}
|
|
27
|
+
export class Scene extends Object3D {
|
|
28
|
+
background: any;
|
|
29
|
+
environment: any;
|
|
30
|
+
}
|
|
31
|
+
export class PerspectiveCamera {
|
|
32
|
+
constructor(fov?: number, aspect?: number, near?: number, far?: number);
|
|
33
|
+
fov: number;
|
|
34
|
+
aspect: number;
|
|
35
|
+
near: number;
|
|
36
|
+
far: number;
|
|
37
|
+
position: Vector3;
|
|
38
|
+
quaternion: Quaternion;
|
|
39
|
+
lookAt(x: number | Vector3, y?: number, z?: number): void;
|
|
40
|
+
updateProjectionMatrix(): void;
|
|
41
|
+
[key: string]: any;
|
|
42
|
+
}
|
|
43
|
+
export class Object3D {
|
|
44
|
+
position: Vector3;
|
|
45
|
+
quaternion: Quaternion;
|
|
46
|
+
visible: boolean;
|
|
47
|
+
userData: Record<string, any>;
|
|
48
|
+
name: string;
|
|
49
|
+
parent: Object3D | null;
|
|
50
|
+
children: Object3D[];
|
|
51
|
+
add(...obj: Object3D[]): this;
|
|
52
|
+
remove(...obj: Object3D[]): this;
|
|
53
|
+
traverse(cb: (obj: Object3D) => void): void;
|
|
54
|
+
[key: string]: any;
|
|
55
|
+
}
|
|
56
|
+
export class Group extends Object3D {}
|
|
57
|
+
export class Vector3 {
|
|
58
|
+
constructor(x?: number, y?: number, z?: number);
|
|
59
|
+
x: number;
|
|
60
|
+
y: number;
|
|
61
|
+
z: number;
|
|
62
|
+
set(x: number, y: number, z: number): this;
|
|
63
|
+
copy(v: Vector3): this;
|
|
64
|
+
add(v: Vector3): this;
|
|
65
|
+
sub(v: Vector3): this;
|
|
66
|
+
clone(): Vector3;
|
|
67
|
+
length(): number;
|
|
68
|
+
normalize(): this;
|
|
69
|
+
multiplyScalar(s: number): this;
|
|
70
|
+
applyQuaternion(q: Quaternion): this;
|
|
71
|
+
toArray(): number[];
|
|
72
|
+
}
|
|
73
|
+
export class Quaternion {
|
|
74
|
+
[key: string]: any;
|
|
75
|
+
}
|
|
76
|
+
export class Vector2 {
|
|
77
|
+
constructor(x?: number, y?: number);
|
|
78
|
+
x: number;
|
|
79
|
+
y: number;
|
|
80
|
+
set(x: number, y: number): this;
|
|
81
|
+
}
|
|
82
|
+
export class Matrix4 {
|
|
83
|
+
copy(m: Matrix4): this;
|
|
84
|
+
[key: string]: any;
|
|
85
|
+
}
|
|
86
|
+
export class BufferGeometry {
|
|
87
|
+
type: string;
|
|
88
|
+
index: { count: number } | null;
|
|
89
|
+
attributes: any;
|
|
90
|
+
[key: string]: any;
|
|
91
|
+
}
|
|
92
|
+
export class Mesh extends Object3D {
|
|
93
|
+
constructor(geometry?: BufferGeometry, material?: any);
|
|
94
|
+
geometry: BufferGeometry;
|
|
95
|
+
material: any;
|
|
96
|
+
isMesh: boolean;
|
|
97
|
+
matrix: Matrix4;
|
|
98
|
+
matrixAutoUpdate: boolean;
|
|
99
|
+
matrixWorld: Matrix4;
|
|
100
|
+
renderOrder: number;
|
|
101
|
+
frustumCulled: boolean;
|
|
102
|
+
updateMatrixWorld(force?: boolean): void;
|
|
103
|
+
raycast: (raycaster: Raycaster, intersects: any[]) => void;
|
|
104
|
+
}
|
|
105
|
+
export class Raycaster {
|
|
106
|
+
setFromCamera(coords: Vector2, camera: any): void;
|
|
107
|
+
intersectObjects(objects: Object3D[], recursive?: boolean): Array<{
|
|
108
|
+
object: Object3D;
|
|
109
|
+
distance: number;
|
|
110
|
+
point: Vector3;
|
|
111
|
+
}>;
|
|
112
|
+
}
|
|
113
|
+
export class MeshBasicNodeMaterial {
|
|
114
|
+
constructor(opts?: any);
|
|
115
|
+
}
|
|
116
|
+
export const MOUSE: { ROTATE: any; PAN: any; DOLLY: any };
|
|
117
|
+
export const TOUCH: { ROTATE: any; PAN: any; DOLLY_PAN: any; DOLLY_ROTATE: any };
|
|
118
|
+
// Catch-all for everything else we don't enumerate.
|
|
119
|
+
const _: any;
|
|
120
|
+
export default _;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
declare module 'three' {
|
|
124
|
+
export * from 'three/webgpu';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
declare module 'three/tsl' {
|
|
128
|
+
const _: any;
|
|
129
|
+
export = _;
|
|
130
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type * as THREE from 'three/webgpu';
|
|
2
|
+
|
|
3
|
+
/** A position+target camera preset for one pane of the lab grid. */
|
|
4
|
+
export interface CameraSpec {
|
|
5
|
+
position: [number, number, number];
|
|
6
|
+
target: [number, number, number];
|
|
7
|
+
/** Vertical FOV in degrees. Default 45. */
|
|
8
|
+
fov?: number;
|
|
9
|
+
/** If true, auto-fit element bounds into the camera frame. */
|
|
10
|
+
fit?: boolean;
|
|
11
|
+
/** Near clip. Default 0.1. */
|
|
12
|
+
near?: number;
|
|
13
|
+
/** Far clip. Default 2000. */
|
|
14
|
+
far?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A tunable knob exposed to the slider editor and the MCP `set_knob` tool. */
|
|
18
|
+
export type Knob =
|
|
19
|
+
| { type: 'number'; min: number; max: number; step?: number; default: number; label?: string }
|
|
20
|
+
| { type: 'int'; min: number; max: number; default: number; label?: string }
|
|
21
|
+
| { type: 'color'; default: string; label?: string }
|
|
22
|
+
| { type: 'boolean'; default: boolean; label?: string }
|
|
23
|
+
/**
|
|
24
|
+
* Action knob — fires `onKnob(handle, key, true)` each time it's set, but
|
|
25
|
+
* does NOT persist a value across reloads and does NOT auto-fire on mount.
|
|
26
|
+
* Use for one-shot triggers (fire cannon, load weapon, request screenshot)
|
|
27
|
+
* where the act of setting is the signal and there is no "current value".
|
|
28
|
+
*/
|
|
29
|
+
| { type: 'trigger'; label?: string };
|
|
30
|
+
|
|
31
|
+
/** Context passed to `Element.mount`. */
|
|
32
|
+
export interface MountContext {
|
|
33
|
+
renderer: THREE.WebGPURenderer;
|
|
34
|
+
scene: THREE.Scene;
|
|
35
|
+
/** Shared time uniform (seconds). Updated each frame by the harness. */
|
|
36
|
+
time: { value: number };
|
|
37
|
+
/** dt in seconds, last frame. */
|
|
38
|
+
dt: { value: number };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The handle returned by `Element.mount`. */
|
|
42
|
+
export interface MountHandle {
|
|
43
|
+
/** The root Object3D the element added to the scene. */
|
|
44
|
+
root: THREE.Object3D;
|
|
45
|
+
/** Tear-down hook. Must remove the element from the scene and free GPU resources. */
|
|
46
|
+
dispose: () => void;
|
|
47
|
+
/** Element-specific data the telemetry/onKnob callbacks may need. */
|
|
48
|
+
userData?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A self-describing 3D element. The triscope lab harness consumes this
|
|
53
|
+
* to render a multi-camera grid, drive a tunables UI, post telemetry,
|
|
54
|
+
* and run the smoke test. Composition is just an element whose `mount`
|
|
55
|
+
* mounts other elements.
|
|
56
|
+
*/
|
|
57
|
+
export interface Element {
|
|
58
|
+
name: string;
|
|
59
|
+
mount: (args: { parent: THREE.Object3D; ctx: MountContext }) => MountHandle;
|
|
60
|
+
/**
|
|
61
|
+
* Optional override for the lab page URL. Either a path relative to the
|
|
62
|
+
* dev server (`/triscope-ship.html`) or a full URL. When set, the harness
|
|
63
|
+
* publishes it on the manifest so the MCP/CLI can route `capture_views`
|
|
64
|
+
* and `run_smoke` to the right page without hardcoding `/labs/<name>.html`.
|
|
65
|
+
*/
|
|
66
|
+
labUrl?: string;
|
|
67
|
+
/** Local-space bounding box. Used for auto-fitting cameras and the scene framing. */
|
|
68
|
+
bounds?: { min: [number, number, number]; max: [number, number, number] };
|
|
69
|
+
/**
|
|
70
|
+
* Named camera presets — each becomes one pane in the lab grid. Pass the
|
|
71
|
+
* literal `'auto'` to have the harness synthesize 4 fitted presets
|
|
72
|
+
* (front/side/top/three-quarter) from `bounds` (see scene-cameras.ts), so an
|
|
73
|
+
* element doesn't have to hand-author cameras.
|
|
74
|
+
*/
|
|
75
|
+
cameras: Record<string, CameraSpec> | 'auto';
|
|
76
|
+
/** Tunable knobs. Rendered as sliders + exposed to MCP `set_knob`. */
|
|
77
|
+
knobs?: Record<string, Knob>;
|
|
78
|
+
/** Live-update hook. Called when a knob changes; must apply the change without rebuilding pipelines. */
|
|
79
|
+
onKnob?: (handle: MountHandle, key: string, value: number | string | boolean) => void;
|
|
80
|
+
/** Per-frame state to publish via the telemetry sink. Return JSON-serializable values. */
|
|
81
|
+
telemetry?: (handle: MountHandle, ctx: MountContext) => Record<string, unknown>;
|
|
82
|
+
/**
|
|
83
|
+
* Named per-frame numeric probes for animated state. The harness samples each
|
|
84
|
+
* every frame, keeps a ring buffer (~2 s at 60 fps), and exposes summary stats
|
|
85
|
+
* under `telemetry.elements.<name>.motion.<probeKey>`:
|
|
86
|
+
* { latest, mean, min, max, peakToPeak, samples: lastN }
|
|
87
|
+
* Use for amplitude (vertex displacement), oscillation rate, particle counts —
|
|
88
|
+
* anything dynamic the Element wants to quantify.
|
|
89
|
+
*/
|
|
90
|
+
motionProbes?: Record<string, (handle: MountHandle, ctx: MountContext) => number>;
|
|
91
|
+
/**
|
|
92
|
+
* Per-frame discrete-event drain. The harness calls this every frame; the
|
|
93
|
+
* element returns events that occurred since the last call (typically by
|
|
94
|
+
* draining an internal queue). The harness appends them to a ring buffer
|
|
95
|
+
* (cap 128) and exposes them via `telemetry.events`. Use for one-shot
|
|
96
|
+
* signals like collisions, weapon fires, state transitions — anything the
|
|
97
|
+
* test script needs to verify a posteriori with `read_telemetry .events`.
|
|
98
|
+
*
|
|
99
|
+
* Implementation MUST drain (return + clear) each call: events returned
|
|
100
|
+
* twice will appear twice in the buffer.
|
|
101
|
+
*/
|
|
102
|
+
events?: (handle: MountHandle, ctx: MountContext) => TriscopeEvent[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Discrete event emitted by an Element. */
|
|
106
|
+
export interface TriscopeEvent {
|
|
107
|
+
/** Timestamp in seconds. Should reuse `ctx.time.value` for sim-consistent ordering. */
|
|
108
|
+
timestamp: number;
|
|
109
|
+
/** Discriminator — caller-defined (e.g. 'fire' | 'splash' | 'impact'). */
|
|
110
|
+
type: string;
|
|
111
|
+
/** Optional opaque payload — anything JSON-serializable. */
|
|
112
|
+
payload?: Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Default value extracted from a knob spec. Trigger knobs have no
|
|
116
|
+
* default — they are pure action signals; this returns `false` only as a
|
|
117
|
+
* placeholder so callers iterating knob values get a defined entry. */
|
|
118
|
+
export function knobDefault(k: Knob): number | string | boolean {
|
|
119
|
+
if (k.type === 'trigger') return false;
|
|
120
|
+
return k.default;
|
|
121
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure live-uniform read/write by path — the data layer behind read_uniform /
|
|
3
|
+
* set_uniform. Lets an agent probe or nudge ANY material uniform / material
|
|
4
|
+
* property / object property (e.g. a shader uniform that was never declared as
|
|
5
|
+
* a knob, or `sun.intensity`) without a source edit + reload. Duck-typed (no
|
|
6
|
+
* `three` import) so it's unit-testable; the harness passes the live scene.
|
|
7
|
+
*
|
|
8
|
+
* Path format: "<objectName|uuid>.<key>" — split on the LAST dot, so dotted
|
|
9
|
+
* object names still work and the trailing segment is the uniform/property key.
|
|
10
|
+
* Writes are TRANSIENT (live only, not persisted via /__knob).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface UniformReadResult {
|
|
14
|
+
kind: 'uniform' | 'material-property' | 'object-property' | 'not-found';
|
|
15
|
+
path: string;
|
|
16
|
+
value?: unknown;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UniformWriteResult {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
kind: 'uniform' | 'material-property' | 'object-property' | 'not-found';
|
|
23
|
+
path: string;
|
|
24
|
+
previous?: unknown;
|
|
25
|
+
current?: unknown;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseUniformPath(path: string): { objectId: string; key: string } {
|
|
30
|
+
const i = path.lastIndexOf('.');
|
|
31
|
+
if (i < 0) return { objectId: path, key: '' };
|
|
32
|
+
return { objectId: path.slice(0, i), key: path.slice(i + 1) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function findObject(root: any, id: string): any {
|
|
36
|
+
if (!root) return null;
|
|
37
|
+
const stack = [root];
|
|
38
|
+
let guard = 0;
|
|
39
|
+
while (stack.length && guard++ < 100000) {
|
|
40
|
+
const o = stack.pop();
|
|
41
|
+
if (o?.name === id || o?.uuid === id) return o;
|
|
42
|
+
const kids = o?.children;
|
|
43
|
+
if (Array.isArray(kids)) for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Locate where a key's value lives: a legacy uniform slot, a material prop, or an object prop. */
|
|
49
|
+
function locateSlot(
|
|
50
|
+
obj: any,
|
|
51
|
+
key: string,
|
|
52
|
+
): {
|
|
53
|
+
kind: 'uniform' | 'material-property' | 'object-property';
|
|
54
|
+
container: any;
|
|
55
|
+
prop: string;
|
|
56
|
+
} | null {
|
|
57
|
+
if (!key) return null;
|
|
58
|
+
const mat = Array.isArray(obj?.material) ? obj.material[0] : obj?.material;
|
|
59
|
+
try {
|
|
60
|
+
if (mat?.uniforms && typeof mat.uniforms === 'object' && mat.uniforms[key] !== undefined) {
|
|
61
|
+
return { kind: 'uniform', container: mat.uniforms[key], prop: 'value' };
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
/* fall through */
|
|
65
|
+
}
|
|
66
|
+
if (mat && key in mat) return { kind: 'material-property', container: mat, prop: key };
|
|
67
|
+
if (obj && key in obj) return { kind: 'object-property', container: obj, prop: key };
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Bound, JSON-safe serialization of a slot value (Color→hex, Vector→array, texture→tag). */
|
|
72
|
+
function serializeVal(v: any): unknown {
|
|
73
|
+
if (v === null || v === undefined) return null;
|
|
74
|
+
const t = typeof v;
|
|
75
|
+
if (t === 'number' || t === 'string' || t === 'boolean') return v;
|
|
76
|
+
try {
|
|
77
|
+
if (v.isTexture || v.image || v.source) return '[texture]';
|
|
78
|
+
if (typeof v.getHexString === 'function') return `#${v.getHexString()}`;
|
|
79
|
+
if (typeof v.x === 'number' && typeof v.y === 'number') {
|
|
80
|
+
const out = [v.x, v.y];
|
|
81
|
+
if (typeof v.z === 'number') out.push(v.z);
|
|
82
|
+
if (typeof v.w === 'number') out.push(v.w);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
/* exotic value — fall through */
|
|
87
|
+
}
|
|
88
|
+
return '[object]';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readUniformValue(root: any, path: string): UniformReadResult {
|
|
92
|
+
const { objectId, key } = parseUniformPath(path);
|
|
93
|
+
const obj = findObject(root, objectId);
|
|
94
|
+
if (!obj) return { kind: 'not-found', path, error: `no object named/uuid "${objectId}"` };
|
|
95
|
+
const slot = locateSlot(obj, key);
|
|
96
|
+
if (!slot)
|
|
97
|
+
return { kind: 'not-found', path, error: `no uniform/property "${key}" on "${objectId}"` };
|
|
98
|
+
return { kind: slot.kind, path, value: serializeVal(slot.container[slot.prop]) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function writeUniformValue(root: any, path: string, value: unknown): UniformWriteResult {
|
|
102
|
+
const { objectId, key } = parseUniformPath(path);
|
|
103
|
+
const obj = findObject(root, objectId);
|
|
104
|
+
if (!obj)
|
|
105
|
+
return { ok: false, kind: 'not-found', path, error: `no object named/uuid "${objectId}"` };
|
|
106
|
+
const slot = locateSlot(obj, key);
|
|
107
|
+
if (!slot)
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
kind: 'not-found',
|
|
111
|
+
path,
|
|
112
|
+
error: `no uniform/property "${key}" on "${objectId}"`,
|
|
113
|
+
};
|
|
114
|
+
const previous = serializeVal(slot.container[slot.prop]);
|
|
115
|
+
try {
|
|
116
|
+
const cur = slot.container[slot.prop];
|
|
117
|
+
if (cur && typeof cur.getHexString === 'function' && typeof cur.set === 'function') {
|
|
118
|
+
cur.set(value as string | number); // THREE.Color — accepts '#hex' or number
|
|
119
|
+
} else if (
|
|
120
|
+
cur &&
|
|
121
|
+
typeof cur.set === 'function' &&
|
|
122
|
+
typeof cur.x === 'number' &&
|
|
123
|
+
Array.isArray(value)
|
|
124
|
+
) {
|
|
125
|
+
cur.set(...(value as number[])); // THREE.Vector*
|
|
126
|
+
} else if (typeof cur === 'number') {
|
|
127
|
+
// Coerce to the slot's existing type so a "6.0" from a loosely-typed tool
|
|
128
|
+
// call doesn't silently store a STRING (it would work by accident in
|
|
129
|
+
// arithmetic but read back as the wrong type and break strict consumers).
|
|
130
|
+
slot.container[slot.prop] = Number(value);
|
|
131
|
+
} else if (typeof cur === 'boolean') {
|
|
132
|
+
slot.container[slot.prop] = value === true || value === 'true';
|
|
133
|
+
} else {
|
|
134
|
+
slot.container[slot.prop] = value;
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
kind: slot.kind,
|
|
140
|
+
path,
|
|
141
|
+
previous,
|
|
142
|
+
error: String((e as { message?: unknown })?.message ?? e),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
kind: slot.kind,
|
|
148
|
+
path,
|
|
149
|
+
previous,
|
|
150
|
+
current: serializeVal(slot.container[slot.prop]),
|
|
151
|
+
};
|
|
152
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Pure Element-contract validation. The harness runs this before mount so a
|
|
2
|
+
// malformed element surfaces a precise, actionable error in telemetry warnings
|
|
3
|
+
// + the boot overlay, instead of a cryptic "harness not mounted within 10s".
|
|
4
|
+
|
|
5
|
+
const KNOB_TYPES = new Set(['number', 'int', 'color', 'boolean', 'trigger']);
|
|
6
|
+
|
|
7
|
+
export function validateElement(el: any): string[] {
|
|
8
|
+
const problems: string[] = [];
|
|
9
|
+
if (!el || typeof el !== 'object') {
|
|
10
|
+
return ['element is not an object'];
|
|
11
|
+
}
|
|
12
|
+
if (typeof el.name !== 'string' || el.name.length === 0) {
|
|
13
|
+
problems.push('element.name must be a non-empty string');
|
|
14
|
+
}
|
|
15
|
+
if (typeof el.mount !== 'function') {
|
|
16
|
+
problems.push('element.mount must be a function');
|
|
17
|
+
}
|
|
18
|
+
// `cameras: 'auto'` is valid — the harness synthesizes fitted presets from
|
|
19
|
+
// bounds (runSceneView's default), so it must NOT trip the "declare a camera"
|
|
20
|
+
// warning (which would pollute read_telemetry .warnings on every boot).
|
|
21
|
+
if (el.cameras === 'auto') {
|
|
22
|
+
/* ok */
|
|
23
|
+
} else if (
|
|
24
|
+
!el.cameras ||
|
|
25
|
+
typeof el.cameras !== 'object' ||
|
|
26
|
+
Object.keys(el.cameras).length === 0
|
|
27
|
+
) {
|
|
28
|
+
problems.push("element.cameras must declare at least one named camera (or 'auto')");
|
|
29
|
+
} else {
|
|
30
|
+
for (const [name, spec] of Object.entries(el.cameras as Record<string, any>)) {
|
|
31
|
+
if (!isVec3(spec?.position) || !isVec3(spec?.target)) {
|
|
32
|
+
problems.push(`camera "${name}" needs position[3] and target[3]`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (el.knobs !== undefined) {
|
|
37
|
+
if (typeof el.knobs !== 'object' || el.knobs === null) {
|
|
38
|
+
problems.push('element.knobs must be an object');
|
|
39
|
+
} else {
|
|
40
|
+
for (const [key, k] of Object.entries(el.knobs as Record<string, any>)) {
|
|
41
|
+
if (!k || !KNOB_TYPES.has(k.type)) {
|
|
42
|
+
problems.push(`knob "${key}" has an unknown type "${k?.type}"`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
(k.type === 'number' || k.type === 'int') &&
|
|
47
|
+
(typeof k.min !== 'number' || typeof k.max !== 'number')
|
|
48
|
+
) {
|
|
49
|
+
problems.push(`knob "${key}" (${k.type}) needs numeric min and max`);
|
|
50
|
+
}
|
|
51
|
+
if ((k.type === 'number' || k.type === 'int') && typeof k.default !== 'number') {
|
|
52
|
+
problems.push(`knob "${key}" (${k.type}) needs a numeric default`);
|
|
53
|
+
}
|
|
54
|
+
if (k.type === 'color' && typeof k.default !== 'string') {
|
|
55
|
+
problems.push(`knob "${key}" (color) needs a string default`);
|
|
56
|
+
}
|
|
57
|
+
if (k.type === 'boolean' && typeof k.default !== 'boolean') {
|
|
58
|
+
problems.push(`knob "${key}" (boolean) needs a boolean default`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const hook of ['onKnob', 'telemetry', 'events'] as const) {
|
|
64
|
+
if (el[hook] !== undefined && typeof el[hook] !== 'function') {
|
|
65
|
+
problems.push(`element.${hook} must be a function if present`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (el.motionProbes !== undefined) {
|
|
69
|
+
if (typeof el.motionProbes !== 'object' || el.motionProbes === null) {
|
|
70
|
+
problems.push('element.motionProbes must be an object of functions');
|
|
71
|
+
} else {
|
|
72
|
+
for (const [key, fn] of Object.entries(el.motionProbes as Record<string, any>)) {
|
|
73
|
+
if (typeof fn !== 'function') problems.push(`motionProbe "${key}" must be a function`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return problems;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isVec3(v: unknown): boolean {
|
|
81
|
+
return Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number');
|
|
82
|
+
}
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Node-only entry. Vite plugin + helpers that touch the filesystem.
|
|
2
|
+
// Import this from your `vite.config.{js,ts}`, never from browser code.
|
|
3
|
+
|
|
4
|
+
export type { TelemetryPaths } from './telemetry.js';
|
|
5
|
+
export { resolveTelemetryPaths, triscopeTelemetryPlugin } from './telemetry.js';
|
package/src/warnings.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** One non-fatal failure the harness swallowed but wants the agent to see. */
|
|
2
|
+
export interface WarningEntry {
|
|
3
|
+
/** Wall-clock ms when recorded. */
|
|
4
|
+
ts: number;
|
|
5
|
+
/** Where it came from: 'postState' | 'postManifest' | 'pollKnobs' | 'telemetry-fn' | 'motion-probe' | 'event-drain' | 'black-frame' | 'knob-clamp' | … */
|
|
6
|
+
source: string;
|
|
7
|
+
/** Short human-readable summary. */
|
|
8
|
+
message: string;
|
|
9
|
+
/** Optional extra context (stack tail, value, …). */
|
|
10
|
+
detail?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WarningRing {
|
|
14
|
+
push: (source: string, message: string, detail?: string) => void;
|
|
15
|
+
list: () => WarningEntry[];
|
|
16
|
+
readonly size: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bounded ring of recent swallowed failures, surfaced via telemetry `.warnings`
|
|
21
|
+
* so an agent can answer "why is telemetry stale / the knob not applying / this
|
|
22
|
+
* pane black?" with a `read_telemetry .warnings` call instead of guessing. The
|
|
23
|
+
* harness wires this into the `.catch(()=>{})` sites that used to fail silently.
|
|
24
|
+
*/
|
|
25
|
+
export function createWarningRing(cap = 32, now: () => number = () => Date.now()): WarningRing {
|
|
26
|
+
const buf: WarningEntry[] = [];
|
|
27
|
+
return {
|
|
28
|
+
push(source: string, message: string, detail?: string): void {
|
|
29
|
+
const entry: WarningEntry = { ts: now(), source, message };
|
|
30
|
+
if (detail !== undefined) entry.detail = detail;
|
|
31
|
+
buf.push(entry);
|
|
32
|
+
if (buf.length > cap) buf.shift();
|
|
33
|
+
},
|
|
34
|
+
list(): WarningEntry[] {
|
|
35
|
+
return buf.slice();
|
|
36
|
+
},
|
|
37
|
+
get size(): number {
|
|
38
|
+
return buf.length;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|