@scenoco-three/editor 0.5.0 → 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 (112) 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 +26 -30
  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/main.ts +0 -543
  82. package/app/meta.ts +0 -24
  83. package/app/picking.ts +0 -47
  84. package/app/session.ts +0 -572
  85. package/app/touchControls.ts +0 -127
  86. package/app/ui-react/AddMemberPopup.tsx +0 -114
  87. package/app/ui-react/BottomLauncher.tsx +0 -15
  88. package/app/ui-react/EditorUI.tsx +0 -22
  89. package/app/ui-react/FilePicker.tsx +0 -80
  90. package/app/ui-react/HeadBtn.tsx +0 -21
  91. package/app/ui-react/InspectorSheet.tsx +0 -475
  92. package/app/ui-react/NodeListSheet.tsx +0 -56
  93. package/app/ui-react/TopBar.tsx +0 -137
  94. package/app/ui-react/Tree.tsx +0 -106
  95. package/app/ui-react/cx.ts +0 -4
  96. package/app/ui-react/fields.tsx +0 -407
  97. package/app/ui-react/format.ts +0 -30
  98. package/app/ui-react/mount.tsx +0 -31
  99. package/app/ui-react/store.ts +0 -80
  100. package/app/ui-react/types.ts +0 -88
  101. package/app/ui-react/ui.module.css +0 -250
  102. package/app/ui-react/useSize.ts +0 -20
  103. package/app/values.ts +0 -137
  104. package/app/vite-env.d.ts +0 -7
  105. package/dist/index.d.ts +0 -10
  106. package/dist/index.js +0 -10
  107. package/dist/mcp.d.ts +0 -32
  108. package/dist/mcp.js +0 -191
  109. package/dist/protocol.d.ts +0 -170
  110. package/dist/protocol.js +0 -22
  111. package/dist/server.d.ts +0 -124
  112. package/dist/server.js +0 -437
