@scenoco-three/editor 0.3.2 → 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 (50) 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 -293
  12. package/app/meta.ts +24 -0
  13. package/app/picking.ts +47 -0
  14. package/app/session.ts +487 -210
  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/app/vite-env.d.ts +4 -2
  35. package/dist/cli/index.js +93 -70
  36. package/dist/index.d.ts +10 -3
  37. package/dist/index.js +9 -5
  38. package/dist/mcp.d.ts +28 -12
  39. package/dist/mcp.js +172 -131
  40. package/dist/protocol.d.ts +74 -65
  41. package/dist/protocol.js +17 -4
  42. package/dist/server.d.ts +106 -39
  43. package/dist/server.js +387 -324
  44. package/package.json +26 -11
  45. package/app/filePicker.ts +0 -64
  46. package/app/panels.ts +0 -553
  47. package/app/toolbar.ts +0 -131
  48. package/app/wsClient.ts +0 -92
  49. package/dist/args.d.ts +0 -8
  50. package/dist/args.js +0 -28
package/app/session.ts CHANGED
@@ -1,148 +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, 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
- /** Fired after the engine/scene changes, so the host rebinds camera + panels. */
23
- onChange?: () => void;
24
- /** Fired when a watched file changes on disk (the open file or a `src=` dep). */
25
- onExternalChange?: (file: string, role: 'open' | 'dep') => void;
26
-
27
- private bundle?: Bundle;
28
- /** Source path of the open scene/prefab — the target of `save`. */
29
- filePath?: string;
30
- /** Editor provenance for the open file (prefab/material origins), for the document. */
31
- provenance?: SceneProvenance;
32
-
33
- constructor(private readonly canvas: HTMLCanvasElement) {
34
- this.engine = this.makeEngine(true);
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());
35
73
  }
36
74
 
37
- private makeEngine(editor: boolean): Engine {
38
- return new Engine({ canvas: this.canvas, autoStart: false, editor, preserveDrawingBuffer: true });
75
+ /**
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.
85
+ */
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`);
97
+
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?.();
39
102
  }
40
103
 
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; componentError?: string }> {
43
- const res = await fetch(`/@scenoco/bundle?file=${encodeURIComponent(path)}`);
44
- const data = (await res.json()) as
45
- | { bundle: Bundle; provenance: SceneProvenance; componentError?: string }
46
- | { error: string; componentError?: string };
47
- if (!res.ok || !('bundle' in data) || !Array.isArray(data.bundle)) {
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);
104
+ // --- the scene's UI, while editing ---------------------------------------------------------
105
+
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;
121
+ }
122
+
123
+ set uiInEditor(on: boolean) {
124
+ this.showUi = on;
125
+ }
126
+
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));
51
146
  }
52
- this.bundle = data.bundle;
53
- this.provenance = data.provenance;
54
- this.filePath = path;
55
- this.toEdit();
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;
147
+ this.renderer.autoClear = autoClear;
58
148
  }
59
149
 
60
- /** Write a set of attribute edits back to the source file (surgical diff). */
61
- async save(edits: EditOp[]): Promise<{ changed: number; unresolved: number }> {
62
- if (!this.filePath) throw new Error('no file open to save');
63
- const res = await fetch('/@scenoco/save', {
64
- method: 'POST',
65
- headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ file: this.filePath, edits }),
67
- });
68
- const data = (await res.json()) as { changed: number; unresolved: number; error?: string };
69
- if (!res.ok) throw new Error(data.error ?? 'save failed');
70
- 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;
71
166
  }
72
167
 
73
- /** Start (or resume) gameplay. */
74
- run(): void {
75
- if (this.mode === 'play') {
76
- this.playing = true; // resume from pause
77
- 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
+ }
78
184
  }
79
- 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';
80
201
  this.playing = true;
81
- this.onChange?.();
202
+ SceneManager.resetFrameClock();
82
203
  }
204
+
83
205
  pause(): void {
84
206
  this.playing = false;
85
207
  }
86
- /** Advance gameplay by a single frame while paused (no-op outside play mode). */
87
- step(dt = 1 / 60): void {
88
- if (this.mode !== 'play') return;
89
- this.engine.tick(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?.();
90
228
  }
91
- stop(): void {
92
- 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();
93
235
  }
94
236
 
95
- /** 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
+ */
96
245
  tick(dt: number): void {
97
- this.engine.tick(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;
267
+ }
268
+
269
+ /**
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.
279
+ */
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();
98
285
  }
99
286
 
100
- /** Capture the current view as a PNG data URL (optionally re-framing the editor camera). */
101
- 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);
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));
107
304
  }
108
- this.engine.tick(0); // render the current frame
109
- 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
110
328
  }
111
329
 
112
- /** Current property values for the node + its components at `id`, as plain JSON. */
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);
384
+ }
385
+
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
+ */
113
392
  telemetry(id: string): unknown {
114
- const node = this.engine.findObject(id);
115
- if (!node) return { error: `no object with id "${id}"` };
116
- const components: Record<string, Record<string, unknown>> = {};
117
- for (const c of this.engine.getComponents(node)) {
118
- // Surface the component's scene id (if any), alongside its @property values.
119
- 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`);
120
396
  }
