@scenoco-three/editor 0.2.0 → 0.3.1
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/dist/cli/index.js +40 -15
- package/dist/protocol.d.ts +10 -6
- package/dist/server.d.ts +15 -2
- package/dist/server.js +149 -39
- package/package.json +4 -4
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/dist/cli/index.js
CHANGED
|
@@ -7,45 +7,70 @@ import { startMcpServer } from '../mcp.js';
|
|
|
7
7
|
const USAGE = `scenoco-editor — open a scene/prefab and drive it from an MCP client
|
|
8
8
|
|
|
9
9
|
Usage:
|
|
10
|
-
scenoco-editor
|
|
10
|
+
scenoco-editor [scene|prefab] [--port <n>] [--no-open]
|
|
11
|
+
|
|
12
|
+
With no file, the editor opens a picker listing the scenes/prefabs in the
|
|
13
|
+
project's assets/ folder. Components are scanned from that assets/ folder and tag
|
|
14
|
+
packages are read from its package.json — no --components/--packages needed.
|
|
11
15
|
|
|
12
16
|
Options:
|
|
13
|
-
--
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
--no-open
|
|
17
|
+
--port <n> Fixed MCP server port — set this so the endpoint an MCP client
|
|
18
|
+
connects to is stable across restarts (the editor's own HTTP
|
|
19
|
+
server always takes a free port; it doesn't need to be fixed)
|
|
20
|
+
--no-open Don't auto-open the editor in a browser
|
|
17
21
|
|
|
18
|
-
Starts a local editor
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
Starts a local editor and an MCP server. The editor opens in your default
|
|
23
|
+
browser; when you stop the server (Ctrl+C) it tells the page to close its own
|
|
24
|
+
tab, so no stale tab is left behind. Point an MCP client at the printed
|
|
25
|
+
endpoint; its tools — open_file, get_preview, run, pause, stop, get_telemetry —
|
|
26
|
+
drive the live editor.`;
|
|
21
27
|
async function main(argv) {
|
|
22
28
|
const { positionals, options } = parseArgs(argv);
|
|
23
29
|
const file = positionals[0];
|
|
24
|
-
if (file ===
|
|
30
|
+
if (file === '-h' || file === '--help') {
|
|
25
31
|
console.log(USAGE);
|
|
26
|
-
return
|
|
32
|
+
return 0;
|
|
27
33
|
}
|
|
28
34
|
// npm/npx rewrites process.cwd() to the nearest enclosing package.json when
|
|
29
35
|
// run from inside a workspace package without its own node_modules/.bin
|
|
30
36
|
// (e.g. `npx scenoco-editor` from examples/breakout/scenes). INIT_CWD still
|
|
31
37
|
// holds the shell's actual directory.
|
|
32
38
|
const cwd = process.env.INIT_CWD || process.cwd();
|
|
39
|
+
// Components default to assets/ and tag packages to the project's package.json
|
|
40
|
+
// deps (see startEditorServer); --components/--packages remain as optional
|
|
41
|
+
// overrides for non-standard layouts.
|
|
42
|
+
// The editor's HTTP server can float on any free port — the browser is pointed
|
|
43
|
+
// at whatever it gets. `--port` fixes the MCP endpoint instead, so an MCP
|
|
44
|
+
// client's configured URL stays valid across editor restarts.
|
|
33
45
|
const editor = await startEditorServer({
|
|
34
46
|
cwd,
|
|
35
|
-
componentRoots: parseList(options.components)
|
|
36
|
-
packages: parseList(options.packages)
|
|
47
|
+
...(parseList(options.components) ? { componentRoots: parseList(options.components) } : {}),
|
|
48
|
+
...(parseList(options.packages) ? { packages: parseList(options.packages) } : {}),
|
|
49
|
+
});
|
|
50
|
+
const mcp = await startMcpServer(editor.bridge, {
|
|
37
51
|
...(options.port ? { port: Number(options.port) } : {}),
|
|
38
52
|
});
|
|
39
|
-
|
|
40
|
-
const
|
|
53
|
+
// With no file, open bare — the editor page shows a picker of the assets/ scenes.
|
|
54
|
+
const query = file ? `?file=${encodeURIComponent(resolve(cwd, file))}` : '';
|
|
55
|
+
const appUrl = `${editor.url}${query}`;
|
|
41
56
|
console.log(`\n editor: ${appUrl}`);
|
|
42
57
|
if (editor.networkUrl)
|
|
43
|
-
console.log(` wi-fi: ${editor.networkUrl}
|
|
58
|
+
console.log(` wi-fi: ${editor.networkUrl}${query}`);
|
|
44
59
|
console.log(` mcp: ${mcp.url} ← add this to your MCP client (transport: http)`);
|
|
45
60
|
console.log(`\n press Ctrl+C to stop\n`);
|
|
46
61
|
if (!('no-open' in options))
|
|
47
62
|
openBrowser(appUrl);
|
|
63
|
+
let closing = false;
|
|
48
64
|
const shutdown = async () => {
|
|
65
|
+
if (closing)
|
|
66
|
+
return;
|
|
67
|
+
closing = true;
|
|
68
|
+
// Tell the editor page to close its own tab, then give the notification a
|
|
69
|
+
// moment to reach the browser before we tear the socket down. Works in any
|
|
70
|
+
// browser/OS (the tab was opened with the platform's default `open`), though
|
|
71
|
+
// window.close() is best-effort — see the app's wsClient for the fallback.
|
|
72
|
+
editor.bridge.push({ push: 'shutdown' });
|
|
73
|
+
await new Promise((r) => setTimeout(r, 250).unref());
|
|
49
74
|
// Connected browsers keep WS/HMR/keep-alive sockets open, so `close()` can
|
|
50
75
|
// stall waiting for them to drain — cap it so Ctrl+C always exits promptly.
|
|
51
76
|
const deadline = new Promise((r) => setTimeout(r, 1500).unref());
|
package/dist/protocol.d.ts
CHANGED
|
@@ -92,16 +92,20 @@ export type EditorResponse = {
|
|
|
92
92
|
error: string;
|
|
93
93
|
};
|
|
94
94
|
/**
|
|
95
|
-
* An unsolicited server→editor notification (not id-correlated).
|
|
96
|
-
*
|
|
97
|
-
*
|
|
95
|
+
* An unsolicited server→editor notification (not id-correlated).
|
|
96
|
+
*
|
|
97
|
+
* - `fileChanged`: a watched file changed on disk — the open scene/prefab
|
|
98
|
+
* (`role: 'open'`) or one of its `src=` deps, a prefab/material (`role: 'dep'`).
|
|
99
|
+
* - `shutdown`: the dev server is stopping; the editor page closes itself so no
|
|
100
|
+
* stale tab is left behind.
|
|
98
101
|
*/
|
|
99
|
-
export
|
|
102
|
+
export type EditorPush = {
|
|
100
103
|
push: 'fileChanged';
|
|
101
|
-
/** Absolute path of the changed file. */
|
|
102
104
|
file: string;
|
|
103
105
|
role: 'open' | 'dep';
|
|
104
|
-
}
|
|
106
|
+
} | {
|
|
107
|
+
push: 'shutdown';
|
|
108
|
+
};
|
|
105
109
|
/** Result payloads, discriminated by `kind`. */
|
|
106
110
|
export type EditorResult = {
|
|
107
111
|
kind: 'ok';
|
package/dist/server.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type EditorCommand, type EditorPush, type EditorResponse } from './protocol.js';
|
|
2
2
|
export interface ServerOptions {
|
|
3
|
-
/** Directories scanned for `@component`/`@node` tags (default `['
|
|
3
|
+
/** Directories scanned for `@component`/`@node` tags (default `['assets']`). */
|
|
4
4
|
componentRoots?: string[];
|
|
5
|
-
/** Tag packages to register (
|
|
5
|
+
/** Tag packages to register (default: the `@scenoco-three/*` deps in package.json). */
|
|
6
6
|
packages?: string[];
|
|
7
7
|
/** Fixed port (default: a free OS port). */
|
|
8
8
|
port?: number;
|
|
@@ -42,3 +42,16 @@ export interface EditorServer {
|
|
|
42
42
|
* to the MCP server over a WebSocket.
|
|
43
43
|
*/
|
|
44
44
|
export declare function startEditorServer(opts?: ServerOptions): Promise<EditorServer>;
|
|
45
|
+
/** A scene/prefab source the picker can open, addressed relative to `cwd`. */
|
|
46
|
+
export interface SceneFile {
|
|
47
|
+
/** Path relative to `cwd` — what the editor passes to `?file=` / `open()`. */
|
|
48
|
+
file: string;
|
|
49
|
+
/** Whether it's a scene or a prefab, for the picker's label. */
|
|
50
|
+
kind: 'scene' | 'prefab';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Every `*.scene.xml` / `*.prefab.xml` under the asset `roots` (default assets/),
|
|
54
|
+
* relative to `cwd`. Isolated to the roots — the walk never escapes them, and
|
|
55
|
+
* build/cache dirs are skipped. Sorted for a stable picker order.
|
|
56
|
+
*/
|
|
57
|
+
export declare function listSceneFiles(roots: string[], cwd: string): SceneFile[];
|
package/dist/server.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createServer as createNetServer } from 'node:net';
|
|
3
|
-
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { dirname, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import chokidar from 'chokidar';
|
|
6
6
|
import { createServer } from 'vite';
|
|
7
7
|
import { WebSocketServer } from 'ws';
|
|
8
|
-
import { bundlePrefab, bundleScene, compilePrefabXml, compileSceneXml, loadComponents, patchSceneXml, scanComponents, } from '@scenoco-three/compiler';
|
|
8
|
+
import { bundlePrefab, bundleScene, collectedPackageSchemas, compilePrefabXml, compileSceneXml, componentLoadError, detectTagPackages, generateMaterialXsd, generatePrefabXsd, generateXsd, loadComponents, patchSceneXml, scanComponents, } from '@scenoco-three/compiler';
|
|
9
9
|
import { WS_PATH } from './protocol.js';
|
|
10
10
|
const appDir = fileURLToPath(new URL('../app', import.meta.url));
|
|
11
11
|
/** Loads `src=` resources from disk, relative to the referencing file. */
|
|
@@ -13,16 +13,64 @@ const fsResolver = (request, referrer) => {
|
|
|
13
13
|
const path = resolve(referrer ? dirname(referrer) : '.', request);
|
|
14
14
|
return existsSync(path) ? { path, contents: readFileSync(path, 'utf8') } : null;
|
|
15
15
|
};
|
|
16
|
+
/**
|
|
17
|
+
* (Re)register the project's components (change-aware — a no-op when nothing
|
|
18
|
+
* changed) and, while the registry is healthy, keep the project's `schema/` XSDs
|
|
19
|
+
* in sync. A corrupted component file no longer wipes the registry: the last-good
|
|
20
|
+
* set stays live and the failure is returned for the editor/MCP to surface.
|
|
21
|
+
*/
|
|
22
|
+
async function refreshComponents(roots, cwd, packages) {
|
|
23
|
+
await loadComponents(roots, cwd, { packages }).catch(() => { });
|
|
24
|
+
const error = componentLoadError();
|
|
25
|
+
if (!error)
|
|
26
|
+
regenerateSchemas(cwd);
|
|
27
|
+
return error;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Rewrite `schema/{scene,prefab,material}.xsd` plus each tag package's XSD from the
|
|
31
|
+
* live registry, so an IDE linked to them tracks component edits without a manual
|
|
32
|
+
* `scenoco export`. Opt-in: only maintains an *existing* `schema/` dir; writes only
|
|
33
|
+
* files whose content changed (no git churn, no watcher feedback loop).
|
|
34
|
+
*/
|
|
35
|
+
function regenerateSchemas(cwd) {
|
|
36
|
+
const dir = resolve(cwd, 'schema');
|
|
37
|
+
if (!existsSync(dir))
|
|
38
|
+
return;
|
|
39
|
+
writeIfChanged(resolve(dir, 'scene.xsd'), generateXsd());
|
|
40
|
+
writeIfChanged(resolve(dir, 'prefab.xsd'), generatePrefabXsd());
|
|
41
|
+
writeIfChanged(resolve(dir, 'material.xsd'), generateMaterialXsd());
|
|
42
|
+
for (const schema of collectedPackageSchemas()) {
|
|
43
|
+
try {
|
|
44
|
+
writeIfChanged(resolve(dir, schema.filename), readFileSync(schema.path, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* a package XSD went missing — skip it */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function writeIfChanged(path, content) {
|
|
52
|
+
try {
|
|
53
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === content)
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* unreadable — fall through and overwrite */
|
|
58
|
+
}
|
|
59
|
+
writeFileSync(path, content);
|
|
60
|
+
}
|
|
16
61
|
/**
|
|
17
62
|
* Start the editor's Vite dev server: serves the app, registers all project
|
|
18
63
|
* components/packages, compiles any scene/prefab on demand, and bridges the page
|
|
19
64
|
* to the MCP server over a WebSocket.
|
|
20
65
|
*/
|
|
21
66
|
export async function startEditorServer(opts = {}) {
|
|
22
|
-
const componentRoots = opts.componentRoots ?? ['src'];
|
|
23
|
-
const packages = opts.packages ?? [];
|
|
24
|
-
const port = opts.port ?? (await freePort());
|
|
25
67
|
const cwd = opts.cwd ?? process.cwd();
|
|
68
|
+
// Convention over configuration: scan `assets/` for components and register the
|
|
69
|
+
// `@scenoco-three/*` tag packages listed in the project's package.json, so the
|
|
70
|
+
// editor needs no `--components`/`--packages`.
|
|
71
|
+
const componentRoots = opts.componentRoots ?? ['assets'];
|
|
72
|
+
const packages = opts.packages ?? detectTagPackages(cwd);
|
|
73
|
+
const port = opts.port ?? (await freePort());
|
|
26
74
|
const watcher = createFileWatcher();
|
|
27
75
|
const server = await createServer({
|
|
28
76
|
configFile: false,
|
|
@@ -122,8 +170,9 @@ function bundleEndpointPlugin(roots, packages, cwd, watcher) {
|
|
|
122
170
|
return {
|
|
123
171
|
name: 'scenoco:bundle-endpoint',
|
|
124
172
|
async configureServer(server) {
|
|
125
|
-
// Register components in this Node process so compilation/validation works
|
|
126
|
-
|
|
173
|
+
// Register components in this Node process so compilation/validation works,
|
|
174
|
+
// and publish the project's XSDs (if it has a schema/ dir).
|
|
175
|
+
await refreshComponents(roots, cwd, packages);
|
|
127
176
|
// POST /@scenoco/save — surgical, attribute-level write-back to the source.
|
|
128
177
|
server.middlewares.use((req, res, next) => {
|
|
129
178
|
if (req.method !== 'POST' || !req.url || !req.url.startsWith('/@scenoco/save'))
|
|
@@ -147,43 +196,72 @@ function bundleEndpointPlugin(roots, packages, cwd, watcher) {
|
|
|
147
196
|
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
|
|
148
197
|
});
|
|
149
198
|
});
|
|
199
|
+
// GET /@scenoco/files — the scenes/prefabs under the project's asset roots,
|
|
200
|
+
// for the start-up file picker (used when the editor is opened with no ?file=).
|
|
201
|
+
// Isolated to `roots` (default assets/): nothing outside them is listed.
|
|
202
|
+
server.middlewares.use((req, res, next) => {
|
|
203
|
+
if (!req.url || !req.url.startsWith('/@scenoco/files'))
|
|
204
|
+
return next();
|
|
205
|
+
res.setHeader('Content-Type', 'application/json');
|
|
206
|
+
res.end(JSON.stringify({ files: listSceneFiles(roots, cwd) }));
|
|
207
|
+
});
|
|
150
208
|
server.middlewares.use((req, res, next) => {
|
|
151
209
|
if (!req.url || !req.url.startsWith('/@scenoco/bundle'))
|
|
152
210
|
return next();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
211
|
+
void (async () => {
|
|
212
|
+
// Pick up any component edits (and re-sync schema/) before compiling, with
|
|
213
|
+
// last-good fallback — a broken component is reported, not fatal.
|
|
214
|
+
const compError = await refreshComponents(roots, cwd, packages);
|
|
215
|
+
try {
|
|
216
|
+
const file = new URL(req.url, 'http://x').searchParams.get('file');
|
|
217
|
+
if (!file)
|
|
218
|
+
throw new Error('missing ?file=');
|
|
219
|
+
const abs = resolve(cwd, file);
|
|
220
|
+
const xml = readFileSync(abs, 'utf8');
|
|
221
|
+
const isPrefab = abs.endsWith('.prefab.xml');
|
|
222
|
+
// Track which src= files the compile touches, so we can watch them too.
|
|
223
|
+
const deps = [];
|
|
224
|
+
const trackingResolver = (request, referrer) => {
|
|
225
|
+
const r = fsResolver(request, referrer);
|
|
226
|
+
if (r)
|
|
227
|
+
deps.push({ path: r.path, contents: r.contents });
|
|
228
|
+
return r;
|
|
229
|
+
};
|
|
230
|
+
const result = isPrefab
|
|
231
|
+
? compilePrefabXml(xml, { path: abs, resolve: trackingResolver })
|
|
232
|
+
: compileSceneXml(xml, { path: abs, resolve: trackingResolver });
|
|
233
|
+
if (!result.ok) {
|
|
234
|
+
res.statusCode = 400;
|
|
235
|
+
res.setHeader('Content-Type', 'application/json');
|
|
236
|
+
// A component load failure is the likely root cause of an "unknown tag"
|
|
237
|
+
// compile error — pass it along so the editor can explain it.
|
|
238
|
+
res.end(JSON.stringify({ error: 'compile failed', diagnostics: result.diagnostics, componentError: compError?.message }));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// A scene's sibling `*.ui.html` (its DOM UI overlay) is embedded in the
|
|
242
|
+
// bundle and watched as a dep, so editing it live-refreshes the overlay
|
|
243
|
+
// — visible statically even in edit mode (engine.overlay).
|
|
244
|
+
if (!isPrefab) {
|
|
245
|
+
const uiFile = abs.replace(/\.(scene\.)?xml$/, '.ui.html');
|
|
246
|
+
if (existsSync(uiFile)) {
|
|
247
|
+
const uiHtml = readFileSync(uiFile, 'utf8');
|
|
248
|
+
result.scene.ui = uiHtml;
|
|
249
|
+
deps.push({ path: uiFile, contents: uiHtml });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
watcher.arm(abs, xml, deps); // watch this file + its src/ui deps for changes
|
|
253
|
+
const bundle = isPrefab ? bundlePrefab(result.scene) : bundleScene(result.scene);
|
|
254
|
+
res.setHeader('Content-Type', 'application/json');
|
|
255
|
+
// `componentError` is present when the scene still compiled (stale last-good
|
|
256
|
+
// registry) but a component file is currently broken — a warning, not fatal.
|
|
257
|
+
res.end(JSON.stringify({ bundle, provenance: result.provenance, componentError: compError?.message }));
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
172
260
|
res.statusCode = 400;
|
|
173
261
|
res.setHeader('Content-Type', 'application/json');
|
|
174
|
-
res.end(JSON.stringify({ error:
|
|
175
|
-
return;
|
|
262
|
+
res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e), componentError: compError?.message }));
|
|
176
263
|
}
|
|
177
|
-
|
|
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
|
-
}
|
|
264
|
+
})();
|
|
187
265
|
});
|
|
188
266
|
},
|
|
189
267
|
};
|
|
@@ -251,6 +329,38 @@ function readJsonBody(req) {
|
|
|
251
329
|
req.on('error', reject);
|
|
252
330
|
});
|
|
253
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Every `*.scene.xml` / `*.prefab.xml` under the asset `roots` (default assets/),
|
|
334
|
+
* relative to `cwd`. Isolated to the roots — the walk never escapes them, and
|
|
335
|
+
* build/cache dirs are skipped. Sorted for a stable picker order.
|
|
336
|
+
*/
|
|
337
|
+
export function listSceneFiles(roots, cwd) {
|
|
338
|
+
const found = new Map(); // keyed by file, dedupes overlapping roots
|
|
339
|
+
const skip = new Set(['node_modules', 'dist', '.scenoco', '.git']);
|
|
340
|
+
const walk = (dir) => {
|
|
341
|
+
let entries;
|
|
342
|
+
try {
|
|
343
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return; // missing/unreadable root — nothing to list
|
|
347
|
+
}
|
|
348
|
+
for (const e of entries) {
|
|
349
|
+
const abs = resolve(dir, e.name);
|
|
350
|
+
if (e.isDirectory()) {
|
|
351
|
+
if (!e.name.startsWith('.') && !skip.has(e.name))
|
|
352
|
+
walk(abs);
|
|
353
|
+
}
|
|
354
|
+
else if (e.name.endsWith('.scene.xml') || e.name.endsWith('.prefab.xml')) {
|
|
355
|
+
const file = relative(cwd, abs).split(sep).join('/');
|
|
356
|
+
found.set(file, { file, kind: e.name.endsWith('.prefab.xml') ? 'prefab' : 'scene' });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
for (const root of roots)
|
|
361
|
+
walk(resolve(cwd, root));
|
|
362
|
+
return [...found.values()].sort((a, b) => a.file.localeCompare(b.file));
|
|
363
|
+
}
|
|
254
364
|
/** Ask the OS for a free TCP port (avoids Vite's 5173-default collisions). */
|
|
255
365
|
function freePort() {
|
|
256
366
|
return new Promise((res, rej) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scenoco-three/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
},
|