@scenoco-three/editor 0.4.0 → 0.5.0

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