121
397
  return {
122
398
  id,
123
399
  type: node.type,
124
- transform: {
125
- position: arr(node.position),
126
- rotation: degArr(node.rotation),
127
- scale: arr(node.scale),
128
- visible: node.visible,
129
- },
130
- 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
+ })),
131
411
  };
132
412
  }
133
413
 
134
- /**
135
- * Execute a batch of player-simulation steps in order against the live engine —
136
- * transport, frame stepping, synthetic key/mouse/gamepad input, and preview/
137
- * telemetry captures. Runs synchronously (no real-time loop interleaves), so an
138
- * agent can drive the game to a precise condition and sample it there. Injected
139
- * input is committed on the next `step`, so the usual order is input → step →
140
- * preview/telemetry. Returns the captures in step order.
141
- */
142
- simulate(steps: SimStep[]): EditorResult[] {
143
- 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 }> = [];
144
420
  for (const step of steps) {
145
- const input = this.engine.input;
146
421
  switch (step.do) {
147
422
  case 'play':
148
423
  this.run();
@@ -151,145 +426,147 @@ export class EditorSession {
151
426
  this.pause();
152
427
  break;
153
428
  case 'stop':
154
- this.stop();
429
+ await this.stop();
155
430
  break;
156
- case 'step': {
157
- const frames = Math.max(0, Math.floor(step.frames ?? 1));
158
- const dt = step.dt ?? 1 / 60;
159
- for (let i = 0; i < frames; i++) this.engine.tick(dt);
431
+ case 'step':
432
+ for (let i = 0; i < (step.frames ?? 1); i++) this.step(step.dt ?? 1 / 60);
160
433
  break;
161
- }
162
- case 'tap':
163
- input.injectKey(step.key, true);
164
- input.injectKey(step.key, false);
434
+ case 'press':
435
+ this.key('keydown', step.key);
436
+ this.step();
437
+ this.key('keyup', step.key);
165
438
  break;
166
439
  case 'keyDown':
167
- input.injectKey(step.key, true);
440
+ this.key('keydown', step.key);
168
441
  break;
169
442
  case 'keyUp':
170
- input.injectKey(step.key, false);
443
+ this.key('keyup', step.key);
171
444
  break;
172
- case 'click': {
173
- if (step.x !== undefined && step.y !== undefined) input.injectMouseMove(step.x, step.y);
174
- const b = step.button === 'right' ? 1 : step.button === 'middle' ? 2 : 0;
175
- input.injectMouseButton(b, true);
176
- 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);
177
448
  break;
178
- }
179
449
  case 'move':
180
- input.injectMouseMove(step.x ?? 0, step.y ?? 0, step.dx ?? 0, step.dy ?? 0);
181
- break;
182
- case 'gamepad':
183
- input.injectGamepad(step.index ?? 0, { ...(step.buttons ? { buttons: step.buttons } : {}), ...(step.axes ? { axes: step.axes } : {}) });
450
+ this.pointer('pointermove', step.x, step.y);
184
451
  break;
185
452
  case 'preview':
186
- 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) });
187
454
  break;
