@scenoco-three/editor 0.2.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/app/main.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { Box3, BoxHelper, Raycaster, Vector2, Vector3, type Object3D } from 'three';
2
+ // Register every project component, package tag, and built-in node up front, so
3
+ // any scene/prefab the agent opens has its tags available.
4
+ import 'virtual:scenoco-components';
5
+ import { EditorSession } from './session.js';
6
+ import { connectEditor } from './wsClient.js';
7
+ import { CameraRig } from './cameraRig.js';
8
+ import { DesktopControls } from './desktopControls.js';
9
+ import { TouchControls, isTouchDevice } from './touchControls.js';
10
+ import { buildPanels, type Panels } from './panels.js';
11
+ import { SceneDocument } from './document.js';
12
+ import { Gizmo, type GizmoMode } from './gizmo.js';
13
+ import { buildToolbar, type Toolbar } from './toolbar.js';
14
+
15
+ const canvas = document.getElementById('app') as HTMLCanvasElement;
16
+ // Right mouse drag is the fly-camera look control — suppress the native
17
+ // context menu it would otherwise pop on a real right-click.
18
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
19
+ const session = new EditorSession(canvas);
20
+
21
+ let rig: CameraRig | undefined;
22
+ let desktop: DesktopControls | undefined;
23
+ let panels: Panels | undefined;
24
+ let selection: BoxHelper | undefined;
25
+ let doc: SceneDocument | undefined;
26
+ let gizmo: Gizmo | undefined;
27
+ let gizmoMode: GizmoMode = 'translate';
28
+ let toolbar: Toolbar | undefined;
29
+ let saving = false;
30
+
31
+ // Rebuild the edit-mode camera, document, gizmo + refresh panels whenever the
32
+ // engine swaps (open / run / stop). Play mode runs the scene's own camera with
33
+ // no editor rig, document, or gizmo.
34
+ function rebind(): void {
35
+ selection = undefined; // the old engine (and its scene helpers) is disposed
36
+ rig = undefined;
37
+ desktop = undefined;
38
+ gizmo?.dispose();
39
+ gizmo = undefined;
40
+ doc = undefined;
41
+
42
+ const graph = session.engine.getSceneGraph();
43
+ if (session.mode === 'edit' && session.editorCamera) {
44
+ rig = new CameraRig(session.editorCamera, sceneCenter());
45
+ desktop = new DesktopControls(rig, session.engine.input);
46
+ if (isTouchDevice()) new TouchControls(rig, canvas, () => gizmo?.dragging ?? false);
47
+ if (graph) {
48
+ doc = new SceneDocument(graph, session.provenance);
49
+ doc.onChange(() => toolbar?.refresh()); // keep the dirty/undo state live
50
+ gizmo = new Gizmo(session.editorCamera, canvas, session.engine.scene, isTouchDevice(), {
51
+ onObjectChange: () => panels?.refresh(),
52
+ onCommit: commitTransform,
53
+ });
54
+ gizmo.setMode(gizmoMode);
55
+ }
56
+ }
57
+ if (!panels) panels = buildPanels(() => session.engine, () => doc, onSelect);
58
+ else panels.refresh();
59
+ if (!toolbar) toolbar = buildToolbar(toolbarHandlers);
60
+ else toolbar.refresh();
61
+ }
62
+ session.onChange = rebind;
63
+
64
+ const toolbarHandlers = {
65
+ play: () => {
66
+ session.run();
67
+ toolbar?.refresh();
68
+ },
69
+ pause: () => {
70
+ session.pause();
71
+ toolbar?.refresh();
72
+ },
73
+ step: () => {
74
+ session.step();
75
+ toolbar?.refresh();
76
+ },
77
+ stop: () => {
78
+ session.stop();
79
+ toolbar?.refresh();
80
+ },
81
+ getMode: () => gizmoMode,
82
+ setMode: setGizmoMode,
83
+ undo: () => {
84
+ doc?.undoLast();
85
+ panels?.refresh();
86
+ toolbar?.refresh();
87
+ },
88
+ redo: () => {
89
+ doc?.redoLast();
90
+ panels?.refresh();
91
+ toolbar?.refresh();
92
+ },
93
+ save: () => void doSave(),
94
+ state: () => ({
95
+ mode: session.mode,
96
+ playing: session.playing,
97
+ canUndo: doc?.canUndo ?? false,
98
+ canRedo: doc?.canRedo ?? false,
99
+ dirty: doc?.isDirty ?? false,
100
+ saving,
101
+ enabled: !!doc,
102
+ }),
103
+ };
104
+
105
+ function setGizmoMode(mode: GizmoMode): void {
106
+ gizmoMode = mode;
107
+ gizmo?.setMode(mode);
108
+ toolbar?.refresh();
109
+ }
110
+
111
+ async function doSave(): Promise<void> {
112
+ if (!doc || saving || !doc.isDirty) return;
113
+ saving = true;
114
+ toolbar?.refresh();
115
+ try {
116
+ await session.save(doc.netEdits());
117
+ doc.markSaved();
118
+ } catch (e) {
119
+ console.error('[editor] save failed:', e);
120
+ } finally {
121
+ saving = false;
122
+ toolbar?.refresh();
123
+ }
124
+ }
125
+
126
+ function onSelect(node: Object3D | null): void {
127
+ if (selection) {
128
+ session.engine.scene.remove(selection);
129
+ selection.geometry.dispose();
130
+ selection = undefined;
131
+ }
132
+ if (node) {
133
+ selection = new BoxHelper(node, 0x4af0e0);
134
+ selection.userData.scenocoEditorOnly = true; // keep out of the Hierarchy + picking
135
+ session.engine.scene.add(selection);
136
+ rig?.focusOn(node.getWorldPosition(new Vector3()));
137
+ }
138
+ gizmo?.attach(node);
139
+ }
140
+
141
+ /** Commit a gizmo drag as one undoable edit (only the channels that changed record). */
142
+ function commitTransform(object: Object3D): void {
143
+ doc?.setTransform(object, 'position', object.position.toArray());
144
+ doc?.setTransform(object, 'rotation', [object.rotation.x, object.rotation.y, object.rotation.z]);
145
+ doc?.setTransform(object, 'scale', object.scale.toArray());
146
+ }
147
+
148
+ // ── viewport pick (tap/click an object to select it) ─────────────────────────────
149
+
150
+ const raycaster = new Raycaster();
151
+ let downX = 0;
152
+ let downY = 0;
153
+ let dragged = false;
154
+ canvas.addEventListener('pointerdown', (e) => {
155
+ downX = e.clientX;
156
+ downY = e.clientY;
157
+ dragged = false;
158
+ });
159
+ canvas.addEventListener('pointermove', (e) => {
160
+ if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) dragged = true;
161
+ });
162
+ canvas.addEventListener('pointerup', (e) => {
163
+ if (dragged || gizmo?.dragging || session.mode !== 'edit') return; // a camera/gizmo gesture, not a pick
164
+ panels?.select(pickAt(e.clientX, e.clientY));
165
+ });
166
+
167
+ function pickAt(clientX: number, clientY: number): Object3D | null {
168
+ const cam = session.editorCamera;
169
+ if (!cam) return null;
170
+ const rect = canvas.getBoundingClientRect();
171
+ const ndc = new Vector2(((clientX - rect.left) / rect.width) * 2 - 1, -((clientY - rect.top) / rect.height) * 2 + 1);
172
+ raycaster.setFromCamera(ndc, cam);
173
+ for (const hit of raycaster.intersectObjects(session.engine.scene.children, true)) {
174
+ if (isEditorOnly(hit.object)) continue; // ignore gizmo handles / box helper / preview lights
175
+ return selectable(hit.object);
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /** Walk up to the nearest id'd ancestor (the editable node); else the hit object. */
181
+ function selectable(object: Object3D): Object3D {
182
+ for (let o: Object3D | null = object; o && o !== session.engine.scene; o = o.parent) {
183
+ if (o.name) return o;
184
+ }
185
+ return object;
186
+ }
187
+
188
+ /** True if `object` (or any ancestor) is editor-internal — excluded from the tree + picking. */
189
+ export function isEditorOnly(object: Object3D): boolean {
190
+ for (let o: Object3D | null = object; o; o = o.parent) if (o.userData.scenocoEditorOnly) return true;
191
+ return false;
192
+ }
193
+
194
+ // ── gizmo mode keys (W/A/S/D/Q/E drive the fly camera; use G/R/Y) ──────────────────
195
+
196
+ window.addEventListener('keydown', (e) => {
197
+ if (isTextField(e.target)) return;
198
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') {
199
+ e.preventDefault();
200
+ if (e.shiftKey) toolbarHandlers.redo();
201
+ else toolbarHandlers.undo();
202
+ return;
203
+ }
204
+ if (!gizmo) return;
205
+ const mode = ({ g: 'translate', r: 'rotate', y: 'scale' } as const)[e.key.toLowerCase()];
206
+ if (mode) setGizmoMode(mode);
207
+ });
208
+ function isTextField(t: EventTarget | null): boolean {
209
+ const el = t as HTMLElement | null;
210
+ return !!el && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA');
211
+ }
212
+
213
+ // ── external file changes (watcher) ──────────────────────────────────────────────
214
+
215
+ let reloading = false;
216
+ let conflictBanner: HTMLElement | undefined;
217
+
218
+ session.onExternalChange = (_file, role) => void handleExternalChange(role);
219
+
220
+ async function handleExternalChange(role: 'open' | 'dep'): Promise<void> {
221
+ if (reloading) return;
222
+ if (role === 'dep') {
223
+ await reloadScene(true); // a prefab/material changed — refresh now, preserving unsaved edits
224
+ } else if (!(doc?.isDirty ?? false)) {
225
+ await reloadScene(false); // the open file changed externally with nothing unsaved — just sync
226
+ } else {
227
+ showConflict(); // unsaved edits vs. an external change — let the user decide
228
+ }
229
+ }
230
+
231
+ async function reloadScene(preserveEdits: boolean): Promise<void> {
232
+ const path = session.filePath;
233
+ if (!path) return;
234
+ reloading = true;
235
+ const pending = preserveEdits ? (doc?.netEdits() ?? []) : [];
236
+ try {
237
+ await session.open(path); // re-fetch + swap → rebind() builds a fresh document
238
+ if (pending.length) doc?.applyEdits(pending);
239
+ } catch (e) {
240
+ console.error('[editor] reload failed:', e);
241
+ } finally {
242
+ reloading = false;
243
+ dismissConflict();
244
+ panels?.refresh();
245
+ toolbar?.refresh();
246
+ }
247
+ }
248
+
249
+ function showConflict(): void {
250
+ if (conflictBanner) return;
251
+ const bar = document.createElement('div');
252
+ bar.id = 'sc-conflict';
253
+ bar.append(textSpan('Conflicting changes were made in the original file.'));
254
+ bar.append(bannerButton('Reload', () => void reloadScene(false)), bannerButton('Keep editing', dismissConflict));
255
+ document.body.append(bar);
256
+ conflictBanner = bar;
257
+ }
258
+ function dismissConflict(): void {
259
+ conflictBanner?.remove();
260
+ conflictBanner = undefined;
261
+ }
262
+ function textSpan(text: string): HTMLElement {
263
+ const s = document.createElement('span');
264
+ s.textContent = text;
265
+ return s;
266
+ }
267
+ function bannerButton(label: string, onClick: () => void): HTMLButtonElement {
268
+ const b = document.createElement('button');
269
+ b.textContent = label;
270
+ b.onclick = onClick;
271
+ return b;
272
+ }
273
+
274
+ let last = performance.now();
275
+ function frame(now: number): void {
276
+ const dt = Math.min((now - last) / 1000, 0.1);
277
+ last = now;
278
+ session.tick(dt);
279
+ if (!gizmo?.dragging) desktop?.update(dt); // let the gizmo own the pointer while dragging
280
+ rig?.sync();
281
+ selection?.update();
282
+ requestAnimationFrame(frame);
283
+ }
284
+
285
+ void (async () => {
286
+ rebind(); // builds the (empty) panels
287
+ connectEditor(session);
288
+ const initial = new URLSearchParams(location.search).get('file');
289
+ if (initial) {
290
+ try {
291
+ await session.open(initial);
292
+ } catch (e) {
293
+ console.error('[editor] open failed:', e);
294
+ }
295
+ }
296
+ requestAnimationFrame(frame);
297
+ })();
298
+
299
+ function sceneCenter(): Vector3 {
300
+ const box = new Box3().setFromObject(session.engine.scene);
301
+ return box.isEmpty() ? new Vector3() : box.getCenter(new Vector3());
302
+ }