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 +21 -0
- package/README.md +26 -0
- package/bin/create.mjs +121 -0
- package/package.json +33 -0
- package/template/.claude/hooks.example.json +19 -0
- package/template/.claude/skills/adopt-triscope/SKILL.md +64 -0
- package/template/.claude/skills/glsl-pitfalls/SKILL.md +55 -0
- package/template/.claude/skills/threejs-telemetry-sink/SKILL.md +60 -0
- package/template/.claude/skills/triscope-iteration-loop/SKILL.md +249 -0
- package/template/.claude/skills/water-shader-convergence/SKILL.md +59 -0
- package/template/gitignore +6 -0
- package/template/index.html +23 -0
- package/template/labs/cube.html +26 -0
- package/template/package.json +23 -0
- package/template/smoke.mjs +339 -0
- package/template/src/elements/cube.ts +90 -0
- package/template/src/labs/cube.ts +25 -0
- package/template/tsconfig.json +13 -0
- package/template/vite.config.js +33 -0
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,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
|
+
});
|