@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 CHANGED
@@ -13,17 +13,20 @@ npm i -D @scenoco-three/editor
13
13
  ## CLI
14
14
 
15
15
  ```
16
- scenoco-editor <scene|prefab> [options]
17
- --components <dirs> Comma-separated component dirs (default: "src")
18
- --packages <pkgs> Comma-separated tag packages (e.g. @scenoco-three/box2d)
19
- --port <n> Fixed editor server port (default: a free port)
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 scenes/level.scene.xml --components src --packages @scenoco-three/box2d
25
- # editor: http://localhost:PORT?file=… ← opens in your browser
26
- # mcp: http://localhost:PORT/mcp ← add to your MCP client (transport: http)
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: 'key'; code: string; action: 'down' | 'up' | 'tap' }
54
- | { do: 'mouse'; x?: number; y?: number; dx?: number; dy?: number;
55
- button?: 'left' | 'right' | 'middle'; action?: 'move' | 'down' | 'up' | 'click' }
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": "key", "code": "Space", "action": "tap" },
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, componentRoots: ['src'], packages: [] });
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
- try {
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 { bundle: Bundle; provenance: SceneProvenance } | { error: string };
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
- throw new Error((data as { error?: string }).error ?? 'failed to open file');
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
- return { file: path, kind: bundleKind(this.bundle) === 'prefab' ? 'prefab' : 'scene', nodes: count(this.engine.scene) };
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 center = new Vector3();
232
- let radius = 1;
233
- if (!box.isEmpty()) {
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 push (a watched file changed) — not an id-correlated command.
24
+ // Unsolicited server pushes — not id-correlated commands.
23
25
  if ('push' in msg) {
24
- session.onExternalChange?.(msg.file, msg.role);
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 = () => setTimeout(open, 1000);
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 <scene|prefab> [options]
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
- --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
+ --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 (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.`;
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 === undefined || file === '-h' || file === '--help') {
30
+ if (file === '-h' || file === '--help') {
25
31
  console.log(USAGE);
26
- return file === undefined ? 1 : 0;
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) ?? ['src'],
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
- const mcp = await startMcpServer(editor.bridge);
40
- const appUrl = `${editor.url}?file=${encodeURIComponent(resolve(cwd, file))}`;
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}?file=${encodeURIComponent(resolve(cwd, file))}`);
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());
@@ -92,16 +92,20 @@ export type EditorResponse = {
92
92
  error: string;
93
93
  };
94
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'`).
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 interface EditorPush {
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 `['src']`). */
3
+ /** Directories scanned for `@component`/`@node` tags (default `['assets']`). */
4
4
  componentRoots?: string[];
5
- /** Tag packages to register (e.g. `['@scenoco-three/box2d']`). */
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
- await loadComponents(roots, cwd, { packages }).catch(() => { });
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
- 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) {
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: 'compile failed', diagnostics: result.diagnostics }));
175
- return;
262
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e), componentError: compError?.message }));
176
263
  }
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
- }
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.2.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.2.0",
41
- "@scenoco-three/vite": "^0.2.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.2.0",
46
+ "@scenoco-three/core": "^0.3.0",
47
47
  "three": ">=0.160",
48
48
  "vite": ">=5"
49
49
  },