@scenoco-three/editor 0.4.0 → 0.5.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -17
  3. package/app/cameraRig.ts +73 -12
  4. package/app/commands.ts +5 -1
  5. package/app/desktopControls.ts +13 -10
  6. package/app/document.ts +474 -427
  7. package/app/editLog.ts +127 -0
  8. package/app/editor.css +51 -0
  9. package/app/gizmo.ts +100 -6
  10. package/app/index.html +6 -122
  11. package/app/main.ts +488 -304
  12. package/app/meta.ts +24 -0
  13. package/app/picking.ts +47 -0
  14. package/app/session.ts +476 -311
  15. package/app/touchControls.ts +85 -62
  16. package/app/ui-react/AddMemberPopup.tsx +114 -0
  17. package/app/ui-react/BottomLauncher.tsx +15 -0
  18. package/app/ui-react/EditorUI.tsx +22 -0
  19. package/app/ui-react/FilePicker.tsx +80 -0
  20. package/app/ui-react/HeadBtn.tsx +21 -0
  21. package/app/ui-react/InspectorSheet.tsx +475 -0
  22. package/app/ui-react/NodeListSheet.tsx +56 -0
  23. package/app/ui-react/TopBar.tsx +137 -0
  24. package/app/ui-react/Tree.tsx +106 -0
  25. package/app/ui-react/cx.ts +4 -0
  26. package/app/ui-react/fields.tsx +407 -0
  27. package/app/ui-react/format.ts +30 -0
  28. package/app/ui-react/mount.tsx +31 -0
  29. package/app/ui-react/store.ts +80 -0
  30. package/app/ui-react/types.ts +88 -0
  31. package/app/ui-react/ui.module.css +250 -0
  32. package/app/ui-react/useSize.ts +20 -0
  33. package/app/values.ts +137 -0
  34. package/dist/cli/index.js +93 -70
  35. package/dist/index.d.ts +10 -3
  36. package/dist/index.js +9 -5
  37. package/dist/mcp.d.ts +28 -12
  38. package/dist/mcp.js +172 -131
  39. package/dist/protocol.d.ts +74 -65
  40. package/dist/protocol.js +17 -4
  41. package/dist/server.d.ts +106 -39
  42. package/dist/server.js +386 -361
  43. package/package.json +25 -10
  44. package/app/filePicker.ts +0 -64
  45. package/app/panels.ts +0 -553
  46. package/app/toolbar.ts +0 -131
  47. package/app/wsClient.ts +0 -92
  48. package/dist/args.d.ts +0 -8
  49. package/dist/args.js +0 -28
package/app/session.ts CHANGED
@@ -1,184 +1,423 @@
1
1
  import {
2
- AmbientLight, Box3, Color, DirectionalLight, PerspectiveCamera, Sphere, Vector3, type Object3D,
2
+ AmbientLight,
3
+ Box3,
4
+ Color,
5
+ DirectionalLight,
6
+ Object3D,
7
+ PerspectiveCamera,
8
+ Scene,
9
+ Vector3,
10
+ WebGLRenderer,
11
+ type Camera,
3
12
  } from 'three';
4
- import { Engine, PrefabRef, Registry, bundleKind, type Bundle, type Component } from '@scenoco-three/core';
5
- import type { SceneProvenance } from '@scenoco-three/compiler';
6
- import type { EditOp, EditorResult, SimStep } from '../dist/protocol.js';
13
+ import { Engine, Input, SceneManager, type PrefabType, type SceneType } from '@scenoco-three/core';
14
+ import type { PreviewFrame, SimStep } from '../src/protocol.js';
7
15
 
8
16
  export type EditorMode = 'edit' | 'play';
9
17
 
18
+ /** Where to point the editor camera and how far back to sit — what framing works out. */
19
+ export interface FocusTarget {
20
+ center: Vector3;
21
+ distance: number;
22
+ }
23
+
10
24
  /**
11
- * Owns the engine and the edit ⇄ play lifecycle. In **edit** mode the scene shows
12
- * its authored pose through an editor-controlled camera (Engine `editor:true`); in
13
- * **play** mode a fresh `editor:false` engine runs the real gameplay through the
14
- * scene's own camera. The engine is recreated on each switch.
25
+ * The live scene, and everything that can be done to it.
26
+ *
27
+ * Two modes, and the difference is only who drives. In **edit** the scene is loaded but not
28
+ * ticked, and an editor camera flies around it; in **play** the scene's own `mainCamera` renders
29
+ * and `SceneManager` ticks. Both look at the same objects — there is no separate preview build,
30
+ * which is what makes "it looked right in the editor" mean something.
15
31
  */
