@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/main.ts DELETED
@@ -1,546 +0,0 @@
1
- import type { Object3D, Vector3 } from 'three';
2
- import type { ProjectModel } from '@scenoco-three/compiler';
3
- import { CameraRig } from './cameraRig.js';
4
- import { DesktopControls } from './desktopControls.js';
5
- import { TouchControls, isTouchDevice } from './touchControls.js';
6
- import { SceneDocument, type RawElement } from './document.js';
7
- import { Gizmo, type GizmoSpace } from './gizmo.js';
8
- import { pickAt } from './picking.js';
9
- import { EditorSession, type FocusTarget } from './session.js';
10
- import { mountEditorUI } from './ui-react/mount.js';
11
- import type { EditorUIHandle } from './ui-react/types.js';
12
- // Type-only, and deliberately so: `import type` is erased before the browser ever sees this, so
13
- // the page never asks for a module out of `src/` — which the published package does not ship.
14
- // The one *value* the page needed from there, the websocket path, arrives as a build-time
15
- // constant instead. See `__SCENOCO_WS_PATH__` in app/globals.d.ts.
16
- import type {
17
- EditorCommand,
18
- EditorPush,
19
- EditorResponse,
20
- EditorResult,
21
- } from '../src/protocol.js';
22
-
23
- /**
24
- * The editor page.
25
- *
26
- * It holds one live scene — the project's own generated module, not a preview build of it — and
27
- * three things that act on it: a viewport that flies around and picks, a document that turns a
28
- * pick into the element that made it, and a bridge that lets an agent look at the same scene the
29
- * person is looking at.
30
- */
31
- const canvas = document.querySelector<HTMLCanvasElement>('#viewport')!;
32
- const session = new EditorSession(canvas);
33
- session.onChange = () => onSceneReplaced();
34
- const rig = new CameraRig(session.editorCamera, session.editorCamera.position.clone().setLength(0));
35
- const controls = new DesktopControls(rig);
36
- // Both, always: a laptop with a touchscreen is one device with two ways to drive the camera, and
37
- // they never conflict because each only calls rig verbs. `isTouchDevice` decides handle *size*,
38
- // which is a question about fingers rather than about which listeners to attach.
39
- //
40
- // Unlike the desktop controls it needs no per-frame update — touch is event-driven, so this
41
- // binding exists to own its listeners for as long as the page does.
42
- const touchControls = new TouchControls(rig, canvas, () => gizmo?.dragging ?? false);
43
-
44
- let model: ProjectModel | null = null;
45
- let doc: SceneDocument | null = null;
46
- let selected: Object3D | null = null;
47
- let gizmo: Gizmo | null = null;
48
- let ui: EditorUIHandle | null = null;
49
- /** A cut/copied subtree, as the XML that produced it. */
50
- let clipboard: string | null = null;
51
- /** Kept here rather than on the gizmo, which is rebuilt with every scene. */
52
- let gizmoSpace: GizmoSpace = 'world';
53
- let status = '';
54
-
55
- // --- loading ---------------------------------------------------------------------------------
56
-
57
- async function documents(): Promise<Array<{ kind: string; path: string; stem: string }>> {
58
- return (await fetch('/@scenoco/documents').then((r) => r.json())) as Array<{
59
- kind: string;
60
- path: string;
61
- stem: string;
62
- }>;
63
- }
64
-
65
- async function open(path: string): Promise<void> {
66
- // A refusal says why — the open dialog shows it, rather than the page half-loading a document
67
- // the server never sent.
68
- const response = await fetch(`/@scenoco/document?path=${encodeURIComponent(path)}`);
69
- const body = (await response.json()) as
70
- | { path: string; module: string; text: string; elements: RawElement[] }
71
- | { error: string };
72
- if ('error' in body) throw new Error(`${path}: ${body.error}`);
73
- const info = body;
74
- model ??= (await fetch('/@scenoco/model').then((r) => r.json())) as ProjectModel;
75
-
76
- // Cache-busted: the module was just rebuilt, and a browser that reuses the old one shows the
77
- // previous scene while the inspector shows the new document.
78
- const reopening = doc?.path === info.path;
79
- await session.open(`${info.module}?v=${Date.now()}`);
80
-
81
- // Framing is for arriving at a document, not for returning to one. A rebuild after a save —
82
- // or after someone else touched a file — should leave the camera exactly where the person
83
- // parked it; throwing them back to the establishing shot every time is what makes an
84
- // otherwise invisible reload feel like the editor lost its place. A page reload counts as
85
- // returning too: the dev server reloads the page whenever project code changes, and coming
86
- // back to a different view every time reads as the editor losing its place.
87
- if (reopening || restoreView(info.path)) {
88
- // keep the view
89
- } else {
90
- frame_(session.frameAll());
91
- }
92
- doc = new SceneDocument(info.path, info.text, info.elements, model);
93
- doc.onChange(() => ui?.refresh());
94
- history.replaceState(null, '', `?path=${encodeURIComponent(path)}`);
95
- ui?.refresh(); // a document is open now — the open dialog stands down, the toolbar wakes up
96
- }
97
-
98
- /**
99
- * The session replaced the live scene — on open, and on every Stop, which rebuilds it.
100
- *
101
- * Everything the editor holds that points *into* a scene has to be rebuilt with it. The gizmo's
102
- * helper is a child of the scene it was made for, so after a Stop it sits in the discarded one
103
- * and the handles simply stop appearing; the selection points at a node that no longer exists in
104
- * any scene. Neither is recoverable by clicking around — the editor looks broken until reload.
105
- */
106
- function onSceneReplaced(): void {
107
- select(null);
108
- attachGizmo();
109
- ui?.refresh();
110
- }
111
-
112
- function attachGizmo(): void {
113
- gizmo?.dispose();
114
- if (!session.scene) return;
115
- gizmo = new Gizmo(session.editorCamera, canvas, session.scene, isTouchDevice(), {
116
- onObjectChange: () => {
117
- // Live while dragging, written to the document once — one drag is one undo step and one
118
- // line in the diff.
119
- if (!selected || !doc) return;
120
- const channel = gizmo!.mode === 'rotate' ? 'eulerAngles' : gizmo!.mode === 'scale' ? 'scale' : 'position';
121
- doc.setTransform(selected, channel, { coalesce: true, from: gizmo!.grabbedValue(channel) });
122
- },
123
- });
124
-
125
- gizmo.setSpace(gizmoSpace);
126
-
127
- // Move the pick behind three's own pointerdown handler. `TransformControls` installs its
128
- // listener in the constructor above, so re-adding ours now puts it last, and `gizmo.dragging`
129
- // is already true by the time `pick` asks. Order is the whole mechanism: registered first, the
130
- // pick sees `dragging === false` on every press, and a grab of a handle reads as a click on
131
- // empty space.
132
- canvas.removeEventListener('pointerdown', pick);
133
- canvas.addEventListener('pointerdown', pick);
134
- }
135
-
136
- // --- selection -------------------------------------------------------------------------------
137
-
138
- /**
139
- * Pick whatever is under the pointer.
140
- *
141
- * A press that lands on a gizmo handle is not a pick at all — it is the start of a drag, and the
142
- * `dragging` test is what tells the two apart. That test only works because this listener runs
143
- * *after* the one `TransformControls` installs, which is why `attachGizmo` re-registers it (see
144
- * there); a press on a handle would otherwise deselect the very object being dragged and detach
145
- * the gizmo out from under three's own pointerdown handler.
146
- */
147
- function pick(event: PointerEvent): void {
148
- if (event.button !== 0 || !session.scene || gizmo?.dragging) return;
149
- select(pickAt(event, canvas, session.scene, session.renderCamera()));
150
- }
151
-
152
- canvas.addEventListener('pointerdown', pick);
153
-
154
- /**
155
- * Selection travels in two directions, and they must not be the same function.
156
- *
157
- * The chrome's store calls the host when *it* selects something; the host calls the store when a
158
- * viewport pick does. Wiring both to one entry point made each call the other for ever — a stack
159
- * overflow before the first frame.
160
- */
161
- function applySelection(node: Object3D | null): void {
162
- selected = node;
163
- gizmo?.attach(node && doc?.canEditTransform(node) ? node : null);
164
- }
165
-
166
- /**
167
- * Fly the rig to what the session just measured.
168
- *
169
- * Two steps because they have two owners: the session measures the subject, the rig owns the
170
- * camera. Writing the camera transform anywhere else is undone by the next `sync()`.
171
- */
172
- function frame_(target: FocusTarget | null): void {
173
- if (target) rig.frameOn(target.center, target.distance);
174
- }
175
-
176
- /**
177
- * Whether a file that changed on disk can change what the open document *is*.
178
- *
179
- * The server watches the whole project and says what moved; every change used to reload the
180
- * scene, so editing an unrelated level, or any tool touching any file, threw away the camera and
181
- * the selection for nothing. A sibling scene cannot affect this one — everything else can: a
182
- * `.ts` changes what a component does, and every other kind of document is something a scene
183
- * references by path.
184
- */
185
- function affectsOpenDocument(changed: string, open: string): boolean {
186
- if (changed === open) return true;
187
- return !changed.endsWith('.scene.xml');
188
- }
189
-
190
- // --- keeping the view across a reload ---------------------------------------------------------
191
-
192
- /**
193
- * Where the camera was, per document, so a reload puts it back.
194
- *
195
- * `sessionStorage` because it belongs to this tab and this sitting: another tab on another
196
- * document has its own, and none of it should outlive the browser session.
197
- */
198
- const VIEW_KEY = 'scenoco:view';
199
-
200
- /** Put the camera back where this document was last looked at. True when there was one. */
201
- function restoreView(path: string): boolean {
202
- try {
203
- const saved = JSON.parse(sessionStorage.getItem(VIEW_KEY) ?? 'null') as {
204
- path: string;
205
- position: [number, number, number];
206
- yaw: number;
207
- pitch: number;
208
- } | null;
209
- if (!saved || saved.path !== path) return false;
210
- session.editorCamera.position.set(...saved.position);
211
- rig.euler.y = saved.yaw;
212
- rig.euler.x = saved.pitch;
213
- rig.sync();
214
- return true;
215
- } catch {
216
- return false; // a malformed entry is not worth a broken boot
217
- }
218
- }
219
-
220
- // The frame loop stops when a tab is hidden, which is the moment before a reload is most likely
221
- // — a phone backgrounding the browser, then the dev server reloading the page on reconnect. These
222
- // two fire when rAF does not, so the last thing saved is the view as it actually was.
223
- window.addEventListener('visibilitychange', saveView);
224
- window.addEventListener('pagehide', saveView);
225
-
226
- /** Remember the current view. Called from the frame loop, at most once a second. */
227
- function saveView(): void {
228
- if (!doc) return;
229
- const { x, y, z } = session.editorCamera.position;
230
- try {
231
- sessionStorage.setItem(
232
- VIEW_KEY,
233
- JSON.stringify({ path: doc.path, position: [x, y, z], yaw: rig.euler.y, pitch: rig.euler.x }),
234
- );
235
- } catch {
236
- /* storage full or blocked — the view is a convenience, never a requirement */
237
- }
238
- }
239
-
240
- /** A pick in the viewport: move the gizmo, and tell the chrome. */
241
- function select(node: Object3D | null): void {
242
- applySelection(node);
243
- ui?.select(node);
244
- }
245
-
246
- // --- keyboard --------------------------------------------------------------------------------
247
-
248
- /**
249
- * The shortcuts the top bar already promises.
250
- *
251
- * Its buttons are labelled `Move (G)`, `Rotate (R)`, `Scale (Y)`, so the keys are part of the
252
- * interface whether or not anything listens for them — a label that lies is worse than no label.
253
- *
254
- * Nothing fires while a field has focus: the inspector is full of text inputs, and typing `y`
255
- * into an id should spell a `y`, not reach past the caret and switch the gizmo.
256
- */
257
- window.addEventListener('keydown', (event) => {
258
- const target = event.target as HTMLElement | null;
259
- if (target?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')) return;
260
-
261
- const accel = event.metaKey || event.ctrlKey;
262
- if (accel) {
263
- const key = event.key.toLowerCase();
264
- if (key === 's') doNow(event, () => void save());
265
- else if (key === 'z') doNow(event, () => (event.shiftKey ? doc?.redoLast() : doc?.undoLast()));
266
- return;
267
- }
268
- if (event.altKey) return;
269
-
270
- switch (event.key.toLowerCase()) {
271
- case 'g':
272
- return doNow(event, () => gizmo?.setMode('translate'));
273
- case 'r':
274
- return doNow(event, () => gizmo?.setMode('rotate'));
275
- case 'y':
276
- return doNow(event, () => gizmo?.setMode('scale'));
277
- // Frame the selection, or the whole scene when there is none — the same key does both in
278
- // every editor that has one.
279
- case 'f':
280
- return doNow(event, () => frame_(selected ? session.frameNode(selected) : session.frameAll()));
281
- default:
282
- }
283
- });
284
-
285
- /** Run a shortcut and repaint the chrome, so a mode button lights up the way a click would. */
286
- function doNow(event: KeyboardEvent, run: () => void): void {
287
- event.preventDefault();
288
- run();
289
- ui?.refresh();
290
- }
291
-
292
- // --- saving ----------------------------------------------------------------------------------
293
-
294
- async function save(): Promise<void> {
295
- if (!doc || !doc.isDirty) return;
296
- const response = await fetch('/@scenoco/save', {
297
- method: 'POST',
298
- headers: { 'Content-Type': 'application/json' },
299
- body: JSON.stringify({ path: doc.path, edits: doc.edits() }),
300
- });
301
- const body = (await response.json()) as { text?: string; elements?: RawElement[]; error?: string };
302
- if (!response.ok) {
303
- // The document is untouched on disk when a save is refused, so the edits stay pending and
304
- // the next attempt can succeed — nothing is lost by showing the reason and stopping.
305
- report(body.error ?? 'save failed');
306
- return;
307
- }
308
- doc.markSaved(body.text!, body.elements ?? []);
309
- report('saved');
310
- }
311
-
312
- // --- the bridge ------------------------------------------------------------------------------
313
-
314
- /**
315
- * Serve the observation commands.
316
- *
317
- * There is no edit command and there will not be one: an agent has file tools, and a second
318
- * write path into someone's source is a write their editor, diff and undo never saw.
319
- */
320
- function connect(): void {
321
- const socket = new WebSocket(`ws://${location.host}${__SCENOCO_WS_PATH__}`);
322
- socket.addEventListener('message', async (event) => {
323
- const message = JSON.parse(String(event.data)) as EditorCommand | EditorPush;
324
- if ('push' in message) {
325
- if (message.push === 'shutdown') window.close();
326
- else if (message.push === 'buildFailed') report(message.message);
327
- else if (doc && affectsOpenDocument(message.file, doc.path)) await open(doc.path);
328
- return;
329
- }
330
- const reply = (response: EditorResponse): void => socket.send(JSON.stringify(response));
331
- try {
332
- reply({ id: message.id, ok: true, result: await run(message) });
333
- } catch (error) {
334
- reply({ id: message.id, ok: false, error: error instanceof Error ? error.message : String(error) });
335
- }
336
- });
337
- socket.addEventListener('close', () => setTimeout(connect, 1000));
338
- }
339
-
340
- async function run(command: EditorCommand): Promise<EditorResult> {
341
- switch (command.type) {
342
- case 'open':
343
- await open(command.path);
344
- return { kind: 'ok', info: command.path };
345
- case 'run':
346
- session.run();
347
- return { kind: 'ok' };
348
- case 'pause':
349
- session.pause();
350
- return { kind: 'ok' };
351
- case 'stop':
352
- await session.stop();
353
- return { kind: 'ok' };
354
- case 'preview':
355
- return { kind: 'image', dataUrl: session.preview(command.frame, command.camera, command.lookAt) };
356
- case 'telemetry':
357
- return { kind: 'json', data: session.telemetry(command.targetId) };
358
- case 'simulate':
359
- return {
360
- kind: 'multi',
361
- results: (await session.simulate(command.steps, command.frame)).map((r) =>
362
- r.kind === 'image' ? { kind: 'image' as const, dataUrl: r.value as string } : { kind: 'json' as const, data: r.value },
363
- ),
364
- };
365
- }
366
- }
367
-
368
- // --- the loop --------------------------------------------------------------------------------
369
-
370
- let last = performance.now();
371
- let viewSavedAt = 0;
372
-
373
- /** One editor frame: fly the camera, tick the scene, draw. */
374
- function step(dt: number): void {
375
- if (session.mode === 'edit') {
376
- controls.update(dt);
377
- // The rig accumulates yaw and pitch and only writes them to the camera here — once, after
378
- // every input adapter has had its say, so two of them can never fight over the transform.
379
- // Nothing called it, so the camera moved but never turned.
380
- rig.sync(dt);
381
- }
382
- session.tick(dt);
383
- }
384
-
385
- function frame(now: number): void {
386
- const dt = Math.min((now - last) / 1000, 0.1);
387
- last = now;
388
- step(dt);
389
- if (now - viewSavedAt > 1000) {
390
- viewSavedAt = now;
391
- saveView();
392
- }
393
- requestAnimationFrame(frame);
394
- }
395
-
396
- /** A short-lived message for the top bar — a save, or why one was refused. */
397
- function report(message: string): void {
398
- status = message;
399
- ui?.refresh();
400
- setTimeout(() => {
401
- if (status === message) {
402
- status = '';
403
- ui?.refresh();
404
- }
405
- }, 4000);
406
- }
407
-
408
- async function boot(): Promise<void> {
409
- ui = mountEditorUI({
410
- getEngine: () => session.engine,
411
- getDoc: () => doc ?? undefined,
412
- getModel: () => model ?? undefined,
413
- getScene: () => session.scene,
414
- onSelect: applySelection,
415
- focusSelected: (node) => frame_(session.frameNode(node)),
416
-
417
- handlers: {
418
- // Every one of these repaints the chrome afterwards, because the toolbar reads session
419
- // state at render time and only renders when asked to. Document edits get their repaint
420
- // from `doc.onChange`; nothing was raising one for the transport or the gizmo mode, so the
421
- // buttons kept whatever state they were last drawn with — press Play and Pause and Stop
422
- // stayed disabled, which made play mode a door with no handle on the inside.
423
- play: () => {
424
- session.run();
425
- ui?.refresh();
426
- },
427
- pause: () => {
428
- session.pause();
429
- ui?.refresh();
430
- },
431
- step: () => {
432
- session.step();
433
- ui?.refresh();
434
- },
435
- stop: () =>
436
- void session.stop().then(() => {
437
- ui?.refresh();
438
- }),
439
- getMode: () => gizmo?.mode ?? 'translate',
440
- setMode: (mode) => {
441
- gizmo?.setMode(mode);
442
- ui?.refresh();
443
- },
444
- // The space outlives any one gizmo: `attachGizmo` builds a new one for every scene, and a
445
- // person who chose Local does not expect Stop to put it back to World.
446
- getSpace: () => gizmoSpace,
447
- setSpace: (space) => {
448
- gizmoSpace = space;
449
- gizmo?.setSpace(space);
450
- ui?.refresh();
451
- },
452
- getShowUi: () => session.uiInEditor,
453
- setShowUi: (on) => {
454
- session.uiInEditor = on;
455
- ui?.refresh();
456
- },
457
- undo: () => doc?.undoLast(),
458
- redo: () => doc?.redoLast(),
459
- save: () => void save(),
460
- state: () => ({
461
- mode: session.mode,
462
- playing: session.playing,
463
- canUndo: doc?.canUndo ?? false,
464
- canRedo: doc?.canRedo ?? false,
465
- dirty: doc?.isDirty ?? false,
466
- saving: false,
467
- enabled: selected !== null && (doc?.canEditTransform(selected) ?? false),
468
- status,
469
- }),
470
- },
471
-
472
- // --- structural edits, all of them splices into the XML ---------------------------------
473
- reparent: (node, newParent, index) => doc?.moveNode(node, newParent, index),
474
- remove: (node) => doc?.removeNode(node),
475
- setId: (node, id) => doc?.setNodeAttr(node, 'id', id === '' ? null : id),
476
-
477
- /**
478
- * Copy takes the element's **source text**, so a paste reproduces what was authored —
479
- * comments, formatting and all — rather than a re-serialization of the live objects.
480
- */
481
- copy: (node) => {
482
- clipboard = doc?.sourceOf(node) ?? null;
483
- report(clipboard ? 'copied' : 'nothing to copy');
484
- },
485
- cut: (node) => {
486
- clipboard = doc?.sourceOf(node) ?? null;
487
- if (clipboard) doc?.removeNode(node);
488
- },
489
- paste: (parent) => {
490
- const target = parent ?? session.scene;
491
- if (clipboard && target) doc?.insertNode(target, clipboard);
492
- },
493
- hasClipboard: () => clipboard !== null,
494
- listDocuments: documents,
495
- openDocument: open,
496
- });
497
-
498
- // A named document opens straight away; without one the chrome shows its open dialog, which
499
- // is what it does whenever no document is open.
500
- const requested = new URLSearchParams(location.search).get('path');
501
- // A bad `?path` leaves the dialog up with the reason, which beats a blank page and a rejected
502
- // promise nobody sees.
503
- if (requested) await open(requested).catch((error: unknown) => report(String(error)));
504
- connect();
505
- requestAnimationFrame(frame);
506
- }
507
-
508
- /**
509
- * A handle on the live editor, for the console.
510
- *
511
- * An editor that cannot be poked at from devtools is one you debug by adding logging and
512
- * restarting. It costs one assignment.
513
- */
514
- Object.assign(window as unknown as Record<string, unknown>, {
515
- scenoco: {
516
- get doc() {
517
- return doc;
518
- },
519
- get session() {
520
- return session;
521
- },
522
- get selected() {
523
- return selected;
524
- },
525
- get gizmo() {
526
- return gizmo;
527
- },
528
- controls,
529
- touchControls,
530
- rig,
531
- save,
532
- select,
533
- /**
534
- * Run frames by hand.
535
- *
536
- * `requestAnimationFrame` stops in a background tab, so this is the only way to exercise the
537
- * scene view — camera fly, gizmo drag — when nobody is looking at it. It runs exactly what
538
- * the loop runs; there is no second path.
539
- */
540
- step: (frames = 1, dt = 1 / 60) => {
541
- for (let i = 0; i < frames; i++) step(dt);
542
- },
543
- },
544
- });
545
-
546
- void boot();
package/app/meta.ts DELETED
@@ -1,24 +0,0 @@
1
- import type { Object3D } from 'three';
2
-
3
- /**
4
- * The few things the chrome needs to say about a live object.
5
- *
6
- * v1 read these off `Object3D` itself — the runtime carried `sceneTag` and `sceneId` because the
7
- * bundle put them there. Nothing does now: the *document* knows which element built a node, so
8
- * the editor asks it, and a node with no element is one nothing authored.
9
- */
10
-
11
- /**
12
- * Gizmo handles and helpers, which belong to the editor rather than to the scene.
13
- *
14
- * Asks about this object alone — the chrome lists roots. Picking asks the same question of every
15
- * ancestor, because a ray lands deep inside a helper; that one is in `picking.ts`.
16
- */
17
- export function isEditorOnly(object: Object3D): boolean {
18
- return object.userData.scenocoEditorOnly === true;
19
- }
20
-
21
- export const round = (n: number): number => Math.round(n * 1000) / 1000;
22
-
23
- /** Radians on a live `Euler` → the degrees a document is written in. */
24
- export const toDeg = (radians: number): number => round((radians * 180) / Math.PI);
package/app/picking.ts DELETED
@@ -1,47 +0,0 @@
1
- import { Raycaster, Vector2, type Camera, type Object3D, type Scene } from 'three';
2
- import { findProvenance } from '@scenoco-three/core';
3
-
4
- const raycaster = new Raycaster();
5
- const pointer = new Vector2();
6
-
7
- /**
8
- * What a click at these coordinates selects: the nearest node a **document** authored.
9
- *
10
- * A ray hits some deeply nested mesh inside a model nobody wrote by hand, so the hit itself is
11
- * rarely the answer — the thing worth selecting is the element that put it there, which is what
12
- * the compiler's provenance stamp says.
13
- *
14
- * Returns null for a click on nothing, and for a click on something the editor drew for itself.
15
- */
16
- export function pickAt(
17
- event: { clientX: number; clientY: number },
18
- canvas: HTMLCanvasElement,
19
- scene: Scene,
20
- camera: Camera,
21
- ): Object3D | null {
22
- const rect = canvas.getBoundingClientRect();
23
- pointer.set(
24
- ((event.clientX - rect.left) / rect.width) * 2 - 1,
25
- -((event.clientY - rect.top) / rect.height) * 2 + 1,
26
- );
27
- raycaster.setFromCamera(pointer, camera);
28
- const hit = raycaster.intersectObjects(scene.children, true).find((i) => !belongsToEditor(i.object));
29
- return hit ? (findProvenance(hit.object)?.object ?? null) : null;
30
- }
31
-
32
- /**
33
- * Is this object, **or anything above it**, something the editor drew for itself?
34
- *
35
- * Not the same question as `meta.ts`'s `isEditorOnly`, which asks only about the object itself —
36
- * that one answers for a node the chrome is about to list, where the object *is* the helper's
37
- * root. Here a ray has hit one of the small meshes deep inside a helper, so the flag is on an
38
- * ancestor. Testing only the hit object let a click on a gizmo handle select the gizmo — which
39
- * attached the transform control to its own helper and put `updateMatrixWorld` into infinite
40
- * recursion, killing the page.
41
- */
42
- function belongsToEditor(object: Object3D): boolean {
43
- for (let o: Object3D | null = object; o; o = o.parent) {
44
- if (o.userData.scenocoEditorOnly) return true;
45
- }
46
- return false;
47
- }