@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.
- package/LICENSE +21 -0
- package/README.md +23 -17
- package/app/cameraRig.ts +73 -12
- package/app/commands.ts +5 -1
- package/app/desktopControls.ts +13 -10
- package/app/document.ts +474 -427
- package/app/editLog.ts +127 -0
- package/app/editor.css +51 -0
- package/app/gizmo.ts +100 -6
- package/app/index.html +6 -122
- package/app/main.ts +488 -304
- package/app/meta.ts +24 -0
- package/app/picking.ts +47 -0
- package/app/session.ts +476 -311
- package/app/touchControls.ts +85 -62
- package/app/ui-react/AddMemberPopup.tsx +114 -0
- package/app/ui-react/BottomLauncher.tsx +15 -0
- package/app/ui-react/EditorUI.tsx +22 -0
- package/app/ui-react/FilePicker.tsx +80 -0
- package/app/ui-react/HeadBtn.tsx +21 -0
- package/app/ui-react/InspectorSheet.tsx +475 -0
- package/app/ui-react/NodeListSheet.tsx +56 -0
- package/app/ui-react/TopBar.tsx +137 -0
- package/app/ui-react/Tree.tsx +106 -0
- package/app/ui-react/cx.ts +4 -0
- package/app/ui-react/fields.tsx +407 -0
- package/app/ui-react/format.ts +30 -0
- package/app/ui-react/mount.tsx +31 -0
- package/app/ui-react/store.ts +80 -0
- package/app/ui-react/types.ts +88 -0
- package/app/ui-react/ui.module.css +250 -0
- package/app/ui-react/useSize.ts +20 -0
- package/app/values.ts +137 -0
- package/dist/cli/index.js +93 -70
- package/dist/index.d.ts +10 -3
- package/dist/index.js +9 -5
- package/dist/mcp.d.ts +28 -12
- package/dist/mcp.js +172 -131
- package/dist/protocol.d.ts +74 -65
- package/dist/protocol.js +17 -4
- package/dist/server.d.ts +106 -39
- package/dist/server.js +386 -361
- package/package.json +25 -10
- package/app/filePicker.ts +0 -64
- package/app/panels.ts +0 -553
- package/app/toolbar.ts +0 -131
- package/app/wsClient.ts +0 -92
- package/dist/args.d.ts +0 -8
- package/dist/args.js +0 -28
package/app/main.ts
CHANGED
|
@@ -1,359 +1,543 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
20
|
-
let
|
|
21
|
-
let
|
|
22
|
-
let
|
|
23
|
-
let
|
|
24
|
-
|
|
25
|
-
let
|
|
26
|
-
|
|
27
|
-
let
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
149
|
+
canvas.addEventListener('pointerdown', pick);
|
|
196
150
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
//
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
385
|
+
step(dt);
|
|
386
|
+
if (now - viewSavedAt > 1000) {
|
|
387
|
+
viewSavedAt = now;
|
|
388
|
+
saveView();
|
|
389
|
+
}
|
|
310
390
|
requestAnimationFrame(frame);
|
|
311
391
|
}
|
|
312
392
|
|
|
313
|
-
/**
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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();
|