create-triscope 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 triscope contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # create-triscope
2
+
3
+ Scaffold a new [triscope](https://github.com/tedin7/triscope) project — a
4
+ Three.js + WebGPU lab wired for the triscope MCP tool chain.
5
+
6
+ ```sh
7
+ npm init triscope my-app
8
+ # or with a starter preset:
9
+ npm init triscope my-app -- --preset wave
10
+ ```
11
+
12
+ Then:
13
+
14
+ ```sh
15
+ cd my-app
16
+ npm install
17
+ npm run dev # open the lab in a WebGPU browser (Chrome/Edge)
18
+ ```
19
+
20
+ The generated project ships a sample `cube` element, a lab page, the telemetry
21
+ Vite plugin, a `.gitignore`, and the `triscope-iteration-loop` skill so an agent
22
+ can drive the converge loop immediately.
23
+
24
+ Already have a Three.js app? Use `npx triscope adopt` (from
25
+ [`@triscope/cli`](https://www.npmjs.com/package/@triscope/cli)) to retrofit it
26
+ instead. MIT.
package/bin/create.mjs ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // `npm init triscope <dir>` — scaffold a new triscope project.
3
+ //
4
+ // Copies packages/create-triscope/template/ to <dir>, doing literal
5
+ // `__PROJECT_NAME__` substitution in package.json and the example skill.
6
+ import {
7
+ copyFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ statSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { basename, dirname, join, resolve } from 'node:path';
16
+ import { fileURLToPath, pathToFileURL } from 'node:url';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+ const TEMPLATE = resolve(__dirname, '../template');
21
+ const PRESETS = resolve(__dirname, '../presets');
22
+
23
+ // create-triscope is released in lockstep with the @triscope/* packages, so its
24
+ // own version is exactly the range to pin a scaffolded project's deps to. The
25
+ // template ships `^__TRISCOPE_VERSION__`; without this substitution it would keep
26
+ // the placeholder `^0.0.0` and the new project's `npm install` would fail to
27
+ // resolve the real published @triscope/core / @triscope/cli (ETARGET).
28
+ const SELF_VERSION = JSON.parse(
29
+ readFileSync(resolve(__dirname, '../package.json'), 'utf8'),
30
+ ).version;
31
+
32
+ /** Parse `<dir> [--preset <name>]` from create-triscope's argv. */
33
+ export function parseCreateArgs(argv) {
34
+ let dir;
35
+ let preset;
36
+ for (let i = 0; i < argv.length; i++) {
37
+ if (argv[i] === '--preset') preset = argv[++i];
38
+ else if (!dir && !argv[i].startsWith('--')) dir = argv[i];
39
+ }
40
+ return { dir, preset };
41
+ }
42
+
43
+ /** Add `<name>: 'labs/<name>.html'` to vite's rollupOptions.input (idempotent,
44
+ * additive — never clobbers existing entries). */
45
+ export function injectLabInput(viteText, name) {
46
+ if (viteText.includes(`labs/${name}.html`)) return viteText;
47
+ const line = ` ${name}: 'labs/${name}.html',`;
48
+ return viteText.replace(/(\n[ \t]*index: 'index\.html',)/, `$1\n${line}`);
49
+ }
50
+
51
+ /** Overlay a preset onto an already-scaffolded project (additive copy + vite input). */
52
+ export function applyPreset(target, preset, subs) {
53
+ const src = join(PRESETS, preset);
54
+ if (!existsSync(src)) {
55
+ const avail = existsSync(PRESETS) ? readdirSync(PRESETS).join(', ') : '(none)';
56
+ throw new Error(`unknown preset "${preset}". Available: ${avail || '(none)'}`);
57
+ }
58
+ copyDir(src, target, subs);
59
+ const vitePath = join(target, 'vite.config.js');
60
+ if (existsSync(vitePath)) {
61
+ writeFileSync(vitePath, injectLabInput(readFileSync(vitePath, 'utf8'), preset));
62
+ }
63
+ }
64
+
65
+ export function copyDir(src, dst, subs) {
66
+ mkdirSync(dst, { recursive: true });
67
+ for (const entry of readdirSync(src)) {
68
+ const sPath = join(src, entry);
69
+ const stat = statSync(sPath);
70
+ if (stat.isDirectory()) {
71
+ copyDir(sPath, join(dst, entry), subs);
72
+ } else {
73
+ // npm refuses to publish a literal `.gitignore` (it strips/renames it on
74
+ // pack), so the template ships it as `gitignore` and we restore the dot at
75
+ // scaffold time — otherwise every generated project would lack a .gitignore.
76
+ const outName = entry === 'gitignore' ? '.gitignore' : entry;
77
+ let content = readFileSync(sPath);
78
+ // Apply substitutions only to text files
79
+ if (/\.(json|md|html|ts|js|mjs|cjs|css)$/.test(entry)) {
80
+ let text = content.toString('utf8');
81
+ for (const [k, v] of Object.entries(subs)) {
82
+ text = text.split(k).join(v);
83
+ }
84
+ content = Buffer.from(text, 'utf8');
85
+ }
86
+ writeFileSync(join(dst, outName), content);
87
+ }
88
+ }
89
+ }
90
+
91
+ export function main() {
92
+ const { dir: arg, preset } = parseCreateArgs(process.argv.slice(2));
93
+ if (!arg) {
94
+ console.error('Usage: npm init triscope <project-dir> [--preset <name>]');
95
+ process.exit(2);
96
+ }
97
+ const target = resolve(process.cwd(), arg);
98
+ if (existsSync(target) && readdirSync(target).length > 0) {
99
+ console.error(`Refusing to scaffold into non-empty directory: ${target}`);
100
+ process.exit(2);
101
+ }
102
+ const projectName = basename(target).replace(/[^A-Za-z0-9._-]/g, '-');
103
+ const subs = { __PROJECT_NAME__: projectName, __TRISCOPE_VERSION__: SELF_VERSION };
104
+ copyDir(TEMPLATE, target, subs);
105
+ if (preset) applyPreset(target, preset, subs);
106
+ console.log(`Scaffolded ${projectName}${preset ? ` (+${preset} preset)` : ''} at ${target}`);
107
+ console.log('');
108
+ console.log('Next steps:');
109
+ console.log(` cd ${arg}`);
110
+ console.log(' npm install');
111
+ console.log(' npm run dev # open the lab in Chrome/Edge with WebGPU');
112
+ if (preset) console.log(` # open /labs/${preset}.html for the ${preset} preset`);
113
+ console.log(' # then from another shell:');
114
+ console.log(' npx triscope state .perf.fps');
115
+ console.log(' npx triscope list');
116
+ }
117
+
118
+ // Only auto-run when invoked as a script (not when imported by tests).
119
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
120
+ main();
121
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "create-triscope",
3
+ "version": "0.4.0",
4
+ "description": "Scaffold a new Triscope project: Vite + Three.js WebGPU + lab harness + .claude/skills/.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-triscope": "./bin/create.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "template"
12
+ ],
13
+ "license": "MIT",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "devDependencies": {
18
+ "@vitest/coverage-v8": "^2.1.9",
19
+ "vitest": "^2.1.9"
20
+ },
21
+ "scripts": {
22
+ "test": "vitest run",
23
+ "test:watch": "vitest",
24
+ "test:coverage": "vitest run --coverage --coverage.reporter=text --coverage.reporter=html"
25
+ },
26
+ "keywords": [
27
+ "triscope",
28
+ "scaffold",
29
+ "create",
30
+ "three.js",
31
+ "webgpu"
32
+ ]
33
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "_comment": "Optional Claude Code hook config for triscope. Copy the 'hooks' block into your .claude/settings.local.json (or settings.json) to enable. The hook runs `triscope auto-capture` after every Edit/Write — it reads /tmp/<project>-state.json from the dev server and prints a one-line motion summary so Claude sees current FPS + probe activity in its next turn, without having to call capture_views.",
3
+ "_what_you_get": "After editing src/triscope/ship-element.ts, the next message to Claude includes a line like: '[triscope] ship motion: sailWanderEnvelope p2p=0.59 freq=0.22Hz'. If peakToPeak is ~0 and windPressure>0, Claude sees the regression immediately without prompting.",
4
+ "_requirements": "1) `triscope` CLI on PATH (npm i -g @triscope/cli, or `node ./node_modules/@triscope/cli/bin/triscope.mjs`). 2) Dev server running (npm run dev). 3) The element you're iterating on has motionProbes declared (otherwise this hook stays silent).",
5
+
6
+ "hooks": {
7
+ "PostToolUse": [
8
+ {
9
+ "matcher": "Edit|Write",
10
+ "hooks": [
11
+ {
12
+ "type": "command",
13
+ "command": "triscope auto-capture --file \"${TOOL_INPUT_file_path:-}\""
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
19
+ }
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: adopt-triscope
3
+ description: Use when ADDING triscope to an existing 3D / Three.js project (retrofit), or wrapping an already-built scene/object so the triscope MCP tools (capture_views, inspect_scene, set_knob, read_telemetry, set_uniform) can drive it. Covers `triscope adopt`, runSceneView, and promoting to a full Element. NOT for a fresh project (use `npm create triscope`).
4
+ ---
5
+
6
+ # Adopting triscope into an existing project
7
+
8
+ triscope's MCP tools don't observe arbitrary Three.js code — the project must expose `window.__TRISCOPE__` via the harness. The contract: (1) `triscopeTelemetryPlugin()` in vite.config; (2) boot via `runLab`/`runSceneLab`/`runSceneView`; (3) a lab HTML page + `package.json#triscope.labs`. Your mesh/shader/geometry code stays normal Three.js — you just *wrap* it.
9
+
10
+ ## Step 1 — run the retrofit
11
+
12
+ ```
13
+ triscope adopt # or: triscope adopt --name myscene
14
+ triscope dev --check # verify Node/deps/chromium/MCP/port
15
+ ```
16
+
17
+ `adopt` injects the Vite plugin (or prints the manual patch if it can't place it — apply it), scaffolds `labs/<name>.html` + `src/labs/<name>.ts` (a `runSceneView` entry with a **placeholder box** so the lab is alive immediately), and wires `rollupOptions.input` + `triscope.labs`. If it warns about a missing dep: `npm i @triscope/core three`.
18
+
19
+ ## Step 2 — point it at YOUR content (decision tree)
20
+
21
+ Open `src/labs/<name>.ts` and replace the placeholder `getRoot()`.
22
+
23
+ **A) "I just want to look at / tweak it" → `runSceneView` (stop here for most cases).**
24
+ Wrap any `THREE.Object3D` (or a builder) in ~1 line. Cameras auto-fit from bounds; `autoKnobs:true` reflects named materials/lights into sliders; `set_uniform` tweaks anything else live.
25
+
26
+ ```ts
27
+ import { runSceneView } from '@triscope/core';
28
+ import { buildScene } from '../scene.js'; // your existing function returning a Group/Object3D
29
+ runSceneView(buildScene(), { name: 'myscene', autoKnobs: true });
30
+ ```
31
+
32
+ This unlocks `capture_views`, `inspect_scene` (with a `lights[]` array), `read_uniform`/`set_uniform`, and autoKnobs `set_knob`. Done.
33
+
34
+ **B) "I need curated camera angles / named knobs / telemetry" → promote to a full Element.**
35
+ Extract the scene build into `mount`, then add intent:
36
+ - **Name your meshes & lights** (`mesh.name = 'hull'`, `light.name = 'sun'`) — that's how `set_uniform`/autoKnobs/`inspect_scene` address them. Unnamed objects are reachable only by uuid.
37
+ - Choose 2–4 **intent** cameras (run `inspect_scene` first to read world positions/bounds), or keep `cameras: 'auto'`.
38
+ - Declare `knobs` + `onKnob` for the params you'll actually tune (auto-derived state isn't tunable unless exposed).
39
+ - Add `telemetry`/`motionProbes` only for hidden state you'll verify (FPS is automatic).
40
+
41
+ ```ts
42
+ // from runSceneView(buildScene()) ... to:
43
+ export const myElement: Element = {
44
+ name: 'myscene',
45
+ bounds: { min: [-2,-2,-2], max: [2,2,2] }, // or omit + cameras:'auto'
46
+ cameras: 'auto', // or hand-pick presets
47
+ knobs: { roughness: { type: 'number', min: 0, max: 1, step: 0.01, default: 0.5 } },
48
+ onKnob: (h, k, v) => { if (k === 'roughness') h.userData.mat.roughness = Number(v); },
49
+ telemetry: (h) => ({ tris: h.userData.triCount }),
50
+ mount: ({ parent }) => { const root = buildScene(); parent.add(root); return { root, dispose: () => parent.remove(root), userData: {} }; },
51
+ };
52
+ // then in the lab entry: runLab({ element: myElement, canvas, ... }) — or mountLabDom() for the DOM.
53
+ ```
54
+
55
+ ## Step 3 — iterate
56
+
57
+ Use **[[triscope-iteration-loop]]** for the edit → reload → telemetry/capture → adjust loop (don't re-derive it here). Key: after editing a `*.lab.ts`-style entry, `capture_views { fresh: true }` to pick up the change.
58
+
59
+ ## Gotchas (learned the hard way)
60
+
61
+ - **Name things you want to tune.** `set_uniform "sun.intensity"` / autoKnobs only work on NAMED objects.
62
+ - **TSL node-material params aren't reflectable** by autoKnobs — use `set_uniform` (it reaches any material prop by `objectName|uuid.key`).
63
+ - **If your object moves in world space** (physics/animation), fixed cameras lose it → capture goes empty (watch `flatFrames` in `capture_views`). Keep the render centered (treadmill) or use follow-cameras in the lab.
64
+ - **Non-Vite projects:** triscope's plugin is Vite-only; you'd need an equivalent dev-server middleware exposing `/__state`,`/__knob`,`/__manifest`,`/__scene`. Easiest path: a tiny Vite lab page that imports your built scene module.
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: glsl-pitfalls
3
+ description: TSL / WebGPU shader gotchas that cost half a day if you don't know them up front. Use when writing or editing TSL node materials, custom shaders, or debugging "my edit had no effect / the frame went black / the animation froze".
4
+ ---
5
+
6
+ # TSL / WebGPU pitfalls
7
+
8
+ ## 1. Material edits need a full reload, not HMR
9
+
10
+ A `THREE.Material` (especially a TSL `NodeMaterial`) is baked into the
11
+ renderer's node graph when the scene mounts. Re-running the module after an
12
+ edit does **not** reach the live material, so the edit looks invisible. The
13
+ triscope Vite plugin forces a full page reload for files matching
14
+ `(\.tsl|Element|Mesh|Material|Shader)\.(ts|tsx|js|mjs)$`. If your shader lives
15
+ in a file that doesn't match, rename it or widen `forceReloadOn` in
16
+ `vite.config`. Symptom: "I edited the shader and nothing changed."
17
+
18
+ ## 2. The TSL `time` node overwrites itself every render
19
+
20
+ `three/tsl`'s `time` is `uniform(0).onRenderUpdate((frame) => frame.time)` — it
21
+ re-reads `renderer.nodeFrame.time` on every render. So if you pause the RAF
22
+ loop and step `ctx.time.value` by hand (e.g. for deterministic capture), any
23
+ shader using `time` will look **frozen** unless you also set
24
+ `renderer.nodeFrame.time`. The harness's `captureMotionFrames('...', {mode:'time'})`
25
+ already does this; hand-rolled capture loops must too.
26
+
27
+ ## 3. Black frame ≠ dark scene
28
+
29
+ If `captureViews` reports `blackFrame: true` / `luminance < 0.004`, the GPU drew
30
+ nothing — a shader compile error, an unbound uniform, or a NaN, **not** a
31
+ lighting problem. Check the page console for WGSL compile errors before tuning
32
+ exposure. A single NaN in a position/normal computation discards the whole
33
+ primitive.
34
+
35
+ ## 4. Update uniforms, don't rebuild pipelines
36
+
37
+ Tune via `uniform.value = x` (what knobs + `onKnob` do) — this is live and
38
+ cheap. Re-creating the material or geometry rebuilds the WebGPU pipeline
39
+ (stutter, lost state, possible device churn). Reserve rebuilds for structural
40
+ changes only.
41
+
42
+ ## 5. Color space
43
+
44
+ Author colors in sRGB and let three convert: set `color.set('#rrggbb')` /
45
+ `THREE.ColorManagement` defaults. Mixing raw linear values with sRGB inputs is
46
+ the usual cause of "too dark" or "washed out" that screenshots can't diagnose —
47
+ confirm with the `luminance` / `dynamicRange` GPU probes, not your eyes.
48
+
49
+ ## 6. Clamp knob inputs you forward to shaders
50
+
51
+ The harness clamps declared knobs to their spec, but if you forward a value
52
+ into a shader that divides or `pow()`s by it, guard against 0 / negative /
53
+ huge values yourself — a wild uniform yields NaN → black frame (pitfall 3).
54
+
55
+ See also: [[threejs-telemetry-sink]], [[water-shader-convergence]].
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: threejs-telemetry-sink
3
+ description: The read-pixel-into-JSON pattern triscope is built on. Use when you need to verify hidden render state (FPS, uniforms, luminance, dynamic range) instead of squinting at a screenshot, or when adding telemetry() / motionProbes to an element.
4
+ ---
5
+
6
+ # Three.js telemetry sink (numbers beat screenshots)
7
+
8
+ Triscope's core bet: a still screenshot lies about hidden state (HDR tone,
9
+ exact uniform values, whether anything is animating, whether the GPU even
10
+ drew). **Numbers don't.** The harness publishes a JSON snapshot every ~500 ms
11
+ and exposes per-camera GPU probes on capture.
12
+
13
+ ## The two readback channels
14
+
15
+ 1. **Telemetry sink.** The harness POSTs a snapshot to `/__state`, which the
16
+ Vite plugin writes to `/tmp/<project>-state.json`. Read it with
17
+ `triscope state [<jq.path>]` or MCP `read_telemetry`. Contains:
18
+ `perf.fps`, `knobs`, per-camera `cameras.<name>.{position,target,fov}`,
19
+ `elements.<name>` (your `telemetry()` return), `elements.<name>.motion`
20
+ (motion-probe stats), `events`, `selection`/`selections` (inspect mode),
21
+ and **`warnings`** (recent swallowed failures — knob clamps, black frames,
22
+ telemetry/probe exceptions; check this first when something seems off).
23
+
24
+ 2. **GPU probes on capture.** `captureViews()` decodes each rendered pane and
25
+ reports per-camera `{ luminance, p5, p95, dynamicRange, blackFrame }`. A
26
+ `blackFrame: true` (or a camera in the response's `blackFrames[]`) means the
27
+ GPU drew nothing — that's a wiring/compile failure, not a dark scene.
28
+
29
+ ## Why `captureViews()` and not a normal screenshot
30
+
31
+ WebGPU renders into a swap-chain texture the spec implicitly destroys at
32
+ composite time (gpuweb/gpuweb#1781). So `canvas.toDataURL()` called out-of-band
33
+ and CDP `Page.captureScreenshot` both miss the surface and return black/empty.
34
+ The harness's `captureViews()` renders + reads back **inside the same task**,
35
+ which is the only reliable path. Always capture through it (MCP `capture_views`
36
+ does this for you).
37
+
38
+ ## Adding telemetry to an element
39
+
40
+ ```ts
41
+ telemetry: (handle, ctx) => ({
42
+ // anything JSON-serialisable; merged under elements.<name>
43
+ triangles: handle.userData.triCount,
44
+ exposure: handle.userData.uExposure.value,
45
+ }),
46
+
47
+ // per-frame scalars → ring-buffered stats (latest/mean/min/max/peakToPeak):
48
+ motionProbes: {
49
+ sailFlutter: (handle, ctx) => handle.userData.uWind.value * Math.sin(ctx.time.value * 4),
50
+ },
51
+ ```
52
+
53
+ ## The rule
54
+
55
+ - Hidden numeric/HDR state → **telemetry** (`read_telemetry`, GPU probes).
56
+ - Shape / composition / silhouette / occlusion → **`capture_views`**.
57
+ - "Is it actually animating?" → **`capture_motion`** or a `motionProbes`
58
+ `peakToPeak` > 0, never a single still.
59
+
60
+ See also: [[glsl-pitfalls]], [[water-shader-convergence]].
@@ -0,0 +1,249 @@
1
+ ---
2
+ name: triscope-iteration-loop
3
+ description: Use when iterating on a 3D element in this triscope project (shader tuning, mesh adjustments, lighting). Lays out the edit → reload → telemetry/capture → adjust loop with the right tools at each step. Prefer numbers over screenshots for hidden state; use capture_views to look at all camera angles in one call.
4
+ ---
5
+
6
+ # Triscope iteration loop
7
+
8
+ This project is built on triscope: every 3D element lives in
9
+ `src/elements/<name>.ts` and renders in its own multi-camera lab at
10
+ `/labs/<name>.html`. Each element declares cameras, knobs, and telemetry —
11
+ the framework handles the rest.
12
+
13
+ ## The loop
14
+
15
+ ```
16
+ edit src/elements/<name>.ts (e.g. drop roughness 0.4 → 0.2)
17
+
18
+ ▼ Vite HMR
19
+ browser remounts element in the lab grid
20
+
21
+ ▼ ~500 ms
22
+ /tmp/<project>-state.json updates
23
+
24
+ ▼ you ↓
25
+ triscope state .elements.<name> (numeric truth — FPS, uniforms)
26
+ mcp:capture_views <name> (visual truth — all N angles)
27
+
28
+ ▼ judge per-camera
29
+ triscope smoke <name> (CI/sign-off gate)
30
+ ```
31
+
32
+ ## Rules learned the hard way (from water3d sessions)
33
+
34
+ - **Numbers beat screenshots for hidden state.** JPEG compression and the
35
+ fact that a still can't show motion mean shader tuning by image alone is
36
+ unreliable. Use `triscope state` to pull FPS, uniform values, lum stats.
37
+ - **Screenshots still win for shape and composition.** Use `capture_views`
38
+ for layout, silhouette, glint placement, occlusion. Don't try to judge
39
+ HDR tone from a screenshot — use numbers.
40
+ - **Set knobs with absolute values, not deltas.** Say "drop roughness 0.4 →
41
+ 0.2", not "decrease roughness slightly". The MCP `set_knob` tool takes an
42
+ absolute value.
43
+ - **Verify per-camera.** A change can fix one pane and break another.
44
+ "CHASE CAM looks right now" is not "all 8 panes look right now". Walk
45
+ each named camera.
46
+ - **Reference photos beat memory.** If you have a reference image of what
47
+ the element should look like, drop it into `refs/<name>/<camera>.png` so
48
+ diffs are easy.
49
+
50
+ ## Tools in this loop
51
+
52
+ - `triscope state [<jq.path>]` — read /tmp/<project>-state.json
53
+ - `triscope list` — registered elements + cameras + knobs
54
+ - `triscope smoke [<element>]` — headed Chromium smoke test
55
+ - MCP `read_telemetry` — same data, accessible to Claude
56
+ - MCP `set_knob` — live update without reload (single or batched via {updates:[...]})
57
+ - MCP `capture_views` — render every named camera; inline PNGs in the tool response
58
+ - MCP `set_reference` / `diff_reference` — store a reference photo, get a side-by-side + meanAbsDiff
59
+ - MCP `capture_motion` — multi-frame filmstrips + motionMagnitude per camera (use for animated elements)
60
+ - MCP `run_smoke` — `triscope smoke` as a tool
61
+
62
+ ## When the element has motion
63
+
64
+ A single `capture_views` frame **cannot reveal whether motion is happening** —
65
+ sails could be at amplitude 0 just because you captured at the wrong phase, or
66
+ because windPressure isn't actually wired to deformation. Use these instead:
67
+
68
+ - **`capture_motion({ element, camera?, frames=6, dt=0.25, mode='time' })`** —
69
+ returns one filmstrip image per camera (6 frames tiled left-to-right) plus a
70
+ numeric `motionMagnitude[camera]` (0-255). Read the rule: <1 = static, >5 =
71
+ visible motion, >20 = vigorous. If you expect motion (windPressure > 0) and
72
+ magnitude is <1, the wiring is broken — not a tuning problem.
73
+ - **`read_telemetry .elements.<name>.motion`** — if the Element declared
74
+ `motionProbes`, each probe exposes `{ latest, mean, min, max, peakToPeak,
75
+ samples: lastN }`. peakToPeak ≈ 0 with non-zero input means the probe isn't
76
+ changing — the animation isn't propagating to the state you're measuring.
77
+ - **Mode choice.** `mode: 'time'` is deterministic (steps `time.value`
78
+ forward, ~instant) — use it for shader-driven motion (TSL uniforms, vertex
79
+ displacement keyed to `ctx.time`). `mode: 'real'` waits dt seconds between
80
+ frames — use it for CPU-integrated state (springs, particles).
81
+
82
+ ## Editing shaders / TSL materials: full-reload, not HMR
83
+
84
+ The triscope vite plugin forces a **full page reload** instead of HMR when
85
+ files matching `(\.tsl|Element|Mesh|Material|Shader)\.(ts|tsx|js|mjs)$`
86
+ change. Reason: TSL materials end up baked into the renderer's node graph
87
+ when the scene mounts, so re-running the module after an edit has no
88
+ effect on the running THREE.Material — the new code never reaches the
89
+ renderer. Full-reload is the only reliable way to see shader edits.
90
+
91
+ Cost: ~1-2 s vs. ~50 ms HMR. Acceptable trade — the failure mode (edits
92
+ silently invisible) costs much more. Plain `.ts` files outside the
93
+ pattern still HMR normally.
94
+
95
+ To override the pattern or disable: pass `forceReloadOn` to the plugin in
96
+ your `vite.config.ts`:
97
+
98
+ ```ts
99
+ triscopeTelemetryPlugin({
100
+ forceReloadOn: /custom-pattern/i, // or pass null to disable entirely
101
+ })
102
+ ```
103
+
104
+ ## Cold-start manifest: declare your labs in package.json
105
+
106
+ If your lab pages don't follow the `/labs/<element>.html` convention,
107
+ declare them in `package.json` so `mcp__triscope__capture_views` works
108
+ on the first call without `labUrl`:
109
+
110
+ ```json
111
+ "triscope": {
112
+ "labs": {
113
+ "ship": "/triscope-ship.html",
114
+ "ocean": "/triscope-ocean.html"
115
+ }
116
+ }
117
+ ```
118
+
119
+ The vite plugin reads this at boot and seeds `/__manifest` so the MCP
120
+ URL resolver returns the right URL immediately.
121
+
122
+ ## Multi-element scenes: runSceneLab + live add/remove
123
+
124
+ Two lab shapes:
125
+
126
+ - **`runLab(element)`** — the single-element lab: one element, **bare** camera/knob
127
+ names (`bow`, `windPressure`). Most `/labs/<name>.html` pages use this; nothing
128
+ about it changed.
129
+ - **`runSceneLab({ elements: [a, b, …], mounted? })`** — a SCENE of N elements in
130
+ one lab grid. Cameras and knobs are **namespaced** `<element>.<key>`, so two
131
+ elements that both declare `top`/`speed` never collide. Compose ship + sea + sky
132
+ together and tune them in one page.
133
+
134
+ In a scene you can instantiate or dispose an element **type** *live, with no
135
+ reload* — the thing a plain edit-reload loop can't do for a brand-new element:
136
+
137
+ - `mcp__triscope__add_element name=<el> element=<any-mounted-el>` — mount a
138
+ registered-but-unmounted element. Its `<el>.<camera>` panes appear in the grid,
139
+ its `<el>.<knob>` knobs appear in the editor, the manifest is re-advertised.
140
+ Returns `{ ok, mounted:[…], available:[…] }`.
141
+ - `mcp__triscope__remove_element name=<el> element=<any-mounted-el>` — dispose it
142
+ live; it stays in the registry and is re-addable.
143
+
144
+ **Rules for scenes (learned from the rewrite):**
145
+ - **Use the namespaced key.** `list_elements` / the manifest advertise
146
+ `ship.windPressure`, `ship.bow`. Use those with `set_knob` / `capture_views` /
147
+ `auto_tune`. (set_knob and the tuners also accept the bare local key as a
148
+ fallback, but the `element.key` form is canonical and avoids surprises.)
149
+ - **add/remove need a page locator.** A scene usually lives on its own page (e.g.
150
+ `/lab.html`). Pass `element=<a mounted element>` (or `labUrl=…`) so the tool
151
+ attaches to the right tab — otherwise it targets the default lab and returns
152
+ `{ ok:false }`.
153
+ - **Single-element labs ignore these.** `add_element`/`remove_element` on a plain
154
+ `runLab` page return `{ ok:false }`: nothing to add, and removing the only
155
+ element is blocked on purpose.
156
+ - **`available` minus `mounted`** (in any add/remove response, or `list_elements`)
157
+ is exactly what you can still mount.
158
+
159
+ ## Reactive loop (optional): the PostToolUse hook
160
+
161
+ `.claude/hooks.example.json` is a ready-to-paste hook config that wires
162
+ `triscope auto-capture` to run after every Edit/Write. The effect: the next
163
+ message to Claude includes a line like
164
+
165
+ `[triscope] ship motion: sailWanderEnvelope p2p=0.59 freq=0.22Hz`
166
+
167
+ so Claude sees current FPS + probe activity automatically, without calling
168
+ `capture_views` or `read_telemetry`. If you edit shader code and the next
169
+ turn shows `p2p≈0` for a probe that was non-zero before, you broke motion.
170
+ To enable: copy the `"hooks"` block from `.claude/hooks.example.json` into
171
+ your `.claude/settings.local.json`.
172
+
173
+ ## Inspect mode: click-to-select sub-meshes (no grep)
174
+
175
+ Open a lab with `?inspect=<element>` and the harness flips to a single
176
+ full-canvas camera with OrbitControls. Right-drag to orbit, scroll to
177
+ zoom, **left-click on a part of the mesh** to lock a selection. The
178
+ selection lands in `telemetry.selection` with the *exact source file
179
+ and line* where that mesh was added to the scene — no grep needed.
180
+
181
+ **From chat:** "ispeziona la nave" → Claude calls
182
+ `mcp__triscope__inspect element=ship`. The running browser flips into
183
+ inspect mode. The user clicks a sail; the next message has
184
+ `selection.source = { file: 'PirateShipMesh.ts', line: 1415, ... }`.
185
+
186
+ **Open the file at line:** `mcp__triscope__open_selection` reads
187
+ `telemetry.selection.source` and spawns `code --goto file:line:col` (or
188
+ honors $EDITOR). Use after the user says "open this" / "show me the
189
+ code". Sub-second: editor jumps to the right spot.
190
+
191
+ How it works without code changes: triscope monkey-patches
192
+ `Object3D.prototype.add` at runtime and tags every added object with
193
+ the user-code stack frame from `new Error().stack`. Element authors do
194
+ not modify their code — existing meshes get tagged on the next reload.
195
+ Vite source-maps make the frames resolve to original `.ts` files in
196
+ dev. The selection survives full-reload via localStorage (matched by
197
+ file:line, not Mesh UUID).
198
+
199
+ ## Auto-tune: converge a knob on a reference
200
+
201
+ When you have a stored reference image for `(element, target_camera)`
202
+ and want to find the knob value that matches it visually:
203
+
204
+ ```
205
+ mcp__triscope__auto_tune element=ship knob=windPressure
206
+ range=[0,2] target_camera=bow max_iterations=12
207
+ ```
208
+
209
+ Golden-section search over the range, maximising SSIM (perceptual
210
+ similarity) against the reference. Each iteration: set_knob → 800ms
211
+ wait → captureViews → diff_reference. Converges to ~0.7% of the range
212
+ in 12 iters. Returns best knob value, final SSIM, full history. Leaves
213
+ the lab at the converged value so you see the result.
214
+
215
+ SSIM > meanAbsDiff as the objective: pixel-level diff chases anti-
216
+ aliasing noise and rewards "darken everything" as fake convergence;
217
+ SSIM tracks actual structural match.
218
+
219
+ ## Snapshot / restore: cheap rollback via git tags
220
+
221
+ When a tuning pass lands somewhere good and you want to checkpoint
222
+ before a risky rewrite:
223
+
224
+ ```
225
+ mcp__triscope__snapshot name=ship-mast-pass-v3 message="happy w/ sail bulge"
226
+ ```
227
+
228
+ Refuses on a dirty WT (would silently lose your in-progress edits on
229
+ restore). Otherwise creates an annotated tag `triscope/snapshot/<name>`
230
+ whose message stores: HEAD commit + every persisted knob value across
231
+ all elements, as JSON. No working-tree files written, no rebase noise.
232
+
233
+ Restore later:
234
+ ```
235
+ mcp__triscope__restore name=ship-mast-pass-v3
236
+ ```
237
+ Checks out the commit (detached HEAD — branch from there if you want
238
+ to keep iterating) and re-posts every knob via `/__knob`. The live lab
239
+ snaps back to the recorded state within ~100 ms.
240
+
241
+ List what you have: `mcp__triscope__list_snapshots`.
242
+
243
+ ## See also
244
+
245
+ - [[threejs-telemetry-sink]] — the read-pixel-into-JSON pattern triscope is built on.
246
+ - [[water-shader-convergence]] — for sea/water elements, the
247
+ Tessendorf/Bruneton/Sea-of-Thieves checklist.
248
+ - [[glsl-pitfalls]] — when writing TSL/GLSL, the gotchas that cost half a
249
+ day if you don't know about them up front.
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: water-shader-convergence
3
+ description: Checklist for converging a believable ocean/water element (waves, foam, refraction, glint, tone). Opt-in — use only when iterating on a sea/water/fluid element. Order the passes; verify each with telemetry + per-camera capture before moving on.
4
+ ---
5
+
6
+ # Water / ocean shader convergence
7
+
8
+ Water is the element where "tune by screenshot" fails hardest: foam, glint, and
9
+ HDR tone all read wrong in a still. Converge in this order, locking each pass
10
+ with numbers (`read_telemetry`, GPU `luminance`/`dynamicRange`) and a
11
+ multi-angle `capture_views` before touching the next.
12
+
13
+ ## 1. Geometry / wave field first
14
+
15
+ - Choppiness / steepness and macro amplitude set the silhouette. Get the
16
+ horizon line and wave scale right at a grazing camera **before** any
17
+ shading — a `motionProbes` peak-to-peak confirms the surface is actually
18
+ displacing (a flat `peakToPeak ≈ 0` with non-zero wind = broken wiring, not a
19
+ tuning problem).
20
+ - FFT (Tessendorf) vs sum-of-Gerstner: FFT gives richer detail but watch the
21
+ tiling period; Gerstner is cheaper and easier to art-direct.
22
+
23
+ ## 2. Normals / detail
24
+
25
+ - Normal-map scale and the cascade of detail frequencies drive sparkle. Too
26
+ strong → noisy glitter that pixel-diff metrics chase (use SSIM, not
27
+ meanAbsDiff, when converging on a reference).
28
+
29
+ ## 3. Refraction / depth absorption / scattering
30
+
31
+ - Depth-absorption tint and scattering give water its body. Verify caustics /
32
+ seafloor legibility through the tinted column at a top-down camera.
33
+
34
+ ## 4. Foam
35
+
36
+ - Foam threshold + strength, keyed to wave Jacobian / crest steepness. Check it
37
+ reads at distance (drone/chase cameras), not just close up.
38
+
39
+ ## 5. Sun glint + sky + tone last
40
+
41
+ - Glint width/intensity from sun azimuth/elevation; then exposure /
42
+ tonemapping. Judge HDR with the `luminance`, `p95`, and `dynamicRange` GPU
43
+ probes — a screenshot cannot show clipping. Aim for `dynamicRange` well above
44
+ 1 (real contrast) without `p95` pinned at 1.0 (blown highlights).
45
+
46
+ ## Knobs worth exposing
47
+
48
+ choppiness, macroAmplitude, normalScale, foamThreshold, foamStrength,
49
+ depthAbsorption, scatterIntensity, sunAzimuth, sunElevation, glintWidth,
50
+ exposure, envMapIntensity. Keep them nested/named per your element — triscope
51
+ imposes no flat schema.
52
+
53
+ ## Convergence loop
54
+
55
+ When you have a reference photo for a camera, `set_reference` then `auto_tune`
56
+ (SSIM, golden-section) converges a single knob hands-free; for several coupled
57
+ knobs, tune them in the pass order above rather than all at once.
58
+
59
+ See also: [[threejs-telemetry-sink]], [[glsl-pitfalls]].
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ .vite/
4
+ *.log
5
+ .DS_Store
6
+ /tmp-*/
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>__PROJECT_NAME__</title>
6
+ <style>
7
+ body { font: 14px/1.5 ui-monospace, monospace; background: #0a1218; color: #d2dee5; padding: 24px; max-width: 720px; }
8
+ a { color: #8fc7d9; }
9
+ code { background: rgba(255,255,255,0.06); padding: 1px 5px; border-radius: 3px; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <h1>__PROJECT_NAME__</h1>
14
+ <p>A triscope project. Open a lab page to start iterating:</p>
15
+ <ul>
16
+ <li><a href="/labs/cube.html">/labs/cube.html</a> — example rotating cube</li>
17
+ </ul>
18
+ <p>From a terminal:</p>
19
+ <pre><code>npx triscope state .perf.fps
20
+ npx triscope list
21
+ npx triscope smoke cube</code></pre>
22
+ </body>
23
+ </html>
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>__PROJECT_NAME__ · cube lab</title>
6
+ <style>
7
+ html, body { margin: 0; padding: 0; overflow: hidden; background: #000; color: #cfd6db; font-family: ui-monospace, monospace; }
8
+ #app { position: fixed; inset: 0; }
9
+ canvas { display: block; width: 100%; height: 100%; }
10
+ .triscope-label { position: absolute; background: rgba(0,0,0,.55); color: #cfd6db; padding: 4px 8px; font-size: 11px; font-weight: 600; letter-spacing: .05em; pointer-events: none; border-radius: 3px; z-index: 5; }
11
+ #hud { position: fixed; bottom: 8px; left: 8px; z-index: 10; font-size: 11px; padding: 4px 8px; background: rgba(0,0,0,.55); border-radius: 3px; }
12
+ #boot { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: #0a1a20; color: #cfd6db; font-size: 14px; z-index: 50; }
13
+ #lab-controls { position: fixed; right: 8px; bottom: 8px; z-index: 20; width: min(320px, calc(100vw - 24px)); padding: 10px 12px; background: rgba(5,12,16,.78); border: 1px solid rgba(210,230,240,.16); border-radius: 6px; box-sizing: border-box; }
14
+ .triscope-editor__row { display: grid; grid-template-columns: 110px 1fr 56px; align-items: center; gap: 6px; font-size: 11px; margin: 4px 0; }
15
+ .triscope-editor__row input { width: 100%; accent-color: #8fc7d9; }
16
+ .triscope-editor__row output { text-align: right; color: #f0f5f7; }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <div id="boot">Initialising Triscope · WebGPU...</div>
21
+ <div id="app"><canvas id="canvas"></canvas></div>
22
+ <div id="hud">- fps · WebGPU</div>
23
+ <div id="lab-controls"></div>
24
+ <script type="module" src="/src/labs/cube.ts"></script>
25
+ </body>
26
+ </html>
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "preview": "vite preview",
9
+ "state": "triscope state",
10
+ "list": "triscope list",
11
+ "smoke": "node smoke.mjs"
12
+ },
13
+ "dependencies": {
14
+ "@triscope/core": "^__TRISCOPE_VERSION__",
15
+ "three": "^0.176.0"
16
+ },
17
+ "devDependencies": {
18
+ "@triscope/cli": "^__TRISCOPE_VERSION__",
19
+ "vite": "^5.4.0",
20
+ "typescript": "^5.6.0",
21
+ "ws": "^8.18.0"
22
+ }
23
+ }
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * End-to-end smoke test for your triscope project.
4
+ *
5
+ * Boots vite, drives Chromium with WebGPU via CDP, asks the harness to render,
6
+ * and asserts the loop is alive:
7
+ * 1. window.__TRISCOPE__ mounts within 30s.
8
+ * 2. The element declares at least one camera.
9
+ * 3. Telemetry reports fps > 1 (the RAF loop is stepping).
10
+ * 4. captureViews() returns a non-empty PNG per camera (the only reliable
11
+ * WebGPU readback path — see README), and no camera rendered black.
12
+ * 5. A knob change propagates into telemetry (best-effort — warns, doesn't
13
+ * fail, if your element has no plain `number` knob to flip).
14
+ *
15
+ * Exit 0 on pass, non-zero with a structured JSON error on failure.
16
+ *
17
+ * node smoke.mjs # smoke the default element ("cube")
18
+ * node smoke.mjs myElement # smoke /labs/myElement.html
19
+ *
20
+ * This file is yours to edit — tighten the assertions for your element
21
+ * (expected camera count, specific knob → telemetry checks, motion probes).
22
+ * Honors $CHROME_BIN / $PUPPETEER_EXECUTABLE_PATH (falls back to `chromium`).
23
+ */
24
+ import { spawn } from 'node:child_process';
25
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26
+ import { tmpdir } from 'node:os';
27
+ import { dirname, join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const HERE = dirname(fileURLToPath(import.meta.url));
31
+ const ELEMENT = process.argv[2] || 'cube';
32
+ // Random high port so we never collide with a dev server already on 5173.
33
+ const SMOKE_PORT = 5300 + Math.floor(Math.random() * 100);
34
+ const BASE = `http://127.0.0.1:${SMOKE_PORT}/`;
35
+ const TARGET = `${BASE}labs/${ELEMENT}.html`;
36
+ const DEBUG_PORT = 9244;
37
+ const OUT = join(tmpdir(), `triscope-smoke-${ELEMENT}`);
38
+ const CHROME = process.env.CHROME_BIN ?? process.env.PUPPETEER_EXECUTABLE_PATH ?? 'chromium';
39
+
40
+ const PROJECT = readProjectName();
41
+ const STATE_FILES = [
42
+ join(tmpdir(), `${PROJECT}-state.json`),
43
+ join(tmpdir(), `${PROJECT.replace(/[^A-Za-z0-9._-]/g, '-')}-state.json`),
44
+ ];
45
+
46
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
47
+
48
+ function readProjectName() {
49
+ try {
50
+ const pkg = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'));
51
+ return String(pkg.name ?? 'triscope-project');
52
+ } catch {
53
+ return 'triscope-project';
54
+ }
55
+ }
56
+
57
+ function readState() {
58
+ for (const p of STATE_FILES) {
59
+ if (existsSync(p)) {
60
+ try {
61
+ return JSON.parse(readFileSync(p, 'utf8'));
62
+ } catch {}
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ function fail(stage, detail) {
69
+ console.error(JSON.stringify({ ok: false, stage, detail }, null, 2));
70
+ process.exit(1);
71
+ }
72
+
73
+ function cdpFactory(ws, consoleLog) {
74
+ const pending = new Map();
75
+ let id = 0;
76
+ ws.onmessage = (e) => {
77
+ const m = JSON.parse(e.data);
78
+ if (m.id && pending.has(m.id)) {
79
+ pending.get(m.id)(m);
80
+ pending.delete(m.id);
81
+ return;
82
+ }
83
+ if (consoleLog && m.method === 'Runtime.exceptionThrown') {
84
+ consoleLog.push(`[exception] ${m.params?.exceptionDetails?.text ?? ''}`);
85
+ }
86
+ };
87
+ return (method, params = {}) =>
88
+ new Promise((resolve, reject) => {
89
+ const n = ++id;
90
+ ws.send(JSON.stringify({ id: n, method, params }));
91
+ const t = setTimeout(() => {
92
+ pending.delete(n);
93
+ reject(new Error(`cdp timeout: ${method}`));
94
+ }, 20000);
95
+ pending.set(n, (m) => {
96
+ clearTimeout(t);
97
+ if (m.error) reject(new Error(`${method}: ${m.error.message}`));
98
+ else resolve(m);
99
+ });
100
+ });
101
+ }
102
+
103
+ async function waitForServer() {
104
+ const start = Date.now();
105
+ while (Date.now() - start < 45000) {
106
+ try {
107
+ if ((await fetch(BASE)).ok) return;
108
+ } catch {}
109
+ await wait(250);
110
+ }
111
+ throw new Error(`vite dev server never became reachable on ${BASE}`);
112
+ }
113
+
114
+ async function main() {
115
+ mkdirSync(OUT, { recursive: true });
116
+ for (const p of STATE_FILES) {
117
+ try {
118
+ rmSync(p, { force: true });
119
+ } catch {}
120
+ }
121
+
122
+ // 1. Boot vite on a strict random port (detached so finally{} kills the tree).
123
+ // --host 127.0.0.1 so the bind matches the IPv4 address we poll/attach on
124
+ // (Vite 5 otherwise binds localhost/::1 and 127.0.0.1 can refuse).
125
+ const vite = spawn(
126
+ 'npx',
127
+ ['--no-install', 'vite', '--port', String(SMOKE_PORT), '--strictPort', '--host', '127.0.0.1'],
128
+ {
129
+ cwd: HERE,
130
+ env: { ...process.env, BROWSER: 'none' },
131
+ stdio: ['ignore', 'pipe', 'pipe'],
132
+ detached: true,
133
+ },
134
+ );
135
+ let viteLog = '';
136
+ vite.stdout.on('data', (c) => (viteLog += c));
137
+ vite.stderr.on('data', (c) => (viteLog += c));
138
+
139
+ let chrome = null;
140
+ try {
141
+ await waitForServer();
142
+
143
+ // 2. Boot Chromium with CDP + WebGPU. Headed by default (WebGPU in headless
144
+ // Chrome is unreliable on Linux); SMOKE_HEADLESS=1 opts into headless.
145
+ const profileDir = join(tmpdir(), `triscope-smoke-${Date.now()}`);
146
+ const headlessArgs = process.env.SMOKE_HEADLESS
147
+ ? ['--headless=new', '--use-angle=vulkan', '--enable-features=Vulkan']
148
+ : ['--ozone-platform=x11'];
149
+ chrome = spawn(
150
+ CHROME,
151
+ [
152
+ ...headlessArgs,
153
+ '--enable-unsafe-webgpu',
154
+ '--ignore-gpu-blocklist',
155
+ `--user-data-dir=${profileDir}`,
156
+ `--remote-debugging-port=${DEBUG_PORT}`,
157
+ '--window-size=1600,900',
158
+ TARGET,
159
+ ],
160
+ { stdio: 'ignore' },
161
+ );
162
+
163
+ // 3. Attach CDP to the lab tab.
164
+ let page = null;
165
+ const start = Date.now();
166
+ while (Date.now() - start < 45000) {
167
+ try {
168
+ const pages = await fetch(`http://127.0.0.1:${DEBUG_PORT}/json`).then((r) => r.json());
169
+ page = Array.isArray(pages)
170
+ ? pages.find((p) => p.type === 'page' && p.url?.startsWith(BASE))
171
+ : null;
172
+ if (page) break;
173
+ } catch {}
174
+ await wait(250);
175
+ }
176
+ if (!page) fail('cdp-attach', { error: 'lab tab never appeared within 15s', viteLog });
177
+
178
+ const { default: WebSocketCtor } = await import('ws')
179
+ .then((m) => ({ default: m.WebSocket }))
180
+ .catch(() => ({ default: globalThis.WebSocket }));
181
+ const ws = new WebSocketCtor(page.webSocketDebuggerUrl);
182
+ await new Promise((res, rej) => {
183
+ ws.onopen = res;
184
+ ws.onerror = (e) => rej(new Error(`ws open failed: ${e?.message ?? e}`));
185
+ });
186
+ const consoleLog = [];
187
+ const call = cdpFactory(ws, consoleLog);
188
+ await call('Runtime.enable');
189
+
190
+ // 4. Wait for the harness to mount.
191
+ let mounted = false;
192
+ for (let i = 0; i < 60; i++) {
193
+ const p = await call('Runtime.evaluate', {
194
+ expression: '!!(window.__TRISCOPE__ && window.__TRISCOPE__.captureViews)',
195
+ returnByValue: true,
196
+ });
197
+ if (p.result.result.value) {
198
+ mounted = true;
199
+ break;
200
+ }
201
+ await wait(500);
202
+ }
203
+ if (!mounted) {
204
+ const boot = await call('Runtime.evaluate', {
205
+ expression: 'document.getElementById("boot")?.textContent ?? ""',
206
+ returnByValue: true,
207
+ });
208
+ fail('mount', {
209
+ error: 'window.__TRISCOPE__ never appeared within 30s',
210
+ bootMsg: boot.result.result.value,
211
+ viteLog,
212
+ consoleAll: consoleLog,
213
+ });
214
+ }
215
+
216
+ // 5. Wait for telemetry with fps > 1.
217
+ let tel = null;
218
+ const t0 = Date.now();
219
+ while (Date.now() - t0 < 15000) {
220
+ tel = readState();
221
+ if (tel?.perf?.fps > 1) break;
222
+ await wait(200);
223
+ }
224
+ if (!(tel?.perf?.fps > 1)) {
225
+ fail('telemetry-warmup', {
226
+ error: 'no telemetry with fps>1 within 15s',
227
+ tel,
228
+ consoleAll: consoleLog,
229
+ });
230
+ }
231
+
232
+ const camNames = await call('Runtime.evaluate', {
233
+ expression: 'Object.keys(window.__TRISCOPE__.cameras ?? {})',
234
+ returnByValue: true,
235
+ });
236
+ const cameras = camNames.result.result.value ?? [];
237
+ if (cameras.length < 1) fail('manifest', { error: 'element declares no cameras', cameras });
238
+
239
+ // 6. captureViews — the only reliable WebGPU readback path. Asserts each
240
+ // camera produced bytes; collects black-frame flags from the harness.
241
+ const viewsResp = await call('Runtime.evaluate', {
242
+ expression: 'window.__TRISCOPE__.captureViews()',
243
+ awaitPromise: true,
244
+ returnByValue: true,
245
+ });
246
+ const views = viewsResp.result.result.value ?? {};
247
+ let captured = 0;
248
+ let fullSize = 0;
249
+ for (const cam of Object.keys(views)) {
250
+ const dataUrl = views[cam];
251
+ if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) continue;
252
+ const buf = Buffer.from(dataUrl.slice('data:image/png;base64,'.length), 'base64');
253
+ writeFileSync(join(OUT, `${cam}.png`), buf);
254
+ captured += 1;
255
+ if (buf.readUInt32BE(16) >= 200 && buf.readUInt32BE(20) >= 100) fullSize += 1;
256
+ }
257
+ if (captured === 0) fail('capture', { error: 'captureViews returned no PNGs', cameras });
258
+
259
+ // Black-frame check via the harness GPU probes (full-size captures only).
260
+ const probesResp = await call('Runtime.evaluate', {
261
+ expression: 'JSON.stringify(window.__TRISCOPE__.lastGpuProbes ?? {})',
262
+ returnByValue: true,
263
+ });
264
+ const gpuProbes = JSON.parse(probesResp.result.result.value || '{}');
265
+ const black = Object.entries(gpuProbes).filter(
266
+ ([, s]) => s?.blackFrame || s?.luminance < 0.004,
267
+ );
268
+ // Render-quality is hard-gated only when HEADED — headless WebGPU readback
269
+ // is unreliable on Linux (can return black frames). Under headless we still
270
+ // proved the chain (mount + cameras + captureViews returned bytes).
271
+ if (!process.env.SMOKE_HEADLESS && fullSize > 0 && black.length > 0) {
272
+ fail('black-frame', {
273
+ error: `camera(s) rendered black: ${black.map(([c]) => c).join(', ')}`,
274
+ gpuProbes,
275
+ });
276
+ }
277
+
278
+ // 7. Best-effort knob propagation: flip the first plain number knob and
279
+ // confirm telemetry reflects it. Soft — warns if the element has none.
280
+ let knobCheck = { skipped: true };
281
+ const manifest = await fetch(`${BASE}__manifest`)
282
+ .then((r) => r.json())
283
+ .catch(() => null);
284
+ const knobs = manifest?.elements?.[ELEMENT]?.knobs ?? [];
285
+ const numberKnob = knobs.find((k) => k.type === 'number');
286
+ if (numberKnob) {
287
+ const targetVal = +(((numberKnob.min ?? 0) + (numberKnob.max ?? 1)) / 2).toFixed(3);
288
+ await fetch(`${BASE}__knob`, {
289
+ method: 'POST',
290
+ headers: { 'content-type': 'application/json' },
291
+ body: JSON.stringify([{ element: ELEMENT, key: numberKnob.name, value: targetVal }]),
292
+ });
293
+ await wait(1200); // knob poll (100ms) + telemetry tick (500ms) + margin
294
+ const after = readState();
295
+ const got = after?.elements?.[ELEMENT]?.[numberKnob.name];
296
+ knobCheck = { knob: numberKnob.name, set: targetVal, got };
297
+ if (typeof got === 'number' && Math.abs(got - targetVal) > 0.05) {
298
+ fail('knob-propagation', {
299
+ ...knobCheck,
300
+ error: 'telemetry did not reflect the knob change',
301
+ });
302
+ }
303
+ }
304
+
305
+ const warnings = readState()?.warnings ?? [];
306
+ console.log(
307
+ JSON.stringify(
308
+ {
309
+ ok: true,
310
+ element: ELEMENT,
311
+ cameras: cameras.length,
312
+ captured,
313
+ fullSize,
314
+ fps: tel.perf.fps,
315
+ knobCheck,
316
+ warnings,
317
+ outDir: OUT,
318
+ },
319
+ null,
320
+ 2,
321
+ ),
322
+ );
323
+ } finally {
324
+ if (chrome && !chrome.killed) chrome.kill();
325
+ if (!vite.killed) {
326
+ try {
327
+ process.kill(-vite.pid, 'SIGTERM');
328
+ } catch {}
329
+ vite.kill('SIGTERM');
330
+ }
331
+ }
332
+ }
333
+
334
+ main().catch((e) => {
335
+ console.error(
336
+ JSON.stringify({ ok: false, stage: 'unhandled', error: String(e?.stack ?? e) }, null, 2),
337
+ );
338
+ process.exit(1);
339
+ });
@@ -0,0 +1,90 @@
1
+ import type { Element } from '@triscope/core';
2
+ import * as THREE from 'three/webgpu';
3
+
4
+ /**
5
+ * Example triscope element: a rotating PBR cube with three exposed knobs.
6
+ * Replace this with your own element(s).
7
+ */
8
+ interface CubeUserData {
9
+ mesh: THREE.Mesh;
10
+ material: THREE.MeshStandardMaterial;
11
+ spin: number;
12
+ }
13
+
14
+ export const cube: Element = {
15
+ name: 'cube',
16
+ bounds: { min: [-1, -1, -1], max: [1, 1, 1] },
17
+
18
+ mount: ({ parent, ctx }) => {
19
+ const sun = new THREE.DirectionalLight(0xffffff, 1.4);
20
+ sun.position.set(2, 3, 1);
21
+ parent.add(sun);
22
+ const amb = new THREE.AmbientLight(0xb0c0ce, 0.4);
23
+ parent.add(amb);
24
+
25
+ const geo = new THREE.BoxGeometry(1, 1, 1);
26
+ const mat = new THREE.MeshStandardMaterial({
27
+ color: '#d8a85a',
28
+ roughness: 0.4,
29
+ metalness: 0.1,
30
+ });
31
+ const mesh = new THREE.Mesh(geo, mat);
32
+ parent.add(mesh);
33
+
34
+ const userData: CubeUserData = { mesh, material: mat, spin: 0.8 };
35
+
36
+ const tick = () => {
37
+ mesh.rotation.y += ctx.dt.value * userData.spin;
38
+ mesh.rotation.x = Math.sin(ctx.time.value * 0.3) * 0.2;
39
+ requestAnimationFrame(tick);
40
+ };
41
+ requestAnimationFrame(tick);
42
+
43
+ return {
44
+ root: mesh,
45
+ userData: userData as unknown as Record<string, unknown>,
46
+ dispose: () => {
47
+ parent.remove(mesh);
48
+ parent.remove(sun);
49
+ parent.remove(amb);
50
+ geo.dispose();
51
+ mat.dispose();
52
+ },
53
+ };
54
+ },
55
+
56
+ cameras: {
57
+ front: { position: [0, 0, 3], target: [0, 0, 0] },
58
+ back: { position: [0, 0, -3], target: [0, 0, 0] },
59
+ left: { position: [-3, 0, 0], target: [0, 0, 0] },
60
+ right: { position: [3, 0, 0], target: [0, 0, 0] },
61
+ top: { position: [0, 3, 0], target: [0, 0, 0] },
62
+ 'three-quarter': { position: [2, 2, 2], target: [0, 0, 0] },
63
+ },
64
+
65
+ knobs: {
66
+ color: { type: 'color', default: '#d8a85a', label: 'color' },
67
+ roughness: { type: 'number', min: 0, max: 1, step: 0.01, default: 0.4, label: 'roughness' },
68
+ metalness: { type: 'number', min: 0, max: 1, step: 0.01, default: 0.1, label: 'metalness' },
69
+ spin: { type: 'number', min: -3, max: 3, step: 0.05, default: 0.8, label: 'spin (rad/s)' },
70
+ },
71
+
72
+ onKnob: (handle, key, value) => {
73
+ const u = handle.userData as unknown as CubeUserData;
74
+ if (key === 'color') u.material.color.set(String(value));
75
+ else if (key === 'roughness') u.material.roughness = Number(value);
76
+ else if (key === 'metalness') u.material.metalness = Number(value);
77
+ else if (key === 'spin') u.spin = Number(value);
78
+ },
79
+
80
+ telemetry: (handle) => {
81
+ const u = handle.userData as unknown as CubeUserData;
82
+ return {
83
+ rotationY: u.mesh.rotation.y,
84
+ color: '#' + u.material.color.getHexString(),
85
+ roughness: u.material.roughness,
86
+ metalness: u.material.metalness,
87
+ spin: u.spin,
88
+ };
89
+ },
90
+ };
@@ -0,0 +1,25 @@
1
+ import { runLab } from '@triscope/core';
2
+ import { cube } from '../elements/cube';
3
+
4
+ async function boot() {
5
+ const canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
6
+ const boot = document.getElementById('boot');
7
+ const hud = document.getElementById('hud');
8
+ const labels = document.getElementById('app');
9
+ const editor = document.getElementById('lab-controls');
10
+ if (!canvas) return;
11
+ try {
12
+ await runLab({
13
+ element: cube,
14
+ canvas,
15
+ hud,
16
+ labelContainer: labels,
17
+ editorContainer: editor,
18
+ bootOverlay: boot,
19
+ });
20
+ } catch (err: any) {
21
+ if (boot) boot.textContent = `Init failed: ${err?.message ?? err}`;
22
+ console.error(err);
23
+ }
24
+ }
25
+ boot();
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "strict": false,
8
+ "skipLibCheck": true,
9
+ "isolatedModules": true,
10
+ "noEmit": true
11
+ },
12
+ "include": ["src", "vite.config.js"]
13
+ }
@@ -0,0 +1,33 @@
1
+ import { triscopeTelemetryPlugin } from '@triscope/core/vite';
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig({
5
+ plugins: [
6
+ // The triscope telemetry sink. Adds dev-server routes the harness talks to:
7
+ // POST /__state → writes /tmp/<project>-state.json (read by `triscope
8
+ // state` / MCP read_telemetry)
9
+ // POST /__knob → queues live knob changes (MCP set_knob)
10
+ // GET /__manifest→ the registered elements/cameras/knobs
11
+ // Options (all optional):
12
+ // project string — name used for the /tmp state files. Default:
13
+ // package.json#name (sanitized).
14
+ // forceReloadOn RegExp|null — files that trigger a full page reload
15
+ // instead of HMR (TSL materials can't hot-swap). Default
16
+ // matches /(\.tsl|Element|Mesh|Material|Shader)\.(ts|tsx|js|mjs)$/i.
17
+ // Pass null to disable; pass your own RegExp to customize.
18
+ triscopeTelemetryPlugin(),
19
+ ],
20
+ server: { port: 5173, open: false },
21
+ build: {
22
+ target: 'es2022',
23
+ sourcemap: true,
24
+ rollupOptions: {
25
+ // One entry per lab page. `triscope init --preset` and `triscope new`
26
+ // add more here; keep `index` and add `<name>: 'labs/<name>.html'`.
27
+ input: {
28
+ index: 'index.html',
29
+ cube: 'labs/cube.html',
30
+ },
31
+ },
32
+ },
33
+ });