188
455
  case 'telemetry':
189
- out.push({ kind: 'json', data: this.telemetry(step.id) });
456
+ out.push({ kind: 'json', value: this.telemetry(step.id) });
190
457
  break;
191
458
  }
192
459
  }
193
460
  return out;
194
461
  }
195
462
 
196
- // ── internals ────────────────────────────────────────────────────────────────
197
-
198
- private toEdit(): void {
199
- this.swap(true);
200
- this.mode = 'edit';
201
- this.playing = false;
202
- this.editorCamera = this.frameCamera();
203
- this.onChange?.();
463
+ /**
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.
469
+ */
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 }));
204
473
  }
205
474
 
206
- private swap(editor: boolean): void {
207
- this.engine.dispose();
208
- this.engine = this.makeEngine(editor);
209
- this.editorCamera = undefined;
210
- this.mode = editor ? 'edit' : 'play';
211
- this.applyBundle();
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
+ );
212
486
  }
487
+ }
213
488
 
214
- 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);
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;
229
502
  }
230
503
  }
231
-
232
- /** Editor camera framing the scene from a 3/4 angle. */
233
- private frameCamera(): PerspectiveCamera {
234
- 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);
241
- this.engine.mainCamera = cam;
242
- return cam;
243
- }
504
+ return wrote ? point : undefined;
244
505
  }
245
506
 
246
- // ── value helpers (telemetry → JSON) ─────────────────────────────────────────────
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;
530
+ }
247
531
 
248
- function propValues(component: Component): Record<string, unknown> {
249
- 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> {
250
534
  const out: Record<string, unknown> = {};
251
- if (!meta) return out;
252
- for (const [name] of meta.properties) out[name] = jsonValue((component as unknown as Record<string, unknown>)[name]);
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();
539
+ }
253
540
  return out;
254
541
  }
255
- function jsonValue(v: unknown): unknown {
256
- if (v == null || typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v ?? null;
257
- if (Array.isArray(v)) return v.map(jsonValue);
258
- 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
- if (o.isVector3) return [o.x, o.y, o.z];
260
- if (o.isVector2) return [o.x, o.y];
261
- if (o.isEuler) return [deg(o.x!), deg(o.y!), deg(o.z!)];
262
- if (o.isColor) return `#${o.getHexString!()}`;
263
- 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)';
265
- return String(v);
266
- }
267
- const arr = (v: { x: number; y: number; z: number }) => [round(v.x), round(v.y), round(v.z)];
268
- const degArr = (v: { x: number; y: number; z: number }) => [deg(v.x), deg(v.y), deg(v.z)];
269
- const deg = (rad: number) => round((rad * 180) / Math.PI);
270
- const round = (n: number) => Math.round(n * 1000) / 1000;
271
-
272
- function vec3(s?: string): Vector3 | undefined {
273
- if (!s) return undefined;
274
- const p = s.split(/[ ,]+/).map(Number).filter((n) => Number.isFinite(n));
275
- return p.length === 3 ? new Vector3(p[0], p[1], p[2]) : undefined;
276
- }
277
- function count(root: Object3D): number {
278
- let n = 0;
279
- root.traverse(() => n++);
280
- 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
+ };
281
562
  }
282
- function registeredMeta(component: Component) {
283
- for (const name of Registry.componentNames()) {
284
- const entry = Registry.getComponent(name);
285
- if (entry && component instanceof entry.ctor) return entry.meta;
286
- }
287
- return undefined;
563
+
564
+ function ambient(): AmbientLight {
565
+ return new AmbientLight(0xffffff, 0.6);
288
566
  }
289
- function componentName(component: Component): string | undefined {
290
- for (const name of Registry.componentNames()) {
291
- const entry = Registry.getComponent(name);
292
- if (entry && component.constructor === entry.ctor) return name;
293
- }
294
- return undefined;
567
+
568
+ function key(): DirectionalLight {
569
+ const light = new DirectionalLight(0xffffff, 2);
570
+ light.position.set(3, 5, 4);
571
+ return light;
295
572
  }