@scenoco-three/editor 0.5.0 → 0.5.4

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.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The websocket path, substituted into the page at serve time by the editor's Vite config.
3
+ *
4
+ * A constant rather than an import because the page and the server share `src/protocol.ts`, and
5
+ * `src/` is not published — the package ships `dist` and `app`. Everything else the page borrows
6
+ * from that module is a type, which erases; this was the only value, so it comes in through
7
+ * `define` and the import can be type-only.
8
+ */
9
+ declare const __SCENOCO_WS_PATH__: string;
package/app/main.ts CHANGED
@@ -9,12 +9,15 @@ import { pickAt } from './picking.js';
9
9
  import { EditorSession, type FocusTarget } from './session.js';
10
10
  import { mountEditorUI } from './ui-react/mount.js';
11
11
  import type { EditorUIHandle } from './ui-react/types.js';
12
- import {
13
- WS_PATH,
14
- type EditorCommand,
15
- type EditorPush,
16
- type EditorResponse,
17
- type EditorResult,
12
+ // Type-only, and deliberately so: `import type` is erased before the browser ever sees this, so
13
+ // the page never asks for a module out of `src/` — which the published package does not ship.
14
+ // The one *value* the page needed from there, the websocket path, arrives as a build-time
15
+ // constant instead. See `__SCENOCO_WS_PATH__` in app/globals.d.ts.
16
+ import type {
17
+ EditorCommand,
18
+ EditorPush,
19
+ EditorResponse,
20
+ EditorResult,
18
21
  } from '../src/protocol.js';
19
22
 
20
23
  /**
@@ -315,7 +318,7 @@ async function save(): Promise<void> {
315
318
  * write path into someone's source is a write their editor, diff and undo never saw.
316
319
  */
317
320
  function connect(): void {
318
- const socket = new WebSocket(`ws://${location.host}${WS_PATH}`);
321
+ const socket = new WebSocket(`ws://${location.host}${__SCENOCO_WS_PATH__}`);
319
322
  socket.addEventListener('message', async (event) => {
320
323
  const message = JSON.parse(String(event.data)) as EditorCommand | EditorPush;
321
324
  if ('push' in message) {
package/app/session.ts CHANGED
@@ -98,6 +98,14 @@ export class EditorSession {
98
98
  this.sceneClass = SceneClass ?? stageFor(PrefabClass!);
99
99
  this.scene = await SceneManager.load(this.sceneClass);
100
100
  this.resize(); // the scene's camera only exists now, and it is the one play mode renders through
101
+
102
+ // One draw to make the scene's render settings current — which is what asks for the post
103
+ // chain — then wait for it. Without this the first `screenshot` after opening shows no post
104
+ // passes, because the composer is a dynamic import that has not landed yet, and "no bloom"
105
+ // is indistinguishable from "the <Bloom> I just wrote does not work".
106
+ this.render();
107
+ await this.engine.effectsReady();
108
+
101
109
  this.onChange?.();
102
110
  }
103
111
 
@@ -200,10 +208,16 @@ export class EditorSession {
200
208
  this.mode = 'play';
201
209
  this.playing = true;
202
210
  SceneManager.resetFrameClock();
211
+ // The toolbar reads `mode` and `playing`, and the React chrome only re-reads on this call.
212
+ // `stop` always raised it and these two never did, so a scene driven into play mode from the
213
+ // MCP kept a toolbar insisting it was stopped: Play still offered, Pause and Step still
214
+ // hidden, while the scene ran behind them. Whoever is watching has to be able to trust it.
215
+ this.onChange?.();
203
216
  }
204
217
 
205
218
  pause(): void {
206
219
  this.playing = false;
220
+ this.onChange?.();
207
221
  }
208
222
 
209
223
  /**
@@ -255,7 +269,10 @@ export class EditorSession {
255
269
 
256
270
  render(camera: Camera = this.renderCamera()): void {
257
271
  if (!this.scene) return;
258
- this.renderer.render(this.scene, camera);
272
+ // Through the engine, not `renderer.render`: the scene's render document — tone mapping,
273
+ // bloom, vignette, fog — is applied there, and a viewport that skips it is not showing the
274
+ // scene the game draws.
275
+ this.engine.renderScene(this.scene, camera);
259
276
  // Only in edit mode: play renders through the scene's own camera, where the UI is already
260
277
  // in front of it, and a run has to show exactly what the game shows — once.
261
278
  if (this.showUi && this.mode === 'edit') this.renderUiOverlay();
@@ -358,11 +375,13 @@ export class EditorSession {
358
375
  camera.aspect = frame.width / frame.height;
359
376
  camera.updateProjectionMatrix();
360
377
 
378
+ // Buffer *and* effect chain move together, or the post passes run at the wrong scale. The
379
+ // canvas is read while it is still that size, then everything goes back.
361
380
  const { width, height } = this.renderer.domElement;
362
- this.renderer.setSize(frame.width, frame.height, false);
363
- this.renderer.render(this.scene, camera);
381
+ this.engine.setFrameSize(frame.width, frame.height);
382
+ this.engine.renderScene(this.scene, camera);
364
383
  const dataUrl = this.canvas.toDataURL('image/png');
365
- this.renderer.setSize(width, height, false);
384
+ this.engine.setFrameSize(width, height);
366
385
 
367
386
  // Put the camera back: a capture is an observation, and an observation that moves the thing
368
387
  // it observed is not one.
@@ -429,11 +448,11 @@ export class EditorSession {
429
448
  await this.stop();
430
449
  break;
431
450
  case 'step':
432
- for (let i = 0; i < (step.frames ?? 1); i++) this.step(step.dt ?? 1 / 60);
451
+ await this.stepVisibly(step.frames ?? 1, step.dt ?? 1 / 60);
433
452
  break;
434
453
  case 'press':
435
454
  this.key('keydown', step.key);
436
- this.step();
455
+ await this.stepVisibly(1);
437
456
  this.key('keyup', step.key);
438
457
  break;
439
458
  case 'keyDown':
@@ -460,6 +479,27 @@ export class EditorSession {
460
479
  return out;
461
480
  }
462
481
 
482
+ /**
483
+ * Advance the scene where someone can watch it happen.
484
+ *
485
+ * `step` only ticks; the editor's own loop is what draws, and it does not get a turn while a
486
+ * batch is running — the whole batch is one synchronous task. Left that way, a person watching
487
+ * the page sees a frozen canvas and then a jump to the final state, which is indistinguishable
488
+ * from the editor having hung. So this draws each frame and hands the browser a chance to paint.
489
+ *
490
+ * Yielding every fourth frame rather than every frame: painting more often than that buys no
491
+ * legibility and a long batch would otherwise run at wall-clock speed. Determinism is untouched
492
+ * either way — the step is a fixed `dt`, so when the frames are drawn changes nothing about what
493
+ * they contain.
494
+ */
495
+ private async stepVisibly(frames: number, dt = 1 / 60): Promise<void> {
496
+ for (let i = 0; i < frames; i++) {
497
+ this.step(dt);
498
+ this.render();
499
+ if (i % 4 === 3) await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
500
+ }
501
+ }
502
+
463
503
  /**
464
504
  * Synthetic input is a real DOM event.
465
505
  *
package/dist/server.js CHANGED
@@ -66,6 +66,9 @@ export async function startEditorServer(options = {}) {
66
66
  // One `three` across the editor and the project, or `instanceof` quietly fails across two
67
67
  // copies and every raycast hit looks like nothing at all.
68
68
  resolve: { dedupe: ['three'] },
69
+ // The page cannot import this from `src/`: that directory is not published. See
70
+ // app/globals.d.ts.
71
+ define: { __SCENOCO_WS_PATH__: JSON.stringify(WS_PATH) },
69
72
  optimizeDeps: { include: ['react', 'react-dom/client'] },
70
73
  });
71
74
  let vite = await serve(chosen.port);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenoco-three/editor",
3
- "version": "0.5.0",
3
+ "version": "0.5.4",
4
4
  "description": "Local visual editor for SceNoCo scenes, with an observation-only MCP server so an agent can see and drive the same live scene.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,8 +45,8 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "^1.12.0",
48
- "@scenoco-three/compiler": "^0.5.0",
49
- "@scenoco-three/core": "^0.5.0",
48
+ "@scenoco-three/compiler": "^0.5.4",
49
+ "@scenoco-three/core": "^0.5.4",
50
50
  "@vitejs/plugin-react": "^6.0.3",
51
51
  "lucide-react": "^0.468.0",
52
52
  "react": "^18.3.1",
@@ -65,4 +65,4 @@
65
65
  "@types/react-dom": "^18.3.7",
66
66
  "@types/ws": "^8.5.13"
67
67
  }
68
- }
68
+ }