@scenoco-three/editor 0.5.4 → 0.7.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 (47) hide show
  1. package/README.md +31 -106
  2. package/bin/serve.js +37 -0
  3. package/dist/editor.js +12521 -0
  4. package/package.json +29 -41
  5. package/app/cameraRig.ts +0 -118
  6. package/app/commands.ts +0 -78
  7. package/app/desktopControls.ts +0 -47
  8. package/app/document.ts +0 -547
  9. package/app/editLog.ts +0 -127
  10. package/app/editor.css +0 -51
  11. package/app/gizmo.ts +0 -169
  12. package/app/globals.d.ts +0 -9
  13. package/app/index.html +0 -14
  14. package/app/main.ts +0 -546
  15. package/app/meta.ts +0 -24
  16. package/app/picking.ts +0 -47
  17. package/app/session.ts +0 -612
  18. package/app/touchControls.ts +0 -127
  19. package/app/ui-react/AddMemberPopup.tsx +0 -114
  20. package/app/ui-react/BottomLauncher.tsx +0 -15
  21. package/app/ui-react/EditorUI.tsx +0 -22
  22. package/app/ui-react/FilePicker.tsx +0 -80
  23. package/app/ui-react/HeadBtn.tsx +0 -21
  24. package/app/ui-react/InspectorSheet.tsx +0 -475
  25. package/app/ui-react/NodeListSheet.tsx +0 -56
  26. package/app/ui-react/TopBar.tsx +0 -137
  27. package/app/ui-react/Tree.tsx +0 -106
  28. package/app/ui-react/cx.ts +0 -4
  29. package/app/ui-react/fields.tsx +0 -407
  30. package/app/ui-react/format.ts +0 -30
  31. package/app/ui-react/mount.tsx +0 -31
  32. package/app/ui-react/store.ts +0 -80
  33. package/app/ui-react/types.ts +0 -88
  34. package/app/ui-react/ui.module.css +0 -250
  35. package/app/ui-react/useSize.ts +0 -20
  36. package/app/values.ts +0 -137
  37. package/app/vite-env.d.ts +0 -7
  38. package/dist/cli/index.d.ts +0 -2
  39. package/dist/cli/index.js +0 -122
  40. package/dist/index.d.ts +0 -10
  41. package/dist/index.js +0 -10
  42. package/dist/mcp.d.ts +0 -32
  43. package/dist/mcp.js +0 -191
  44. package/dist/protocol.d.ts +0 -170
  45. package/dist/protocol.js +0 -22
  46. package/dist/server.d.ts +0 -124
  47. package/dist/server.js +0 -440
