@scenoco-three/editor 0.5.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -106
- package/bin/serve.js +37 -0
- package/dist/editor.js +12521 -0
- package/package.json +29 -41
- package/app/cameraRig.ts +0 -118
- package/app/commands.ts +0 -78
- package/app/desktopControls.ts +0 -47
- package/app/document.ts +0 -547
- package/app/editLog.ts +0 -127
- package/app/editor.css +0 -51
- package/app/gizmo.ts +0 -169
- package/app/globals.d.ts +0 -9
- package/app/index.html +0 -14
- package/app/main.ts +0 -546
- package/app/meta.ts +0 -24
- package/app/picking.ts +0 -47
- package/app/session.ts +0 -612
- package/app/touchControls.ts +0 -127
- package/app/ui-react/AddMemberPopup.tsx +0 -114
- package/app/ui-react/BottomLauncher.tsx +0 -15
- package/app/ui-react/EditorUI.tsx +0 -22
- package/app/ui-react/FilePicker.tsx +0 -80
- package/app/ui-react/HeadBtn.tsx +0 -21
- package/app/ui-react/InspectorSheet.tsx +0 -475
- package/app/ui-react/NodeListSheet.tsx +0 -56
- package/app/ui-react/TopBar.tsx +0 -137
- package/app/ui-react/Tree.tsx +0 -106
- package/app/ui-react/cx.ts +0 -4
- package/app/ui-react/fields.tsx +0 -407
- package/app/ui-react/format.ts +0 -30
- package/app/ui-react/mount.tsx +0 -31
- package/app/ui-react/store.ts +0 -80
- package/app/ui-react/types.ts +0 -88
- package/app/ui-react/ui.module.css +0 -250
- package/app/ui-react/useSize.ts +0 -20
- package/app/values.ts +0 -137
- package/app/vite-env.d.ts +0 -7
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -122
- package/dist/index.d.ts +0 -10
- package/dist/index.js +0 -10
- package/dist/mcp.d.ts +0 -32
- package/dist/mcp.js +0 -191
- package/dist/protocol.d.ts +0 -170
- package/dist/protocol.js +0 -22
- package/dist/server.d.ts +0 -124
- 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
|
-
}
|