@scenoco-three/editor 0.4.0 → 0.5.4

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 (50) 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/globals.d.ts +9 -0
  11. package/app/index.html +6 -122
  12. package/app/main.ts +491 -304
  13. package/app/meta.ts +24 -0
  14. package/app/picking.ts +47 -0
  15. package/app/session.ts +513 -308
  16. package/app/touchControls.ts +85 -62
  17. package/app/ui-react/AddMemberPopup.tsx +114 -0
  18. package/app/ui-react/BottomLauncher.tsx +15 -0
  19. package/app/ui-react/EditorUI.tsx +22 -0
  20. package/app/ui-react/FilePicker.tsx +80 -0
  21. package/app/ui-react/HeadBtn.tsx +21 -0
  22. package/app/ui-react/InspectorSheet.tsx +475 -0
  23. package/app/ui-react/NodeListSheet.tsx +56 -0
  24. package/app/ui-react/TopBar.tsx +137 -0
  25. package/app/ui-react/Tree.tsx +106 -0
  26. package/app/ui-react/cx.ts +4 -0
  27. package/app/ui-react/fields.tsx +407 -0
  28. package/app/ui-react/format.ts +30 -0
  29. package/app/ui-react/mount.tsx +31 -0
  30. package/app/ui-react/store.ts +80 -0
  31. package/app/ui-react/types.ts +88 -0
  32. package/app/ui-react/ui.module.css +250 -0
  33. package/app/ui-react/useSize.ts +20 -0
  34. package/app/values.ts +137 -0
  35. package/dist/cli/index.js +93 -70
  36. package/dist/index.d.ts +10 -3
  37. package/dist/index.js +9 -5
  38. package/dist/mcp.d.ts +28 -12
  39. package/dist/mcp.js +172 -131
  40. package/dist/protocol.d.ts +74 -65
  41. package/dist/protocol.js +17 -4
  42. package/dist/server.d.ts +106 -39
  43. package/dist/server.js +389 -361
  44. package/package.json +24 -9
  45. package/app/filePicker.ts +0 -64
  46. package/app/panels.ts +0 -553
  47. package/app/toolbar.ts +0 -131
  48. package/app/wsClient.ts +0 -92
  49. package/dist/args.d.ts +0 -8
  50. package/dist/args.js +0 -28
package/app/main.ts CHANGED
@@ -1,359 +1,546 @@
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
+ // 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')!;
17
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);
18
43
 
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;
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 = '';
64
54
 
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
- }
55
+ // --- loading ---------------------------------------------------------------------------------
111
56
 
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
- }
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
+ }>;
125
63
  }
126
64
 
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()));
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());
138
91
  }
139
- gizmo?.attach(node);
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
140
96
  }
141
97
 
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());
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();
147
110
  }
148
111
 
149
- // ── viewport pick (tap/click an object to select it) ─────────────────────────────
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
+ });
150
124
 
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
- });
125
+ gizmo.setSpace(gizmoSpace);
167
126
 
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;
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);
179
134
  }
180
135
 
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
- }
136
+ // --- selection -------------------------------------------------------------------------------
188
137
 
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;
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()));
193
150
  }
194
151
 
195
- // ── gizmo mode keys (W/A/S/D/Q/E drive the fly camera; use G/R/Y) ──────────────────
152
+ canvas.addEventListener('pointerdown', pick);
196
153
 
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');
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);
212
164
  }
213
165
 
214
- // ── external file changes (watcher) ──────────────────────────────────────────────
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
+ }
215
175
 
216
- let reloading = false;
217
- let conflictBanner: HTMLElement | undefined;
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
+ }
218
189
 
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);
190
+ // --- keeping the view across a reload ---------------------------------------------------------
223
191
 
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
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
232
217
  }
233
218
  }
234
219
 
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() ?? []) : [];
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;
240
230
  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();
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 */
252
237
  }
253
238
  }
254
239
 
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;
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);
267
244
  }
268
245
 
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;
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()));
277
266
  return;
278
267
  }
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;
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');
282
306
  return;
283
307
  }
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;
308
+ doc.markSaved(body.text!, body.elements ?? []);
309
+ report('saved');
289
310
  }
290
- function textSpan(text: string): HTMLElement {
291
- const s = document.createElement('span');
292
- s.textContent = text;
293
- return s;
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));
294
338
  }
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;
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
+ }
300
366
  }
301
367
 
368
+ // --- the loop --------------------------------------------------------------------------------
369
+
302
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
+
303
385
  function frame(now: number): void {
304
386
  const dt = Math.min((now - last) / 1000, 0.1);
305
387
  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();
388
+ step(dt);
389
+ if (now - viewSavedAt > 1000) {
390
+ viewSavedAt = now;
391
+ saveView();
392
+ }
310
393
  requestAnimationFrame(frame);
311
394
  }
312
395
 
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
- }
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);
325
406
  }
326
407
 
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
- })();
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),
355
476
 
356
- function sceneCenter(): Vector3 {
357
- const box = new Box3().setFromObject(session.engine.scene);
358
- return box.isEmpty() ? new Vector3() : box.getCenter(new Vector3());
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);
359
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();