@scenoco-three/editor 0.2.0 → 0.3.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/README.md +20 -15
- package/app/filePicker.ts +64 -0
- package/app/index.html +27 -0
- package/app/main.ts +57 -11
- package/app/session.ts +12 -11
- package/app/wsClient.ts +29 -3
- package/package.json +4 -4
- package/dist/args.d.ts +0 -8
- package/dist/args.js +0 -28
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -74
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -6
- package/dist/mcp.d.ts +0 -16
- package/dist/mcp.js +0 -150
- package/dist/protocol.d.ts +0 -157
- package/dist/protocol.js +0 -9
- package/dist/server.d.ts +0 -44
- package/dist/server.js +0 -264
package/README.md
CHANGED
|
@@ -13,17 +13,20 @@ npm i -D @scenoco-three/editor
|
|
|
13
13
|
## CLI
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
-
scenoco-editor
|
|
17
|
-
--components <dirs> Comma-separated component dirs (default: "
|
|
18
|
-
--packages <pkgs> Comma-separated tag packages (
|
|
19
|
-
|
|
16
|
+
scenoco-editor [scene|prefab] [options] Omit the file to choose from a start-up picker
|
|
17
|
+
--components <dirs> Comma-separated component dirs (default: "assets")
|
|
18
|
+
--packages <pkgs> Comma-separated tag packages (default: every @scenoco-three/*
|
|
19
|
+
dependency in the project's package.json)
|
|
20
|
+
--port <n> Fixed MCP port — pin it so your MCP client survives restarts
|
|
21
|
+
(the HTTP editor server always takes a free port and prints its URL)
|
|
20
22
|
--no-open Don't auto-open a browser
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
```bash
|
|
24
|
-
scenoco-editor
|
|
25
|
-
|
|
26
|
-
#
|
|
26
|
+
scenoco-editor # zero-config: file picker, auto-detected packages
|
|
27
|
+
scenoco-editor assets/scenes/level.scene.xml --port 55470
|
|
28
|
+
# editor: http://localhost:HTTP_PORT?file=… ← opens in your browser
|
|
29
|
+
# mcp: http://localhost:55470/mcp ← add to your MCP client (transport: http)
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
It serves the editor app through Vite + `@scenoco-three/vite` (so the open scene compiles
|
|
@@ -44,15 +47,17 @@ prompt).
|
|
|
44
47
|
### `simulate` — closed-loop driving
|
|
45
48
|
|
|
46
49
|
One call injects input, steps the sim, and captures — *see a game move, not a static scene*.
|
|
47
|
-
Each command is `{ do, … }`:
|
|
50
|
+
Each command is a self-contained verb `{ do, … }`:
|
|
48
51
|
|
|
49
52
|
```ts
|
|
50
53
|
type SimStep =
|
|
51
54
|
| { do: 'play' | 'pause' | 'stop' }
|
|
52
|
-
| { do: 'step'; frames?: number; dt?: number }
|
|
53
|
-
| { do: '
|
|
54
|
-
| { do: '
|
|
55
|
-
|
|
55
|
+
| { do: 'step'; frames?: number; dt?: number } // advance `frames` ticks (default 1)
|
|
56
|
+
| { do: 'tap'; key: string } // key press + release
|
|
57
|
+
| { do: 'keyDown'; key: string }
|
|
58
|
+
| { do: 'keyUp'; key: string }
|
|
59
|
+
| { do: 'click'; x?: number; y?: number; button?: 'left' | 'right' | 'middle' }
|
|
60
|
+
| { do: 'move'; x?: number; y?: number; dx?: number; dy?: number }
|
|
56
61
|
| { do: 'gamepad'; index?: number; buttons?: boolean[]; axes?: number[] }
|
|
57
62
|
| { do: 'preview'; camera?: string; lookAt?: string }
|
|
58
63
|
| { do: 'telemetry'; id: string };
|
|
@@ -61,7 +66,7 @@ type SimStep =
|
|
|
61
66
|
```jsonc
|
|
62
67
|
// "press Space, advance 90 frames, read the ball, screenshot" — in one tool call
|
|
63
68
|
[ { "do": "play" },
|
|
64
|
-
{ "do": "
|
|
69
|
+
{ "do": "tap", "key": "Space" },
|
|
65
70
|
{ "do": "step", "frames": 90 },
|
|
66
71
|
{ "do": "telemetry", "id": "ball" },
|
|
67
72
|
{ "do": "preview" } ]
|
|
@@ -74,8 +79,8 @@ The result is a `multi` payload: each capture's image/JSON in step order.
|
|
|
74
79
|
```ts
|
|
75
80
|
import { startEditorServer, startMcpServer } from '@scenoco-three/editor';
|
|
76
81
|
|
|
77
|
-
const editor = await startEditorServer({ cwd
|
|
78
|
-
const mcp = await startMcpServer(editor.bridge); // editor.bridge.send(EditorCommand)
|
|
82
|
+
const editor = await startEditorServer({ cwd }); // defaults: assets/ roots, packages from package.json
|
|
83
|
+
const mcp = await startMcpServer(editor.bridge, { port: 55470 }); // editor.bridge.send(EditorCommand)
|
|
79
84
|
console.log(editor.url, mcp.url);
|
|
80
85
|
// … later
|
|
81
86
|
await mcp.close(); await editor.close();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** A scene/prefab the picker can open (mirrors the server's `/@scenoco/files`). */
|
|
2
|
+
interface PickableFile {
|
|
3
|
+
file: string;
|
|
4
|
+
kind: 'scene' | 'prefab';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The start-up open dialog: shown when the editor is opened with no `?file=`.
|
|
9
|
+
* Lists the scenes/prefabs the server found under the project's assets/ folder
|
|
10
|
+
* and resolves with the one the user picks. It's a modal overlay — the viewport
|
|
11
|
+
* behind it is empty until a file is chosen.
|
|
12
|
+
*/
|
|
13
|
+
export async function showFilePicker(onPick: (file: string) => void): Promise<void> {
|
|
14
|
+
const overlay = document.createElement('div');
|
|
15
|
+
overlay.className = 'sc-picker';
|
|
16
|
+
const panel = document.createElement('div');
|
|
17
|
+
panel.className = 'sc-picker-panel';
|
|
18
|
+
overlay.append(panel);
|
|
19
|
+
|
|
20
|
+
const title = document.createElement('div');
|
|
21
|
+
title.className = 'sc-picker-title';
|
|
22
|
+
title.textContent = 'Open a scene';
|
|
23
|
+
panel.append(title);
|
|
24
|
+
|
|
25
|
+
const list = document.createElement('div');
|
|
26
|
+
list.className = 'sc-picker-list';
|
|
27
|
+
list.textContent = 'Scanning assets…';
|
|
28
|
+
panel.append(list);
|
|
29
|
+
document.body.append(overlay);
|
|
30
|
+
|
|
31
|
+
let files: PickableFile[] = [];
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch('/@scenoco/files');
|
|
34
|
+
files = ((await res.json()) as { files: PickableFile[] }).files ?? [];
|
|
35
|
+
} catch {
|
|
36
|
+
/* handled by the empty branch below */
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
list.textContent = '';
|
|
40
|
+
if (files.length === 0) {
|
|
41
|
+
const empty = document.createElement('div');
|
|
42
|
+
empty.className = 'sc-picker-empty';
|
|
43
|
+
empty.textContent = 'No .scene.xml or .prefab.xml files found under assets/.';
|
|
44
|
+
list.append(empty);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const f of files) {
|
|
49
|
+
const row = document.createElement('button');
|
|
50
|
+
row.className = 'sc-picker-row';
|
|
51
|
+
const name = document.createElement('span');
|
|
52
|
+
name.className = 'sc-picker-name';
|
|
53
|
+
name.textContent = f.file;
|
|
54
|
+
const kind = document.createElement('span');
|
|
55
|
+
kind.className = 'sc-picker-kind';
|
|
56
|
+
kind.textContent = f.kind;
|
|
57
|
+
row.append(name, kind);
|
|
58
|
+
row.onclick = () => {
|
|
59
|
+
overlay.remove();
|
|
60
|
+
onPick(f.file);
|
|
61
|
+
};
|
|
62
|
+
list.append(row);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/app/index.html
CHANGED
|
@@ -93,6 +93,33 @@
|
|
|
93
93
|
color: #ffd9d9; padding: 5px 10px; cursor: pointer; font: 12px ui-monospace, monospace; }
|
|
94
94
|
#sc-conflict button:first-of-type { background: var(--accent); color: #0e1219; border-color: var(--accent); }
|
|
95
95
|
#sc-conflict button:hover { filter: brightness(1.1); }
|
|
96
|
+
/* component-load error banner: last-good is live, this says what's broken */
|
|
97
|
+
#sc-error { position: fixed; top: 62px; left: 50%; transform: translateX(-50%); z-index: 7;
|
|
98
|
+
display: flex; align-items: flex-start; gap: 10px; max-width: min(680px, 92vw);
|
|
99
|
+
padding: 9px 13px; border-radius: 10px; background: rgba(44, 22, 14, 0.97);
|
|
100
|
+
border: 1px solid #7a4326; color: #ffdcc2; font: 12px/1.45 ui-monospace, monospace;
|
|
101
|
+
backdrop-filter: blur(6px); box-shadow: 0 10px 34px rgba(0, 0, 0, 0.5); }
|
|
102
|
+
#sc-error > span { white-space: pre-wrap; overflow: auto; max-height: 34vh; }
|
|
103
|
+
#sc-error button { flex: 0 0 auto; border: 1px solid #7a4326; border-radius: 7px; background: transparent;
|
|
104
|
+
color: #ffdcc2; padding: 5px 10px; cursor: pointer; font: 12px ui-monospace, monospace; }
|
|
105
|
+
#sc-error button:hover { filter: brightness(1.15); }
|
|
106
|
+
/* start-up file picker: shown when the editor opens with no ?file= */
|
|
107
|
+
.sc-picker { position: fixed; inset: 0; z-index: 8; display: flex; align-items: center;
|
|
108
|
+
justify-content: center; background: rgba(10, 12, 18, 0.72); backdrop-filter: blur(4px); }
|
|
109
|
+
.sc-picker-panel { width: min(440px, 88vw); max-height: 76vh; display: flex; flex-direction: column;
|
|
110
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
|
|
111
|
+
font: 13px/1.5 ui-monospace, monospace; box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5); }
|
|
112
|
+
.sc-picker-title { padding: 14px 18px; font-weight: 700; color: #9fe7ff; letter-spacing: 0.06em;
|
|
113
|
+
text-transform: uppercase; font-size: 12px; border-bottom: 1px solid var(--line); }
|
|
114
|
+
.sc-picker-list { overflow: auto; padding: 6px; }
|
|
115
|
+
.sc-picker-row { width: 100%; display: flex; align-items: center; justify-content: space-between;
|
|
116
|
+
gap: 10px; padding: 9px 12px; border: 0; border-radius: 8px; background: transparent; color: #e7f0fb;
|
|
117
|
+
font: 13px ui-monospace, monospace; text-align: left; cursor: pointer; }
|
|
118
|
+
.sc-picker-row:hover { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
|
|
119
|
+
.sc-picker-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
120
|
+
.sc-picker-kind { flex: 0 0 auto; color: #6f93b8; font-size: 11px; text-transform: uppercase;
|
|
121
|
+
letter-spacing: 0.04em; border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; }
|
|
122
|
+
.sc-picker-empty { padding: 22px 18px; color: #8fa6c4; text-align: center; }
|
|
96
123
|
</style>
|
|
97
124
|
</head>
|
|
98
125
|
<body>
|
package/app/main.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import { Box3, BoxHelper, Raycaster, Vector2, Vector3, type Object3D } from 'three';
|
|
2
|
-
// Register every project component, package tag, and built-in node up front, so
|
|
3
|
-
// any scene/prefab the agent opens has its tags available.
|
|
4
|
-
import 'virtual:scenoco-components';
|
|
5
2
|
import { EditorSession } from './session.js';
|
|
6
3
|
import { connectEditor } from './wsClient.js';
|
|
7
4
|
import { CameraRig } from './cameraRig.js';
|
|
@@ -11,6 +8,7 @@ import { buildPanels, type Panels } from './panels.js';
|
|
|
11
8
|
import { SceneDocument } from './document.js';
|
|
12
9
|
import { Gizmo, type GizmoMode } from './gizmo.js';
|
|
13
10
|
import { buildToolbar, type Toolbar } from './toolbar.js';
|
|
11
|
+
import { showFilePicker } from './filePicker.js';
|
|
14
12
|
|
|
15
13
|
const canvas = document.getElementById('app') as HTMLCanvasElement;
|
|
16
14
|
// Right mouse drag is the fly-camera look control — suppress the native
|
|
@@ -32,6 +30,9 @@ let saving = false;
|
|
|
32
30
|
// engine swaps (open / run / stop). Play mode runs the scene's own camera with
|
|
33
31
|
// no editor rig, document, or gizmo.
|
|
34
32
|
function rebind(): void {
|
|
33
|
+
// A file is loaded (from the URL, a user pick, or an MCP `open`) — the start-up
|
|
34
|
+
// picker, if still up, has served its purpose.
|
|
35
|
+
if (session.filePath) document.querySelector('.sc-picker')?.remove();
|
|
35
36
|
selection = undefined; // the old engine (and its scene helpers) is disposed
|
|
36
37
|
rig = undefined;
|
|
37
38
|
desktop = undefined;
|
|
@@ -234,10 +235,12 @@ async function reloadScene(preserveEdits: boolean): Promise<void> {
|
|
|
234
235
|
reloading = true;
|
|
235
236
|
const pending = preserveEdits ? (doc?.netEdits() ?? []) : [];
|
|
236
237
|
try {
|
|
237
|
-
await session.open(path); // re-fetch + swap → rebind() builds a fresh document
|
|
238
|
+
const summary = await session.open(path); // re-fetch + swap → rebind() builds a fresh document
|
|
238
239
|
if (pending.length) doc?.applyEdits(pending);
|
|
240
|
+
setComponentError(summary.componentError); // clears when a fix takes effect
|
|
239
241
|
} catch (e) {
|
|
240
242
|
console.error('[editor] reload failed:', e);
|
|
243
|
+
setComponentError(e instanceof Error ? e.message : String(e));
|
|
241
244
|
} finally {
|
|
242
245
|
reloading = false;
|
|
243
246
|
dismissConflict();
|
|
@@ -259,6 +262,28 @@ function dismissConflict(): void {
|
|
|
259
262
|
conflictBanner?.remove();
|
|
260
263
|
conflictBanner = undefined;
|
|
261
264
|
}
|
|
265
|
+
|
|
266
|
+
// A persistent error banner for a broken component set. The editor page stays
|
|
267
|
+
// alive (MCP too) but can't render until the code compiles again; this says why
|
|
268
|
+
// and reloads automatically on fix. Passing `undefined` clears it.
|
|
269
|
+
let errorBanner: HTMLElement | undefined;
|
|
270
|
+
function setComponentError(message: string | undefined): void {
|
|
271
|
+
if (!message) {
|
|
272
|
+
errorBanner?.remove();
|
|
273
|
+
errorBanner = undefined;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const text = `Component code has an error — the editor will reload once it's fixed:\n${message}`;
|
|
277
|
+
if (errorBanner) {
|
|
278
|
+
(errorBanner.firstChild as HTMLElement).textContent = text;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const bar = document.createElement('div');
|
|
282
|
+
bar.id = 'sc-error';
|
|
283
|
+
bar.append(textSpan(text), bannerButton('Dismiss', () => setComponentError(undefined)));
|
|
284
|
+
document.body.append(bar);
|
|
285
|
+
errorBanner = bar;
|
|
286
|
+
}
|
|
262
287
|
function textSpan(text: string): HTMLElement {
|
|
263
288
|
const s = document.createElement('span');
|
|
264
289
|
s.textContent = text;
|
|
@@ -282,17 +307,38 @@ function frame(now: number): void {
|
|
|
282
307
|
requestAnimationFrame(frame);
|
|
283
308
|
}
|
|
284
309
|
|
|
310
|
+
/** Open a file and reflect it in the URL, so reloads and the wi-fi link resume it. */
|
|
311
|
+
async function openFile(path: string): Promise<void> {
|
|
312
|
+
try {
|
|
313
|
+
const summary = await session.open(path);
|
|
314
|
+
const url = new URL(location.href);
|
|
315
|
+
url.searchParams.set('file', path);
|
|
316
|
+
history.replaceState(null, '', url);
|
|
317
|
+
setComponentError(summary.componentError);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
console.error('[editor] open failed:', e);
|
|
320
|
+
setComponentError(e instanceof Error ? e.message : String(e));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
285
324
|
void (async () => {
|
|
325
|
+
// Register every project component, package tag, and built-in node. Imported
|
|
326
|
+
// dynamically + caught so a broken component file can't abort page startup: the
|
|
327
|
+
// WS bridge (hence MCP) and the error banner still come up. While something is
|
|
328
|
+
// broken the editor can't render scenes, but it stays alive; a full reload on
|
|
329
|
+
// the next successful HMR update revives it once every error is fixed.
|
|
330
|
+
try {
|
|
331
|
+
await import('virtual:scenoco-components');
|
|
332
|
+
} catch (e) {
|
|
333
|
+
console.error('[editor] components failed to load:', e);
|
|
334
|
+
setComponentError(e instanceof Error ? e.message : String(e));
|
|
335
|
+
if (import.meta.hot) import.meta.hot.on('vite:afterUpdate', () => location.reload());
|
|
336
|
+
}
|
|
286
337
|
rebind(); // builds the (empty) panels
|
|
287
338
|
connectEditor(session);
|
|
288
339
|
const initial = new URLSearchParams(location.search).get('file');
|
|
289
|
-
if (initial)
|
|
290
|
-
|
|
291
|
-
await session.open(initial);
|
|
292
|
-
} catch (e) {
|
|
293
|
-
console.error('[editor] open failed:', e);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
340
|
+
if (initial) await openFile(initial);
|
|
341
|
+
else void showFilePicker(openFile); // no ?file= → let the user pick from assets/
|
|
296
342
|
requestAnimationFrame(frame);
|
|
297
343
|
})();
|
|
298
344
|
|
package/app/session.ts
CHANGED
|
@@ -39,17 +39,22 @@ export class EditorSession {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Fetch + load a scene/prefab (edit mode). Returns a small summary. */
|
|
42
|
-
async open(path: string): Promise<{ file: string; kind: 'scene' | 'prefab'; nodes: number }> {
|
|
42
|
+
async open(path: string): Promise<{ file: string; kind: 'scene' | 'prefab'; nodes: number; componentError?: string }> {
|
|
43
43
|
const res = await fetch(`/@scenoco/bundle?file=${encodeURIComponent(path)}`);
|
|
44
|
-
const data = (await res.json()) as
|
|
44
|
+
const data = (await res.json()) as
|
|
45
|
+
| { bundle: Bundle; provenance: SceneProvenance; componentError?: string }
|
|
46
|
+
| { error: string; componentError?: string };
|
|
45
47
|
if (!res.ok || !('bundle' in data) || !Array.isArray(data.bundle)) {
|
|
46
|
-
|
|
48
|
+
const message = (data as { error?: string }).error ?? 'failed to open file';
|
|
49
|
+
const componentError = (data as { componentError?: string }).componentError;
|
|
50
|
+
throw new Error(componentError ? `${message}\n\n${componentError}` : message);
|
|
47
51
|
}
|
|
48
52
|
this.bundle = data.bundle;
|
|
49
53
|
this.provenance = data.provenance;
|
|
50
54
|
this.filePath = path;
|
|
51
55
|
this.toEdit();
|
|
52
|
-
|
|
56
|
+
const summary = { file: path, kind: bundleKind(this.bundle) === 'prefab' ? ('prefab' as const) : ('scene' as const), nodes: count(this.engine.scene) };
|
|
57
|
+
return data.componentError ? { ...summary, componentError: data.componentError } : summary;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
/** Write a set of attribute edits back to the source file (surgical diff). */
|
|
@@ -228,13 +233,9 @@ export class EditorSession {
|
|
|
228
233
|
private frameCamera(): PerspectiveCamera {
|
|
229
234
|
const cam = new PerspectiveCamera(50, this.canvas.clientWidth / this.canvas.clientHeight || 1, 0.01, 50000);
|
|
230
235
|
const box = new Box3().setFromObject(this.engine.scene);
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const sphere = box.getBoundingSphere(new Sphere());
|
|
235
|
-
center.copy(sphere.center);
|
|
236
|
-
radius = sphere.radius || 1;
|
|
237
|
-
}
|
|
236
|
+
const sphere = box.isEmpty() ? new Sphere(new Vector3(), 1) : box.getBoundingSphere(new Sphere());
|
|
237
|
+
const center = sphere.center;
|
|
238
|
+
const radius = sphere.radius || 1;
|
|
238
239
|
cam.position.copy(center).addScaledVector(new Vector3(0.6, 0.45, 1).normalize(), (radius / Math.sin((50 * Math.PI) / 180 / 2)) * 1.2);
|
|
239
240
|
cam.lookAt(center);
|
|
240
241
|
this.engine.mainCamera = cam;
|
package/app/wsClient.ts
CHANGED
|
@@ -10,6 +10,8 @@ export function connectEditor(session: EditorSession): void {
|
|
|
10
10
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
11
11
|
const url = `${proto}://${location.host}${WS_PATH}`;
|
|
12
12
|
|
|
13
|
+
let stopped = false; // set on a shutdown push — don't reconnect to a dead server
|
|
14
|
+
|
|
13
15
|
const open = (): void => {
|
|
14
16
|
const ws = new WebSocket(url);
|
|
15
17
|
ws.onmessage = async (ev) => {
|
|
@@ -19,9 +21,14 @@ export function connectEditor(session: EditorSession): void {
|
|
|
19
21
|
} catch {
|
|
20
22
|
return;
|
|
21
23
|
}
|
|
22
|
-
// Unsolicited server
|
|
24
|
+
// Unsolicited server pushes — not id-correlated commands.
|
|
23
25
|
if ('push' in msg) {
|
|
24
|
-
|
|
26
|
+
if (msg.push === 'shutdown') {
|
|
27
|
+
stopped = true;
|
|
28
|
+
closeEditorTab();
|
|
29
|
+
} else {
|
|
30
|
+
session.onExternalChange?.(msg.file, msg.role);
|
|
31
|
+
}
|
|
25
32
|
return;
|
|
26
33
|
}
|
|
27
34
|
const cmd = msg;
|
|
@@ -33,12 +40,31 @@ export function connectEditor(session: EditorSession): void {
|
|
|
33
40
|
}
|
|
34
41
|
ws.send(JSON.stringify(response));
|
|
35
42
|
};
|
|
36
|
-
ws.onclose = () =>
|
|
43
|
+
ws.onclose = () => {
|
|
44
|
+
if (!stopped) setTimeout(open, 1000);
|
|
45
|
+
};
|
|
37
46
|
ws.onerror = () => ws.close();
|
|
38
47
|
};
|
|
39
48
|
open();
|
|
40
49
|
}
|
|
41
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The dev server stopped: close this tab. `window.close()` only succeeds for a
|
|
53
|
+
* tab the script itself opened (or one Chrome deems closable) — if the browser
|
|
54
|
+
* blocks it, fall back to a "server stopped" placeholder so the stale page is
|
|
55
|
+
* obvious and inert rather than silently retrying a dead connection.
|
|
56
|
+
*/
|
|
57
|
+
function closeEditorTab(): void {
|
|
58
|
+
window.close();
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
document.title = 'Editor stopped';
|
|
61
|
+
document.body.innerHTML =
|
|
62
|
+
'<div style="position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' +
|
|
63
|
+
'font:500 15px system-ui,sans-serif;color:#9aa4b2;background:#0a0e16">' +
|
|
64
|
+
'Editor server stopped — you can close this tab.</div>';
|
|
65
|
+
}, 150);
|
|
66
|
+
}
|
|
67
|
+
|
|
42
68
|
/**
|
|
43
69
|
* Execute one relayed command against the session. Exported so the command
|
|
44
70
|
* dispatch (not just the WS plumbing) can be driven directly in tests.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scenoco-three/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Local preview + headless frame render for SceNoCo scenes/prefabs (for multimodal LLMs)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,13 +37,13 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
40
|
-
"@scenoco-three/compiler": "^0.
|
|
41
|
-
"@scenoco-three/vite": "^0.
|
|
40
|
+
"@scenoco-three/compiler": "^0.3.0",
|
|
41
|
+
"@scenoco-three/vite": "^0.3.0",
|
|
42
42
|
"chokidar": "^4.0.3",
|
|
43
43
|
"ws": "^8.18.0"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
|
-
"@scenoco-three/core": "^0.
|
|
46
|
+
"@scenoco-three/core": "^0.3.0",
|
|
47
47
|
"three": ">=0.160",
|
|
48
48
|
"vite": ">=5"
|
|
49
49
|
},
|
package/dist/args.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
/** Split a comma-separated list, trimming blanks. */
|
|
2
|
-
export declare function parseList(v: string | undefined): string[] | undefined;
|
|
3
|
-
/** Flags that take a value; other `--flags` are booleans (`''`), the rest positionals. */
|
|
4
|
-
export declare const VALUE_FLAGS: Record<string, string>;
|
|
5
|
-
export declare function parseArgs(args: string[]): {
|
|
6
|
-
positionals: string[];
|
|
7
|
-
options: Record<string, string>;
|
|
8
|
-
};
|
package/dist/args.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// Pure CLI-arg helpers (no vite imports), so they're unit-testable.
|
|
2
|
-
/** Split a comma-separated list, trimming blanks. */
|
|
3
|
-
export function parseList(v) {
|
|
4
|
-
return v ? v.split(',').map((s) => s.trim()).filter(Boolean) : undefined;
|
|
5
|
-
}
|
|
6
|
-
/** Flags that take a value; other `--flags` are booleans (`''`), the rest positionals. */
|
|
7
|
-
export const VALUE_FLAGS = {
|
|
8
|
-
'--components': 'components',
|
|
9
|
-
'--packages': 'packages',
|
|
10
|
-
'--port': 'port',
|
|
11
|
-
};
|
|
12
|
-
export function parseArgs(args) {
|
|
13
|
-
const positionals = [];
|
|
14
|
-
const options = {};
|
|
15
|
-
for (let i = 0; i < args.length; i++) {
|
|
16
|
-
const arg = args[i];
|
|
17
|
-
if (arg in VALUE_FLAGS) {
|
|
18
|
-
options[VALUE_FLAGS[arg]] = args[++i] ?? '';
|
|
19
|
-
}
|
|
20
|
-
else if (arg.startsWith('--')) {
|
|
21
|
-
options[arg.slice(2)] = ''; // boolean flag, e.g. --no-open
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
positionals.push(arg);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return { positionals, options };
|
|
28
|
-
}
|
package/dist/cli/index.d.ts
DELETED
package/dist/cli/index.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
4
|
-
import { parseArgs, parseList } from '../args.js';
|
|
5
|
-
import { startEditorServer } from '../server.js';
|
|
6
|
-
import { startMcpServer } from '../mcp.js';
|
|
7
|
-
const USAGE = `scenoco-editor — open a scene/prefab and drive it from an MCP client
|
|
8
|
-
|
|
9
|
-
Usage:
|
|
10
|
-
scenoco-editor <scene|prefab> [options]
|
|
11
|
-
|
|
12
|
-
Options:
|
|
13
|
-
--components <dirs> Comma-separated component dirs (default: "src")
|
|
14
|
-
--packages <pkgs> Comma-separated tag packages (e.g. @scenoco-three/box2d)
|
|
15
|
-
--port <n> Fixed editor server port (default: a free port)
|
|
16
|
-
--no-open Don't auto-open the editor in a browser
|
|
17
|
-
|
|
18
|
-
Starts a local editor (opened in your browser) and an MCP server. Point an MCP
|
|
19
|
-
client at the printed endpoint; its tools — open_file, get_preview, run, pause,
|
|
20
|
-
stop, get_telemetry — drive the live editor.`;
|
|
21
|
-
async function main(argv) {
|
|
22
|
-
const { positionals, options } = parseArgs(argv);
|
|
23
|
-
const file = positionals[0];
|
|
24
|
-
if (file === undefined || file === '-h' || file === '--help') {
|
|
25
|
-
console.log(USAGE);
|
|
26
|
-
return file === undefined ? 1 : 0;
|
|
27
|
-
}
|
|
28
|
-
// npm/npx rewrites process.cwd() to the nearest enclosing package.json when
|
|
29
|
-
// run from inside a workspace package without its own node_modules/.bin
|
|
30
|
-
// (e.g. `npx scenoco-editor` from examples/breakout/scenes). INIT_CWD still
|
|
31
|
-
// holds the shell's actual directory.
|
|
32
|
-
const cwd = process.env.INIT_CWD || process.cwd();
|
|
33
|
-
const editor = await startEditorServer({
|
|
34
|
-
cwd,
|
|
35
|
-
componentRoots: parseList(options.components) ?? ['src'],
|
|
36
|
-
packages: parseList(options.packages) ?? [],
|
|
37
|
-
...(options.port ? { port: Number(options.port) } : {}),
|
|
38
|
-
});
|
|
39
|
-
const mcp = await startMcpServer(editor.bridge);
|
|
40
|
-
const appUrl = `${editor.url}?file=${encodeURIComponent(resolve(cwd, file))}`;
|
|
41
|
-
console.log(`\n editor: ${appUrl}`);
|
|
42
|
-
if (editor.networkUrl)
|
|
43
|
-
console.log(` wi-fi: ${editor.networkUrl}?file=${encodeURIComponent(resolve(cwd, file))}`);
|
|
44
|
-
console.log(` mcp: ${mcp.url} ← add this to your MCP client (transport: http)`);
|
|
45
|
-
console.log(`\n press Ctrl+C to stop\n`);
|
|
46
|
-
if (!('no-open' in options))
|
|
47
|
-
openBrowser(appUrl);
|
|
48
|
-
const shutdown = async () => {
|
|
49
|
-
// Connected browsers keep WS/HMR/keep-alive sockets open, so `close()` can
|
|
50
|
-
// stall waiting for them to drain — cap it so Ctrl+C always exits promptly.
|
|
51
|
-
const deadline = new Promise((r) => setTimeout(r, 1500).unref());
|
|
52
|
-
await Promise.race([Promise.all([mcp.close().catch(() => { }), editor.close().catch(() => { })]), deadline]);
|
|
53
|
-
process.exit(0);
|
|
54
|
-
};
|
|
55
|
-
process.on('SIGINT', () => void shutdown());
|
|
56
|
-
process.on('SIGTERM', () => void shutdown());
|
|
57
|
-
await new Promise(() => { }); // keep alive
|
|
58
|
-
return 0;
|
|
59
|
-
}
|
|
60
|
-
/** Open a URL in the default browser (best-effort, per platform). */
|
|
61
|
-
function openBrowser(url) {
|
|
62
|
-
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
63
|
-
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
64
|
-
try {
|
|
65
|
-
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
/* non-fatal: the URL is printed */
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
process.exitCode = await main(process.argv.slice(2)).catch((e) => {
|
|
72
|
-
console.error(e instanceof Error ? e.message : String(e));
|
|
73
|
-
return 1;
|
|
74
|
-
});
|
package/dist/index.d.ts
DELETED
|
@@ -1,3 +0,0 @@
|
|
|
1
|
-
export { startEditorServer, type ServerOptions, type EditorServer, type EditorBridge } from './server.js';
|
|
2
|
-
export { startMcpServer, buildMcpServer, type McpHandle } from './mcp.js';
|
|
3
|
-
export { WS_PATH, commandId, type EditorCommand, type EditorResponse, type EditorResult, } from './protocol.js';
|
package/dist/index.js
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// `@scenoco-three/editor` — programmatic API behind the `scenoco-editor` CLI.
|
|
2
|
-
// Serves the editor app, bridges it to an MCP server over a WebSocket, and exposes
|
|
3
|
-
// MCP tools (open_file / get_preview / run / pause / stop / get_telemetry).
|
|
4
|
-
export { startEditorServer } from './server.js';
|
|
5
|
-
export { startMcpServer, buildMcpServer } from './mcp.js';
|
|
6
|
-
export { WS_PATH, commandId, } from './protocol.js';
|
package/dist/mcp.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
-
import type { EditorBridge } from './server.js';
|
|
3
|
-
export interface McpHandle {
|
|
4
|
-
/** The MCP endpoint URL to add to an MCP client. */
|
|
5
|
-
url: string;
|
|
6
|
-
close(): Promise<void>;
|
|
7
|
-
}
|
|
8
|
-
/**
|
|
9
|
-
* Start a stateless MCP server (Streamable HTTP) whose tools relay to the live
|
|
10
|
-
* editor over `bridge`. A fresh `McpServer` + transport is built per request.
|
|
11
|
-
*/
|
|
12
|
-
export declare function startMcpServer(bridge: EditorBridge, opts?: {
|
|
13
|
-
port?: number;
|
|
14
|
-
}): Promise<McpHandle>;
|
|
15
|
-
/** Build an McpServer whose tools relay to the editor. */
|
|
16
|
-
export declare function buildMcpServer(bridge: EditorBridge): McpServer;
|
package/dist/mcp.js
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { createServer as createHttpServer } from 'node:http';
|
|
2
|
-
import { createServer as createNetServer } from 'node:net';
|
|
3
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
-
import { z } from 'zod';
|
|
6
|
-
import { commandId } from './protocol.js';
|
|
7
|
-
/**
|
|
8
|
-
* Start a stateless MCP server (Streamable HTTP) whose tools relay to the live
|
|
9
|
-
* editor over `bridge`. A fresh `McpServer` + transport is built per request.
|
|
10
|
-
*/
|
|
11
|
-
export async function startMcpServer(bridge, opts = {}) {
|
|
12
|
-
const port = opts.port ?? (await freePort());
|
|
13
|
-
const http = createHttpServer((req, res) => void handle(req, res, bridge));
|
|
14
|
-
await new Promise((resolve) => http.listen(port, resolve));
|
|
15
|
-
return {
|
|
16
|
-
url: `http://localhost:${port}/mcp`,
|
|
17
|
-
close: () => new Promise((resolve) => http.close(() => resolve())),
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
/** Build an McpServer whose tools relay to the editor. */
|
|
21
|
-
export function buildMcpServer(bridge) {
|
|
22
|
-
const server = new McpServer({ name: 'scenoco-editor', version: '0.1.0' });
|
|
23
|
-
const relay = (cmd) => relayToEditor(bridge, cmd);
|
|
24
|
-
server.registerTool('open_file', { description: 'Open a scene (.scene.xml) or prefab (.prefab.xml) in the editor (edit mode).', inputSchema: { path: z.string() } }, ({ path }) => relay({ id: commandId(), type: 'open', path }));
|
|
25
|
-
server.registerTool('get_preview', {
|
|
26
|
-
description: 'Capture the current editor view as a PNG. Optionally frame a specific camera.',
|
|
27
|
-
inputSchema: {
|
|
28
|
-
camera: z.string().optional().describe('Camera position "x y z"'),
|
|
29
|
-
lookAt: z.string().optional().describe('Look-at point "x y z"'),
|
|
30
|
-
},
|
|
31
|
-
}, ({ camera, lookAt }) => relay({ id: commandId(), type: 'preview', ...(camera ? { camera } : {}), ...(lookAt ? { lookAt } : {}) }));
|
|
32
|
-
server.registerTool('playback', {
|
|
33
|
-
description: 'Control gameplay: play (run components/systems/physics), pause, or stop (back to edit mode).',
|
|
34
|
-
inputSchema: { state: z.enum(['play', 'pause', 'stop']) },
|
|
35
|
-
}, ({ state }) => relay({ id: commandId(), type: state === 'play' ? 'run' : state }));
|
|
36
|
-
server.registerTool('get_telemetry', { description: 'Current property values (JSON) for the node/components at a scene id.', inputSchema: { id: z.string() } }, ({ id }) => relay({ id: commandId(), type: 'telemetry', targetId: id }));
|
|
37
|
-
// A simulation command — one entry in the `simulate` batch. Loose (all fields
|
|
38
|
-
// optional) so the agent composes any step; only `do` is required.
|
|
39
|
-
const simStep = z.object({
|
|
40
|
-
do: z.enum(['play', 'pause', 'stop', 'step', 'tap', 'keyDown', 'keyUp', 'click', 'move', 'gamepad', 'preview', 'telemetry']),
|
|
41
|
-
key: z.string().optional().describe("tap/keyDown/keyUp: key name, e.g. 'Space', 'W', 'LeftArrow'"),
|
|
42
|
-
frames: z.number().int().optional(),
|
|
43
|
-
dt: z.number().optional(),
|
|
44
|
-
x: z.number().optional(),
|
|
45
|
-
y: z.number().optional(),
|
|
46
|
-
dx: z.number().optional(),
|
|
47
|
-
dy: z.number().optional(),
|
|
48
|
-
button: z.enum(['left', 'right', 'middle']).optional(),
|
|
49
|
-
index: z.number().int().optional(),
|
|
50
|
-
buttons: z.array(z.boolean()).optional(),
|
|
51
|
-
axes: z.array(z.number()).optional(),
|
|
52
|
-
camera: z.string().optional(),
|
|
53
|
-
lookAt: z.string().optional(),
|
|
54
|
-
id: z.string().optional(),
|
|
55
|
-
});
|
|
56
|
-
server.registerTool('simulate', {
|
|
57
|
-
description: 'Drive gameplay: an ordered batch of steps; returns previews (images) and telemetry (JSON) in order. ' +
|
|
58
|
-
'Each step is one self-contained verb: { do: "play"|"pause"|"stop" | "step"(frames) | "tap"(key) | ' +
|
|
59
|
-
'"keyDown"(key) | "keyUp"(key) | "click"(x,y,button) | "move"(x,y,dx,dy) | "preview"(camera,lookAt) | "telemetry"(id) }. ' +
|
|
60
|
-
'Example: [{do:"play"},{do:"tap",key:"Space"},{do:"step",frames:90},{do:"preview"}].',
|
|
61
|
-
inputSchema: { commands: z.array(simStep) },
|
|
62
|
-
}, ({ commands }) => relay({ id: commandId(), type: 'simulate', steps: commands }));
|
|
63
|
-
return server;
|
|
64
|
-
}
|
|
65
|
-
/** Send a command to the editor and map its reply to MCP tool content. */
|
|
66
|
-
async function relayToEditor(bridge, cmd) {
|
|
67
|
-
let response;
|
|
68
|
-
try {
|
|
69
|
-
response = await bridge.send(cmd);
|
|
70
|
-
}
|
|
71
|
-
catch (e) {
|
|
72
|
-
return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
|
|
73
|
-
}
|
|
74
|
-
if (!response.ok)
|
|
75
|
-
return { isError: true, content: [{ type: 'text', text: response.error }] };
|
|
76
|
-
return toContent(response.result);
|
|
77
|
-
}
|
|
78
|
-
function toContent(result) {
|
|
79
|
-
switch (result.kind) {
|
|
80
|
-
case 'image': {
|
|
81
|
-
const base64 = result.dataUrl.replace(/^data:image\/png;base64,/, '');
|
|
82
|
-
return { content: [{ type: 'image', data: base64, mimeType: 'image/png' }] };
|
|
83
|
-
}
|
|
84
|
-
case 'json':
|
|
85
|
-
return { content: [{ type: 'text', text: JSON.stringify(result.data, null, 2) }] };
|
|
86
|
-
case 'multi': {
|
|
87
|
-
// Flatten each capture's blocks, preserving step order.
|
|
88
|
-
const content = result.results.flatMap((r) => toContent(r).content);
|
|
89
|
-
return { content: content.length > 0 ? content : [{ type: 'text', text: 'ok' }] };
|
|
90
|
-
}
|
|
91
|
-
case 'ok':
|
|
92
|
-
default:
|
|
93
|
-
return { content: [{ type: 'text', text: result.info ?? 'ok' }] };
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
async function handle(req, res, bridge) {
|
|
97
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
98
|
-
res.setHeader('Access-Control-Allow-Headers', 'content-type, mcp-session-id, mcp-protocol-version');
|
|
99
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
100
|
-
if (req.method === 'OPTIONS') {
|
|
101
|
-
res.statusCode = 204;
|
|
102
|
-
res.end();
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
if ((req.url ?? '').split('?')[0] !== '/mcp') {
|
|
106
|
-
res.statusCode = 404;
|
|
107
|
-
res.end('not found');
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (req.method !== 'POST') {
|
|
111
|
-
res.statusCode = 405;
|
|
112
|
-
res.setHeader('Allow', 'POST');
|
|
113
|
-
res.end(JSON.stringify({ error: 'this MCP endpoint is stateless — use POST' }));
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
const body = await readJson(req);
|
|
117
|
-
// Stateless: a fresh server + transport per request.
|
|
118
|
-
const server = buildMcpServer(bridge);
|
|
119
|
-
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
120
|
-
res.on('close', () => {
|
|
121
|
-
void transport.close();
|
|
122
|
-
void server.close();
|
|
123
|
-
});
|
|
124
|
-
await server.connect(transport);
|
|
125
|
-
await transport.handleRequest(req, res, body);
|
|
126
|
-
}
|
|
127
|
-
function readJson(req) {
|
|
128
|
-
return new Promise((resolve) => {
|
|
129
|
-
let data = '';
|
|
130
|
-
req.on('data', (c) => (data += c));
|
|
131
|
-
req.on('end', () => {
|
|
132
|
-
try {
|
|
133
|
-
resolve(data ? JSON.parse(data) : undefined);
|
|
134
|
-
}
|
|
135
|
-
catch {
|
|
136
|
-
resolve(undefined);
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
function freePort() {
|
|
142
|
-
return new Promise((res, rej) => {
|
|
143
|
-
const probe = createNetServer();
|
|
144
|
-
probe.on('error', rej);
|
|
145
|
-
probe.listen(0, () => {
|
|
146
|
-
const port = probe.address().port;
|
|
147
|
-
probe.close(() => res(port));
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
}
|
package/dist/protocol.d.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
/** WebSocket path the editor connects to on the dev server. */
|
|
2
|
-
export declare const WS_PATH = "/scenoco-ws";
|
|
3
|
-
/** A command sent MCP-server → editor (id-correlated with its response). */
|
|
4
|
-
export type EditorCommand = {
|
|
5
|
-
id: string;
|
|
6
|
-
type: 'open';
|
|
7
|
-
path: string;
|
|
8
|
-
} | {
|
|
9
|
-
id: string;
|
|
10
|
-
type: 'preview';
|
|
11
|
-
camera?: string;
|
|
12
|
-
lookAt?: string;
|
|
13
|
-
size?: string;
|
|
14
|
-
} | {
|
|
15
|
-
id: string;
|
|
16
|
-
type: 'run';
|
|
17
|
-
} | {
|
|
18
|
-
id: string;
|
|
19
|
-
type: 'pause';
|
|
20
|
-
} | {
|
|
21
|
-
id: string;
|
|
22
|
-
type: 'stop';
|
|
23
|
-
} | {
|
|
24
|
-
id: string;
|
|
25
|
-
type: 'telemetry';
|
|
26
|
-
targetId: string;
|
|
27
|
-
} | {
|
|
28
|
-
id: string;
|
|
29
|
-
type: 'simulate';
|
|
30
|
-
steps: SimStep[];
|
|
31
|
-
};
|
|
32
|
-
/**
|
|
33
|
-
* One step in a `simulate` batch — transport, frame stepping, synthetic input, or
|
|
34
|
-
* a capture. Executed in order against the live engine, so an agent can drive the
|
|
35
|
-
* game to a precise condition and sample preview/telemetry there.
|
|
36
|
-
*/
|
|
37
|
-
export type SimStep = {
|
|
38
|
-
do: 'play' | 'pause' | 'stop';
|
|
39
|
-
}
|
|
40
|
-
/** Advance the simulation by `frames` ticks (default 1) of `dt` seconds (default 1/60). */
|
|
41
|
-
| {
|
|
42
|
-
do: 'step';
|
|
43
|
-
frames?: number;
|
|
44
|
-
dt?: number;
|
|
45
|
-
}
|
|
46
|
-
/** Keyboard — `key` is a Unity KeyCode name (e.g. 'Space', 'W'). Each action is its own verb. */
|
|
47
|
-
| {
|
|
48
|
-
do: 'tap';
|
|
49
|
-
key: string;
|
|
50
|
-
} | {
|
|
51
|
-
do: 'keyDown';
|
|
52
|
-
key: string;
|
|
53
|
-
} | {
|
|
54
|
-
do: 'keyUp';
|
|
55
|
-
key: string;
|
|
56
|
-
}
|
|
57
|
-
/** Mouse — `x,y` canvas-relative px (top-left). `click` = down+up; button defaults to left. */
|
|
58
|
-
| {
|
|
59
|
-
do: 'click';
|
|
60
|
-
x?: number;
|
|
61
|
-
y?: number;
|
|
62
|
-
button?: 'left' | 'right' | 'middle';
|
|
63
|
-
} | {
|
|
64
|
-
do: 'move';
|
|
65
|
-
x?: number;
|
|
66
|
-
y?: number;
|
|
67
|
-
dx?: number;
|
|
68
|
-
dy?: number;
|
|
69
|
-
}
|
|
70
|
-
/** Gamepad: synthetic pad `index` (default 0) with pressed `buttons` + `axes`. */
|
|
71
|
-
| {
|
|
72
|
-
do: 'gamepad';
|
|
73
|
-
index?: number;
|
|
74
|
-
buttons?: boolean[];
|
|
75
|
-
axes?: number[];
|
|
76
|
-
} | {
|
|
77
|
-
do: 'preview';
|
|
78
|
-
camera?: string;
|
|
79
|
-
lookAt?: string;
|
|
80
|
-
} | {
|
|
81
|
-
do: 'telemetry';
|
|
82
|
-
id: string;
|
|
83
|
-
};
|
|
84
|
-
/** The editor's reply, keyed by the command id. */
|
|
85
|
-
export type EditorResponse = {
|
|
86
|
-
id: string;
|
|
87
|
-
ok: true;
|
|
88
|
-
result: EditorResult;
|
|
89
|
-
} | {
|
|
90
|
-
id: string;
|
|
91
|
-
ok: false;
|
|
92
|
-
error: string;
|
|
93
|
-
};
|
|
94
|
-
/**
|
|
95
|
-
* An unsolicited server→editor notification (not id-correlated). Sent when a
|
|
96
|
-
* watched file changes on disk: the open scene/prefab (`role: 'open'`) or one of
|
|
97
|
-
* its `src=` dependencies — a prefab/material (`role: 'dep'`).
|
|
98
|
-
*/
|
|
99
|
-
export interface EditorPush {
|
|
100
|
-
push: 'fileChanged';
|
|
101
|
-
/** Absolute path of the changed file. */
|
|
102
|
-
file: string;
|
|
103
|
-
role: 'open' | 'dep';
|
|
104
|
-
}
|
|
105
|
-
/** Result payloads, discriminated by `kind`. */
|
|
106
|
-
export type EditorResult = {
|
|
107
|
-
kind: 'ok';
|
|
108
|
-
info?: string;
|
|
109
|
-
} | {
|
|
110
|
-
kind: 'image';
|
|
111
|
-
dataUrl: string;
|
|
112
|
-
} | {
|
|
113
|
-
kind: 'json';
|
|
114
|
-
data: unknown;
|
|
115
|
-
} | {
|
|
116
|
-
kind: 'multi';
|
|
117
|
-
results: EditorResult[];
|
|
118
|
-
};
|
|
119
|
-
/** Random correlation id for a command. */
|
|
120
|
-
export declare function commandId(): string;
|
|
121
|
-
/**
|
|
122
|
-
* Which compiled element an edit targets. Resolved server-side via the source
|
|
123
|
-
* map (compiled index → source element), so it survives prefab expansion where
|
|
124
|
-
* structural paths don't. `id`s are carried for readability/debugging.
|
|
125
|
-
*/
|
|
126
|
-
export type EditTarget = {
|
|
127
|
-
kind: 'node';
|
|
128
|
-
nodeIndex: number;
|
|
129
|
-
id?: string;
|
|
130
|
-
} | {
|
|
131
|
-
kind: 'component';
|
|
132
|
-
componentIndex: number;
|
|
133
|
-
nodeIndex: number;
|
|
134
|
-
nodeId?: string;
|
|
135
|
-
/** Component tag + its ordinal among same-typed components on the node. */
|
|
136
|
-
type: string;
|
|
137
|
-
ordinal: number;
|
|
138
|
-
} | {
|
|
139
|
-
kind: 'attachment';
|
|
140
|
-
nodeIndex: number;
|
|
141
|
-
/** Owning node id; absent for the scene-root, which `root` then marks. */
|
|
142
|
-
nodeId?: string;
|
|
143
|
-
root?: boolean;
|
|
144
|
-
/** Attachment tag + its ordinal among same-tag attachments on the node. */
|
|
145
|
-
tag: string;
|
|
146
|
-
ordinal: number;
|
|
147
|
-
};
|
|
148
|
-
/**
|
|
149
|
-
* One attribute change in compiled form (numbers/arrays/strings/bools). The
|
|
150
|
-
* server serializes `value` to an attribute string using the registry codecs,
|
|
151
|
-
* or removes the attribute when it equals the registry default (minimal diff).
|
|
152
|
-
*/
|
|
153
|
-
export interface EditOp {
|
|
154
|
-
target: EditTarget;
|
|
155
|
-
attr: string;
|
|
156
|
-
value: number | boolean | string | number[];
|
|
157
|
-
}
|
package/dist/protocol.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// Shared command/response protocol for the WebSocket bridge between the MCP
|
|
2
|
-
// server (Node) and the editor app (browser). Pure types + a tiny correlation
|
|
3
|
-
// helper, so both ends agree and the relay logic is unit-testable.
|
|
4
|
-
/** WebSocket path the editor connects to on the dev server. */
|
|
5
|
-
export const WS_PATH = '/scenoco-ws';
|
|
6
|
-
/** Random correlation id for a command. */
|
|
7
|
-
export function commandId() {
|
|
8
|
-
return Math.random().toString(36).slice(2, 10);
|
|
9
|
-
}
|
package/dist/server.d.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { type EditorCommand, type EditorPush, type EditorResponse } from './protocol.js';
|
|
2
|
-
export interface ServerOptions {
|
|
3
|
-
/** Directories scanned for `@component`/`@node` tags (default `['src']`). */
|
|
4
|
-
componentRoots?: string[];
|
|
5
|
-
/** Tag packages to register (e.g. `['@scenoco-three/box2d']`). */
|
|
6
|
-
packages?: string[];
|
|
7
|
-
/** Fixed port (default: a free OS port). */
|
|
8
|
-
port?: number;
|
|
9
|
-
/** Base directory `componentRoots` are resolved against (default: `process.cwd()`). */
|
|
10
|
-
cwd?: string;
|
|
11
|
-
}
|
|
12
|
-
/** Relays MCP tool commands to the connected editor page and awaits its reply. */
|
|
13
|
-
export interface EditorBridge {
|
|
14
|
-
connected(): boolean;
|
|
15
|
-
send(cmd: EditorCommand): Promise<EditorResponse>;
|
|
16
|
-
/** Send an unsolicited notification to the editor page (e.g. a file change). */
|
|
17
|
-
push(msg: EditorPush): void;
|
|
18
|
-
}
|
|
19
|
-
/** Watches the open file + its `src=` deps and pushes change notifications. */
|
|
20
|
-
export interface FileWatcher {
|
|
21
|
-
/** (Re)arm the watch set after a successful compile of `openFile` with `deps`. */
|
|
22
|
-
arm(openFile: string, openContents: string, deps: {
|
|
23
|
-
path: string;
|
|
24
|
-
contents: string;
|
|
25
|
-
}[]): void;
|
|
26
|
-
/** Record content the editor itself just wrote, so its own save isn't seen as external. */
|
|
27
|
-
note(file: string, contents: string): void;
|
|
28
|
-
setPush(push: (msg: EditorPush) => void): void;
|
|
29
|
-
close(): Promise<void>;
|
|
30
|
-
}
|
|
31
|
-
export interface EditorServer {
|
|
32
|
-
/** Local URL the editor app is served at. */
|
|
33
|
-
url: string;
|
|
34
|
-
/** LAN URL (open on another device on the same network). */
|
|
35
|
-
networkUrl?: string;
|
|
36
|
-
bridge: EditorBridge;
|
|
37
|
-
close(): Promise<void>;
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Start the editor's Vite dev server: serves the app, registers all project
|
|
41
|
-
* components/packages, compiles any scene/prefab on demand, and bridges the page
|
|
42
|
-
* to the MCP server over a WebSocket.
|
|
43
|
-
*/
|
|
44
|
-
export declare function startEditorServer(opts?: ServerOptions): Promise<EditorServer>;
|
package/dist/server.js
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { createServer as createNetServer } from 'node:net';
|
|
3
|
-
import { dirname, resolve } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import chokidar from 'chokidar';
|
|
6
|
-
import { createServer } from 'vite';
|
|
7
|
-
import { WebSocketServer } from 'ws';
|
|
8
|
-
import { bundlePrefab, bundleScene, compilePrefabXml, compileSceneXml, loadComponents, patchSceneXml, scanComponents, } from '@scenoco-three/compiler';
|
|
9
|
-
import { WS_PATH } from './protocol.js';
|
|
10
|
-
const appDir = fileURLToPath(new URL('../app', import.meta.url));
|
|
11
|
-
/** Loads `src=` resources from disk, relative to the referencing file. */
|
|
12
|
-
const fsResolver = (request, referrer) => {
|
|
13
|
-
const path = resolve(referrer ? dirname(referrer) : '.', request);
|
|
14
|
-
return existsSync(path) ? { path, contents: readFileSync(path, 'utf8') } : null;
|
|
15
|
-
};
|
|
16
|
-
/**
|
|
17
|
-
* Start the editor's Vite dev server: serves the app, registers all project
|
|
18
|
-
* components/packages, compiles any scene/prefab on demand, and bridges the page
|
|
19
|
-
* to the MCP server over a WebSocket.
|
|
20
|
-
*/
|
|
21
|
-
export async function startEditorServer(opts = {}) {
|
|
22
|
-
const componentRoots = opts.componentRoots ?? ['src'];
|
|
23
|
-
const packages = opts.packages ?? [];
|
|
24
|
-
const port = opts.port ?? (await freePort());
|
|
25
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
26
|
-
const watcher = createFileWatcher();
|
|
27
|
-
const server = await createServer({
|
|
28
|
-
configFile: false,
|
|
29
|
-
root: appDir,
|
|
30
|
-
logLevel: 'warn',
|
|
31
|
-
server: { host: true, port, strictPort: true, fs: { strict: false } },
|
|
32
|
-
plugins: [allComponentsPlugin(componentRoots, packages, cwd), bundleEndpointPlugin(componentRoots, packages, cwd, watcher)],
|
|
33
|
-
});
|
|
34
|
-
await server.listen();
|
|
35
|
-
const bridge = attachBridge(server);
|
|
36
|
-
watcher.setPush((msg) => bridge.push(msg));
|
|
37
|
-
const url = server.resolvedUrls?.local[0] ?? `http://localhost:${port}/`;
|
|
38
|
-
const networkUrl = server.resolvedUrls?.network[0];
|
|
39
|
-
return {
|
|
40
|
-
url,
|
|
41
|
-
...(networkUrl ? { networkUrl } : {}),
|
|
42
|
-
bridge,
|
|
43
|
-
close: async () => {
|
|
44
|
-
await watcher.close();
|
|
45
|
-
await server.close();
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* A push-based file watcher: tracks the open file plus its `src=` dependencies,
|
|
51
|
-
* and notifies the editor when one changes on disk. Changes whose content matches
|
|
52
|
-
* what we last compiled/wrote are ignored — so the editor's own save (and no-op
|
|
53
|
-
* touches) don't masquerade as external edits.
|
|
54
|
-
*/
|
|
55
|
-
function createFileWatcher() {
|
|
56
|
-
const w = chokidar.watch([], { ignoreInitial: true, awaitWriteFinish: true });
|
|
57
|
-
const content = new Map(); // abs path → last-known content
|
|
58
|
-
let openFile = '';
|
|
59
|
-
let push = () => { };
|
|
60
|
-
const onChange = (changed) => {
|
|
61
|
-
const abs = resolve(changed);
|
|
62
|
-
if (!content.has(abs))
|
|
63
|
-
return; // not in our set
|
|
64
|
-
let text;
|
|
65
|
-
try {
|
|
66
|
-
text = readFileSync(abs, 'utf8');
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return; // deleted mid-event; ignore
|
|
70
|
-
}
|
|
71
|
-
if (text === content.get(abs))
|
|
72
|
-
return; // our own write or a no-op touch
|
|
73
|
-
content.set(abs, text);
|
|
74
|
-
push({ push: 'fileChanged', file: abs, role: abs === openFile ? 'open' : 'dep' });
|
|
75
|
-
};
|
|
76
|
-
w.on('change', onChange);
|
|
77
|
-
w.on('add', onChange);
|
|
78
|
-
return {
|
|
79
|
-
arm(file, openContents, deps) {
|
|
80
|
-
const abs = resolve(file);
|
|
81
|
-
const next = new Map([[abs, openContents]]);
|
|
82
|
-
for (const d of deps)
|
|
83
|
-
next.set(resolve(d.path), d.contents);
|
|
84
|
-
for (const old of content.keys())
|
|
85
|
-
if (!next.has(old))
|
|
86
|
-
w.unwatch(old);
|
|
87
|
-
for (const p of next.keys())
|
|
88
|
-
if (!content.has(p))
|
|
89
|
-
w.add(p);
|
|
90
|
-
content.clear();
|
|
91
|
-
for (const [k, v] of next)
|
|
92
|
-
content.set(k, v);
|
|
93
|
-
openFile = abs;
|
|
94
|
-
},
|
|
95
|
-
note(file, contents) {
|
|
96
|
-
content.set(resolve(file), contents);
|
|
97
|
-
},
|
|
98
|
-
setPush(fn) {
|
|
99
|
-
push = fn;
|
|
100
|
-
},
|
|
101
|
-
close: () => w.close(),
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
/** `virtual:scenoco-components` → import every project component + package + built-in nodes. */
|
|
105
|
-
function allComponentsPlugin(roots, packages, cwd) {
|
|
106
|
-
const id = 'virtual:scenoco-components';
|
|
107
|
-
const resolved = '\0' + id;
|
|
108
|
-
return {
|
|
109
|
-
name: 'scenoco:all-components',
|
|
110
|
-
resolveId: (source) => (source === id ? resolved : null),
|
|
111
|
-
load(source) {
|
|
112
|
-
if (source !== resolved)
|
|
113
|
-
return null;
|
|
114
|
-
const files = [...scanComponents(roots, cwd).files.keys()].map((f) => `import ${JSON.stringify(`/@fs${f}`)};`);
|
|
115
|
-
const pkgs = packages.map((p) => `import ${JSON.stringify(p)};`);
|
|
116
|
-
return ['import "@scenoco-three/core/nodes";', ...pkgs, ...files].join('\n');
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
/** `GET /@scenoco/bundle?file=<abs>` → the compiled runtime bundle (JSON). */
|
|
121
|
-
function bundleEndpointPlugin(roots, packages, cwd, watcher) {
|
|
122
|
-
return {
|
|
123
|
-
name: 'scenoco:bundle-endpoint',
|
|
124
|
-
async configureServer(server) {
|
|
125
|
-
// Register components in this Node process so compilation/validation works.
|
|
126
|
-
await loadComponents(roots, cwd, { packages }).catch(() => { });
|
|
127
|
-
// POST /@scenoco/save — surgical, attribute-level write-back to the source.
|
|
128
|
-
server.middlewares.use((req, res, next) => {
|
|
129
|
-
if (req.method !== 'POST' || !req.url || !req.url.startsWith('/@scenoco/save'))
|
|
130
|
-
return next();
|
|
131
|
-
void readJsonBody(req)
|
|
132
|
-
.then((body) => {
|
|
133
|
-
const { file, edits } = (body ?? {});
|
|
134
|
-
if (!file || !Array.isArray(edits))
|
|
135
|
-
throw new Error('expected { file, edits }');
|
|
136
|
-
const abs = resolve(cwd, file);
|
|
137
|
-
const { text, changed, unresolved } = patchSceneXml(readFileSync(abs, 'utf8'), edits);
|
|
138
|
-
if (changed > 0)
|
|
139
|
-
writeFileSync(abs, text);
|
|
140
|
-
watcher.note(abs, text); // our own write — don't report it back as an external change
|
|
141
|
-
res.setHeader('Content-Type', 'application/json');
|
|
142
|
-
res.end(JSON.stringify({ ok: true, changed, unresolved: unresolved.length }));
|
|
143
|
-
})
|
|
144
|
-
.catch((e) => {
|
|
145
|
-
res.statusCode = 400;
|
|
146
|
-
res.setHeader('Content-Type', 'application/json');
|
|
147
|
-
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
server.middlewares.use((req, res, next) => {
|
|
151
|
-
if (!req.url || !req.url.startsWith('/@scenoco/bundle'))
|
|
152
|
-
return next();
|
|
153
|
-
try {
|
|
154
|
-
const file = new URL(req.url, 'http://x').searchParams.get('file');
|
|
155
|
-
if (!file)
|
|
156
|
-
throw new Error('missing ?file=');
|
|
157
|
-
const abs = resolve(cwd, file);
|
|
158
|
-
const xml = readFileSync(abs, 'utf8');
|
|
159
|
-
const isPrefab = abs.endsWith('.prefab.xml');
|
|
160
|
-
// Track which src= files the compile touches, so we can watch them too.
|
|
161
|
-
const deps = [];
|
|
162
|
-
const trackingResolver = (request, referrer) => {
|
|
163
|
-
const r = fsResolver(request, referrer);
|
|
164
|
-
if (r)
|
|
165
|
-
deps.push({ path: r.path, contents: r.contents });
|
|
166
|
-
return r;
|
|
167
|
-
};
|
|
168
|
-
const result = isPrefab
|
|
169
|
-
? compilePrefabXml(xml, { path: abs, resolve: trackingResolver })
|
|
170
|
-
: compileSceneXml(xml, { path: abs, resolve: trackingResolver });
|
|
171
|
-
if (!result.ok) {
|
|
172
|
-
res.statusCode = 400;
|
|
173
|
-
res.setHeader('Content-Type', 'application/json');
|
|
174
|
-
res.end(JSON.stringify({ error: 'compile failed', diagnostics: result.diagnostics }));
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
watcher.arm(abs, xml, deps); // watch this file + its src deps for changes
|
|
178
|
-
const bundle = isPrefab ? bundlePrefab(result.scene) : bundleScene(result.scene);
|
|
179
|
-
res.setHeader('Content-Type', 'application/json');
|
|
180
|
-
res.end(JSON.stringify({ bundle, provenance: result.provenance }));
|
|
181
|
-
}
|
|
182
|
-
catch (e) {
|
|
183
|
-
res.statusCode = 400;
|
|
184
|
-
res.setHeader('Content-Type', 'application/json');
|
|
185
|
-
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
},
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
/** WebSocket bridge: one editor page ↔ the MCP server, with id-correlated replies. */
|
|
192
|
-
function attachBridge(server) {
|
|
193
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
194
|
-
let editor;
|
|
195
|
-
const pending = new Map();
|
|
196
|
-
server.httpServer?.on('upgrade', (req, socket, head) => {
|
|
197
|
-
// Only our path; Vite's HMR upgrades (vite-hmr subprotocol) fall through to it.
|
|
198
|
-
if (!req.url || !req.url.startsWith(WS_PATH))
|
|
199
|
-
return;
|
|
200
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
201
|
-
editor = ws;
|
|
202
|
-
ws.on('message', (data) => {
|
|
203
|
-
try {
|
|
204
|
-
const r = JSON.parse(String(data));
|
|
205
|
-
pending.get(r.id)?.(r);
|
|
206
|
-
pending.delete(r.id);
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
/* ignore malformed */
|
|
210
|
-
}
|
|
211
|
-
});
|
|
212
|
-
ws.on('close', () => {
|
|
213
|
-
if (editor === ws)
|
|
214
|
-
editor = undefined;
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
return {
|
|
219
|
-
connected: () => !!editor && editor.readyState === editor.OPEN,
|
|
220
|
-
send: (cmd) => new Promise((res, rej) => {
|
|
221
|
-
if (!editor || editor.readyState !== editor.OPEN) {
|
|
222
|
-
rej(new Error('no editor connected — open the editor page in a browser first'));
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
pending.set(cmd.id, res);
|
|
226
|
-
editor.send(JSON.stringify(cmd));
|
|
227
|
-
setTimeout(() => {
|
|
228
|
-
if (pending.delete(cmd.id))
|
|
229
|
-
rej(new Error('editor did not respond in time'));
|
|
230
|
-
}, 15000);
|
|
231
|
-
}),
|
|
232
|
-
push: (msg) => {
|
|
233
|
-
if (editor && editor.readyState === editor.OPEN)
|
|
234
|
-
editor.send(JSON.stringify(msg));
|
|
235
|
-
},
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
/** Read a request's JSON body (for the save endpoint). */
|
|
239
|
-
function readJsonBody(req) {
|
|
240
|
-
return new Promise((resolve, reject) => {
|
|
241
|
-
let data = '';
|
|
242
|
-
req.on('data', (c) => (data += c));
|
|
243
|
-
req.on('end', () => {
|
|
244
|
-
try {
|
|
245
|
-
resolve(data ? JSON.parse(data) : undefined);
|
|
246
|
-
}
|
|
247
|
-
catch (e) {
|
|
248
|
-
reject(e instanceof Error ? e : new Error(String(e)));
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
req.on('error', reject);
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
/** Ask the OS for a free TCP port (avoids Vite's 5173-default collisions). */
|
|
255
|
-
function freePort() {
|
|
256
|
-
return new Promise((res, rej) => {
|
|
257
|
-
const probe = createNetServer();
|
|
258
|
-
probe.on('error', rej);
|
|
259
|
-
probe.listen(0, () => {
|
|
260
|
-
const port = probe.address().port;
|
|
261
|
-
probe.close(() => res(port));
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
}
|