@scenoco-three/editor 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/main.ts CHANGED
@@ -217,6 +217,9 @@ let reloading = false;
217
217
  let conflictBanner: HTMLElement | undefined;
218
218
 
219
219
  session.onExternalChange = (_file, role) => void handleExternalChange(role);
220
+ // A gameplay frame that throws surfaces in the same banner; playback is already
221
+ // paused by the session, so the editor stays alive until the code is fixed.
222
+ session.onRuntimeError = (message) => setComponentError(message);
220
223
 
221
224
  async function handleExternalChange(role: 'open' | 'dep'): Promise<void> {
222
225
  if (reloading) return;
@@ -322,11 +325,19 @@ async function openFile(path: string): Promise<void> {
322
325
  }
323
326
 
324
327
  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.
328
+ // Core tags + tag packages first, as their own import — this is the registry
329
+ // every core scene decodes against, so it must NOT depend on project components
330
+ // loading (a broken component previously cascaded into "Background not registered").
331
+ try {
332
+ await import('virtual:scenoco-runtime');
333
+ } catch (e) {
334
+ console.error('[editor] runtime tags failed to load:', e);
335
+ setComponentError(e instanceof Error ? e.message : String(e));
336
+ }
337
+ // Project components, imported separately + caught so a broken component file
338
+ // can't abort page startup or wipe the core tags above: the WS bridge (hence
339
+ // MCP) and the error banner still come up, and core scenes still preview. A full
340
+ // reload on the next successful HMR update revives components once they're fixed.
330
341
  try {
331
342
  await import('virtual:scenoco-components');
332
343
  } catch (e) {
package/app/session.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  AmbientLight, Box3, Color, DirectionalLight, PerspectiveCamera, Sphere, Vector3, type Object3D,
3
3
  } from 'three';
4
- import { Engine, Registry, bundleKind, type Bundle, type Component } from '@scenoco-three/core';
4
+ import { Engine, PrefabRef, Registry, bundleKind, type Bundle, type Component } from '@scenoco-three/core';
5
5
  import type { SceneProvenance } from '@scenoco-three/compiler';
6
6
  import type { EditOp, EditorResult, SimStep } from '../dist/protocol.js';
7
7
 
@@ -19,10 +19,19 @@ export class EditorSession {
19
19
  playing = false;
20
20
  /** The editor camera (edit mode only). */
21
21
  editorCamera?: PerspectiveCamera;
22
+ /** A reused framing camera for play-mode previews of scenes/prefabs that have no camera of their own. */
23
+ private previewCamera?: PerspectiveCamera;
22
24
  /** Fired after the engine/scene changes, so the host rebinds camera + panels. */
23
25
  onChange?: () => void;
24
26
  /** Fired when a watched file changes on disk (the open file or a `src=` dep). */
25
27
  onExternalChange?: (file: string, role: 'open' | 'dep') => void;
28
+ /**
29
+ * Fired when a gameplay frame throws (a component hook / system error). The host
30
+ * shows it in the same "component code has an error" banner; the session stays
31
+ * alive (playback paused) so a fix + HMR reload recovers, rather than the RAF
32
+ * loop dying or the MCP session wedging.
33
+ */
34
+ onRuntimeError?: (message: string) => void;
26
35
 
27
36
  private bundle?: Bundle;
28
37
  /** Source path of the open scene/prefab — the target of `save`. */
@@ -86,7 +95,7 @@ export class EditorSession {
86
95
  /** Advance gameplay by a single frame while paused (no-op outside play mode). */
87
96
  step(dt = 1 / 60): void {
88
97
  if (this.mode !== 'play') return;
89
- this.engine.tick(dt); // bypass the play+paused gate in tick()
98
+ this.runTick(dt); // bypass the play+paused gate in tick()
90
99
  }
91
100
  stop(): void {
92
101
  this.toEdit();
@@ -94,18 +103,45 @@ export class EditorSession {
94
103
 
95
104
  /** Advance one frame: edit always renders; play advances only while playing. */
96
105
  tick(dt: number): void {
97
- this.engine.tick(this.mode === 'play' && !this.playing ? 0 : dt);
106
+ this.runTick(this.mode === 'play' && !this.playing ? 0 : dt);
107
+ }
108
+
109
+ /**
110
+ * Drive the engine one frame, catching any error a component hook / system
111
+ * throws so a single bad frame can't kill the RAF loop or the MCP session.
112
+ * On failure it pauses playback (so the failing frame isn't re-run every RAF)
113
+ * and surfaces the error to the host banner. Returns the error message, if any.
114
+ */
115
+ private runTick(dt: number): string | undefined {
116
+ try {
117
+ this.engine.tick(dt);
118
+ return undefined;
119
+ } catch (e) {
120
+ this.playing = false;
121
+ const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
122
+ this.onRuntimeError?.(message);
123
+ return message;
124
+ }
98
125
  }
99
126
 
100
127
  /** Capture the current view as a PNG data URL (optionally re-framing the editor camera). */
101
128
  preview(opts: { camera?: string; lookAt?: string }): string {
102
- if (this.mode === 'edit' && this.editorCamera) {
103
- const cam = vec3(opts.camera);
104
- const look = vec3(opts.lookAt);
105
- if (cam) this.editorCamera.position.copy(cam);
106
- if (look) this.editorCamera.lookAt(look);
129
+ if (this.mode === 'edit') {
130
+ if (this.editorCamera) {
131
+ const cam = vec3(opts.camera);
132
+ const look = vec3(opts.lookAt);
133
+ if (cam) this.editorCamera.position.copy(cam);
134
+ if (look) this.editorCamera.lookAt(look);
135
+ }
136
+ // Particles only emit under their play-mode render system — render a short
137
+ // throwaway play burst so a VFX prefab isn't previewed empty while authoring.
138
+ if (this.hasParticles()) return this.particlePreview(opts);
139
+ } else {
140
+ // Play mode: honor the camera override (P2) and give a camera-less lone prefab
141
+ // a default framed camera + backdrop, so it isn't a blank white frame.
142
+ this.framePlayPreview(opts);
107
143
  }
108
- this.engine.tick(0); // render the current frame
144
+ this.runTick(0); // render the current frame (guarded — a bad frame won't wedge the session)
109
145
  return this.canvas.toDataURL('image/png');
110
146
  }
111
147
 
@@ -156,7 +192,12 @@ export class EditorSession {
156
192
  case 'step': {
157
193
  const frames = Math.max(0, Math.floor(step.frames ?? 1));
158
194
  const dt = step.dt ?? 1 / 60;
159
- for (let i = 0; i < frames; i++) this.engine.tick(dt);
195
+ for (let i = 0; i < frames; i++) {
196
+ // A throwing frame surfaces as a result (not a bare transport error)
197
+ // and stops the batch, leaving the session alive for the agent to inspect.
198
+ const error = this.runTick(dt);
199
+ if (error) { out.push({ kind: 'json', data: { error } }); return out; }
200
+ }
160
201
  break;
161
202
  }
162
203
  case 'tap':
@@ -212,35 +253,100 @@ export class EditorSession {
212
253
  }
213
254
 
214
255
  private applyBundle(): void {
215
- if (!this.bundle) return;
216
- if (bundleKind(this.bundle) === 'prefab') {
217
- this.engine.instantiate(this.bundle);
218
- // Editor-only preview lights — flagged so the Hierarchy/picking ignore them.
219
- const ambient = new AmbientLight(0xffffff, 0.6);
220
- ambient.userData.scenocoEditorOnly = true;
221
- this.engine.scene.add(ambient);
222
- const key = new DirectionalLight(0xffffff, 2);
223
- key.position.set(3, 5, 4);
224
- key.userData.scenocoEditorOnly = true;
225
- this.engine.scene.add(key);
226
- if (!this.engine.scene.background) this.engine.scene.background = new Color(0x15181f);
227
- } else {
228
- this.engine.loadScene(this.bundle);
229
- }
256
+ if (this.bundle) applyBundleToEngine(this.engine, this.bundle);
230
257
  }
231
258
 
232
259
  /** Editor camera framing the scene from a 3/4 angle. */
233
260
  private frameCamera(): PerspectiveCamera {
234
261
  const cam = new PerspectiveCamera(50, this.canvas.clientWidth / this.canvas.clientHeight || 1, 0.01, 50000);
235
- const box = new Box3().setFromObject(this.engine.scene);
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;
239
- cam.position.copy(center).addScaledVector(new Vector3(0.6, 0.45, 1).normalize(), (radius / Math.sin((50 * Math.PI) / 180 / 2)) * 1.2);
240
- cam.lookAt(center);
262
+ frameCameraTo(cam, this.engine.scene);
241
263
  this.engine.mainCamera = cam;
242
264
  return cam;
243
265
  }
266
+
267
+ /** Any live `ParticleSystem` component in the scene — VFX that only emits under its play-mode render system. */
268
+ private hasParticles(): boolean {
269
+ const ctor = Registry.getComponent('ParticleSystem')?.ctor;
270
+ return ctor ? this.engine.getComponentsOfType(ctor).length > 0 : false;
271
+ }
272
+
273
+ /**
274
+ * Frame a preview for the current play scene: honor a `camera`/`lookAt` override
275
+ * (P2 — worked only in edit mode before), and give a scene with no camera of its
276
+ * own (a lone prefab) a default framed camera + neutral backdrop so it isn't a
277
+ * blank white frame. Left untouched when the scene has its own camera and no override.
278
+ */
279
+ private framePlayPreview(opts: { camera?: string; lookAt?: string }): void {
280
+ const camPos = vec3(opts.camera);
281
+ const lookAt = vec3(opts.lookAt);
282
+ if (this.engine.mainCamera && !camPos && !lookAt) return; // scene's own camera, no override — leave it
283
+ const cam = (this.previewCamera ??= new PerspectiveCamera(50, 1, 0.01, 50000));
284
+ cam.aspect = this.canvas.clientWidth / this.canvas.clientHeight || 1;
285
+ const center = frameCameraTo(cam, this.engine.scene); // auto-frame, then apply any override
286
+ if (camPos) cam.position.copy(camPos);
287
+ if (lookAt) cam.lookAt(lookAt);
288
+ else if (camPos) cam.lookAt(center);
289
+ cam.updateProjectionMatrix();
290
+ this.engine.mainCamera = cam; // not one of the scene's cameras → a render override
291
+ if (!this.engine.scene.background) this.engine.scene.background = new Color(0x15181f);
292
+ }
293
+
294
+ /**
295
+ * Render a particle-bearing file in a throwaway play engine advanced a few
296
+ * frames, so its VFX is actually visible when previewed while authoring (in edit
297
+ * mode the particle render system never runs, so the effect shows empty). The
298
+ * edit session's own engine is left untouched; the next frame re-renders it.
299
+ */
300
+ private particlePreview(opts: { camera?: string; lookAt?: string }): string {
301
+ const engine = new Engine({ canvas: this.canvas, autoStart: false, editor: false, preserveDrawingBuffer: true });
302
+ try {
303
+ if (this.bundle) applyBundleToEngine(engine, this.bundle);
304
+ const cam = new PerspectiveCamera(50, this.canvas.clientWidth / this.canvas.clientHeight || 1, 0.01, 50000);
305
+ const center = frameCameraTo(cam, engine.scene);
306
+ const camPos = vec3(opts.camera);
307
+ const lookAt = vec3(opts.lookAt);
308
+ if (camPos) cam.position.copy(camPos);
309
+ if (lookAt || camPos) cam.lookAt(lookAt ?? center);
310
+ cam.updateProjectionMatrix();
311
+ engine.mainCamera = cam;
312
+ for (let i = 0; i < PARTICLE_PREVIEW_FRAMES; i++) engine.tick(1 / 60);
313
+ return this.canvas.toDataURL('image/png');
314
+ } finally {
315
+ engine.dispose();
316
+ }
317
+ }
318
+ }
319
+
320
+ /** Frames to advance a particle preview so the effect has visibly emitted. */
321
+ const PARTICLE_PREVIEW_FRAMES = 30;
322
+
323
+ /** Instantiate a bundle into `engine`: a prefab gets neutral preview lights + backdrop; a scene loads as-is. */
324
+ function applyBundleToEngine(engine: Engine, bundle: Bundle): void {
325
+ if (bundleKind(bundle) === 'prefab') {
326
+ engine.instantiate(bundle);
327
+ // Editor-only preview lights — flagged so the Hierarchy/picking ignore them.
328
+ const ambient = new AmbientLight(0xffffff, 0.6);
329
+ ambient.userData.scenocoEditorOnly = true;
330
+ engine.scene.add(ambient);
331
+ const key = new DirectionalLight(0xffffff, 2);
332
+ key.position.set(3, 5, 4);
333
+ key.userData.scenocoEditorOnly = true;
334
+ engine.scene.add(key);
335
+ if (!engine.scene.background) engine.scene.background = new Color(0x15181f);
336
+ } else {
337
+ engine.loadScene(bundle);
338
+ }
339
+ }
340
+
341
+ /** Position `cam` to frame `scene` from a 3/4 angle; returns the scene center it looks at. */
342
+ function frameCameraTo(cam: PerspectiveCamera, scene: Object3D): Vector3 {
343
+ const box = new Box3().setFromObject(scene);
344
+ const sphere = box.isEmpty() ? new Sphere(new Vector3(), 1) : box.getBoundingSphere(new Sphere());
345
+ const center = sphere.center;
346
+ const radius = sphere.radius || 1;
347
+ cam.position.copy(center).addScaledVector(new Vector3(0.6, 0.45, 1).normalize(), (radius / Math.sin((50 * Math.PI) / 180 / 2)) * 1.2);
348
+ cam.lookAt(center);
349
+ return center;
244
350
  }
245
351
 
246
352
  // ── value helpers (telemetry → JSON) ─────────────────────────────────────────────
@@ -255,13 +361,19 @@ function propValues(component: Component): Record<string, unknown> {
255
361
  function jsonValue(v: unknown): unknown {
256
362
  if (v == null || typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v ?? null;
257
363
  if (Array.isArray(v)) return v.map(jsonValue);
364
+ if (v instanceof PrefabRef) return v.describe(); // a `prefab` prop → its label, not "[object Object]"
258
365
  const o = v as { isVector3?: boolean; isVector2?: boolean; isColor?: boolean; isEuler?: boolean; isObject3D?: boolean; x?: number; y?: number; z?: number; name?: string; getHexString?: () => string };
259
366
  if (o.isVector3) return [o.x, o.y, o.z];
260
367
  if (o.isVector2) return [o.x, o.y];
261
368
  if (o.isEuler) return [deg(o.x!), deg(o.y!), deg(o.z!)];
262
369
  if (o.isColor) return `#${o.getHexString!()}`;
263
370
  if (o.isObject3D) return o.name || '(node)';
264
- if ('node' in (o as object) && 'engine' in (o as object)) return componentName(v as Component) ?? '(component)';
371
+ // A component reference → its scene #id (what the agent correlates on), falling
372
+ // back to the class name only when the target has no id.
373
+ if ('node' in (o as object) && 'engine' in (o as object)) {
374
+ const c = v as Component;
375
+ return c.id !== undefined ? `#${c.id}` : (componentName(c) ?? '(component)');
376
+ }
265
377
  return String(v);
266
378
  }
267
379
  const arr = (v: { x: number; y: number; z: number }) => [round(v.x), round(v.y), round(v.z)];
package/app/vite-env.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
- // The editor server's plugin emits a module that imports every project component,
4
- // package, and built-in node tag (so any opened scene/prefab has its tags ready).
3
+ // The editor server's plugin emits two modules: `scenoco-runtime` registers the
4
+ // built-in node tags + tag packages, and `scenoco-components` imports every project
5
+ // component. They're split so a broken component can't wipe the core tag registry.
6
+ declare module 'virtual:scenoco-runtime';
5
7
  declare module 'virtual:scenoco-components';
package/dist/cli/index.js CHANGED
File without changes
package/dist/server.js CHANGED
@@ -149,22 +149,60 @@ function createFileWatcher() {
149
149
  close: () => w.close(),
150
150
  };
151
151
  }
152
- /** `virtual:scenoco-components` → import every project component + package + built-in nodes. */
152
+ /**
153
+ * Two virtual modules, split so a broken project component can't wipe the core
154
+ * tag registry (the P1 "Background not registered" cascade):
155
+ * - `virtual:scenoco-runtime` → `@scenoco-three/core/nodes` + tag packages
156
+ * (the infrastructure every core scene decodes against; imported first).
157
+ * - `virtual:scenoco-components` → only the project's component files (fragile —
158
+ * a broken one throws here, isolated from core/package registration).
159
+ * The components module is invalidated when a component file is added/removed, so
160
+ * a deleted file never lingers as a stale import (the client-cache half of P1).
161
+ */
153
162
  function allComponentsPlugin(roots, packages, cwd) {
154
- const id = 'virtual:scenoco-components';
155
- const resolved = '\0' + id;
163
+ const runtimeId = 'virtual:scenoco-runtime';
164
+ const componentsId = 'virtual:scenoco-components';
165
+ const resolvedRuntime = '\0' + runtimeId;
166
+ const resolvedComponents = '\0' + componentsId;
167
+ const absRoots = roots.map((r) => resolve(cwd, r));
168
+ const isComponentFile = (file) => SOURCE_EXT_RE.test(file) && absRoots.some((r) => resolve(file).startsWith(r));
156
169
  return {
157
170
  name: 'scenoco:all-components',
158
- resolveId: (source) => (source === id ? resolved : null),
171
+ resolveId: (source) => source === runtimeId ? resolvedRuntime : source === componentsId ? resolvedComponents : null,
159
172
  load(source) {
160
- if (source !== resolved)
161
- return null;
162
- const files = [...scanComponents(roots, cwd).files.keys()].map((f) => `import ${JSON.stringify(`/@fs${f}`)};`);
163
- const pkgs = packages.map((p) => `import ${JSON.stringify(p)};`);
164
- return ['import "@scenoco-three/core/nodes";', ...pkgs, ...files].join('\n');
173
+ if (source === resolvedRuntime) {
174
+ // Core nodes first (so core tags register even if a package throws next), then packages.
175
+ return ['import "@scenoco-three/core/nodes";', ...packages.map((p) => `import ${JSON.stringify(p)};`)].join('\n');
176
+ }
177
+ if (source === resolvedComponents) {
178
+ return [...scanComponents(roots, cwd).files.keys()].map((f) => `import ${JSON.stringify(`/@fs${f}`)};`).join('\n') || 'export {};';
179
+ }
180
+ return null;
181
+ },
182
+ configureServer(server) {
183
+ // Vite's watcher is rooted at the editor app dir, so it only sees component
184
+ // files once they're in the module graph (already imported). Watch the project
185
+ // component roots explicitly, so a brand-NEW file's `add` fires too — not just
186
+ // edits/deletes of already-imported ones.
187
+ server.watcher.add(absRoots);
188
+ // Adding/removing a component file changes the import list — rebuild the
189
+ // components module (not the runtime one) and reload, so a new file gets
190
+ // imported and a just-deleted one isn't imported from a stale graph.
191
+ const invalidate = (file) => {
192
+ if (!isComponentFile(file))
193
+ return;
194
+ const mod = server.moduleGraph.getModuleById(resolvedComponents);
195
+ if (mod)
196
+ server.moduleGraph.invalidateModule(mod);
197
+ server.ws.send({ type: 'full-reload' });
198
+ };
199
+ server.watcher.on('add', invalidate);
200
+ server.watcher.on('unlink', invalidate);
165
201
  },
166
202
  };
167
203
  }
204
+ /** Component source extensions the editor imports (mirrors the compiler's scan). */
205
+ const SOURCE_EXT_RE = /\.[cm]?[jt]sx?$/;
168
206
  /** `GET /@scenoco/bundle?file=<abs>` → the compiled runtime bundle (JSON). */
169
207
  function bundleEndpointPlugin(roots, packages, cwd, watcher) {
170
208
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenoco-three/editor",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Local preview + headless frame render for SceNoCo scenes/prefabs (for multimodal LLMs)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,18 +32,18 @@
32
32
  "access": "public"
33
33
  },
34
34
  "scripts": {
35
- "build": "tsc -p tsconfig.build.json",
35
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/cli/index.js",
36
36
  "typecheck": "npm --prefix ../core run build && npm --prefix ../compiler run build && npm --prefix ../vite run build && npm run build && tsc --noEmit -p tsconfig.json"
37
37
  },
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.12.0",
40
- "@scenoco-three/compiler": "^0.3.0",
41
- "@scenoco-three/vite": "^0.3.0",
40
+ "@scenoco-three/compiler": "^0.4.0",
41
+ "@scenoco-three/vite": "^0.4.0",
42
42
  "chokidar": "^4.0.3",
43
43
  "ws": "^8.18.0"
44
44
  },
45
45
  "peerDependencies": {
46
- "@scenoco-three/core": "^0.3.0",
46
+ "@scenoco-three/core": "^0.4.0",
47
47
  "three": ">=0.160",
48
48
  "vite": ">=5"
49
49
  },