@triscope/cli 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 +23 -0
- package/bin/triscope.mjs +141 -0
- package/package.json +38 -0
- package/src/adopt.mjs +127 -0
- package/src/auto-capture.mjs +74 -0
- package/src/dev.mjs +23 -0
- package/src/figma.mjs +124 -0
- package/src/gltf-scaffold.mjs +189 -0
- package/src/import-element.mjs +193 -0
- package/src/init.mjs +78 -0
- package/src/list.mjs +21 -0
- package/src/mcp.mjs +257 -0
- package/src/parse-flags.mjs +25 -0
- package/src/preflight.mjs +110 -0
- package/src/smoke-lib.mjs +230 -0
- package/src/smoke.mjs +182 -0
- package/src/state.mjs +46 -0
- package/src/wizard.mjs +101 -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,23 @@
|
|
|
1
|
+
# @triscope/cli
|
|
2
|
+
|
|
3
|
+
The `triscope` command-line tool for [triscope](https://github.com/tedin7/triscope)
|
|
4
|
+
projects.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npx triscope <command>
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Commands
|
|
11
|
+
|
|
12
|
+
- **`dev`** — start the Vite dev server with the telemetry plugin wired in
|
|
13
|
+
(`dev --check` runs a preflight first).
|
|
14
|
+
- **`list`** — print the element manifest of the running lab.
|
|
15
|
+
- **`state [<jq-path>]`** — read the live telemetry snapshot (or one path of it).
|
|
16
|
+
- **`import <spec>`** — pull in a published/GitHub element package and wire a lab page.
|
|
17
|
+
- **`adopt`** — retrofit an existing Vite + Three project: inject the telemetry
|
|
18
|
+
plugin and scaffold a `runSceneView` lab.
|
|
19
|
+
- **`smoke`** — headless boot + capture sanity check.
|
|
20
|
+
|
|
21
|
+
Pairs with [`@triscope/core`](https://www.npmjs.com/package/@triscope/core) (the
|
|
22
|
+
lab runtime) and [`@triscope/mcp`](https://www.npmjs.com/package/@triscope/mcp)
|
|
23
|
+
(the AI tool surface). MIT.
|
package/bin/triscope.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runAdopt } from '../src/adopt.mjs';
|
|
3
|
+
import { runAutoCapture } from '../src/auto-capture.mjs';
|
|
4
|
+
// Triscope CLI entry. Pure ESM JS — no build step.
|
|
5
|
+
import { runDev } from '../src/dev.mjs';
|
|
6
|
+
import { runFigma } from '../src/figma.mjs';
|
|
7
|
+
import { runGltfScaffold } from '../src/gltf-scaffold.mjs';
|
|
8
|
+
import { runImport } from '../src/import-element.mjs';
|
|
9
|
+
import { runInit } from '../src/init.mjs';
|
|
10
|
+
import { runList } from '../src/list.mjs';
|
|
11
|
+
import { runMcp } from '../src/mcp.mjs';
|
|
12
|
+
import { parseFlags } from '../src/parse-flags.mjs';
|
|
13
|
+
import { runSmoke } from '../src/smoke.mjs';
|
|
14
|
+
import { runState } from '../src/state.mjs';
|
|
15
|
+
|
|
16
|
+
const [, , subcommand, ...rest] = process.argv;
|
|
17
|
+
|
|
18
|
+
const HELP = `triscope — multi-angle 3D iteration framework
|
|
19
|
+
|
|
20
|
+
USAGE
|
|
21
|
+
triscope <command> [options]
|
|
22
|
+
|
|
23
|
+
COMMANDS
|
|
24
|
+
init <dir> [--install] Scaffold a new triscope project in <dir>. Wraps
|
|
25
|
+
create-triscope. With --install, also runs
|
|
26
|
+
\`npm install\`. With --quick, run a setup wizard
|
|
27
|
+
(deps + MCP + hook); add --yes for non-interactive.
|
|
28
|
+
dev [--check] Start the Vite dev server (in the current project).
|
|
29
|
+
With --check, run the preflight checklist (Node,
|
|
30
|
+
deps, chromium, MCP, port) and exit instead.
|
|
31
|
+
adopt [--name <n>] Retrofit triscope onto an EXISTING Vite + Three
|
|
32
|
+
project: inject the telemetry plugin + scaffold a
|
|
33
|
+
runSceneView lab. Then point it at your scene.
|
|
34
|
+
import <pkg|github> Install a published element package
|
|
35
|
+
(triscope-element-<name> or github:user/repo) and
|
|
36
|
+
wire its lab + vite input. [--name <n>] [--no-install]
|
|
37
|
+
new-gltf <file.glb> Generate a wired Element from a glTF/GLB asset
|
|
38
|
+
(bounds + animations auto-detected). [--name <n>]
|
|
39
|
+
new-figma <key> <node> Export a Figma node (needs FIGMA_TOKEN) as a PNG and
|
|
40
|
+
scaffold a textured-plane Element. [--name <n>]
|
|
41
|
+
state [<jq.path>] Read /tmp/<project>-state.json. With a path
|
|
42
|
+
(e.g. ".elements.ship.triangles"), prints just
|
|
43
|
+
that slice.
|
|
44
|
+
list Print the current scene manifest (elements,
|
|
45
|
+
cameras, knobs) from the running dev server.
|
|
46
|
+
smoke [<element>] Run the headed-Chromium smoke harness against a
|
|
47
|
+
lab page. Defaults to the scene lab. Element
|
|
48
|
+
argument picks /labs/<element>.html.
|
|
49
|
+
mcp install [--project] Register the triscope MCP server with Claude Code
|
|
50
|
+
(user scope by default; --project writes .mcp.json
|
|
51
|
+
in the cwd).
|
|
52
|
+
mcp uninstall Remove the triscope MCP registration.
|
|
53
|
+
auto-capture [--file <p>] Print one-line motion summary from telemetry.
|
|
54
|
+
Designed to wire as a Claude Code PostToolUse hook.
|
|
55
|
+
|
|
56
|
+
OPTIONS
|
|
57
|
+
--url <url> Override the dev server URL (default http://localhost:5173).
|
|
58
|
+
--port <n> Override the Vite port for \`triscope dev\`.
|
|
59
|
+
--help, -h Print this message.
|
|
60
|
+
|
|
61
|
+
EXAMPLES
|
|
62
|
+
triscope dev
|
|
63
|
+
triscope state .perf.fps
|
|
64
|
+
triscope state .elements.ship
|
|
65
|
+
triscope list
|
|
66
|
+
triscope smoke ship
|
|
67
|
+
triscope smoke --url http://localhost:5174/labs/scene.html
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
async function main() {
|
|
71
|
+
if (!subcommand || subcommand === '--help' || subcommand === '-h') {
|
|
72
|
+
console.log(HELP);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const { flags, positional } = parseFlags(rest);
|
|
76
|
+
if (flags.help) {
|
|
77
|
+
console.log(HELP);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
switch (subcommand) {
|
|
82
|
+
case 'init':
|
|
83
|
+
await runInit({
|
|
84
|
+
dir: positional[0],
|
|
85
|
+
install: flags.install,
|
|
86
|
+
quick: flags.quick,
|
|
87
|
+
yes: flags.yes,
|
|
88
|
+
preset: flags.preset,
|
|
89
|
+
});
|
|
90
|
+
break;
|
|
91
|
+
case 'dev':
|
|
92
|
+
await runDev({ port: flags.port, check: flags.check });
|
|
93
|
+
break;
|
|
94
|
+
case 'adopt':
|
|
95
|
+
await runAdopt({ name: flags.name ?? 'scene' });
|
|
96
|
+
break;
|
|
97
|
+
case 'import':
|
|
98
|
+
await runImport({
|
|
99
|
+
source: positional[0],
|
|
100
|
+
name: flags.name,
|
|
101
|
+
install: flags['no-install'] !== true,
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
case 'new-gltf':
|
|
105
|
+
await runGltfScaffold({ file: positional[0], name: flags.name });
|
|
106
|
+
break;
|
|
107
|
+
case 'new-figma':
|
|
108
|
+
await runFigma({ fileKey: positional[0], nodeId: positional[1], name: flags.name });
|
|
109
|
+
break;
|
|
110
|
+
case 'state':
|
|
111
|
+
await runState({ path: positional[0] });
|
|
112
|
+
break;
|
|
113
|
+
case 'list':
|
|
114
|
+
await runList({ url: flags.url });
|
|
115
|
+
break;
|
|
116
|
+
case 'smoke':
|
|
117
|
+
await runSmoke({ element: positional[0], url: flags.url, screenshot: flags.screenshot });
|
|
118
|
+
break;
|
|
119
|
+
case 'mcp':
|
|
120
|
+
await runMcp({
|
|
121
|
+
action: positional[0],
|
|
122
|
+
scope: flags.project ? 'project' : 'user',
|
|
123
|
+
url: flags.url,
|
|
124
|
+
withHook: flags['no-hook'] !== true,
|
|
125
|
+
});
|
|
126
|
+
break;
|
|
127
|
+
case 'auto-capture':
|
|
128
|
+
await runAutoCapture({ file: flags.file });
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
console.error(`Unknown command: ${subcommand}\n`);
|
|
132
|
+
console.log(HELP);
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(`triscope ${subcommand} failed:`, err?.message || err);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@triscope/cli",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Triscope CLI: dev, state, list, smoke. Headed-Chromium WebGPU smoke harness for AI iteration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"triscope": "./bin/triscope.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"pngjs": "^7.0.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@vitest/coverage-v8": "^2.1.9",
|
|
22
|
+
"vitest": "^2.1.9"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "echo \"cli: no build\" && exit 0",
|
|
26
|
+
"typecheck": "echo \"cli: pure ESM JS, no typecheck\" && exit 0",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"test:coverage": "vitest run --coverage --coverage.reporter=text --coverage.reporter=html"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"triscope",
|
|
33
|
+
"cli",
|
|
34
|
+
"three.js",
|
|
35
|
+
"webgpu",
|
|
36
|
+
"claude-code"
|
|
37
|
+
]
|
|
38
|
+
}
|
package/src/adopt.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// `triscope adopt` — retrofit triscope onto an EXISTING Vite + Three.js project
|
|
2
|
+
// in one command: inject the telemetry plugin into vite.config (the one piece
|
|
3
|
+
// you can't get for free), scaffold a `runSceneView`-based lab page + entry, and
|
|
4
|
+
// wire it into rollupOptions.input + package.json#triscope.labs. After this the
|
|
5
|
+
// MCP tools work; the generated entry renders a placeholder until you point
|
|
6
|
+
// runSceneView at your own scene (see the `// TODO` in src/labs/<name>.ts).
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
injectLabInput,
|
|
11
|
+
injectTelemetryPlugin,
|
|
12
|
+
labHtml,
|
|
13
|
+
wireProjectLabs,
|
|
14
|
+
} from './import-element.mjs';
|
|
15
|
+
|
|
16
|
+
const VITE_CONFIGS = ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'];
|
|
17
|
+
|
|
18
|
+
/** Lab entry that wraps a scene via runSceneView — placeholder until the user
|
|
19
|
+
* swaps in their own object/builder. Renders a box+light so the lab is alive
|
|
20
|
+
* (and MCP-pilotable) immediately, proving the adoption worked. */
|
|
21
|
+
export function adoptLabEntry(name) {
|
|
22
|
+
return `// triscope lab generated by \`triscope adopt\`.
|
|
23
|
+
// runSceneView wraps ANY THREE.Object3D (or a () => Object3D) into the lab —
|
|
24
|
+
// no Element boilerplate. Replace getRoot() below with YOUR scene/group.
|
|
25
|
+
import { runSceneView } from '@triscope/core';
|
|
26
|
+
|
|
27
|
+
async function getRoot() {
|
|
28
|
+
// TODO: return your existing content here, e.g.:
|
|
29
|
+
// import { buildScene } from '../scene.js'; return buildScene();
|
|
30
|
+
const THREE = await import('three/webgpu');
|
|
31
|
+
const g = new THREE.Group();
|
|
32
|
+
const mesh = new THREE.Mesh(
|
|
33
|
+
new THREE.BoxGeometry(1, 1, 1),
|
|
34
|
+
new THREE.MeshStandardMaterial({ color: 0xff8844, roughness: 0.5 }),
|
|
35
|
+
);
|
|
36
|
+
mesh.name = 'placeholder';
|
|
37
|
+
const key = new THREE.DirectionalLight(0xffffff, 2.2);
|
|
38
|
+
key.name = 'key';
|
|
39
|
+
key.position.set(2, 3, 4);
|
|
40
|
+
g.add(mesh, key, new THREE.AmbientLight(0x8899aa, 0.4));
|
|
41
|
+
return g;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
runSceneView(await getRoot(), { name: ${JSON.stringify(name)}, autoKnobs: true }).catch((e) => {
|
|
45
|
+
const b = document.getElementById('boot');
|
|
46
|
+
if (b) b.textContent = 'Init failed: ' + (e?.message ?? e);
|
|
47
|
+
console.error(e);
|
|
48
|
+
});
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runAdopt({ cwd = process.cwd(), name = 'scene' } = {}) {
|
|
53
|
+
if (!/^[a-z][\w-]*$/i.test(name)) {
|
|
54
|
+
console.error(`adopt: lab name must be [A-Za-z][\\w-]* (got ${JSON.stringify(name)})`);
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
const created = [];
|
|
58
|
+
const notes = [];
|
|
59
|
+
|
|
60
|
+
// 1. Telemetry plugin — the one piece adopt can't infer.
|
|
61
|
+
const vitePath = VITE_CONFIGS.map((f) => join(cwd, f)).find((f) => existsSync(f));
|
|
62
|
+
if (!vitePath) {
|
|
63
|
+
console.error(
|
|
64
|
+
'adopt: no vite.config.{js,ts,mjs} found. `triscope adopt` retrofits a Vite + Three project; for a fresh project use `npm create triscope`.',
|
|
65
|
+
);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
const inj = injectTelemetryPlugin(readFileSync(vitePath, 'utf8'));
|
|
69
|
+
if (inj.injected) {
|
|
70
|
+
writeFileSync(vitePath, inj.text);
|
|
71
|
+
created.push(`${vitePath} (triscopeTelemetryPlugin)`);
|
|
72
|
+
} else if (inj.already) {
|
|
73
|
+
notes.push('vite.config already registers triscopeTelemetryPlugin ✓');
|
|
74
|
+
} else {
|
|
75
|
+
notes.push(`MANUAL vite.config patch needed — ${inj.manual}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Dependency check (don't auto-install; just tell the user).
|
|
79
|
+
const pkgPath = join(cwd, 'package.json');
|
|
80
|
+
if (existsSync(pkgPath)) {
|
|
81
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
82
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
83
|
+
if (!deps['@triscope/core']) notes.push('MISSING dependency — run: npm i @triscope/core three');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. Scaffold lab page + runSceneView entry, wire vite input + labs (idempotent).
|
|
87
|
+
const htmlPath = join(cwd, 'labs', `${name}.html`);
|
|
88
|
+
if (existsSync(htmlPath)) {
|
|
89
|
+
notes.push(`${htmlPath} exists — left as-is`);
|
|
90
|
+
} else {
|
|
91
|
+
mkdirSync(dirname(htmlPath), { recursive: true });
|
|
92
|
+
writeFileSync(htmlPath, labHtml(name));
|
|
93
|
+
created.push(htmlPath);
|
|
94
|
+
}
|
|
95
|
+
const entryPath = join(cwd, 'src', 'labs', `${name}.ts`);
|
|
96
|
+
if (existsSync(entryPath)) {
|
|
97
|
+
notes.push(`${entryPath} exists — left as-is`);
|
|
98
|
+
} else {
|
|
99
|
+
mkdirSync(dirname(entryPath), { recursive: true });
|
|
100
|
+
writeFileSync(entryPath, adoptLabEntry(name));
|
|
101
|
+
created.push(entryPath);
|
|
102
|
+
}
|
|
103
|
+
if (vitePath) {
|
|
104
|
+
const v = injectLabInput(readFileSync(vitePath, 'utf8'), name);
|
|
105
|
+
writeFileSync(vitePath, v);
|
|
106
|
+
// injectLabInput is a no-op if it can't find `index: 'index.html'` to anchor
|
|
107
|
+
// to (e.g. a config with no rollupOptions.input). Dev + MCP work regardless
|
|
108
|
+
// (they resolve via triscope.labs); only the production `vite build` needs it.
|
|
109
|
+
if (!v.includes(`labs/${name}.html`)) {
|
|
110
|
+
notes.push(
|
|
111
|
+
`couldn't auto-add to rollupOptions.input — for \`vite build\`, add \`${name}: 'labs/${name}.html'\` to it manually (dev + MCP work without it)`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
wireProjectLabs(cwd, name);
|
|
116
|
+
|
|
117
|
+
console.log(`triscope adopt: wired lab "${name}".`);
|
|
118
|
+
for (const c of created) console.log(` + ${c}`);
|
|
119
|
+
for (const n of notes) console.log(` • ${n}`);
|
|
120
|
+
console.log('\nNext:');
|
|
121
|
+
console.log(` 1. point runSceneView at your scene in src/labs/${name}.ts (the // TODO)`);
|
|
122
|
+
console.log(' 2. triscope dev --check (verify Node/deps/chromium/MCP/port)');
|
|
123
|
+
console.log(
|
|
124
|
+
` 3. npm run dev → open /labs/${name}.html → drive it with the triscope MCP tools`,
|
|
125
|
+
);
|
|
126
|
+
return { name, created, notes };
|
|
127
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// `triscope auto-capture` — minimal hook-friendly status print.
|
|
2
|
+
//
|
|
3
|
+
// Designed to be wired as a Claude Code PostToolUse hook that runs after
|
|
4
|
+
// every Edit. Reads /tmp/<project>-state.json (already maintained by the
|
|
5
|
+
// dev server) and prints a one-line motion summary per element that has
|
|
6
|
+
// motionProbes declared. Cheap: no Chromium spawn, just a file read.
|
|
7
|
+
//
|
|
8
|
+
// Hook config example (settings.json or .claude/settings.local.json):
|
|
9
|
+
// {
|
|
10
|
+
// "hooks": {
|
|
11
|
+
// "PostToolUse": [{
|
|
12
|
+
// "matcher": "Edit|Write",
|
|
13
|
+
// "hooks": [{ "type": "command", "command": "triscope auto-capture" }]
|
|
14
|
+
// }]
|
|
15
|
+
// }
|
|
16
|
+
// }
|
|
17
|
+
//
|
|
18
|
+
// Optional `--file <path>` arg: filter so the hook only prints when an
|
|
19
|
+
// edited file likely affects 3D state. Falls back to printing always.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export function readProjectName(cwd) {
|
|
26
|
+
try {
|
|
27
|
+
const pkgPath = join(cwd, 'package.json');
|
|
28
|
+
if (!existsSync(pkgPath)) return 'triscope-project';
|
|
29
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
30
|
+
return String(pkg.name ?? 'triscope-project').replace(/[^A-Za-z0-9._-]/g, '-');
|
|
31
|
+
} catch {
|
|
32
|
+
return 'triscope-project';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const RELEVANT = /\b(triscope|lab|scene|element|shader|mesh)/i;
|
|
37
|
+
|
|
38
|
+
export function fmt(n) {
|
|
39
|
+
if (!Number.isFinite(n)) return '?';
|
|
40
|
+
return Number(n).toFixed(2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function runAutoCapture({ file } = {}) {
|
|
44
|
+
// If a file path was passed and it doesn't look like 3D code, exit silently.
|
|
45
|
+
if (file && !RELEVANT.test(file)) return;
|
|
46
|
+
|
|
47
|
+
const project = readProjectName(process.cwd());
|
|
48
|
+
const statePath = join(tmpdir(), `${project}-state.json`);
|
|
49
|
+
if (!existsSync(statePath)) {
|
|
50
|
+
// No dev server running — the hook just stays quiet.
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
let state;
|
|
54
|
+
try {
|
|
55
|
+
state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const elements = state?.elements;
|
|
60
|
+
if (!elements || typeof elements !== 'object') return;
|
|
61
|
+
|
|
62
|
+
const lines = [];
|
|
63
|
+
for (const [name, payload] of Object.entries(elements)) {
|
|
64
|
+
const motion = payload?.motion;
|
|
65
|
+
if (!motion || typeof motion !== 'object') continue;
|
|
66
|
+
const probes = Object.entries(motion)
|
|
67
|
+
.filter(([, s]) => s && typeof s === 'object')
|
|
68
|
+
.map(([k, s]) => `${k} p2p=${fmt(s.peakToPeak)} freq=${fmt(s.dominantFreqHz)}Hz`)
|
|
69
|
+
.join(', ');
|
|
70
|
+
if (probes) lines.push(`[triscope] ${name} motion: ${probes}`);
|
|
71
|
+
}
|
|
72
|
+
if (state?.perf?.fps != null) lines.unshift(`[triscope] fps=${fmt(state.perf.fps)}`);
|
|
73
|
+
if (lines.length > 0) console.log(lines.join('\n'));
|
|
74
|
+
}
|
package/src/dev.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// `triscope dev` — proxy to `vite` in the current project.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { formatPreflight, runPreflight } from './preflight.mjs';
|
|
6
|
+
|
|
7
|
+
export async function runDev({ port, check } = {}) {
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
// `triscope dev --check`: print the preflight checklist and exit (non-zero on
|
|
10
|
+
// any FAIL) without booting Vite — turns mid-session surprises into a gate.
|
|
11
|
+
if (check) {
|
|
12
|
+
const report = await runPreflight({ cwd, port: port ? Number(port) : 5173 });
|
|
13
|
+
console.log(formatPreflight(report));
|
|
14
|
+
process.exit(report.ok ? 0 : 1);
|
|
15
|
+
}
|
|
16
|
+
// Prefer locally-installed vite binary, fall back to PATH.
|
|
17
|
+
const localVite = resolve(cwd, 'node_modules/.bin/vite');
|
|
18
|
+
const vite = existsSync(localVite) ? localVite : 'vite';
|
|
19
|
+
const args = [];
|
|
20
|
+
if (port) args.push('--port', String(port));
|
|
21
|
+
const child = spawn(vite, args, { stdio: 'inherit' });
|
|
22
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
23
|
+
}
|
package/src/figma.mjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// `triscope new-figma <fileKey> <nodeId>` — export a Figma node as a PNG (REST
|
|
2
|
+
// images API, needs FIGMA_TOKEN) and scaffold an Element that shows it as a flat
|
|
3
|
+
// TEXTURED PLANE (reference image / UI-in-3D). This is deliberately NOT
|
|
4
|
+
// "2D design → 3D geometry" (impossible to infer); it brings the rendered node
|
|
5
|
+
// into the scene as a plane you can frame, fade, and place. CLI-only by design
|
|
6
|
+
// (no MCP tool) — keeps the tool surface lean and the token out of the agent.
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function figmaImageUrl(fileKey, nodeId, { scale = 2, format = 'png' } = {}) {
|
|
11
|
+
const q = new URLSearchParams({ ids: nodeId, format, scale: String(scale) });
|
|
12
|
+
return `https://api.figma.com/v1/images/${encodeURIComponent(fileKey)}?${q.toString()}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function texturedPlaneSource({ name, assetPath }) {
|
|
16
|
+
return `// Generated by \`triscope new-figma\`. A Figma node exported as a flat
|
|
17
|
+
// textured plane (reference image / UI-in-3D) — NOT geometry inferred from a
|
|
18
|
+
// 2D design. Edit freely.
|
|
19
|
+
import type { Element } from '@triscope/core';
|
|
20
|
+
import * as THREE from 'three/webgpu';
|
|
21
|
+
|
|
22
|
+
const ASSET_URL = ${JSON.stringify(assetPath)};
|
|
23
|
+
|
|
24
|
+
interface ${cap(name)}Data {
|
|
25
|
+
mesh: THREE.Mesh;
|
|
26
|
+
material: THREE.MeshBasicMaterial;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const ${name}: Element = {
|
|
30
|
+
name: '${name}',
|
|
31
|
+
bounds: { min: [-1, -0.6, -0.05], max: [1, 0.6, 0.05] },
|
|
32
|
+
|
|
33
|
+
mount: ({ parent }) => {
|
|
34
|
+
const geo = new THREE.PlaneGeometry(2, 1.2);
|
|
35
|
+
const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, transparent: true });
|
|
36
|
+
const mesh = new THREE.Mesh(geo, material);
|
|
37
|
+
parent.add(mesh);
|
|
38
|
+
new THREE.TextureLoader().load(ASSET_URL, (tex) => {
|
|
39
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
40
|
+
material.map = tex;
|
|
41
|
+
material.needsUpdate = true;
|
|
42
|
+
const img = tex.image as { width?: number; height?: number } | undefined;
|
|
43
|
+
if (img?.width && img?.height) mesh.scale.set(img.width / img.height, 1, 1);
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
root: mesh,
|
|
47
|
+
userData: { mesh, material } as unknown as Record<string, unknown>,
|
|
48
|
+
dispose: () => {
|
|
49
|
+
parent.remove(mesh);
|
|
50
|
+
geo.dispose();
|
|
51
|
+
material.dispose();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
cameras: {
|
|
57
|
+
front: { position: [0, 0, 3], target: [0, 0, 0] },
|
|
58
|
+
angle: { position: [2, 1, 3], target: [0, 0, 0] },
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
knobs: {
|
|
62
|
+
opacity: { type: 'number', min: 0, max: 1, step: 0.05, default: 1, label: 'opacity' },
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
onKnob: (handle, key, value) => {
|
|
66
|
+
const u = handle.userData as unknown as ${cap(name)}Data;
|
|
67
|
+
if (key === 'opacity') u.material.opacity = Number(value);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cap(s) {
|
|
74
|
+
return s.replace(/[^A-Za-z0-9]/g, '_').replace(/^./, (c) => c.toUpperCase());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runFigma({
|
|
78
|
+
fileKey,
|
|
79
|
+
nodeId,
|
|
80
|
+
name: nameOverride,
|
|
81
|
+
scale = 2,
|
|
82
|
+
cwd = process.cwd(),
|
|
83
|
+
}) {
|
|
84
|
+
const token = process.env.FIGMA_TOKEN;
|
|
85
|
+
if (!fileKey || !nodeId) {
|
|
86
|
+
console.error(
|
|
87
|
+
'Usage: triscope new-figma <fileKey> <nodeId> [--name <n>] (needs FIGMA_TOKEN)',
|
|
88
|
+
);
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
if (!token) {
|
|
92
|
+
console.error('FIGMA_TOKEN env var is required (a Figma personal access token).');
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
const name = (nameOverride ?? `figma-${nodeId}`).replace(/[^A-Za-z0-9_-]/g, '-');
|
|
96
|
+
|
|
97
|
+
// 1. Ask Figma for a rendered image URL for the node.
|
|
98
|
+
const meta = await fetch(figmaImageUrl(fileKey, nodeId, { scale }), {
|
|
99
|
+
headers: { 'X-Figma-Token': token },
|
|
100
|
+
}).then((r) => r.json());
|
|
101
|
+
const imageUrl = meta?.images?.[nodeId];
|
|
102
|
+
if (meta?.err || !imageUrl) {
|
|
103
|
+
console.error(`Figma did not return an image for node ${nodeId}: ${meta?.err ?? 'no url'}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 2. Download the rendered PNG into /public.
|
|
108
|
+
const bytes = Buffer.from(await (await fetch(imageUrl)).arrayBuffer());
|
|
109
|
+
const publicDir = join(cwd, 'public');
|
|
110
|
+
mkdirSync(publicDir, { recursive: true });
|
|
111
|
+
const assetFile = `${name}.png`;
|
|
112
|
+
writeFileSync(join(publicDir, assetFile), bytes);
|
|
113
|
+
|
|
114
|
+
// 3. Scaffold the textured-plane element.
|
|
115
|
+
const elPath = join(cwd, 'src', 'elements', `${name}.ts`);
|
|
116
|
+
mkdirSync(dirname(elPath), { recursive: true });
|
|
117
|
+
writeFileSync(elPath, texturedPlaneSource({ name, assetPath: `/${assetFile}` }));
|
|
118
|
+
|
|
119
|
+
console.log(`exported Figma node ${nodeId} → element "${name}"`);
|
|
120
|
+
console.log(` + ${join(publicDir, assetFile)} (${(bytes.length / 1024).toFixed(0)} KB)`);
|
|
121
|
+
console.log(` + ${elPath}`);
|
|
122
|
+
console.log(' note: this is a flat textured plane (reference/UI-in-3D), not inferred geometry.');
|
|
123
|
+
return { name, elPath };
|
|
124
|
+
}
|