@scenoco-three/editor 0.5.4 → 0.6.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 (113) hide show
  1. package/README.md +31 -93
  2. package/dist/cli/index.js +49 -115
  3. package/dist-app/assets/EffectComposer-lekj9UUq.js +1 -0
  4. package/dist-app/assets/GLTFLoader-C2lUhzim.js +1 -0
  5. package/dist-app/assets/OutputPass-DZtd342R.js +72 -0
  6. package/dist-app/assets/Pass-BP3b3hlf.js +1 -0
  7. package/dist-app/assets/RenderPass-BjVchlYg.js +1 -0
  8. package/dist-app/assets/ShaderPass-DMXLcV9X.js +24 -0
  9. package/dist-app/assets/SkeletonUtils-Cxy_owx9.js +1 -0
  10. package/dist-app/assets/index-AxOpglZ-.css +1 -0
  11. package/dist-app/assets/index-D-Iq7tix.js +4135 -0
  12. package/dist-app/assets/inter-CaiW00F8.js +1 -0
  13. package/dist-app/assets/registry-BOM35tSr.js +1433 -0
  14. package/dist-app/assets/worker-_Lh8Vt-i.js +1270 -0
  15. package/dist-app/examples/breakout/assets/geometry/ball.geometry.xml +1 -0
  16. package/dist-app/examples/breakout/assets/geometry/brick.geometry.xml +1 -0
  17. package/dist-app/examples/breakout/assets/geometry/floor-sensor.geometry.xml +1 -0
  18. package/dist-app/examples/breakout/assets/geometry/paddle.geometry.xml +1 -0
  19. package/dist-app/examples/breakout/assets/geometry/wall-side.geometry.xml +1 -0
  20. package/dist-app/examples/breakout/assets/geometry/wall-top.geometry.xml +1 -0
  21. package/dist-app/examples/breakout/assets/materials/ball.material.xml +1 -0
  22. package/dist-app/examples/breakout/assets/materials/brick-firm.material.xml +1 -0
  23. package/dist-app/examples/breakout/assets/materials/brick-hard.material.xml +1 -0
  24. package/dist-app/examples/breakout/assets/materials/brick-soft.material.xml +1 -0
  25. package/dist-app/examples/breakout/assets/materials/paddle.material.xml +10 -0
  26. package/dist-app/examples/breakout/assets/materials/wall.material.xml +1 -0
  27. package/dist-app/examples/breakout/assets/render/neon.render.xml +14 -0
  28. package/dist-app/examples/breakout/assets/scenes/arena.prefab.xml +68 -0
  29. package/dist-app/examples/breakout/assets/scenes/brick.prefab.xml +15 -0
  30. package/dist-app/examples/breakout/assets/scenes/hud.scene.xml +56 -0
  31. package/dist-app/examples/breakout/assets/scenes/level-1.scene.xml +36 -0
  32. package/dist-app/examples/breakout/assets/scenes/level-2.scene.xml +36 -0
  33. package/dist-app/examples/breakout/assets/scenes/level-3.scene.xml +36 -0
  34. package/dist-app/examples/castle/assets/geometry/capsule.geometry.xml +3 -0
  35. package/dist-app/examples/castle/assets/geometry/cone.geometry.xml +2 -0
  36. package/dist-app/examples/castle/assets/geometry/cube.geometry.xml +3 -0
  37. package/dist-app/examples/castle/assets/geometry/cylinder.geometry.xml +3 -0
  38. package/dist-app/examples/castle/assets/geometry/plane.geometry.xml +4 -0
  39. package/dist-app/examples/castle/assets/geometry/quad.geometry.xml +3 -0
  40. package/dist-app/examples/castle/assets/geometry/sphere.geometry.xml +2 -0
  41. package/dist-app/examples/castle/assets/materials/grass.material.xml +1 -0
  42. package/dist-app/examples/castle/assets/materials/roof.material.xml +1 -0
  43. package/dist-app/examples/castle/assets/materials/stone.material.xml +1 -0
  44. package/dist-app/examples/castle/assets/materials/wood.material.xml +1 -0
  45. package/dist-app/examples/castle/assets/scenes/main.scene.xml +25 -0
  46. package/dist-app/examples/collector/assets/geometry/capsule.geometry.xml +3 -0
  47. package/dist-app/examples/collector/assets/geometry/cone.geometry.xml +2 -0
  48. package/dist-app/examples/collector/assets/geometry/cube.geometry.xml +3 -0
  49. package/dist-app/examples/collector/assets/geometry/cylinder.geometry.xml +3 -0
  50. package/dist-app/examples/collector/assets/geometry/plane.geometry.xml +4 -0
  51. package/dist-app/examples/collector/assets/geometry/quad.geometry.xml +3 -0
  52. package/dist-app/examples/collector/assets/geometry/sphere.geometry.xml +2 -0
  53. package/dist-app/examples/collector/assets/materials/crate.material.xml +1 -0
  54. package/dist-app/examples/collector/assets/materials/depot.material.xml +1 -0
  55. package/dist-app/examples/collector/assets/materials/ground.material.xml +1 -0
  56. package/dist-app/examples/collector/assets/materials/player.material.xml +1 -0
  57. package/dist-app/examples/collector/assets/scenes/crate.prefab.xml +9 -0
  58. package/dist-app/examples/collector/assets/scenes/main.scene.xml +83 -0
  59. package/dist-app/examples/playable/assets/geometry/capsule.geometry.xml +3 -0
  60. package/dist-app/examples/playable/assets/geometry/cone.geometry.xml +2 -0
  61. package/dist-app/examples/playable/assets/geometry/cube.geometry.xml +3 -0
  62. package/dist-app/examples/playable/assets/geometry/cylinder.geometry.xml +3 -0
  63. package/dist-app/examples/playable/assets/geometry/plane.geometry.xml +4 -0
  64. package/dist-app/examples/playable/assets/geometry/quad.geometry.xml +3 -0
  65. package/dist-app/examples/playable/assets/geometry/sphere.geometry.xml +2 -0
  66. package/dist-app/examples/playable/assets/materials/ground.material.xml +1 -0
  67. package/dist-app/examples/playable/assets/materials/target.material.xml +2 -0
  68. package/dist-app/examples/playable/assets/scenes/main.scene.xml +53 -0
  69. package/dist-app/examples/playable/assets/scenes/target.prefab.xml +9 -0
  70. package/dist-app/examples.json +68 -0
  71. package/{app → dist-app}/index.html +2 -2
  72. package/dist-app/model.json +1 -0
  73. package/package.json +25 -29
  74. package/app/cameraRig.ts +0 -118
  75. package/app/commands.ts +0 -78
  76. package/app/desktopControls.ts +0 -47
  77. package/app/document.ts +0 -547
  78. package/app/editLog.ts +0 -127
  79. package/app/editor.css +0 -51
  80. package/app/gizmo.ts +0 -169
  81. package/app/globals.d.ts +0 -9
  82. package/app/main.ts +0 -546
  83. package/app/meta.ts +0 -24
  84. package/app/picking.ts +0 -47
  85. package/app/session.ts +0 -612
  86. package/app/touchControls.ts +0 -127
  87. package/app/ui-react/AddMemberPopup.tsx +0 -114
  88. package/app/ui-react/BottomLauncher.tsx +0 -15
  89. package/app/ui-react/EditorUI.tsx +0 -22
  90. package/app/ui-react/FilePicker.tsx +0 -80
  91. package/app/ui-react/HeadBtn.tsx +0 -21
  92. package/app/ui-react/InspectorSheet.tsx +0 -475
  93. package/app/ui-react/NodeListSheet.tsx +0 -56
  94. package/app/ui-react/TopBar.tsx +0 -137
  95. package/app/ui-react/Tree.tsx +0 -106
  96. package/app/ui-react/cx.ts +0 -4
  97. package/app/ui-react/fields.tsx +0 -407
  98. package/app/ui-react/format.ts +0 -30
  99. package/app/ui-react/mount.tsx +0 -31
  100. package/app/ui-react/store.ts +0 -80
  101. package/app/ui-react/types.ts +0 -88
  102. package/app/ui-react/ui.module.css +0 -250
  103. package/app/ui-react/useSize.ts +0 -20
  104. package/app/values.ts +0 -137
  105. package/app/vite-env.d.ts +0 -7
  106. package/dist/index.d.ts +0 -10
  107. package/dist/index.js +0 -10
  108. package/dist/mcp.d.ts +0 -32
  109. package/dist/mcp.js +0 -191
  110. package/dist/protocol.d.ts +0 -170
  111. package/dist/protocol.js +0 -22
  112. package/dist/server.d.ts +0 -124
  113. package/dist/server.js +0 -440