16
32
  export class EditorSession {
17
- engine: Engine;
33
+ readonly canvas: HTMLCanvasElement;
34
+ /**
35
+ * A real core `Engine`, with its frame loop left stopped.
36
+ *
37
+ * The editor drives `SceneManager` itself, so it needs none of the loop — but it does need to
38
+ * *be* the host, because a system reaches the renderer through `Engine.current`. Building a
39
+ * bare `WebGLRenderer` instead left that null, and the symptom was a scene whose `<Canvas>` HUD
40
+ * simply never appeared: `UIRenderer` had nothing to size the screen with and skipped it, with
41
+ * nothing anywhere saying why.
42
+ */
43
+ readonly engine: Engine;
44
+ readonly renderer: WebGLRenderer;
45
+ /** The camera the editor flies. Edit mode renders through it. */
46
+ readonly editorCamera: PerspectiveCamera;
47
+
48
+ scene: Scene | null = null;
18
49
  mode: EditorMode = 'edit';
19
50
  playing = false;
20
- /** The editor camera (edit mode only). */
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;
24
- /** Fired after the engine/scene changes, so the host rebinds camera + panels. */
25
- onChange?: () => void;
26
- /** Fired when a watched file changes on disk (the open file or a `src=` dep). */
27
- onExternalChange?: (file: string, role: 'open' | 'dep') => void;
51
+ /** The class the open module exports, so the scene can be rebuilt from scratch. */
52
+ private sceneClass: SceneType | null = null;
53
+ /** Raised whenever the loaded scene is replaced, so the host can rebind to it. */
54
+ onChange: (() => void) | null = null;
55
+
56
+ /** One editor-owned stand-in per scene camera, for the UI overlay pass. */
57
+ private readonly overlays = new WeakMap<Camera, Camera>();
58
+ private showUi = false;
59
+
60
+ private clock = 0;
61
+
62
+ constructor(canvas: HTMLCanvasElement) {
63
+ this.canvas = canvas;
64
+ // Constructed, never started: `Engine` owns the renderer, input and canvas sizing, and the
65
+ // editor owns the frame loop.
66
+ this.engine = new Engine({ canvas, container: canvas.parentElement ?? document.body });
67
+ this.renderer = this.engine.renderer;
68
+ this.editorCamera = new PerspectiveCamera(50, 1, 0.1, 2000);
69
+ this.editorCamera.position.set(6, 5, 10);
70
+ this.editorCamera.lookAt(0, 0, 0);
71
+ this.resize();
72
+ window.addEventListener('resize', () => this.resize());
73
+ }
74
+
28
75
  /**
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.
76
+ * Load a document from its generated module.
77
+ *
78
+ * The same module the game imports, served straight out of the project — so the editor is
79
+ * looking at the real thing rather than at a rendering of a description of it.
80
+ *
81
+ * A scene loads as itself. A prefab is a node, not a scene, so it opens on a stage: an
82
+ * otherwise empty scene holding one instance of it, lit well enough to see. That is Unity's
83
+ * prefab mode, and it is the only way a document whose root is a `Mesh` can be looked at at
84
+ * all — the alternative is finding a scene that happens to spawn one.
33
85
  */
34
- onRuntimeError?: (message: string) => void;
86
+ async open(moduleUrl: string): Promise<void> {
87
+ await SceneManager.unloadAll();
88
+ const module = (await import(/* @vite-ignore */ moduleUrl)) as Record<string, unknown>;
89
+ const exported = Object.values(module).filter((value): value is new () => object => typeof value === 'function');
90
+ const SceneClass = exported.find(
91
+ (value): value is SceneType => Scene.prototype.isPrototypeOf(value.prototype),
92
+ );
93
+ const PrefabClass = exported.find(
94
+ (value): value is PrefabType<Object3D> => Object3D.prototype.isPrototypeOf(value.prototype),
95
+ );
96
+ if (!SceneClass && !PrefabClass) throw new Error(`${moduleUrl} exports no scene or prefab class`);
35
97
 
36
- private bundle?: Bundle;
37
- /** Source path of the open scene/prefab — the target of `save`. */
38
- filePath?: string;
39
- /** Editor provenance for the open file (prefab/material origins), for the document. */
40
- provenance?: SceneProvenance;
98
+ this.sceneClass = SceneClass ?? stageFor(PrefabClass!);
99
+ this.scene = await SceneManager.load(this.sceneClass);
100
+ this.resize(); // the scene's camera only exists now, and it is the one play mode renders through
101
+ this.onChange?.();
102
+ }
103
+
104
+ // --- the scene's UI, while editing ---------------------------------------------------------
41
105
 
42
- constructor(private readonly canvas: HTMLCanvasElement) {
43
- this.engine = this.makeEngine(true);
106
+ /**
107
+ * Whether the editor draws the scene's screen-space UI over the viewport.
108
+ *
109
+ * A `<Canvas>` is a child of the *scene's* camera and sizes itself to it, so from the editor
110
+ * camera a HUD is a small rectangle floating in world space near a camera you are usually
111
+ * looking away from. Play mode has been the only place it is legible, which makes laying one
112
+ * out a matter of pressing Play, looking, pressing Stop.
113
+ *
114
+ * Switching this on draws it in a second pass instead: a camera matching the one the UI was
115
+ * built for, over the top of the finished frame, clearing nothing. Nothing about the scene
116
+ * graph changes — the Canvas stays the child of its camera, where the document put it and
117
+ * where the hierarchy shows it.
118
+ */
119
+ get uiInEditor(): boolean {
120
+ return this.showUi;
44
121
  }
45
122
 
46
- private makeEngine(editor: boolean): Engine {
47
- return new Engine({ canvas: this.canvas, autoStart: false, editor, preserveDrawingBuffer: true });
123
+ set uiInEditor(on: boolean) {
124
+ this.showUi = on;
48
125
  }
49
126
 
50
- /** Fetch + load a scene/prefab (edit mode). Returns a small summary. */
51
- async open(path: string): Promise<{ file: string; kind: 'scene' | 'prefab'; nodes: number; componentError?: string }> {
52
- const res = await fetch(`/@scenoco/bundle?file=${encodeURIComponent(path)}`);
53
- const data = (await res.json()) as
54
- | { bundle: Bundle; provenance: SceneProvenance; componentError?: string }
55
- | { error: string; componentError?: string };
56
- if (!res.ok || !('bundle' in data) || !Array.isArray(data.bundle)) {
57
- const message = (data as { error?: string }).error ?? 'failed to open file';
58
- const componentError = (data as { componentError?: string }).componentError;
59
- throw new Error(componentError ? `${message}\n\n${componentError}` : message);
127
+ /**
128
+ * Draw the UI over the finished frame.
129
+ *
130
+ * `render` takes any `Object3D` as its root, so passing the UI root draws that subtree and
131
+ * nothing else — no layer masks to keep in step as uikit builds and drops meshes during
132
+ * layout, and no second copy of the scene.
133
+ *
134
+ * The camera is a copy rather than the scene's own. A render pass writes to the camera it is
135
+ * given — `matrixWorldInverse`, the projection matrix — and an editor-only overlay has no
136
+ * business leaving fingerprints on the camera the document declared. The copy takes its world
137
+ * matrix and its projection, so the UI lands exactly where it was laid out to land.
138
+ */
139
+ private renderUiOverlay(): void {
140
+ const autoClear = this.renderer.autoClear;
141
+ this.renderer.autoClear = false;
142
+ for (const root of this.uiRoots()) {
143
+ const owner = root.parent as Camera | null;
144
+ if (!owner?.isCamera) continue;
145
+ this.renderer.render(root, this.overlayFor(owner));
60
146
  }
61
- this.bundle = data.bundle;
62
- this.provenance = data.provenance;
63
- this.filePath = path;
64
- this.toEdit();
65
- const summary = { file: path, kind: bundleKind(this.bundle) === 'prefab' ? ('prefab' as const) : ('scene' as const), nodes: count(this.engine.scene) };
66
- return data.componentError ? { ...summary, componentError: data.componentError } : summary;
147
+ this.renderer.autoClear = autoClear;
67
148
  }
68
149
 
69
- /** Write a set of attribute edits back to the source file (surgical diff). */
70
- async save(edits: EditOp[]): Promise<{ changed: number; unresolved: number }> {
71
- if (!this.filePath) throw new Error('no file open to save');
72
- const res = await fetch('/@scenoco/save', {
73
- method: 'POST',
74
- headers: { 'Content-Type': 'application/json' },
75
- body: JSON.stringify({ file: this.filePath, edits }),
76
- });
77
- const data = (await res.json()) as { changed: number; unresolved: number; error?: string };
78
- if (!res.ok) throw new Error(data.error ?? 'save failed');
79
- return data;
150
+ /** A stand-in for one of the scene's cameras, kept in step with it and owned by the editor. */
151
+ private overlayFor(owner: Camera): Camera {
152
+ let copy = this.overlays.get(owner);
153
+ if (!copy) {
154
+ copy = owner.clone(false) as Camera; // no children: the UI subtree is rendered, not copied
155
+ this.overlays.set(owner, copy);
156
+ }
157
+ copy.copy(owner, false); // projection parameters — fov, aspect, near, far
158
+ // The owner's `matrix` is local to its parent; its `matrixWorld` is where it actually is,
159
+ // and the overlay has no parent to inherit from.
160
+ copy.matrixAutoUpdate = false;
161
+ copy.matrixWorldAutoUpdate = false;
162
+ copy.matrix.copy(owner.matrixWorld);
163
+ copy.matrixWorld.copy(owner.matrixWorld);
164
+ copy.matrixWorldNeedsUpdate = false;
165
+ return copy;
80
166
  }
81
167
 
82
- /** Start (or resume) gameplay. */
83
- run(): void {
84
- if (this.mode === 'play') {
85
- this.playing = true; // resume from pause
86
- return;
168
+ /**
169
+ * The screen-space UI roots in the scene.
170
+ *
171
+ * Identified by the fields a uikit `Fullscreen` alone carries, rather than by importing uikit:
172
+ * a project that uses no UI must not drag the package in, and the editor has no business
173
+ * depending on one tag package over another.
174
+ */
175
+ private uiRoots(): Object3D[] {
176
+ const found: Object3D[] = [];
177
+ for (const child of this.scene?.children ?? []) {
178
+ if (!(child as Partial<Camera>).isCamera) continue;
179
+ for (const grandchild of child.children) {
180
+ if ('pixelSize' in grandchild && 'sizeX' in grandchild && 'sizeY' in grandchild) {
181
+ found.push(grandchild);
182
+ }
183
+ }
87
184
  }
88
- this.swap(false);
185
+ return found;
186
+ }
187
+
188
+ // --- transport ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Start the simulation on the scene as it is now — including everything moved since it loaded.
192
+ *
193
+ * The systems get told to re-read the scene graph first. Edit mode is a whole session of
194
+ * writing transforms from outside the simulation, and a physics world does not watch for that:
195
+ * without this, a crate dragged across the level plays with its collider still where the
196
+ * document put it, and things bounce off empty space.
197
+ */
198
+ run(): void {
199
+ for (const system of this.scene?.systems ?? []) system.syncTransforms?.();
200
+ this.mode = 'play';
89
201
  this.playing = true;
90
- this.onChange?.();
202
+ SceneManager.resetFrameClock();
91
203
  }
204
+
92
205
  pause(): void {
93
206
  this.playing = false;
94
207
  }
95
- /** Advance gameplay by a single frame while paused (no-op outside play mode). */
96
- step(dt = 1 / 60): void {
97
- if (this.mode !== 'play') return;
98
- this.runTick(dt); // bypass the play+paused gate in tick()
208
+
209
+ /**
210
+ * Stop, and put the scene back as the document describes it — Unity's Stop, which discards
211
+ * everything play mode did.
212
+ *
213
+ * Rebuilt rather than merely halted, because a run has to start from the same place every
214
+ * time. Without it a second `simulate` batch begins wherever the first one left off, and two
215
+ * identical batches disagree — which is precisely the property an agent is relying on when it
216
+ * drives a scene to a condition and looks at it.
217
+ */
218
+ async stop(): Promise<void> {
219
+ this.playing = false;
220
+ this.mode = 'edit';
221
+ if (!this.sceneClass) return;
222
+ await SceneManager.unloadAll();
223
+ this.scene = await SceneManager.load(this.sceneClass);
224
+ this.resize(); // a rebuilt scene brings a fresh camera with a default aspect
225
+ this.clock = 0;
226
+ SceneManager.resetFrameClock();
227
+ this.onChange?.();
99
228
  }
100
- stop(): void {
101
- this.toEdit();
229
+
230
+ /** Advance by exactly one step — the frame-by-frame inspection a bug needs. */
231
+ step(dt = 1 / 60): void {
232
+ this.clock += dt * 1000;
233
+ SceneManager.tick(this.clock);
234
+ Input.endFrame();
102
235
  }
103
236
 
104
- /** Advance one frame: edit always renders; play advances only while playing. */
237
+ /**
238
+ * One frame of the editor's own loop.
239
+ *
240
+ * `Input.endFrame` runs whether or not the scene stepped, and that is the point: `Engine` calls
241
+ * it from the loop the editor does not use, so nothing else would. Without it `getKeyDown` is
242
+ * true for ever after one press and `mouseDelta` keeps its last value — a camera that never
243
+ * stops turning once you have turned it, and a one-frame edge that fires on every frame.
244
+ */
105
245
  tick(dt: number): void {
106
- this.runTick(this.mode === 'play' && !this.playing ? 0 : dt);
246
+ if (this.playing) this.step(dt);
247
+ else {
248
+ // Edit mode ticks nothing, which leaves anything presentational stale — a UI layout most
249
+ // visibly. Systems that can be brought up to date without simulating say so.
250
+ for (const system of this.scene?.systems ?? []) system.layout?.(dt);
251
+ Input.endFrame();
252
+ }
253
+ this.render();
254
+ }
255
+
256
+ render(camera: Camera = this.renderCamera()): void {
257
+ if (!this.scene) return;
258
+ this.renderer.render(this.scene, camera);
259
+ // Only in edit mode: play renders through the scene's own camera, where the UI is already
260
+ // in front of it, and a run has to show exactly what the game shows — once.
261
+ if (this.showUi && this.mode === 'edit') this.renderUiOverlay();
262
+ }
263
+
264
+ /** Play renders through the scene's camera; edit through the editor's. */
265
+ renderCamera(): Camera {
266
+ return this.mode === 'play' && this.scene?.mainCamera ? this.scene.mainCamera : this.editorCamera;
107
267
  }
108
268
 
109
269
  /**
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.
270
+ * Match the drawing buffer and **both** cameras to the canvas.
271
+ *
272
+ * `Engine.resize` sizes the buffer and the scene's camera, and it is the very code the game
273
+ * runs — so it is called rather than reimplemented. But the editor never starts the engine's
274
+ * loop, which is what would normally keep calling it: the constructor's one call happens before
275
+ * any scene is loaded, so `mainCamera` does not exist yet and never gets an aspect. Play mode
276
+ * then renders the game through a camera still at aspect 1 and every sphere is an ellipse.
277
+ *
278
+ * The editor's own camera is not the engine's business, so it is handled here.
114
279
  */
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
- }
280
+ private resize(): void {
281
+ this.engine.resize();
282
+ const { clientWidth: w, clientHeight: h } = this.canvas;
283
+ this.editorCamera.aspect = w / Math.max(h, 1);
284
+ this.editorCamera.updateProjectionMatrix();
125
285
  }
126
286
 
127
- /** Capture the current view as a PNG data URL (optionally re-framing the editor camera). */
128
- preview(opts: { camera?: string; lookAt?: string }): string {
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);
287
+ /**
288
+ * Put the editor camera where it can see everything, and say what to look at.
289
+ *
290
+ * The **point** comes back rather than the camera being aimed here, because the camera rig owns
291
+ * orientation: it accumulates yaw and pitch and writes them every frame, so a `lookAt` from
292
+ * outside is overwritten on the very next one. Framing that positioned the camera perfectly and
293
+ * then pointed it somewhere else was exactly that bug.
294
+ */
295
+ frameAll(): FocusTarget | null {
296
+ if (!this.scene) return null;
297
+ // Only what the document authored. The gizmo's helper is a child of the scene too, and its
298
+ // picker plane is 100000 units across: framing that put the camera millions of units out, and
299
+ // since the handles scale with camera distance, each press of the key made the next one worse.
300
+ const box = new Box3();
301
+ for (const child of this.scene.children) {
302
+ if (child.userData.scenocoEditorOnly) continue;
303
+ box.union(new Box3().setFromObject(child));
143
304
  }
144
- this.runTick(0); // render the current frame (guarded — a bad frame won't wedge the session)
145
- return this.canvas.toDataURL('image/png');
305
+ return box.isEmpty() ? null : this.focus(box);
306
+ }
307
+
308
+ /** What it takes to see one node: where it is, and from how far. */
309
+ frameNode(node: Object3D): FocusTarget | null {
310
+ const box = new Box3().setFromObject(node);
311
+ return box.isEmpty() ? null : this.focus(box);
312
+ }
313
+
314
+ /**
315
+ * Measure, rather than move.
316
+ *
317
+ * The camera's *angle* belongs to whoever is flying it, and framing must not take it — so this
318
+ * answers only the half a bounding box can answer, and `CameraRig.frameOn` decides where that
319
+ * puts the camera given where it currently looks.
320
+ */
321
+ private focus(box: Box3): FocusTarget {
322
+ const center = box.getCenter(new Vector3());
323
+ // A floor of half a unit, so framing a point light or an empty group doesn't put the camera
324
+ // inside it.
325
+ const radius = Math.max(box.getSize(new Vector3()).length() / 2, 0.5);
326
+ const fit = radius / Math.sin((this.editorCamera.fov * Math.PI) / 360);
327
+ return { center, distance: fit * 1.2 }; // a fifth of a radius of air around the subject
328
+ }
329
+
330
+ // --- what an agent asks for ---------------------------------------------------------------
331
+
332
+ /**
333
+ * Render one PNG from an arbitrary viewpoint.
334
+ *
335
+ * A capture camera rather than the editor's own: a shot must not move what the person at the
336
+ * keyboard is looking at, and two shots of the same run have to be comparable.
337
+ */
338
+ preview(frame: PreviewFrame, at: string, lookAt?: string): string {
339
+ if (!this.scene) throw new Error('no scene is open');
340
+
341
+ const usingScene = frame.ui && this.scene.mainCamera;
342
+ const camera = usingScene
343
+ ? (this.scene.mainCamera as PerspectiveCamera)
344
+ : new PerspectiveCamera(50, frame.width / frame.height, 0.1, 2000);
345
+
346
+ const previous = {
347
+ position: camera.position.clone(),
348
+ quaternion: camera.quaternion.clone(),
349
+ aspect: camera.aspect,
350
+ };
351
+ const target = pointOf(lookAt) ?? new Vector3();
352
+ const eye = at === 'fit' ? this.fitPoint(camera, target) : pointOf(at);
353
+ if (!eye) throw new Error(`camera="${at}" is not a point — write "y: 6; z: 12", or "fit"`);
354
+ camera.position.copy(eye);
355
+ camera.lookAt(target);
356
+ // Always, including the scene's own camera: it is framed for the editor viewport, and
357
+ // rendering it into a differently-shaped buffer stretches everything in the shot.
358
+ camera.aspect = frame.width / frame.height;
359
+ camera.updateProjectionMatrix();
360
+
361
+ const { width, height } = this.renderer.domElement;
362
+ this.renderer.setSize(frame.width, frame.height, false);
363
+ this.renderer.render(this.scene, camera);
364
+ const dataUrl = this.canvas.toDataURL('image/png');
365
+ this.renderer.setSize(width, height, false);
366
+
367
+ // Put the camera back: a capture is an observation, and an observation that moves the thing
368
+ // it observed is not one.
369
+ camera.position.copy(previous.position);
370
+ camera.quaternion.copy(previous.quaternion);
371
+ camera.aspect = previous.aspect;
372
+ camera.updateProjectionMatrix();
373
+ return dataUrl;
374
+ }
375
+
376
+ /** A viewpoint that fits the whole scene, for `camera="fit"`. */
377
+ private fitPoint(camera: PerspectiveCamera, target: Vector3): Vector3 {
378
+ const box = new Box3().setFromObject(this.scene!);
379
+ const center = box.isEmpty() ? new Vector3() : box.getCenter(new Vector3());
380
+ target.copy(center);
381
+ const radius = box.isEmpty() ? 5 : Math.max(box.getSize(new Vector3()).length() / 2, 0.5);
382
+ const distance = (radius / Math.sin((camera.fov * Math.PI) / 360)) * 1.2;
383
+ return center.clone().addScaledVector(new Vector3(0.6, 0.5, 1).normalize(), distance);
146
384
  }
147
385
 
148
- /** Current property values for the node + its components at `id`, as plain JSON. */
386
+ /**
387
+ * Read the state at an `id` the document declared.
388
+ *
389
+ * The generated scene class carries a typed field per `id`, so this is a property lookup —
390
+ * there is no registry to consult and no name index to keep.
391
+ */
149
392
  telemetry(id: string): unknown {
150
- const node = this.engine.findObject(id);
151
- if (!node) return { error: `no object with id "${id}"` };
152
- const components: Record<string, Record<string, unknown>> = {};
153
- for (const c of this.engine.getComponents(node)) {
154
- // Surface the component's scene id (if any), alongside its @property values.
155
- components[componentName(c) ?? c.constructor.name] = { ...(c.id !== undefined ? { id: c.id } : {}), ...propValues(c) };
393
+ const node = (this.scene as unknown as Record<string, Object3D | undefined>)?.[id];
394
+ if (!node || typeof node !== 'object' || !('isObject3D' in node)) {
395
+ throw new Error(`no id "${id}" in this document`);
156
396
  }
157
397
  return {
158
398
  id,
159
399
  type: node.type,
160
- transform: {
161
- position: arr(node.position),
162
- rotation: degArr(node.rotation),
163
- scale: arr(node.scale),
164
- visible: node.visible,
165
- },
166
- components,
400
+ name: node.name,
401
+ position: node.position.toArray(),
402
+ eulerAngles: node.eulerAngles.toArray(),
403
+ scale: node.scale.toArray(),
404
+ visible: node.visible,
405
+ active: node.activeInHierarchy,
406
+ components: node.components.map((component) => ({
407
+ type: component.constructor.name,
408
+ enabled: component.enabled,
409
+ ...readable(component),
410
+ })),
167
411
  };
168
412
  }
169
413
 
170
- /**
171
- * Execute a batch of player-simulation steps in order against the live engine —
172
- * transport, frame stepping, synthetic key/mouse/gamepad input, and preview/
173
- * telemetry captures. Runs synchronously (no real-time loop interleaves), so an
174
- * agent can drive the game to a precise condition and sample it there. Injected
175
- * input is committed on the next `step`, so the usual order is input → step →
176
- * preview/telemetry. Returns the captures in step order.
177
- */
178
- simulate(steps: SimStep[]): EditorResult[] {
179
- const out: EditorResult[] = [];
414
+ /** Run a batch of steps in order, collecting whatever each one produced. */
415
+ async simulate(
416
+ steps: readonly SimStep[],
417
+ frame: PreviewFrame,
418
+ ): Promise<Array<{ kind: 'image' | 'json'; value: unknown }>> {
419
+ const out: Array<{ kind: 'image' | 'json'; value: unknown }> = [];
180
420
  for (const step of steps) {
181
- const input = this.engine.input;
182
421
  switch (step.do) {
183
422
  case 'play':
184
423
  this.run();
@@ -187,221 +426,147 @@ export class EditorSession {
187
426
  this.pause();
188
427
  break;
189
428
  case 'stop':
190
- this.stop();
429
+ await this.stop();
191
430
  break;
192
- case 'step': {
193
- const frames = Math.max(0, Math.floor(step.frames ?? 1));
194
- const dt = step.dt ?? 1 / 60;
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
- }
431
+ case 'step':
432
+ for (let i = 0; i < (step.frames ?? 1); i++) this.step(step.dt ?? 1 / 60);
201
433
  break;
202
- }
203
- case 'tap':
204
- input.injectKey(step.key, true);
205
- input.injectKey(step.key, false);
434
+ case 'press':
435
+ this.key('keydown', step.key);
436
+ this.step();
437
+ this.key('keyup', step.key);
206
438
  break;
207
439
  case 'keyDown':
208
- input.injectKey(step.key, true);
440
+ this.key('keydown', step.key);
209
441
  break;
210
442
  case 'keyUp':
211
- input.injectKey(step.key, false);
443
+ this.key('keyup', step.key);
212
444
  break;
213
- case 'click': {
214
- if (step.x !== undefined && step.y !== undefined) input.injectMouseMove(step.x, step.y);
215
- const b = step.button === 'right' ? 1 : step.button === 'middle' ? 2 : 0;
216
- input.injectMouseButton(b, true);
217
- input.injectMouseButton(b, false);
445
+ case 'click':
446
+ this.pointer('pointerdown', step.x, step.y, step.button);
447
+ this.pointer('pointerup', step.x, step.y, step.button);
218
448
  break;
219
- }
220
449
  case 'move':
221
- input.injectMouseMove(step.x ?? 0, step.y ?? 0, step.dx ?? 0, step.dy ?? 0);
222
- break;
223
- case 'gamepad':
224
- input.injectGamepad(step.index ?? 0, { ...(step.buttons ? { buttons: step.buttons } : {}), ...(step.axes ? { axes: step.axes } : {}) });
450
+ this.pointer('pointermove', step.x, step.y);
225
451
  break;
226
452
  case 'preview':
227
- out.push({ kind: 'image', dataUrl: this.preview({ ...(step.camera ? { camera: step.camera } : {}), ...(step.lookAt ? { lookAt: step.lookAt } : {}) }) });
453
+ out.push({ kind: 'image', value: this.preview(frame, step.camera, step.lookAt) });
228
454
  break;
229
455
  case 'telemetry':
230
- out.push({ kind: 'json', data: this.telemetry(step.id) });
456
+ out.push({ kind: 'json', value: this.telemetry(step.id) });
231
457
  break;
232
458
  }
233
459
  }
234
460
  return out;
235
461
  }
236
462
 
237
- // ── internals ────────────────────────────────────────────────────────────────
238
-
239
- private toEdit(): void {
240
- this.swap(true);
241
- this.mode = 'edit';
242
- this.playing = false;
243
- this.editorCamera = this.frameCamera();
244
- this.onChange?.();
245
- }
246
-
247
- private swap(editor: boolean): void {
248
- this.engine.dispose();
249
- this.engine = this.makeEngine(editor);
250
- this.editorCamera = undefined;
251
- this.mode = editor ? 'edit' : 'play';
252
- this.applyBundle();
253
- }
254
-
255
- private applyBundle(): void {
256
- if (this.bundle) applyBundleToEngine(this.engine, this.bundle);
257
- }
258
-
259
- /** Editor camera framing the scene from a 3/4 angle. */
260
- private frameCamera(): PerspectiveCamera {
261
- const cam = new PerspectiveCamera(50, this.canvas.clientWidth / this.canvas.clientHeight || 1, 0.01, 50000);
262
- frameCameraTo(cam, this.engine.scene);
263
- this.engine.mainCamera = cam;
264
- return cam;
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
463
  /**
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.
464
+ * Synthetic input is a real DOM event.
465
+ *
466
+ * `Input` listens to the page, so there is nothing to fake and no test-only path through it —
467
+ * an agent's key press travels the same route a person's does, which is the only way a
468
+ * simulated run tells you anything about the real one.
278
469
  */
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);
470
+ private key(type: 'keydown' | 'keyup', name: string): void {
471
+ const code = keyCodeFor(name);
472
+ window.dispatchEvent(new KeyboardEvent(type, { code, key: name, bubbles: true }));
292
473
  }
293
474
 
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
- }
475
+ private pointer(type: string, x?: number, y?: number, button?: 'left' | 'right' | 'middle'): void {
476
+ const rect = this.canvas.getBoundingClientRect();
477
+ this.canvas.dispatchEvent(
478
+ new PointerEvent(type, {
479
+ bubbles: true,
480
+ clientX: rect.left + (x ?? rect.width / 2),
481
+ clientY: rect.top + (y ?? rect.height / 2),
482
+ button: button === 'right' ? 2 : button === 'middle' ? 1 : 0,
483
+ pointerId: 1,
484
+ }),
485
+ );
317
486
  }
318
487
  }
319
488
 
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);
489
+ /** `"y: 6; z: 12"` — the document's own vector grammar, so an agent learns one. */
490
+ export function pointOf(text?: string): Vector3 | undefined {
491
+ if (!text || text === 'fit') return undefined;
492
+ const point = new Vector3();
493
+ let wrote = false;
494
+ for (const part of text.split(';')) {
495
+ const [axis, value] = part.split(':');
496
+ const n = Number(value?.trim());
497
+ if (!axis || !Number.isFinite(n)) continue;
498
+ const key = axis.trim() as 'x' | 'y' | 'z';
499
+ if (key === 'x' || key === 'y' || key === 'z') {
500
+ point[key] = n;
501
+ wrote = true;
502
+ }
338
503
  }
504
+ return wrote ? point : undefined;
339
505
  }
340
506
 
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;
507
+ /**
508
+ * A `KeyCode` member name as the DOM spells it.
509
+ *
510
+ * `Space` is `Space`, `W` is `KeyW`, `LeftArrow` is `ArrowLeft` — one small table, because the
511
+ * two vocabularies genuinely differ and an agent should write the one the engine documents.
512
+ */
513
+ function keyCodeFor(name: string): string {
514
+ const special: Record<string, string> = {
515
+ Space: 'Space',
516
+ LeftArrow: 'ArrowLeft',
517
+ RightArrow: 'ArrowRight',
518
+ UpArrow: 'ArrowUp',
519
+ DownArrow: 'ArrowDown',
520
+ Return: 'Enter',
521
+ Escape: 'Escape',
522
+ Tab: 'Tab',
523
+ LeftShift: 'ShiftLeft',
524
+ LeftControl: 'ControlLeft',
525
+ };
526
+ if (special[name]) return special[name]!;
527
+ if (/^[A-Z]$/.test(name)) return `Key${name}`;
528
+ if (/^Alpha[0-9]$/.test(name)) return `Digit${name.slice(5)}`;
529
+ return name;
350
530
  }
351
531
 
352
- // ── value helpers (telemetry → JSON) ─────────────────────────────────────────────
353
-
354
- function propValues(component: Component): Record<string, unknown> {
355
- const meta = registeredMeta(component);
532
+ /** A component's own public values — what a document could have written, read back. */
533
+ function readable(component: object): Record<string, unknown> {
356
534
  const out: Record<string, unknown> = {};
357
- if (!meta) return out;
358
- for (const [name] of meta.properties) out[name] = jsonValue((component as unknown as Record<string, unknown>)[name]);
359
- return out;
360
- }
361
- function jsonValue(v: unknown): unknown {
362
- if (v == null || typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v ?? null;
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]"
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 };
366
- if (o.isVector3) return [o.x, o.y, o.z];
367
- if (o.isVector2) return [o.x, o.y];
368
- if (o.isEuler) return [deg(o.x!), deg(o.y!), deg(o.z!)];
369
- if (o.isColor) return `#${o.getHexString!()}`;
370
- if (o.isObject3D) return o.name || '(node)';
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)');
535
+ for (const [key, value] of Object.entries(component)) {
536
+ if (key.startsWith('_')) continue;
537
+ if (value === null || ['number', 'string', 'boolean'].includes(typeof value)) out[key] = value;
538
+ else if (value instanceof Vector3) out[key] = value.toArray();
376
539
  }
377
- return String(v);
378
- }
379
- const arr = (v: { x: number; y: number; z: number }) => [round(v.x), round(v.y), round(v.z)];
380
- const degArr = (v: { x: number; y: number; z: number }) => [deg(v.x), deg(v.y), deg(v.z)];
381
- const deg = (rad: number) => round((rad * 180) / Math.PI);
382
- const round = (n: number) => Math.round(n * 1000) / 1000;
383
-
384
- function vec3(s?: string): Vector3 | undefined {
385
- if (!s) return undefined;
386
- const p = s.split(/[ ,]+/).map(Number).filter((n) => Number.isFinite(n));
387
- return p.length === 3 ? new Vector3(p[0], p[1], p[2]) : undefined;
540
+ return out;
388
541
  }
389
- function count(root: Object3D): number {
390
- let n = 0;
391
- root.traverse(() => n++);
392
- return n;
542
+
543
+ /**
544
+ * A scene that holds one instance of a prefab — the stage a prefab is edited on.
545
+ *
546
+ * The lights and the background are the editor's, not the document's, so they carry the
547
+ * `scenocoEditorOnly` flag every other editor-drawn helper does: the hierarchy, picking and
548
+ * framing all skip them, and nothing the person sees in the tree came from anywhere but the
549
+ * prefab they opened.
550
+ */
551
+ function stageFor(Prefab: PrefabType<Object3D>): SceneType {
552
+ return class PrefabStage extends Scene {
553
+ override onLoad(): void {
554
+ this.instantiate(Prefab);
555
+ this.background = new Color(0x15181f);
556
+ for (const light of [ambient(), key()]) {
557
+ light.userData.scenocoEditorOnly = true;
558
+ this.add(light);
559
+ }
560
+ }
561
+ };
393
562
  }
394
- function registeredMeta(component: Component) {
395
- for (const name of Registry.componentNames()) {
396
- const entry = Registry.getComponent(name);
397
- if (entry && component instanceof entry.ctor) return entry.meta;
398
- }
399
- return undefined;
563
+
564
+ function ambient(): AmbientLight {
565
+ return new AmbientLight(0xffffff, 0.6);
400
566
  }
401
- function componentName(component: Component): string | undefined {
402
- for (const name of Registry.componentNames()) {
403
- const entry = Registry.getComponent(name);
404
- if (entry && component.constructor === entry.ctor) return name;
405
- }
406
- return undefined;
567
+
568
+ function key(): DirectionalLight {
569
+ const light = new DirectionalLight(0xffffff, 2);
570
+ light.position.set(3, 5, 4);
571
+ return light;
407
572
  }