package/app/session.ts DELETED
@@ -1,572 +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
- this.onChange?.();
102
- }
103
-
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));
146
- }
147
- this.renderer.autoClear = autoClear;
148
- }
149
-
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;
166
- }
167
-
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
- }
184
- }
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';
201
- this.playing = true;
202
- SceneManager.resetFrameClock();
203
- }
204
-
205
- pause(): void {
206
- this.playing = false;
207
- }
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?.();
228
- }
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();
235
- }
236
-
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
- */
245
- tick(dt: number): void {
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();
285
- }
286
-
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));
304
- }
305
- return box.isEmpty() ? null : this.focus(box);
306
- }
307
-
308
- /** What it takes to see one node: where it is, and from how far. */
309
- frameNode(node: Object3D): FocusTarget | null {
310
- const box = new Box3().setFromObject(node);
311
- return box.isEmpty() ? null : this.focus(box);
312
- }
313
-
314
- /**
315
- * Measure, rather than move.
316
- *
317
- * The camera's *angle* belongs to whoever is flying it, and framing must not take it — so this
318
- * answers only the half a bounding box can answer, and `CameraRig.frameOn` decides where that
319
- * puts the camera given where it currently looks.
320
- */
321
- private focus(box: Box3): FocusTarget {
322
- const center = box.getCenter(new Vector3());
323
- // A floor of half a unit, so framing a point light or an empty group doesn't put the camera
324
- // inside it.
325
- const radius = Math.max(box.getSize(new Vector3()).length() / 2, 0.5);
326
- const fit = radius / Math.sin((this.editorCamera.fov * Math.PI) / 360);
327
- return { center, distance: fit * 1.2 }; // a fifth of a radius of air around the subject
328
- }
329
-
330
- // --- what an agent asks for ---------------------------------------------------------------
331
-
332
- /**
333
- * Render one PNG from an arbitrary viewpoint.
334
- *
335
- * A capture camera rather than the editor's own: a shot must not move what the person at the
336
- * keyboard is looking at, and two shots of the same run have to be comparable.
337
- */
338
- preview(frame: PreviewFrame, at: string, lookAt?: string): string {
339
- if (!this.scene) throw new Error('no scene is open');
340
-
341
- const usingScene = frame.ui && this.scene.mainCamera;
342
- const camera = usingScene
343
- ? (this.scene.mainCamera as PerspectiveCamera)
344
- : new PerspectiveCamera(50, frame.width / frame.height, 0.1, 2000);
345
-
346
- const previous = {
347
- position: camera.position.clone(),
348
- quaternion: camera.quaternion.clone(),
349
- aspect: camera.aspect,
350
- };
351
- const target = pointOf(lookAt) ?? new Vector3();
352
- const eye = at === 'fit' ? this.fitPoint(camera, target) : pointOf(at);
353
- if (!eye) throw new Error(`camera="${at}" is not a point — write "y: 6; z: 12", or "fit"`);
354
- camera.position.copy(eye);
355
- camera.lookAt(target);
356
- // Always, including the scene's own camera: it is framed for the editor viewport, and
357
- // rendering it into a differently-shaped buffer stretches everything in the shot.
358
- camera.aspect = frame.width / frame.height;
359
- camera.updateProjectionMatrix();
360
-
361
- const { width, height } = this.renderer.domElement;
362
- this.renderer.setSize(frame.width, frame.height, false);
363
- this.renderer.render(this.scene, camera);
364
- const dataUrl = this.canvas.toDataURL('image/png');
365
- this.renderer.setSize(width, height, false);
366
-
367
- // Put the camera back: a capture is an observation, and an observation that moves the thing
368
- // it observed is not one.
369
- camera.position.copy(previous.position);
370
- camera.quaternion.copy(previous.quaternion);
371
- camera.aspect = previous.aspect;
372
- camera.updateProjectionMatrix();
373
- return dataUrl;
374
- }
375
-
376
- /** A viewpoint that fits the whole scene, for `camera="fit"`. */
377
- private fitPoint(camera: PerspectiveCamera, target: Vector3): Vector3 {
378
- const box = new Box3().setFromObject(this.scene!);
379
- const center = box.isEmpty() ? new Vector3() : box.getCenter(new Vector3());
380
- target.copy(center);
381
- const radius = box.isEmpty() ? 5 : Math.max(box.getSize(new Vector3()).length() / 2, 0.5);
382
- const distance = (radius / Math.sin((camera.fov * Math.PI) / 360)) * 1.2;
383
- return center.clone().addScaledVector(new Vector3(0.6, 0.5, 1).normalize(), distance);
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
- */
392
- telemetry(id: string): unknown {
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`);
396
- }
397
- return {
398
- id,
399
- type: node.type,
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
- })),
411
- };
412
- }
413
-
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 }> = [];
420
- for (const step of steps) {
421
- switch (step.do) {
422
- case 'play':
423
- this.run();
424
- break;
425
- case 'pause':
426
- this.pause();
427
- break;
428
- case 'stop':
429
- await this.stop();
430
- break;
431
- case 'step':
432
- for (let i = 0; i < (step.frames ?? 1); i++) this.step(step.dt ?? 1 / 60);
433
- break;
434
- case 'press':
435
- this.key('keydown', step.key);
436
- this.step();
437
- this.key('keyup', step.key);
438
- break;
439
- case 'keyDown':
440
- this.key('keydown', step.key);
441
- break;
442
- case 'keyUp':
443
- this.key('keyup', step.key);
444
- break;
445
- case 'click':
446
- this.pointer('pointerdown', step.x, step.y, step.button);
447
- this.pointer('pointerup', step.x, step.y, step.button);
448
- break;
449
- case 'move':
450
- this.pointer('pointermove', step.x, step.y);
451
- break;
452
- case 'preview':
453
- out.push({ kind: 'image', value: this.preview(frame, step.camera, step.lookAt) });
454
- break;
455
- case 'telemetry':
456
- out.push({ kind: 'json', value: this.telemetry(step.id) });
457
- break;
458
- }
459
- }
460
- return out;
461
- }
462
-
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 }));
473
- }
474
-
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
- );
486
- }
487
- }
488
-
489
- /** `"y: 6; z: 12"` — the document's own vector grammar, so an agent learns one. */
490
- export function pointOf(text?: string): Vector3 | undefined {
491
- if (!text || text === 'fit') return undefined;
492
- const point = new Vector3();
493
- let wrote = false;
494
- for (const part of text.split(';')) {
495
- const [axis, value] = part.split(':');
496
- const n = Number(value?.trim());
497
- if (!axis || !Number.isFinite(n)) continue;
498
- const key = axis.trim() as 'x' | 'y' | 'z';
499
- if (key === 'x' || key === 'y' || key === 'z') {
500
- point[key] = n;
501
- wrote = true;
502
- }
503
- }
504
- return wrote ? point : undefined;
505
- }
506
-
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
- }
531
-
532
- /** A component's own public values — what a document could have written, read back. */
533
- function readable(component: object): Record<string, unknown> {
534
- const out: Record<string, unknown> = {};
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
- }
540
- return out;
541
- }
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
- };
562
- }
563
-
564
- function ambient(): AmbientLight {
565
- return new AmbientLight(0xffffff, 0.6);
566
- }
567
-
568
- function key(): DirectionalLight {
569
- const light = new DirectionalLight(0xffffff, 2);
570
- light.position.set(3, 5, 4);
571
- return light;
572
- }
@@ -1,127 +0,0 @@
1
- import { Vector2 } from 'three';
2
- import type { CameraRig } from './cameraRig.js';
3
-
4
- /**
5
- * Touch navigation, for editing a scene on a phone or tablet:
6
- *
7
- * - **one finger** drags the scene — the camera moves opposite the finger, so whatever is under
8
- * it stays under it
9
- * - **two fingers** turn the view by their average movement, and pinch to dolly in and out
10
- *
11
- * One finger pans rather than orbits because the other half of the editor's one-finger vocabulary
12
- * is already spoken for: a tap selects, and a drag on a gizmo handle moves the thing you selected.
13
- * Turning is the gesture you reach for least often while placing something, so it gets the
14
- * two-finger slot.
15
- *
16
- * Every motion is scaled by `focusDistance`, which is Unity's trick: a drag covers the same
17
- * *screen* distance whether you're an arm's length from a crate or across the level, so the
18
- * controls feel the same at every zoom.
19
- *
20
- * It shares the camera with the desktop controls by construction — both only ever call `rig`
21
- * verbs, and the rig is what writes the transform.
22
- */
23
- export class TouchControls {
24
- private readonly points = new Map<number, Vector2>();
25
- private previousPinch = 0;
26
-
27
- /** Per-pixel rates, multiplied by the focus distance (except look, which is angular). */
28
- private readonly pan = 0.0016;
29
- private readonly dolly = 0.005;
30
- private readonly look = 0.005; // radians per pixel
31
-
32
- private detach: Array<() => void> = [];
33
-
34
- private readonly rig: CameraRig;
35
- /** True while a gizmo handle owns the gesture — the camera stays out of it. */
36
- private readonly isBlocked: () => boolean;
37
-
38
- constructor(rig: CameraRig, canvas: HTMLElement, isBlocked: () => boolean = () => false) {
39
- this.rig = rig;
40
- this.isBlocked = isBlocked;
41
- const on = (type: string, handler: (event: TouchEvent) => void): void => {
42
- const listener = handler as EventListener;
43
- // Not passive: these gestures have to beat the browser's own pan/zoom, and it only
44
- // honours `preventDefault` from a listener that said up front it might call it.
45
- canvas.addEventListener(type, listener, { passive: false });
46
- this.detach.push(() => canvas.removeEventListener(type, listener));
47
- };
48
- on('touchstart', this.onStart);
49
- on('touchmove', this.onMove);
50
- on('touchend', this.onEnd);
51
- on('touchcancel', this.onEnd);
52
- }
53
-
54
- private onStart = (event: TouchEvent): void => {
55
- for (const touch of Array.from(event.changedTouches)) {
56
- this.points.set(touch.identifier, new Vector2(touch.clientX, touch.clientY));
57
- }
58
- this.previousPinch = this.pinchDistance();
59
- };
60
-
61
- private onMove = (event: TouchEvent): void => {
62
- // A finger dragging a gizmo handle emits touch events too — that gesture belongs to the
63
- // gizmo, and the camera must hold still under it or the drag fights the view.
64
- if (this.points.size === 1 && this.isBlocked()) return;
65
- event.preventDefault();
66
- const scale = this.rig.focusDistance;
67
-
68
- if (this.points.size === 1) {
69
- for (const touch of Array.from(event.touches)) {
70
- const previous = this.points.get(touch.identifier);
71
- if (!previous) continue;
72
- this.rig.move(
73
- -(touch.clientX - previous.x) * this.pan * scale,
74
- (touch.clientY - previous.y) * this.pan * scale,
75
- 0,
76
- );
77
- previous.set(touch.clientX, touch.clientY);
78
- }
79
- return;
80
- }
81
-
82
- let dx = 0;
83
- let dy = 0;
84
- let moved = 0;
85
- for (const touch of Array.from(event.touches)) {
86
- const previous = this.points.get(touch.identifier);
87
- if (!previous) continue;
88
- dx += touch.clientX - previous.x;
89
- dy += touch.clientY - previous.y;
90
- previous.set(touch.clientX, touch.clientY);
91
- moved++;
92
- }
93
- // The *average* of the two fingers, so a pinch — where they travel opposite ways — cancels
94
- // out and dollies without also swinging the view around.
95
- if (moved > 0) this.rig.rotate((-dx / moved) * this.look, (-dy / moved) * this.look);
96
-
97
- const pinch = this.pinchDistance();
98
- if (this.previousPinch > 0) {
99
- const forward = (pinch - this.previousPinch) * this.dolly * scale;
100
- this.rig.move(0, 0, forward);
101
- // Closer in means finer control, the same way the wheel tunes fly speed on desktop.
102
- this.rig.focusDistance = Math.max(0.1, this.rig.focusDistance - forward);
103
- }
104
- this.previousPinch = pinch;
105
- };
106
-
107
- private onEnd = (event: TouchEvent): void => {
108
- for (const touch of Array.from(event.changedTouches)) this.points.delete(touch.identifier);
109
- // Lifting one of two fingers must not read as a huge pinch on the next move.
110
- this.previousPinch = this.pinchDistance();
111
- };
112
-
113
- private pinchDistance(): number {
114
- const [a, b] = [...this.points.values()];
115
- return a && b ? a.distanceTo(b) : 0;
116
- }
117
-
118
- dispose(): void {
119
- for (const off of this.detach) off();
120
- this.detach = [];
121
- }
122
- }
123
-
124
- /** True on a device that can be touched — which is what decides fat gizmo handles. */
125
- export function isTouchDevice(): boolean {
126
- return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
127
- }