package/app/session.ts DELETED
@@ -1,612 +0,0 @@
1
- import {
2
- AmbientLight,
3
- Box3,
4
- Color,
5
- DirectionalLight,
6
- Object3D,
7
- PerspectiveCamera,
8
- Scene,
9
- Vector3,
10
- WebGLRenderer,
11
- type Camera,
12
- } from 'three';
13
- import { Engine, Input, SceneManager, type PrefabType, type SceneType } from '@scenoco-three/core';
14
- import type { PreviewFrame, SimStep } from '../src/protocol.js';
15
-
16
- export type EditorMode = 'edit' | 'play';
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
-
24
- /**
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.
31
- */
32
- export class EditorSession {
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;
49
- mode: EditorMode = 'edit';
50
- playing = false;
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
-
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
-
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
-
109
- this.onChange?.();
110
- }
111
-
112
- // --- the scene's UI, while editing ---------------------------------------------------------
113
-
114
- /**
115
- * Whether the editor draws the scene's screen-space UI over the viewport.
116
- *
117
- * A `<Canvas>` is a child of the *scene's* camera and sizes itself to it, so from the editor
118
- * camera a HUD is a small rectangle floating in world space near a camera you are usually
119
- * looking away from. Play mode has been the only place it is legible, which makes laying one
120
- * out a matter of pressing Play, looking, pressing Stop.
121
- *
122
- * Switching this on draws it in a second pass instead: a camera matching the one the UI was
123
- * built for, over the top of the finished frame, clearing nothing. Nothing about the scene
124
- * graph changes — the Canvas stays the child of its camera, where the document put it and
125
- * where the hierarchy shows it.
126
- */
127
- get uiInEditor(): boolean {
128
- return this.showUi;
129
- }
130
-
131
- set uiInEditor(on: boolean) {
132
- this.showUi = on;
133
- }
134
-
135
- /**
136
- * Draw the UI over the finished frame.
137
- *
138
- * `render` takes any `Object3D` as its root, so passing the UI root draws that subtree and
139
- * nothing else — no layer masks to keep in step as uikit builds and drops meshes during
140
- * layout, and no second copy of the scene.
141
- *
142
- * The camera is a copy rather than the scene's own. A render pass writes to the camera it is
143
- * given — `matrixWorldInverse`, the projection matrix — and an editor-only overlay has no
144
- * business leaving fingerprints on the camera the document declared. The copy takes its world
145
- * matrix and its projection, so the UI lands exactly where it was laid out to land.
146
- */
147
- private renderUiOverlay(): void {
148
- const autoClear = this.renderer.autoClear;
149
- this.renderer.autoClear = false;
150
- for (const root of this.uiRoots()) {
151
- const owner = root.parent as Camera | null;
152
- if (!owner?.isCamera) continue;
153
- this.renderer.render(root, this.overlayFor(owner));
154
- }
155
- this.renderer.autoClear = autoClear;
156
- }
157
-
158
- /** A stand-in for one of the scene's cameras, kept in step with it and owned by the editor. */
159
- private overlayFor(owner: Camera): Camera {
160
- let copy = this.overlays.get(owner);
161
- if (!copy) {
162
- copy = owner.clone(false) as Camera; // no children: the UI subtree is rendered, not copied
163
- this.overlays.set(owner, copy);
164
- }
165
- copy.copy(owner, false); // projection parameters — fov, aspect, near, far
166
- // The owner's `matrix` is local to its parent; its `matrixWorld` is where it actually is,
167
- // and the overlay has no parent to inherit from.
168
- copy.matrixAutoUpdate = false;
169
- copy.matrixWorldAutoUpdate = false;
170
- copy.matrix.copy(owner.matrixWorld);
171
- copy.matrixWorld.copy(owner.matrixWorld);
172
- copy.matrixWorldNeedsUpdate = false;
173
- return copy;
174
- }
175
-
176
- /**
177
- * The screen-space UI roots in the scene.
178
- *
179
- * Identified by the fields a uikit `Fullscreen` alone carries, rather than by importing uikit:
180
- * a project that uses no UI must not drag the package in, and the editor has no business
181
- * depending on one tag package over another.
182
- */
183
- private uiRoots(): Object3D[] {
184
- const found: Object3D[] = [];
185
- for (const child of this.scene?.children ?? []) {
186
- if (!(child as Partial<Camera>).isCamera) continue;
187
- for (const grandchild of child.children) {
188
- if ('pixelSize' in grandchild && 'sizeX' in grandchild && 'sizeY' in grandchild) {
189
- found.push(grandchild);
190
- }
191
- }
192
- }
193
- return found;
194
- }
195
-
196
- // --- transport ---------------------------------------------------------------------------
197
-
198
- /**
199
- * Start the simulation on the scene as it is now — including everything moved since it loaded.
200
- *
201
- * The systems get told to re-read the scene graph first. Edit mode is a whole session of
202
- * writing transforms from outside the simulation, and a physics world does not watch for that:
203
- * without this, a crate dragged across the level plays with its collider still where the
204
- * document put it, and things bounce off empty space.
205
- */
206
- run(): void {
207
- for (const system of this.scene?.systems ?? []) system.syncTransforms?.();
208
- this.mode = 'play';
209
- this.playing = true;
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?.();
216
- }
217
-
218
- pause(): void {
219
- this.playing = false;
220
- this.onChange?.();
221
- }
222
-
223
- /**
224
- * Stop, and put the scene back as the document describes it — Unity's Stop, which discards
225
- * everything play mode did.
226
- *
227
- * Rebuilt rather than merely halted, because a run has to start from the same place every
228
- * time. Without it a second `simulate` batch begins wherever the first one left off, and two
229
- * identical batches disagree — which is precisely the property an agent is relying on when it
230
- * drives a scene to a condition and looks at it.
231
- */
232
- async stop(): Promise<void> {
233
- this.playing = false;
234
- this.mode = 'edit';
235
- if (!this.sceneClass) return;
236
- await SceneManager.unloadAll();
237
- this.scene = await SceneManager.load(this.sceneClass);
238
- this.resize(); // a rebuilt scene brings a fresh camera with a default aspect
239
- this.clock = 0;
240
- SceneManager.resetFrameClock();
241
- this.onChange?.();
242
- }
243
-
244
- /** Advance by exactly one step — the frame-by-frame inspection a bug needs. */
245
- step(dt = 1 / 60): void {
246
- this.clock += dt * 1000;
247
- SceneManager.tick(this.clock);
248
- Input.endFrame();
249
- }
250
-
251
- /**
252
- * One frame of the editor's own loop.
253
- *
254
- * `Input.endFrame` runs whether or not the scene stepped, and that is the point: `Engine` calls
255
- * it from the loop the editor does not use, so nothing else would. Without it `getKeyDown` is
256
- * true for ever after one press and `mouseDelta` keeps its last value — a camera that never
257
- * stops turning once you have turned it, and a one-frame edge that fires on every frame.
258
- */
259
- tick(dt: number): void {
260
- if (this.playing) this.step(dt);
261
- else {
262
- // Edit mode ticks nothing, which leaves anything presentational stale — a UI layout most
263
- // visibly. Systems that can be brought up to date without simulating say so.
264
- for (const system of this.scene?.systems ?? []) system.layout?.(dt);
265
- Input.endFrame();
266
- }
267
- this.render();
268
- }
269
-
270
- render(camera: Camera = this.renderCamera()): void {
271
- if (!this.scene) return;
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);
276
- // Only in edit mode: play renders through the scene's own camera, where the UI is already
277
- // in front of it, and a run has to show exactly what the game shows — once.
278
- if (this.showUi && this.mode === 'edit') this.renderUiOverlay();
279
- }
280
-
281
- /** Play renders through the scene's camera; edit through the editor's. */
282
- renderCamera(): Camera {
283
- return this.mode === 'play' && this.scene?.mainCamera ? this.scene.mainCamera : this.editorCamera;
284
- }
285
-
286
- /**
287
- * Match the drawing buffer and **both** cameras to the canvas.
288
- *
289
- * `Engine.resize` sizes the buffer and the scene's camera, and it is the very code the game
290
- * runs — so it is called rather than reimplemented. But the editor never starts the engine's
291
- * loop, which is what would normally keep calling it: the constructor's one call happens before
292
- * any scene is loaded, so `mainCamera` does not exist yet and never gets an aspect. Play mode
293
- * then renders the game through a camera still at aspect 1 and every sphere is an ellipse.
294
- *
295
- * The editor's own camera is not the engine's business, so it is handled here.
296
- */
297
- private resize(): void {
298
- this.engine.resize();
299
- const { clientWidth: w, clientHeight: h } = this.canvas;
300
- this.editorCamera.aspect = w / Math.max(h, 1);
301
- this.editorCamera.updateProjectionMatrix();
302
- }
303
-
304
- /**
305
- * Put the editor camera where it can see everything, and say what to look at.
306
- *
307
- * The **point** comes back rather than the camera being aimed here, because the camera rig owns
308
- * orientation: it accumulates yaw and pitch and writes them every frame, so a `lookAt` from
309
- * outside is overwritten on the very next one. Framing that positioned the camera perfectly and
310
- * then pointed it somewhere else was exactly that bug.
311
- */
312
- frameAll(): FocusTarget | null {
313
- if (!this.scene) return null;
314
- // Only what the document authored. The gizmo's helper is a child of the scene too, and its
315
- // picker plane is 100000 units across: framing that put the camera millions of units out, and
316
- // since the handles scale with camera distance, each press of the key made the next one worse.
317
- const box = new Box3();
318
- for (const child of this.scene.children) {
319
- if (child.userData.scenocoEditorOnly) continue;
320
- box.union(new Box3().setFromObject(child));
321
- }
322
- return box.isEmpty() ? null : this.focus(box);
323
- }
324
-
325
- /** What it takes to see one node: where it is, and from how far. */
326
- frameNode(node: Object3D): FocusTarget | null {
327
- const box = new Box3().setFromObject(node);
328
- return box.isEmpty() ? null : this.focus(box);
329
- }
330
-
331
- /**
332
- * Measure, rather than move.
333
- *
334
- * The camera's *angle* belongs to whoever is flying it, and framing must not take it — so this
335
- * answers only the half a bounding box can answer, and `CameraRig.frameOn` decides where that
336
- * puts the camera given where it currently looks.
337
- */
338
- private focus(box: Box3): FocusTarget {
339
- const center = box.getCenter(new Vector3());
340
- // A floor of half a unit, so framing a point light or an empty group doesn't put the camera
341
- // inside it.
342
- const radius = Math.max(box.getSize(new Vector3()).length() / 2, 0.5);
343
- const fit = radius / Math.sin((this.editorCamera.fov * Math.PI) / 360);
344
- return { center, distance: fit * 1.2 }; // a fifth of a radius of air around the subject
345
- }
346
-
347
- // --- what an agent asks for ---------------------------------------------------------------
348
-
349
- /**
350
- * Render one PNG from an arbitrary viewpoint.
351
- *
352
- * A capture camera rather than the editor's own: a shot must not move what the person at the
353
- * keyboard is looking at, and two shots of the same run have to be comparable.
354
- */
355
- preview(frame: PreviewFrame, at: string, lookAt?: string): string {
356
- if (!this.scene) throw new Error('no scene is open');
357
-
358
- const usingScene = frame.ui && this.scene.mainCamera;
359
- const camera = usingScene
360
- ? (this.scene.mainCamera as PerspectiveCamera)
361
- : new PerspectiveCamera(50, frame.width / frame.height, 0.1, 2000);
362
-
363
- const previous = {
364
- position: camera.position.clone(),
365
- quaternion: camera.quaternion.clone(),
366
- aspect: camera.aspect,
367
- };
368
- const target = pointOf(lookAt) ?? new Vector3();
369
- const eye = at === 'fit' ? this.fitPoint(camera, target) : pointOf(at);
370
- if (!eye) throw new Error(`camera="${at}" is not a point — write "y: 6; z: 12", or "fit"`);
371
- camera.position.copy(eye);
372
- camera.lookAt(target);
373
- // Always, including the scene's own camera: it is framed for the editor viewport, and
374
- // rendering it into a differently-shaped buffer stretches everything in the shot.
375
- camera.aspect = frame.width / frame.height;
376
- camera.updateProjectionMatrix();
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.
380
- const { width, height } = this.renderer.domElement;
381
- this.engine.setFrameSize(frame.width, frame.height);
382
- this.engine.renderScene(this.scene, camera);
383
- const dataUrl = this.canvas.toDataURL('image/png');
384
- this.engine.setFrameSize(width, height);
385
-
386
- // Put the camera back: a capture is an observation, and an observation that moves the thing
387
- // it observed is not one.
388
- camera.position.copy(previous.position);
389
- camera.quaternion.copy(previous.quaternion);
390
- camera.aspect = previous.aspect;
391
- camera.updateProjectionMatrix();
392
- return dataUrl;
393
- }
394
-
395
- /** A viewpoint that fits the whole scene, for `camera="fit"`. */
396
- private fitPoint(camera: PerspectiveCamera, target: Vector3): Vector3 {
397
- const box = new Box3().setFromObject(this.scene!);
398
- const center = box.isEmpty() ? new Vector3() : box.getCenter(new Vector3());
399
- target.copy(center);
400
- const radius = box.isEmpty() ? 5 : Math.max(box.getSize(new Vector3()).length() / 2, 0.5);
401
- const distance = (radius / Math.sin((camera.fov * Math.PI) / 360)) * 1.2;
402
- return center.clone().addScaledVector(new Vector3(0.6, 0.5, 1).normalize(), distance);
403
- }
404
-
405
- /**
406
- * Read the state at an `id` the document declared.
407
- *
408
- * The generated scene class carries a typed field per `id`, so this is a property lookup —
409
- * there is no registry to consult and no name index to keep.
410
- */
411
- telemetry(id: string): unknown {
412
- const node = (this.scene as unknown as Record<string, Object3D | undefined>)?.[id];
413
- if (!node || typeof node !== 'object' || !('isObject3D' in node)) {
414
- throw new Error(`no id "${id}" in this document`);
415
- }
416
- return {
417
- id,
418
- type: node.type,
419
- name: node.name,
420
- position: node.position.toArray(),
421
- eulerAngles: node.eulerAngles.toArray(),
422
- scale: node.scale.toArray(),
423
- visible: node.visible,
424
- active: node.activeInHierarchy,
425
- components: node.components.map((component) => ({
426
- type: component.constructor.name,
427
- enabled: component.enabled,
428
- ...readable(component),
429
- })),
430
- };
431
- }
432
-
433
- /** Run a batch of steps in order, collecting whatever each one produced. */
434
- async simulate(
435
- steps: readonly SimStep[],
436
- frame: PreviewFrame,
437
- ): Promise<Array<{ kind: 'image' | 'json'; value: unknown }>> {
438
- const out: Array<{ kind: 'image' | 'json'; value: unknown }> = [];
439
- for (const step of steps) {
440
- switch (step.do) {
441
- case 'play':
442
- this.run();
443
- break;
444
- case 'pause':
445
- this.pause();
446
- break;
447
- case 'stop':
448
- await this.stop();
449
- break;
450
- case 'step':
451
- await this.stepVisibly(step.frames ?? 1, step.dt ?? 1 / 60);
452
- break;
453
- case 'press':
454
- this.key('keydown', step.key);
455
- await this.stepVisibly(1);
456
- this.key('keyup', step.key);
457
- break;
458
- case 'keyDown':
459
- this.key('keydown', step.key);
460
- break;
461
- case 'keyUp':
462
- this.key('keyup', step.key);
463
- break;
464
- case 'click':
465
- this.pointer('pointerdown', step.x, step.y, step.button);
466
- this.pointer('pointerup', step.x, step.y, step.button);
467
- break;
468
- case 'move':
469
- this.pointer('pointermove', step.x, step.y);
470
- break;
471
- case 'preview':
472
- out.push({ kind: 'image', value: this.preview(frame, step.camera, step.lookAt) });
473
- break;
474
- case 'telemetry':
475
- out.push({ kind: 'json', value: this.telemetry(step.id) });
476
- break;
477
- }
478
- }
479
- return out;
480
- }
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
-
503
- /**
504
- * Synthetic input is a real DOM event.
505
- *
506
- * `Input` listens to the page, so there is nothing to fake and no test-only path through it —
507
- * an agent's key press travels the same route a person's does, which is the only way a
508
- * simulated run tells you anything about the real one.
509
- */
510
- private key(type: 'keydown' | 'keyup', name: string): void {
511
- const code = keyCodeFor(name);
512
- window.dispatchEvent(new KeyboardEvent(type, { code, key: name, bubbles: true }));
513
- }
514
-
515
- private pointer(type: string, x?: number, y?: number, button?: 'left' | 'right' | 'middle'): void {
516
- const rect = this.canvas.getBoundingClientRect();
517
- this.canvas.dispatchEvent(
518
- new PointerEvent(type, {
519
- bubbles: true,
520
- clientX: rect.left + (x ?? rect.width / 2),
521
- clientY: rect.top + (y ?? rect.height / 2),
522
- button: button === 'right' ? 2 : button === 'middle' ? 1 : 0,
523
- pointerId: 1,
524
- }),
525
- );
526
- }
527
- }
528
-
529
- /** `"y: 6; z: 12"` — the document's own vector grammar, so an agent learns one. */
530
- export function pointOf(text?: string): Vector3 | undefined {
531
- if (!text || text === 'fit') return undefined;
532
- const point = new Vector3();
533
- let wrote = false;
534
- for (const part of text.split(';')) {
535
- const [axis, value] = part.split(':');
536
- const n = Number(value?.trim());
537
- if (!axis || !Number.isFinite(n)) continue;
538
- const key = axis.trim() as 'x' | 'y' | 'z';
539
- if (key === 'x' || key === 'y' || key === 'z') {
540
- point[key] = n;
541
- wrote = true;
542
- }
543
- }
544
- return wrote ? point : undefined;
545
- }
546
-
547
- /**
548
- * A `KeyCode` member name as the DOM spells it.
549
- *
550
- * `Space` is `Space`, `W` is `KeyW`, `LeftArrow` is `ArrowLeft` — one small table, because the
551
- * two vocabularies genuinely differ and an agent should write the one the engine documents.
552
- */
553
- function keyCodeFor(name: string): string {
554
- const special: Record<string, string> = {
555
- Space: 'Space',
556
- LeftArrow: 'ArrowLeft',
557
- RightArrow: 'ArrowRight',
558
- UpArrow: 'ArrowUp',
559
- DownArrow: 'ArrowDown',
560
- Return: 'Enter',
561
- Escape: 'Escape',
562
- Tab: 'Tab',
563
- LeftShift: 'ShiftLeft',
564
- LeftControl: 'ControlLeft',
565
- };
566
- if (special[name]) return special[name]!;
567
- if (/^[A-Z]$/.test(name)) return `Key${name}`;
568
- if (/^Alpha[0-9]$/.test(name)) return `Digit${name.slice(5)}`;
569
- return name;
570
- }
571
-
572
- /** A component's own public values — what a document could have written, read back. */
573
- function readable(component: object): Record<string, unknown> {
574
- const out: Record<string, unknown> = {};
575
- for (const [key, value] of Object.entries(component)) {
576
- if (key.startsWith('_')) continue;
577
- if (value === null || ['number', 'string', 'boolean'].includes(typeof value)) out[key] = value;
578
- else if (value instanceof Vector3) out[key] = value.toArray();
579
- }
580
- return out;
581
- }
582
-
583
- /**
584
- * A scene that holds one instance of a prefab — the stage a prefab is edited on.
585
- *
586
- * The lights and the background are the editor's, not the document's, so they carry the
587
- * `scenocoEditorOnly` flag every other editor-drawn helper does: the hierarchy, picking and
588
- * framing all skip them, and nothing the person sees in the tree came from anywhere but the
589
- * prefab they opened.
590
- */
591
- function stageFor(Prefab: PrefabType<Object3D>): SceneType {
592
- return class PrefabStage extends Scene {
593
- override onLoad(): void {
594
- this.instantiate(Prefab);
595
- this.background = new Color(0x15181f);
596
- for (const light of [ambient(), key()]) {
597
- light.userData.scenocoEditorOnly = true;
598
- this.add(light);
599
- }
600
- }
601
- };
602
- }
603
-
604
- function ambient(): AmbientLight {
605
- return new AmbientLight(0xffffff, 0.6);
606
- }
607
-
608
- function key(): DirectionalLight {
609
- const light = new DirectionalLight(0xffffff, 2);
610
- light.position.set(3, 5, 4);
611
- return light;
612
- }