@@ -1,88 +0,0 @@
1
- import type { Object3D, Scene } from 'three';
2
- import type { Engine } from '@scenoco-three/core';
3
- import type { ProjectModel } from '@scenoco-three/compiler';
4
- import type { SceneDocument } from '../document.js';
5
- import type { GizmoMode, GizmoSpace } from '../gizmo.js';
6
-
7
- /**
8
- * The transport/gizmo/edit actions the top bar drives, plus a `state()` snapshot
9
- * it polls to style itself. Structurally identical to what `main.ts` already
10
- * builds — moved here from the old `toolbar.ts` so the React chrome owns the type.
11
- */
12
- export interface ToolbarHandlers {
13
- // transport
14
- play(): void;
15
- pause(): void;
16
- step(): void;
17
- stop(): void;
18
- // gizmo + edit
19
- getMode(): GizmoMode;
20
- setMode(mode: GizmoMode): void;
21
- /** Handles along the world's axes, or the object's own — Unity's Global/Local. */
22
- getSpace(): GizmoSpace;
23
- setSpace(space: GizmoSpace): void;
24
- /** Whether the editor camera is showing the scene's screen-space UI. */
25
- getShowUi(): boolean;
26
- setShowUi(on: boolean): void;
27
- undo(): void;
28
- redo(): void;
29
- save(): void;
30
- /** Current state, re-read on each refresh. */
31
- state(): {
32
- mode: 'edit' | 'play';
33
- playing: boolean;
34
- canUndo: boolean;
35
- canRedo: boolean;
36
- dirty: boolean;
37
- saving: boolean;
38
- /** True when something the document owns is selected — the gizmo has a target. */
39
- enabled: boolean;
40
- /** A short-lived message — a save, or why one was refused. */
41
- status?: string;
42
- };
43
- }
44
-
45
- /**
46
- * Everything the React chrome needs from the host page (`main.ts`): live getters
47
- * for the engine/document, the toolbar handlers, a selection callback (viewport
48
- * highlight + framing), and a "focus the current selection" action.
49
- */
50
- export interface EditorDeps {
51
- getEngine: () => Engine;
52
- getDoc: () => SceneDocument | undefined;
53
- /** What the project's TypeScript declares — the inspector's and the picker's schema. */
54
- getModel: () => ProjectModel | undefined;
55
- /** The live scene, which the editor owns rather than `Engine` (it drives its own loop). */
56
- getScene: () => Scene | null;
57
- handlers: ToolbarHandlers;
58
- /** Called whenever selection changes (from the tree or the viewport) — host highlights/frames it. */
59
- onSelect: (node: Object3D | null) => void;
60
- /** Reframe the camera on a node (the node-list "focus" action). */
61
- focusSelected: (node: Object3D) => void;
62
- /** Reparent a node under a new parent (tree drag) — records an undoable move. */
63
- reparent: (node: Object3D, newParent: Object3D, index?: number) => void;
64
- /** Copy a node's subtree to the clipboard. */
65
- copy: (node: Object3D) => void;
66
- /** Cut (copy + delete) a node's subtree. */
67
- cut: (node: Object3D) => void;
68
- /** Paste the clipboard subtree under `parent` (the scene root when null). */
69
- paste: (parent: Object3D | null) => void;
70
- /** Delete a node's subtree (undoable). */
71
- remove: (node: Object3D) => void;
72
- /** Rename (or set) a node's scene id. */
73
- setId: (node: Object3D, id: string) => void;
74
- /** Whether the clipboard holds a subtree (for Paste's enabled state). */
75
- hasClipboard: () => boolean;
76
- /** Every document in the project — what the open dialog lists. */
77
- listDocuments: () => Promise<Array<{ kind: string; path: string; stem: string }>>;
78
- /** Open one, by project-relative path. Rejects with why, which the dialog shows. */
79
- openDocument: (path: string) => Promise<void>;
80
- }
81
-
82
- /** The tiny imperative surface `main.ts` keeps a handle to (mirrors the old panels/toolbar). */
83
- export interface EditorUIHandle {
84
- /** Re-read the live engine/document into the tree, inspector, and top bar. */
85
- refresh(): void;
86
- /** Select a node from outside (a viewport pick) — syncs tree + inspector. */
87
- select(node: Object3D | null): void;
88
- }
@@ -1,250 +0,0 @@
1
- /* Editor chrome styles (CSS Module — class names are scoped/hashed, referenced as
2
- `styles.x` from the TSX). Shares the dark theme vars from index.html. */
3
-
4
- /* ── top bar ─────────────────────────────────────────────────────────────── */
5
- .topbar {
6
- position: fixed; top: 8px; left: 8px; right: 8px; margin: 0 auto;
7
- width: max-content; max-width: calc(100% - 16px);
8
- display: flex; align-items: center; gap: 2px;
9
- padding: 5px; border-radius: 12px;
10
- background: var(--panel); border: 1px solid var(--edge);
11
- box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
12
- backdrop-filter: blur(6px); pointer-events: auto;
13
- overflow-x: auto; overflow-y: hidden; scrollbar-width: thin;
14
- -webkit-overflow-scrolling: touch;
15
- }
16
- .topbar::-webkit-scrollbar { height: 0; }
17
-
18
- .tbtn {
19
- position: relative; flex: 0 0 auto;
20
- width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
21
- padding: 0; border: 1px solid transparent; border-radius: 9px;
22
- background: transparent; color: #cfe0f2; cursor: pointer;
23
- }
24
- .tbtn svg { width: 21px; height: 21px; display: block; }
25
- .tbtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
26
- .tbtn.active { background: rgba(74, 240, 224, 0.2); border-color: var(--accent); color: #eafdff; }
27
- .tbtn:disabled { opacity: 0.3; cursor: default; }
28
- .tbtn.save.dirty { color: var(--accent); }
29
- .tbtn.save.dirty::after {
30
- content: ''; position: absolute; top: 7px; right: 7px;
31
- width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
32
- }
33
- .tbtn.save.saving { opacity: 0.5; }
34
- .sep { flex: 0 0 auto; width: 1px; height: 24px; background: var(--line); margin: 0 3px; }
35
-
36
- /* ── bottom launcher ─────────────────────────────────────────────────────── */
37
- .launcher {
38
- position: fixed; left: 12px; bottom: 12px; z-index: 6;
39
- width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
40
- padding: 0; border-radius: 14px; cursor: pointer; pointer-events: auto;
41
- background: var(--panel); border: 1px solid var(--edge); color: #cfe0f2;
42
- box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
43
- backdrop-filter: blur(6px); box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
44
- }
45
- .launcher:hover { color: #eafdff; border-color: var(--accent); }
46
- .launcher svg { width: 24px; height: 24px; }
47
-
48
- /* ── bottom sheets (node list + inspector) ───────────────────────────────── */
49
- .sheet {
50
- position: fixed; z-index: 7; display: flex; flex-direction: column;
51
- background: var(--panel); border: 1px solid var(--edge);
52
- backdrop-filter: blur(10px); pointer-events: auto;
53
- box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.55);
54
- animation: sheet-in 0.18s ease;
55
- }
56
- @keyframes sheet-in { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } }
57
-
58
- /* Portrait / narrow: a bottom-docked sheet that sits a bit lower (leaves more of
59
- the viewport visible above it); one at a time. */
60
- .sheet {
61
- left: 0; right: 0; bottom: 0;
62
- height: min(50vh, 460px);
63
- border-radius: 16px 16px 0 0;
64
- padding-bottom: env(safe-area-inset-bottom, 0);
65
- }
66
- /* Landscape: full-height side columns (top→bottom) — node list left, inspector
67
- right, both may coexist. */
68
- @media (orientation: landscape) {
69
- .sheet { top: 12px; bottom: 12px; height: auto; border-radius: 16px; width: min(400px, 44vw); }
70
- .sheetNodes { left: 12px; right: auto; }
71
- .sheetInspector { right: 12px; left: auto; }
72
- }
73
-
74
- .head {
75
- flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
76
- padding: 9px 10px 9px 14px; border-bottom: 1px solid var(--line);
77
- }
78
- .title {
79
- flex: 0 1 auto; min-width: 0; font: 700 11px/1 ui-monospace, monospace; color: #9fe7ff;
80
- letter-spacing: 0.08em; text-transform: uppercase;
81
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
82
- }
83
- /* Many actions on a narrow phone: let the row scroll horizontally rather than overflow. */
84
- /* The button group sits against the right edge; `auto` only consumes spare
85
- space, so a crowded header still lets the group shrink and scroll. */
86
- .actions { display: flex; align-items: center; gap: 2px; margin-left: auto; min-width: 0; overflow-x: auto; scrollbar-width: none; }
87
- .actions::-webkit-scrollbar { display: none; }
88
- .headBtn {
89
- flex: 0 0 auto;
90
- width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
91
- padding: 0; border: 1px solid transparent; border-radius: 8px;
92
- background: transparent; color: #cfe0f2; cursor: pointer;
93
- }
94
- .headBtn svg { width: 18px; height: 18px; }
95
- .headBtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.12); color: #eafdff; }
96
- .headBtn:disabled { opacity: 0.32; cursor: default; }
97
- .body { flex: 1 1 auto; overflow: auto; -webkit-overflow-scrolling: touch; }
98
-
99
- /* ── tree (react-arborist rows) ──────────────────────────────────────────── */
100
- .tree { padding: 4px 0; }
101
- .node {
102
- display: flex; align-items: center; gap: 4px; height: 100%;
103
- padding-right: 8px; cursor: pointer; white-space: nowrap;
104
- font: 12px/1.4 ui-monospace, monospace; color: #d6e2f0; border-radius: 6px;
105
- }
106
- .node:hover { background: rgba(74, 240, 224, 0.08); }
107
- .node.sel { background: rgba(74, 240, 224, 0.18); color: #eafdff; }
108
- .node.drop { background: rgba(74, 240, 224, 0.14); box-shadow: inset 0 0 0 1px var(--accent); }
109
- .caret {
110
- flex: 0 0 18px; width: 18px; height: 100%; display: flex; align-items: center; justify-content: center;
111
- color: #6f93b8; user-select: none;
112
- }
113
- .caret svg { width: 14px; height: 14px; }
114
- .nodeName { overflow: hidden; text-overflow: ellipsis; }
115
- .nodeType { color: #6f93b8; margin-left: 6px; }
116
-
117
- /* ── inspector rows + sections ───────────────────────────────────────────── */
118
- .inspector { padding: 6px 12px 16px; font: 12px/1.5 ui-monospace, monospace; }
119
- .section { margin-top: 4px; }
120
- .secHead {
121
- display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin: 10px 0 4px;
122
- color: #9fe7ff; font-weight: 700; border-bottom: 1px solid var(--line);
123
- padding-bottom: 3px; cursor: pointer; user-select: none;
124
- text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px;
125
- }
126
- .secHeadPlain { cursor: default; }
127
- /* The registry description of a section's tag/component: a subtle, lower-case
128
- caption beside the uppercase title. Truncates rather than wrapping so a long
129
- description never pushes the rows down; the full text is in the row tooltips. */
130
- .secDesc {
131
- /* Own line, full width, wrapping: the description explains what the section is
132
- for and is usually worth more than the property names under it. */
133
- flex: 1 0 100%; margin: 2px 0 1px 20px;
134
- color: #8a9db5; font-weight: 400; font-size: 10px; line-height: 1.45;
135
- text-transform: none; letter-spacing: 0;
136
- white-space: normal; overflow-wrap: anywhere;
137
- }
138
- .secCaret { flex: 0 0 14px; width: 14px; display: inline-flex; align-items: center; color: #6f93b8; }
139
- .secCaret svg { width: 13px; height: 13px; }
140
- .secBody { padding: 1px 0 2px; }
141
-
142
- /* Per-component/attachment toolbar (move up / move down / delete). Pushed to the
143
- right of the section header; the buttons stop propagation so a click doesn't
144
- also fold the section. */
145
- .secActions { margin-left: auto; display: inline-flex; align-items: center; gap: 1px; }
146
- .memberBtn {
147
- width: 22px; height: 22px; display: inline-flex; align-items: center; justify-content: center;
148
- padding: 0; border: 0; border-radius: 5px;
149
- background: transparent; color: #7d90a8; cursor: pointer;
150
- }
151
- .memberBtn:hover:not(:disabled) { background: rgba(74, 240, 224, 0.14); color: #eafdff; }
152
- .memberBtn:disabled { opacity: 0.28; cursor: default; }
153
- .memberDelete:hover:not(:disabled) { background: rgba(255, 120, 120, 0.16); color: #ff9b9b; }
154
- .memberBtn svg { width: 13px; height: 13px; }
155
- /* The identity row: an active tick and the id, on one line, as Unity's inspector opens. */
156
- .identity { display: flex; align-items: center; gap: 8px; padding: 6px 0 8px; }
157
- .identityId { flex: 1 1 auto; min-width: 0; text-align: left; font-weight: 700; color: #eafdff; }
158
-
159
- .row { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 30px; padding: 2px 0; }
160
- .k { color: #8fa6c4; flex: 0 0 auto; }
161
- .v { color: #e7f0fb; text-align: right; word-break: break-word; display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
162
- .empty { color: #6f8099; padding: 16px 4px; }
163
-
164
- /* An attribute that is NOT written in the source: the widget shows the value the
165
- node will build with, dimmed, so "no attribute in the XML" reads differently
166
- from "explicitly set to this". Typing in it writes the attribute. */
167
- .unsetRow .k { color: #61708a; }
168
- .unsetRow .input, .unsetRow .swatch, .unsetRow .toggle { opacity: 0.45; }
169
- .unsetRow:focus-within .input, .unsetRow:focus-within .swatch, .unsetRow:focus-within .toggle { opacity: 1; }
170
-
171
- /* Clears the attribute (removes it from the file). Only rendered when written. */
172
- .clearBtn {
173
- flex: 0 0 auto; width: 20px; height: 20px; margin-left: 2px;
174
- display: inline-flex; align-items: center; justify-content: center;
175
- padding: 0; border: 0; border-radius: 5px;
176
- background: transparent; color: #6f8099; cursor: pointer;
177
- }
178
- .clearBtn:hover { background: rgba(255, 120, 120, 0.14); color: #ff9b9b; }
179
- .clearBtn svg { width: 12px; height: 12px; }
180
-
181
- /* A disabled/locked row: dimmed value + a small lock glyph (reason in the title). */
182
- .locked { cursor: help; }
183
- .locked .k { color: #6f8099; }
184
- .locked .lockVal { color: #7c8ba3; }
185
- .lockIcon { width: 12px; height: 12px; color: #6f8099; flex: 0 0 auto; opacity: 0.85; }
186
-
187
- /* ── editable field widgets (16px font avoids iOS tap-zoom; roomy touch targets) ── */
188
- .input {
189
- background: #0e1219; color: #e7f0fb; border: 1px solid var(--line); border-radius: 5px;
190
- padding: 4px 6px; font: 16px ui-monospace, monospace; min-height: 28px;
191
- }
192
- .input:focus { outline: none; border-color: var(--accent); }
193
- .num { width: 64px; text-align: right; }
194
- .axes { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
195
- .axis { display: flex; align-items: center; gap: 2px; }
196
- .axisK { color: #6f93b8; font-size: 11px; width: 12px; text-align: center; }
197
- .scrub { cursor: ew-resize; touch-action: none; }
198
- .toggle { width: 22px; height: 22px; accent-color: var(--accent); }
199
- .color { display: flex; align-items: center; gap: 5px; }
200
- .swatch { width: 30px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 5px; background: none; cursor: pointer; }
201
- .hex { width: 78px; }
202
- .select { min-width: 96px; cursor: pointer; }
203
-
204
- /* ── add component / attachment picker ─────────────────────────────────────── */
205
- /* Centred modal over a dimming backdrop. Above the sheets (z 7) so it is never
206
- occluded, and capped in height so a long registry scrolls instead of overflowing. */
207
- .modalBackdrop {
208
- position: fixed; inset: 0; z-index: 9;
209
- display: flex; align-items: center; justify-content: center;
210
- background: rgba(4, 8, 14, 0.55); backdrop-filter: blur(2px);
211
- pointer-events: auto;
212
- }
213
- .modal {
214
- width: min(460px, calc(100vw - 32px)); max-height: min(70vh, 560px);
215
- display: flex; flex-direction: column;
216
- background: var(--panel); border: 1px solid var(--edge); border-radius: 12px;
217
- box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
218
- overflow: hidden;
219
- }
220
- .modalHead {
221
- flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
222
- padding: 10px 10px 10px 14px; border-bottom: 1px solid var(--line);
223
- }
224
- .modalHead .clearBtn { margin-left: auto; }
225
- .modalFilter { flex: 0 0 auto; margin: 10px 12px 6px; }
226
- .modalList { flex: 1 1 auto; overflow-y: auto; padding: 2px 6px 8px; }
227
- .modalItem {
228
- width: 100%; display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
229
- padding: 7px 8px; border: 0; border-radius: 6px;
230
- background: transparent; color: #e7f0fb; text-align: left; cursor: pointer;
231
- font: inherit;
232
- }
233
- .modalItem:hover { background: rgba(74, 240, 224, 0.12); }
234
- .modalItemTag { font-weight: 700; color: #9fe7ff; }
235
- .modalItemDesc { color: #8a9db5; font-size: 10px; line-height: 1.45; }
236
- /* The kind chip on a file-picker row — `scene` / `prefab` beside the name. */
237
- .pickKind {
238
- margin-left: 8px; padding: 1px 6px; border-radius: 999px;
239
- background: rgba(74, 240, 224, 0.12); color: #7fd8cf;
240
- font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
241
- }
242
-
243
- /* The "+ Add …" row that opens the picker, one per area. */
244
- .addRow {
245
- width: 100%; display: flex; align-items: center; justify-content: center; gap: 6px;
246
- margin: 6px 0 2px; padding: 5px 0; border: 1px dashed var(--line); border-radius: 6px;
247
- background: transparent; color: #7d90a8; cursor: pointer; font: inherit; font-size: 11px;
248
- }
249
- .addRow:hover { border-color: var(--accent); color: #eafdff; }
250
- .addRow svg { width: 13px; height: 13px; }
@@ -1,20 +0,0 @@
1
- import { useLayoutEffect, useRef, useState } from 'react';
2
-
3
- /**
4
- * Track an element's content size via ResizeObserver — react-arborist needs
5
- * explicit pixel width/height, so we measure its (flex) container and feed them in.
6
- */
7
- export function useSize<T extends HTMLElement>(): readonly [React.RefObject<T>, { width: number; height: number }] {
8
- const ref = useRef<T>(null);
9
- const [size, setSize] = useState({ width: 0, height: 0 });
10
- useLayoutEffect(() => {
11
- const el = ref.current;
12
- if (!el) return;
13
- const measure = (): void => setSize({ width: el.clientWidth, height: el.clientHeight });
14
- const ro = new ResizeObserver(measure);
15
- ro.observe(el);
16
- measure();
17
- return () => ro.disconnect();
18
- }, []);
19
- return [ref, size] as const;
20
- }
package/app/values.ts DELETED
@@ -1,137 +0,0 @@
1
- import type { PropInfo, ValueShape } from '@scenoco-three/compiler';
2
-
3
- /**
4
- * A value as the inspector's widgets handle it: a number, a flag, some text, or a list of axes.
5
- *
6
- * The document stores attribute **text**, and the widgets — the number scrubber, the vector row,
7
- * the colour swatch — work in typed values. This is the pair of functions between them, and it
8
- * speaks the DSL's own grammar in both directions: `x: 1; y: 2` for a vector, `#rrggbb` for a
9
- * colour, degrees for a rotation. So what a widget writes is what a person would have written.
10
- */
11
- export type Scalar = number | boolean | string | number[];
12
-
13
- /** Attribute text → a value a widget can edit, or undefined when there is nothing to show. */
14
- export function parseValue(shape: ValueShape, text: string | undefined): Scalar | undefined {
15
- if (text === undefined) return undefined;
16
- switch (shape) {
17
- case 'number': {
18
- const n = Number(text.trim());
19
- return Number.isFinite(n) ? n : 0;
20
- }
21
- case 'boolean':
22
- return text.trim() === 'true';
23
- case 'color':
24
- return colorToNumber(text);
25
- case 'vector2':
26
- return axes(text, 2);
27
- case 'vector3':
28
- case 'euler':
29
- return axes(text, 3);
30
- case 'vector4':
31
- return axes(text, 4);
32
- default:
33
- return text;
34
- }
35
- }
36
-
37
- /** A value from a widget → the text the document gets. */
38
- export function formatValue(shape: ValueShape, value: Scalar): string {
39
- switch (shape) {
40
- case 'number':
41
- return String(round(Number(value)));
42
- case 'boolean':
43
- return value ? 'true' : 'false';
44
- case 'color':
45
- return `#${Math.max(0, Math.round(Number(value))).toString(16).padStart(6, '0').slice(-6)}`;
46
- case 'vector2':
47
- return named(value as number[], ['x', 'y']);
48
- case 'vector3':
49
- case 'euler':
50
- return named(value as number[], ['x', 'y', 'z']);
51
- case 'vector4':
52
- return named(value as number[], ['x', 'y', 'z', 'w']);
53
- default:
54
- return String(value);
55
- }
56
- }
57
-
58
- /**
59
- * Something to render for an attribute the document does not write.
60
- *
61
- * The row still has to draw, and setting it is the whole point of it being there — so an unwritten
62
- * optional shows its declared default when it has one, and a neutral value when it does not.
63
- */
64
- export function shownValue(prop: PropInfo, written: string | undefined): Scalar {
65
- return parseValue(prop.shape, written) ?? parseValue(prop.shape, prop.default?.replace(/^['"]|['"]$/g, '')) ?? neutral(prop.shape);
66
- }
67
-
68
- function neutral(shape: ValueShape): Scalar {
69
- switch (shape) {
70
- case 'boolean':
71
- return false;
72
- case 'string':
73
- case 'enum':
74
- case 'nodeRef':
75
- case 'assetRef':
76
- return '';
77
- case 'vector2':
78
- return [0, 0];
79
- case 'vector3':
80
- case 'euler':
81
- return [0, 0, 0];
82
- case 'vector4':
83
- return [0, 0, 0, 0];
84
- default:
85
- return 0;
86
- }
87
- }
88
-
89
- /** Which widget renders a shape, or `null` for one no widget can edit. */
90
- export function widgetFor(prop: PropInfo): ValueShape | null {
91
- switch (prop.shape) {
92
- case 'number':
93
- case 'boolean':
94
- case 'string':
95
- case 'color':
96
- case 'vector2':
97
- case 'vector3':
98
- case 'vector4':
99
- case 'euler':
100
- case 'enum':
101
- case 'nodeRef':
102
- case 'assetRef':
103
- return prop.shape;
104
- default:
105
- return null; // an array, or something the DSL cannot express
106
- }
107
- }
108
-
109
- function axes(text: string, arity: number): number[] {
110
- const out = new Array<number>(arity).fill(0);
111
- const names = ['x', 'y', 'z', 'w'];
112
- // The one spelling a document uses. A value that is not in it reads as zeroes, which is what
113
- // the compiler would have rejected anyway — the widget then writes it back correctly.
114
- for (const part of text.split(';')) {
115
- const [axis, value] = part.split(':');
116
- const index = names.indexOf((axis ?? '').trim());
117
- const n = Number((value ?? '').trim());
118
- if (index !== -1 && index < arity && Number.isFinite(n)) out[index] = n;
119
- }
120
- return out;
121
- }
122
-
123
- function named(values: number[], names: string[]): string {
124
- return names.map((name, i) => `${name}: ${round(values[i] ?? 0)}`).join('; ');
125
- }
126
-
127
- function colorToNumber(text: string): number {
128
- const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(text.trim());
129
- if (!hex) return 0;
130
- const digits = hex[1]!;
131
- return parseInt(digits.length === 3 ? digits.replace(/./g, (c) => c + c) : digits, 16);
132
- }
133
-
134
- /** Three decimals: a scrubbed number should not write `0.30000000000000004` into someone's file. */
135
- function round(n: number): number {
136
- return Math.round(n * 1000) / 1000;
137
- }
package/app/vite-env.d.ts DELETED
@@ -1,7 +0,0 @@
1
- /// <reference types="vite/client" />
2
-
3
- // The editor server's plugin emits two modules: `scenoco-runtime` registers the
4
- // built-in node tags + tag packages, and `scenoco-components` imports every project
5
- // component. They're split so a broken component can't wipe the core tag registry.
6
- declare module 'virtual:scenoco-runtime';
7
- declare module 'virtual:scenoco-components';
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/cli/index.js DELETED
@@ -1,122 +0,0 @@
1
- #!/usr/bin/env node
2
- import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
4
- import { dirname, join, resolve } from 'node:path';
5
- import { startEditorServer } from '../server.js';
6
- import { createMcpServer, startMcpHttp } from '../mcp.js';
7
- const USAGE = `scenoco-editor — open a scene and drive it from an MCP client
8
-
9
- Usage:
10
- scenoco-editor [document] [options]
11
-
12
- With no document, the editor opens a picker listing the project's scenes and
13
- prefabs. Tag packages are read from the project's package.json, so --modules is
14
- only for compiling against something other than what the project depends on.
15
-
16
- Options:
17
- --root <dir> Project root (default: the nearest one at or above the
18
- current directory)
19
- --modules a,b Packages whose tags the documents use, beyond three
20
- --mcp-port <n> Fixed MCP port (default 55470) — an MCP client is configured
21
- once with a URL, so this must not move between restarts
22
-
23
- The editor serves its own page and imports the project's generated modules, so
24
- what you see is the same scene the game loads. Its MCP tools **observe**: open,
25
- screenshot, telemetry, play/pause/stop, simulate. Documents are changed by
26
- editing the XML — in the editor, or in your own editor — and the watcher rebuilds.`;
27
- const DEFAULT_MCP_PORT = 55470;
28
- async function main(argv) {
29
- if (argv.includes('-h') || argv.includes('--help')) {
30
- console.log(USAGE);
31
- return 0;
32
- }
33
- const flag = (name) => {
34
- const i = argv.indexOf(`--${name}`);
35
- return i === -1 ? undefined : argv[i + 1];
36
- };
37
- const document = argv.find((a) => !a.startsWith('--') && argv[argv.indexOf(a) - 1]?.startsWith('--') !== true);
38
- const modules = flag('modules')?.split(',').filter(Boolean);
39
- // npm and npx rewrite `cwd` to the nearest enclosing package.json when run from inside a
40
- // workspace package; `INIT_CWD` still holds the directory the shell was actually in.
41
- const from = flag('root') ?? process.env.INIT_CWD ?? process.cwd();
42
- const root = flag('root') ? resolve(from) : projectRootAt(from);
43
- let closing = false;
44
- const editor = await startEditorServer({
45
- root,
46
- ...(modules ? { modules: ['three', ...modules] } : {}),
47
- // Self-halt once every tab is closed, so a spawned editor never lingers holding its ports.
48
- onIdle: () => {
49
- if (closing)
50
- return;
51
- console.error('\n no editor connected — shutting down so no stale process is left behind\n');
52
- void shutdown();
53
- },
54
- });
55
- const mcpPort = Number(flag('mcp-port') ?? DEFAULT_MCP_PORT);
56
- // A server per session: an MCP client that reconnects gets a fresh one rather than a 400.
57
- const mcp = await startMcpHttp(() => createMcpServer(editor.bridge, () => editor.url), mcpPort);
58
- const query = document ? `?path=${encodeURIComponent(document)}` : '';
59
- const url = `${editor.url}${query}`;
60
- console.log(`\n editor: ${url}`);
61
- // The address to type into a phone on the same Wi-Fi — the touch controls only mean anything
62
- // on a device with fingers.
63
- if (editor.networkUrl)
64
- console.log(` wi-fi: ${editor.networkUrl}${query}`);
65
- console.log(` mcp: http://localhost:${mcpPort} ← add this to your MCP client (transport: http)`);
66
- console.log('\n press Ctrl+C to stop\n');
67
- const shutdown = async () => {
68
- if (closing)
69
- return;
70
- closing = true;
71
- // A connected browser holds WebSocket and HMR sockets open, so `close()` can stall waiting
72
- // for them to drain. Cap it, so Ctrl+C always exits promptly.
73
- const deadline = new Promise((done) => setTimeout(done, 1500).unref());
74
- await Promise.race([
75
- Promise.all([mcp.close().catch(() => { }), editor.close().catch(() => { })]),
76
- deadline,
77
- ]);
78
- process.exit(0);
79
- };
80
- process.on('SIGINT', () => void shutdown());
81
- process.on('SIGTERM', () => void shutdown());
82
- // Having reclaimed the previous session's port, give any tab left behind by an ungraceful exit
83
- // a moment to reattach on its own retry loop — a restart should land in the tab that is
84
- // already open rather than stack a second one beside it. On a fresh port nothing is waiting.
85
- const reattached = editor.reusedPort && (await editor.bridge.waitForConnection(1500));
86
- if (reattached) {
87
- console.log(' reattached the editor tab already open — no new tab\n');
88
- // That tab is still showing whatever it had open, so a named document has to be asked for.
89
- if (document)
90
- await editor.bridge.send({ id: 'cli-open', type: 'open', path: document }).catch(() => { });
91
- }
92
- else {
93
- openBrowser(url);
94
- }
95
- return new Promise(() => { }); // runs until interrupted
96
- }
97
- /**
98
- * The project a bare `scenoco-editor` means: the nearest directory at or above `from` that holds
99
- * a `tsconfig.json`.
100
- *
101
- * Running it from `src/` or `assets/scenes/` is the normal way to reach for an editor, and
102
- * "no tsconfig.json here" is a poor answer when the project is one directory up. Falls back to
103
- * `from` unchanged, so the error still names the place the person actually was.
104
- */
105
- function projectRootAt(from) {
106
- for (let dir = resolve(from);; dir = dirname(dir)) {
107
- if (existsSync(join(dir, 'tsconfig.json')))
108
- return dir;
109
- if (dirname(dir) === dir)
110
- return resolve(from);
111
- }
112
- }
113
- function openBrowser(url) {
114
- const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
115
- spawn(command, [url], { stdio: 'ignore', detached: true, shell: process.platform === 'win32' }).unref();
116
- }
117
- main(process.argv.slice(2)).then((code) => {
118
- process.exitCode = code;
119
- }, (error) => {
120
- console.error(error instanceof Error ? error.message : String(error));
121
- process.exitCode = 1;
122
- });
package/dist/index.d.ts DELETED
@@ -1,10 +0,0 @@
1
- /**
2
- * `@scenoco-three/editor` — the programmatic API behind the `scenoco-editor` CLI.
3
- *
4
- * Serves the editor page, bridges it to an MCP server over a WebSocket, and exposes MCP tools
5
- * that **observe**: open, screenshot, telemetry, play/pause/stop, simulate. Documents are changed
6
- * by editing the XML; there is no remote edit tool, by decision.
7
- */
8
- export { startEditorServer, applyEdits, type ServerOptions, type EditorServer, type EditorBridge, type Edit, } from './server.js';
9
- export { createMcpServer, startMcpHttp } from './mcp.js';
10
- export { WS_PATH, commandId, type EditorCommand, type EditorPush, type EditorResponse, type EditorResult, type PreviewFrame, type SimStep, } from './protocol.js';
package/dist/index.js DELETED
@@ -1,10 +0,0 @@
1
- /**
2
- * `@scenoco-three/editor` — the programmatic API behind the `scenoco-editor` CLI.
3
- *
4
- * Serves the editor page, bridges it to an MCP server over a WebSocket, and exposes MCP tools
5
- * that **observe**: open, screenshot, telemetry, play/pause/stop, simulate. Documents are changed
6
- * by editing the XML; there is no remote edit tool, by decision.
7
- */
8
- export { startEditorServer, applyEdits, } from './server.js';
9
- export { createMcpServer, startMcpHttp } from './mcp.js';
10
- export { WS_PATH, commandId, } from './protocol.js';